第一版是照着教程做的:按固定字数切块、加一点重叠、向量化入库。用测试文档跑得很好,接了真实客户的知识库之后问题成片出现。
一是切出来的块没有语义。「该功能需要企业版授权」被单独切成一块,检索到了也没用——不知道「该功能」指什么。更隐蔽的是指代关系断裂:段落里的「上述配置」「它」跨块之后彻底失去指向,这类块在库里是噪音,还会挤占 top-k 的位置。
二是表格被切烂了。按字数切把表头和数据行分开,切完的数据行完全无法理解——一行数字没有列名。而企业知识库里恰恰有大量表格(价格表、参数对照、权限矩阵)。
三是文档更新之后旧内容还在。这是最严重的一个,也是最难查的。更新逻辑是「按块 ID 覆盖」,但客户改了内容之后分块数量变了——原来 10 块现在 8 块,覆盖了前 8 个,后面 2 个旧块留在库里继续参与检索。表现是「文档明明改了,Agent 还在答旧的」,而你去查库能看到新内容确实在,于是完全找不到原因。
四是同步用文档路径做标识。客户重命名了一个目录,整批文档被判定为「旧的删除 + 新的新增」,全量重新向量化,成本和延迟都爆了,而且中间有一段时间检索不到。
所以第二版重做了四件事:按语义边界切分、给每个块补上结构路径前缀、更新改成整份重建、同步改用稳定外部 ID 并加对账。其中第三条是纯粹的正确性修复,另外三条都是质量提升。
最值得说的判断是第二条:与其把切分算法做得更精细,不如先把丢掉的上下文补回去。结构路径前缀的成本极低(几个字),但它把「该功能需要企业版授权」变成了「产品手册 > 权限管理 > 数据导出:该功能需要企业版授权」,语义立刻完整。这条改动的收益比调切分参数大得多。
整份重建 vs 按块增量,我们选了整份重建。增量在理论上更省——只有变化的段落需要重新向量化。但它要求「块与源内容的对应关系稳定」,而这个前提在真实文档上不成立:用户在中间插入一段,后面所有块的边界都变了,块 ID 和内容的对应关系全乱。为了维护这个对应关系要引入内容指纹匹配、块级 diff,复杂度很高而且一旦算错就是孤儿块。整份重建的代价是重复计算未变部分的向量(有成本但可预期),换来的是确定性——「库里只有当前版本的块」这个性质是靠删除全部旧块保证的,不依赖任何算法正确。这个取舍的原则和库存那边一样:宁可多做一点确定的工作,不要引入一个可能算错的优化。
分块参数按文档类型分开配,不用一套打天下。同一个知识库里的 API 文档、政策条款、FAQ、工单记录,合理的切法完全不同——FAQ 天然一问一答就是一块,API 文档按接口切,政策条款按条款编号切。一套参数必然在某类文档上很差。代价是配置变多、需要人判断文档类型(我们用路径规则加人工标注结合)。但这个成本是一次性的,而切错的代价是持续的。
对账发现不一致后允许自动修复,这和交易系统的做法不同,要说清为什么。交易对账发现偏差绝不自动改数,因为改错方向会造成二次资损。但知识库的重建是幂等且无副作用的——重新解析入库一份文档,最坏结果是还是错的,不会产生新的损害。所以这里可以自动修。唯一的例外是块数异常偏离时不自动修,因为那通常意味着解析器坏了,自动重建会把错误结果固化到库里,反而掩盖问题。「什么情况下允许自动修复」的判据是「修错了会不会产生不可逆后果」,这个判据比「要不要自动修」这个问题本身更值得说。
踩过的坑一:重叠区导致同一段内容被召回两次。相邻块有重叠,用户问的内容正好在重叠区,两个块都被召回,top-k 里有两条几乎相同的内容,白占了一个位置。修法是在检索后做去重——按内容相似度合并高度重叠的候选,保留结构路径更精确的那个。这个坑说明重叠是有代价的,不是免费的保险。
踩过的坑二:解析器升级后没重建,新旧格式的块混在库里。换了 PDF 解析库,提取质量明显变好,但只对新增文档生效,历史文档还是旧解析结果。表现是「老文档答得差、新文档答得好」,排查了很久才想到是解析器版本问题。修法是给每个块记录解析器版本,并且支持按版本批量重建。这条和「模型版本要记录」是同一个道理——任何会影响产出的组件版本都要跟着数据存。
没做的部分:没做图片和扫描件的 OCR 入库。技术上可行,但 OCR 的错误率会引入一类很难处理的问题——识别错的文字看起来是正常内容,检索能召回,答案就是错的,而且无法察觉。当时的结论是宁可不支持,也不要引入不可察觉的错误源。如果要做,必须给 OCR 内容打上低可信标记并在答案里提示。
分块质量怎么测,这是最容易被追问的地方。它没有现成指标,我们用的是可判定的代理指标:抽样一批块,人工标注「这个块脱离原文之后是否可理解」,算可理解比例。标注标准要写清(有明确主体、无未解析的指代、如果是表格行则带表头),否则不同人标出来不一致。报数字时必须说明抽了多少块、谁标的、标注一致性怎么校准——我们的做法是两人独立标一批,比对分歧再统一标准。这套说明比数字本身更能证明你真做过。
孤儿块问题怎么验证修好了:这是正确性问题,用构造用例。造一份文档,第一版切成 N 块,改动内容让它切成 N-2 块,重新入库后断言库里该文档的块数正好是 N-2,且不存在属于该文档的旧版本块。这是个可以静态检查的性质,写成断言最可靠,不需要统计。
对账不一致条目数:报每轮对账发现的绝对条目数(个位数),不报一致率。理由和交易对账一样——一致率的分母(文档总数)会让分子看起来微不足道,反而掩盖问题,而且一被追问统计口径就说不清。还要区分三类分别报:缺失、多余、哈希不一致,它们指向不同的成因。
同步延迟:从客户在源系统保存文档,到该文档能被检索命中的端到端时长。要说清同步任务的触发周期,否则这个数字没有意义——如果是每 10 分钟扫一次,那延迟的下限就是 10 分钟,报「平均 5 分钟」反而说明统计有问题。
入库成功率:按「处理条数 / 成功 / 跳过 / 失败」四个数报,不要只报成功率。「跳过」这一类最需要单独看——它包含了「哈希未变所以跳过」(正常)和「解析为空所以跳过」(异常),混在一起就丢掉了诊断价值。我们踩过的坑就是这两种都被计为跳过,异常被正常淹没了。
第一版是纯向量检索,最省事。上线后三类问题反复出现,而且每一类都不是「调阈值」能解决的。
一是精确匹配全废。用户问「ERR_5032 是什么错误」,检索返回了一堆讲错误处理的通用文档,唯独没有那条错误码的说明。原因是错误码、产品型号、版本号这类标识在向量空间里几乎没有区分度——ERR_5032 和 ERR_5039 的向量几乎一样。企业知识库里这类查询占比很高,纯向量方案在这上面是结构性的短板,不是调参能修的。
二是权限过滤放在了检索之后。先检索 top 20,再按权限筛掉没权限的,剩下几条给模型。问题是某些查询的 top 20 全是别的部门的文档,筛完变成零结果,用户看到「没找到相关内容」,但他自己有权限的那份文档明明存在,只是排在第 30 位。这是个很隐蔽的 bug,因为它只在特定权限组合下出现。
三是用户表述和文档表述的距离。用户说「导不出来数据」,文档写「数据导出功能的权限要求」。语义相关但字面和句式差得远,向量相似度不够高,排在了第八位而 top-k 是 5。用文档原句去检索能召回,用用户的问法召回不到——这个现象是诊断的关键,它说明索引没问题、召回能力也在,差的是查询侧。
所以第二版做了四件事:加关键词召回并按排名融合、把权限过滤下推到查询条件、对排序不准的情况加重排、加查询改写缩短表述距离。四条各自解决上面一个问题,其中第二条是正确性修复,其余是质量提升。
融合选按排名而不按分数,代价是丢掉了分数携带的强度信息。按分数融合理论上更精细——一个相似度 0.95 的结果和 0.75 的结果应该有区别,而按排名它们只是第一和第二。但实际上这个精细度换不来稳定性:两路量纲不同、分布不同、而且分布会随语料变化,调出来的权重在换模型或者换客户之后就不适用了。我们选了「稍微粗糙但不需要维护」的方案。如果某个场景确实需要用到强度信息,做法是在融合之后用重排来精细排序,而不是回去调融合权重——把「精确排序」这个职责交给专门做这件事的组件,比在融合层调参更可控。
查询改写用小模型而不是大模型。改写是个受约束的短任务(把口语变成术语),小模型完全够用,而它在关键路径上,延迟差异直接体现在首 token 上。代价是改写质量偶尔差一些——但因为我们是取并集不是替换,改写差的时候原问题那一路还在,下限有保障。这个「用并集给不可靠的增强环节兜底」的模式值得记,它让我们敢用更快更便宜的组件。
重排做成条件触发,而不是所有查询都走。全量开启的话平均延迟会明显上升,而收益只体现在一部分查询上(排序不准的那些)。判断依据是「召回够但排序不准」这个特征——我们用融合后候选数和分数分布来估计:候选很多且头部分数接近,说明区分不开,值得重排;候选很少或者头部分数明显领先,重排的意义不大。另外高精度场景(比如涉及金额和政策条款的问题)无条件开启,这类问题答错代价高,值得多花几百毫秒。
踩过的坑一:权限过滤下推之后零结果率反而上升了。下推是对的,但下推之后过滤掉的文档不再参与召回,导致某些用户可召回的文档总量很少,达不到 top-k。表现是「权限少的用户经常查不到东西」。这不是 bug 而是真实情况的暴露——他确实没有那些文档的权限。但产品上需要区分「没有相关内容」和「有内容但你没权限」,前者建议换问法,后者应该提示「可联系管理员申请权限」。我们的做法是额外做一次不带权限过滤的计数查询(只取数量不取内容),如果无权限的命中数大于零就给出申请权限的提示。注意只能返回「存在但无权限」这个事实,不能返回任何内容片段或标题,否则又是泄露。
踩过的坑二:改写把专有名词改坏了。用户问的是产品里一个自定义功能名,改写模型不认识,把它「修正」成了一个常见词,两路都召回不到。修法是改写前先做专有名词保护——把从术语表匹配到的词标记为不可改写,改写后校验这些词是否还在,不在就丢弃改写结果。这一条的通用教训是:任何「自动修正」环节都要有一个「不许动的白名单」。
没做的部分:没做基于用户点击和反馈的排序学习。方向是对的(搜索服务那边就是这么做的),但企业知识库的单客户流量太小,攒不够可用的行为数据,而跨客户共享行为数据既有隐私问题也不成立(不同客户的文档和术语完全不同)。这是「同样的技术在不同数据规模下不适用」的例子,值得在面试里说——它显示你判断方案的依据是数据条件而不是技术先进性。
召回质量用标注集测,这里必须说清标注成本。做法是准备一批真实问题,人工标注每个问题的「正确答案所在的块」,然后算命中率(正确块是否出现在 top-k 里)和命中位次。规模要诚实——我们标了两三百条,不是几万条,因为这是人工标注。面试时我会直接说数量级,「标了两百多条」比含糊地说「大量测试」可信得多。
命中率和命中位次要分开报,因为它们指向不同的修法。命中率低是召回问题(去修分块、加关键词路、改写),命中位次靠后是排序问题(上重排、调融合)。只报一个「准确率」把两件事混在一起,就丢掉了诊断价值,而且面试官一追问「那你怎么知道该修哪一块」就答不上。
混合检索的收益要按查询类型拆开报。整体提升可能不明显,但在含硬标识的查询上提升会很大——因为纯向量在这类查询上几乎是全废。我们的做法是把标注集按「含标识」和「纯自然语言」分成两组分别算。不拆开报会严重低估这个改动的价值,也说不清它到底解决了什么。
零结果率:线上直接统计,但要区分三种零结果——真的没有相关内容、有内容但相关度低于阈值被拦、有内容但用户无权限。三者的处理完全不同(补文档 / 调阈值或改写 / 提示申请权限),混在一个指标里没法行动。
延迟要按段拆开量。查询改写、两路召回(并行取最大值)、融合去重、重排,四段各自的耗时。因为它们全在首 token 的关键路径上,哪一段慢就直接体现在用户感知上。我们的口径是分段报而不报一个总数,这样才能说清优化空间在哪。
权限过滤正确性不靠统计,靠红队用例。构造多用户多权限组合的固定用例,断言每个用户召回的块集合严格属于他的可见范围。这类断言必须进回归且不允许失败,因为它防的是最严重的一类事故。
第一版有 RAG 但没有校验,认为「有资料在上下文里模型就不会编」。这个假设错了,三种情况下它照样编。
一是检索没召回到,但模型还是答了。相关度很低的几个块进了上下文,模型从里面找不到答案,就用训练时的常识补了一个。而它的语气和答对时完全一样,用户没有任何办法判断。这是最危险的一类。
二是问题很具体而资料很笼统。用户问「这个套餐的保修期是几个月」,资料只写了「按标准政策执行」。模型不会说「资料里没写」,它会填一个常见的数字。这类错误特别隐蔽,因为答案的形式完全正确。
三是把多个片段的信息串了。A 产品的名称配了 B 产品的参数,两个片段都在上下文里、每个数字都能找到出处,但组合起来是错的。
还有一个不是幻觉但同样致命的问题:业务方改了文档,但答案还是旧的,而没有任何迹象能让人发现。他们只能靠用户投诉才知道,而投诉之前已经错了很多次。
所以第三个模块的核心思路是:不追求让模型不出错,而是让出错能被代码检测出来,且检测不出来的时候默认行为是安全的。三个手段对应上面三类:引用做成结构化且服务端校验(解决第一类,无有效引用就不放行)、关键事实回查(解决第二三类,数字和标识必须能在原文找到)、三道拒答闸(兜住所有检测到问题的情况)。第四个问题用来源与版本回显解决。
最值得说的判断是关于引用的:引用的价值不在于给用户看,而在于它可以被代码校验。如果只是让模型在答案末尾写一句「参考了产品手册」,那是装饰,无法验证。改成输出片段 ID 之后,服务端可以检查这个 ID 是否在本次召回结果里、这个片段是否真的包含答案里的说法——引用从此变成了一个可执行的断言。这个转变是整个模块能成立的基础。
相符性校验选了「字面覆盖」这个粗糙方案,而不是用模型做语义判断。字面检查会漏——答案用同义词改写了原文的说法,字面对不上但语义正确,这种情况检查不出问题(漏判)。用模型判断能覆盖同义改写,但代价是多一次调用(延迟和成本)、而且判断本身也可能错。我们的取舍是接受漏判、坚决避免误判:漏判的后果是「一个错误答案没被拦下」,这还有关键事实回查作为第二道;误判的后果是「正确答案被替换成拒答」,用户会觉得系统很笨,而且这类问题很难被发现(没人会投诉「你本来能答对却说不知道」)。在两类错误里选择容忍哪一类,判据是「哪一类能被后续环节兜住、哪一类会静默积累」。
数字回查的归一化做到什么程度,我们止步于常见格式。中文数字、千分位、货币符号、百分号、日期的几种常见写法都做了;但没做单位换算(原文「一年」答案「12 个月」)和范围推断(原文「3 到 5 天」答案「4 天」)。理由是这些需要理解语义,一旦引入就又回到了模型判断的路上,而且越复杂的归一化越容易反过来产生误判。没覆盖的部分我们的处理是让它走漏判(不标记),而不是尝试用不可靠的方法去覆盖。
拒答阈值不做全局统一,按问题类型分档。涉及金额、政策、承诺的问题阈值调高(宁可多拒),一般性的功能咨询阈值调低(多答一些)。统一阈值必然是个折中,对高风险问题太松、对普通问题太严。代价是要维护类型分类,我们用轻量分类加关键词规则结合,分类错的成本可接受(错到高档只是多拒一次)。
踩过的坑一:事实回查的误判率一开始高到不可用。没做格式归一化,原文「三个月」答案「3 个月」直接判定为编造,正常答案被大量拦下,拒答率飙升。上线半天就回滚了。教训是检测类逻辑上线前必须先在离线日志上跑一遍看误判率,不能直接上线路。我们后来的做法是新增任何校验规则都先跑「影子模式」——只记录不生效,看一周的误判情况再决定是否启用。
踩过的坑二:要求引用之后答案质量反而下降了。模型为了满足「每个论断都要有引用」,开始只说原文里明确写了的话,答案变得零碎、缺少必要的组织和衔接。修法是区分「事实性论断」和「组织性表述」——只要求前者带引用,后者(「下面分三种情况」「需要注意的是」)不要求。约定清楚之后质量恢复了。这个坑说明约束加得太死会挤压掉有价值的能力,要给它留出不需要引用的表达空间。
没做的部分:没做答案的自动置信度评分(给用户一个百分比)。原因是这个分数会被用户当成可信度依据,而我们没有能力让它准确——引用校验通过只说明「有出处」,不说明「理解正确」。给一个不可靠的置信度分数,比不给更糟,因为它会诱导用户放松判断。我们的替代方案是回显来源让用户自己核对,把判断权交回去而不是给一个假的确定性。
编造型错误的验证用构造用例,不用统计。做法是准备一批「知识库里确实没有答案」的问题(比如问一个不存在的套餐的保修期),断言系统走拒答而不是给出一个数字。报法是「这批用例由每轮可复现降至未再复现」——因为这是断言型测试,通过或不通过,不适合报百分比。用「可复现 / 未复现」这个口径比编一个准确率诚实得多,而且面试官能立刻理解你在测什么。
校验规则的误判率必须单独测,这是最容易出事的地方。方法是影子模式:新规则只记录不生效,跑一周线上流量,人工抽查被标记的样本里有多少是误判。误判率的口径要说清——分母是被规则标记的数量,不是总请求量。我们的教训就是没测这个直接上线,半天就回滚了,所以这段经历本身比数字更值得讲。
拒答率要按三道闸分别统计。入口拦(相关度不足)、类型前置(合规转人工)、出口替换(校验失败)。三个数字的诊断意义完全不同:入口拦占比高说明知识库覆盖不够,出口替换占比高说明模型在编造,类型前置占比高说明用户问的敏感问题多。混在一个「拒答率」里就丢掉了全部诊断价值。
拒答的正确性也要测,不能只测拒答率。要区分该拒而拒(正确)、该拒未拒(漏,最危险)、不该拒而拒(误拒,影响体验)。用标注集算后两类的比例。只看拒答率高低是没意义的——拒答率 50% 可能是知识库太差也可能是阈值太严,必须配合正确性一起看。
来源回显的效果不用技术指标衡量,用行为指标。我们看的是「业务方主动报告文档同步问题的次数」——回显上线后这个数字上升了,说明他们真的在看并且能自己发现问题。这是个反直觉的正向指标:报告的问题变多是好事,因为之前那些问题一直存在只是没被发现。这个视角在面试里值得说,它显示你关心的是问题被发现的效率而不是问题数量的账面下降。
没有匹配的内容,换个关键词试试。
项目拆解 · 企业知识库检索增强(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据