欢迎光临
我们一直在努力

# RAG 混合检索实战:BM25 + 向量检索 + RRF 融合,从原理到代码全链路

RAG 混合检索实战:BM25 + 向量检索 + RRF 融合,从原理到代码全链路

导读:向量检索擅长语义理解,但面对精确数字、政策名称时容易"跑偏";BM25 擅长关键词匹配,但无法理解同义改写。本文从项目真实代码出发,完整讲解 BM25 算法原理、中文分词实现、BM25 检索编码、RRF 倒数排名融合算法,以及如何用 asyncio.gather 并行执行双路检索。读完可直接落地到自己的 RAG 项目中。

适合读者:

  • 正在做 RAG 系统但召回质量不达标的开发者
  • 听说过 BM25 和混合检索但还没动手实现过的工程师
  • 想了解 RRF 融合算法内部机制的算法爱好者
  • 从向量检索单一路径升级到混合检索的团队

阅读收益:

  • 理解 BM25 的 TF-IDF 升级版原理(不用背公式,看懂思想)
  • 掌握 jieba 中文分词在 BM25 中的实战用法(保留数字、过滤标点)
  • 学会编写 BM25 检索函数(从 MySQL 子块中召回关键词匹配结果)
  • 理解 RRF(Reciprocal Rank Fusion)为什么比直接相加分数更科学
  • 拿到可直接落地的 hybrid 检索模式代码(向量 + BM25 并行执行)

目录

  • 为什么单一路径检索不够
  • BM25 算法原理:TF-IDF 的升级版
  • 中文分词:jieba + 正则过滤
  • BM25 检索函数实现
  • RRF 倒数排名融合
  • 混合检索:并行执行向量 + BM25
  • 结果对比:vector vs hybrid
  • 踩坑清单:混合检索的 10 个常见错误
  • 总结
  • 文末互动

  • 1. 为什么单一路径检索不够

    1.1 向量检索的局限

    向量检索基于 Embedding 语义相似度,擅长处理:

    • “办社保卡需要什么材料?” ↔ “申请社保卡要准备哪些证件?”(同义改写)
    • “数字化补贴政策” ↔ “企业数字化转型扶持措施”(语义相近)

    但向量检索在面对以下场景时表现不佳:

    • 精确数字匹配:“2026年8月31日”——向量会把 “2026” 和 “2025” 视为相近
    • 短关键词:“申报材料”——向量可能把它和 “报告材料” 混为一谈
    • 政策名称/条款编号:“第二条”、“第三章”——这些短文本语义信息不足

    1.2 BM25 的定位

    BM25(Best Match 25)是基于词频的经典信息检索算法,属于 TF-IDF 的升级版。它的核心特点是:

    • 只看字面词汇,不理解语义
    • 精确匹配关键词,对数字、日期、短词敏感
    • 分数基于词频和文档长度,不是概率值

    1.3 两者互补

    检索方式擅长不擅长典型场景
    向量检索 同义表达、口语问题、语义相近 精确数字、日期、短关键词 “怎么办理社保卡”
    BM25 政策名称、办理材料、日期、条款编号 同义改写、语义理解 “2026年8月31日”、“申报材料”

    混合检索的思路:向量负责语义召回,BM25 负责关键词召回,RRF 负责融合两路结果。


    2. BM25 算法原理:TF-IDF 的升级版

    2.1 TF-IDF 的问题

    传统 TF-IDF 的公式:

    score = TF * IDF
    TF = 词在文档中出现次数
    IDF = log(总文档数 / 包含词的文档数)

    TF-IDF 的问题是:

    • 词频无限增长时分数无限增大(一篇文档重复 100 次 “社保”,分数远高于合理的 5 次)
    • 没有考虑文档长度(长文档天然词频更高,不公平)

    2.2 BM25 的改进

    BM25 引入了三个改进:

    (1)词频饱和函数

    BM25 不直接用词频,而是用饱和函数:

    TF_saturation = (freq * (k1 + 1)) / (freq + k1 * (1 – b + b * doc_len / avg_len))

    • k1 控制饱和速度(通常 1.2~2.0)
    • 词频越高,分数增长越慢,最终趋于饱和

    (2)文档长度归一化

    • b 控制长度惩罚(通常 0.75)
    • doc_len / avg_len 是当前文档长度与平均长度的比值
    • 长文档的 TF 会被适当降低

    (3)IDF 改进

    IDF_BM25 = log((N – n + 0.5) / (n + 0.5))

    • N 是总文档数
    • n 是包含该词的文档数
    • 避免 IDF 为负数(当几乎所有文档都包含该词时)

    2.3 核心洞察

    BM25 分数不是概率,不适合直接和向量分数相加。这也是为什么要用 RRF 融合,而不是简单加权求和。


    3. 中文分词:jieba + 正则过滤

    3.1 为什么需要分词

    BM25 基于词(token)计算词频。英文天然有空格分词,中文需要先做分词处理。

    3.2 项目中的分词实现

    import re
    import jieba

    def _tokenize(text: str) –> list[str]:
    """中文简单分词:jieba + 过滤纯标点/空格(保留数字和含数字的词)。"""
    tokens = jieba.lcut(text.strip())
    return [
    t for t in tokens
    if t.strip() and not re.match(r"^[\\s\\W]+$", t)
    ]

    关键点:

    • jieba.lcut():精确模式分词,返回列表
    • t.strip():过滤纯空白字符
    • re.match(r"^[\\s\\W]+$", t):过滤纯标点符号(但保留含数字的词)

    3.3 分词测试

    test_cases = [
    "2026年8月31日",
    "社保缴纳记录",
    "第二条 支持范围",
    "数字化补贴申报材料",
    "办理社保卡需要什么材料?",
    ]

    for text in test_cases:
    tokens = _tokenize(text)
    print(f"原文: {text}")
    print(f"分词: {tokens}")

    输出示例:

    原文: 2026年8月31日
    分词: ['2026', '年', '8', '月', '31', '日']

    原文: 社保缴纳记录
    分词: ['社保', '缴纳', '记录']

    原文: 第二条 支持范围
    分词: ['第二', '条', '支持', '范围']

    注意:数字被保留了!这是 BM25 的优势所在——精确匹配 “2026”、“8月”、“31日”。

    3.4 分词踩坑:数字被过滤

    如果正则写错,把数字也过滤了:

    # 错误:过滤了所有非字母字符,包括数字
    return [t for t in tokens if re.match(r"^[a-zA-Z\\u4e00-\\u9fa5]+$", t)]
    # 结果:['年', '月', '日'] —— 数字没了!

    正确做法:只过滤纯标点,保留数字和含数字的词。


    4. BM25 检索函数实现

    4.1 整体流程

    BM25 检索的完整流程:

    1. 从 MySQL 查询子块(只查 child 类型,信息更聚焦)
    2. 应用过滤条件(文档ID、主题、生效日期)
    3. 对所有子块内容分词,构建 BM25 语料库
    4. 对用户问题分词,计算 BM25 分数
    5. 按分数降序排序,截取 top_k
    6. 返回与向量检索一致的数据结构

    4.2 代码实现

    from rank_bm25 import BM25Okapi
    from sqlalchemy import select
    from sqlalchemy.orm import Session

    def _bm25_search(
    question: str,
    session: Session,
    filters: RetrievalFilters,
    top_k: int,
    ) –> list[dict]:
    """
    BM25 关键词检索:从 MySQL 子块中查询,计算 BM25 分数。
    只检索子块(chunk_type='child'),原因:子块更短,信息更聚焦。
    """

    # 1. 构建 SQL 查询(只查子块)
    stmt = select(DocumentChunk).where(DocumentChunk.is_parent == False)

    # document_ids 过滤
    if filters.document_ids:
    stmt = stmt.where(DocumentChunk.document_id.in_(filters.document_ids))

    # topic / effective_before 过滤(合并为一次 join)
    needs_join = filters.topic or filters.effective_before
    if needs_join:
    stmt = stmt.join(PolicyDocument)

    if filters.topic:
    stmt = stmt.where(PolicyDocument.topic == filters.topic)

    if filters.effective_before:
    stmt = stmt.where(
    (PolicyDocument.effective_date <= filters.effective_before)
    | (PolicyDocument.effective_date.is_(None))
    )

    rows = list(session.scalars(stmt).all())
    if not rows:
    return []

    # 2. 构建 BM25 语料库(对所有子块分词)
    corpus = [_tokenize(r.content) for r in rows]
    bm25 = BM25Okapi(corpus)

    # 3. 计算问题与子块的 BM25 分数
    query_tokens = _tokenize(question)
    scores = bm25.get_scores(query_tokens)

    # 4. 组装结果(保持与向量检索一致的数据结构)
    results = []
    for idx, row in enumerate(rows):
    if scores[idx] <= 0:
    continue
    results.append({
    "chunk_id": row.id,
    "content": row.content,
    "score": float(scores[idx]),
    "metadata": {
    "document_id": row.document_id,
    "filename": row.document.filename if row.document else "",
    "topic": row.document.topic if row.document else "",
    "section": row.section or "",
    "parent_id": row.parent_id or "",
    },
    "source": "bm25",
    })

    # 5. 按分数降序排序并截断
    results.sort(key=lambda x: x["score"], reverse=True)
    return results[:top_k]

    4.3 数据结构一致性

    BM25 返回的数据结构和向量检索保持一致:

    {
    "chunk_id": "uuid",
    "content": "子块正文",
    "score": 2.5, # BM25 分数(非概率,不可跨方法比较)
    "metadata": {
    "document_id": "uuid",
    "filename": "文件名",
    "topic": "主题",
    "section": "章节",
    "parent_id": "父块ID",
    },
    "source": "bm25", # 标记来源
    }

    4.4 过滤条件处理

    BM25 的过滤直接写在 SQL 查询中:

    # 主题过滤
    stmt = stmt.where(PolicyDocument.topic == filters.topic)

    # 生效日期过滤(包含 NULL 的情况)
    stmt = stmt.where(
    (PolicyDocument.effective_date <= filters.effective_before)
    | (PolicyDocument.effective_date.is_(None))
    )

    注意:日期过滤需要 join PolicyDocument 表,但要避免笛卡尔积(多个 join 条件合并为一次 join)。


    5. RRF 倒数排名融合

    5.1 为什么不用简单加权

    假设向量检索返回:

    • chunk_A: score=0.85
    • chunk_B: score=0.72

    BM25 返回:

    • chunk_B: score=3.5
    • chunk_C: score=2.1

    问题:0.85 和 3.5 是不同量纲的分数,不能直接相加。

    5.2 RRF 的核心思想

    RRF(Reciprocal Rank Fusion)不看原始分数,只看排名位置:

    RRF_score = Σ 1 / (k + rank)

    • rank:该结果在某一路检索中的排名(从 0 开始)
    • k:平滑常数,通常取 60
    • 排名越靠前,加分越多

    5.3 代码实现

    def reciprocal_rank_fusion(
    vector_results: list[dict],
    bm25_results: list[dict],
    k: int = 60,
    ) –> list[dict]:
    """
    RRF 倒数排名融合。

    原理:不直接比较分数,只看排名。某结果在两路检索中排名越靠前,得分越高。
    score = Σ 1 / (k + rank)
    """
    scores: dict[str, float] = {} # chunk_id -> rrf_score
    sources: dict[str, set] = {} # chunk_id -> {source_names}
    all_items: dict[str, dict] = {} # chunk_id -> 原始数据

    # 处理向量结果
    for rank, item in enumerate(vector_results):
    cid = item["chunk_id"]
    scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
    sources.setdefault(cid, set()).add("vector")
    if cid not in all_items:
    all_items[cid] = item

    # 处理 BM25 结果
    for rank, item in enumerate(bm25_results):
    cid = item["chunk_id"]
    scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
    sources.setdefault(cid, set()).add("bm25")
    if cid not in all_items:
    all_items[cid] = item

    # 组装融合结果
    ranked = []
    for cid, item in all_items.items():
    ranked.append({
    **item,
    "rrf_score": scores.get(cid, 0.0),
    "sources": list(sources.get(cid, set())),
    })

    # 按 RRF 分数降序排序
    ranked.sort(key=lambda x: x["rrf_score"], reverse=True)
    return ranked

    5.4 RRF 的优势

    特性简单加权RRF
    分数量纲 必须统一 不需要统一
    长尾处理 差(低分结果被淹没) 好(排名决定贡献)
    参数敏感 敏感(权重调参难) 不敏感(k=60 通用)
    可解释性 差 好(只看排名)

    5.5 sources 字段的意义

    融合结果增加了 sources 字段,标记该结果来自哪些检索方式:

    ["vector"] # 仅向量检索命中
    ["bm25"] # 仅 BM25 命中
    ["vector", "bm25"] # 两路同时命中(高可信度)

    两路同时命中的结果通常更可靠,在后续排序中应该被优先考虑。


    6. 混合检索:并行执行向量 + BM25

    6.1 整体设计

    async def _hybrid_search():
    """并行执行向量检索和 BM25 检索。"""
    vector_task = asyncio.to_thread(
    vector_store.search,
    query=question,
    top_k=top_k * 3, # 向量多召回一些,交给融合逻辑排序
    document_ids=doc_ids,
    )
    bm25_task = asyncio.to_thread(
    _bm25_search,
    question=question,
    session=session,
    filters=filters,
    top_k=top_k,
    )
    return await asyncio.gather(vector_task, bm25_task)

    vector_candidates, bm25_candidates = asyncio.run(_hybrid_search())

    6.2 为什么 top_k * 3

    向量检索召回 top_k * 3 条,BM25 召回 top_k 条:

    • 向量检索召回更多候选,让 RRF 有更充分的选择空间
    • BM25 分数本身不精确,召回太多低分候选反而干扰
    • 最终融合后截取 top_k 条交给下游

    6.3 混合检索完整流程

    用户问题
    ↓
    并行执行 [向量检索] + [BM25检索]
    ↓
    向量结果 (30条) BM25结果 (10条)
    ↓ ↓
    └────── RRF 融合 ────┘
    ↓
    融合结果 (去重后 <= 40条)
    ↓
    按 rrf_score 降序排序
    ↓
    截取 top_k (10条)
    ↓
    父块扩展(子块 → 父块)
    ↓
    分数过滤 + 证据截断
    ↓
    最终证据 (5条)

    6.4 trace 调试信息

    混合检索在 trace 中保留每阶段的中间结果:

    trace = {
    "vector_results": [...], # 向量检索原始结果
    "bm25_results": [...], # BM25 检索原始结果
    "rrf_results": [...], # RRF 融合结果
    "steps": [
    "[vector] 召回 30 条候选",
    "[bm25] 召回 8 条候选",
    "[rrf] 融合后 35 条候选",
    ]
    }

    trace 的作用:

    • 排查问题时查看哪一路召回出了问题
    • 评估时对比 vector-only vs hybrid 的效果差异
    • 监控两路召回的覆盖率和重叠率

    7. 结果对比:vector vs hybrid

    7.1 测试问题分类

    问题类型示例预期表现
    关键词精确型 “社保缴纳记录” BM25 更强
    语义理解型 “办理社保卡需要什么材料?” 向量更强
    混合型 “2026年8月31日的政策有什么支持范围?” 混合最强

    7.2 观察指标

    # sources 分布统计
    only_vector = sum(1 for c in rrf_results if c.get("sources") == ["vector"])
    only_bm25 = sum(1 for c in rrf_results if c.get("sources") == ["bm25"])
    both = sum(1 for c in rrf_results if set(c.get("sources", [])) == {"vector", "bm25"})

    print(f"仅 Vector: {only_vector}")
    print(f"仅 BM25: {only_bm25}")
    print(f"两者都有: {both}")

    如果 both 比例高,说明两路召回有良好互补;如果 only_bm25 高,说明知识库中有大量精确关键词信息。


    8. 踩坑清单:混合检索的 10 个常见错误

    坑1:BM25 分数和向量分数直接相加

    # 错误:量纲不同,不能直接加
    combined_score = vector_score + bm25_score

    # 正确:用 RRF 只看排名
    rrf_score = 1/(60 + vector_rank) + 1/(60 + bm25_rank)

    坑2:分词过滤了数字

    # 错误:正则把数字也过滤了
    tokens = [t for t in jieba.lcut(text) if re.match(r"^[\\u4e00-\\u9fa5]+$", t)]

    # 正确:保留数字和含数字的词
    tokens = [t for t in jieba.lcut(text) if t.strip() and not re.match(r"^[\\s\\W]+$", t)]

    坑3:BM25 查的是父块不是子块

    # 错误:父块太长,BM25 词频稀释
    stmt = select(DocumentChunk).where(DocumentChunk.is_parent == True)

    # 正确:子块更短,信息更聚焦
    stmt = select(DocumentChunk).where(DocumentChunk.is_parent == False)

    坑4:忘记 asyncio.to_thread

    # 错误:同步函数直接 await,阻塞事件循环
    vector_task = vector_store.search(...) # 这是同步函数!

    # 正确:放到线程池执行
    vector_task = asyncio.to_thread(vector_store.search, ...)

    坑5:RRF 的 k 值太小

    # 错误:k=1 导致排名差异被过度放大
    reciprocal_rank_fusion(v, b, k=1)

    # 正确:k=60 是论文推荐值,平滑效果好
    reciprocal_rank_fusion(v, b, k=60)

    坑6:过滤条件导致笛卡尔积

    # 错误:多次 join 同一个表
    stmt = stmt.join(PolicyDocument).where(...)
    stmt = stmt.join(PolicyDocument).where(...) # 又 join 一次!

    # 正确:合并为一次 join
    needs_join = filters.topic or filters.effective_before
    if needs_join:
    stmt = stmt.join(PolicyDocument)

    坑7:BM25 结果为空时没有降级处理

    # 错误:BM25 为空时直接返回空
    if not bm25_results:
    return [] # 应该降级为只用向量结果

    # 正确:RRF 融合时,一路为空不影响另一路
    fused = reciprocal_rank_fusion(vector_results, bm25_results or [])

    坑8:没有保留 trace 导致无法排查

    # 错误:只返回最终结果,中间过程丢了
    trace = {} # 空

    # 正确:每阶段都记录
    trace["vector_results"] = vector_candidates
    trace["bm25_results"] = bm25_candidates
    trace["rrf_results"] = candidates
    trace["steps"].append(f"[vector] 召回 {len(vector_candidates)} 条")

    坑9:混合模式用 vector 的 min_score

    # 错误:BM25 分数范围是 0~10,向量是 0~1,用同一个阈值不合理
    min_score = 0.5 # 对 BM25 来说太高了

    # 正确:混合模式用更低的阈值,或不设阈值
    min_score = 0.01 # 让 RRF 分数决定排序

    坑10:没有测试 sources 字段

    # 错误:不检查两路是否真的有互补
    # 应该定期统计 sources 分布,评估混合检索效果

    # 正确:在检查脚本中统计
    print(f"仅 Vector: {only_vector}")
    print(f"仅 BM25: {only_bm25}")
    print(f"两者都有: {both}")


    9. 总结

    9.1 核心要点回顾

    知识点关键记忆
    BM25 TF-IDF 升级版,基于词频和文档长度,只看字面匹配
    分词 jieba + 保留数字,过滤纯标点
    子块检索 只查 child 类型,信息更聚焦
    RRF 不看分数看排名,公式 1/(k+rank)
    k=60 平滑常数,论文推荐,通用不敏感
    并行 asyncio.gather + asyncio.to_thread
    trace 保留每阶段中间结果,便于排查
    sources 标记结果来源,统计互补效果

    9.2 学习路径建议

  • 入门:先实现 BM25 单路检索,理解分词和分数含义
  • 进阶:加入 RRF 融合,对比 vector-only 和 hybrid 的效果
  • 实战:加入 trace 调试,分析 sources 分布
  • 优化:调整 top_k 比例,测试不同 k 值
  • 深入:尝试多路融合(向量 + BM25 + 知识图谱)
  • 9.3 延伸方向

    • 重排序(Rerank):在 RRF 后接交叉编码器(Cross-Encoder)精排
    • 查询扩展:用 LLM 扩展查询词,提升 BM25 召回率
    • 混合权重:尝试 Learned Sparse Retrieval(如 SPLADE)
    • 多路融合:向量 + BM25 + 关键词规则 + 知识图谱

    10. 文末互动

    思考题:

  • 你的 RAG 项目目前召回质量的最大瓶颈是什么?是语义不准还是关键词漏召?
  • 你试过用 RRF 融合吗?和简单加权相比效果如何?
  • 如果你的知识库以精确术语为主(如医疗、法律),BM25 的比例是否应该调高?
  • 如果本文对你有帮助:

    • 点赞支持,让更多 RAG 开发者看到
    • 收藏备用,实现混合检索时翻出来参考
    • 评论交流,说说你的检索架构选型

    作者的话:从单一路径到混合检索,是 RAG 系统从"能用"到"好用"的关键一步。向量检索和 BM25 不是竞争关系,而是互补关系。理解两者的边界,设计合理的融合策略,才能让系统既能理解用户的口语化表达,又能精确命中政策条文中的关键词。

    赞(0)
    未经允许不得转载:171主机测评 » # RAG 混合检索实战:BM25 + 向量检索 + RRF 融合,从原理到代码全链路
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址