引言:Demo 到上线之间,隔着的全是检索
几乎所有团队做知识库问答都经历过同一条曲线:接上向量数据库、塞进几百篇文档、用大模型跑一遍,效果惊艳;
等文档量涨到几万篇、用户开始问真实问题,准确率断崖式下跌。
问题的根源往往不在生成端。把错误回答拆开看,80% 以上是检索没把正确片段拿回来,或者拿回来了但排在第 30 名被截断掉。生成模型只是在噪声里做了合理推测——它没错,是我们的上下文错了。
这条曲线背后其实是一条很朴素的漏斗:假设索引阶段召回率是 0.95,检索阶段 Recall@5 是 0.7,重排后进入 prompt 的 top-5 命中率是 0.85,生成阶段还有 0.9 的忠实度,那么端到端准确率就是 0.95 × 0.7 × 0.85 × 0.9 ≈ 0.51。每一环看起来都"还行",乘起来就塌了。而其中乘数最小、最容易优化的,恰恰是检索这一段——它不像生成那样受限于模型能力,改一个切分策略、加一路召回,指标立刻就能在评测集上看到反馈。
本文基于一个真实落地的项目(约 3.2 万篇产品文档与工单,切分后 12 万 chunk,评测集 800 条真实用户问题)复盘检索链路的三段核心环节:切分策略、混合检索、重排,并给出可复现的对比数据。项目背景补充一句:文档来源包括 Confluence 导出、工单系统 API、以及一部分 PDF 转 Markdown 的运维手册,语言以中文为主、夹杂大量英文术语与代码片段,这也决定了后面很多策略的选择。核心结论先放在前面:
结构感知切分 + 父子块(Small-to-Big)+ BM25/向量 RRF 混合召回 + Cross-Encoder 重排,是当前性价比最高的一条链路。在端到端答案准确率上,它把基线从 61% 拉到 85%,而额外成本只有约 150ms 延迟。
需要说明的是,这条链路并不追求"每个环节都用最强模型",而是追求每个环节都解决一个明确的失效模式。下面按索引、召回、精排的顺序逐一拆解。
一、切分策略:切分是语义边界问题,不是长度问题
1.1 为什么固定窗口切分注定失败
固定 512 token 切分的问题不在于"切得不准",而在于它会把一个完整语义单元劈成两半,然后分别污染两个 chunk。一个 FAQ 的答案被切开,前半段没有结论、后半段没有前提,两段在向量空间里都是"半真半假"的向量,谁都不像正确答案。
从原理上看,embedding 模型通常对 token 向量做池化(CLS 或均值池化)得到句向量。一个 chunk 里混进了半截无关内容,池化后的向量就被"稀释"了——它既不完全像问题,也不完全像答案,落在向量空间里一个谁都不挨着的位置。更糟的是,这种稀释是不可逆的:你无法从向量反推出哪部分是噪声。
更隐蔽的问题是上下文丢失:一段正文被切出来后,脱离了它所属的章节标题,就成了"该参数默认为 30 秒"这种完全无法检索的孤立句子。用户在问"重试间隔怎么配置"时,这个 chunk 里既没有"重试"也没有"配置",向量匹配自然落空。
我们在复盘时还发现两类典型的"被切坏"的片段:一是表格,一行表头配几行数据,被切到两个 chunk 后,后一个 chunk 只剩一堆没有列名的数字;二是代码块,一段 40 行的配置示例被从中间截断,前半段是注释、后半段是参数,谁都不完整。这两类内容在技术文档里占比不低,固定窗口对它们的破坏几乎是系统性的。
1.2 四种策略的实测对比
我们在同一份 800 条评测集上对比了四组切分方案(检索统一使用同一个 embedding 模型与向量库,排除变量):
| 策略 | 配置 | Recall@5 | MRR | 平均 chunk 长度 |
|---|---|---|---|---|
| A. 固定窗口 | 512 token, overlap 50 | 0.62 | 0.41 | 512 |
| B. 递归字符切分 | 800 / overlap 100 | 0.68 | 0.47 | 640 |
| C. 结构感知切分 | 按 Markdown 标题层级 | 0.74 | 0.55 | 780 |
| D. 结构感知 + 父子块 | 子块 300,父块整节 | 0.79 | 0.63 | 子 300 / 父 1500 |
逐行解释一下差距的来源:B 比 A 好,是因为递归切分优先在段落、句号处断开,减少了"半句话"chunk;C 比 B 好,是因为它把标题路径拼进了正文,让每个 chunk 自带"我在讲什么"的上下文,同时尽量保证一节内容不被拆散;而 C 的问题在于一节太长——有的章节有 3000 字,整节做向量会稀释语义,检索精度反而下降。
策略 D 的关键在于双层索引:用小的子块(300 token)做向量检索,命中后返回它所属的完整父块(整个章节,约 1500 token)给大模型。小块 的向量更聚焦、区分度更高;父块保证喂给 LLM 的上下文完整。这一招在我们的场景里单独贡献了 5 个点的 Recall@5。
参数选择上也有经验可循:子块 300 token 是"能表达一个完整问答对"的下限,再小就会丢掉前提;父块控制在 1500 token 左右,是因为再大就会挤占 prompt 里其他候选的位置,而且超出多数 reranker 的有效窗口。如果某节确实超长(比如 4000 字),我们会退化为"按二级标题再切一层",而不是无脑放大父块。
1.3 代码:结构感知 + 父子块切分
importrefromdataclassesimportdataclass,fieldfromtypingimportListfromlangchain_text_splittersimportRecursiveCharacterTextSplitter HEADING_RE=re.compile(r"^(#{1,4})\s+(.*)$")@dataclassclassChunk:chunk_id:strtext:strparent_id:strmeta:dict=field(default_factory=dict)defsplit_by_heading(md:str)->List[tuple]:"""按标题层级切 section,返回 [(标题路径, 正文), ...]"""stack,buf,sections=[],[],[]forlineinmd.splitlines():m=HEADING_RE.match(line)ifm:ifbuf:sections.append(([tfor_,tinstack],"\n".join(buf).strip()))buf=[]level=len(m.group(1))# 用栈维护标题层级:遇到同级或更高级标题时回退# 这样即使出现 # -> ### 的跳级,路径也能正确拼接whilestackandstack[-1][0]>=level:stack.pop()stack.append((level,m.group(2).strip()))else:buf.append(line)ifbuf:sections.append(([tfor_,tinstack],"\n".join(buf).strip()))returnsectionsdefbuild_chunks(doc_id:str,md:str):child_splitter=RecursiveCharacterTextSplitter(chunk_size=300,chunk_overlap=40,# 分隔符从粗到细:段落 -> 行 -> 句号 -> 分号 -> 空格# 中文场景下"。"和";"必须显式加入,否则会按空格硬切separators=["\n\n","\n","。",";"," "])parents,children={},[]foridx,(path,body)inenumerate(split_by_heading(md)):ifnotbody:continueparent_id=f"{doc_id}#sec{idx}"# 关键:把标题路径拼回正文,让每个 chunk 自带上下文prefix=" > ".join(path)parents[parent_id]=f"【{prefix}】\n{body}"forj,subinenumerate(child_splitter.split_text(body)):children.append(Chunk(chunk_id=f"{parent_id}#c{j}",text=f"【{prefix}】\n{sub}",# 子块也带标题前缀parent_id=parent_id,meta={"doc_id":doc_id,"section":prefix,"level":len(path)},))returnparents,children这里有两个容易忽略的细节:标题路径要拼进 chunk 正文(而不是只放进 metadata),因为 embedding 模型看的是文本;父块的 text 与子块的 text 都要保留,检索时用子块,组装 prompt 时用父块。
还有三个实战中必须处理的边界情况:第一,无标题文档(比如纯文本工单)走split_by_heading会返回一整段,此时要 fallback 到递归切分,并自动补一个"文档标题"作为 prefix;第二,表格与代码块要整体保护,可以在切分前把 ```代码块和连续|表格行替换成占位符,切完再还原,避免被拦腰截断;第三,标题路径过长时(比如四级标题拼起来 60 个字)要做截断,否则 prefix 本身就会淹没正文的语义信号。
二、混合检索:稀疏与稠密失效的场景完全互补
2.1 谁在什么时候失效
纯向量检索在我们的工单库里错得很有规律:
- 用户问「
ERR_4021是什么错误」——向量模型把编号当成无意义 token,召回一堆不相关的错误码文档; - 用户问「
kafka.max.poll.records怎么调」——点号和下划线被 tokenizer 打散,语义漂移; - 用户问「V2.3.1 到 V2.4.0 的升级注意事项」——版本号几乎完全依赖字面匹配。
这三类问题的共同点是查询的核心信息是一个"低频字面量",而稠密向量天生对低频 token 不敏感:它在预训练时见过太多"错误码"的泛化语义,却很难记住ERR_4021这个具体串和哪篇文档相关。反过来,BM25 基于词频和逆文档频率(IDF),恰好会给这类稀有 token 极高的权重——ERR_4021在整个语料里只出现在两篇文档中,IDF 巨大,一匹配就顶到最前。
而纯 BM25 的失效场景同样明确:用户用口语描述「上传文件一直转圈最后失败」,文档里写的是「大文件分片上传超时导致上传中断」,字面零重叠。BM25 在这里几乎召回为零,而向量模型能凭"上传失败"的语义近似把它捞回来。
这两类失效几乎不重叠,所以混合检索不是"锦上添花",而是必需项。实测中,BM25 单独 Recall@5 = 0.58,向量单独 0.71,混合后 0.83。注意 0.83 明显高于两者简单取并集的期望——因为两路的排序互补,融合后原本各自排在第 6~10 名的正确答案被顶进了前 5。
2.2 融合方式:优先 RRF,慎用加权分数
两种常见融合:
- 加权分数融合:
score = α·vec + (1-α)·bm25。问题在于两路分数分布完全不同(余弦相似度集中在 0.6~0.9,BM25 分数是无上界的),归一化方式一变,α 的最优值就漂移,线上极不稳定。 - RRF(Reciprocal Rank Fusion):只用排名不用分数,
score(d) = Σ 1/(k + rank_i(d)),k 常取 60。鲁棒性远好于分数融合,我们线上直接用 RRF。
RRF 的妙处在于它天然抹平了分数尺度的差异:不管某一路给的是 0.87 还是 23.5,只要排名第 1,贡献就是 1/(60+1)。k=60 这个经验值的含义是"让前几名的分数差距足够平缓",避免某一路的 top-1 直接压倒另一路的所有结果;如果 k 取太小(比如 1),排名波动会被放大,融合反而不稳。我们在离线评测里试过 k ∈ {10, 30, 60, 100},60 附近确实是平台区,最终固定为 60。
defrrf_fuse(rankings:list[list[str]],k:int=60,weights=None)->list[tuple]:"""rankings: 多路召回的 doc_id 列表(已按相关性降序)"""weights=weightsor[1.0]*len(rankings)scores={}forw,rankinginzip(weights,rankings):forrank,doc_idinenumerate(ranking,start=1):# 用 1/(k+rank) 累加,排名越靠前贡献越大,但差距被 k 拉平scores[doc_id]=scores.get(doc_id,0.0)+w/(k+rank)returnsorted(scores.items(),key=lambdax:-x[1])# 用法:两路各召回 50 条,融合后取 top 30 交给重排vec_hits=vector_store.search(query,top_k=50,where={"tenant":tid})bm25_hits=bm25_index.search(query,top_k=50,where={"tenant":tid})fused=rrf_fuse([[h.idforhinvec_hits],[h.idforhinbm25_hits]])[:30]注意where过滤条件必须在召回阶段下推,而不是召回后再过滤——否则多租户场景下 top-50 可能被别的租户占满,召回直接归零。这是我们踩过的最贵的坑之一。当时线上突然出现某租户"什么都搜不到",排查半天才发现是向量库的过滤放在了后置阶段,而该租户的文档只占全库 2%,top-50 里一条都进不来。修复方式很简单:把tenant作为向量库的标量过滤字段,在 ANN 检索时一并传入。
2.3 查询改写:低成本的一路补充
对短查询(< 8 字)额外做一次 LLM 改写扩写,再走一遍向量检索,作为第三路参与 RRF,Recall@5 还能再涨约 2 个点。但要注意改写引入的延迟(约 300~600ms),我们只对短查询和低置信度结果触发。
改写的 prompt 也很关键,不要让它自由发挥,而是要求"补全领域术语、保留原始编号、输出 2~3 个变体"。比如用户输入"证书过期怎么办",改写后得到"TLS 证书过期 续期 更新 流程"和"客户端证书有效期到期 报错 处理",这样原本因为口语化而漏召回的文档就能被捞回来。为了避免改写把原查询的意图带偏,我们始终保留原始查询作为一路,改写结果只做补充、不做替换。
三、重排:整条链路里 ROI 最高的一环
3.1 为什么必须有重排
双塔向量检索的本质是 query 和 doc 各自独立编码后算内积,两者在编码时互相看不见。这意味着模型在编码文档时,根本不知道用户会问什么,只能把"这段文本大概讲什么"压进一个固定维度的向量里。信息在这里被有损压缩了。
而 Cross-Encoder 把[query, doc]拼在一起送进模型做全交叉注意力,能捕捉"这个词在这句话里到底指什么"。比如 query 是"配置超时",doc 里同时出现"连接超时"和"读取超时",双塔只能给一个笼统的相似度,交叉编码器却能判断出这段讲的是"连接超时"、和 query 更相关。
代价是它不能预计算,只能对候选集逐条打分,复杂度从双塔的 O(N) 变成 O(N×L)(N 是候选数,L 是文档长度)。所以必须两级架构:召回负责从 12 万降到 30~50,重排负责从 50 精排到 5。这个"先粗后精"的漏斗是工程上唯一可行的做法——如果直接对 12 万篇做交叉编码,单次查询要几分钟。
实测数据(同一份混合召回结果,取 top-50):
| 阶段 | Recall@5 | nDCG@10 | 端到端答案准确率 | 检索延迟 P95 |
|---|---|---|---|---|
| 仅向量召回 | 0.71 | 0.52 | 61% | 45 ms |
| + 混合召回(RRF) | 0.83 | 0.61 | 74% | 90 ms |
| + 重排 top-50 | 0.91 | 0.76 | 85% | 240 ms |
重排带来的 nDCG 提升(0.61 → 0.76)比召回阶段任何单项优化都大,而延迟只增加 150ms——这是整条链路里性价比最高的一次投入。
3.2 模型选型与工程细节
我们对比了bge-reranker-v2-m3、bge-reranker-base与商用 API。结论是:中文技术文档场景下 bge-reranker-v2-m3 已经够用,base 版本快 3 倍但 nDCG 掉 4 个点;商用 API 效果略好(+1.5 nDCG)但引入网络依赖和数据合规问题。
fromFlagEmbeddingimportFlagReranker reranker=FlagReranker("BAAI/bge-reranker-v2-m3",use_fp16=True)defrerank(query:str,docs:list[str],top_n:int=5,threshold:float=0.0):# 长文档必须截断,否则 batch 显存爆炸;实测 1024 token 足够pairs=[[query,d[:1024]]fordindocs]# compute_score 内部按 batch 前向,返回每条 pair 的相关性分数scores=reranker.compute_score(pairs,normalize=True,batch_size=16)# 分数阈值过滤:低于阈值的直接丢弃,宁可少给上下文也不喂噪声ranked=[(d,s)ford,sinzip(docs,scores)ifs>=threshold]ranked.sort(key=lambdax:-x[1])returnranked[:top_n]几个工程细节值得展开。截断方向:我们截的是文档前 1024 token,因为结构感知切分后,标题路径和结论通常都在前面;如果文档是"先铺垫后结论"的写法,截尾可能更合适,这要看语料习惯。batch_size:16 是在单张 A10 上压测出来的平衡点,再大显存吃紧、再小吞吐上不去,P95 延迟会从 150ms 涨到 300ms 以上。阈值过滤:当所有候选分数都低于阈值(比如 0.05)时,说明知识库里确实没有相关内容,此时应当走"拒答/转人工"分支,而不是硬塞 5 条噪声给大模型——这是把幻觉率压下去的关键一步,我们在线上把拒答率控制在 8% 左右。
3.3 重排的踩坑与优化建议
实际落地中,重排这一段最容易出问题的地方不在模型,而在输入的组织方式。第一条经验:送进重排的候选必须先按 chunk_id 去重,因为混合召回时同一个父块可能被多个子块命中,不去重就会让同一段内容占据多个精排位,白白浪费候选名额。第二条:重排的输入最好用子块文本而非父块,子块短、聚焦,交叉编码器判断更准;等精排出 top-5 后,再按parent_id换成父块拼进 prompt。第三条:重排模型要和 embedding 模型保持"同源",我们一开始用某个英文 reranker 配中文 embedding,nDCG 反而比不重排还低,换回 bge 系列后立刻恢复正常——跨模型家族的语义空间不一致,会让精排打分失真。
优化上还有两个低成本手段:一是对 query 做缓存,热门问题的重排结果缓存 10 分钟,命中率能到 20% 以上,直接省掉这部分延迟;二是按候选数动态调整 batch,召回 50 条时分两批、召回 30 条时一批跑完,避免小请求也要等满一个 batch。这些细节单看都不起眼,但叠起来能让 P95 延迟稳定在 240ms 以内,用户体验上就是"问完几乎立刻出字"。
四、落地清单与常见问题
把上面的链路串起来,上线前建议对照这份清单逐项检查:
- 切分:是否保留标题路径进正文?表格/代码块是否被保护?是否有无标题文档的 fallback?
- 索引:父子块是否分开存储、用 parent_id 关联?子块向量是否独立建索引?
- 召回:BM25 与向量是否都做了租户过滤下推?两路 top_k 是否一致?RRF 的 k 是否固定?
- 重排:候选是否去重?是否设了分数阈值与拒答分支?截断长度与 batch 是否压测过?
- 评测:是否有固定的离线评测集?每次改动是否跑同一套 Recall@5 / nDCG@10 / 端到端准确率?
几个高频问题也一并回答。Q:父子块会不会让存储翻倍?会,父块文本大约多占 1.3 倍空间,但相比向量索引本身的开销可以忽略,我们 12 万 chunk 的父块文本不到 2GB。Q:RRF 之后还要不要保留原始分数?建议保留在 metadata 里用于排查,但融合决策只看排名。Q:重排能不能省掉?在小语料(< 5000 chunk)和简单问题上可以,一旦上量,重排几乎是唯一能把 nDCG 拉上 0.7 的手段。Q:查询改写会不会引入延迟抖动?会,所以只对短查询和低置信度结果触发,并设了 800ms 超时兜底。
最后说监控。
更多硬核网安与AI工具包,请扫码获取完整源码!
离线指标之外,线上我们重点盯四个数:点赞率、点踩率、拒答率、检索延迟 P95。点踩率突然上升,八成是某批新文档切分异常;拒答率飙升,通常是重排阈值配高了或召回为空。把这三个环节的指标拆开埋点,出问题时才能一眼定位是"没召回"还是"排错了",而不是笼统地归咎于"大模型不行"。检索链路的价值,恰恰在于它是可测量、可归因、可迭代的——这也是它比换模型更值得投入的原因。
更多硬核网安与AI工具包,请扫码获取完整源码!
离线指标之外,线上我们重点盯四个数:点赞率、点踩率、拒答率、检索延迟 P95。点踩率突然上升,八成是某批新文档切分异常;拒答率飙升,通常是重排阈值配高了或召回为空。把这三个环节的指标拆开埋点,出问题时才能一眼定位是"没召回"还是"排错了",而不是笼统地归咎于"大模型不行"。检索链路的价值,恰恰在于它是可测量、可归因、可迭代的——这也是它比换模型更值得投入的原因。