答案散在几十篇笔记里,Top-K 救不了
最近在折腾本地知识库的检索。场景不复杂:笔记散在几十上百个 Markdown 文件里,问一个问题,相关的记录可能分布在十几篇里。用标准的 Top-K 去捞,很快就会卡住。
K 调小了漏东西;调大了上下文塞满,token 涨得比效果快。我一开始也试着在 K 上找平衡点,试了几次发现方向本身不对:K 这个数字同时背着两个互相冲突的任务,怎么调都是两头堵。
一个 K 干了两个岗位的活
标准 RAG 是一条直线:切块、建索引、查询时取最相似的 Top-K、塞进 prompt。在这条线里,K 同时决定两件事:候选池有多大,以及最终写进上下文的有多少。想少漏就得让池子大,想让上下文便宜就得让池子小。两个目标绑在同一个数上,调到哪都不舒服。
把这两个数字拆开,事情就清楚了。
召回阶段只管宽,把可能沾边的都捞回来;中间加一层 rerank 做精排;最后按 token 预算决定装哪几条进上下文。三个数分开定,K 只管第一阶段的粗召回,可以放心调大。候选池从 20 加到 150,多花的只是本地向量检索和 rerank 的算力,生成阶段的 token 一点没多。真正贵的是最后那次模型调用,而它看到多少内容由预算定,不归候选池管。
Anthropic 在 2024 年那篇讲 Contextual Retrieval 的文章里给过一组数:他们给切开的 chunk 配上一段由原文生成的背景说明再建索引,Top-20 的召回失败率从 5.7% 降到了 3.7%;加上 BM25 做混合检索,降到 2.9%;最后叠加上 rerank,压到 1.9%,比基线少了 67% 的失败。
这组数里我更看重的是另一句:他们试了 5、10、20 三种上下文条数,20 条效果最好。也就是说,只要前面做了精排和上下文补全,给模型多看几条并不会把答案搞坏。
chunk 本身的处理也在影响召回。笔记被切成几百字的小块以后,这篇在讲什么的信息就丢了,代词指代、时间背景全断。Anthropic 的做法是在入库时用模型给每块补 50 到 100 个 token 的文档级背景,算是一次性投入。本地笔记其实有个更省事的做法:直接把标题、多级小标题、所在文件夹、tags 拼在块前面。这些结构本来就是现成的,不用调模型生成。
散落的答案要的是覆盖
上面那套能把召回率往上抬一截,但治不了开头说的那种情况。
单跳问题,答案就在一两段话里,找到最像的那几段就行。但碰到聚合问题——比如某个项目过去半年做过哪些决定,或者两个概念在哪些笔记里被对比过——答案是一组散落的证据。这时候 Top-K 的目标函数就错了:它在找最相关的几段,而你要的是覆盖。
在本地笔记库里做覆盖,有几种成本不高的做法。
最直接的是把检索单元从“块”往上提一层,直接用“笔记”当检索单位。入库时给每篇笔记生成一段摘要,先搜摘要定位笔记,命中之后再进那篇笔记里取具体的块。答案散在十几篇里的时候,这个改动比单纯调 K 明显得多——摘要不会被切碎,一篇笔记只要沾边就能整篇入围,具体读哪一段留给后面的步骤决定。
再往上是做层次索引,块、小节、笔记、主题各建一层,查询从上层往下钻。RAPTOR 走的就是这条路,把文档递归摘要成一棵树,树上的每个节点都可以成为命中项。对个人笔记库来说,目录和双链本来就有,树的一半其实是现成的。
查询那头也能拆。像“汇总所有关于某个主题的决定”这种需求,一个向量根本表达不出来,先拆成几个子查询分别检索再合并,命中面会宽很多。
顺手还可以加个去重。同一篇长笔记的相邻段落很容易一起命中,占掉五六个名额说的是同一件事。检索回来后按笔记分组,每篇只留得分最高的一两段,或者用 MMR 这种带多样性惩罚的排序,逼着上下文把名额分给不同的来源。
Agentic Search 换了一个变量
到这里为止,检索都只发生一次,只是尽量捞得更宽更准。这两年常被提起的 Agentic Search(比如 TREC 2025 RAG 赛道里 GRILL Lab 和 Glasgow Terrier 做的迭代式检索),改的是另一件事:把“决定检索预算”的时间点往后推了。
静态 RAG 必须在看到问题之前就把 K 定死,因为你不知道这个问题需要几段证据。换成 Agent 模式,模型可以一边查一边定:先查一轮,看看回来什么,缺什么再补,够了就停。简单问题和聚合问题花掉的预算可以完全不一样。
具体跑起来大概是这么个循环:模型自己写查询、看结果、决定继续挖还是换方向;命中一篇笔记的摘要之后,再决定要不要调工具读全文;每轮结束前盘点一下已经找到什么、还缺什么。检索在这里成了模型手里的一个工具,不再是一次写死的函数调用。
两种结构摆在一起,差别集中在几个地方:
| 静态 RAG | Agentic Search | |
|---|---|---|
| 检索次数 | 固定一次 | 由模型看情况决定 |
| 下一次查询的依据 | 提问前写好的语句 | 上一轮看到了什么 |
| 停止条件 | K 用完 | 证据够了 |
| 上下文怎么进 | 一次装入一批 | 读到哪算哪,只留有用的 |
| 主要失败模式 | 漏召回 | 乱搜、重复搜、停不下来 |
上下文怎么进也直接影响成本。静态 RAG 一次性把 K 条全塞进去,大部分其实没用上;Agent 读一段筛一段,最后生成时上下文里基本只有真能引用的材料。对成本敏感的本地部署,这个区别不小。
但代价也很直接,首先是慢。Agent 的每一步都是串行的,模型写查询、检索、判断,跑五到八轮,几十秒很正常。总 token 量也未必更少,轮数一多,历史消息和重复阅读的开销就会滚起来,必须靠中途压缩历史、只留结论来控场。
另外两个代价更隐蔽。一是底层检索器的水平依然是上限,Agent 只能组合索引里捞得回来的东西,如果底层召回不行,它只是把同一次漏召重复八遍;二是行为不稳定,重复查询、追着无关线索跑、该停不停,都得靠硬性的轮数上限和证据清单框住。
2025 年 11 月有篇论文(arXiv:2511.18177)在 1200 份 SEC 文件、150 道题上对比过几种方案:基于混合检索加元数据过滤的 Agentic RAG,相比不走向量的纯层次结构检索,胜率 68%,延迟还略低(5.2 秒对 5.98 秒)。但同一组实验里还有两个数据值得注意:单加一层 cross-encoder rerank,就把 MRR@5 提升了 59 个百分点;small-to-big 检索的胜率也有 65%,耗时只多了 0.2 秒。
可以看出来,Agent 化确实有收益,但像 rerank 和 small-to-big 这类便宜的改进,提升同样明显,改起来还简单得多。
现在 Agentic Search 被讲得有点万能了。它真正能补的其实只有两件事:一句查询表达不了的需求,以及提问前定不准的检索预算。如果问题本身是单跳的,或者索引里压根没有那段材料,跑多少轮都没用。
工具比 Prompt 重要
真要在本地笔记库上试 Agentic,有几个接口设计上的细节比写 prompt 重要得多。
搜索接口返回的应该是笔记级摘要、命中片段和 note_id,整段全文留给模型按 id 去取,让它自己决定读到多深。再单独配一个 grep 工具——本地几千到几万篇 Markdown,精确字符串匹配毫秒级就能回来,找编号、人名、专有名词比走向量靠谱得多。平时写笔记有打双链(wikilink)习惯的话,再加一个顺着链接查邻居的工具。
控制层面,轮数上限一般给到 6 到 10 轮,要求每轮先写清已知什么、缺什么,旧的检索结果和中间输出及时压成摘要,避免上下文无限膨胀。
不过我不建议一上来就跑完整的 Agent loop。可以先做个半固定的版本:默认正常检索一次,只有模型判断证据不够时,才允许它补一到两轮。主干流程大概长这样:
flowchart LR
Q(["用户提问"]) --> Recall["1. 混合粗召回<br/>(BM25 + 向量 + 笔记摘要)<br/>候选池 K = 150"]
Recall --> Rerank["2. 精排与去重<br/>(Cross-Encoder + 同笔记折叠)"]
Rerank --> Pack["3. 按 Token 预算装箱<br/>最终上下文 ≈ 10~20 条"]
Pack --> Check{"证据够吗?"}
Check -- "单跳问题 / 证据已够" --> LLM(["直接生成答案"])
Check -. "缺证据 (限补 1~2 轮)" .-> Tools["按需调本地工具<br/>grep | read_note(id) | wikilink"]
Tools -. "压缩中间结果,追加上下文" .-> Check
style Recall fill:#f0f7ff,stroke:#91caff
style Rerank fill:#f0f7ff,stroke:#91caff
style Pack fill:#f0f7ff,stroke:#91caff
style Check fill:#fffbe6,stroke:#ffe58f
style Tools fill:#f6ffed,stroke:#b7eb8f,stroke-dasharray: 5 5
这个折中版本能拿到大部分收益,延迟可控,平时做评测也好对齐。等这套跑顺了,再考虑放开轮数。
我现在的顺序
如果从头改起,我现在的动手顺序很固定:先做混合检索加 rerank 加按预算打包,这是基础,不做的话后面每一步都会被底层的召回质量拖住;然后加上笔记级索引,专门治跨文档的聚合问题;再往后是允许补一到两轮检索的半固定循环;完整 Agent 放最后。
flowchart TB
subgraph S1["阶段一:标准静态 RAG(一个 K 干两份活)"]
direction LR
Q1(["提问"]) --> V1["向量检索 (Top-K)"]
V1 -- "直接塞入 K 条<br/>(K 小漏召回 / K 大爆 Token)" --> L1["LLM 生成"]
end
subgraph S2["阶段二:解耦召回与上下文(基础底座)"]
direction LR
Q2(["提问"]) --> H2["混合粗召回<br/>(向量 + BM25 + 摘要)<br/>大候选池 K=150"]
H2 --> R2["Rerank 精排"]
R2 --> B2["按笔记去重 &<br/>按预算打包"]
B2 --> L2["LLM 生成"]
end
subgraph S3["阶段三:半固定循环 -> 完整 Agent(按需迭代)"]
direction LR
Q3(["提问"]) --> H3["阶段二底座<br/>首轮检索打包"]
H3 --> E3{"证据齐了吗?"}
E3 -- "缺拼图:补 1~2 轮" --> T3["调工具:改写查询 /<br/>grep / 读全文 / 查双链"]
T3 --> C3["压缩历史 & 更新清单"]
C3 --> E3
E3 -- "够了" --> L3["LLM 生成"]
end
S1 ~~~ S2 ~~~ S3
style S1 fill:#fafafa,stroke:#d9d9d9
style S2 fill:#f0f7ff,stroke:#91caff
style S3 fill:#f6ffed,stroke:#b7eb8f
style E3 fill:#fffbe6,stroke:#ffe58f
判断要不要走到完整 Agent,主要看两件事。一是日常问题里聚合和多跳的占比,偶尔出现的话半固定补检索就够了,占比高才值得上完整 loop;二是延迟预算,如果要求秒级响应,多轮串行的方案可以直接pass。
还有一种情况连 RAG 都不用做。Anthropic 那篇文章里提过,如果知识库小于 20 万 token(大概五百页文档),直接把全文塞进 prompt 配合 Prompt Caching 更简单。本地笔记库如果只有几百篇、总量不大,这笔账值得先算一下,可能比搭一套检索管线省事得多。
评测也不用搞太复杂,分三层看就行:检索层有没有把该召回的笔记捞回来,看 Recall@K;打包层塞进上下文的材料里有多少真正相关,看 Context Precision 和 Recall;生成层看答案对不对、引用是不是真能支撑结论。题目不用多,攒三五十道自己平时真会问的问题,手动标出标准答案和对应的笔记,就够把 K 和预算这两个数定下来了。
如果手头已经有套现成的管线,只能改一处,我会先加 rerank 和按预算打包。架构不用大动,延迟增加有限,比闷头调 K 的试错成本低得多,效果也更稳。
这条线跑稳之后,再决定要不要往 Agent 走。