网页内容更新后,重新切块再写入知识库,如果中途失败,查询可能看到一半旧版本、一半新版本。先删除旧块再导入,又会在导入失败时让原本可用的文档消失。
本文用 Python 标准库和 SQLite 演示一个最小版本生命周期:新块写入、切换可见版本和删除旧块在同一事务内完成。代码已在本地 Python 3.9.6 运行,验证了初始导入、异常回滚、成功切换、过期写入拒绝和级联删除。
一、先规定查询能看见什么
文档实体使用稳定 ID,例如 manual;版本号表示一次完整内容快照;分块身份由文档、版本和块序号共同组成。docs.active 指向当前可见版本,查询只返回与它匹配的分块。
分块序号不能单独充当长期 ID。正文前面插入一段后,后续块的序号可能全部变化。来源 URL、采集时间和内容指纹在真实系统中也应保留,本文为了聚焦事务省略了这些字段。
二、完整离线示例
保存为 rag_version_demo.py,使用 python3 rag_version_demo.py 执行。不要使用 -O,示例依赖 assert 检查结果。无需安装向量库,也不调用模型。
import sqlite3
con = sqlite3.connect(':memory:')
con.execute('PRAGMA foreign_keys = ON')
con.executescript('''
CREATE TABLE docs(id TEXT PRIMARY KEY, active INTEGER NOT NULL);
CREATE TABLE chunks(
doc TEXT REFERENCES docs(id) ON DELETE CASCADE,
version INTEGER NOT NULL, pos INTEGER NOT NULL, body TEXT NOT NULL,
PRIMARY KEY(doc, version, pos));
''')
def replace_version(doc, expected, new_version, texts, fail=False):
if new_version <= expected or not texts or any(not t.strip() for t in texts):
raise ValueError('invalid_version_or_content')
with con:
row = con.execute('SELECT active FROM docs WHERE id=?', (doc,)).fetchone()
current = row[0] if row else 0
if current != expected:
raise ValueError('stale_update')
if row is None:
con.execute('INSERT INTO docs VALUES (?,0)', (doc,))
con.executemany('INSERT INTO chunks VALUES (?,?,?,?)',
[(doc, new_version, i, text) for i, text in enumerate(texts)])
if fail:
raise RuntimeError('simulated_failure')
con.execute('UPDATE docs SET active=? WHERE id=?', (new_version, doc))
con.execute('DELETE FROM chunks WHERE doc=? AND version<>?', (doc, new_version))
def visible(doc):
return [r[0] for r in con.execute('''
SELECT c.body FROM chunks c JOIN docs d ON c.doc=d.id
WHERE d.id=? AND c.version=d.active ORDER BY c.pos''', (doc,))]
replace_version('manual', 0, 1, ['old A', 'old B'])
assert visible('manual') == ['old A', 'old B']
try:
replace_version('manual', 1, 2, ['new A'], fail=True)
except RuntimeError:
pass
assert visible('manual') == ['old A', 'old B']
assert con.execute('SELECT COUNT(*) FROM chunks WHERE version=2').fetchone()[0] == 0
replace_version('manual', 1, 2, ['new A'])
assert visible('manual') == ['new A']
try:
replace_version('manual', 1, 3, ['stale'])
except ValueError as error:
assert str(error) == 'stale_update'
else:
raise AssertionError('stale update accepted')
with con:
con.execute('DELETE FROM docs WHERE id=?', ('manual',))
assert visible('manual') == []
assert con.execute('SELECT COUNT(*) FROM chunks').fetchone()[0] == 0
con.close()
print('PASS: initial version, rollback, switch, stale rejection, cascading delete')
运行输出:
PASS: initial version, rollback, switch, stale rejection, cascading delete
三、失败时为什么还能看到旧内容
replace_version 先核对调用方知道的旧版本,再写新分块。模拟异常发生在新分块插入后、active 更新前。异常离开连接上下文时触发回滚,所以新分块不会残留,active 仍指向旧版本。
成功路径中,active 更新和旧块清理随事务一起提交。这里强调的是同一个 SQLite 数据库内的事务边界,不是对两个不同存储服务的分布式原子提交。
SQLite 事务说明可查官方文档:https://www.sqlite.org/lang_transaction.html 。连接显式开启了 foreign_keys;删除 docs 行时,ON DELETE CASCADE 删除其分块。外键开关和级联行为参见:https://www.sqlite.org/foreignkeys.html 。
四、这份测试的边界
示例使用单连接、串行操作和内存数据库。expected 检查证明的是顺序执行时能拒绝过期任务,不能据此声称跨进程并发更新安全。默认延迟事务下,读取版本与开始写事务之间的竞争需要另外设计和验证,例如明确事务起点、使用带版本条件的更新并核对影响行数。
fail=True 是受控 Python 异常,不是进程被杀死或断电实验;内存数据库也没有验证重启后的持久化恢复。生产部署还需要真实存储、并发读写和故障注入测试。
代码没有向量检索、embedding 或模型回答评估。visible 返回正文分块,只用来展示可见版本规则。事务正确不代表召回正确,后者仍要用问题、证据和预期答案组成的评测集验证。
五、换成外部向量库后怎么落地
当元数据在关系库、向量在另一个服务时,不要把这段事务代码理解成跨库保证。可以先把新版本作为暂存数据写入,验证块数、版本、embedding 配置与检索结果,准备完成后再切换可见版本。
每次查询都必须落实版本过滤;如果先检索再过滤,旧版本可能占满候选集,让有效结果不足。这需要根据所用向量库能力选择服务端过滤、扩大候选或隔离索引,并用回归问题检查。
切换后清理旧版本应可重试且幂等。利用持久化任务或 outbox 记录待执行操作,定期对账“元数据声明存在的版本”和“向量库实际存在的块”。旧任务再次执行时必须核对版本,避免覆盖新内容。
删除也是完整链路:先确保被撤回文档不可检索,再清理分块、向量与相关缓存,最后核验引用页面和历史回答的处理策略。短暂抓取失败不能直接等价于原文永久删除,应设置可解释的确认状态。
从爬虫到 RAG,稳定系统需要回答三个问题:当前对外提供哪个版本、半途失败如何恢复、旧内容何时不再出现。把这三件事变成可验证的规则,再扩展模型和检索配置,更容易定位线上问题。
本文由 AI 辅助编写,代码为本地可复现的机制示例。
392



被折叠的 条评论
为什么被折叠?



