TencentDB Agent Memory 的检索实现:FTS5 加 bm25 再做图扩展

2026-08-16

TencentDB-Agent-Memory 这个仓库的时候,最容易先入为主的一处是它的 GitHub topics。截至 2026-08-16 我们采集时,这个仓库挂着 embeddingvector-search 这两个标签。看到这两个词,多数人脑子里会自动补出一套流程:切块、算向量、塞进向量库、余弦相似度召回。

但在 MemoryKnowledge/ 这个目录里,我们用脚本对全部文件做正则扫描,搜 embedvector|Vector命中 0 处。没有 embedding 模型名,没有向量维度,没有向量索引类型——这一层在这个模块里根本不存在。

先把边界说清楚:以下所有内容都基于 feat/server_team 分支(这就是该仓库的默认分支)上的快照 97f9465,采集日 2026-08-16。我们没有部署、没有起过任何容器、没有调用过一次接口。MemoryKnowledge/package.json 里这个模块的版本还是 0.1.0(包名 @tencentdb-agent-memory/knowledge-service),整仓 CHANGELOG 的最新条目是 2.0.1-beta.1,参数与接口随版本变动,请以仓库最新内容为准。另外,我们只扫了 MemoryKnowledge/ 这一个目录,同仓另外三个模块有没有向量实现,本文不覆盖。

一次 wiki 检索请求,代码上是怎么走的

入口是 POST /v3/wiki/search。这条路径在 MemoryKnowledge/openapi.yaml:408 有定义,规格里给的 summary 原文就是 Search wiki pages (BM25 full-text)——规格本身也没提向量。默认端口 8421、API 前缀 /v3,这两个值 README 与 MemoryKnowledge/src/config.ts:150,154 是一致的。

路由 handler 在 MemoryKnowledge/src/routes/wiki.ts:467。它先做参数校验:hop 必须是 0 到 5 的整数(490 行)、decay 在 0 到 1 之间(498 行)、minScore 非负(506 行)。然后才落到 MemoryKnowledge/src/engines/wiki/manager.ts 里的检索实现。

这里有个结构上的前提:每个 wiki 有一个自己的 SQLite 文件 index.db,和它的正文 .md 同目录、同生命周期。这一点写在 MemoryKnowledge/src/engines/wiki/index-db.ts 的模块注释(3-5 行)里。也就是说,检索不是全库一张大索引,而是按 wiki 分片的一堆小库。读连接走 LRU 池,池上限 POOL_MAX 默认 300,可用环境变量 KNOWLEDGE_WIKI_POOL_MAX 覆盖(33-37 行);写连接是独立开的,事务内做完就 wal_checkpoint(TRUNCATE)close(),不进池(11-14、162-176 行)。

index.db 里的三张表,各干各的活

initSchema()MemoryKnowledge/src/engines/wiki/index-db.ts:72-119 建表。跟检索直接相关的是三张:

结构要点
wiki_ftsFTS5 虚拟表,列 page_id UNINDEXED, title_tok, content_toktokenize = 'unicode61 remove_diacritics 0'75-82
page_metapage_id TEXT PRIMARY KEY, title, type, rel_path, snippet85-93
graph_edgesource_id TEXT NOT NULL, target_id TEXT NOT NULL, PRIMARY KEY (source_id, target_id)96-102

(第四张是 source 表,管源文件的 sha256 与摄取状态,和检索不在一条路径上,这里不展开。)

page_meta 的建表注释写得很直白:正文不入库,留在磁盘上的 .md 里。所以搜索结果返回的不是从正文里按 query 现切的高亮片段,而是 snippet 这一列——一个写入时就预生成好的静态摘要。生成规则在 MemoryKnowledge/src/engines/wiki/manager.ts:316-330:优先取 frontmatter 里的 description,没有就截正文前 SNIPPET_CONTEXT = 80 个字符。

这个设计取舍是明摆着的:同一个页面无论你用什么词搜出来,拿到的摘要都是同一段。你要是指望搜索结果里的摘要能随查询词变化,这个模块目前不给。

中文分词写在 JS 里,不在 FTS5 的分词器里

这是整条链路上最容易看漏的一处。wiki_fts 建表时的 tokenize 配置是 unicode61 remove_diacritics 0(75-82 行),这是 SQLite 自带的分词器。而中文的切分逻辑并不在这里,它落在 MemoryKnowledge/src/engines/wiki/manager.ts:340-374tokenize() 函数里,规则分三支:

  • 纯英文或数字 token:保留完整词,过一遍停用词表。
  • 纯中文且长度大于 1:走 bigram + 整词——把「知识图谱」这样的词拆成相邻两字的组合,同时把整个 token 也留下。
  • 中英混合 token:先按中英边界拆开,再各自按上面两条处理。

停用词表 STOP_WORDS 在同文件 308-314 行,中英各一批;具体收了哪些词,直接翻这七行就能看全,本文不逐一抄录。

查询侧对这个函数的用法在 ftsSearch() 里直接能看到:query 先过一遍 tokenize(),再拼成 FTS5 的 MATCH 表达式(下一节展开)。wiki_fts 的两个可检索列名字也带着后缀——title_tokcontent_tok。至于换掉中文切分策略之后,已经建好的索引要怎么处理,我们没有在 MemoryKnowledge/ 里找到对应说明。

bm25(wiki_fts, 5.0, 1.0):两个权重与那个负号

排序在 ftsSearch() 里,MemoryKnowledge/src/engines/wiki/manager.ts:381-391。它做的事很短:把 query 过一遍 tokenize(),每个 token 加 * 前缀做前缀匹配,用 OR 连起来,扔给 wiki_fts MATCH,排序表达式是:

bm25(wiki_fts, 5.0, 1.0)

两个数字对应 FTS5 建表时的两个可检索列:title_tok 权重 5.0,content_tok 权重 1.0。也就是标题命中比正文命中权重高。同一个文件 379 行附近的注释里,还能看到 minisearch 作为「被替换掉的旧方案」被提及。

然后是那个容易看反的地方:SQLite 的 bm25() 返回值是越负越相关,代码取了负号,把它翻成「越大越相关」的正分。这个方向对下一步有影响——图扩展要拿这个分数去乘衰减系数、跟 minScore 比大小。

这两个权重值都是代码里写死的字面量,不是环境变量,也不在 MemoryKnowledge/src/config.ts 的配置表里。要不要调、调成多少,取决于你的语料里标题信息量有多大,项目没有给通用值,我们也没有任何依据给建议。

BM25 只是第一步。MemoryKnowledge/src/engines/wiki/graph-search.ts 的模块注释(4-5 行)写明了第二步:从 BM25 的种子命中出发,沿 [[wikilink]] 边做多跳 BFS,每跳按 decay 衰减分数。种子本身固定记作 hop=0(68 行)。

图对象用的是 graphology,而且不是常驻内存的——查询时从 graph_edge 表把边读出来临时建一张内存图(MemoryKnowledge/src/engines/wiki/manager.ts:9,14,177)。也就是说,图这一层没有单独的存储组件,边就存在那张两列的 graph_edge 表里。

这一层的默认值集中在 MemoryKnowledge/src/engines/wiki/manager.ts:439-445

常量含义
DEFAULT_LIMIT20返回条数
DEFAULT_HOP0默认不做图扩展
DEFAULT_DECAY0.5每跳衰减系数
DEFAULT_MIN_SCORE0.1分数下限
HOP_LIMIT5hop 的上限,与路由层 0..5 的校验对得上
RELATED_CAP10related 的条数上限
EXPANSION_CAP200扩展条数上限

另外 MemoryKnowledge/src/engines/wiki/graph-search.ts:38 里还有一个 DEFAULT_MAX_NODES = 200。这几个常量我们核到的是名字与取值,更细的判定分支没有逐行读完,用到时请以源码为准。

DEFAULT_HOP = 0 这个默认值值得单独说一句:默认情况下这条链路就是纯 BM25,图扩展是你显式传 hop 才会打开的。把 hop 从 0 调到 1 会牵动什么,代码语义上是清楚的——多一跳意味着多一批分数被乘上 decay 的节点进入候选,同时更容易撞上 minScore 和节点数上限这两道闸。至于对你的召回结果是好是坏,我们没有部署过,也没有任何依据评价。

顺手记下三处对不上的地方

按只陈述差异、不推断原因的规矩,把核到的三处摆在这里:

一是 topics 与本模块实现。 仓库 topics 里有 embeddingvector-search(GitHub 侧,2026-08-16 采集),而我们在 MemoryKnowledge/ 内搜 embed / vector 命中 0 处,本模块的检索是 SQLite FTS5 + BM25 + wikilink 图扩展。两处口径不一致,以我们实读的仓库状态为准;其它三个模块我们没有核查。

二是那个被引用的测试目录。 MemoryKnowledge/src/engines/wiki/index-db.ts:74 的注释写着「与 __tests__/bm25-comparison 验证过的配置一致」。而我们在 MemoryKnowledge/find 不到任何 *.test.ts__tests__/vitest.config.ts——同仓另外三个模块各有一个 vitest.config.tsMemoryKnowledge/README.md:95 里也列了 pnpm testpackage.json:15-17 有对应的 test / test:watch / test:coverage 脚本,devDependencies 里也有 vitest

三是 minisearch 这个依赖。 它声明在 MemoryKnowledge/package.json 的依赖里,但我们全文 grep 不到 import;能搜到的只有注释里把它作为「被替换掉的旧方案」提及(src/engines/wiki/index-db.ts:17src/engines/wiki/manager.ts:8,379)。我们只陈述「代码里 grep 不到 import」这一件事,不推断它是否还有别的用途。同类 grep 不到引用的依赖还有 @node-rs/jiebajs-tiktokengraphology-communities-louvain 等若干个。

你能从这条路径上带走什么

如果你要读这个模块的检索层,顺序建议是:src/routes/wiki.ts:467 看参数边界 → src/engines/wiki/index-db.ts:72-119 看三张表 → src/engines/wiki/manager.ts:340-391 看分词与 BM25 → src/engines/wiki/graph-search.ts 看多跳。四个文件,一条路径,中间没有隐藏的服务调用。

也顺带记住一件事:docker-compose.yml 里只声明了 knowledge 一个服务,没有 postgres、redis、clickhouse,也没有任何向量库容器。这跟上面读到的实现是自洽的——检索这一层的全部状态就落在每个 wiki 自己的那个 index.db 文件里。

最后必须点明的一层:这个服务处理的是团队上传的文档与克隆下来的代码仓库,index.db 里存着标题、摘要和分词后的正文 token,磁盘上还躺着 .md 原文。这些都是敏感数据,怎么存放、谁能访问、留存多久,得按你自己的合规要求来定。

延伸阅读


本文依据 TencentDB Agent Memory 官方仓库(github.com/TencentCloud/TencentDB-Agent-Memoryfeat/server_team 分支上的 README、INSTALL、CHANGELOG、ROADMAP 与四个模块的源码整理, 核对日 2026-08-16,对应仓库快照 97f9465。该仓库的默认分支即为 feat/server_team。 本文内容为仓库源码与文档口径,我们没有部署、也没有运行过该项目的任何一个模块, 因此不涉及运行效果、检索质量与性能的任何描述。 该项目主模块处于 beta 阶段、其余模块版本号仍为 0.1.0,参数与接口随版本变动,请以仓库最新内容为准。 该项目会采集并存储团队的对话、文档与代码,属于敏感数据,是否使用请结合自身合规要求评估。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。