上次那版 AI 驱动文档搜索系统上线后,我们团队内部的反馈基本可以总结成一句话:能搜到,但搜不准。同事找合同编号搜不到,问"上个季度研发部请假审批流程"返回一堆无关文档,检索质量远没达到接入大模型之前的预期。这篇文章记录的是我针对这套系统做的第二轮完整迭代:混合召回、重排、分块策略、查询改写,以及最后怎么把离线评估和线上反馈对齐。如果你手里的文档搜索系统也处于"demo 能跑、实用不行"的阶段,这篇应该能帮你省不少弯路。
1. 第二版要解决的第一性问题:从"能找到"到"找得准"
v1 的链路其实非常简单:文档解析 → 固定长度切块 → Embedding → 向量库 → TopK 召回 → 拼 Prompt → LLM 生成回答。demo 阶段拿着精心准备的几个测试问题跑,效果看起来还行。但真实用户一进来,问题立刻现出原形。
我把收集到的用户反馈归成了四类,每一类都对应一个明确的根因:
| 现象 | 用户原话(近似) | 根因 |
|---|---|---|
| 精确词搜不到 | "物料编号 PRJ-0231 搜不出来" | 纯向量检索丢精确匹配 |
| 语义相关但不顶用 | "搜到的是提到这个词的文档,不是讲这个事的文档" | 排序只看相关性,不看信息完整性 |
| 长文档答案被淹没 | "答案在文档第 12 页,模型答得模棱两可" | 切块太粗,关键上下文被稀释 |
| 口语化查询效果差 | "我想看看新版报销单怎么填" | 查询缺改写,同义词没打通 |
第一类问题是最致命的。向量检索的本质是把文本映射到高维语义空间,这对"语义相近"非常友好,但对"精确一致"反而是一种破坏。比如物料编号 PRJ-0231 会被 embedding 模型切成若干 token 再平均到向量里,你搜"PRJ-0231"和搜"PRJ-0232"得到的向量可能非常接近,但用户要的就是那个唯一串。这种场景下,传统的关键词倒排索引反而更可靠。
第三类和第四类问题同样关键。固定 512 token 一刀切,经常把一张表格或一段完整的制度条款切成两半;用户那边则是"我今天下午要找 XX 部门去年的考勤制度,你知道在哪个文件吗"这种口语化输入,embedding 勉强能抓到几个词,召回质量却很差。
所以第二版的边界很清楚:不做花哨的 Prompt 优化,先把检索链路做成"混合召回 + 两阶段排序"。具体拆成五个任务:混合检索、重排、分块策略、查询改写、评估闭环。每一项都有明确验收标准,后面我会逐个讲清楚当时的选型和实测数据。
提示:如果你正准备改造自己的文档搜索系统,我强烈建议也按这个顺序来。先解决检索质量,再回头看 Prompt。检索到的内容不对,Prompt 写得再好也是空中楼阁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 混合检索:BM25 和向量的配比不是拍脑袋定的
坦白说,v1 只用了向量检索,是因为当时的想法很朴素:"语义搜索肯定吊打关键词搜索"。实际跑完才发现,这两者根本不是替代关系,而是互补关系。
向量检索的优势在"模糊":同义词、指代、口语化说法都能兜住。比如用户问"请假要提前几天申请",文档里写的是"休假申请需提前三个工作日提交",语义上完全一致,关键词检索很难命中,但向量可以。
向量检索的劣势也在"模糊":它对精确 token 不敏感。搜"PRJ-0231",结果里全是与该编号相关的培训材料,唯独没有那个唯一的项目立项文档,因为立项文档里编号只出现了一次,被其他相似文档的语义淹没了。
我的做法是双路召回,然后在融合层合并:
- 第一路:开源检索引擎的 BM25 分词召回,负责精确词、编号、文件名、专有名词;
- 第二路:Embedding 模型加向量库 ANN 召回,负责语义相似和口语化表达。
融合方式我选的是 RRF(Reciprocal Rank Fusion),而不是对两路分数做加权求和。原因是 BM25 分数和向量相似度根本不在一个量纲上,直接加权需要反复调权重,换个数据集又失效。RRF 只看排名,把多路召回结果的排名转化成得分再求和,实现只有十几行:
python复制def reciprocal_rank_fusion(ranked_lists, k=60):
"""多路召回结果融合,ranked_lists 是多个按相关性排序的 doc_id 列表"""
scores = {}
for rank_list in ranked_lists:
for rank, doc_id in enumerate(rank_list, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
K 取 60 是社区和论文里最常见的默认值,可以理解成"排名第 60 位的文档已经几乎不贡献得分",相当于给长尾结果一个很低的保底分,避免两路结果完全被头部霸占。
我用内部的一批真实查询做了小规模对比实验,召回池固定为 50 条:
| 检索方式 | Recall@5 | Recall@20 | 定位 |
|---|---|---|---|
| 仅 BM25 | 61.2% | 74.5% | 精确词强,口语化弱 |
| 仅向量 | 64.8% | 82.1% | 语义强,精确词弱 |
| BM25 + 向量(简单加权) | 68.3% | 85.4% | 需要反复调权重 |
| BM25 + 向量(RRF) | 69.5% | 88.9% | 免调参,表现稳定 |
结论很明确:RRF 融合后 Recall@20 从单路的 82% 提升到接近 89%。这一步做完,"精确词搜不到"的问题基本被解决,代价是检索响应时间从原来的 80ms 增加到约 120ms,完全可接受。
需要提醒一句:RRF 只是把多路结果"合并",不会修正某一路内部的排序错误。比如 BM25 把一条完全无关但关键词很多的文档排在前面,RRF 会把它带进最终结果。所以后面必须有重排层兜底。
3. Rerank 重排层:开源小模型也能顶事
召回解决的是"有没有",重排解决的是"排得对不对"。我们最终给 LLM 喂的是 Top8,但召回的 50 条里可能只有 10 条真正有用,剩下 40 条是"看似相关,实则不是用户要的"。如果只按召回分数取 Top8,大概率会把噪声带进 Prompt。
重排的原理可以这样理解:Embedding 召回是"先各自把文档和查询编码成向量,再看向量距离",这叫双塔结构,速度快但信息粒度粗。重排模型则是"把查询和每条候选文档拼接成一个输入,让模型对这一个 pair 打分",叫交叉编码器,相当于让模型真正"读一遍"查询和文档的关系。类比来说,embedding 像简历初筛,重排像面试——初筛要快、要广,面试要准、要深。
我选的是社区里常见的开源中文交叉编码器重排模型,参数量不大,int8 量化后跑在 CPU 上就能满足内部使用。整个流程调整为:
- 混合召回拿到 50 条候选;
- 先做一层规则的元数据过滤(按部门、时间、文档类型等);
- 剩余候选全部送入重排模型打分;
- 取 Top8 拼进 Prompt 给 LLM。
实测延迟如下(单条查询、50 条候选、CPU int8 推理):
| 环节 | 耗时 |
|---|---|
| BM25 + 向量召回 | 约 120ms |
| 重排 50 条候选(batch) | 约 350ms |
| 总检索耗时 | 约 470ms |
470ms 对于内容检索类场景完全能接受。如果对延迟更敏感,可以把候选数从 50 降到 30,或者用更大的 batch 并行。这里有个关键点:重排模型打分是"逐条对比查询和文档",分数只在同一查询内部有意义,不能跨查询比较。所以不要试图拿重排分数去设全局阈值——不同查询的分数分布可以差得离谱。
另一个经验是:重排之前一定要把无意义的候选过滤掉,否则 50 条里混着 20 条完全无关的文档,重排模型虽然能识别,但白白浪费算力。元数据过滤规则务必前置在重排之前,而不是之后。
还有一点容易被忽视:重排模型对文本长度有上限,超长候选会被截断,截断点选得好不好直接影响打分。我建议在重排输入前对候选做一次"取关键段落"的预处理,而不是直接丢全文。这一步后来跟分块策略联动优化,效果很明显。
4. 分块与索引策略:调优过程与实测数据
很多人以为分块就是把文档按固定长度切开,其实分块策略对检索质量的影响,比对模型选择的影响还大。v1 用固定 512 token、无重叠的切法,带来两个典型问题。
一是表格被拆散。内部文档大量使用表格,固定长度切块经常把表头切到上一块、表格内容切到下一块,检索时命中表头那块却丢了内容,答案残缺。
二是长段落被腰斩。制度类文档经常在"第一章总则"下跟着一整个大段落,512 token 一刀切后,前面一块讲了"适用范围",后面一块讲了"例外情形",用户问"哪些情况不适用"时只命中前面那块,答案自然不对。
我最终采用的分块策略是"标题段落感知 + 上限截断 + 滑动重叠":
- 解析文档时保留标题层级结构(一级标题、二级标题、段落);
- 以"二级标题下的连续内容"为一个自然块,如果自然块长度超过上限,再按句子边界切分;
- 相邻块之间保留 80 token 重叠,避免关键句恰好落在切割边界上;
- 每一块都带上元数据:来源文件、标题路径、页码、切块序号。
用一批真实查询做对照实验,结果如下:
| 分块方式 | 平均块大小 | Recall@5 | 备注 |
|---|---|---|---|
| 固定 512 token,无重叠 | 约 512 token | 64.2% | 表格/段落断裂明显 |
| 固定 512 token,重叠 64 | 约 512 token | 66.8% | 略有提升,仍旧粗暴 |
| 固定 1024 token,重叠 128 | 约 1024 token | 68.9% | 长文档上下文更完整 |
| 标题段落感知 + 800 token 截断 + 80 重叠 | 约 600 token | 74.1% | 表格和制度条款完整度最高 |
从数据看,标题段落感知带来的提升最显著。原因是它同时保证了两个目标:块的语义完整性(不把一个完整条款切开),以及块的粒度适中(不会因为某个标题下内容过长而让整块变成一篇小文章,导致 embedding 向量被平均得毫无重点)。
索引更新上,我们没有整天全量重建,而是做了增量更新:文件新增或修改时,只重新解析和索引该文件对应的块,并清理旧的块。这里提醒一句:切块 ID 必须是稳定可推导的,比如"文件哈希 + 标题路径 + 块序号",否则增量更新时容易残留旧块,污染检索结果。
表格解析同样需要单独处理。我后来在解析阶段先用表格识别逻辑把 PDF 里的表格转成 Markdown 表格,再作为独立块参与切分。这样检索到表格块时,Markdown 原文可以完整喂给 LLM。这一步看起来小,但对内部文档库的实用性提升巨大,财务报表、审批流程表格都是高频被搜索的内容。
5. 查询改写:用户不会像搜索引擎那样说话
用户输入的查询,和文档里的书面表达往往差得很远。比如文档里写的是"员工休假管理办法",用户搜的是"请假几天要审批",两者 embedding 能沾点边,但召回质量不稳定。我把查询改写拆成三层,前两层用规则和词典,第三层交给 LLM。
第一层是同义词扩展。建一个领域词典,把"请假/休假/年假/调休"映射到同一标准词簇,把"报销/费用/报销单/发票"归一化。检索时把用户查询里的词做一次扩展,扩展词作为额外的检索入口。
第二层是术语归一。内部文档里经常出现"ERP 系统"和"企业资源计划系统"这种全称/简称混用,规则词典里直接做"缩写 → 标准全称"的映射。
第三层是 LLM 查询改写。前两层覆盖不了的语言现象,交给大模型。我用的改写 Prompt 大致是:
code复制你是一名检索查询改写助手。用户输入一句自然语言查询,
请改写成更适合文档检索的关键词组合。
要求:
1. 保留原文中的精确词(编号、人名、专有名词),不得改动
2. 可以补充同义词,但不要扩写成长句
3. 不要编造原文没有的信息
4. 最多输出 3 个候选查询,每个不超过 20 个词
这里最重要的要求是"保留精确词不得改动"。用户搜"PRJ-0231 延期 申请",LLM 如果把编号拆成"项目的延期申请",这一条精确信息就丢了,混合检索的 BM25 路也救不回来。改写的目标不是"把查询变得像书面语",而是"在不丢失信息的前提下,增加召回入口"。
改写后的查询不是只取一个,而是作为多路查询并行走检索:原始查询走一路,改写后的候选查询分别走一路,最后用 RRF 融合。即使改写失败,原始查询仍然兜底。
实际收益方面,我在内部用 50 条真实口语查询做了一次对照:
| 查询类型 | 不改写 Recall@10 | 改写后 Recall@10 |
|---|---|---|
| 含编号/精确词 | 71.4% | 75.8% |
| 口语化表达("我想找那个填报销的表格") | 43.2% | 61.5% |
| 复合需求("门禁申请流程和访客登记要求") | 52.0% | 66.3% |
口语化查询的提升最明显,从 43% 涨到 61%,这恰恰是真实用户最常出现的输入类型。代价是每次改写多一次 LLM 调用,约 0.5 到 0.8 秒延迟和一点 token 成本。我的建议是:对查询做缓存,同一个查询 24 小时内不重复改写;同时只对"长度大于等于 4 个汉字、且不是纯编号"的查询做改写,纯编号直接跳过,省下无意义的调用。
6. 评估闭环:离线指标与线上反馈怎么对起来
一路调优看似每一步都有提升,但如果没有统一的评估集,这些提升很可能是"在测试集上过拟合"。第二版我专门补了评估闭环,这也是内部系统能不能持续迭代的关键。
评估集怎么建?我们从线上搜索日志里随机抽了 300 条真实查询,过滤掉明显乱码和测试数据,然后让团队里三个人分别标注每条查询对应的 1 到 3 篇最相关文档,取多数票作为标准答案。这项工作看起来费人力,但 300 条查询三个人两天就标完了,换来的是一劳永逸的回归测试基础。
离线指标我用的是这三个:
- Recall@K:TopK 结果里包含多少比例的标准答案文档;
- MRR@10:第一个标准答案在结果里的排名的倒数,衡量"能不能放到第一屏";
- NDCG@10:考虑多级相关性的排序质量指标,比 MRR 更精细。
评估脚本的核心逻辑不复杂,几十行就能写出来:
python复制def recall_at_k(results, relevant, k):
return len(set(results[:k]) & set(relevant)) / len(relevant) if relevant else 0.0
def mrr_at_10(results, relevant):
for i, doc_id in enumerate(results[:10], start=1):
if doc_id in relevant:
return 1.0 / i
return 0.0
每次改分块参数、换重排模型、调融合权重,都先跑一套评估对比基线。我给自己定的规矩是:任何改动,离线 Recall@10 不低于上一个版本才能发灰度。线上再看两个信号:搜索结果点击率,以及"无结果/无帮助"反馈按钮的数据。如果离线指标提升但线上点击率下降,大概率是评估集和线上真实查询分布有偏差,这时候要回头补充评估集,而不是强行上线。
这里有个很容易犯的错误:评估集的查询是"抽样当时"的真实查询,三个月后新文档进来、新用户进来,查询分布会漂移。所以我每隔一段时间就重新抽样一批新查询补充进评估集,旧查询留着做回归,但权重逐步降低。评估集是活的,不是一次做完就完事。
灰度发布同样关键。我当时先用 5% 的内部流量跑新版本,观察一周,确认线上指标没有明显恶化,再逐步放大到 50%、100%。文档搜索不像交易系统那么敏感,但检索结果突然变化会让用户很困惑,所以小流量灰度、提前给用户一个"搜索算法已更新"的提示,是值得做的。
7. 上线过程中的几个坑与最终成本观察
第二轮迭代上线后跑了两周,整体效果达到预期,中间踩了不少坑,挑几个印象最深的记录一下。
第一个坑是 PDF 表格解析。一开始用通用解析库直接抽文本,表格内容被拆成一行行碎片,检索时根本拼不回来。后面改成先做版面分析识别表格区域,再转换成 Markdown,花了一天多时间,换来的检索质量提升非常值。如果你的文档库里表格占比高,这一步不要省。
第二个坑是元数据过滤的坑。有个用户按部门筛选文档,结果发现筛选后一条结果都没有。排查半天才发现,新入库的一批文档没有写部门字段,而检索逻辑把"没有该字段"当成"不匹配",全部过滤掉了。正确做法是:用户没主动选择筛选条件时,过滤逻辑只对"已有该字段"的文档生效,缺省字段的文档应该放行。
第三个坑是重排模型的输入长度。文本超长被截断后,重排分数会异常偏高,因为模型只看到了文档开头,没看到被截掉的部分。这也解释了为什么重排前先要做段落级预处理,而不是直接丢原文。
成本方面,我拉了一张日常运行成本观察表,供参考:
| 成本项 | 单次查询/文档成本 | 说明 |
|---|---|---|
| Embedding 服务 | 按 token 计费,单文档约几分钱 | 提前批量算好,增量时才调用 |
| LLM 查询改写 | 单次约 0.5~0.8 秒,token 费用极低 | 加缓存后可省掉一半以上调用 |
| 重排模型 CPU 推理 | 50 条候选约 350ms | int8 量化后无 GPU 也能跑 |
| 索引存储 | 向量 + 原文副本 | 容量主要被向量占,可按需降低维度 |
最后说一个个人体会:整个二版迭代里,收益最大的其实是"评估闭环"和"分块策略"这两个不那么性感的部分。重排、改写这些名词听起来很高级,但没有评估闭环,你根本不知道某个改动是真有效还是碰运气;分块策略听着很基础,但对内部文档库这种表格多、制度条款长的场景,它的提升比换一个更大更强的模型明显得多。
如果你也只打算从这篇里带走一条经验,我希望是:先建一个 100 条真实查询的评估集,再开始动任何参数。这一步花掉半天,后面能帮你省掉无数个"凭感觉调参"的夜晚。
