我做了一个文档知识工作台:从搜索到 RAG,再到 Agent

最开始做 Anchr,我想解决的问题很具体:手里的文档越来越多,怎么才能把里面的信息重新找出来、用起来?

目录能解决文件放在哪里,系统自带的全文搜索则要求我还记得原文用过哪些词。资料少时这些都够用;资料一多,更常见的情况是:我记得某份文档讲过这件事,却忘了文件名,也不记得当时的表达。

Anchr 于是从文档搜索做起。搜索基本可用后,我才在检索结果上生成回答;再往后,当一次检索不足以处理跨文档问题时,才有了 Agent。功能名称从 Search 变成 RAG,再变成 Agent,背后却一直在做同一件事:让原始资源稳定地变成模型可以使用、用户又能回到原文核对的证据。

1
2
3
4
5
6
7
8
9
10
11
12
13
原始资源

解析与结构提取

分段与 Embedding

版本化写入索引

查询改写 → 多路召回 → 融合 → 重排

可追溯证据

模型回答 → 引用校验 → 保存与展示

这条链路有一个很麻烦的特点:前一步丢掉的信息,后面通常补不回来。解析时丢了表格和页码,搜索再准也很难还原原文;分段时把条件和结论切开,模型看到的就只剩半句话;新旧版本同时参与召回,后面的重排也不知道哪份才是当前内容。RAG 里很多问题,根本不在提示词(Prompt)。

技术文档尤其容易暴露这些问题。一个结论往往同时受版本、前置条件和运行环境限定:“默认开启”可能只适用于某个大版本,“会自动重试”可能只针对幂等请求。上游只要丢掉一个限定,模型就可能从真实原文中组织出一个看似合理、却缺少条件的回答。

先让文档变成可以定位的内容

用户看到的是 PDF、Markdown、纯文本或图片,检索系统需要的却不是一个文件名加一大段字符串。它至少要知道哪些内容属于同一段,它们在第几页、前后顺序如何,图片和文字之间有没有关联。

在 Anchr 里,进入入库任务的资源都交给 Anchr Docling 解析。它是一个独立运行的 Python sidecar,就是专门放在主服务旁边处理文档的辅助服务。主服务负责提交任务、跟踪状态,再接收它返回的结构化结果。

解析入口统一以后,后续链路不需要分别理解“PDF 的第三页”和“Markdown 的第二个二级标题”。它处理的是文本块、图片、页码、标题和位置。文件格式的差异在解析阶段被吸收,用来回溯原文的结构则要继续保留。

这对 PDF 很重要。Markdown 和纯文本天然有阅读顺序,PDF 更像一张排好的画布,文字、表格和图片要靠页面位置组成语义。假设一份分布式事务协议说明用表格列出执行阶段、参与者状态、持久化记录和故障恢复动作,如果只按 PDF 内部的存储顺序抽取文字,prepared 状态的恢复动作很可能会和 committed 状态拼到一起。Docling 会尽量恢复这些结构,同时保留 PDF 的页码和页面位置。

解析后的文档不会整份写成一条搜索数据,而是继续拆成一组 Segment。Segment 是搜索和引用使用的基本片段:文本段落、从图片中识别出的文字(OCR),以及文档中的图片,都可以成为独立的检索单元。每个单元都带着文档、页码、顺序和位置信息。需要语义检索的内容,还会通过 Embedding 转换成可以比较的向量。

这里没有把所有内容强行压成同一种文本。普通段落用正文生成文本向量,OCR 文本可以参与文字检索;当 Embedding 配置支持多模态时,原始图片还可以保留独立的视觉表示。一张展示协调者故障恢复的时序图,可能因为图中的 preparecommit 被全文检索命中,也可能因为整体交互结构被向量检索找到。两条路径最后都要回到同一份原始资源的具体位置。

分段也不是只调一个长度参数。切得太小,“参与者进入 prepared 状态后必须保留锁和事务日志”和“直到收到最终决定或完成故障恢复”可能落在两个片段里,前一句便丢了终止条件。切得太大,一个片段又会混入正常提交、协调者恢复和启发式处理等多条路径,全文相关性和向量表示都会变得模糊。

所以我把“搜索命中哪一段”和“用户需要阅读多少内容”分成了两个问题。搜索先用较小的 Segment 定位;用户打开原文,或后续需要更多证据时,再按顺序取回相邻片段。这比直接放大检索片段更灵活,也让搜索、引用和原文预览共用同一套位置信息。

文档到这里已经变成了有结构、有位置的片段,下一步才是写入索引。但索引写入不是一次就能完成的操作,新内容写到一半时能不能被搜到,成了紧接着要解决的问题。

新索引没写完,旧内容先别动

文档解析、Embedding 和索引写入都可能访问其他服务,耗时也很不稳定。如果上传请求一直等到整条链路结束,一份复杂 PDF 或一次 Embedding 限流就可能长时间占着连接。所以 Anchr 把入库做成了后台任务:上传成功只代表原始资源已经交给系统,不代表它马上就能被搜到。

一个批次里,每份资源都有自己的处理项,状态会随着解析、Embedding 和写入索引向前推进。某份文档失败时,已经成功的文档不会被一起拖住;页面上的进度也能对应到真实的处理阶段。

阶段分开以后,失败也不再只是一句笼统的“入库失败”。Docling 没能取回原文件、解析结果为空、Embedding 服务限流,以及 Elasticsearch 没有完成写入,它们发生的位置和后续处理并不一样。Anchr 会在进程内对部分短暂的 Docling 和 Embedding 错误做有上限的重试;处理项最终失败后,则保留失败阶段和原因,用户可以重新处理整份文档。

新文档首次入库只要等这条链路跑完。一旦允许重新解析和重新向量化,便会多出一个更难的问题:旧内容什么时候不再可见,新内容又从哪一刻开始参与搜索?

假设某份部署手册把运行时要求从 JDK 17 调整到 JDK 21。重新处理时如果先删旧索引,新版本建好前,这份手册会暂时从搜索中消失;如果新旧 Segment 边写边覆盖,用户又可能同时搜到“JDK 17”和“JDK 21”。对搜索来说,最危险的不是索引里暂时有两份数据,而是它们在同一次查询里同时可见。

Anchr 会为每次内容处理分配一个独立的索引版本 generation。旧版本继续提供搜索,新版本在后台完成解析、向量化和索引写入。只有写入数量和目标版本都通过检查,新版本才会激活;旧版本和激活失败的新数据,稍后再交给后台清理。

激活不能只看“最后一次 Elasticsearch 请求没报错”。写入完成后,主服务会核对写入结果是否属于当前文档和目标版本,应该可读的 Segment 是否已经完整写入。真正切换前,还要再确认当前文档状态:如果文档在此期间被删除,或这次处理已经被更新的版本超过,它就不会被激活。这道检查避免了较慢的后台任务在结束时把已删除或已更新的文档“写回来”。

这里没有让 MySQL 和 Elasticsearch 参与同一个分布式事务。MySQL 记录当前生效的版本,Elasticsearch 保存可以重建的 Segment 搜索副本。查询可能从 Elasticsearch 找到多个版本的候选,但只有与 MySQL 当前版本一致的 Segment 才能继续往后走。

这个分工也决定了失败时该信谁。Elasticsearch 里暂时多出一个未激活的版本,不会改变用户看到的文档;清理任务短暂失败,也不会让当前已生效的内容突然不可用。这个方案不能让 MySQL 和 Elasticsearch 像一个数据库事务那样同时成功或回滚,但用户什么时候看到新版本,只由 MySQL 里的版本切换决定。

新旧版本的可见性确定后,当前查询到底能用哪些内容也就清楚了。接下来才轮到检索本身:同一个问题,既要找到准确的原词,也要覆盖意思相同的不同说法。

一次搜索,要同时照顾“原词”和“大意”

真正召回之前,系统先要确定这次搜索可以落在哪些文档上。用户是在整个知识库里提问,还是只在某份技术手册里查找,这个范围必须先确定。查询改写可以改变“拿什么话去搜”,却不能把搜索范围悄悄扩大,否则不在当前上下文里的资料也会混进答案。

范围确定后,才轮到处理用户这句话到底在问什么。比如用户先问“PostgreSQL 的 Serializable 隔离级别为什么还会让事务失败”,接着只问“只读事务也会吗?”。如果直接用后一句搜索,数据库、隔离级别和序列化冲突这些前提都丢了。Anchr 会结合最近对话改写查询;如果改写模型超时或输出不合法,就退回用户的原问题继续搜索。改写只是帮助召回,不会替换用户真正提出的问题。

改写后的查询会进入多路召回,也就是同时用几种方式找候选。全文检索擅长命中类名、错误码、配置项和文件标题等明确术语,向量检索则用来覆盖不同表达。用户问“为什么数据库为了保证可串行化反而会主动回滚事务”,文档里写的可能是“检测到可能产生不可串行化结果的读写依赖,需要中止其中一个事务”。说法不同,指向的却是同一个并发控制问题。

反过来,像 SQLSTATE 40001could not serialize access due to read/write dependencies among transactions 这样的标识和原始错误信息,一旦被泛化成语义,精确度反而会下降。全文检索因此不能被向量检索取代。如果当前 Embedding 支持图片,同一个查询向量还可以去匹配依赖关系图和技术手册里提取的图片。

1
2
3
文本全文召回 ──┐
文本向量召回 ──┼→ RRF 融合 → 当前版本过滤 → 多样化 → Rerank
文档图片召回 ──┘

三路召回的原始分数并不在同一个尺度上,直接相加会让分值范围更大的那一路占便宜。Anchr 用 RRF(Reciprocal Rank Fusion)合并结果。它不直接比较原始分数,而是根据候选在各路结果中的名次累积贡献。同一个 Segment 既命中了配置项原词,又和问题语义接近时,会获得两路支持;只在某一路出现的内容,也不会因为分数不可比就直接消失。

融合后先检查当前索引版本,再整理结果并交给 Rerank,也就是让重排模型更细地判断候选与问题的相关性。这个顺序很关键:旧版本的片段再相关,也不应该占用后面的候选名额,更不值得消耗一次重排。如果版本过滤放在最后,过期内容还可能提前挤掉本该进入重排的新版片段。

一份长文档里的几个相邻段落往往会同时命中,如果不加处理,前十条可能大半都来自同一份文档。搜索整个知识库时,Anchr 会适当限制同一文档、同一类型的候选数量,给其他资料留出位置。这不代表其他文档一定更正确,只是避免一份长文档凭借片段数量占满有限的候选窗口。已经明确限定在一份文档内时,目标变成找出这份文档里最相关的位置,打散动作便会放到 Rerank 之后,免得过早删掉有用的连续片段。

Rerank 只处理已经收窄过的候选范围,它不是第二套召回。前面被搜索范围、版本或候选数量限制排除的内容,它无法凭空找回来。所以最终顺序会同时参考调整到相同范围后的检索得分和 Rerank 得分;当 Rerank 服务失败或返回空结果时,则保留 RRF 的原顺序。这条降级路径可以保住已有的检索结果,但救不回召回阶段已经丢掉的证据。

到这里,系统得到的还是一份排好序的搜索候选。它们适合展示给人,却不适合原样塞给回答模型。

搜到的结果,还不能直接交给模型

搜索页面和问答模型对结果的需求不一样。人可以先看几张文档卡片,再点开其中一条继续判断;模型的上下文有限,每放入一个片段都在消耗窗口,也会增加引用与结论错配的机会。

Anchr 会先把排好的 Segment 按文档合并成结果卡片,保留每份文档中排名靠前的命中位置。用户看到的因此是“哪些资料值得打开”,不是十几条来自同一文件的碎片。这些搜索结果偏向阅读导航,不需要与最终答案使用的证据完全一致。

固定 RAG 会从这些结果里继续收缩答案候选,最多选择 5 个能追溯、能引用的片段。它们要有真实的 Segment 标识,能对应到页面展示的结果,而且有可供回答使用的文本证据。限制数量,就是让模型只在这几个片段里组织答案。如果把搜索返回的前 K 个结果全部塞进 Prompt,重复片段、弱相关段落和互相冲突的版本说明也会一起进去,模型反而要在回答前先替检索系统做一遍去噪。

图片召回还有一个边界:纯视觉向量可以帮助找到一张相关架构图,但如果当前没有可以引用的文本,它就不会直接成为知识型回答的根据。能帮助排序,不代表已经成为可以表达、可以核对的证据。

进入生成阶段的 Prompt 不只有用户问题和几段文本。它会同时带上用户原问题、检索时使用的改写查询、当前回答模式,以及经过编号的证据列表。原问题保留用户的真实意图,改写查询说明这次搜索实际用了什么表达,证据列表则限定模型可以使用的事实。把它们分开,是为了避免“为了搜得更全而补写的查询”在生成阶段反过来改变用户的问题。

在调用回答模型前,后端还会先做一次证据检查。没有可引用的 Segment、有效内容太短,或者检索分数低且证据过少时,系统会直接返回证据不足,而不是让模型在薄弱的上下文上补齐答案。这也让“没找到足够证据”和“模型调用失败”成为两种不同的结果。

通过这道检查后,模型才会开始生成。但生成完成也不等于回答可以直接交给用户,后端还要检查它有没有遵守证据规则。

模型写完了,后端还要验收

回答模型需要返回带状态和引用编号的结构化结果:有证据时是 ANSWERED,证据不足时是 NO_EVIDENCENO_EVIDENCE 表示检索和生成链路正常结束,但现有资料不足以支撑结论。模型超时、协议无法解析或调用异常则属于生成失败。分开这两种结果后,用户才知道该换个问法、补充资料,还是稍后重试。

即使模型返回了 ANSWERED,这份回答也只是候选结果。模型可能输出不合法的 JSON,引用一个不存在的编号,或者给出了知识型结论却没有任何引用。这些问题不能只靠 Prompt 里的“请勿编造”解决,后端可以用代码里的固定规则检查它们。

Anchr 会解析模型返回的结构,检查回答状态、引用编号是否超出输入证据,以及知识型回答是否真的使用了引用。模型只拿到 1 至 5 号证据,回答里却出现 [7],这份回答就不会直接通过。

验收成功后,后端只保留答案实际引用的 Segment,而不会把所有候选都包装成“参考资料”。这些 Segment 再按文档合并、重新编号,补上文件、页码、原文位置与命中原因。如果模型没有给出合适的引用说明,系统就使用检索阶段已经生成的匹配摘要,避免只为了装饰引用而再补一条理由。前面解析阶段保留的位置信息,到这里才真正变成可点击的回溯路径。

回答和搜索结果卡片也是两份不同的产物。模型可能只用了两个 Segment,页面仍然可以保留其他高相关结果,方便用户继续阅读。如果证据没有达到回答门槛,系统也可以保留已经找到的文档线索,同时明确告诉用户暂时无法给出有证据的结论。没有生成答案,不代表这次搜索毫无价值。

在页面逐步展示回答时,模型返回给后端的状态、引用编号等协议字段,不会原样显示出来。服务端会从增量输出中只取可展示的回答正文;完整结果验证结束后,如果最终文本与前面的临时内容不一致,页面会再用验证后的版本校准。逐步展示是为了减少等待感,最终校验才决定这轮回答能否成立。

引用、回答状态、结果卡片,以及这次查询经过了哪些步骤,都会和本轮会话一起保存。用户刷新页面后,仍然能看到完整结果。保存这条检索轨迹,也方便在回答不理想时继续往前查:是查询改写偏了,召回没找到,还是版本过滤、重排、证据检查或生成校验拦住了结果。如果最后只留下一段回答,这条链路就又变成了一个黑盒。

引用能说明“这段回答使用了哪份资料”,却不能保证模型对资料的理解一定正确。比如 PostgreSQL 文档说的是“VACUUM 不能清理仍可能被活动事务快照看到的旧行版本”,模型仍然可能概括成“长事务会阻塞 VACUUM”,把“限制部分旧版本的回收”扩大成“整个清理过程都无法运行”。引用不会自动修正这个结论,但它至少让用户可以直接回到原文检查。到这一步,一条固定 RAG 链路才真正闭合。

固定 RAG 现在做到了哪里

现在的 Anchr 已经能把原始资源转成可搜索、可回溯的证据,让全文、文本向量和文档图片共同参与召回,再把筛过的证据交给回答模型。模型回答通过引用校验后,会和搜索结果一起保存,用户仍然可以回到原文继续阅读。

这条链路还有一些没补齐的地方。文档入库在主服务重启后,还不能从任意中间阶段原地续跑;搜索已经有多路召回、RRF 和 Rerank,但还没有大规模人工标注数据支撑完整的离线评测。接下来仍然要补搜索评测、改善入库中断后的恢复路径,并继续把回答失败和证据不足区分清楚。

这些都是固定链路内部还要继续补的工作。但另外还有一类问题,不是把一次检索调得更准就能解决的。

一次检索不够,才需要 Agent

这篇文章里的 RAG 在生成回答前只检索一次。问“PostgreSQL 的普通 VACUUM 为什么通常不会缩小表文件”时,如果一次检索已经找到了空间复用和文件回收之间的区别,固定链路就已经够用。

更复杂的问题可能是“PostgreSQL 只读副本的延迟为什么持续上升”。它既涉及 WAL 的发送、接收和回放位置,也可能需要继续核对网络吞吐、回放冲突、长查询和当前部署参数。第一次搜索找到延迟发生在哪个阶段后,才知道下一步该查传输链路还是回放过程。后续检索方向取决于前一步读到的内容,固定 RAG 到这里便碰到了边界。

Anchr 的 Agent 就从这个边界开始。它没有另起一套知识链路,而是沿用前面的检索、版本过滤和引用规则,让模型可以根据中间结果继续搜索和阅读。至于这个循环为什么需要预算、状态和证据边界,我会放在下一篇《一次 RAG 不够怎么办:Anchr 迭代 Agent RAG 的过程》里单独讲。

项目源码https://github.com/ryanmeowy/anchr-app
Webhttps://github.com/ryanmeowy/anchr-web
文档解析https://github.com/ryanmeowy/anchr-docling