Rerank 与混合检索:怎么让 RAG 从「能用」变成「好用」

Rerank 与混合检索:怎么让 RAG 从「能用」变成「好用」

上两篇我把 RAG 的概念和最小闭环讲完了------第 21 篇说清「先查后答」,第 22 篇手搓了一个纯 Python 的 MiniRAG。那个 MiniRAG 确实能跑,但**「能跑」和「好用」之间还差着一截**。

差在哪?就一句话:上篇的检索只用了「向量相似度」这一招,而这一招有两个天生的短板。 这篇就专治这两个短板------Rerank(重排序) 和 混合检索(Hybrid Search)。这是让 RAG 检索质量上一个台阶的最划算的两步,很多「知识库项目」从 demo 到能上线,就差这一步。

还是那句话:不引入新依赖 ,继续用 numpy + requests(外加 Python 标准库自带的 math、collections),代码照旧能直接跑。

一、先看清向量检索的两个短板

上篇的检索是这么干的:问题转向量 → 和所有文档块算余弦相似度 → 取 Top-K。这套「向量检索」很好用,但它有俩毛病:

短板一:会漏「精确关键词」。 向量管的是「语义像不像」,不是「词在不在」。你问「XT-9000 路由器怎么重置密码」,向量可能把「路由器怎么改密码」这种语义接近但压根没提 XT-9000 的块也捞出来,反而漏掉真正写着 XT-9000 的那块。型号、编号、人名、专有名词这类「精确词」,恰恰是向量检索的软肋。

短板二:排序不够准。 Embedding 模型本质是「两个东西分别编码再比距离」,它算的是「长得像不像」,不是「答不答得上」。所以 Top-K 里经常混进「相关但没用」的块,而且排序经常错------真正有用的那块可能排在第 3、第 4,甚至掉出 Top-K。

这两个短板,分别对应两个解法:短板二(排序不准)用 Rerank 救,短板一(漏关键词)用 混合检索 救。下面一个个来。

二、Rerank:粗召回 + 精排,把排序救回来

Rerank 的思路特别朴素:先用便宜快速的方法多捞一些候选(粗召回),再用一个更准但更慢的模型把这些候选重新排一遍(精排),最后只取前几个。

为什么要分两步?因为那个「更准的模型」------叫 cross-encoder(交叉编码器) ------它把「问题 + 文档」拼在一起 输入模型,让模型直接判断「这条能不能回答这个问题」,所以准确率远高于 embedding 那种「分别编码再比距离」的 bi-encoder。但它有个致命缺点:太慢。因为它要逐条算「问题+文档」的相关性,没法像向量那样一次性算完整个库。

所以正确姿势是:embedding 粗召回 Top-50,rerank 精排取 Top-5。 快慢搭配,准确率还上来了。

硅基流动(就是上篇做 embedding 那家)有现成的 rerank 模型 BAAI/bge-reranker-v2-m3,接口也是 OpenAI 兼容风格,一行就能调:

python 复制代码
import requests

RERANK_MODEL = "BAAI/bge-reranker-v2-m3"   # cross-encoder 重排模型

def rerank(query, documents, top_n=4):
    """对候选 documents 精排,返回 [(index, score)],按相关度降序"""
    resp = requests.post(
        f"{EMBED_BASE}/rerank",
        headers={"Authorization": f"Bearer {EMBED_KEY}"},
        json={"model": RERANK_MODEL, "query": query, "documents": documents, "top_n": top_n},
        timeout=60,
    )
    resp.raise_for_status()
    return [(r["index"], r["relevance_score"]) for r in resp.json()["results"]]

把它接在上篇的 search() 之后,就是一个「两阶段检索」:

python 复制代码
def search_with_rerank(self, query, top_k=4, recall=20):
    # 第一阶段:向量粗召回 Top-recall
    q = np.array(embed([query])[0])
    scores = cosine_similarity(q, self.vectors)
    candidate_idx = np.argsort(scores)[::-1][:recall]
    candidates = [self.chunks[i] for i in candidate_idx]

    # 第二阶段:rerank 精排取 Top-k
    ranked = rerank(query, candidates, top_n=top_k)
    return [(candidates[idx], score) for idx, score in ranked]

就这几行,检索质量肉眼可见地提一档------召回还是向量干的,但最终给你的,是 cross-encoder 精挑过的。

三、混合检索:BM25 + 向量,一个管「精确」、一个管「语义」

Rerank 救了排序,但短板一「漏精确关键词」还没解决------因为粗召回阶段就把 XT-9000 那块给漏了,后面精排再准也捞不回来。所以要换思路:粗召回阶段就别只靠向量,再拉一个「关键词检索」进来打配合。

关键词检索的经典算法是 BM25,它干的是传统搜索的活------查「这串词在不在文档里、出现了几次、是不是稀有词」。它的强项正好是向量的弱项:

向量检索(语义) BM25(关键词)
擅长 同义改写、意思相近 精确词、型号、编号
「XT-9000 重置」 可能捞到「路由器改密码」 精准命中含 XT-9000 的块

两个一互补,就把「漏词」和「漏意」都堵上了。这就是混合检索。

先写个精简版的 BM25(不装第三方库,标准库就够):

python 复制代码
import math
from collections import Counter

class SimpleBM25:
    def __init__(self, corpus, k1=1.5, b=0.75):
        self.k1, self.b = k1, b
        self.corpus = corpus
        self.doc_tokens = [self._tokenize(d) for d in corpus]
        self.doc_len = [len(t) for t in self.doc_tokens]
        self.avgdl = sum(self.doc_len) / len(corpus)
        df = Counter()
        for t in self.doc_tokens:
            df.update(set(t))
        self.idf = {w: math.log((len(corpus) - n + 0.5) / (n + 0.5) + 1)
                    for w, n in df.items()}

    def _tokenize(self, text):
        """简化中文分词:空格分的英文词保留,中文按单字(示意用,生产上 jieba)"""
        toks = []
        for seg in text.replace("\n", " ").split():
            if any("一" <= ch <= "鿿" for ch in seg):
                toks.extend(list(seg))      # 中文按字拆
            else:
                toks.append(seg.lower())     # 英文词小写
        return toks

    def scores(self, query):
        qt = self._tokenize(query)
        out = []
        for dt, dl in zip(self.doc_tokens, self.doc_len):
            tf = Counter(dt)
            s = 0.0
            for term in qt:
                if term in tf:
                    idf = self.idf.get(term, 0)
                    denom = tf[term] * (self.k1 + 1) / (tf[term] + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
                    s += idf * denom
            out.append(s)
        return out

两条检索结果怎么合并?最常用的是 RRF(Reciprocal Rank Fusion,倒数排名融合)------它不看分数、只看排名,把「在向量榜排第几」和「在关键词榜排第几」按名次加权求和。优点是简单、不用调分数尺度:

python 复制代码
from collections import defaultdict

def rrf_fuse(rank_lists, k=60):
    """rank_lists: 若干「按相关度降序的索引列表」,返回融合后的索引排序"""
    score = defaultdict(float)
    for ranks in rank_lists:
        for rank, idx in enumerate(ranks):
            score[idx] += 1.0 / (k + rank + 1)   # 名次越靠前,贡献越大
    return sorted(score, key=lambda i: -score[i])

把它们串起来,就是一个完整的混合检索:

python 复制代码
class HybridRAG(MiniRAG):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.bm25 = None

    def build_index(self, documents, **kw):
        super().build_index(documents, **kw)
        self.bm25 = SimpleBM25(self.chunks)     # 顺便建 BM25 索引

    def hybrid_search(self, query, top_k=4, recall=20):
        # 向量召回 Top-recall
        q = np.array(embed([query])[0])
        vec_scores = cosine_similarity(q, self.vectors)
        vec_rank = list(np.argsort(vec_scores)[::-1][:recall])
        # BM25 召回 Top-recall
        bm_scores = self.bm25.scores(query)
        bm_rank = list(np.argsort(bm_scores)[::-1][:recall])
        # RRF 融合 → 取 Top-k
        fused = rrf_fuse([vec_rank, bm_rank])[:top_k]
        return [(self.chunks[i], float(vec_scores[i])) for i in fused]

    def ask(self, query, top_k=4):
        hits = self.hybrid_search(query, top_k)
        context = "\n\n".join(f"[资料{i+1}]\n{h[0]}" for i, h in enumerate(hits))
        prompt = ("请只根据下面提供的资料回答问题;资料里没有的信息就明确说「资料里没有」,"
                  "不要编造。\n\n" + context + f"\n\n问题:{query}\n\n回答:")
        return chat([{"role": "user", "content": prompt}]), hits

进阶版还可以在 RRF 之后再加一层 rerank(即「混合召回 → rerank 精排」),效果最好,代价是两次推理。生产里这个组合很常见,但 demo 阶段先跑通上面的就够。

四、避坑清单:这轮我又补了 5 个坑

  1. Rerank 只对候选集做,别全量做:cross-encoder 慢,全库 rerank 直接把你拖垮。永远「向量粗召回 Top-20~50,再 rerank 取 Top-5」。

  2. BM25 的中文分词别裸用空格 :上篇说过中文没空格,这里同理。英文库里 rank-bm25 开箱即用,中文要么按字拆(我上面的示意),要么上 jieba,否则 BM25 全退化成一整块。

  3. RRF 的参数 k 别瞎调 :k=60 是社区常用的稳妥值,它对结果不敏感,别花太多时间在它上面。

  4. rerank 和 embedding 别用混模型 :rerank 用的是 cross-encoder(bge-reranker-v2-m3),embedding 用的是 bi-encoder(bge-m3),这俩是两种模型,别拿 embedding 模型去调 /rerank 接口。

  5. 先测「召回率」再测「答案质量」:很多项目一上来只看最终答案,结果错了都不知道是检索错还是生成错。正确姿势是先把「命中的资料对不对」这个指标单独跑通,再往下看。

五、什么时候值得上这套优化

不是所有场景都要上 rerank + hybrid,给个判断:

  • 文档里大量专有名词 / 型号 / 编号:必上混合检索,纯向量一定漏;
  • Top-K 里经常混进「相关但没用」的块:上 rerank,立竿见影;
  • 数据量很小(几十块以内):别折腾,直接全量塞给模型都行,优化收益不大;
  • 数据量大、要上线:混合召回 + rerank 精排,是当前性价比最高的标准配置。

小结

这篇把 RAG 从「能用」往「好用」推了一步,三句话收尾:

  • 短板有两个:向量检索会漏「精确关键词」,而且排序不够准;
  • 两个解法:Rerank 用 cross-encoder 精排救排序(粗召回 + 精排两阶段),混合检索用 BM25 + 向量互补救漏词,RRF 做融合;
  • 还是一样的配方 :不引新依赖,numpy + requests 继续干,代码能直接跑。

系列预告:向量检索、BM25、rerank 都还是「在文本里捞」。但有些问题跨文档、要多跳推理------「A 和 B 什么关系」「这两份报告的共同结论是什么」------这时候得换一种组织方式。下一篇聊 GraphRAG:把文档抽成知识图谱,回答多跳问题。关注不迷路。


本文代码均为可直接运行的最小实现,接口调用遵循 OpenAI 兼容协议;rerank/embedding/BM25 算法细节与模型选型以各厂商官方文档为准。

相关推荐
救救孩子把5 小时前
AI面试八股文-23_23-Agent可观测性与生产监控面试题
人工智能·面试·职场和发展·agent·langsmith·langfuase
IT_陈寒5 小时前
Java静态变量初始化坑得我头皮发麻
前端·人工智能·后端
知行产研5 小时前
白马山领跑、南山矿扩编 31 台,安徽露天矿无人驾驶进入规模化阶段
人工智能
FinelyYang5 小时前
考试系统简答题 AI 阅卷:覆盖度驱动、异步评分与人机协同落地实践
java·大数据·人工智能
伍树明5 小时前
mini-swe-agent 本地部署+效果评测记录
人工智能
运维行者_5 小时前
网络监控工具怎么选?从设备监控到智能运维的能力清单
运维·服务器·开发语言·网络·人工智能·支持向量机·php
清华kenny5 小时前
安徽亳州中医AI健康辨识仪品牌排名前十之适用场景解析
人工智能·安徽晓得养
郑鱼咚5 小时前
大胆点,向 AI 要你真正想要的结果
人工智能
信可维5 小时前
多个 AI 同时改一个仓库:先划边界,再谈并行
人工智能