给 Agent 接上可回查的本地资料检索
从六份合成资料开始,把每条检索结果带回具体文件和原文行,再检查更新、删除与空结果。
- 每条命中都保留原文行、文件哈希与版本化来源
- 参数绑定与 MATCH 语法需要分别处理
- 词法分数只用于排序,冲突证据需要进一步核对
- 空结果、索引陈旧与删除失败都能用测试复现
1. 先做出一条可以回查的结果
一个助手被问到“导出的文件会保留多久”,本地目录里恰好有四种说法:旧手册写七天,新手册写三十天,会议纪要写十四天,临时演练通知写六十天。把第一条匹配内容直接塞进回答,会掩盖文件版本和适用时间。我们先让检索工具把相关原文和位置带回来,再由人检查这几份资料。
这篇教程从空目录建立一个小型 SQLite FTS5 索引。运行后可以看到命中的原文、片段、文件路径、行号与稳定来源标识;修改或删除资料后,也有测试检查旧结果是否退出索引。全部内容都是为本文原创的合成资料,程序不读取你的其他文件,不连接模型、网络或 embedding 服务。
读者需要会运行 Python 文件。准备 Python 3.10 或更新版本,并确认它自带的 SQLite 支持 FTS5。本次实测环境为 Python 3.12.14、SQLite 3.53.1。下面的完整文件可以直接复制,压缩包中的文件与正文相同。
已有的Agent 记忆教程处理用户事实的保存和纠正;本篇建立的是文档证据入口。需要理解工具怎样进入决策循环,可接着看最小 Agent 教程。本文不会重复实现那套循环。
2. 语料里故意留下冲突和检索盲区
make_corpus.py 会新建 corpus 目录,生成六个 UTF-8 文本和一份 manifest.json。本例用 splitlines 分行,语料使用普通换行。每个非空行作为一个检索单元,空行保留在行号计算中。这种切分足够支持短说明,也让结果中的第几行能直接在编辑器中找到。遇到长段落、表格或跨行条件,实际产品要另做切分与上下文拼接。
清单由资料维护者提供:doc_id 标识文档,revision 是人工修订标签,status 标记 current 或 archived,expires 是不再使用这份资料的日期。当前手册和会议纪要同时保留,程序不会根据文件名或者排序猜测哪份是最终规定。旧版另用 handbook-v1 保存,以便演示归档过滤。
为让中文词项边界可复现,五份教学文本已经人工加了空格,例如“导出 文件 保留 30 天”。这不是程序自动完成的分词。compact.txt 刻意保存“导出文件保留三十天”,用于观察默认 unicode61 的限制:查询整串能命中,查询“导出”不会命中这一行。实际中文资料需要选择并验证分词方案,不能把这里的合成成绩迁移成中文搜索质量结论。
3. 先检查 FTS5,再创建示例目录
将下文三个完整 Python 文件保存到一个新的练习目录。先运行这条能力检查;成功会打印 SQLite 版本。如果出现 no such module: fts5,应换用带 FTS5 的 Python/SQLite 构建,停在这里排查,不要悄悄把全文检索改成另一种实现。
能力检查
python3 -c 'import sqlite3; c=sqlite3.connect(":memory:"); c.execute("CREATE VIRTUAL TABLE t USING fts5(x)"); print(sqlite3.sqlite_version)'
创建、同步、查询、测试
python3 make_corpus.py
python3 retrieval.py sync
python3 retrieval.py query --query '导出 保留'
python3 -m unittest -v test_retrieval
make_corpus.py 在 corpus 已存在时会报 FileExistsError,避免覆盖你已经修改的练习文件。同步命令会创建 sample-index.sqlite,并重建该库里的 chunks 与 search 两张教学表;只对本例专用数据库执行,别把 --db 指向已有业务数据库。重复查询不会重新读取资料目录。
4. 把参数绑定和 MATCH 语法分开处理
查询“导出 保留”在本例里表示两项都要出现。expression 先按空白切开查询,再把每一段转成带双引号的 FTS 短语,并将输入中的双引号成对转义。只有应用自己插入 AND。用户输入的 OR、括号、冒号不会取得布尔操作或列选择的权限。
SQL 使用占位符绑定 MATCH 表达式、日期和数量。绑定参数能隔离 SQL 结构,但 MATCH 仍有自己的语法。例如把单个双引号作为绑定值直接交给 MATCH,会抛出语法错误。本文保留了这个失败测试。封装后的入口会把纯标点查询当作空结果,并限制输入长度、词项数量和返回数量。
“导出 OR 薄荷”会被当作三个必须同时满足的普通词项,因此返回空列表。引号与星号被安全装入短语后,仍会经过 tokenizer;它们不保证按字符原样检索。这是一个简化的全部词项匹配入口,不提供高级搜索语法,也没有自然语言问题理解能力。
对应规则可在 SQLite 的 FTS5 字符串说明中核对;本文的输入长度、AND 组合和错误处理是自行设计的教学约定。
5. 来源标识比一次查询的行 ID 活得更久
chunks 保存元数据和原文,search 保存每行文本的全文索引,二者通过本次索引里的整数 rowid 对应。重建索引会重新分配整数,所以结果里虽然保留了 id 便于调试,引用时要使用 source_id。
source_id 由 doc_id、revision、完整文件 SHA-256 与行号组合。相同内容重建,标识保持不变;只重命名路径并同步清单,也不改变标识。修改任何文件字节会改变哈希,连带改变该文档所有行的标识。这样旧引用不会无声地指向新版本,但也意味着它不适合用作跨版本段落追踪 ID。
text 是索引保存的完整原文行。snippet 是同一行中带方括号高亮的短片段,使用十八个词项的预算;它可能截断内容,人工核对时仍要看 text 与原文件。回查函数 verify_source 同时比对整个文件哈希和对应行内容。这个哈希用于发现变更,不能证明文档作者或内容真实性。verify_source 不认证 doc_id、revision、status 或 expires 的含义,也不会由 query 自动调用;需要核对当前文件的宿主应显式执行它。
本例没有保存全部历史文件,资料删除或被覆盖后,旧引用可能无法回查。需要长期审计时,应另建受控版本归档及保留规则。也不要把 snippet 直接作为 HTML 插入网页;其中包含原文,展示时应按普通文本转义。
6. 读第一次查询结果,暂时不要替它下结论
初次同步实际写入十二个非空行。查询“导出 保留”返回两条:handbook.txt 第 2 行写三十天,meeting.txt 第 2 行写十四天且待确认。两份资料的词法分数相同,由 source_id 提供固定的并列次序。当前返回的是冲突证据,仍需查资料负责人或正式规则才能形成确定答案。
本次实测命中行摘录;完整元数据见 query 输出
handbook.txt:2 [导出] 文件 [保留] 30 天 , 到期 后 清理 。
meeting.txt:2 [导出] 文件 [保留] 14 天 , 待 负责人 确认 。
lexical_score 来自 bm25(search),这里只用它排序,数值更小排在前面。它不表示可信程度或回答正确的概率。语料增加、行切分改变,分数也可能变化;归档和过期行虽不返回,仍存在 FTS 表中并影响词频统计。本例没有跨查询阈值,更没有把这个分数转换为置信度。
as_of 固定为 2026-10-07 以复现实验。到期日当天即排除,所以 notice 的六十天不会返回;改为 2026-10-06 则可以查到。生产宿主应决定检查时间,不要让模型通过随意回拨日期取回过期证据。归档与日期字段本身也需要维护,本程序不会自动识别文档是否已经失效。
回查命中行
from retrieval import connect, retrieve, verify_source
db = connect('sample-index.sqlite')
try:
for hit in retrieve(db, '导出 保留', as_of='2026-10-07'):
print(hit['path'], hit['line'], verify_source('corpus', hit))
finally:
db.close()
这段检查应打印 handbook.txt 2 True 和 meeting.txt 2 True。它检查当前磁盘内容与索引快照的一致性;文件在检查后仍可能被修改,本例未锁定资料目录。
7. 让更新和删除有可观察的结果
先把 corpus/handbook.txt 的 30 改成 45,并在 manifest 中把对应 revision 改为 r3。此时再次 query 仍读旧索引,所以旧数字暂时继续出现;verify_source 对旧结果会返回 False。运行 sync 后,查询“30 天”得到空列表,“45 天”得到带新哈希和修订标签的结果。代码不会强制 revision 递增,因此维护者应自行更新标签;即使漏改,内容哈希仍会变化。
sync 先读清单和所有文件,验证完才开启替换事务。元数据与 FTS 行一起删除和重写;任何准备阶段的错误都不会清空旧索引。这个实现针对六份小文件,采用全量重建来让同步关系容易检查,没有增量队列、文件监听或并发写入协调。读文件期间请暂停编辑,否则不同文件可能来自不同时间点。
删除 meeting.txt 时,同时从 manifest 移除它的条目,再执行 sync。之后“14 天”应为空。若只删除文件却保留清单,sync 会报缺失文件,并保留旧索引。若只移除清单,文件还在磁盘,但它会退出下次索引。这几种状态要区分清楚,不能看到文件已删就认定搜索也已更新。
SQLite 表里的逻辑删除不等于磁盘安全擦除,也没有清理旧输出、备份或外部副本。本文只检查后续查询看不到被移除的材料。
8. 工具可以返回空,资料中的命令也只是资料
search_tool 是给 Agent 宿主使用的最小只读接口。宿主绑定数据库和 as_of,只开放 query 参数,将返回对象交给后续决策步骤。查询“发票”返回 hits: [],不会拼一个看起来合理的答案。查询“输出 保存”也为空,因为这份实现没有同义词扩展;一次不命中只能说明当前检索没有找到匹配材料。
返回对象包含 kind、query、as_of、hits 与 action_authority: false,没有 answer 字段。这个标记表达数据用途,不能防住提示注入。将来接入模型时,文档中的“忽略限制”“已获批准”等句子仍然只是来源内容,不能据此取得工具执行权限。涉及发送、写入和删除的动作必须另走宿主授权。
这套例子面向单一所有者的合成目录,不实现多用户鉴权。目录边界检查只是避免清单误指向外部文件,不是抵御能够修改本地目录、符号链接或数据库的攻击者的安全沙箱。真实私人资料还需要访问控制、备份策略和检索范围检查。
9. 完整语料生成器
语料直接写在这个文件里,既可以核对内容,也可以从空目录独立复现。它只创建新目录。
make_corpus.py
"""Create ONLY a new synthetic corpus directory; never overwrite one."""
import json
from pathlib import Path
DOCUMENTS = [
('handbook-v1', 'r1', 'archived', None,
'旧版 导出 说明\n导出 文件 保留 7 天 。\n'),
('handbook', 'r2', 'current', None,
'星屿 项目 导出 手册\n导出 文件 保留 30 天 , 到期 后 清理 。\n下载 入口 位于 设置 页面 。\n'),
('meeting', 'r1', 'current', None,
'星屿 会议 纪要\n导出 文件 保留 14 天 , 待 负责人 确认 。\n'),
('notice', 'r1', 'current', '2026-10-07',
'临时 导出 通知\n导出 文件 保留 60 天 , 仅限 本次 演练 。\n'),
('garden', 'r1', 'current', None,
'窗台 养护 笔记\n薄荷 每周 检查 土壤 湿度 。\n'),
('compact', 'r1', 'current', None,
'导出文件保留三十天\n'),
]
def make(folder):
root = Path(folder)
root.mkdir(parents=True, exist_ok=False)
manifest = []
for doc, revision, status, expires, text in DOCUMENTS:
filename = doc + '.txt'
(root / filename).write_text(text, encoding='utf-8')
manifest.append(dict(doc_id=doc, revision=revision, status=status,
expires=expires, path=filename))
(root / 'manifest.json').write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if __name__ == '__main__':
make('corpus')
print('created corpus: 6 synthetic files')
10. 完整索引与查询实现
这是全部运行逻辑,只使用 Python 标准库。操作失败会保留异常,调用方应处理错误,不能把索引故障伪装成没有资料。
retrieval.py
"""Offline, single-owner teaching index. Corpus is trusted local sample data."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import sqlite3
from datetime import date
SCHEMA = '''
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY, source_id TEXT UNIQUE NOT NULL,
doc_id TEXT NOT NULL, revision TEXT NOT NULL, path TEXT NOT NULL,
sha256 TEXT NOT NULL, line INTEGER NOT NULL, text TEXT NOT NULL,
status TEXT NOT NULL, expires TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(text, tokenize='unicode61');
'''
def iso(value):
if not isinstance(value, str) or date.fromisoformat(value).isoformat() != value:
raise ValueError('use YYYY-MM-DD')
return value
def connect(path):
db = sqlite3.connect(path)
db.row_factory = sqlite3.Row
try:
db.executescript(SCHEMA)
except sqlite3.OperationalError:
db.close()
raise
return db
def sync(db, folder):
"""Validate the whole manifest first; replace both tables in one transaction."""
root = Path(folder).resolve()
manifest = json.loads((root / 'manifest.json').read_text(encoding='utf-8'))
if not isinstance(manifest, list):
raise ValueError('manifest must be a list')
prepared, seen = [], set()
for item in manifest:
doc, rev = item['doc_id'], item['revision']
if not all(isinstance(x, str) and re.fullmatch(r'[a-z0-9-]{1,40}', x)
for x in (doc, rev)) or doc in seen:
raise ValueError('invalid or duplicate document ID')
seen.add(doc)
rel = item['path']
if not isinstance(rel, str) or Path(rel).is_absolute():
raise ValueError('relative path required')
path = (root / rel).resolve()
if not path.is_relative_to(root) or path.suffix != '.txt':
raise ValueError('only corpus .txt files allowed')
raw = path.read_bytes()
if len(raw) > 100000:
raise ValueError('sample file too large')
body = raw.decode('utf-8')
digest = hashlib.sha256(raw).hexdigest()
status = item['status']
if status not in ('current', 'archived'):
raise ValueError('unknown status')
expires = item.get('expires')
if expires is not None:
iso(expires)
for line, text in enumerate(body.splitlines(), 1):
if text.strip():
source = f'{doc}@{rev}:{digest}#L{line}'
prepared.append((source, doc, rev, rel, digest, line,
text, status, expires))
with db:
db.execute('DELETE FROM search')
db.execute('DELETE FROM chunks')
for values in prepared:
cur = db.execute('INSERT INTO chunks(source_id,doc_id,revision,path,'
'sha256,line,text,status,expires) VALUES(?,?,?,?,?,?,?,?,?)', values)
db.execute('INSERT INTO search(rowid,text) VALUES(?,?)',
(cur.lastrowid, values[6]))
return len(prepared)
def expression(query):
# Only the application adds AND. Quotation/operator input stays phrase data.
if not isinstance(query, str) or len(query) > 200 or '\x00' in query:
raise ValueError('query must be at most 200 characters, without NUL')
parts = query.split()
if len(parts) > 12:
raise ValueError('at most 12 terms')
if not parts or not any(c.isalnum() for c in query):
return None
return ' AND '.join('"' + p.replace('"', '""') + '"' for p in parts)
def retrieve(db, query, *, as_of, limit=10):
iso(as_of)
if type(limit) is not int or not 1 <= limit <= 20:
raise ValueError('limit must be 1..20')
expr = expression(query)
if expr is None:
return []
sql = '''SELECT c.*, snippet(search,0,'[',']',' … ',18) AS snippet,
bm25(search) AS lexical_score
FROM search JOIN chunks c ON c.id=search.rowid
WHERE search MATCH ? AND c.status='current'
AND (c.expires IS NULL OR c.expires > ?)
ORDER BY lexical_score,c.source_id LIMIT ?'''
return [dict(r) for r in db.execute(sql, (expr, as_of, limit))]
def verify_source(folder, hit):
root = Path(folder).resolve()
path = (root / hit['path']).resolve()
if not path.is_relative_to(root):
return False
try:
raw = path.read_bytes()
lines = raw.decode('utf-8').splitlines()
return (hashlib.sha256(raw).hexdigest() == hit['sha256']
and 1 <= hit['line'] <= len(lines)
and lines[hit['line'] - 1] == hit['text'])
except (OSError, UnicodeError):
return False
def search_tool(db, query, *, as_of):
# as_of is a host policy value, not a date chosen by the model.
return {'kind': 'document_evidence', 'query': query,
'as_of': as_of, 'hits': retrieve(db, query, as_of=as_of),
'action_authority': False}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['sync', 'query'])
parser.add_argument('--db', default='sample-index.sqlite')
parser.add_argument('--corpus', default='corpus')
parser.add_argument('--as-of', default='2026-10-07')
parser.add_argument('--query', default='导出 保留')
args = parser.parse_args()
db = connect(args.db)
try:
if args.action == 'sync':
print(json.dumps({'indexed_lines': sync(db, args.corpus)}))
else:
print(json.dumps(search_tool(db, args.query, as_of=args.as_of),
ensure_ascii=False, indent=2))
finally:
db.close()
if __name__ == '__main__':
main()
11. 完整测试:保留成功与失败样例
测试每次创建独立临时目录与数据库。下面十三项检查在本次环境真实运行通过,其中包含预期抛出异常的反例;通过表示错误按预期发生,不表示这些操作成功。
test_retrieval.py
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from make_corpus import make
from retrieval import connect, sync, retrieve, verify_source, expression, search_tool
TODAY = '2026-10-07'
class RetrievalTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / 'corpus'
make(self.root)
self.db = connect(Path(self.tmp.name) / 'index.sqlite')
sync(self.db, self.root)
def tearDown(self):
self.db.close()
self.tmp.cleanup()
def search(self, q, **kwargs):
return retrieve(self.db, q, as_of=kwargs.pop('as_of', TODAY), **kwargs)
def test_conflicting_documents_are_both_returned(self):
hits = self.search('导出 保留')
self.assertEqual({r['doc_id'] for r in hits}, {'handbook', 'meeting'})
self.assertEqual({r['line'] for r in hits}, {2})
self.assertTrue(all(verify_source(self.root, r) for r in hits))
self.assertTrue(all('[导出]' in r['snippet'] for r in hits))
def test_archived_and_expired_excluded(self):
self.assertEqual(self.search('7 天'), [])
self.assertEqual(self.search('60 天'), [])
self.assertEqual(len(self.search('60 天', as_of='2026-10-06')), 1)
def test_empty_unrelated_and_synonym(self):
for q in ('', ' ', '"', '***', '发票', '输出 保存'):
with self.subTest(q=q):
self.assertEqual(self.search(q), [])
def test_unicode61_is_not_chinese_word_segmentation(self):
hits = self.search('导出文件保留三十天')
self.assertEqual([r['doc_id'] for r in hits], ['compact'])
self.assertNotIn('compact', {r['doc_id'] for r in self.search('导出')})
def test_quotes_and_operators_are_not_fts_programs(self):
self.assertEqual(len(self.search('"导出" 保留')), 2)
for q in ('导出 OR 薄荷', "x'; DROP TABLE chunks; --", 'NEAR(',
'导出 : (', '" OR "', '\"导出'):
with self.subTest(q=q):
self.search(q) # must not raise SQL or MATCH syntax error
self.assertEqual(self.db.execute('SELECT count(*) FROM chunks').fetchone()[0], 12)
self.assertEqual(self.search('导出 OR 薄荷'), [])
def test_direct_bound_match_still_has_syntax_errors(self):
with self.assertRaises(sqlite3.OperationalError):
self.db.execute('SELECT rowid FROM search WHERE search MATCH ?', ('"',)).fetchall()
def test_reindex_is_stable_and_rename_keeps_id(self):
before = {r['source_id'] for r in self.search('导出 保留')}
sync(self.db, self.root)
self.assertEqual(before, {r['source_id'] for r in self.search('导出 保留')})
mf = self.root / 'manifest.json'
items = json.loads(mf.read_text())
(self.root / 'handbook.txt').rename(self.root / 'renamed.txt')
items[1]['path'] = 'renamed.txt'
mf.write_text(json.dumps(items), encoding='utf-8')
sync(self.db, self.root)
self.assertEqual(before, {r['source_id'] for r in self.search('导出 保留')})
def test_update_replaces_old_terms_and_detects_stale_hit(self):
old = self.search('30 天')[0]
path = self.root / 'handbook.txt'
path.write_text(path.read_text().replace('30', '45'), encoding='utf-8')
self.assertFalse(verify_source(self.root, old))
# An unsynchronized index still returns its old snapshot.
self.assertEqual(len(self.search('30 天')), 1)
sync(self.db, self.root)
self.assertEqual(self.search('30 天'), [])
new = self.search('45 天')[0]
self.assertNotEqual(old['source_id'], new['source_id'])
self.assertTrue(verify_source(self.root, new))
def test_delete_requires_manifest_and_index_sync(self):
mf = self.root / 'manifest.json'
items = json.loads(mf.read_text())
(self.root / 'meeting.txt').unlink()
with self.assertRaises(FileNotFoundError):
sync(self.db, self.root)
self.assertEqual(len(self.search('14 天')), 1) # old index intact
mf.write_text(json.dumps([x for x in items if x['doc_id'] != 'meeting']))
sync(self.db, self.root)
self.assertEqual(self.search('14 天'), [])
def test_invalid_manifest_cannot_wipe_index(self):
mf = self.root / 'manifest.json'
items = json.loads(mf.read_text())
items[0]['path'] = '../outside.txt'
mf.write_text(json.dumps(items))
with self.assertRaises(ValueError):
sync(self.db, self.root)
self.assertEqual(len(self.search('导出 保留')), 2)
def test_invalid_query_contract(self):
for q in ('x' * 201, 'a\x00b', ' '.join(['a'] * 13), None):
with self.subTest(q=q), self.assertRaises(ValueError):
expression(q)
with self.assertRaises(ValueError):
self.search('导出', limit=True)
with self.assertRaises(ValueError):
self.search('导出', as_of='2026-1-1')
def test_tool_returns_no_answer_on_no_hit(self):
result = search_tool(self.db, '发票', as_of=TODAY)
self.assertEqual(result['hits'], [])
self.assertFalse(result['action_authority'])
self.assertNotIn('answer', result)
def test_snapshot_persists_on_reopen(self):
path = Path(self.tmp.name) / 'index.sqlite'
another = connect(path)
try:
self.assertEqual(len(retrieve(another, '导出 保留', as_of=TODAY)), 2)
finally:
another.close()
if __name__ == '__main__':
unittest.main()
12. 运行之后能确认什么
本次十三项测试验证了:冲突资料同时返回、归档与到期边界、无关词及同义词不命中、中文整串的分词限制、引号与操作符输入、直接 MATCH 的语法失败、重建及重命名后的来源稳定性、更新后的旧词退出、删除时的清单一致性、越界清单失败时保留索引、输入契约、工具空结果和重新打开数据库后的持久化。测试没有测量召回率、语义理解、模型回答质量或大型资料库性能。
接下来可以自己加一份包含相同关键词却用途不同的材料,检查它是否也被检出,再决定需要哪些业务过滤。先把“为什么返回这一行”解释清楚,才有条件评估切分、排序和检索方案的改变。
如果下一步要把这一路词法结果与另一份排名合并,可以接着做RRF 排名融合实验。先注意输入粒度:本篇返回原文行,那个实验接收文档级 ID 排名。应先定义行到文档的映射与折叠规则,让两路使用相同的身份和检索范围;不要直接混加原始分数。
技术核对日期为 2026-10-07。主要依据为 SQLite FTS5 官方文档中的 unicode61、查询字符串、snippet 与 bm25 部分。本文语料、代码、切分方式、来源标识和测试均为原创教学设计。
这就是语义 RAG 吗?
本例只实现词法检索和来源回查,没有 embedding、语义排序或模型回答。
为什么中文文本里有空格?
教学语料显式预分词,让 unicode61 的词项边界可复现。未分词中文另有失败例,不能据此推断真实中文检索效果。
删除文件后为什么还查得到?
查询读取索引快照。需要同步更新清单并重建索引;清单仍引用缺失文件时同步会失败,旧索引保留。