一、混合检索-BM25检索+向量检索

1、为什么需要混合检索

  • 在前面的课程中,我们简要介绍了检索器的三种方式:稠密向量检索(语义匹配)、稀疏向量检索/BM25(关键词匹配)以及混合检索(两者结合)。本讲将对这一话题进行深度展开
  • 我们已经掌握了纯向量检索的实战方法。那么问题来了:既然向量检索已经这么强了,为什么还需要BM25?为什么还需要混合检索?

1.1 案例助解:一个让向量检索"翻车"的查询

  • 假设我们的知识库中有以下4篇文档:
文档编号 文档内容
D1 苹果公司发布了新款iPhone 15,搭载A17芯片
D2 苹果是一种营养丰富的水果,含有丰富的维生素
D3 苹果公司的CEO蒂姆·库克在发布会上做了演讲
D4 每天吃一个苹果有益健康,医生远离我
  • 用户查询:"苹果公司的最新动态是什么?"

  • 纯向量检索的问题分析:

    • 向量模型(如 BGE)将文本映射到语义空间时,"苹果公司"和"苹果水果"的向量可能非常接近
    • 因为它们都包含"苹果"这个词,且上下文结构类似
    • D2("苹果是一种营养丰富的水果")可能获得和 D1 相近的相似度得分
    • 这导致向量检索可能把"水果"相关的文档排到"公司"相关文档的前面
  • BM25的优势:

    • BM25 通过精确词匹配,"公司"和"最新动态"这些关键词能有效区分两类文档
    • D1 和 D3 因为包含"公司"一词,在 BM25 的 TF-IDF 计算中获得更高权重
    • D2 和 D4 因为不包含"公司",排名自然下降
  • 核心结论:向量检索懂"意思",BM25 懂"字面"。当用户查询中包含专有名词、精确关键词时,纯向量检索可能"跑偏"

1.2 两种检索范式的本质差异

维度 稠密向量检索(Dense Retrieval) 稀疏向量检索/BM25(Sparse Retrieval)
匹配机制 语义相似度(余弦相似度/内积) 关键词精确匹配 + TF-IDF 加权
对同义词 优秀:"汽车"能匹配"轿车" 弱:依赖分词和同义词扩展
对专有名词 可能模糊:型号、编号、人名 精确匹配,效果显著
对长文本查询 效果较好,能捕获整体语义 效果依赖查询词质量,关键词越多越准
对短关键词查询 可能发散,缺乏足够的语义信号 精确高效
跨语言能力 依赖多语言Embedding模型 无跨语言能力(依赖翻译)
计算成本 需要GPU/Embedding模型推理 CPU即可,速度快,内存开销小
可解释性 弱:高维向量难以解释为什么相似 强:可以明确说出匹配了哪些关键词
索引构建 需要向量数据库(如ChromaDB) 仅需倒排索引,内存即可
典型成功场景 "如何学习编程" → "编程入门指南" "ERROR_CODE_50023" → 精确匹配该错误码
典型失败场景 专有名词(产品型号、法律条款号) 同义词、改写表达、跨语言查询

1.3 混合检索的设计思路

  • 核心思想非常简单:取长补短,优势互补
flowchart LR A[用户查询] --> B[BM25关键词检索] A --> C[向量语义检索] B --> D[BM25排序结果 Top-K] C --> E[向量排序结果 Top-K] D --> F[分数融合: RRF / 加权求和] E --> F F --> G[最终排序结果]
  • 一句话总结:向量检索懂意思,BM25懂字面,混合检索两者兼得

1.4 TF-IDF

  • TF-IDF(Term Frequency–Inverse Document Frequency,词频-逆文档频率)是信息检索、文本挖掘和自然语言处理中最经典且最常用的文本向量化与关键词提取算法之一。它的核心思想非常直观:一个词如果在当前文档中出现得越频繁(TF高),同时在整个语料库的其他文档中出现得越少(IDF高),那么这个词对该文档的区分度就越高,权重也就越大

1.5 总结

  • 纯向量检索在语义理解上强大,但在精确匹配场景(专有名词、型号、代码、编号)存在盲区
  • BM25 在精确关键词匹配上天然占优,但无法理解同义词和语义改写
  • 混合检索通过"双路召回 + 分数融合",在两类场景中都能取得良好效果
  • 这并非"谁取代谁"的关系,而是"1 + 1 > 2"的互补架构

2、BM25检索深入

  • 在前面课程中我们提到 BM25 是基于词袋模型的经典检索方法,通过统计词频和逆文档频率来评估文档与查询的相关性

2.1 BM25的历史渊源

  • BM25 的全称是 Best Matching 25,它是信息检索领域最经典的排序算法之一
  • 演进路线:
flowchart LR A[布尔模型<br/>Boolean Model] --> B[向量空间模型<br/>VSM] B --> C[TF-IDF<br/>1970年代] C --> D[BM25<br/>1994年, Robertson] D --> E[BM25F / BM25+<br/>变体优化]
  • BM25 至今仍是 Elasticsearch 的默认相关性排序算法,在搜索引擎、推荐系统、RAG 系统中广泛使用
  • 它的核心优势:无需训练、可解释性强、对精确匹配效果极好

2.2 TF-IDF快速回顾

  • 在理解 BM25 之前,必须先理解 TF-IDF,因为 BM25 本质上是 TF-IDF 的改进版

  • TF(词频,Term Frequency):衡量一个词在文档中的重要程度

$$
TF(t, d) = \frac{\text{词}t\text{在文档}d\text{中出现的次数}}{\text{文档}d\text{的总词数}}
$$

  • IDF(逆文档频率,Inverse Document Frequency):衡量一个词的"稀缺程度"—— 出现越少越重要

$$
IDF(t) = \log\frac{N}{df(t)}
$$

  • 其中 $N$ 是文档总数,$df(t)$ 是包含词 $t$ 的文档数

  • TF-IDF = TF × IDF:一个词在本文档中出现越多(TF高)且在其他文档中出现越少(IDF高),它对该文档就越重要

  • TF-IDF的两个核心问题(正是BM25要解决的):

    1. 词频无限增长问题:TF是线性增长的,一个词出现100次不会比出现10次重要10倍
    2. 文档长度问题:长文档天然有更多词出现,TF天然偏高,缺乏长度归一化

2.3 BM25数学公式详解

  • BM25的标准公式(针对一个查询 $Q$ 和一个文档 $D$):

$$
BM25(D, Q) = \sum_{i=1}^{n} IDF(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}
$$

  • 公式拆解:BM25可以理解为三个部分的组合:

$$
BM25 = IDF \times \text{词频饱和项} \times \text{长度归一化项}
$$

  • 各符号含义表:
符号 含义 在公式中的作用
$q_i$ 查询 $Q$ 中的第 $i$ 个词 遍历查询中的所有词
$f(q_i, D)$ 词 $q_i$ 在文档 $D$ 中的词频 衡量该词在本文档中的重要程度
$|D|$ 文档 $D$ 的长度(总词数) 用于长度归一化
$\text{avgdl}$ 所有文档的平均长度 长度归一化的基准线
$k_1$ 词频饱和参数(通常 1.2 ~ 2.0) 控制词频对得分的影响上限
$b$ 长度归一化参数(通常 0.75) 控制文档长度惩罚的程度(0=不惩罚)
  • $k_1$ 参数:控制"词频饱和"的速度

    • $k_1$ 越大 → 词频对得分的影响越大 → 更接近线性TF(像TF-IDF)
    • $k_1$ 越小 → 词频越快达到饱和 → 词出现1次和10次差别不大
    • 直观理解:一个词出现第1次贡献很大,第2次也有贡献,但第100次的贡献微乎其微
  • $b$ 参数:控制"文档长度惩罚"的力度

    • $b = 0$ → 不做长度归一化(长文档不受惩罚)
    • $b = 1$ → 完全按比例归一化(长文档受到最强惩罚)
    • $b = 0.75$ → 默认折中值,实验证明效果最好
  • IDF 的 BM25 版本(与标准IDF略有不同):

$$
IDF(q_i) = \log\left(\frac{N - df(q_i) + 0.5}{df(q_i) + 0.5} + 1\right)
$$

  • 注意:这里的 $+0.5$ 是平滑项,避免分母为零。当 $df(q_i) > N/2$ 时,IDF 可能为负值(该词太常见,反而降低相关性)

2.4 案例助解:手工计算BM25得分

  • 为帮助理解,我们设计一个极简场景:

文档集(3篇文档):

编号 文档内容(已分词)
D1 苹果 / 公司 / 发布 / 新 / 手机
D2 苹果 / 是 / 一种 / 水果
D3 苹果 / 公司 / 推出 / 新 / 产品
  • 参数设定:

    • $\text{avgdl} = (5 + 4 + 5) / 3 = 4.67$
    • $k_1 = 1.5$,$b = 0.75$
  • 查询 $Q$:"苹果 公司"

  • Step 1:计算IDF

$df$ $IDF = \log\left(\frac{3 - df + 0.5}{df + 0.5} + 1\right)$
"苹果" 3 $\log(\frac{0.5}{3.5} + 1) = \log(1.143) \approx 0.134$
"公司" 2 $\log(\frac{1.5}{2.5} + 1) = \log(1.6) \approx 0.470$
  • Step 2:计算每个文档对"苹果"的贡献

D1(长度=5,"苹果"出现1次):
$$\text{得分} = 0.134 \times \frac{1 \times 2.5}{1 + 1.5 \times (1 - 0.75 + 0.75 \times \frac{5}{4.67})} = 0.134 \times \frac{2.5}{1 + 1.5 \times 1.054} \approx 0.134 \times 0.967 \approx 0.130$$

D2(长度=4,"苹果"出现1次):
$$\text{得分} = 0.134 \times \frac{1 \times 2.5}{1 + 1.5 \times (1 - 0.75 + 0.75 \times \frac{4}{4.67})} = 0.134 \times \frac{2.5}{1 + 1.5 \times 0.893} \approx 0.134 \times 1.067 \approx 0.143$$

D3(长度=5,"苹果"出现1次):同 D1,$\approx 0.130$

  • Step 3:计算每个文档对"公司"的贡献

D1("公司"出现1次):$0.470 \times \frac{2.5}{1 + 1.5 \times 1.054} \approx 0.470 \times 0.967 \approx 0.454$

D2("公司"出现0次):$0.470 \times 0 = 0$

D3("公司"出现1次):同D1,$\approx 0.454$

  • Step 4:最终BM25得分
文档 "苹果"贡献 "公司"贡献 BM25总分 排名
D1 0.130 0.454 0.584 1
D2 0.143 0.000 0.143 3
D3 0.130 0.454 0.584 1
  • 观察:虽然 D2 的"苹果"单项得分最高(因为文档短,词密度高),但因为缺少"公司"这个关键词,总分最低。这就是 BM25 精确匹配的威力

2.5 实战:使用rank_bm25库

2.5.1 环境准备

  • 安装依赖:
pip install rank_bm25 jieba
  • rank_bm25:轻量级 BM25 实现库,无需外部服务,适合学习和中小型项目
  • jieba:中文分词工具,BM25在中文场景必须配合分词使用

2.5.2 基础BM25检索(不分词)

  • 案例代码:
from rank_bm25 import BM25Okapi

# 准备文档集
documents = [
    # 科技类 —— 苹果公司
    "苹果公司市值突破三万亿,成为全球最有价值的科技公司",
    "苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升",
    # 科技类 —— 其他公司
    "华为Mate60搭载麒麟芯片强势回归,引发市场广泛关注",
    # 水果类
    "苹果是一种营养丰富的水果,含有丰富的维生素和膳食纤维",
    "每天吃一个苹果有益健康,可以降低胆固醇并帮助消化",
    "红富士苹果产地直供,新鲜采摘,脆甜多汁口感极佳",
    "水果市场行情分析:苹果价格稳定,柑橘类水果销量上升",
]

# 直接按空格分词(英文方式,对中文无效)
tokenized_corpus = [doc.split(" ") for doc in documents]
bm25 = BM25Okapi(tokenized_corpus)

# 查询
query = "苹果公司的最新动态"
tokenized_query = query.split(" ")
scores = bm25.get_scores(tokenized_query)

# 输出结果
for i, score in enumerate(scores):
    print(f"D{i+1}: 得分={score:.4f} | {documents[i]}")
  • 运行结果:
D1: 得分=0.0000 | 苹果公司发布了新款iPhone 15,搭载A17芯片和潜望式长焦镜头
D2: 得分=0.0000 | 苹果公司CEO蒂姆·库克在秋季发布会上介绍了Vision Pro头显设备
D3: 得分=0.0000 | 苹果公司市值突破三万亿,成为全球最有价值的科技公司
D4: 得分=0.0000 | 苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升
D5: 得分=0.0000 | 华为Mate 60搭载麒麟芯片强势回归,引发市场广泛关注
D6: 得分=0.0000 | 三星发布了新一代折叠屏手机Galaxy Z Fold,采用钛合金铰链
D7: 得分=0.0000 | 苹果是一种营养丰富的水果,含有丰富的维生素和膳食纤维
D8: 得分=0.0000 | 每天吃一个苹果有益健康,可以降低胆固醇并帮助消化
D9: 得分=0.0000 | 红富士苹果产地直供,新鲜采摘,脆甜多汁口感极佳
D10: 得分=0.0000 | 水果市场行情分析:苹果价格稳定,柑橘类水果销量上升
  • 所有得分都是0!原因:中文没有空格分词,doc.split(" ") 把整句话当成一个词,无法匹配。中文必须先用jieba分词

2.5.3 中文分词 + BM25

  • 案例代码:
import jieba
from rank_bm25 import BM25Okapi

# 准备文档集
documents = [
    # 科技类 —— 苹果公司
    "苹果公司发布了新款iPhone 15,搭载A17芯片和潜望式长焦镜头",
    "苹果公司CEO蒂姆·库克在秋季发布会上介绍了Vision Pro头显设备",
    "苹果公司市值突破三万亿,成为全球最有价值的科技公司",
    "苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升",
    # 科技类 —— 其他公司
    "华为Mate 60搭载麒麟芯片强势回归,引发市场广泛关注",
    "三星发布了新一代折叠屏手机Galaxy Z Fold,采用钛合金铰链",
    # 水果类
    "苹果是一种营养丰富的水果,含有丰富的维生素和膳食纤维",
    "每天吃一个苹果有益健康,可以降低胆固醇并帮助消化",
    "红富士苹果产地直供,新鲜采摘,脆甜多汁口感极佳",
    "水果市场行情分析:苹果价格稳定,柑橘类水果销量上升",
]

# 中文停用词表(常见停用词,避免无意义词干扰BM25排序)
STOP_WORDS = set([
    "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都",
    "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会",
    "着", "没有", "看", "好", "自己", "这", "那", "他", "她", "它", "们",
    "这个", "那个", "什么", "哪", "怎么", "吗", "呢", "吧", "啊", "哦",
    "还", "被", "把", "让", "从", "对", "与", "但", "而", "或", "所",
    "为", "以", "及", "可", "可以", "能", "能够", "应该", "需要", "已经",
    "虽然", "如果", "因为", "所以", "只是", "还是", "不过", "然后",
    "之", "其", "中", "等", "等等", "即", "使", "向", "将", "按", "当",
    "于", "由", "比", "除了", "关于", "以及", "并且", "此外", "另外",
    "过", "着", "来", "去", "做", "作", "像", "如", "如同", "由于",
])

def tokenize(text):
    """分词并过滤停用词和标点符号"""
    tokens = jieba.cut(text)
    return [t for t in tokens if t.strip() not in STOP_WORDS and len(t.strip()) > 1]

# 使用jieba分词 + 停用词过滤
tokenized_corpus = [tokenize(doc) for doc in documents]
print("分词结果预览(已过滤停用词):")
for i, tokens in enumerate(tokenized_corpus):
    print(f"  D{i+1}: {tokens}")

bm25 = BM25Okapi(tokenized_corpus)

# 查询
query = "苹果公司的最新动态是什么"
tokenized_query = tokenize(query)
print(f"\n查询分词(已过滤停用词): {tokenized_query}")

scores = bm25.get_scores(tokenized_query)

# 按得分降序排列
results = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
print("\nBM25检索结果:")
for rank, (idx, score) in enumerate(results, start=1):
    print(f"  第{rank}名: D{idx+1} | 得分={score:.4f} | {documents[idx]}")
  • 运行结果:
分词结果预览(已过滤停用词):
  D1: ['苹果公司', '发布', '新款', 'iPhone', '15', '搭载', 'A17', '芯片', '潜望', '长焦', '镜头']
  D2: ['苹果公司', 'CEO', '蒂姆', '库克', '秋季', '发布会', '介绍', 'Vision', 'Pro', '头显', '设备']
  D3: ['苹果公司', '市值', '突破', '万亿', '成为', '全球', '最有', '价值', '科技', '公司']
  D4: ['苹果公司', '新款', 'MacBook', '采用', '自研', 'M3', '芯片', '性能', '大幅', '提升']
  D5: ['华为', 'Mate', '60', '搭载', '麒麟', '芯片', '强势', '回归', '引发', '市场', '广泛', '关注']
  D6: ['三星', '发布', '新一代', '折叠', '手机', 'Galaxy', 'Fold', '采用', '钛合金', '铰链']
  D7: ['苹果', '一种', '营养', '丰富', '水果', '含有', '丰富', '维生素', '膳食', '纤维']
  D8: ['每天', '苹果', '有益健康', '降低', '胆固醇', '帮助', '消化']
  D9: ['红富士', '苹果', '产地', '直供', '新鲜', '采摘', '脆甜', '多汁', '口感', '极佳']
  D10: ['水果市场', '行情', '分析', '苹果', '价格', '稳定', '柑橘类', '水果', '销量', '上升']

查询分词(已过滤停用词): ['苹果公司', '最新', '动态']

BM25检索结果:
  第1名: D3 | 得分=0.3694 | 苹果公司市值突破三万亿,成为全球最有价值的科技公司
  第2名: D4 | 得分=0.3694 | 苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升
  第3名: D1 | 得分=0.3535 | 苹果公司发布了新款iPhone 15,搭载A17芯片和潜望式长焦镜头
  第4名: D2 | 得分=0.3535 | 苹果公司CEO蒂姆·库克在秋季发布会上介绍了Vision Pro头显设备
  第5名: D5 | 得分=0.0000 | 华为Mate 60搭载麒麟芯片强势回归,引发市场广泛关注
  第6名: D6 | 得分=0.0000 | 三星发布了新一代折叠屏手机Galaxy Z Fold,采用钛合金铰链
  第7名: D7 | 得分=0.0000 | 苹果是一种营养丰富的水果,含有丰富的维生素和膳食纤维
  第8名: D8 | 得分=0.0000 | 每天吃一个苹果有益健康,可以降低胆固醇并帮助消化
  第9名: D9 | 得分=0.0000 | 红富士苹果产地直供,新鲜采摘,脆甜多汁口感极佳
  第10名: D10 | 得分=0.0000 | 水果市场行情分析:苹果价格稳定,柑橘类水果销量上升
  • 观察:
    • 过滤停用词后,"的"、"是"、"了"等无意义词不再干扰 BM25 排序
    • D1 和 D3 包含"苹果公司",排名前二,成功将"公司"相关文档排在前面
    • D2 和 D4 只包含"苹果"(水果含义),得分为0(因为查询中"苹果公司"是一个整体词,与单独的"苹果"不匹配)
    • 这就是 jieba 分词将"苹果公司"识别为一个整体带来的效果

2.6 封装BM25检索器

  • 为了方便后续与向量检索进行混合,我们封装一个可复用的 BM25 检索器类:
import jieba
from rank_bm25 import BM25Okapi

# 中文停用词表
STOP_WORDS = set([
    "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都",
    "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会",
    "着", "没有", "看", "好", "自己", "这", "那", "他", "她", "它", "们",
    "这个", "那个", "什么", "哪", "怎么", "吗", "呢", "吧", "啊", "哦",
    "还", "被", "把", "让", "从", "对", "与", "但", "而", "或", "所",
    "为", "以", "及", "可", "可以", "能", "能够", "应该", "需要", "已经",
    "虽然", "如果", "因为", "所以", "只是", "还是", "不过", "然后",
    "之", "其", "中", "等", "等等", "即", "使", "向", "将", "按", "当",
    "于", "由", "比", "除了", "关于", "以及", "并且", "此外", "另外",
    "过", "着", "来", "去", "做", "作", "像", "如", "如同", "由于",
])


class BM25Retriever:
    """基于BM25的关键词检索器"""

    def __init__(self, documents, language="chinese"):
        """
        documents: 原始文档列表(未分词的中文文本)
        language: 语言类型,中文需要jieba分词
        """
        self.documents = documents
        if language == "chinese":
            self.tokenized_corpus = [
                [t for t in jieba.cut(doc)
                 if t.strip() not in STOP_WORDS and len(t.strip()) > 1]
                for doc in documents
            ]
        else:
            self.tokenized_corpus = [doc.split(" ") for doc in documents]
        self.bm25 = BM25Okapi(self.tokenized_corpus)

    def retrieve(self, query, top_k=5):
        """
        检索与查询最相关的top_k个文档
        返回: [(文档索引, BM25得分, 文档内容), ...]
        """
        tokenized_query = [
            t for t in jieba.cut(query)
            if t.strip() not in STOP_WORDS and len(t.strip()) > 1
        ]
        scores = self.bm25.get_scores(tokenized_query)
        # 按得分降序取top_k
        top_indices = sorted(
            range(len(scores)), key=lambda i: scores[i], reverse=True
        )[:top_k]
        return [(idx, scores[idx], self.documents[idx]) for idx in top_indices]


if __name__ == "__main__":
    docs = [
        # 科技类 —— 苹果公司
        "苹果公司发布了新款iPhone 15,搭载A17芯片和潜望式长焦镜头",
        "苹果公司CEO蒂姆·库克在秋季发布会上介绍了Vision Pro头显设备",
        "苹果公司市值突破三万亿,成为全球最有价值的科技公司",
        "苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升",
        # 科技类 —— 其他公司
        "华为Mate 60搭载麒麟芯片强势回归,引发市场广泛关注",
        "三星发布了新一代折叠屏手机Galaxy Z Fold,采用钛合金铰链",
        # 水果类
        "苹果是一种营养丰富的水果,含有丰富的维生素和膳食纤维",
        "每天吃一个苹果有益健康,可以降低胆固醇并帮助消化",
        "红富士苹果产地直供,新鲜采摘,脆甜多汁口感极佳",
        "水果市场行情分析:苹果价格稳定,柑橘类水果销量上升",
    ]
    retriever = BM25Retriever(docs)
    results = retriever.retrieve("苹果公司的最新动态", top_k=3)
    for rank, (idx, score, doc) in enumerate(results, start=1):
        print(f"{rank}. [D{idx+1}] 得分={score:.4f} → {doc}")

2.7 BM25的优势与局限

优势 局限
无需训练,开箱即用 无法理解同义词("汽车"≠"轿车")
计算速度快,纯CPU即可 无法处理一词多义("苹果"公司 vs 水果)
专有名词、数字、代码精确匹配 OOV(未登录词)问题
可解释性强:能明确说出匹配了哪些词 忽略词序信息("A打败B" = "B打败A")
配合分词后对中文支持良好 对长尾、改写查询效果差
无需向量数据库,内存即可构建索引 不捕获语义关系

2.8 总结

  • BM25是TF-IDF的改进版,通过 $k_1$ 控制词频饱和、$b$ 控制文档长度归一化
  • 在中文场景中,BM25必须配合分词工具(如jieba)使用
  • BM25擅长精确关键词匹配(专有名词、型号、编号),这正是向量检索的短板
  • rank_bm25 库提供了开箱即用的BM25实现,适合学习和小型项目

3、向量检索回顾与深入

3.1 向量检索原理回顾

  • 在第8讲(ChromaDB向量数据库)中,我们已经完整学习了向量检索的流程:

    1. 文档 → Embedding模型 → 高维向量 → 存入向量数据库
    2. 用户查询 → 同一Embedding模型 → 查询向量
    3. 查询向量与文档向量计算余弦相似度 → 返回Top-K最相似文档
  • 向量检索的核心优势在于语义理解能力

    • "如何学习编程" 能匹配到 "编程入门指南"
    • "怎样做红烧肉" 能匹配到 "红烧肉的烹饪方法"
    • 即使关键词完全不同,只要语义相近,就能被检索到

3.2 向量检索的失效场景

  • 但向量检索并非万能,以下场景中纯向量检索可能失效。这也是我们引入BM25的根本原因

3.2.1 场景一:产品型号与编号

  • 查询:"iPhone 15 Pro Max 256GB 蓝色"
  • 问题:向量模型可能无法精确区分 "iPhone 15 Pro Max" 和 "iPhone 15 Pro" 或 "iPhone 14 Pro Max"
  • 原因:这些型号在语义空间中非常接近,向量模型将它们映射到几乎相同的位置
  • BM25的优势:通过精确字符串匹配,能准确识别型号

3.2.2 场景二:错误代码与日志

  • 查询:"ERROR_CODE_50023 是什么原因"
  • 问题:向量模型不认识这个自定义错误码,只能依赖上下文词("错误"、"原因")进行模糊匹配
  • BM25的优势:精确匹配 "ERROR_CODE_50023",返回包含该代码的文档

3.2.3 场景三:人名与实体名

  • 查询:"张三的简历"
  • 问题:在向量空间中,"张三"和"张四"、"张五"的向量距离可能非常接近(都是人名+姓氏相同)
  • BM25的优势:精确匹配"张三",不会混淆

3.2.4 场景四:法律条款、合同编号

  • 查询:"《民法典》第1043条"

  • 问题:向量模型无法理解法律条款编号的精确含义

  • BM25的优势:通过精确字符串匹配,找到包含"第1043条"的文档

  • 总结:向量检索在"意思相近"的场景中表现出色,但在"字面精确"的场景中存在盲区。BM25恰好填补了这个盲区

3.3 实战:基于ChromaDB构建向量检索器

  • 为后续混合检索做准备,我们封装一个向量检索器:
import os
from chromadb import Client
from chromadb.config import Settings
from langchain_huggingface import HuggingFaceEmbeddings


class VectorRetriever:
    """基于ChromaDB的稠密向量检索器"""

    def __init__(self, documents, model_name="BAAI/bge-base-zh-v1.5"):
        """
        documents: 文档列表
        model_name: Embedding模型名称
        """
        self.documents = documents

        # 初始化Embedding模型
        self.embeddings = HuggingFaceEmbeddings(
            model_name=model_name,
            cache_folder=os.path.join(os.getcwd(), "models", "bge-base-zh-v1.5"),
            model_kwargs={"device": "cuda"},
            encode_kwargs={"normalize_embeddings": True},
        )

        # 初始化ChromaDB(内存模式)
        self.client = Client(Settings(anonymized_telemetry=False))
        self.collection = self.client.create_collection(
            name="documents", get_or_create=True
        )

        # 构建索引
        self._build_index()

    def _build_index(self):
        """将文档向量化并存入ChromaDB"""
        for i, doc in enumerate(self.documents):
            vector = self.embeddings.embed_query(doc)
            self.collection.add(
                ids=[str(i)],
                embeddings=[vector],
                documents=[doc],
            )
        print(f"向量索引构建完成,共 {len(self.documents)} 个文档")

    def retrieve(self, query, top_k=5):
        """
        检索与查询最相关的top_k个文档
        返回: [(文档索引, 相似度得分, 文档内容), ...]
        """
        query_vector = self.embeddings.embed_query(query)
        results = self.collection.query(
            query_embeddings=[query_vector],
            n_results=top_k,
        )
        output = []
        for i, doc_id in enumerate(results["ids"][0]):
            idx = int(doc_id)
            distance = results["distances"][0][i]
            # 将距离转换为相似度(余弦距离 → 余弦相似度)
            similarity = 1 - distance
            output.append((idx, similarity, self.documents[idx]))
        return output


if __name__ == "__main__":
    docs = [
        # 科技类 —— 苹果公司
        "苹果公司发布了新款iPhone 15,搭载A17芯片和潜望式长焦镜头",
        "苹果公司CEO蒂姆·库克在秋季发布会上介绍了Vision Pro头显设备",
        "苹果公司市值突破三万亿,成为全球最有价值的科技公司",
        "苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升",
        # 科技类 —— 其他公司
        "华为Mate 60搭载麒麟芯片强势回归,引发市场广泛关注",
        "三星发布了新一代折叠屏手机Galaxy Z Fold,采用钛合金铰链",
        # 水果类
        "苹果是一种营养丰富的水果,含有丰富的维生素和膳食纤维",
        "每天吃一个苹果有益健康,可以降低胆固醇并帮助消化",
        "红富士苹果产地直供,新鲜采摘,脆甜多汁口感极佳",
        "水果市场行情分析:苹果价格稳定,柑橘类水果销量上升",
    ]
    retriever = VectorRetriever(docs)
    results = retriever.retrieve("苹果公司的最新动态", top_k=4)

    print("\n向量检索结果:")
    for rank, (idx, score, doc) in enumerate(results, start=1):
        print(f"  {rank}. [D{idx + 1}] 相似度={score:.4f} → {doc}")
  • 运行结果:
向量索引构建完成,共 10 个文档

向量检索结果:
  1. [D3] 相似度=0.3439 → 苹果公司市值突破三万亿,成为全球最有价值的科技公司
  2. [D1] 相似度=0.2558 → 苹果公司发布了新款iPhone 15,搭载A17芯片和潜望式长焦镜头
  3. [D2] 相似度=0.1869 → 苹果公司CEO蒂姆·库克在秋季发布会上介绍了Vision Pro头显设备
  4. [D4] 相似度=0.1561 → 苹果公司将在新款MacBook上采用自研M3芯片,性能大幅提升
  • 观察:
    • 向量检索成功将D1和D3排在前面(语义上更接近"公司动态")
    • 但D2(水果)仍然得到了0.65的相似度,说明向量模型对"苹果"的两种含义产生了一定的混淆
    • 如果查询是更精确的关键词(如"iPhone 15 Pro Max"),这种混淆会更加明显

3.4 总结

  • 向量检索的语义理解能力强大,但在专有名词、编号、代码等精确匹配场景存在盲区
  • 向量检索的四个典型失效场景:产品型号、错误代码、人名实体、法律条款
  • 这些失效场景恰好是BM25的优势场景,构成了混合检索的核心动机
  • 将BM25Retriever和VectorRetriever都封装为统一接口(retrieve(query, top_k)),为后续混合融合做好准备

4、BM25+向量检索实战

  • 这是本讲的核心章节。我们将从零构建一个完整的混合检索系统,包含三个层次的实战

4.1 混合检索的核心问题:分数融合

  • BM25检索和向量检索返回的结果在分数维度上有本质差异:
维度 BM25得分 向量相似度得分
范围 无上限(取决于查询和文档长度) 通常在 [0, 1] 或 [-1, 1]
含义 关键词匹配程度(非归一化) 语义相似度(归一化)
分布 可能跨度很大(0到几百) 集中在某个区间(如0.5~1)
可比性 不同查询间的得分不可直接比较 不同查询间的相似度可比较
  • 因为两种分数的量纲和分布完全不同,不能直接将BM25得分和向量相似度相加
  • 解决思路有两种主流方案:RRF(Reciprocal Rank Fusion)加权分数融合

4.2 RRF算法

4.2.1 RRF的数学原理

  • RRF(Reciprocal Rank Fusion)的核心思想非常巧妙:不关心分数的绝对值,只关心排名

  • 公式:

$$
RRF(d) = \sum_{r \in R} \frac{1}{k + r(d)}
$$

  • 其中:

    • $R$ 是所有参与融合的检索器集合(如 BM25 + 向量检索)
    • $r(d)$ 是文档 $d$ 在某个检索器中的排名(第1名 = 1,第2名 = 2,...)
    • $k$ 是平滑参数,通常取 60,防止第1名(分母=61)和第2名(分母=62)的差距过大
  • RRF 的三个关键特性:

    1. 排名越高,贡献越大:第1名贡献 $1/61 \approx 0.0164$,第10名贡献 $1/70 \approx 0.0143$
    2. 只依赖排名,不依赖原始分数:解决了不同检索器分数不可比的问题
    3. $k$ 值调节平滑度:$k$ 越大,排名之间的差异越小(更"民主");$k$ 越小,排名靠前的文档优势越大

4.2.2 案例助解:手工计算RRF

  • 假设有4篇文档,BM25和向量检索分别给出如下排名:
文档 BM25排名 向量检索排名
D1 1 3
D2 3 1
D3 2 2
D4 4 4
  • 取 $k = 60$,计算RRF得分:
文档 BM25贡献 向量贡献 RRF总分 最终排名
D1 1/61 = 0.01639 1/63 = 0.01587 0.03226 1
D2 1/63 = 0.01587 1/61 = 0.01639 0.03226 1
D3 1/62 = 0.01613 1/62 = 0.01613 0.03226 1
D4 1/64 = 0.01563 1/64 = 0.01563 0.03125 2
  • 观察:D1、D2、D3得分相同(都是第1+第3或第2+第2),这体现了RRF的公平性—— 一个检索器排第1、另一个排第3,与两个都排第2,重要性相当

4.2.3 RRF代码实现

def reciprocal_rank_fusion(rankings_list, k=60):
    """
    对多个检索器的排名结果进行RRF融合

    参数:
        rankings_list: 列表的列表,每个子列表是 [(doc_id, score), ...],
                       按得分降序排列
        k: RRF平滑参数,默认60

    返回:
        [(doc_id, rrf_score), ...],按RRF分数降序排列
    """
 
    rrf_scores = {}

    for rankings in rankings_list:
        for rank, (doc_id, _) in enumerate(rankings, start=1):
            if doc_id not in rrf_scores:
                rrf_scores[doc_id] = 0
            rrf_scores[doc_id] += 1 / (k + rank)

    # 按RRF分数降序排列
    sorted_results = sorted(
        rrf_scores.items(), key=lambda x: x[1], reverse=True
    )
    return sorted_results


if __name__ == "__main__":
    # 模拟BM25排名(doc_id, bm25_score)
    bm25_rankings = [("D1", 3.5), ("D3", 2.8), ("D2", 1.2), ("D4", 0.5)]
    # 模拟向量检索排名(doc_id, similarity)
    vector_rankings = [("D2", 0.92), ("D3", 0.88), ("D1", 0.75), ("D4", 0.60)]

    fused = reciprocal_rank_fusion([bm25_rankings, vector_rankings])
    for doc_id, rrf_score in fused:
        print(f"{doc_id}: RRF得分 = {rrf_score:.6f}")
  • 运行结果:
D1: RRF得分 = 0.032266
D2: RRF得分 = 0.032266
D3: RRF得分 = 0.032258
D4: RRF得分 = 0.031250

4.3 加权分数融合

  • RRF 忽略了原始分数的大小差异。在某些场景中,我们可能希望保留原始分数的信息(比如BM25得分特别高的文档应该更有优势)

  • 加权分数融合是 RRF 的替代方案:

  • 步骤

    1. 对 BM25 得分做 Min-Max 归一化,映射到 [0, 1] 区间
    2. 对向量相似度做同样的归一化
    3. 加权求和:$\text{final_score} = \alpha \cdot \text{bm25_norm} + (1 - \alpha) \cdot \text{vector_norm}$
    4. $\alpha$ 是超参数(默认0.5),控制 BM25 和向量检索的权重
  • 加权融合代码实现:

def min_max_normalize(scores):
    """将分数列表归一化到[0, 1]区间"""
    min_score = min(scores)
    max_score = max(scores)
    if max_score == min_score:
        return [0.5] * len(scores)  # 所有分数相同,返回中间值
    return [(s - min_score) / (max_score - min_score) for s in scores]


def weighted_score_fusion(bm25_results, vector_results, alpha=0.5):
    """
    对BM25和向量检索结果进行加权分数融合

    参数:
        bm25_results: [(doc_id, bm25_score), ...]
        vector_results: [(doc_id, similarity), ...]
        alpha: BM25的权重(0~1),1-alpha为向量检索权重

    返回:
        [(doc_id, final_score), ...],按最终分数降序排列
    """
    # 构建得分字典
    bm25_dict = {doc_id: score for doc_id, score in bm25_results}
    vector_dict = {doc_id: score for doc_id, score in vector_results}

    # 获取所有涉及的文档ID
    all_doc_ids = set(bm25_dict.keys()) | set(vector_dict.keys())

    # 归一化BM25得分
    bm25_scores = [bm25_dict.get(did, 0) for did in all_doc_ids]
    bm25_norm = dict(zip(all_doc_ids, min_max_normalize(bm25_scores)))

    # 归一化向量得分
    vector_scores = [vector_dict.get(did, 0) for did in all_doc_ids]
    vector_norm = dict(zip(all_doc_ids, min_max_normalize(vector_scores)))

    # 加权融合
    final_scores = {}
    for doc_id in all_doc_ids:
        final_scores[doc_id] = (
            alpha * bm25_norm[doc_id] + (1 - alpha) * vector_norm[doc_id]
        )

    return sorted(final_scores.items(), key=lambda x: x[1], reverse=True)

4.4 RRF vs 加权融合对比

维度 RRF(排名融合) 加权分数融合
输入 只需要排名,不需要原始分数 需要原始分数
分数可比性 天然解决(只看排名) 需要归一化处理
参数 只有 $k$ 一个参数,简单 $\alpha$ 权重参数,需要根据场景调节
适用场景 检索器输出分数不可比时(通用方案) 检索器分数可归一化时(需要分数质量好)
鲁棒性 非常鲁棒,对异常分数不敏感 对异常分数敏感(极端值影响归一化)
推荐度 ⭐⭐⭐⭐⭐ 首选方案 ⭐⭐⭐ 备选方案

4.5 实战一:手动实现完整混合检索系统

  • 现在我们将前面封装的 BM25RetrieverVectorRetriever 组合起来,构建完整的混合检索系统

4.5.1 环境准备

pip install rank_bm25 jieba chromadb langchain-huggingface sentence-transformers

4.5.2 完整代码

import os

import jieba
from rank_bm25 import BM25Okapi
from chromadb import Client
from chromadb.config import Settings
from langchain_huggingface import HuggingFaceEmbeddings


# ==================== BM25检索器 ====================

# 中文停用词表
STOP_WORDS = set([
    "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都",
    "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会",
    "着", "没有", "看", "好", "自己", "这", "那", "他", "她", "它", "们",
    "这个", "那个", "什么", "哪", "怎么", "吗", "呢", "吧", "啊", "哦",
    "还", "被", "把", "让", "从", "对", "与", "但", "而", "或", "所",
    "为", "以", "及", "可", "可以", "能", "能够", "应该", "需要", "已经",
    "虽然", "如果", "因为", "所以", "只是", "还是", "不过", "然后",
    "之", "其", "中", "等", "等等", "即", "使", "向", "将", "按", "当",
    "于", "由", "比", "除了", "关于", "以及", "并且", "此外", "另外",
    "过", "着", "来", "去", "做", "作", "像", "如", "如同", "由于",
])

def tokenize(text):
    """分词并过滤停用词和标点符号"""
    return [t for t in jieba.cut(text)
            if t.strip() not in STOP_WORDS and len(t.strip()) > 1]


class BM25Retriever:
    """基于BM25的关键词检索器"""

    def __init__(self, documents):
        self.documents = documents
        self.tokenized_corpus = [tokenize(doc) for doc in documents]
        self.bm25 = BM25Okapi(self.tokenized_corpus)

    def retrieve(self, query, top_k=10):
        tokenized_query = tokenize(query)
        scores = self.bm25.get_scores(tokenized_query)
        top_indices = sorted(
            range(len(scores)), key=lambda i: scores[i], reverse=True
        )[:top_k]
        return [(idx, scores[idx]) for idx in top_indices]


# ==================== 向量检索器 ====================

class VectorRetriever:
    """基于ChromaDB的稠密向量检索器"""

    def __init__(self, documents, model_name="BAAI/bge-base-zh-v1.5"):
        self.documents = documents
        self.embeddings = HuggingFaceEmbeddings(
            model_name=model_name,
            cache_folder=os.path.join(os.getcwd(), "models", "bge-base-zh-v1.5"),
            model_kwargs={"device": "cuda"},
            encode_kwargs={"normalize_embeddings": True},
        )
        self.client = Client(Settings(anonymized_telemetry=False))
        self.collection = self.client.create_collection(
            name="hybrid_search_demo", get_or_create=True
        )
        self._build_index()

    def _build_index(self):
        for i, doc in enumerate(self.documents):
            vector = self.embeddings.embed_query(doc)
            self.collection.add(
                ids=[str(i)],
                embeddings=[vector],
                documents=[doc],
            )
        print(f"向量索引构建完成,共 {len(self.documents)} 个文档")

    def retrieve(self, query, top_k=10):
        query_vector = self.embeddings.embed_query(query)
        results = self.collection.query(
            query_embeddings=[query_vector],
            n_results=top_k,
        )
        output = []
        for i, doc_id in enumerate(results["ids"][0]):
            idx = int(doc_id)
            distance = results["distances"][0][i]
            similarity = 1 - distance
            output.append((idx, similarity))
        return output


# ==================== 融合算法 ====================

def reciprocal_rank_fusion(rankings_list, k=60):
    """RRF排名融合"""
    rrf_scores = {}
    for rankings in rankings_list:
        for rank, (doc_id, _) in enumerate(rankings, start=1):
            if doc_id not in rrf_scores:
                rrf_scores[doc_id] = 0
            rrf_scores[doc_id] += 1 / (k + rank)
    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)


def min_max_normalize(scores):
    """Min-Max归一化到[0,1]"""
    min_s, max_s = min(scores), max(scores)
    if max_s == min_s:
        return [0.5] * len(scores)
    return [(s - min_s) / (max_s - min_s) for s in scores]


def weighted_score_fusion(bm25_results, vector_results, alpha=0.5):
    """加权分数融合"""
    bm25_dict = dict(bm25_results)
    vector_dict = dict(vector_results)
    all_ids = set(bm25_dict.keys()) | set(vector_dict.keys())

    bm25_scores = [bm25_dict.get(did, 0) for did in all_ids]
    bm25_norm = dict(zip(all_ids, min_max_normalize(bm25_scores)))

    vector_scores = [vector_dict.get(did, 0) for did in all_ids]
    vector_norm = dict(zip(all_ids, min_max_normalize(vector_scores)))

    final = {}
    for did in all_ids:
        final[did] = alpha * bm25_norm[did] + (1 - alpha) * vector_norm[did]
    return sorted(final.items(), key=lambda x: x[1], reverse=True)


# ==================== 混合检索系统 ====================

class HybridRetriever:
    """混合检索系统:BM25 + 向量检索 + RRF/加权融合"""

    def __init__(self, documents, fusion_method="rrf", alpha=0.5):
        """
        documents: 文档列表
        fusion_method: 融合方法,"rrf" 或 "weighted"
        alpha: 加权融合时BM25的权重(仅weighted方法使用)
        """
        self.documents = documents
        self.fusion_method = fusion_method
        self.alpha = alpha

        print("初始化BM25检索器...")
        self.bm25_retriever = BM25Retriever(documents)

        print("初始化向量检索器...")
        self.vector_retriever = VectorRetriever(documents)

        print("混合检索器初始化完成!")

    def retrieve(self, query, top_k=5):
        """
        执行混合检索
        返回: [(文档索引, 融合得分, 文档内容), ...]
        """
        # 双路召回
        bm25_results = self.bm25_retriever.retrieve(query, top_k=top_k * 2)
        vector_results = self.vector_retriever.retrieve(query, top_k=top_k * 2)

        # 分数融合
        if self.fusion_method == "rrf":
            fused = reciprocal_rank_fusion([bm25_results, vector_results])
        else:
            fused = weighted_score_fusion(bm25_results, vector_results, self.alpha)

        # 取top_k
        top_results = fused[:top_k]
        return [
            (doc_id, score, self.documents[doc_id])
            for doc_id, score in top_results
        ]


# ==================== 对比实验 ====================

if __name__ == "__main__":
    # 准备文档集
    documents = [
        "苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片,支持USB-C接口",
        "苹果是一种营养丰富的水果,含有丰富的维生素C和膳食纤维",
        "苹果公司的CEO蒂姆·库克在2024年WWDC上发布了Apple Intelligence",
        "每天吃一个苹果有益健康,医生远离我",
        "苹果公司的MacBook Pro搭载了M4芯片,性能大幅提升",
        "红富士苹果是中国最受欢迎的苹果品种之一",
        "苹果公司2024年Q4财报显示iPhone收入同比增长6%",
        "苹果汁的制作方法:将新鲜苹果洗净切块,放入榨汁机",
    ]

    # 初始化混合检索器
    hybrid = HybridRetriever(documents, fusion_method="rrf")

    # 测试查询
    test_queries = [
        "苹果公司最新产品发布",  # 语义理解 + 精确匹配
        "苹果有什么营养价值",    # 语义为主
        "iPhone 15 Pro Max规格",  # 精确匹配为主
    ]

    for query in test_queries:
        print(f"\n{'='*60}")
        print(f"查询: {query}")
        print(f"{'='*60}")

        results = hybrid.retrieve(query, top_k=4)
        for rank, (idx, score, doc) in enumerate(results, start=1):
            print(f"  {rank}. [D{idx+1}] 融合得分={score:.6f} → {doc}")
  • 运行结果示例:
初始化BM25检索器...
初始化向量检索器...
Loading model cost 0.409 seconds.
Prefix dict has been built successfully.
向量索引构建完成,共 8 个文档
混合检索器初始化完成!

============================================================
查询: 苹果公司最新产品发布
============================================================
  1. [D3] 融合得分=0.032522 → 苹果公司的CEO蒂姆·库克在2024年WWDC上发布了Apple Intelligence
  2. [D1] 融合得分=0.032522 → 苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片,支持USB-C接口
  3. [D6] 融合得分=0.031025 → 红富士苹果是中国最受欢迎的苹果品种之一
  4. [D5] 融合得分=0.031010 → 苹果公司的MacBook Pro搭载了M4芯片,性能大幅提升

============================================================
查询: 苹果有什么营养价值
============================================================
  1. [D2] 融合得分=0.032522 → 苹果是一种营养丰富的水果,含有丰富的维生素C和膳食纤维
  2. [D4] 融合得分=0.031754 → 每天吃一个苹果有益健康,医生远离我
  3. [D1] 融合得分=0.031099 → 苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片,支持USB-C接口
  4. [D3] 融合得分=0.031025 → 苹果公司的CEO蒂姆·库克在2024年WWDC上发布了Apple Intelligence

============================================================
查询: iPhone 15 Pro Max规格
============================================================
  1. [D1] 融合得分=0.032787 → 苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片,支持USB-C接口
  2. [D5] 融合得分=0.032258 → 苹果公司的MacBook Pro搭载了M4芯片,性能大幅提升
  3. [D7] 融合得分=0.031498 → 苹果公司2024年Q4财报显示iPhone收入同比增长6%
  4. [D6] 融合得分=0.030798 → 红富士苹果是中国最受欢迎的苹果品种之一

4.6 实战二:使用LangChain集成混合检索

  • LangChain 提供了 BM25RetrieverEnsembleRetriever,可以用少量代码实现混合检索
import os

from langchain_classic.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever as LCBM25Retriever
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from langchain_core.documents import Document


# 准备文档
texts = [
    "苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片",
    "苹果是一种营养丰富的水果,含有丰富的维生素C",
    "苹果公司的CEO蒂姆·库克在WWDC上发布了Apple Intelligence",
    "每天吃一个苹果有益健康,医生远离我",
]
documents = [
    Document(page_content=text, metadata={"source": f"doc_{i}"})
    for i, text in enumerate(texts)
]

# 初始化Embedding
embeddings = HuggingFaceEmbeddings(
    model_name="BAAI/bge-base-zh-v1.5",
    cache_folder=os.path.join(os.getcwd(), "models", "bge-base-zh-v1.5"),
    model_kwargs={"device": "cuda"},
    encode_kwargs={"normalize_embeddings": True},
)

# 构建向量存储
vector_store = Chroma.from_documents(
    documents=documents,
    embedding=embeddings,
    collection_name="langchain_demo",
)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 4})

# 构建BM25检索器
bm25_retriever = LCBM25Retriever.from_documents(
    documents, k=4
)

# 构建混合检索器(EnsembleRetriever默认使用RRF融合)
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5],  # 各占50%权重
)

# 执行检索
query = "苹果公司最新产品"
results = ensemble_retriever.invoke(query)

print(f"查询: {query}\n")
for rank, doc in enumerate(results, start=1):
    print(f"{rank}. {doc.page_content}")
  • 运行结果示例:
查询: 苹果公司最新产品

1. 苹果公司的CEO蒂姆·库克在WWDC上发布了Apple Intelligence
2. 每天吃一个苹果有益健康,医生远离我
3. 苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片
4. 苹果是一种营养丰富的水果,含有丰富的维生素C
  • LangChain方案 vs 手动实现对比:
维度 手动实现 LangChain EnsembleRetriever
代码量 约150行 约30行
灵活性 完全可控,可自定义融合算法 使用内置RRF,自定义空间有限
学习价值 深入理解原理 快速集成,适合生产
生产就绪度 需要自行优化 LangChain生态,集成度高
推荐场景 学习和研究 项目实战

4.7 实战三:加入Reranker的增强混合检索

  • 在第9讲(知识库构建工具库)中我们介绍了Reranker(重排序模型)。将Reranker加入混合检索流水线可以进一步提升精度

  • 完整流水线架构

flowchart TD A[用户查询] --> B[BM25关键词检索<br/>粗排 Top-20] A --> C[向量语义检索<br/>粗排 Top-20] B --> D[RRF / 加权融合] C --> D D --> E[融合结果 Top-20] E --> F[Reranker 精排<br/>Cross-Encoder] F --> G[最终结果 Top-5] G --> H[LLM 生成回答]
  • 核心思路:

    • 粗排(Recall阶段):混合检索召回Top-20,目标是"尽量不遗漏"
    • 精排(Rerank阶段):使用Cross-Encoder(交叉编码器)对20条结果逐条打分,筛选出Top-5
    • 生成(Answer阶段):将Top-5文档作为上下文,交给LLM生成回答
  • 案例代码:

import os

import jieba
from rank_bm25 import BM25Okapi
from chromadb import Client
from chromadb.config import Settings
from langchain_huggingface import HuggingFaceEmbeddings
from FlagEmbedding import FlagReranker


# ==================== 粗排:混合检索(复用之前的代码) ====================

# 中文停用词表
STOP_WORDS = set([
    "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都",
    "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会",
    "着", "没有", "看", "好", "自己", "这", "那", "他", "她", "它", "们",
    "这个", "那个", "什么", "哪", "怎么", "吗", "呢", "吧", "啊", "哦",
    "还", "被", "把", "让", "从", "对", "与", "但", "而", "或", "所",
    "为", "以", "及", "可", "可以", "能", "能够", "应该", "需要", "已经",
    "虽然", "如果", "因为", "所以", "只是", "还是", "不过", "然后",
    "之", "其", "中", "等", "等等", "即", "使", "向", "将", "按", "当",
    "于", "由", "比", "除了", "关于", "以及", "并且", "此外", "另外",
    "过", "着", "来", "去", "做", "作", "像", "如", "如同", "由于",
])

def tokenize(text):
    """分词并过滤停用词和标点符号"""
    return [t for t in jieba.cut(text)
            if t.strip() not in STOP_WORDS and len(t.strip()) > 1]


class BM25Retriever:
    """BM25关键词检索器"""

    def __init__(self, documents):
        self.documents = documents
        self.tokenized_corpus = [tokenize(doc) for doc in documents]
        self.bm25 = BM25Okapi(self.tokenized_corpus)

    def retrieve(self, query, top_k=20):
        tokenized_query = tokenize(query)
        scores = self.bm25.get_scores(tokenized_query)
        top_indices = sorted(
            range(len(scores)), key=lambda i: scores[i], reverse=True
        )[:top_k]
        return [(idx, scores[idx]) for idx in top_indices]


class VectorRetriever:
    """向量语义检索器"""

    def __init__(self, documents, model_name="BAAI/bge-base-zh-v1.5"):
        self.documents = documents
        self.embeddings = HuggingFaceEmbeddings(
            model_name=model_name,
            model_kwargs={"device": "cpu"},
            cache_folder=os.path.join(os.getcwd(), "models", "bge-base-zh-v1.5"),
            encode_kwargs={"normalize_embeddings": True},
        )
        self.client = Client(Settings(anonymized_telemetry=False))
        self.collection = self.client.create_collection(
            name="reranker_demo", get_or_create=True
        )
        for i, doc in enumerate(documents):
            vector = self.embeddings.embed_query(doc)
            self.collection.add(ids=[str(i)], embeddings=[vector], documents=[doc])

    def retrieve(self, query, top_k=20):
        query_vector = self.embeddings.embed_query(query)
        results = self.collection.query(
            query_embeddings=[query_vector], n_results=top_k
        )
        output = []
        for i, doc_id in enumerate(results["ids"][0]):
            idx = int(doc_id)
            similarity = 1 - results["distances"][0][i]
            output.append((idx, similarity))
        return output


def reciprocal_rank_fusion(rankings_list, k=60):
    """RRF排名融合"""
    rrf_scores = {}
    for rankings in rankings_list:
        for rank, (doc_id, _) in enumerate(rankings, start=1):
            if doc_id not in rrf_scores:
                rrf_scores[doc_id] = 0
            rrf_scores[doc_id] += 1 / (k + rank)
    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)


# ==================== 精排:Reranker ====================

class RerankerModule:
    """基于FlagEmbedding的Reranker精排模块"""

    def __init__(self, model_name="BAAI/bge-reranker-large"):
        """
        model_name: Reranker模型名称
        也可使用: "BAAI/bge-reranker-base" (更快) 或 "BAAI/bge-reranker-v2-m3" (更强)
        """
        print(f"加载Reranker模型: {model_name} ...")
        self.reranker = FlagReranker(
            model_name,
            use_fp16=True,  # 使用半精度加速
        )

    def rerank(self, query, documents, top_k=5):
        """
        对文档列表进行精排

        参数:
            query: 用户查询
            documents: 待精排的文档列表(字符串列表)
            top_k: 返回前k个

        返回:
            [(文档索引, rerank得分), ...],按得分降序
        """
        # 构建(query, doc)对
        pairs = [[query, doc] for doc in documents]

        # 计算相关性得分
        scores = self.reranker.compute_score(pairs)

        # 按得分降序取top_k
        top_indices = sorted(
            range(len(scores)), key=lambda i: scores[i], reverse=True
        )[:top_k]
        return [(idx, scores[idx]) for idx in top_indices]


# ==================== 完整流水线 ====================

class HybridSearchWithReranker:
    """混合检索 + Reranker精排的完整RAG检索流水线"""

    def __init__(self, documents):
        self.documents = documents
        print("初始化粗排模块...")
        self.bm25 = BM25Retriever(documents)
        self.vector = VectorRetriever(documents)
        print("初始化精排模块...")
        self.reranker = RerankerModule()

    def search(self, query, recall_k=20, rerank_k=5):
        """
        完整检索流水线

        参数:
            query: 用户查询
            recall_k: 粗排召回数量
            rerank_k: 精排返回数量

        返回:
            [(文档内容, BM25排名, 向量排名, RRF得分, Rerank得分), ...]
        """
        # 第一步:双路粗排
        bm25_results = self.bm25.retrieve(query, top_k=recall_k)
        vector_results = self.vector.retrieve(query, top_k=recall_k)

        # 第二步:RRF融合
        fused = reciprocal_rank_fusion([bm25_results, vector_results])

        # 第三步:取出融合后的文档内容(Top-20)
        fused_doc_ids = [doc_id for doc_id, _ in fused[:recall_k]]
        fused_docs = [self.documents[did] for did in fused_doc_ids]

        # 第四步:Reranker精排
        reranked = self.reranker.rerank(query, fused_docs, top_k=rerank_k)

        # 第五步:整理输出
        bm25_rank_map = {doc_id: rank for rank, (doc_id, _) in enumerate(bm25_results, start=1)}
        vector_rank_map = {doc_id: rank for rank, (doc_id, _) in enumerate(vector_results, start=1)}
        rrf_score_map = dict(fused)

        results = []
        for fused_pos, rerank_score in reranked:
            doc_id = fused_doc_ids[fused_pos]
            results.append({
                "content": self.documents[doc_id],
                "bm25_rank": bm25_rank_map.get(doc_id, "-"),
                "vector_rank": vector_rank_map.get(doc_id, "-"),
                "rrf_score": round(rrf_score_map.get(doc_id, 0), 6),
                "rerank_score": round(rerank_score, 4),
            })
        return results


if __name__ == "__main__":
    documents = [
        "苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片",
        "苹果是一种营养丰富的水果,含有丰富的维生素C",
        "苹果公司的CEO蒂姆·库克在WWDC上发布了Apple Intelligence",
        "每天吃一个苹果有益健康,医生远离我",
        "苹果公司的MacBook Pro搭载了M4芯片,性能大幅提升",
        "红富士苹果是中国最受欢迎的苹果品种之一",
        "苹果公司2024年Q4财报显示iPhone收入同比增长6%",
        "苹果汁的制作方法:将新鲜苹果洗净切块,放入榨汁机",
    ]

    pipeline = HybridSearchWithReranker(documents)

    query = "苹果公司最新产品发布"
    print(f"\n查询: {query}\n")

    results = pipeline.search(query, recall_k=20, rerank_k=5)

    print(f"{'排名':<6}{'BM25排名':<10}{'向量排名':<10}{'RRF得分':<12}{'Rerank得分':<12}{'内容'}")
    print("-" * 100)
    for rank, r in enumerate(results, start=1):
        print(f"{rank:<6}{str(r['bm25_rank']):<10}{str(r['vector_rank']):<10}"
              f"{r['rrf_score']:<12}{r['rerank_score']:<12}{r['content'][:40]}...")
  • 运行结果示例:
初始化粗排模块...
Building prefix dict from the default dictionary ...
Loading model from cache C:\Users\cc\AppData\Local\Temp\jieba.cache
Loading model cost 0.389 seconds.
Prefix dict has been built successfully.
初始化精排模块...
加载Reranker模型: BAAI/bge-reranker-large ...

查询: 苹果公司最新产品发布

You're using a XLMRobertaTokenizerFast tokenizer. Please note that with a fast tokenizer, using the `__call__` method is faster than using a method to encode the text followed by a call to the `pad` method to get a padded encoding.
排名    BM25排名    向量排名      RRF得分       Rerank得分    内容
----------------------------------------------------------------------------------------------------
1     2         1         0.032522    3.6895      苹果公司发布了新款iPhone 15 Pro Max,搭载A17 Pro芯片...
2     1         2         0.032522    0.2659      苹果公司的CEO蒂姆·库克在WWDC上发布了Apple Intelligence...
3     5         4         0.03101     -0.0843     苹果公司的MacBook Pro搭载了M4芯片,性能大幅提升...
4     6         3         0.031025    -2.5801     红富士苹果是中国最受欢迎的苹果品种之一...
5     7         5         0.03031     -3.3887     苹果公司2024年Q4财报显示iPhone收入同比增长6%...
  • Reranker的作用总结:
阶段 模型类型 作用 典型数量 速度
粗排 Bi-Encoder 高效召回,保证不遗漏 1000→20 毫秒级
精排 Cross-Encoder 精细打分,去除噪音 20→5 秒级
生成 LLM 基于Top-5文档生成答案 5→1 秒级

4.8 三种检索方式效果对比

  • 我们将纯BM25、纯向量检索、混合检索(RRF)、混合检索(加权)、混合检索+Reranker五种方案的检索结果汇总对比
查询类型 纯BM25 纯向量检索 混合(RRF) 混合(加权) 混合+Reranker
精确关键词(型号) ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
语义理解(同义改写) ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
混合型查询 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
短查询(2-3个词) ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
长查询(一句话) ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

4.9 总结

  • 混合检索的核心挑战是分数融合,RRF(排名融合)是工业界首选方案
  • 三种融合策略:RRF(推荐首选)、加权分数融合(需要调参)、EnsembleRetriever(LangChain封装)
  • Reranker是混合检索的"提质利器":粗排保证召回,精排保证精度
  • 完整流水线:BM25 + 向量 → RRF融合 → Reranker精排 → LLM生成

5、混合检索的评估与调优

5.1 回顾评估指标

  • 在第10讲(RAG评估指标)中,我们学习了检索系统的核心评估指标:
指标 含义 公式 关注点
Hit Rate 检索结果中至少命中一个相关文档的比例 $\frac{\text{命中次数}}{\text{总查询数}}$ 是否能找到
MRR 第一个相关文档排名的倒数平均值 $\frac{1}{N}\sum\frac{1}{\text{rank}_i}$ 第一个对的多靠前

5.2 参数调优建议

参数 默认值 作用 调优建议
RRF的 $k$ 值 60 控制排名间差异的平滑度 增大 $k$(如100)→ 排名靠后的文档也有机会;减小 $k$(如30)→ 排名靠前的文档优势更大
加权融合 $\alpha$ 0.5 BM25和向量检索的权重分配 专有名词多 → 增大 $\alpha$(偏BM25);同义表达多 → 减小 $\alpha$(偏向量)
粗排 Top-K 20 双路召回的文档数量 混合检索可适当增大(因为后续有Reranker精排),建议 15~30
Reranker精排数量 5 最终传给LLM的文档数 平衡精度和延迟:3~5条足够,过多会增加LLM成本且可能导致信息过载
BM25的 $k_1$ 1.5 词频饱和速度 长文档多 → 适当减小(让词频更快饱和)
BM25的 $b$ 0.75 文档长度惩罚力度 文档长度差异大 → 保持默认;文档长度均匀 → 可减小到0.5

5.3 总结

  • 评估检索效果需要标注数据(查询-相关文档对),用Hit Rate和MRR量化对比
  • 一般情况下:混合检索(RRF)> 纯BM25 ≈ 纯向量检索(取决于查询类型)
  • 加入Reranker后效果进一步提升,是性价比最高的提质手段
  • 参数调优需要根据实际数据特点进行,没有"万能参数"

6、本讲总结与后续展望

6.1 知识地图回顾

flowchart TD A[TF-IDF<br/>词频-逆文档频率] --> B[BM25<br/>词频饱和 + 长度归一化] B --> C[稀疏向量检索<br/>关键词精确匹配] D[Word2Vec / BERT] --> E[Embedding模型<br/>BGE / text2vec] E --> F[稠密向量检索<br/>语义相似匹配] C --> G[混合检索<br/>RRF / 加权融合] F --> G G --> H[Reranker精排<br/>Cross-Encoder] H --> I[LLM生成<br/>RAG最终回答] style G fill:#f9f,stroke:#333,stroke-width:3px style H fill:#bbf,stroke:#333,stroke-width:2px

6.2 核心要点回顾

要点 一句话总结
为什么需要混合检索 向量检索懂"意思",BM25懂"字面",两者互补才能覆盖所有场景
BM25的核心改进 在TF-IDF基础上增加了词频饱和($k_1$)和文档长度归一化($b$)
向量检索的盲区 专有名词、型号、编号、代码等精确匹配场景
RRF融合 不关心分数绝对值,只看排名,是工业界首选融合方案
Reranker的价值 粗排保证召回(不遗漏),精排保证精度(去噪音)
完整流水线 BM25 + 向量 → 融合 → Reranker → LLM

7、融入项目参考

from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnablePassthrough, RunnableParallel, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.documents import Document
from FlagEmbedding import FlagReranker
from ai.models import LoadALYModel
import os
import jieba
from rank_bm25 import BM25Okapi
from chat.util import ChromaUtil

# 项目根路径
path_base = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
# 重排序模型路径
reranker_model_name = os.path.abspath(os.path.join(path_base, 'models', 'bge-reranker-large_v1'))
# 向量数据库集合的名字
vector_db_name = 'law_knowledge_base'

# BM25 索引全局缓存(文档列表 + BM25Okapi 对象)
_bm25_cache = {"docs": None, "tokenized_corpus": None, "bm25": None}


def _build_bm25_index(vector_db):
    """从 Chroma 取出全部文档,构建 BM25 索引(带全局缓存,仅首次构建)"""
    global _bm25_cache

    if _bm25_cache["bm25"] is not None:
        return _bm25_cache["docs"], _bm25_cache["bm25"]

    print("🔄 正在从 Chroma 加载全部文档,构建 BM25 索引...")

    # 获取全部文档(不限制数量)
    all_data = vector_db.get()
    if not all_data or not all_data.get('documents'):
        print("⚠️ Chroma 中无文档,BM25 检索将返回空结果")
        _bm25_cache["docs"] = []
        _bm25_cache["tokenized_corpus"] = []
        _bm25_cache["bm25"] = None
        return [], None

    documents = all_data['documents']
    metadatas = all_data['metadatas'] or [{}] * len(documents)
    ids = all_data.get('ids', [str(i) for i in range(len(documents))])

    print(f"  Chroma 中共有 {len(documents)} 篇文档")

    docs = [
        Document(page_content=text, metadata=meta or {}, id=doc_id)
        for text, meta, doc_id in zip(documents, metadatas, ids)
    ]

    # 中文分词后构建 BM25Okapi
    tokenized_corpus = [list(jieba.cut(doc.page_content)) for doc in docs]
    bm25 = BM25Okapi(tokenized_corpus)

    _bm25_cache["docs"] = docs
    _bm25_cache["tokenized_corpus"] = tokenized_corpus
    _bm25_cache["bm25"] = bm25

    print(f"✅ BM25 索引构建完成,共 {len(docs)} 篇文档")
    return docs, bm25


def _bm25_search(question, bm25, docs, k=10):
    """使用 BM25 进行关键词检索,返回 Top-K 文档"""
    if bm25 is None:
        return []

    tokenized_query = list(jieba.cut(question))
    scores = bm25.get_scores(tokenized_query)
    # 按分数降序取 Top-K
    top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
    return [docs[i] for i in top_indices if scores[i] > 0]


def _rrf_fusion(docs_a, docs_b, k=60):
    """
    Reciprocal Rank Fusion (RRF) 融合两路检索结果。
    对每篇文档,累加它在两路中的 1/(k+rank) 分数,按总分降序排列。
    """
    # 用 id 做去重 key,没有 id 则用 page_content 前 200 字符
    def _get_key(doc):
        return doc.id if hasattr(doc, 'id') and doc.id else doc.page_content[:200]

    doc_map = {}
    score_map = {}

    for rank, doc in enumerate(docs_a, start=1):
        key = _get_key(doc)
        doc_map[key] = doc
        score_map[key] = score_map.get(key, 0) + 1.0 / (k + rank)

    for rank, doc in enumerate(docs_b, start=1):
        key = _get_key(doc)
        doc_map[key] = doc
        score_map[key] = score_map.get(key, 0) + 1.0 / (k + rank)

    sorted_keys = sorted(score_map, key=score_map.get, reverse=True)
    return [doc_map[key] for key in sorted_keys]


def _print_docs(title, docs):
    """打印检索结果"""
    print(f"\n{'=' * 60}")
    print(f"  {title}(共 {len(docs)} 篇)")
    print(f"{'=' * 60}")
    if not docs:
        print("  (无结果)")
        return
    for i, doc in enumerate(docs, 1):
        print(f"  [{i}] {doc.page_content[:300]}{'...' if len(doc.page_content) > 300 else ''}")


def _format_chat_history(chat_history):
    """将对话记录列表格式化为 Prompt 可用的字符串"""
    if not chat_history:
        return "暂无历史对话"
    lines = []
    for record in chat_history:
        if record['role'] == 'user':
            lines.append(f"用户: {record['content']}")
        elif record['role'] == 'ai':
            lines.append(f"助手: {record['content']}")
    return "\n".join(lines)


def _build_messages(chat_history, question):
    """将对话记录 + 当前问题 构建为 LangChain 消息列表"""
    messages = []
    if chat_history:
        for record in chat_history:
            if record['role'] == 'user':
                messages.append(HumanMessage(content=record['content']))
            elif record['role'] == 'ai':
                messages.append(AIMessage(content=record['content']))
    messages.append(HumanMessage(content=question))
    return messages


# 流式
def chat_stream(question, chat_history=None):
    # 获取连接对象
    vector_db = ChromaUtil.get_law_conn()

    # 稠密向量检索器(语义匹配)
    vector_retriever = vector_db.as_retriever(search_kwargs={"k": 10})

    # 构建 BM25 索引(全局缓存,仅首次构建)
    bm25_docs, bm25_index = _build_bm25_index(vector_db)

    # 大模型
    llm = LoadALYModel.load_model()

    # 提示词
    template = """
        你是一个智能问答助手。

        请先判断用户问题是否属于法律相关问题。

        【处理规则】

        1. 如果问题属于法律、法规、合同、劳动纠纷、侵权、婚姻、债务、刑事、行政等法律领域问题:
           - 请优先结合提供的上下文内容进行回答
           - 回答必须基于上下文信息
           - 如果上下文中未提及相关内容,请明确回复:
             "资料中未提及相关内容"

        2. 如果问题不属于法律领域问题:
           - 忽略上下文内容
           - 直接基于你的自身知识正常回答用户问题
           - 不要提及上下文或知识库

        【历史对话】
        {chat_history}

        【上下文】
        {context}

        【用户问题】
        {question}

        请直接输出最终答案。
    """
    prompt = PromptTemplate(template=template, input_variables=["context", "question", "chat_history"])

    # 重排序模型
    reranker_model = FlagReranker(
        model_name_or_path=reranker_model_name,
        use_fp16=True,
        local_files_only=True,
        devices=["cuda:0"]
    )

    # 混合检索函数(手动实现,每步都打印结果)
    def hybrid_retrieve(question):
        """
        1. 向量检索 → 打印结果
        2. BM25 检索 → 打印结果
        3. RRF 融合 → 打印结果
        4. 返回融合后的文档列表
        """
        # 第 1 步:向量检索
        vector_docs = vector_retriever.invoke(question)
        _print_docs("🔵 向量检索结果(语义匹配)", vector_docs)

        # 第 2 步:BM25 关键词检索
        bm25_result = _bm25_search(question, bm25_index, bm25_docs, k=10)
        _print_docs("🟢 BM25 检索结果(关键词匹配)", bm25_result)

        # 第 3 步:RRF 融合
        fused_docs = _rrf_fusion(vector_docs, bm25_result)
        _print_docs("🟣 RRF 融合后结果(混合检索)", fused_docs)

        return fused_docs

    # 重排序
    def reranker(inputs):
        """
        自定义重排序函数
        输入: {"context": List[Document], "question": str, "chat_history": str}
        输出: {"context": str, "question": str, "chat_history": str} (直接适配 PromptTemplate)
        """
        docs = inputs["context"]
        question = inputs["question"]
        chat_history = inputs.get("chat_history", "")

        print(f"\n🔄 开始进行重排序... (候选文档数: {len(docs)})")

        if not docs:
            print("⚠️ 未检索到任何文档,跳过重排序")
            return {"context": "", "question": question, "chat_history": chat_history}

        # 1. 构造 query-document 对并计算相关性分数
        pairs = [[question, doc.page_content] for doc in docs]
        scores = reranker_model.compute_score(pairs)

        # 2. 将分数注入 metadata 并按分数降序排序
        for doc, score in zip(docs, scores):
            doc.metadata["relevance_score"] = round(float(score), 4)
        sorted_docs = sorted(docs, key=lambda d: d.metadata["relevance_score"], reverse=True)

        # 3. 截取 Top-3 高相关文档
        top_n = 3
        final_docs = sorted_docs[:top_n]

        # 4. 打印重排序后的结果
        print(f"✅ 重排序完成,保留 Top-{top_n} 文档:")
        for i, doc in enumerate(final_docs, 1):
            score = doc.metadata.get("relevance_score", "N/A")
            print(f"\n【文档 {i}】(相关度: {score})")
            print(f"  内容: {doc.page_content[:500]}{'...' if len(doc.page_content) > 500 else ''}")
            print("-" * 60)

        # 5. 将文档列表格式化为 context 字符串,供 Prompt 使用
        context_str = "\n\n".join(
            f"[来源{i + 1}] {doc.page_content}" for i, doc in enumerate(final_docs)
        )
        return {"context": context_str, "question": question, "chat_history": chat_history}

    # 将历史对话格式化为字符串
    history_str = _format_chat_history(chat_history)

    # 构造问答链
    qa_chain = (
            RunnableParallel({
                "context": RunnableLambda(hybrid_retrieve),  # 混合检索(含打印)
                "question": RunnablePassthrough(),
                "chat_history": RunnableLambda(lambda _: history_str),
            })
            | RunnableLambda(reranker)  # 重排序(透传 chat_history)
            | prompt
            | llm
            | StrOutputParser()
    )
    for chunk in qa_chain.stream(question):
        if chunk:
            yield chunk


if __name__ == '__main__':
    # 示例:从数据库查出的对话记录
    demo_history = []
    # 当前问题
    current_question = "《中华人民共和国合伙企业法》第十八条规定了什么内容?"
    for chunk in chat_stream(current_question, chat_history=demo_history):
        print(chunk)