背景
在企业知识库场景下,文档类型多样——产品手册、周报月报、培训教材、配置指南——用户的查询方式也千差万别。有人搜精确型号 "R6900 G5",有人问自然语言"设备频繁重启可能是什么原因",也有人混着来"R6900 的内存兼容规则是什么"。
单一检索策略很难同时覆盖这些场景。纯向量检索对"R6900 G5"这种精确术语容易召回其他型号的文档;纯 BM25 对"设备频繁重启"这种口语化表达又无法关联到"异常断电""系统崩溃"等同义表述。
这篇文章记录我们最终演化出的三路并行检索架构,重点在融合算法的设计和 Rerank 的服务端优化。
三路并行策略
主力策略,利用 ES 8.x 原生的 knn + query 并行能力,向量和关键词同时参与检索,最终分数叠加。
knn_hybrid = {
"field": "vector",
"query_vector": vector,
"k": 50,
"num_candidates": 400,
"boost": 1.6 # 向量权重
}
bool_query = {
"should": [
{"match": {"content": {"query": query, "boost": 0.2}}},
{"match_phrase": {"content": {"query": query, "slop": slop, "boost": 0.1}}}
],
"minimum_should_match": 1
}
# ES 最终分数 = knn_score × 1.6 + bm25_score × 0.2TEXT_BOOST=0.2 看起来很低,但 BM25 原始分数范围在 550+,而 KNN cosine 分数只有 01。不压低 BM25,关键词匹配会碾压语义相关性。
为什么 k=50 而不是更大?
Hybrid 有 BM25 兜底召回关键词命中的文档,向量侧不需要特别大的召回窗口。k=50 配合 num_candidates=400 的比值(8:1)在精度和延迟之间取得了较好的平衡。
三路通过 ThreadPoolExecutor 并行执行,各自有独立的超时控制(1500ms / 1200ms / 800ms)。任一路超时不影响其他两路返回结果。
多路融合算法
三路返回的分数量纲完全不同——BM25 是 050+,KNN cosine 是 01,Phrase 是 0~50+——不能直接比较。融合分三步进行:
Min-Max 归一化(每路独立)
将每路的分数映射到 [0, 1] 区间,消除量纲差异:
def normalize_scores(hits):
scores = [hit['_score'] for hit in hits]
min_s, max_s = min(scores), max(scores)
if max_s == min_s: # 边界保护
return hits # 只有 1 个结果或分数相同时跳过
for hit in hits:
hit['_score'] = (hit['_score'] - min_s) / (max_s - min_s)
return hits用一组真实数据走一遍。假设用户查询 "内存兼容性规则",三路返回:
Hybrid 原始分: doc_A=12.5 doc_B=8.3 doc_C=6.1
归一化 (range=6.4): doc_A=1.000 doc_B=0.344 doc_C=0.000
Semantic 原始分: doc_A=0.87 doc_D=0.72 doc_B=0.65
归一化 (range=0.22): doc_A=1.000 doc_D=0.318 doc_B=0.000
Phrase 原始分: doc_C=15.2 doc_E=9.8
归一化 (range=5.4): doc_C=1.000 doc_E=0.000
Min-Max 的陷阱
注意 Semantic 路的 doc_B:原始 cosine 分数 0.65 并不低(通常代表中度相关),但归一化后变成了 0.000,因为它是该路的最低分。Min-Max 只关注相对位置,不保留绝对语义。Z-score 归一化或分位数归一化在这方面更稳健。
乘以策略权重
WEIGHTS = {'hybrid': 1.0, 'semantic': 1.0, 'phrase': 0.6}
weighted_score = normalized_score * WEIGHTS[strategy_name]加权后:
Hybrid (×1.0): doc_A=1.000 doc_B=0.344 doc_C=0.000
Semantic(×1.0): doc_A=1.000 doc_D=0.318 doc_B=0.000
Phrase (×0.6): doc_C=0.600 doc_E=0.000
Phrase 乘 0.6 的效果:即使 Phrase 给 doc_C 打了归一化满分 1.0,加权后也只有 0.6——不会压过 Hybrid/Semantic 的高分文档。
按文档 ID 合并去重
同一个文档被多路命中时,取最高加权分,同时记录命中来源:
doc_scores = {}
for strategy_name, hits in results.items():
for hit in normalize_and_weight(hits, strategy_name):
doc_id = hit['_id']
weighted = hit['_score'] * WEIGHTS[strategy_name]
if doc_id not in doc_scores:
doc_scores[doc_id] = {'score': weighted, 'types': [strategy_name]}
else:
if weighted > doc_scores[doc_id]['score']:
doc_scores[doc_id]['score'] = weighted
doc_scores[doc_id]['types'].append(strategy_name)最终排序结果:
| 排名 | 文档 | 最终分数 | 命中策略 | 说明 |
|---|---|---|---|---|
| 1 | doc_A | 1.000 | hybrid, semantic | 两路满分,但取 max 后和单路满分一样 |
| 2 | doc_C | 0.600 | hybrid, phrase | Phrase 把它从 0.000 拉到 0.600 |
| 3 | doc_B | 0.344 | hybrid, semantic | Hybrid 贡献了主要分数 |
| 4 | doc_D | 0.318 | semantic | 仅语义命中 |
| 5 | doc_E | 0.000 | phrase | Phrase 路最低分 |
当前 Max Score 策略的设计缺陷
doc_A 被两路都给了满分,但最终分数和只被一路命中完全一样——多路命中这个信号被浪费了。改为累加 + 命中次数 bonus 会更合理:final = sum_score × (1 + 0.1 × (hit_count - 1)),这样 doc_A 能拿到 2.0 × 1.1 = 2.2,拉开与单路命中文档的差距。
Rerank 精排:服务端 Prompt 优化
粗排之后用 Cross-Encoder(Qwen3-Reranker-8B)做精排。Bi-Encoder(粗排)将 query 和 document 分别编码再比较方向,Cross-Encoder 则将二者拼接后一起推理,能捕捉更细粒度的交互关系。
部署时遇到一个实际问题:默认 prompt 模板下 Reranker 输出的分数区分度很差。
优化前(默认 prompt,分数集中):
相关文档: 0.45 ~ 0.70
不相关文档: 0.25 ~ 0.55 ← 大量重叠
→ min_score=0.5 过滤时误伤/漏放都很严重
优化后(自定义 template + think 标记):
相关文档: 0.70 ~ 0.98
不相关文档: 0.02 ~ 0.30 ← 清晰分离
→ min_score=0.5 分界效果显著提升
核心做法是在 vLLM 和业务代码之间加了一层 FastAPI 代理,用自定义 Jinja2 模板改写输入 prompt。关键改动:
[system] Judge whether the Document meets the requirements
based on the Query and the Instruct provided.
Note that the answer can only be "yes" or "no".
[user]
[Instruct]: Given a web search query, retrieve relevant passages
[Query]: ...用户查询...
[Document]: ...候选文档...
[assistant]
[think] ← 空思考标记:引导模型跳过推理
[/think]
← 直接输出 yes/no 判断模板中的空思考标记([think][/think])引导模型跳过推理过程直接输出判断。配合 vLLM 的 classifier_from_token: ["no", "yes"] 配置,让引擎直接输出 "yes" token 的 softmax 概率作为相关性分数,而非走完整的文本生成流程。
超时降级
三路策略的降级思路不同,因为瓶颈来源不同:
| Hybrid 降级 | Semantic 降级 | |
|---|---|---|
| 瓶颈 | BM25 Bool Query 复杂度 | KNN 候选集遍历量 |
| 降级操作 | 砍掉 BM25,只保留 KNN | 保留 KNN,candidates 500→150 |
| 质量影响 | 丢失关键词匹配能力 | 召回窗口缩小 |
降级后仍可能失败——此时该路整体返回空,但不影响其他两路。这是三路并行的容错优势:任意一路甚至两路失败,只要有一路返回结果,系统就不会给用户空答案。
数据处理层的优化
检索质量不只取决于检索算法,数据入库阶段的处理同样关键。
表格结构化保留。 网页文档中的表格如果提取为纯文本,行列关系会完全丢失。我们在 HTML 解析阶段用状态机追踪表格上下文(嵌套表格用栈管理),输出时保留 <table>/<tr>/<td> 标记。超长表格每 20 行自动分页并重复表头——确保 chunk 切分后每个片段都是自包含的。
PPT 视觉模型分析。 PPT 是视觉化文档,用视觉模型(VLM)对每页截图做两步分析:页面分类(标题页/目录页/内容页/感谢页)→ 内容提取。目录页额外识别高亮章节,用来给内容页建立章节归属(如 项目总结.pptx / 二、技术方案)。非内容页过滤不入库,减少检索噪声。
局限与演进方向
Agentic Retrieval-Augmented Generation: A Survey
从单次检索到 Agent 自主循环检索的演进综述
arxiv.org
Effective Context Engineering for AI Agents
Anthropic 关于 Agent 上下文管理的工程实践,与检索结果的组装和压缩密切相关
www.anthropic.com