Finn Days
博客笔记项目标签关于
博客笔记项目标签关于
Finn Days
博客·关于·RSS

企业知识库的混合检索架构:三路融合与精排实践

2026.01.15·10 min read
RAGElasticsearchRerankInformation Retrieval

背景

在企业知识库场景下,文档类型多样——产品手册、周报月报、培训教材、配置指南——用户的查询方式也千差万别。有人搜精确型号 "R6900 G5",有人问自然语言"设备频繁重启可能是什么原因",也有人混着来"R6900 的内存兼容规则是什么"。

单一检索策略很难同时覆盖这些场景。纯向量检索对"R6900 G5"这种精确术语容易召回其他型号的文档;纯 BM25 对"设备频繁重启"这种口语化表达又无法关联到"异常断电""系统崩溃"等同义表述。

这篇文章记录我们最终演化出的三路并行检索架构,重点在融合算法的设计和 Rerank 的服务端优化。

三路并行策略

主力策略,利用 ES 8.x 原生的 knn + query 并行能力,向量和关键词同时参与检索,最终分数叠加。

hybrid_strategy.py
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.2

TEXT_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] 区间,消除量纲差异:

normalize.py
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 归一化或分位数归一化在这方面更稳健。

乘以策略权重

weighting.py
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 合并去重

同一个文档被多路命中时,取最高加权分,同时记录命中来源:

merge.py
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)

最终排序结果:

排名文档最终分数命中策略说明
1doc_A1.000hybrid, semantic两路满分,但取 max 后和单路满分一样
2doc_C0.600hybrid, phrasePhrase 把它从 0.000 拉到 0.600
3doc_B0.344hybrid, semanticHybrid 贡献了主要分数
4doc_D0.318semantic仅语义命中
5doc_E0.000phrasePhrase 路最低分

当前 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。关键改动:

qwen3_rerank_template.jinja(结构简化)
[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

下一篇

用中间件模式约束 Agent:一个生产级 Harness 框架的设计

分享:··

Finn

AI 工程师,做 Agent 系统和 RAG 检索。在这里写下工程实践中踩过的坑和学到的东西。

订阅 Newsletter

每周精选技术文章,直达你的收件箱。不发垃圾邮件,随时退订。

我们尊重你的隐私,邮箱信息仅用于发送 Newsletter,不会分享给第三方。

评论

目录

  • 背景
  • 三路并行策略
  • 多路融合算法
  • Rerank 精排:服务端 Prompt 优化
  • 超时降级
  • 数据处理层的优化
  • 局限与演进方向