给智能体补会话记忆:我选 SQLite FTS5,先不上向量库

举报
阿诺林 发表于 2026/09/08 14:33:34 2026/09/08
【摘要】 引语:会话记录检索这个需求,SQLite FTS5 就够了——1 万条长文本查询 0.14 毫秒,比 LIKE 快 15 倍。但中文上手必踩三个坑,这篇把实测数据和修法都摆出来,代码可以直接抄。 1. 背景:为什么不是向量库智能体干完活就失忆:第二天问它「上周那个排查结论是什么」「那个触发器当时是怎么写的」,答不上来。会话记录明明都在本地库里,缺的是一层检索。而且这类查询九成是词面回查——回...

引语:会话记录检索这个需求,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 而不是向量库,绝大多数查询是关键词回查,别为长尾需求先付重架构的成本;二,中文必须外挂分词,写入和查询两端都要切,切法一致;三,触发器同步和重建时的切词细节不能省,省了就是「查不到」的脏索引,比没有索引更误导人。

记忆不是把数据存下来,是让下一次能被查到。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。