给智能体补会话记忆:我选 SQLite FTS5,先不上向量库
引语:会话记录检索这个需求,SQLite FTS5 就够了——1 万条长文本查询 0.14 毫秒,比 LIKE 快 15 倍。但中文上手必踩三个坑,这篇把实测数据和修法都摆出来,代码可以直接抄。
1. 背景:为什么不是向量库
智能体干完活就失忆:第二天问它「上周那个排查结论是什么」「那个触发器当时是怎么写的」,答不上来。会话记录明明都在本地库里,缺的是一层检索。而且这类查询九成是词面回查——回查一个报错原文、一段配置、一条结论里的关键词,不是语义泛搜。我第一反应是上向量库,评估完放弃了——为了「找历史记录」这一个需求,引入嵌入模型、向量存储、网络往返,太重。SQLite FTS5 是全文检索,Python 标准库 sqlite3 直接内建(本机 3.45.3),零依赖零部署。但它对中文不是开箱即用,三个坑依次说。
2. 坑一:连续中文整段成一个 token
FTS5 默认分词器 unicode61 按空格和标点切 token,连续中文没有分隔符,整句会被当成一个巨长的 token。实测:把「华为云开发者社区」原文入库,查「开发者」命中 0。我先试了自定义分隔符——能解决「·」这类符号的边界问题,但词中间没有符号照样整段,这条路不通。正确做法是写入前用 jieba 切词,jieba 把这句切成「华为/云/开发者/社区」,每个词独立成 token,查询立刻正常。注意查询侧同理:查「会话记录」也要先切成「会话 记录」,两端切法不一致就查不到。还有个小提示:切词时用 w.isalnum() 过滤一遍,标点和单字虚词不入索引,索引体积和误命中都能降下来。
import sqlite3, jieba
con = sqlite3.connect(':memory:')
con.execute("CREATE VIRTUAL TABLE t USING fts5(c, tokenize='unicode61')")
# 原文直存:连续中文整段变成一个 token
con.execute("INSERT INTO t VALUES ('华为云开发者社区')")
q = con.execute("SELECT count(*) FROM t WHERE t MATCH ?", ('"开发者"',))
print(q.fetchone()[0]) # 0 —— 查不到
# jieba 切分后再存:华为 云 开发者 社区
words = ' '.join(w for w in jieba.lcut('华为云开发者社区') if w.isalnum())
con.execute("INSERT INTO t VALUES (?)", (words,))
q = con.execute("SELECT count(*) FROM t WHERE t MATCH ?", ('"开发者"',))
print(q.fetchone()[0]) # 1 —— 命中
3. 坑二:索引不会自己同步,触发器别省
第二个坑更隐蔽:FTS5 虚表不会随 content 表自动更新,插入、修改、删除都得自己同步。官方给的模式是外部内容表(external content)加三个触发器——原文只存一份,索引里只有 token,省空间;UPDATE 触发器要先写一条 delete 命令再写新版本,顺序不能反。实测全链路:插入后查「会话」命中 1 行;改标题后查新词命中;删除后命中归零。还有个细节:'rebuild' 重建命令会从 content 表拉原文重建——原文没切过词,重建出来的索引照样是错的,批量重建也要在 Python 侧切词后再写入。
import sqlite3, jieba
def seg(text):
"""切词:写入和查询两端都必须走这一步"""
return ' '.join(w for w in jieba.lcut(text) if w.isalnum())
con = sqlite3.connect('memory.db')
con.execute("CREATE TABLE IF NOT EXISTS docs("
"id INTEGER PRIMARY KEY, title TEXT, body TEXT)")
# 外部内容表:索引只存 token,原文只存一份
con.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5("
"title, body, content='docs', content_rowid='id', "
"tokenize='unicode61')")
# 三个触发器:索引不会自己更新,全靠它们同步
con.execute("""CREATE TRIGGER IF NOT EXISTS docs_ai
AFTER INSERT ON docs BEGIN
INSERT INTO docs_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END""")
con.execute("""CREATE TRIGGER IF NOT EXISTS docs_ad
AFTER DELETE ON docs BEGIN
INSERT INTO docs_fts(docs_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
END""")
con.execute("""CREATE TRIGGER IF NOT EXISTS docs_au
AFTER UPDATE ON docs BEGIN
INSERT INTO docs_fts(docs_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
INSERT INTO docs_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END""")
# 查询:查询词也要切,按 BM25 相关度排序
def search(con, text, limit=5):
terms = [w for w in jieba.lcut(text) if w.isalnum()]
cond = ' AND '.join('"%s"' % t for t in terms)
return con.execute(
"SELECT d.id, d.title FROM docs_fts f "
"JOIN docs d ON d.id = f.rowid "
"WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?",
(cond, limit)).fetchall()
con.execute("INSERT INTO docs(title, body) VALUES (?, ?)",
(seg('智能体会话记忆'), seg('把 IM 会话记录接进了检索')))
print(search(con, '会话记录')) # [(1, '智能体 会话 记忆')]
4. 坑三:trigram 救不了两字词
有人推荐 trigram 分词器做中文兜底,实测它有硬盲区:trigram 以三个字符为最小匹配单位,两字词直接查不到。文档「这纯属撞大运」里明明有「大运」,查「大运」命中 0,查「撞大运」才命中——中文两字词是最高频的查询单元,这个盲区是致命的。另外配置 detail='none' 时 phrase 查询直接报错:fts5: phrase queries are not supported。这还带来一种更迷惑的现象:文档里有「会话」,你搜个不相干的「大脑」,两字查询全都不报错、只默默返回空——错得无声无息,排查时全靠人眼盯数据。结论明确:中文场景别拿 trigram 兜底;只有字段极短、查询词确定三字以上时它才有用武之地。
import sqlite3
con = sqlite3.connect(':memory:')
con.execute("CREATE VIRTUAL TABLE t USING fts5(c, tokenize='trigram')")
con.execute("INSERT INTO t VALUES (?)",
('默认用 IM 会话记录做检索,这纯属撞大运',))
for q in ['大运', '撞大运']:
n = con.execute("SELECT count(*) FROM t WHERE t MATCH ?",
(q,)).fetchone()[0]
print(q, '->', n) # 大运 -> 0(两字词盲区),撞大运 -> 1
5. 性能数据和选型边界
基准:1 万条记录、平均 3.6 KB 中文长文本。jieba 切分一次 9.7 秒(一次性成本),FTS5 建索引入库 0.42 秒,之后 LIKE 全表扫查一个词 2.1 毫秒,FTS5 同查询 0.14 毫秒,15 倍;记录再涨一个量级,差距只会更大。
| 查询方式 | 耗时 | 说明 |
|---|---|---|
| LIKE 全表扫 | 2.1 ms | 1 万条、平均 3.6 KB |
| FTS5 + jieba | 0.14 ms | BM25 排序,亚毫秒 |
边界也要说清楚:FTS5 是词面检索,「缓存穿透」查不到只写了「缓存击穿」的记录,词面不同就召不回。这个场景再补向量通道(sqlite-vec 这类本地扩展,https://github.com/asg017/sqlite-vec ),FTS5 管关键词回查、向量管语义召回,两者互补不是替代。我的建议:先用 FTS5 起步,语义召回的需求真实出现了再加向量,别反过来。
6. 总结
三个判断:一,会话记忆的第一版选 FTS5 而不是向量库,绝大多数查询是关键词回查,别为长尾需求先付重架构的成本;二,中文必须外挂分词,写入和查询两端都要切,切法一致;三,触发器同步和重建时的切词细节不能省,省了就是「查不到」的脏索引,比没有索引更误导人。
记忆不是把数据存下来,是让下一次能被查到。
- 点赞
- 收藏
- 关注作者
评论(0)