AI面包君Learn · Build · Share
03Bread RAG · 第 5
总进度 20 / 33

Hybrid 检索 + LLM Rerank

让模型找到资料。Hybrid 检索 + LLM Rerank,边读边运行配套 Python 代码。

2026-09-0760 分钟380 行代码难度 ★★★★

1. 故事:单一检索的盲区

ch02-ch03 用向量检索。看起来很美——但工业实战会撞墙:

查询向量检索BM25
"BW-20260301-0042 的订单"❌ 编号语义低,命中率差✅ 字面精准匹配
"vegan 选项"✅ 命中"植物基"❌ 完全没共同字
"员工面包福利"半准(命中"员工"语义)半准(命中"福利"字面)

单一检索都有盲区。生产 RAG 都做 hybrid(混合检索):

                     用户查询
                        │
            ┌───────────┴────────────┐
            ▼                        ▼
     +-------------+          +-------------+
     | 向量检索     │          │ BM25 检索    │
     | top-10      │          │ top-10      │
     +-------------+          +-------------+
            │                        │
            └───────────┬────────────┘
                        ▼
                  +----------+
                  | RRF 融合  │       ← 用 Reciprocal Rank Fusion
                  +----------+         合并两个排名列表,跳过分数尺度问题
                        │
                        ▼
                  +-----------+
                  | LLM 重排   │       ← 给 LLM 看 top-K 候选
                  +-----------+         让它选最相关的 final_k 个
                        │
                        ▼
              拼进 prompt 让 LLM 回答

本章把这套完整做出来。


2. 跑起来

cd ch04_hybrid
python main.py

预期输出(截取问题 1 的展示):

======================================================================
问题 1: BW-20260301-0042 这个订单退款时要注意什么?
======================================================================
  [向量检索 top-5]
    #1 [bread_workshop.md] (chunk 5) ## 五、订单编号规则
    #2 [bread_workshop.md] (chunk 4) ## 四、退换货政策
    ...
  [BM25 检索 top-5]
    #1 [bread_workshop.md] (chunk 5) ## 五、订单编号规则
    #2 [bread_workshop.md] (chunk 4) ## 四、退换货政策
    ...
  [Hybrid (RRF) top-5]
    #1 [bread_workshop.md] (chunk 5) score=0.033
    ...
  [LLM 重排 final top-3]
    #1 [bread_workshop.md] (chunk 4) ## 四、退换货政策
    #2 [bread_workshop.md] (chunk 5) ## 五、订单编号规则

AI: 根据订单编号 BW-20260301-0042 退款时,必须提供完整的订单编号...

4 路结果并排展示——这是教学的重点。看每个问题哪一路赢,你会内化"什么场景用什么"。


3. 逐行精讲

BM25:手写的"经典 IR"

class BM25:
    def __init__(self, corpus_tokens, k1=1.5, b=0.75):
        ...
        df = Counter()
        for doc in corpus_tokens:
            for term in set(doc):
                df[term] += 1
        self.idf = {term: math.log((self.N - n + 0.5) / (n + 0.5) + 1) for term, n in df.items()}
        self.tfs = [Counter(doc) for doc in corpus_tokens]
        self.doc_lens = [len(d) for d in corpus_tokens]

    def score(self, query_tokens):
        scores = [0.0] * self.N
        for i in range(self.N):
            for q in query_tokens:
                if q not in self.tfs[i]: continue
                idf = self.idf.get(q, 0.0)
                tf = self.tfs[i][q]
                dl = self.doc_lens[i]
                numer = tf * (self.k1 + 1)
                denom = tf + self.k1 * (1 - self.b + self.b * dl / self.avg_dl)
                scores[i] += idf * numer / denom
        return scores

公式直观解读:

  • tf = 词频。出现越多次得分越高,但有上限(k1 = 1.5 控制递减斜率)
  • idf = 逆文档频率。词越罕见越值钱("加盟"比"的"信息量大)
  • |D|/avgDL = 文档长度归一化。长文档天然容易撞到词,惩罚一下(b = 0.75 控制强度)

BM25 是 2010 年之前 Google 主排序算法的核心。对编号、专有名词、英文术语——它至今没被打败。

RRF:跳过分数尺度的融合艺术

def rrf_fuse(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, idx in enumerate(ranking):
            scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

问题:BM25 分数可以是 050,向量余弦是 01。直接加权融合很糟糕。

解决:RRF 只看排名——第 1 名 = 1/(60+1),第 2 名 = 1/(60+2),...。两路都进 top 几的 chunk 自然累加得高分。完全跳过原始分数尺度

k=60 是 RRF 论文里推荐的默认值。Elasticsearch、OpenSearch、Pinecone 的 hybrid 都用这个套路。

LLM Rerank:用模型当裁判

def llm_rerank(chat_client, model, query, candidates, final_k=3):
    listing = "\n".join(f"[{i}] {c.text[:200]}" for i, c in enumerate(candidates))
    messages = [
        {"role": "system", "content": "你是搜索结果重排序专家..."},
        {"role": "user", "content": f"查询: {query}\n\n候选片段:\n{listing}"},
    ]
    resp = chat_client.chat.completions.create(model=model, messages=messages, temperature=0)
    # 解析 LLM 返回的 "2,0,4" 这种编号序列
    picks = [int(x) for x in re.findall(r"\d+", resp.choices[0].message.content)]
    ...

思想

  • 用 BM25 + 向量取 fetch_k=10 个候选(召回阶段——快、宽)
  • 用 LLM 看完所有候选再选 final_k=3 个(精排阶段——慢、准)

工业级 rerank 通常用专门的 cross-encoder 模型(如 bge-reranker-large),比 LLM 更快更便宜。我们这里用对话 LLM 直接当 reranker——教学版图省事

注意 temperature=0——重排序要稳定输出,不要随机。


4. 卡住了怎么办

❌ LLM rerank 把候选筛得太狠(只剩 1-2 个)

这正是 ch04 输出里发生的事。LLM 倾向于严格——觉得"相关"才保留。要放宽:

  • 改 system prompt 里的 "选出最多 final_k 个" 为 "必须输出 final_k 个最相关的"
  • 或者降到 final_k=2,反正会被 prompt 进上下文

❌ BM25 返回的全是 0 分

通常是 query 用了文档里完全没有的词(如英文术语)。这正是 hybrid 存在的意义——向量分量会兜底。

❌ 中文 BM25 效果差

很正常——我们用"按字切"分词,BM25 在词级别才发挥最佳。装 jieba 改进:

import jieba
def tokenize(text):
    return [t for t in jieba.cut(text) if t not in STOPWORDS]

❌ RRF score 看着都差不多 0.03

正常。RRF score = 1/61 ≈ 0.016,重复出现两次就 0.032。绝对值不重要,排名才是它的产出。

❌ rerank 把好的答案丢了

LLM 也会犯错。监督要点:

  • 看 hybrid top-10 里是否本来就有正确答案——如果没有,是召回问题,rerank 救不了
  • 看 rerank 选了什么——是不是 LLM 误解了 query

工业实践:rerank 出错时,回退用 hybrid 原始排名作为容错。


5. 思考题

题 1:禁掉某一路

search_hybrid 改成只用向量(删掉 bm25_top 那行),跑问题 1。看"BW-20260301-0042" 是否还命中订单编号规则——大概率会降到第 2-3 名。这就是 BM25 不可替代的场景

反过来禁掉向量,跑问题 4 "我能从太原下单冷链配送吗?"——观察 BM25 单独的效果。

题 2:调权重

工业级 hybrid 通常给 BM25 和向量加权重(如 0.4 + 0.6)而不是简单加 RRF score。给 rrf_fuseweights=[0.5, 0.5] 参数:

def rrf_fuse(rankings, weights, k=60):
    scores = {}
    for ranking, w in zip(rankings, weights):
        for rank, idx in enumerate(ranking):
            scores[idx] = scores.get(idx, 0.0) + w / (k + rank + 1)
    ...

试不同权重组合([0.7, 0.3] vs [0.3, 0.7]),看哪种对你的文档集更好。

题 3:用专业 reranker

pip install sentence-transformers,用 BAAI/bge-reranker-large 本地跑。它是 cross-encoder——同时看 query 和候选打分,比 LLM rerank 准确得多。

代价:要装 torch(~2GB),首次跑下 1GB 模型。生产环境部署是有 GPU 的服务端,本地教学用 LLM rerank 已够。


这一章你学会了什么

  • BM25 的公式:tf + idf + 长度归一化
  • RRF 融合:用排名而不是分数,跳过尺度问题
  • 召回 + 精排两阶段:宽召回(fetch_k=10)+ 严格精排(final_k=3)
  • ✅ LLM rerank 的实现:让模型输出编号序列
  • ✅ "什么场景用什么"的工程直觉

下一章 ch05(终章):把 RAG 包成一个工具接入 Bread Agent —— agent 在多轮对话里自己决定"要不要查知识库"。