首页 / 资讯中心 / 文章详情

RAG知识库问答召回优化实践:从数据清洗到混合检索

RAG知识库问答召回优化实践:从数据清洗到混合检索 ★ FEATURED ARTICLE
这篇AI开发日记20261004记录的是我和一套企业内部知识库问答系统死磕一天的经历。系统上线大半年大模型换了好几版业务方下午两点准时在群里冒出来“这几个问题知识库里明明有答案机器人就是答不对。”我把反馈问题的日志翻了一遍发现真正的原因根本不是大模型不会答而是RAG链路里的召回环节压根没把该用的资料捞出来——模型拿不到对的上下文再聪明也白搭。如果你也正在折腾客服问答、企业内部知识库或者任何基于文档的智能助手被“答非所问”折磨过这篇记录里涉及的定位思路、清洗脚本、切分策略、混合检索和评测方法应该能直接帮上忙。我会尽量按当天的排查顺序来写先分清问题出在哪一层再改数据质量最后调整检索策略并尽量给出可复现的参数和代码。1. 今天在调什么一个“答不对”的问答系统1.1 业务反馈从头说起问题其实不在模型先说清楚这套系统在做什么。它运行在某个企业内部主要材料是产品手册、操作说明和FAQ格式大多是PDF和Word。用户提问后系统先从这些文档里检索相关内容再把命中的文本块拼进提示词交给大模型生成回答。标准的RAG架构听着简单实际跑起来总有各种“意外惊喜”。业务方的反馈很典型“产品在质保期内坏了怎么办这个问题的答案就在手册第X页机器人却让我联系经销商。我们明明写了质保期内换新政策它为什么不引用”我看着这条反馈第一反应也是怀疑提示词写得不到位或者模型指令理解偏了。但当我把同一条问题连续抽了几个版本日志之后基本可以断定问题不在模型生成阶段。为什么从日志可以看到系统实际拿到的检索内容。那条关于质保的问题知识库里有明确条款但是检索接口返回的前五名里没有它。排名第一的文本块是“服务网点列表”分数0.78真正包含“换新政策”的块排在第七位分数只有0.65。我的检索逻辑设置是只取前Top 5所以那段关键内容根本没进入大模型的上下文。模型能看到的全是网点地址和客服电话自然只能回答“请联系售后”。这就像去餐厅吃饭厨师手艺再好备菜的人把该用的食材放在角落里没递到灶台上出锅的菜肯定不对味。**RAG场景里的“备菜环节”就是检索链路大多数企业级问答做不好问题往往出在备菜而不是炒菜。**想通这一点后面的排查方向就清楚了。1.2 日志里藏着真相先分清“没查到”还是“答错”排查这类问题我一般不看“回答文本”先看三样东西检索接口返回的文本块列表和相似度分数最终拼进提示词的上下文内容用户原始提问是否经过改写或意图识别这次逐个看下来发现有个明显的规律**凡是涉及具体型号、条款编号、发票金额、换货条件这类精确信息的问题召回质量就崩回答自然跟着崩。**比如用户问“产品SN码在哪里看”知识库原话是“SN码位于机身底部铭牌”但系统召回的却是“产品连接步骤”。这已经不是语言组织问题而是检索到的内容压根不对。这一步排查的关键是把问题从“模型答错了”拆成“模型没拿到材料所以答错”。如果日志里检索到的上下文是对的回答还不对那才需要去调提示词、换模型、调温度参数。反之如果上下文本身就是错的后面全是白费功夫。我处理过的RAG项目里至少有八成“答非所问”属于后者。2. 分锅大会问题出在RAG链路的哪一环2.1 从原始文档到chunk链路里最容易翻车的三个环节确定问题在检索侧之后还得继续往下拆。因为“检索结果不好”本身是个笼统结论可能是原始文档解析出错也可能是文本切分把语义切碎了也可能是Embedding模型选得不对甚至可能是召回策略不匹配。我把这条链路按职责拆成六个环节链路环节主要职责常见翻车点文档解析从PDF/Word提取纯文本表格错乱、页眉页脚混入正文、扫描件乱码文本清洗去掉噪声、修正解析错误误删正文、全半角混乱、断行没合并文本切分把长文档切成适合检索的块固定字符切分切断语义、表格被拆行向量化把文本块编码成向量模型太小、领域术语表征不稳定索引与检索从向量库/关键词库召回候选召回策略单薄、只靠向量漏精确词重排与提示拼装对候选排序并构造上下文Top K太小、没有重排、上下文超长被截断用户反馈的“质保期内坏了怎么办”这个case一开始我怀疑是解析问题因为原始手册是PDF里面大量使用表格。把PDF导出来一看果然“质保条款”那张表格被解析成了纯文本每一行都变成一个孤立文本块表头和换行全丢了。这种数据进检索库查得到才奇怪。2.2 用“期望命中块”对比“实际命中块”来定位定位问题不能靠猜。当天下午我先拉了最近两周的高频失败问题挑出20条然后人工去知识库原文里找“如果我是系统应该检索到哪一段”。这个“应该命中的文本块”就是基准答案拿它和系统实际返回的文本块做对照问题在哪一层立刻现形。举一个当天最典型的例子。手册里有一张表格四列分别是“故障类型”“处理方式”“所需材料”“备注”。其中一行写着“产品在质保期内出现非人为损坏可凭发票原件办理换新”。我的切分逻辑是按固定字符长度切刚好把这个表格按行切散每一行变成了独立chunk。用户问“换新需要带什么材料”系统召回的chunk是“备注请先联系售后确认”另一行“所需材料发票原件”反而没有进入Top 5。因为语义相近但信息不完整模型只能根据“联系售后”几个字生成回答。这就是典型的“切分把语义切碎了”。当一张表格的语义要靠多行组合才能表达时切分策略必须保留表格的整体性否则检索精度天然打折。那天我手工把所有“期望命中块”和“实际命中块”做了个对照表发现20条问题里有14条的“实际命中块”都不完整其中有8条问题都可以归结为“表格被拆散导致”。2.3 再往下挖为什么语义向量会漏掉精确关键词表格切碎是表层原因。继续往下想还有一个更深层的问题为什么那些切得还算完整的文本块有时候也使不上劲比如“SN码位于机身底部铭牌”这种句子按常理语义检索应该能匹配“SN码怎么看”才对但实际召回得分很低。这跟Embedding模型的工作方式有关。向量检索的核心是把文本投射到高维语义空间计算相似度时语义相近但字面完全不同的句子也能排到一块。这本是优势但它带来的问题是词面信息被均匀化稀释了。越是罕见、越是专业化的精确词比如“SN码”“铭牌”“质保卡”在向量空间里的表征越不稳定。训练语料里这类词出现频率低编码出来容易和周围词混在一起加上公司内部文档还有自己的一套黑话通用开源向量模型根本没见过几次自然召回不准。于是在那一层的结论变得很清楚不能只靠向量检索一条腿走路。要解决精确词漏召回需要把传统的词法检索类似BM25那一类擅长精确匹配关键词也拉进来两者并行召回再融合排序。这也是当天下午决定动手改造的核心方向。3. 先改数据清洗和分割是第一生产力3.1 PDF文档清洗别让脏数据毁掉Embedding上午定位完问题下午第一件事不是调模型而是先处理数据。很多团队遇到召回差第一反应就是“换个更强的Embedding模型”其实脏数据没处理干净换什么模型都白搭。当天我写了一个针对PDF知识库的清洗脚本专门处理几类高频噪声页眉、页脚、页码混入正文换行符把中文句子硬生生拆成两段全角空格、制表符混入PDF表格解析后行列混乱import re def clean_kb_text(raw: str) - str: # 1. 去掉页码和页眉页脚这里按实际格式调整 text re.sub(r第\s*\d\s*页, , raw) text re.sub(r共\s*\d\s*页, , text) # 2. 合并中文内容里被换行断开的句子 # 只在前后都是中文字符时合并避免把标题和正文黏在一起 text re.sub(r([\u4e00-\u9fa5。、])[\n](?[\u4e00-\u9fa5]), r\1, text) # 3. 去掉多余空行和分页符 text re.sub(r\n\s*\n, \n\n, text) text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f], , text) # 4. 统一全角空格 text text.replace(\u3000, ) # 5. 去掉表格解析常见的“|”分隔噪声保留内容本身 text re.sub(r\|\s*\|, | , text) return text.strip()注意清洗规则最忌讳贪多。我见过有同学把“客服”替换成“服务”想把词统一结果把品牌名里的“客服系统”改得面目全非反而破坏召回。清洗的目标是把解析噪声降低而不是做语义改写每一行规则都应该是可解释、可回滚的。3.2 从“按字数切”换成“按结构切”清洗完文档紧接着改切分策略。原来的切分逻辑很粗暴固定每个块512字符块与块之间没有重叠。好处是实现简单坏处也明显——语义边界完全被无视。当天我改成了一套基于结构的切分思路大致规则有三条按标题、段落边界优先切分一个完整段落尽量放在同一个块里超过设定长度的段落再按句子边界切开并让相邻块保持一定重叠表格单独识别一张表尽量放进一个块表头作为摘要前缀保留参数上我把默认的块大小定在512字符重叠区定为64字符。为什么是512因为块太小语义残缺块太大检索时噪声多512字符对中文文档来说大约能覆盖三四段话适合大多数场景。重叠区64字符是为了避免一句话在边界被切开让前后块都有机会包含完整上下文。还有一个容易忽略但非常重要的细节**每一块要带标题前缀。**比如“质保条款”那一章系统切出来第一个块是章节标题加表格内容后面被切成的几个块如果裸奔出来检索时只知道内容是某种条款但不知道出自哪个章节。当天改造后每个文本块生成时会带上最近的标题层级比如“产品手册质保政策换新材料”。这一步看似不起眼但对提升Recall作用非常明显模型拿到带上下文的块之后回答质量整个上了一个台阶。4. 检索改造向量关键词混合检索别再做单一选手4.1 为什么纯向量检索会漏掉“准确词”数据侧改完表格被拆散的问题解决了一部分但精确词漏召回的问题仍然存在。我拿测试问题“SN码在哪个位置”去跑向量检索发现系统还是倾向返回“设备连接”“蓝牙配对”这类语义上相似但实际不是目标答案的块。打个比方向量检索像是一个“翻译后再比对意思”的流程你说“SN码在哪里”它去语义空间里找“位置”“编码”“识别”这些概念相近的内容而BM25这类词法检索像“对暗号”直接看文档里有没有出现“SN码”三个字。前者的强项是可以理解“换新要什么材料”和“质保期内换机手续”是同一个意思后者的强项是碰到“SN码”“型号A2X”这种精确词时命中率极其稳定。这两者不是替代关系而是互补关系。只做向量检索相当于只派一个翻译去认人遇到脸熟的能认出来遇到名字极其特殊的就抓瞎。只做词法检索虽然能认出特殊名字但用户换个说法就完全没招。所以当天的核心改造方向就是混合检索。4.2 混合检索的具体做法BM25和向量召回并行RRF融合当天下午我先在检索服务里同时保留两路召回一路走向量语义检索按余弦相似度取Top 20另一路走词法检索把字段做分词后按 BM25 打分取 Top 20。两边并行然后把两个列表合并排序。合并排序没有用简单的加权求分因为向量相似度和BM25分数不是同一个尺度直接相加很别扭。我采用的是RRFReciprocal Rank Fusion融合逻辑非常简单每个文档在某个列表里排第几名就加一个1/(krank)的分值所有列表累加后重新排序。def rrf_fuse(rank_lists: list[list[str]], k: int 60) - list[str]: 输入多个召回列表每个列表按相关度从高到低排列。 返回按 RRF 分数融合后的结果列表。 scores: dict[str, float] {} for rank_list in rank_lists: for rank, doc_id in enumerate(rank_list, start1): scores[doc_id] scores.get(doc_id, 0.0) 1.0 / (k rank) sorted_items sorted(scores.items(), keylambda x: x[1], reverseTrue) return [doc_id for doc_id, _ in sorted_items] # 检索入口 def hybrid_retrieve(query: str, top_k: int 20) - list[str]: vector_hits vector_search(query, top_k20) # 语义召回 bm25_hits keyword_search(query, top_k20) # 词法召回 return rrf_fuse([vector_hits, bm25_hits])[:top_k]这里k取60是业界常见的经验值它决定了排名带来的分值衰减速度。k越小第一名对最终结果的“话语权”越大k太大排名先后就没区别了。实测下来60这个值在两个列表规模相等时比较稳不容易让单一列表的头部排名霸榜。之所以用RRF而不是直接把两个分数标准化后加权是因为RRF对分数尺度不敏感几乎不需要调权重。向量分数在0到1之间但分布往往集中BM25分数动辄十几二十直接加权的话权重得调半天。RRF只看排名天然规避了这个问题。这也是很多检索项目宁可少一个“最优化”也要选它的原因工程上稳定、好解释。4.3 权重不是拍脑袋一个小成本评测集混合检索上线前我先搭了一个小评测集。方法不复杂从业务方反馈的高频问题里抽了50条每条人工标注“期望命中的知识库文本块ID”。比如“SN码在哪个位置”对应“产品手册设备外观SN码位置”这个块“质保期内换新需要什么材料”对应“质保政策换新材料清单”这个块。然后用Recall20来评估对每条问题跑检索看期望命中的块是否出现在返回的前20个结果里。这个指标不需要评估回答文本只评估“材料有没有被捞出来”十分贴合当前阶段的排查目标。当天调参记录大致如下配置切分方式检索方式Recall20原始基线固定512字符无重叠纯向量检索61.3%清洗结构切分段落/表格感知重叠64纯向量检索72.6%清洗结构切分段落/表格感知重叠64向量BM25RRF融合83.7%从61.3%到83.7%提升相当明显。不过我得提醒一句50条样本的评测集规模不大涨了22个百分点并不代表线上一定也涨这么多但它至少说明方向是对的。我当天没有继续追求更高的离线指标而是停下来把评测样本新增了几条容易混淆的问题防止后面调参调过头反而把通用性破坏掉。5. 线上小流量的验证与收尾5.1 离线和线上效果差异离线评测集上效果好不代表线上立刻就好。这里有个很常见的误解以为离线指标涨了线上用户抱怨就会马上消失。实际上离线评测集是静态的线上用户提问每隔几天就可能出现新说法而生产环境还有并发、超时、向量索引更新延迟等因素。当天的做法是加了一个线上的检索配置开关让新检索引擎只承接10%的流量剩下90%继续走原有逻辑然后连续跑72小时观察两个指标召回日志里“无有效上下文”的比例以及业务方手动标记的差评率。用小流量验证既避免一次性切换导致问题爆发又能获得相对可信的对比数据。这里也要提醒**小流量对比要尽量保证同一个用户问题不会因为路由分流产生不同结果否则业务方会把两边答案截图发到群里场面会比较尴尬。**我们当时的做法是固定按用户维度分流同一个用户始终走同一个检索逻辑。5.2 过拟合评测集的坑调参那天遇到一个特别典型的陷阱不加这句等于白写。下午我有条测试问题怎么调都返回不到理想位置越看越急于是手动改了一条切分规则单独处理这种问法。改完之后那条测试用例确实过了但跑一遍完整评测集总体Recall反而掉了两个点。新规则对个例友好对整体有害。这就是典型的过拟合评测集。评测集的目的是帮你发现共性规律不是让每条query都精准命中。为了单条问题去加规则往往会把另外一批正常case搞坏。后来我学乖了再调参时会固定一组“盲测样本”不参与调参只在最后统一评测。当天留下的教训是调检索参数先把评测集跑一遍记录基线每改一个变量只跑评测集不要凭一两条case的直觉判断好坏。5.3 当天另外记下的两条待办当天收尾时我在开发日志里补了两条待办现在也分享出来供同样在做知识库问答的朋友参考。第一条是重排环节。混合检索把候选范围扩大之后Top 20里面还是会有不少噪声下一步应该加一个轻量级重排模型对候选精排把真正有用的块顶到前面。RAG效果好本质上是“检索不漏排序精准”的组合我只完成了前一半。第二条是用户反馈闭环。现在的评测集是离线人工标注但知识库会持续更新用户也会不断产生新的奇怪问法。最理想的方案是在前端加一个“回答是否有帮助”的按钮把差评case自动沉淀到评测集里每周更新一次让检索质量可以持续迭代。没有反馈闭环的检索系统上线三个月后往往又会退化到原来的水平。这天的开发经历让我特别感慨的一点是**做AI应用真正费时间的往往不是模型本身而是围绕数据、检索、评测做的一堆脏活累活。**把这些基础环节打磨扎实比盲目换一个更大的模型管用得多。如果你也在被类似问题困扰不妨从日志排查开始先分清是“没查到”还是“答不对”再对症下药。改召回参数之前先把评测集搭起来这点经验值回票价。
阅读完成 · 觉得有帮助?
咨询建站