← 返回博客 XFENG · INSIGHTS

从可检索到可回答

在真实企业文档上反复对照原文后,我们把知识入库从解析成功改成结构保真。本文记录切块、表头传递、长度指标失效和检索验收,说明答不准往往在入库阶段就已经定下来。

左侧按解析元素切成互不完整的小块,右侧按文档结构收敛成可回答的知识块

过去一段时间,我们把十几类真实业务文档反复喂进知识库。家电维修手册、产品需求文档、报价表、培训用的 PPT、接口文档,还有一份六个 sheet 的银行业务 Excel。每喂一次,我们把切出来的块一个一个打开,对着原文找哪里断错了。

看完这些,我们的经验判断是,很多答不准在检索发生之前就已经定下来了。这一条没有统计数字撑着,它来自逐块对照原文的经验,但它决定了我们后来把精力放在哪里。用户上传文档,系统显示处理成功,块数统计正常,问题已经埋在库里。检索环节再怎么调参数、换模型、加重排,都只是在一堆拼不出答案的碎片里挑最像的那几个。

这篇文章的范围是文档进入知识库的那一段,讲怎么保住原文结构,以及怎么验收才算做对。里面用到的做法大多是业界已有的工程实践,我们能添的是在真实企业文档上判断这些做法有没有做对的那部分经验。

解析器的单位不是检索的单位

PDF 解析器读完一页,吐出来的是版面元素,一个标题、一个段落、一张表格。Excel 读完一张表吐出来的是行,PPT 读完一页吐出来的是文本框。这些单位属于文件格式,回答的是这一页上有什么东西。

检索块要回答的是另一个问题。一个人带着疑问过来,读到这一块,能不能得到一个完整的意思。

把前者直接当成后者,是我们见过的最常见的做法,也是最贵的一种。我们给它起个名字,叫「解析元素即检索块」。

后果很直接。一份产品需求文档进来,切成两百多块,其中大半不足一百字。一块里只有一个小标题,下一块只有半张表格的表头,再下一块是一句「详见下节」。检索命中了其中三块,拼起来仍然答不出用户问的那件事。向量库里的数字都很正常,报表也不难看,可这套东西从根上就不能用。

我们走过一段弯路。发现碎片以后,第一反应是在分块的出口加一道最小块合并,问题在于它只挂在其中一条处理路径上。PDF 被强制路由到按版面元素切块,走的是另一条路,合并逻辑对它完全不生效。同一个知识库里,一部分文档的块合并过,PDF 的块还是碎的。

不同格式当然可以走不同的处理路径,后面 Markdown 和 Excel 就走了各自的路。要统一的是质量要求和可观测性。这一次的麻烦在于同一条质量规则只在一部分路径上生效,而报表上看不出这种分裂。

左边是「解析元素即检索块」,一页文档被打成十几个互不完整的小块;右边是结构保真摄入,同一页收敛成一个父块加三个带表头的子块。

结构保真摄入

重做的时候我们把三件事分开。文档结构理解是一件事,版面、章节层级和表格的行列网格要用专门的工具还原。叙述文本的语义切分是另一件事,说明文字在哪里断开才不伤意思,这跟文件格式无关。检索平台自己守住剩下的部分,策略路由、存储、混合检索、重排、权限和最后的上下文组装。

我们把这套做法叫结构保真摄入。要点是块的边界由文档自己的结构决定,不由解析器的输出粒度决定。说得更直白一点,知识入库的目标是保留回答问题所需要的关系,标题和它下面正文的关系、表头和数据行的关系、一句说明和它所说明的那个数字的关系。块只是装这些关系的容器,容器切得均匀与否不重要,关系断没断才重要。

Markdown 走语法树,标题层级、代码围栏、列表和表格都是语法节点,照着切就行。Excel 直接读表格网格,拿到真实的 sheet 名、列头和每一行单元格,自己生成表格文本,而不是去清洗解析器吐出来的那串扁平字符串。其余格式先做结构理解,再在结构内部做语义切分。产出是 Parent 和 Child 两级块,子块用来命中,父块保留完整上下文。

改善的幅度比我们预期的大。一份接口文档从 278 块收敛到 58 块,最大的块 512 token。那份银行业务 Excel 从 170 块收敛到 94 块,六个真实 sheet 名全部保留,跨 sheet 串块为 0。还有一份班费记账表,里面的金额原来是 6459.830000000001,现在恢复成正常的货币精度。

这几个数字证明的是输出粒度和结构完整性变了。它们不等于问答正确率变高,块数从两百多降到几十,也推不出答得更准。同一批真实问题在改造前后的答案对照,我们还在补。

表头和上下文跟着块走

表格是最容易切坏的东西,因为表格的意思有一半在表头里。一张报价表切成三块,只有第一块带着列名,第二块和第三块里全是数字和短词,12、是、含税。这些块就算命中了,模型也读不出这个 12 是数量还是天数。

所以每个块顶部都重复一次真实表头。冗余是划算的,表头占不了几个 token,缺了它整块就废。行不拆,一行装不下就按行开窗,宁可让块超过目标长度,也不在一行中间断开。PPT 默认保留页边界,跨页合并只在验证过边界正确的前提下才允许,把两页并成一块省下来的那点空间,通常换不回丢掉的边界。

子块命中以后,同一个父块下命中的覆盖率够高就整体扩展到父块,不够就补上相邻的兄弟块。

改造前只有第一个块带表头,第三个块同时装下了两个方案;改造后每块重复真实表头,整行不拆,一块装不下就按行开窗。

看边界,不看长度

我们有一轮验收,报表全绿。PPT 的中位块长 737 字,短块率为零,Excel 也没有碎块。按当时的指标口径,这两种格式都算过关了。

然后我们把块打开看。PPT 那八页被合并成三块,第一块横跨前四页,结尾停在一页幻灯片的结论句上,那句话讲 LLM 到底在干什么,只说完了前半截,后半截在第二块的开头。

Excel 那份报价表更能说明问题。表里有方案 A 和方案 B 两套报价,各自几行明细带一行合计。下面用一张简化的示意表复现当时的切法,行数和金额是示意值,切错的位置是真实的。切完之后,方案 A 的明细分在前两块,第三块开头是方案 A 的合计行,32 人日、153,600,紧接着就是方案 B 的标题和它的第一行明细。第二块和第三块都没有表头,块里只剩下方案 A、联调测试、4、19,200 这样的短词和数字。

用户问方案 A 一共多少钱。检索命中第三块,命中得没错,方案 A 的合计确实在里面。模型读到的是一段以合计行开头、紧接着方案 B 报价的文本,没有列名告诉它 32 和 153,600 分别是什么,也没有明显的分界告诉它下面几行属于另一套方案。它可能答对,也可能把方案 B 的接口对接 33,600 加进方案 A 的总价,或者把 32 当成金额。这类错误在日志里看不出来,检索命中了,模型也给了一个数。

改造之后,同一个问题命中的块是这样。块内只有方案 A,顶部是真实表头,方案、项目、人日、金额,最后一行是方案 A 的合计 32 人日、153,600。方案 B 从下一块开始。模型拿到这一块,读得出 153,600 是方案 A 的金额合计,也拿不到方案 B 的任何数字去混。

长度指标给出的是假阳性。块长正常、短块率为零,内容边界可以是全错的。

反过来也成立,短块不天然意味着质量差。一条完整的错误码说明、一张独立的小表格、一句有独立意义的结论,本来就该短。我们后来的规则里,完整的问答对允许低于最小长度,表格里的关键数值允许单独成块但必须带表头,只有没有信息量的占位内容才丢弃。为了让长度分布好看去强行合并,换来的是把两件不相干的事装进同一块。

那一轮之后我们换了裁判。验收标准改成三条。一块单独拿出来读,意思完不完整。它跟上一块的接缝,落在什么位置。表头和所属章节,有没有随块保留下来。换成客户自己也能问的话,就是这一段离开原文以后,还知道自己在说什么吗;表里的数字离开表头,还认得出是什么吗;再看一块里有没有装着两件不相干的事。后来每一轮我们都逐块读原文对照。指标仍然有用,它负责筛出可疑的那几十个块,判断哪一块真的坏了得有人或者模型去读。

左边的验收报表两项全绿,右边相邻两块的切口正落在一句话中间。

好成绩要在新文档上重新拿一次

改完第一轮,我们在两份样本上拿到了很高的分,当时以为这块可以收工了。然后换了六份新文档,接口文档、那份银行 Excel、一张营销需求表,还有几份格式更杂的。分数几乎减半。

减半的地方很具体。有份文档的中文编号标题「一、二、三」是普通文本,不是语法意义上的标题,章节边界因此全部认错,第二到第五部分被挂到了第一部分底下。这个问题在前两份样本上根本不会暴露,那两份文档恰好没有中文编号标题。

前面的好成绩是过拟合,我们调的东西解决的是那两份文档的具体形状。新文档最后逼出了根因级的重做,Markdown 单独走语法树并识别中文编号标题和长分隔线,Excel 直接从表格模型读单元格。

所以验收集要一直往难处换。一套摄入策略在固定样本上的分数会单调上升,其中有相当一部分来自记住了样本。

中文文档有自己的断词问题

型号和故障码经常紧贴着汉字。用户写型号 XX-A6,写出现E3,中间都没有空格。实体识别那一层用的模式按英文词边界写,词边界依赖空格和标点,在这里根本匹配不上。用户问的那个型号压根没有进入检索,系统拿着一句被剥掉型号的问题去搜,搜回来一堆通用说明。

这个坑不报错。日志里没有异常,检索有结果,模型也会礼貌地回答一段。只有把用户原话和实际发出去的检索词并排放在一起看,才能发现型号在中间掉了。修法很简单,把词边界的假设换掉。教训是,一切按英文语料写出来的默认规则,在中文业务文档上都要重新验一遍。

弱相关信号只排序

摄入做对以后,检索层还有一件事要改。

旧逻辑里有一道相关性过滤。配置了关键词集合的租户,命中的文档要跟查询有一半以上的字面覆盖,达不到就把这条结果整个删掉。默认设置允许把结果过滤成零条。

这条规则想挡住明显跑题的召回,可它挡掉的东西比它想挡的多得多。比如用户问制冷效果差怎么办,正确答案那一块写的是压缩机启动异常的排查步骤,字面覆盖接近零。这块被删了,剩下的结果里没有答案,模型只能说不知道,或者拿别的内容凑一段。

我们把它改成了软加权。查询里抽出来的锚点,型号、故障码和实体名,用来给结果加权重排。锚点匹配上的结果往前提,覆盖不足的结果往后压。没有锚点的时候,分数和顺序完全不动。

严格过滤在检索层保留着。权限、租户隔离和明确的业务约束该挡就挡,这些条件的判断是确定的,挡错了能查出来。字面覆盖率这种弱相关性信号是另一回事,它跟答案对不对之间的关系我们没有验证过,在验证之前只让它参与排序。删除不可逆,哪一条真的能用,交给后面的重排和上下文组装去判断。

排序信号与置信信号分离

软加权会改变分数的刻度。原来 0.6 分算中等相关,加权之后同一条结果可能是 0.75。下游有一套判断 AI 有多确定的逻辑,读的是检索分数,用的是绝对阈值。合并前的评审把这一点挑了出来,加权一接进去,所有结果的置信度都往上抬一档,本来该走转人工的对话会被判成高置信度直接回答。修法是让检索响应同时带两个分数,基础分是加权之前的原始相关度,排序分是加权之后的结果,置信判断只读基础分。分数调整之前先问一句,还有谁在读这个数字。

这一步只是让业务加权不再污染原始信号。原始检索分本身也不等于回答正确的概率,能不能据它判断置信度,要结合具体的模型、检索链路和一批标注样本另行验证。那是另一篇文章的题目。

查询计划进入混合检索和重排,锚点只做软加权,再按覆盖率扩展到父块;下方分叉标出基础分供置信判断参考、排序分供排序。

检索看到的东西要比模型的改写更多

多轮对话里,模型会把用户这一轮的话改写成一个独立的检索问句。改写是必要的,用户说它还是不行,直接拿去检索没有意义,可检索层只看得到改写后的句子,用户原话里的细节就丢了,包括那个紧贴汉字的型号。我们改成往下传一份查询计划,里面有用户原话、独立的检索问句和跨轮保留的会话槽位,上一轮用户说过型号,这一轮说它出现 E3,槽位里的型号还在,检索能把两轮拼起来。组装上下文的时候有三层预算,最多几篇文档、每篇最多多少字、总共最多多少字。超预算的时候可以整篇跳过,也可以带明确标记地截断,要避免的是无标记截断,模型必须知道自己拿到的是不是完整文档。残缺的那一篇不带标记进了上下文,模型分不出这是文档本来的样子还是被切掉的。

降级必须可见

最后一件事,我们删掉了一段看起来很负责任的代码。原来的处理路径里有一层自动回退,新的结构理解如果失败,就退回旧的解析和分块逻辑把文档处理完。任务状态是成功,用户看到绿色对勾。

这个设计把失败藏起来了。退回旧路径产出的是碎片,跟前面说的两百多块那种一模一样。同一个知识库里,处理顺利的文档是结构完整的块,处理失败的文档是碎块,两种质量契约并存,界面上它们长得一样。用户查不出来为什么有些文档答得好、有些答不好。回退还会掩盖部署故障。模型没有预热成功,或者子进程超时的配置写错了,这些运维问题都表现为处理成功但效果变差,没有人会去查一个显示成功的任务。

现在解析失败就是失败,明确报错,进重试队列。我们反对的是静默降级。显式的降级不在此列,只要它带明确标记、能被查询、能回滚,用户和运维都知道这一篇是降级处理过的,那就是一个可以接受的设计。过渡期我们留过一个显式的全局回滚开关,整套摄入出问题时统一切回去,新链路稳定之后连这个开关和旧链路一起删掉了。按文件的静默降级从头到尾没有保留。一个知识库只能有一种质量契约,做不到就明确失败,不要用更差的结果冒充成功。

恢复需要的信息要留在库里

回过头看,这段时间改的东西大部分在检索之前。真正需要动脑子的判断几乎都发生在文档进库的那一刻,边界切在哪里,失败了算成功还是算失败。检索层的改动要少得多,而且都是在把之前做错的删掉。

原始结构和定位信息没有随块保留下来的时候,后面的排序补不回它们。只要原文、父块和位置信息还在,系统就可以重新读取、重新组装,这一版切错了下一版还有得救。问题落在摄入阶段有没有保留恢复所需的信息。

对使用知识库的团队来说,这件事最直接的意义是少改资料。产品手册、报价表、培训 PPT 原样上传就能参与服务,不用先有人把它们手工重写成一问一答的格式。入库时保住了关系,资料就能按它原来的样子被用起来。

模型会继续变强,上下文窗口还会涨,今天必须在摄入阶段做对的一些事情,将来也许可以交给模型自己去理。在那之前,这些做法能管多久,取决于库里还剩下多少可供重新组装的原始信息。

这套摄入链路已经合入主线,在内部环境用真实业务文档做过多轮验收。固定问题集上的问答前后对照还没做完,所以这篇文章里的结论都限定在结构完整性上。答得准不准,等那份对照出来再说。

注:本文为 2025 年底的项目实践记录。