RAG 知识库问答系统实战:分块、混合检索、RRF 融合与重排全链路拆解

2 阅读10分钟

很多人第一次做 RAG,流程都长这样:把文档按 500 字切一刀,丢进向量库,用户提问时取 Top-5,塞进 Prompt,交给大模型。

demo 跑通那天很爽,上线一周后就开始收到反馈:

  • "我明明写了这段,它说没有查到。"
  • "答案里的数字是编的。"
  • "同样的问题,换个说法结果完全不一样。"

问题几乎从来不在大模型,而在检索。这篇文章不讲概念,直接把一套能用的 RAG 检索链路拆开:分块 → 向量召回 → BM25 召回 → RRF 融合 → 重排 → Prompt 约束,每一步都给出可直接跑的 Python 代码和踩坑说明。

一、先认清:RAG 的瓶颈在召回,不在生成

一个简化但残酷的公式:

最终答案质量 ≈ 检索命中率 × 模型忠实度

如果正确答案压根没进上下文,再强的模型也只能靠"想象力"补全,也就是我们常说的幻觉。所以优化顺序应该是:

优先级优化项典型收益
P0分块策略命中率 +15% ~ 30%
P0混合检索(向量 + 关键词)命中率 +10% ~ 25%
P1重排(Rerank)精确率显著提升
P1Prompt 约束与引用幻觉明显下降
P2换更大的生成模型边际收益最小

下面按这个顺序来。

二、分块:固定长度切分是万恶之源

固定长度切分最大的问题是切断语义。一个函数说明被拦腰截断,前半段进了 chunk A,后半段进了 chunk B,两个 chunk 单独看都答不了问题。

更稳的做法是结构感知 + 重叠切分:优先按标题、段落等自然边界切,超长再退化为按句子切,并保留一定重叠。

import re
from dataclasses import dataclass, field
from typing import List


@dataclass
class Chunk:
    text: str
    doc_id: str
    heading_path: str = ""          # 所属标题链,如 "部署指南 > 环境变量"
    meta: dict = field(default_factory=dict)


def split_sentences(text: str) -> List[str]:
    """中英文混合断句:在句末标点后切开,标点保留在前一句。"""
    parts = re.split(r'(?<=[。!?!?;;\n])', text)
    return [p.strip() for p in parts if p.strip()]


def pack_sentences(sentences: List[str], max_chars: int, overlap: int) -> List[str]:
    """把句子按上限打包成块,块之间保留 overlap 个字符的重叠。"""
    chunks, buf = [], ""
    for sent in sentences:
        # 单句本身就超长,先冲掉缓冲区,再按硬长度切
        if len(sent) > max_chars:
            if buf:
                chunks.append(buf)
                buf = ""
            for i in range(0, len(sent), max_chars):
                chunks.append(sent[i:i + max_chars])
            continue

        if len(buf) + len(sent) <= max_chars:
            buf += sent
        else:
            chunks.append(buf)
            buf = (buf[-overlap:] if overlap > 0 else "") + sent

    if buf:
        chunks.append(buf)
    return chunks


def split_markdown(doc_id: str, md: str, max_chars: int = 600, overlap: int = 80) -> List[Chunk]:
    """按 Markdown 标题分段,段内再按句子打包。"""
    lines = md.split("\n")
    heading_stack: List[str] = []
    buckets = []            # [(heading_path, [line, ...]), ...]
    current: List[str] = []

    def flush():
        if current:
            buckets.append((" > ".join(heading_stack), "\n".join(current)))

    for line in lines:
        m = re.match(r'^(#{1,6})\s+(.*)$', line)
        if m:
            flush()
            current = []
            level = len(m.group(1))
            heading_stack = heading_stack[:level - 1]
            heading_stack.append(m.group(2).strip())
        else:
            current.append(line)
    flush()

    chunks: List[Chunk] = []
    for heading_path, body in buckets:
        body = body.strip()
        if not body:
            continue
        for piece in pack_sentences(split_sentences(body), max_chars, overlap):
            # 关键一步:把标题链拼回正文,让 chunk 自带上下文
            prefixed = f"{heading_path}\n{piece}" if heading_path else piece
            chunks.append(Chunk(text=prefixed, doc_id=doc_id, heading_path=heading_path))
    return chunks

三个容易被忽略的细节:

  1. 把标题链拼进 chunk 正文。用户问"环境变量怎么配",而正文里可能只写了"在 .env 中填入以下字段",没有"环境变量"四个字。标题链能补上这个语义锚点,这一步往往比换向量模型更有效。
  2. 重叠不要开太大。80 ~ 15% 之间比较合理,开到 50% 会让相似 chunk 挤满 Top-K,反而挤掉了真正的答案。
  3. 代码块、表格单独成块。被切断的代码块几乎没有检索价值,还会污染上下文。

三、向量检索:先把最小可用版本写清楚

不依赖任何向量数据库,用 numpy 就能把原理跑通。生产环境换成 pgvector / Milvus / Qdrant,接口是一样的。

import numpy as np
from typing import Callable, List, Tuple

EmbedFn = Callable[[List[str]], np.ndarray]   # 输入文本列表,返回 (n, dim) 矩阵


class VectorIndex:
    def __init__(self, embed_fn: EmbedFn):
        self.embed_fn = embed_fn
        self.chunks: List[Chunk] = []
        self.matrix: np.ndarray | None = None   # (n, dim),已 L2 归一化

    @staticmethod
    def _l2_normalize(m: np.ndarray) -> np.ndarray:
        norms = np.linalg.norm(m, axis=1, keepdims=True)
        norms[norms == 0] = 1e-12       # 防止零向量除零
        return m / norms

    def build(self, chunks: List[Chunk], batch_size: int = 64) -> None:
        self.chunks = chunks
        vectors = []
        for i in range(0, len(chunks), batch_size):
            batch = [c.text for c in chunks[i:i + batch_size]]
            vectors.append(np.asarray(self.embed_fn(batch), dtype=np.float32))
        self.matrix = self._l2_normalize(np.vstack(vectors))

    def search(self, query: str, top_k: int = 20) -> List[Tuple[int, float]]:
        if self.matrix is None:
            raise RuntimeError("index not built")
        q = np.asarray(self.embed_fn([query]), dtype=np.float32)
        q = self._l2_normalize(q)[0]
        # 已归一化,点积即余弦相似度
        scores = self.matrix @ q
        top_k = min(top_k, len(scores))
        # argpartition 取 Top-K 是 O(n),比全排序快很多
        idx = np.argpartition(-scores, top_k - 1)[:top_k]
        idx = idx[np.argsort(-scores[idx])]
        return [(int(i), float(scores[i])) for i in idx]

注意两点:必须先归一化再点积,否则得到的是内积而不是余弦相似度,长文本会被系统性地打高分;Top-K 用 argpartition,十万级向量下比 argsort 快一个量级。

四、只用向量检索一定会翻车

向量擅长语义泛化,但对低频精确 token 极不敏感。这些查询向量检索几乎必挂:

  • 错误码:ERR_CONN_1053
  • 型号 / 版本号:v2.3.1-beta
  • 人名、内部项目代号:Project Kelpie
  • 精确 API 名:onBeforeUnmount

原因很直白:embedding 把文本压成几百维稠密向量,低频字符串的信息在压缩中被抹掉了。这时候需要关键词检索兜底,BM25 是性价比最高的选择。

import math
from collections import Counter
from typing import List, Tuple


class BM25:
    """标准 BM25 实现(Okapi BM25)。tokenize 中文建议用 jieba,英文按空格+小写即可。"""

    def __init__(self, corpus_tokens: List[List[str]], k1: float = 1.5, b: float = 0.75):
        self.k1, self.b = k1, b
        self.docs = corpus_tokens
        self.n = len(corpus_tokens)
        self.doc_len = [len(d) for d in corpus_tokens]
        self.avgdl = sum(self.doc_len) / self.n if self.n else 0.0
        self.tf = [Counter(d) for d in corpus_tokens]

        df = Counter()
        for d in corpus_tokens:
            for term in set(d):
                df[term] += 1
        # 带平滑的 IDF,保证非负
        self.idf = {
            t: math.log(1 + (self.n - c + 0.5) / (c + 0.5))
            for t, c in df.items()
        }

    def search(self, query_tokens: List[str], top_k: int = 20) -> List[Tuple[int, float]]:
        scores = [0.0] * self.n
        for i in range(self.n):
            tf_i, dl = self.tf[i], self.doc_len[i]
            s = 0.0
            for term in query_tokens:
                f = tf_i.get(term, 0)
                if f == 0:
                    continue
                denom = f + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
                s += self.idf.get(term, 0.0) * f * (self.k1 + 1) / denom
            scores[i] = s
        ranked = sorted(range(self.n), key=lambda i: -scores[i])[:top_k]
        return [(i, scores[i]) for i in ranked if scores[i] > 0]

五、RRF 融合:不用调权重的混合检索

有了两路召回,怎么合并?很多人第一反应是加权求和:0.7 * 向量分 + 0.3 * BM25 分

这是个坑:两路分数量纲完全不同。余弦相似度在 0~1,BM25 可能是 3.7 也可能是 21.5,随语料变化。权重调好了换个知识库就失效。

更稳的做法是 RRF(Reciprocal Rank Fusion):只用排名,不用分数。

from typing import Dict, List, Tuple


def rrf_fuse(rank_lists: List[List[Tuple[int, float]]], k: int = 60,
             weights: List[float] | None = None) -> List[Tuple[int, float]]:
    """
    rank_lists: 多路召回结果,每路是按分数降序的 [(doc_idx, score), ...]
    k: 平滑常数,经验值 60,越大越弱化头部排名的优势
    """
    weights = weights or [1.0] * len(rank_lists)
    fused: Dict[int, float] = {}
    for w, rank_list in zip(weights, rank_lists):
        for rank, (doc_idx, _score) in enumerate(rank_list):
            fused[doc_idx] = fused.get(doc_idx, 0.0) + w / (k + rank + 1)
    return sorted(fused.items(), key=lambda kv: -kv[1])

RRF 的好处是免调参、跨量纲、抗异常值。一路召回把某个 chunk 排在第 1,另一路排在第 3,融合后它自然浮到最前面;只有一路命中的结果也不会被完全淹没。

把三步串起来:

def hybrid_search(query: str, vec_index: VectorIndex, bm25: BM25,
                  tokenize, top_k: int = 8) -> List[Chunk]:
    vec_hits = vec_index.search(query, top_k=30)
    kw_hits = bm25.search(tokenize(query), top_k=30)
    fused = rrf_fuse([vec_hits, kw_hits], k=60, weights=[1.0, 0.7])
    return [vec_index.chunks[i] for i, _ in fused[:top_k]]

六、重排:召回放宽,精排收紧

融合之后仍然是"粗排"。真正拉开差距的是 Cross-Encoder 重排

  • 向量检索是 Bi-Encoder:query 和 doc 分别编码,再算相似度。快,但两者从未"见过"彼此。
  • 重排是 Cross-Encoder:把 query 和 doc 拼在一起送进模型,输出相关性分数。慢,但精度高得多。

所以标准姿势是:粗排召回 50 条 → 重排选出 5 条

from typing import List


def rerank(query: str, chunks: List[Chunk], score_pairs, top_k: int = 5) -> List[Chunk]:
    """
    score_pairs: (query, [doc_text, ...]) -> List[float]
    可接 bge-reranker、cohere rerank 或任意 Cross-Encoder 服务。
    """
    if not chunks:
        return []
    scores = score_pairs(query, [c.text for c in chunks])
    order = sorted(range(len(chunks)), key=lambda i: -scores[i])
    return [chunks[i] for i in order[:top_k]]

一个实践中很有效的补充:给重排设置分数阈值。低于阈值的全部丢掉,宁可让模型回答"资料里没有",也不要塞一堆不相关内容进去——那正是幻觉的温床。

七、Prompt:把"别编"写成可执行的约束

检索做好了,最后一步是别让模型自由发挥。有效的 Prompt 有三个共性:编号引用、显式兜底、限定信息来源

SYSTEM_PROMPT = """你是一个严谨的知识库问答助手。

规则:
1. 只能依据【参考资料】回答,不得使用资料之外的知识。
2. 每个结论后必须标注来源编号,格式为 [1]、[2],可多个。
3. 如果资料不足以回答,直接回复:「根据现有资料无法回答该问题」,并说明还缺少什么信息。
4. 资料之间冲突时,指出冲突点,不要自行裁决。
5. 不要复述规则,直接给答案。"""


def build_prompt(query: str, chunks: List[Chunk]) -> str:
    refs = "\n\n".join(
        f"[{i + 1}] 来源:{c.doc_id}"
        + (f" / {c.heading_path}" if c.heading_path else "")
        + f"\n{c.text}"
        for i, c in enumerate(chunks)
    )
    return f"【参考资料】\n{refs}\n\n【用户问题】\n{query}"

第 3 条尤其重要。大模型默认倾向于"必须给出答案",不显式给它一条体面的退路,它就会编。加上兜底话术后,无答案场景的幻觉率会明显下降。

八、怎么知道自己改好了:先建评测集

没有评测,所有优化都是玄学。最小可用的评测集只需要 50 条:从真实用户问题里抽样,人工标注每条问题对应的正确 chunk。

核心指标两个:

def recall_at_k(retrieved_ids: List[str], gold_ids: set, k: int) -> float:
    """Top-K 里是否包含正确答案所在 chunk(命中率)"""
    return 1.0 if set(retrieved_ids[:k]) & gold_ids else 0.0


def mrr(retrieved_ids: List[str], gold_ids: set) -> float:
    """Mean Reciprocal Rank:正确答案排得越靠前分越高"""
    for rank, cid in enumerate(retrieved_ids, start=1):
        if cid in gold_ids:
            return 1.0 / rank
    return 0.0

评测流程固定下来后,每次改动只看两个数:Recall@5MRR。任何"感觉变好了"都不算数。

九、排查清单

上线后出问题,按这个顺序查,能覆盖绝大多数情况:

现象优先排查
答案完全不相关打印召回的 chunk,确认正确内容是否被召回
明明有内容却查不到分块是否切断语义;是否缺关键词召回
精确名词 / 错误码查不到BM25 是否生效;中文是否正确分词
答案对但缺细节Top-K 太小,或 chunk 太碎
编造数字和结论Prompt 缺兜底话术;重排缺分数阈值
检索很慢是否每次请求都重算 embedding;query 结果加缓存

写在最后

RAG 工程化真正难的地方,不是接通某个向量数据库,而是把分块、混合召回、融合、重排、Prompt 约束、评测这条链路的每一环都做扎实。

如果只能做三件事,我会选:

  1. 分块时把标题链拼进正文;
  2. 加上 BM25 做混合检索,用 RRF 融合;
  3. 建一个 50 条的评测集,用 Recall@5 说话。

这三步的投入产出比,远高于把生成模型换成更贵的那个。