很多人第一次做 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 单独看都答不了问题。
更稳的做法是结构感知 + 重叠切分:优先按标题、段落等自然边界切,超长再退化为按句子切,并保留一定重叠。
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
三个容易被忽略的细节:
- 把标题链拼进 chunk 正文。用户问"环境变量怎么配",而正文里可能只写了"在
.env中填入以下字段",没有"环境变量"四个字。标题链能补上这个语义锚点,这一步往往比换向量模型更有效。 - 重叠不要开太大。80 ~ 15% 之间比较合理,开到 50% 会让相似 chunk 挤满 Top-K,反而挤掉了真正的答案。
- 代码块、表格单独成块。被切断的代码块几乎没有检索价值,还会污染上下文。
三、向量检索:先把最小可用版本写清楚
不依赖任何向量数据库,用 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@5 和 MRR。任何"感觉变好了"都不算数。
九、排查清单
上线后出问题,按这个顺序查,能覆盖绝大多数情况:
| 现象 | 优先排查 |
|---|---|
| 答案完全不相关 | 打印召回的 chunk,确认正确内容是否被召回 |
| 明明有内容却查不到 | 分块是否切断语义;是否缺关键词召回 |
| 精确名词 / 错误码查不到 | BM25 是否生效;中文是否正确分词 |
| 答案对但缺细节 | Top-K 太小,或 chunk 太碎 |
| 编造数字和结论 | Prompt 缺兜底话术;重排缺分数阈值 |
| 检索很慢 | 是否每次请求都重算 embedding;query 结果加缓存 |
写在最后
RAG 工程化真正难的地方,不是接通某个向量数据库,而是把分块、混合召回、融合、重排、Prompt 约束、评测这条链路的每一环都做扎实。
如果只能做三件事,我会选:
- 分块时把标题链拼进正文;
- 加上 BM25 做混合检索,用 RRF 融合;
- 建一个 50 条的评测集,用
Recall@5说话。
这三步的投入产出比,远高于把生成模型换成更贵的那个。