我做了一个文档知识工作台:从搜索到 RAG,再到 Agent
最开始做 Anchr,我想解决的问题很具体:手里的文档越来越多以后,怎么把里面的信息重新找出来、用起来?
目录能解决文件放在哪里,系统自带的全文搜索则要求我记得原文里的词。资料少时这些都够用,多起来以后,情况往往变成了:我记得某份文档讲过这件事,却忘了文件名,也不记得当时用了什么表达。
Anchr 于是从文档搜索开始。搜索基本可用后,我才在检索结果上生成回答;再往后,一次检索覆盖不了跨文档问题,Agent 才加进来。表面上是功能从 Search 增加到 RAG 和 Agent,实际开发时,工作重心一直是同一条链路:让原始资源稳定地变成模型可以使用、用户又能回到原文核对的证据。
1 | 原始资源 |
这条链路里,前一步的输出都是后一步的输入。解析时丢掉了表格和页码,搜索再准也难以还原原文;分段把条件和结论切开,模型拿到的就只是半句话;新旧版本同时参与召回,后面的 Rerank 也分不清哪一份才是当前有效的内容。RAG 的问题很少只发生在 Prompt 里。
技术文档尤其容易暴露这些问题。一个结论往往同时受版本、前置条件和运行环境限定:“默认开启”可能只适用于某个大版本,“会自动重试”可能只针对幂等请求。只要上游处理丢掉了其中一个限定,模型就可能用一段真实原文组织出一个看似合理、实际不完整的回答。
先让一份文档变成可定位的内容
用户看到的是 PDF、Markdown、纯文本或图片,检索系统需要的却不是一个文件名和一大段字符串。它需要知道文档里哪些内容属于同一段,它们出现在第几页、前后顺序如何,以及图片和文字之间有没有关联。
在 Anchr 里,所有进入入库任务的资源都由 Python sidecar Anchr Docling 解析。主服务提交任务、跟踪状态并接收结构化结果,后续的分段和检索都以这份结果为起点。
把入口统一起来后,后面的链路不需要分别理解“PDF 的第三页”和“Markdown 的第二个二级标题”。它只消费解析后的文本块、图片、页码、标题和位置。格式差异由解析阶段吸收,但原始文档中用于回溯的结构不能因此被抹平。
统一入口不代表每种资源都一样容易处理。Markdown 和纯文本天然带有阅读顺序,PDF 更像一张排好的画布,文字、表格和图片要靠页面位置组成语义。假设一份分布式事务协议说明用表格列出执行阶段、参与者状态、持久化记录和故障恢复动作,如果只按 PDF 内部存储顺序抽取文字,prepared 状态的恢复动作很可能会和 committed 状态拼在一起。Docling 会尽量恢复这些结构,并为 PDF 内容保留页码和页面位置等信息。
解析结果不会整份写成一条搜索数据,而是继续被映射成一组 Segment。文本段落、图片 OCR 和文档中的图片可以形成不同类型的检索单元,各自携带文档、页码、顺序和位置信息。需要向量检索的内容,再按当前的 Embedding 能力生成向量。
这里没有把所有内容强行压成同一种文本。普通段落用正文生成文本向量,图片中识别出的字符可以作为 OCR 文本参与检索,支持多模态 Embedding 时,原始图片还可以保留独立的视觉表示。一张展示协调者故障恢复过程的时序图,可能因图中的 prepare、commit 被全文检索命中,也可能因整体交互结构被向量检索找到。两条路径最后仍然指向同一份原始资源和对应位置。
分段在这里从来不只是一个长度参数。切得太小,“参与者进入 prepared 状态后必须保留锁和事务日志”和“直到收到最终决定或完成故障恢复”可能落在两个片段里;前一句失去后面的终止条件,就很容易被理解成无限期保留。切得太大,一个片段又会混入正常提交、协调者恢复和启发式处理等多条路径,全文相关性与向量表示都会变得模糊。Anchr 中的 Segment 更像一个搜索入口,它要足够精确,同时保留向前后恢复上下文的可能。
因此,“搜索命中了哪一段”和“用户需要阅读多少内容”被分成了两个问题。搜索先用较小的 Segment 定位,用户点开原文或后续需要更多证据时,再沿着顺序取回相邻片段。这比单纯把 Chunk 放大更灵活,也让搜索、Citation 和后续阅读使用同一套位置信息。
这些页码、顺序和位置信息暂时还没有出现在回答里,但它们会沿着整条链路一直传到 Citation 和原文预览。在此之前,这批 Segment 还要先安全地进入索引。
索引写完之前,半成品不应该被搜到
文档解析、Embedding 和索引写入都可能访问进程外服务,耗时也很不稳定。如果上传请求一直等到整条链路结束,一份复杂 PDF 或一次 Embedding 限流就可能长时间占着连接。所以 Anchr 把入库做成后台任务,上传成功只说明原始资源已经交给系统,不代表它已经可以参与检索。
一个批次里的每份资源都有独立处理项,阶段会随着解析、Embedding 和写入索引向前推进。这样一批文档中某一份处理失败时,已经成功的文档不会被一起拖住;页面上显示的进度也有真实阶段对应,而不是用一条动画遮住不确定的等待。
阶段拆开之后,失败也不再只是一个笼统的“入库失败”。Docling 没能取回原文件、解析结果为空、Embedding 服务限流,以及 Elasticsearch 写入未完成,处理位置和后续动作都不一样。Anchr 会在进程内对一些短暂的 Docling 和 Embedding 错误做有界重试;处理项最终失败后,则保留失败阶段和原因,由用户重新发起整份文档的处理。
新文档首次入库只需要等待这条链路完成。支持重新解析和重新向量化后,问题就变了:旧内容什么时候不再可见,新内容又从哪一刻开始参与搜索?
假设某份部署手册把服务的运行时要求从 JDK 17 调整到 JDK 21。如果重新处理时先删旧索引,新版本建好前,这份手册会暂时从搜索中消失;如果新旧 Segment 边写边覆盖,用户又可能同时搜到“JDK 17”和“JDK 21”。对搜索来说,最危险的不是索引里暂时有两份数据,而是它们在同一次查询中同时可见。
Anchr 为每次内容处理分配独立的 generation。旧 generation 继续提供搜索,新 generation 在后台完成解析、向量化和索引写入。只有写入数量和目标版本经过检查,新版本才会被激活;旧版本和激活失败的新数据,之后再由后台清理。
激活不是“最后一次 ES 请求没报错”就算成功。写入完成后,主服务还会核对返回的写入结果是否属于当前文档和目标 generation,可读 Segment 是否已经完整写入。真正切换时会再锁定当前文档状态:如果文档在这段时间已被删除,或者这次处理已经被另一个更新版本超过,新 generation 不会被激活。这道检查避免了一个较慢的后台任务在结束时把已删除或已更新的文档“写回来”。
这里没有让 MySQL 和 Elasticsearch 参与同一个分布式事务。MySQL 记录当前生效的 generation,Elasticsearch 保存可重建的 Segment 投影。查询可以从 Elasticsearch 召回到多个版本的候选,但只有与 MySQL 中当前版本一致的 Segment 才能继续向后流转。这一道可见性检查,把入库的版本控制连到了下一步检索。
这个分工还决定了失败时该信谁。Elasticsearch 里暂时多出一个未激活 generation,不会改变用户能看到的文档版本;清理任务短暂失败,也不应该把当前已生效内容判为不可用。业务上的“当前版本”由 MySQL 决定,ES 中暂留的投影由后台事件继续收拾。这样不能让两个存储产生强事务,但可以把用户可见性压缩到一个清楚的切换点上。
一次搜索,要同时覆盖“原词”和“大意”
真正开始召回之前,系统要先确定这次搜索可以落在哪些文档上。用户是在整个知识库里提问,还是只查看某一份技术手册;当前会话有没有选定资源,这些范围必须先收敛下来。查询改写只能改变“拿什么话去搜”,不能把搜索范围悄悄扩大。否则一句为了提高召回率而补全的查询,就可能把不在当前上下文里的资料混进答案。
范围确定后,下一个问题才是用户这句话到底在问什么,而不是先争论用 BM25 还是向量。在连续对话里,用户可能先问“PostgreSQL 的 Serializable 隔离级别为什么还会让事务失败”,接着只问“只读事务也会吗?”。搜索如果直接使用后半句,就丢了数据库、隔离级别和序列化冲突这些前提。Anchr 会结合最近对话改写查询,如果改写模型超时或输出不合法,则退回用户的原问题继续查找。改写在这里是一层召回辅助,不会替换用户真正提出的问题,后面的回答仍然以原问题为准。
改写后的查询会进入多路召回。全文检索擅长命中类名、错误码、配置项和文件标题等明确术语;向量检索用来接住另一种表达。用户问“为什么数据库为了保证可串行化反而会主动回滚事务”,文档里写的可能是“检测到可能产生不可串行化结果的读写依赖,需要中止其中一个事务”。原词不同,说的却是同一个并发控制问题。反过来,像 SQLSTATE 40001、could not serialize access due to read/write dependencies among transactions 这样的错误标识和原始信息,一旦被泛化成语义,精确度反而会下降,所以全文召回不能被向量检索取代。如果当前 Embedding 配置支持图片,同一个查询向量还可以去匹配依赖关系图和技术手册中提取的图片。
1 | 文本全文召回 ──┐ |
混合检索真正麻烦的,不是把三条查询都发出去,而是怎么把它们合成一份结果。三路召回的原始分数不在同一个尺度上,直接相加容易让分值范围更大的一路占便宜。RRF 不比较这些原始分数,只根据候选在各路结果中的名次累积贡献。同一个 Segment 如果既被配置项原词命中,又和问题语义接近,会在融合时获得两路支持;只在某一路出现的内容也不会因为分数不可比而直接消失。融合的意义不是简单拼接三份列表,而是把它们重新变成一份可比较、可去重的候选集。
候选融合后,先通过前面提到的 generation 可见性检查,再进入结果整理和 Rerank。这个顺序很重要:旧版本的片段即使和问题非常相关,也不应该占用后面的候选名额,更不值得消耗一次 Rerank。版本过滤如果拖到最后,过期内容不只会污染结果,还可能提前挤掉本来应该进入重排的新版本片段。入库阶段维护的“当前 generation”,到这里才真正成为检索质量的一部分。
“有关”和“适合放在前面”并不完全是同一个问题。一份长文档里的几个相邻段落往往会同时命中,如果不加处理,前十条可能有大半来自同一份文档。搜整个知识库时,Anchr 会适当限制同一文档、同一类型的候选数量,给其他资料留出位置。这不是假设其他文档一定更正确,而是避免一份长文档仅凭片段数量垄断有限的候选窗口。已经明确限定在一份文档内时,目标从“覆盖更多资料”变成“找到这份文档里最相关的位置”,打散动作就会放到 Rerank 之后,避免过早删掉有用的连续片段。
Rerank 只处理已经收敛过的候选窗口,它负责重新判断候选和问题的细粒度相关性,却不是第二套无限召回。前面被范围、版本或候选窗口排除的内容,不会被 Rerank 凭空找回来。因此最终顺序不会完全抹掉前面的检索信号,而是同时参考归一化后的检索得分和 Rerank 得分;如果 Rerank 服务失败或返回空结果,则保留 RRF 的原顺序。这条降级路径能保住已有的检索结果,但 Rerank 仍然救不了召回阶段已经丢失的证据。
到这里,搜索已经给出了一批排好的候选。对于搜索页面,这些结果可以直接供人浏览;对于 RAG,它们还只是可能有用的材料,不能原样塞进 Prompt。
搜索结果还要再收敛,才会成为模型证据
搜索页面和问答模型对结果的需要不同。人可以先看几张文档卡片,再点开其中一条继续判断;模型的上下文有限,放进去的每个片段都在消耗窗口,也会增加引用和结论错配的机会。
Anchr 先把排好的 Segment 按文档聚合成结果卡片,保留每份文档中排名靠前的命中位置。聚合之后,用户看到的是“哪些资料值得打开”,而不是十几条来自同一文件的碎片。结果卡片因此偏向阅读导航,不必和最终答案使用的证据完全相同。
答案候选会在这些结果中继续收缩。固定 RAG 最多选择 5 个能够追溯和引用的候选,它们必须有真实的 Segment 标识,能对应到页面上展示的结果,而且具备可用的文本证据。限制数量不是为了追求一个漂亮的 Top 5,而是为了控制模型实际面对的事实边界。把搜索 Top K 全部塞进去,看起来给了模型更多信息,实际也把重复片段、弱相关段落和相互冲突的版本说明一起带进了上下文,模型需要在回答问题之前先替检索系统做一遍去噪。
图片召回还带来一个额外边界:纯视觉向量可以帮助找到一张相关架构图,但如果当前没有可供回答引用的文本,它就不会被直接当成知识型答案的依据。能够帮助排序,不等于已经成为可表达、可核对的证据。搜索允许把线索展示给用户,自动回答则需要更严格的准入条件。
进入生成阶段的 Prompt 不只有一个用户问题和几段文本。它会同时带上用户原问题、为检索改写后的查询、当前回答模式,以及经过编号的证据列表。原问题保留用户的真实意图,改写查询说明这次检索实际用了什么表达,证据列表则给出模型允许使用的事实边界。把这几部分分开,能避免“为了搜得更全而补写的查询”在生成阶段反过来改变问题本身。
在调用回答模型之前,后端还会先做一次证据门禁。如果没有可引用的 Segment,有效内容太短,或者检索分数低且证据过少,系统会直接返回证据不足,而不是让模型在薄弱上下文上补齐一个答案。这一步放在模型调用之前,既减少一次没有必要的生成,也让“没搜到足够证据”和“模型调用失败”成为两种不同结果。严格问答、摘要式回答和探索式回答对证据数量及允许的表达范围不完全相同,但引用必须来自输入证据这条规则不会改变。
对模型来说,这是一次受限的写作任务:证据够时生成回答,并把编号放在它所支持的陈述之后;证据不够时明确返回无证据结果。它输出的还不是可以直接交给用户的最终文本,而是一份等待后端验收的候选回答。
模型输出只是候选回答
回答模型被要求返回一份带状态和引用编号的结构化结果:有证据时是 ANSWERED,证据不足时是 NO_EVIDENCE。这里的状态不是给页面换一句提示语那么简单。NO_EVIDENCE 表示检索和生成链路正常结束,只是现有资料不足以支撑结论;模型超时、协议无法解析或调用异常,则是生成失败。把两者混在一起,用户无法判断应该换个问法、补充资料,还是稍后重试。
即使模型返回了 ANSWERED,它也只是候选回答。模型可能输出不合法的 JSON,引用一个不存在的编号,或者给出了知识型结论却没有任何引用。这些问题不能只靠 Prompt 里的“请勿编造”解决,因为后端完全可以用确定性规则检查它们。
Anchr 会解析模型返回的结构,检查回答状态、引用编号是否超出输入证据,以及知识型回答是否实际使用了引用。模型只拿到了 1 到 5 号证据,回答里出现了 [7],这份回答就不会直接通过。验收成功后,后端只保留答案实际引用的 Segment,而不是把所有候选都包装成“参考资料”;随后再按文档归并和重新编号,补上文件、页码、原文位置与命中原因。模型没有给出合适的引用说明时,系统会退回检索阶段已经生成的匹配摘要,避免为了装饰 Citation 再编一条理由。前面解析阶段保留的位置信息,到这里才真正变成可点击的回溯路径。
回答和搜索结果卡片也是两份不同的产物。模型可能只用了两个 Segment,页面仍然可以保留其他高相关结果,方便用户自己继续阅读。反过来,如果证据没有达到回答门槛,系统可以保留找到的文档线索,同时明确告诉用户暂时无法给出有证据的结论。这同样是 RAG 的正常出口:没有生成答案,不等于这次搜索毫无价值;有时最诚实的结果,就是把相关资料交还给用户,而不替它们补出结论。
在流式回答里,模型生成的协议字段也不会原样显示在页面上。服务端从增量输出中只提取可展示的回答正文;完整结果验证结束后,如果最终文本与前面的临时内容不一致,页面会再用已验证的版本校准。流式输出解决的是等待感,最终校验决定的才是这轮回答能否成立,两者不能互相代替。
引用、回答状态、检索轨迹和结果卡片会和本轮会话一起保存,用户刷新页面后仍然能看到完整结果。保存检索轨迹不只是为了复现页面:当回答不理想时,需要区分问题出在查询改写、召回、版本过滤、重排、证据门禁还是生成校验。如果最后只留下一段回答文本,整条链路就重新变成了一个无法解释的黑盒。
Citation 解决的是“这段回答使用了哪份资料”,不是“模型对资料的理解一定正确”。假如 PostgreSQL 文档说的是“VACUUM 不能清理仍可能被活动事务快照看到的旧行版本”,模型仍然可能概括成“长事务会阻塞 VACUUM”,把“限制部分旧版本的回收”扩大成“整个清理过程都无法运行”。引用不会自动修正这个结论,但它让用户可以直接回到原文检查。到这一步,一条固定的 RAG 链路才算完整闭合。
这条 RAG 链路还在继续补齐
这篇文章里的 RAG 在生成回答之前只检索一次。问“PostgreSQL 的普通 VACUUM 为什么通常不会缩小表文件”时,如果一次检索已经拿到了空间复用和文件回收之间的区别,固定链路就已经够用。
更复杂的问题可能是“PostgreSQL 只读副本的延迟为什么持续上升”。它既涉及 WAL 的发送、接收和回放位置,也可能需要继续核对网络吞吐、回放冲突、长查询以及当前部署参数。第一次搜索找到延迟发生在哪个阶段后,才知道下一步应该查传输链路还是回放过程;后续方向取决于前一步读到的内容。到了这里,固定 RAG 就碰到了边界。
Anchr 的 Agent 是从这个边界开始的。它没有另起一套知识链路,而是在前面的检索、版本过滤和引用规则上,允许模型根据中间结果继续搜索和阅读。至于这个循环为什么后来需要预算、状态和证据边界,会放在下一篇《一次 RAG 不够怎么办:Anchr 迭代 Agent RAG 的过程》里单独说。
项目源码:https://github.com/ryanmeowy/anchr-app
Web:https://github.com/ryanmeowy/anchr-web
文档解析:https://github.com/ryanmeowy/anchr-docling