跳转至

Agent 会话搜索:查询调优

一次搜索的成本,以及 src/main/ai-vault-search/session-search-engine.ts 中的参数能带来什么。这里的每个数字都来自 config/scripts/session-search-query-benchmark.ts,基于 session-search-synthetic-corpus.ts 中的合成语料库;唯一例外是 conversation_fts 对比测试,它自己写入语料库,因为答案取决于转录中有多少是工具输出。本文件中的任何内容都没有针对真实转录进行测量,而且这两个基准测试也绝不能指向真实转录。

运行它

该基准测试是一个顶层 await 模块,它通过无扩展名路径导入主进程树,因此需要一个由打包器支持的运行器,而不是裸 node:

cat > src/main/ai-vault-search/zz-bench.test.ts <<'EOF'
import { it } from 'vitest'
it('runs', { timeout: 1_800_000 }, async () => {
  await import('../../../config/scripts/session-search-query-benchmark')
})
EOF
BENCH_OUT=/tmp/ss-query-bench.json pnpm test src/main/ai-vault-search/zz-bench.test.ts
rm src/main/ai-vault-search/zz-bench.test.ts

下面的 conversation_fts 对比测试以相同方式运行,只是导入 config/scripts/session-search-conversation-fts-benchmark,并使用 CORPUS_MB 和 TOOL_SHARE 来设定其语料库的大小和形态。config/scripts 不在任何类型检查项目中,因此在存在该临时测试时,tsc 会为它拉入的每个脚本报告 TS6307;删除它后运行又会变干净。

BENCH_OUT 存在是因为 vitest 会拦截 console.log;报告也会写入该路径,同时打印出来。

范围:第二个 FTS 表为读者带来了什么

语料库:40 个合成 Claude 转录,10.5 MB,9,600 条消息,通过真实存储建立索引。八个查询,路由阶梯的每个层级一个,加上两种跳过它的形态;每个查询 5 次预热运行和 25 个样本。Apple 芯片,热页缓存,机器其余时间空闲。单位是毫秒,如果还有其他东西争用磁盘,25 个样本的 p95 每次运行会波动几毫秒。

范围 p50 p95
all 7.22 8.94
conversation 5.33 7.86

按查询,先 all 后 conversation(p50 / p95):

查询 all conversation
"terminal reattach"(短语) 5.24 / 8.42 2.97 / 3.24
resolveTerminalPath(标识符) 7.55 / 8.94 6.47 / 6.72
src/main/…/session-transcript-reader.ts(路径) 8.69 / 10.12 7.78 / 8.04
why is the daemon snapshot stale(自然语言文本) 7.84 / 8.57 5.90 / 7.01
reattahc worktre(错字修复) 7.30 / 7.39 5.53 / 5.89
index(常见词) 5.45 / 5.66 3.81 / 4.02
repo:app-3(仅操作符) 0.12 / 0.16 0.10 / 0.10
worktree 限定到一个 cwd 1.47 / 1.63 1.25 / 1.49

解读:

  • conversation 在 p50 上大约快 1.4 倍,在 p95 上快 1.1 倍,并且它是同一张表上的列过滤,而不是它自己的表。缩小到两个自然语言文本列正是带来差距的原因:需要评分的倒排项更少。它也是这样的范围:匹配项是人写出的内容,而不是工具打印出来的内容。
  • scopePaths 查询是该页面上最便宜的真实搜索。它是 SQL 能精确表达的一种收窄,因此它查找 sessions_cwd_key,并交给排序一个小的候选集。
  • 仅操作符的数字是下限,而不是典型成本。repo: 和 path: 在 JS 中对检索到的行应用(参见 session-search-row-filter 了解为什么它们不能下推到 SQL),因此它们的成本取决于遍历在填满候选集之前必须读取多少个会话。此语料库有 40 个会话,即该遍历的一页;如果一个索引中很少有会话匹配操作符,则会读取到 session-search-retrieval 中的上限。

在真实语料库规模下 conversation 范围的成本

conversation 曾是第二个 FTS 表,保存两个自然语言文本列的副本。现在它是一个列过滤——{user_text assistant_text}: (…),bm25 权重将另外两列置零——PR 2 依据本节曾经持有的对比测试删除了该表:过滤器的 p95 达到专用表的 1.16-1.36 倍,低于 2 倍门槛,而该表的维护成本是索引的十分之一。以下是实际发布的模式所做的内容,在表被删除且工具行被限制后,在同一语料库上再次测量。

语料库:来自 config/scripts/session-search-tool-heavy-corpus.ts 的 Claude 转录,105 MB,通过真实存储建立索引,位于真实转录树所处的 80-97% 区间的两个点。工具输出中一半的 token 是会话也使用的词,因此一个会话词确实有过滤器必须丢弃的倒排项。每个层级 20 个查询,两种范围按查询交错,热缓存;config/scripts/session-search-scope-benchmark.ts,运行两次。

工具占比 层级 all p50 / p95 conversation p50 / p95
86% 短语 16.69 / 17.48 13.08 / 13.52
86% 或 31.91 / 35.74 22.25 / 23.87
86% 与 70.04 / 74.00 53.47 / 59.39
93% 短语 9.14 / 13.36 7.23 / 8.51
93% 或 16.46 / 18.70 12.34 / 14.88
93% 与 39.65 / 43.44 31.05 / 32.92

从中可以读出三件事。

过滤器是收益,而不是成本。 每个层级在窄范围下都比宽范围快,p50 上快 1.2 到 1.4 倍。对比测试将过滤器与一个专门为该查询构建的表进行了比较;相对于它替换的宽表,它做了第二个表所做的事,即读取更少的倒排项。

and 层级是语料库规模显现的地方。 这些查询是八个词,选择它们是为了让那么长的有序连续片段不出现,并且短语层级必须未命中;一个真实的双词 AND 更接近短语行。它也是最嘈杂的:第二次运行的 p95 在一个桶上达到 140 ms,这就是一个 70 ms 查询的二十个样本所买到的。看 p50 列。

索引比对比测试中的小得多。 在 93% 工具输出时索引为 57 MB,在 86% 时为 103 MB;而在 PR 2 将索引中的工具行限制为 3,072 个字符之前,仅 messages_fts 就大约需要 150 MB。如今,工具密集型转录的大部分内容根本不在索引中,这改变了上述所有数字,也是两者中更大的效应。

这里没有衡量的是相关性,而列过滤确实带来了一个被删除的表所没有的排序差异。FTS5 的 bm25 按整行长度归一化,并且没有按列长度,因此当一行还包含工具输出时,两行相同散文内容的得分会不同。rowid 集合没有变化,这正是删除决策所依据的;但集合内部的顺序可能会变化。session-search-engine.test.ts 固定了方向。

sessionCandidateLimit

评审者的 F13:这是一个可调默认值,而不是常量。它限制了 SQL 交给排序的会话数量,因此同时限制了检索成本和调用方在答案直接停止之前可以翻页的深度。

只有当匹配的会话数超过限制允许的数量时,该限制才会产生任何成本,因此这里在第二个语料库上测量:2,500 条单轮转录,10.9 MB,其中每一条都匹配查询。限制值按样本交错测量,因为如果连续运行,第一个配置会为操作系统缓存尚未见过的每一页付出代价,而仅排序本身对 p95 的影响就比限制值更大。

限制 p50 p95 调用方可达到的 20 条分页数量
200 6.85 7.21 10
600 7.93 8.36 30
1200 9.55 10.53 60
2400 12.32 13.45 120

600 是默认值:在 p50 上它比 200 高约 16%,但换来了三倍的可达深度,并且曲线只在超过 1200 后才变陡。拥有大得多的索引的主机可以提高它;结果中的 truncated.candidates 会说明限制是否是截断答案的原因,因此调用方永远不需要猜测。

这里没有衡量的是相关性。这些数字说明限制的成本,而不是它检索到了什么。BM25 权重(session-search-retrieval.ts)和标识符影子列(session-search-identifier-split.ts)中引用的 MRR 数字来自原始检索对比测试,基于真实转录,无法从本仓库复现。任何基于相关性理由对限制所做的更改都需要评估集,而不是这个基准测试。

拼写修复的成本

修复是其中一个梯级,其成本随词汇表大小变化,而不是随结果大小变化。它只为作用域中没有倒排项的术语运行,因此普通查询永远不会支付这项成本;无意义查询会为每个术语支付一次。

在包含 160 万个不同术语的合成词汇表上测量,每个术语都出现在两行中,因此没有任何术语被过滤掉:

查询 p50
一个已知术语(无修复) 11 ms
一个未知术语 10 ms
39 个未知 12 字符术语(480 字符) 387 ms
12 个未知 40 字符术语 99 ms

由此可得出两点。成本与未知术语数量和词汇表大小成线性关系,并且 search 是同步的,因此在如此大的索引上,一个 512 字符的无意义查询会让线程阻塞三分之一秒。此外,作用域计数修复使这变得更便宜而不是更昂贵——之前是 737 毫秒——因为按术语排序词汇表扫描去掉了按 doc 排序所需的排序,而它添加的计数最多是每个前缀八次有界探测。每个查询的未知术语上限已作为后续事项记录在拆分计划中。

页面预热,已移除

PR 2 将 warm() 推迟给了解读取会触及哪些页面的人——warm() 是对 messages 的分片读取,会在第一次查询前将其页面拉入操作系统缓存。这里没有重新添加它,原因有二。为其提供依据的测量(首次查询从 1.3 秒降至 0.45 秒)是在 4 GB 索引上进行的,而本文件中的两个语料库都不在其数量级范围内,因此 PR 4 无法展示收益:移除该调用使 10.5 MB 语料库的 p50 变化小于运行间波动。此外,它是一个可取消的后台过程,需要一个具有生命周期的所有者;一个不持有定时器的查询库没有可以挂载 stopped() 的对象,而从同步 search 发起的即发即忘异步读取是一个没有任何东西可以监督的拒绝。它应属于 PR 3b 中的索引器,后者已经负责启动和停止工作。

此处未定

哪个进程可以打开、删除并重建索引是 PR 3b 的决定。一个发现较旧 schema 版本的第二个句柄会在活动存储继续从未链接的 inode 应答时替换文件,而本 PR 首次使这种情况可达,因为它是第一个读取的东西。PR 4 所做的只是拒绝使其恶化。引擎在搜索前恢复其派生词汇表和代际触发器。缺失的 messages_fts 会明确失败;连接所有者必须重建源索引。没有降级搜索能力状态或查询日志。当存在评估消费者时,调用方可以添加日志。

每次搜索都在检索前和最终内容读取后检查代际。并发提交会以 stale-generation 拒绝该页,包括没有游标的第一页。调用方可以从第一页重试。不需要长期读取事务,并且混合页永远不会作为有效快照返回。

仓库/路径运算符在短语或 AND 路由被接受之前应用。候选截断由作出应答的梯级报告,而不是由尝试过的每个梯级报告。梯子的每个梯级都匹配其前一个梯级的超集,因此一个达到上限但没有合格会话的梯级之后总会跟着一个也达到上限的梯级:无论哪种情况,完整候选集都保持显式。

短语梯级和 AND 梯级既用于散文,也用于看似字面量的输入,基于查询的原始输入词元,而不是去除停用词后的 OR 主体。从转录中粘贴出来的一句话是按顺序的普通词语;在 OR 下,其常见词语会用最近会话填满候选限制,而包含该句话的旧会话永远无法进入排序。成本是两个通常未命中的 FTS 查询,在上面的语料库上,这处于本测试框架的运行间噪声之内。单词元查询仍然只在它看起来像字面量时才走该梯级。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca