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

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

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

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

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

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

一个简化但残酷的公式:

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

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

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

下面按这个顺序来。

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

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

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

python 复制代码
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,接口是一样的。

python 复制代码
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 是性价比最高的选择。

python 复制代码
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):只用排名,不用分数。

python 复制代码
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,融合后它自然浮到最前面;只有一路命中的结果也不会被完全淹没。

把三步串起来:

python 复制代码
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 条

python 复制代码
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 有三个共性:编号引用、显式兜底、限定信息来源

python 复制代码
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。

核心指标两个:

python 复制代码
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 说话。

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

相关推荐
用户1306095607231 小时前
第二章学完后,我对 webpack5 前端工程化的一点理解
前端
a1117761 小时前
小鸡下蛋 小游戏 html
前端·开源·html
码哥DFS1 小时前
算法练习day1-备战2027届秋招
前端·javascript·数据结构·算法
Bigger1 小时前
因为一句「华流才是最屌的」,我做了一个中国风设计的 Skill
前端·人工智能·设计
别怪我很水1 小时前
Vue element admin 浏览器本地存储 localStorage、useStorage
前端·javascript·vue.js
手揽回忆怎么睡1 小时前
Ubuntu 22.04 64位安装MinIO
linux·前端·ubuntu
铁皮饭盒2 小时前
网页端OCR, 加载6mb大小模型, 又快又准, 百度这次真香
前端·后端
Web极客码2 小时前
如何在WordPress中添加动态内容
服务器·前端·wordpress
云浪2 小时前
给 AI Agent 加上语音交互:ASR + 流式 TTS 实战
前端·人工智能·后端