引言
大模型的幻觉与知识时效性问题,靠微调很难根治:知识一变就得重训,成本高且容易遗忘。**检索增强生成(RAG)**换了个思路——把外部知识库当作「外挂记忆」,每次回答前先检索出相关片段,拼进上下文让模型基于证据作答。
RAG 的成败几乎全在检索质量上:检索不到,生成再强也是编。本文从 embedding 选型讲到向量索引原理,再讲到混合召回与重排,最后给出完整的链路搭建代码与评测方法。
前置:文本向量化基础见 https://plumephp.com/ml-nlp-basics/;召回排序的分层思想与 https://plumephp.com/ml-recommender-systems/ 高度相通;文本特征的清洗与构造见 https://plumephp.com/ml-feature-engineering/。
目录
- 1. RAG 要解决什么问题
- 2. Embedding 模型选型
- 3. 向量索引:HNSW 与 IVF
- 4. 分块与召回策略
- 5. 重排:从召回走向精排
- 6. 搭建一条完整 RAG 链路
- 7. RAG 评测:命中率、忠实度与端到端
- 8. 常见坑与优化清单
- 9. 总结
- 延伸阅读
1. RAG 要解决什么问题
1.1 三种知识注入方式对比
| 方式 | 知识更新 | 可溯源 | 成本 | 适用 |
|---|---|---|---|---|
| 提示里塞 | 每次手改 | 差 | 低 | 极少量固定知识 |
| 微调 | 需重训 | 差 | 高 | 风格、格式、术语 |
| RAG | 改库即可 | 好(带引用) | 中 | 事实性知识、频繁更新 |
1.2 RAG 的两段式结构
离线索引:文档 → 分块 → embedding → 向量库
在线查询:问题 → embedding → 检索 Top-K → 重排 → 拼上下文 → LLM 生成
几乎所有 RAG 问题都能定位到这两段中的某一步:索引阶段是数据工程,查询阶段是检索工程。
2. Embedding 模型选型
2.1 好的 embedding 要看什么
- 维度:384 / 768 / 1024 / 1536。维度越高表达能力越强,但存储与检索成本线性上升。
- 最大长度:常见 512 token,长文模型可到 8192。超长会截断。
- 语言覆盖:中文场景必须选中文或多语模型,纯英文模型在中文上会明显退化。
- 对称性:有些模型要求查询和文档用不同前缀(如
query:与passage:),用错会掉点。
2.2 常用模型速查
| 模型 | 维度 | 特点 |
|---|---|---|
| BGE-M3 | 1024 | 多语、支持稠密+稀疏+多向量 |
| BGE-large-zh | 1024 | 中文检索强,社区常用 |
| text-embedding-3-small | 1536 | 通用、便宜 |
| m3e-base | 768 | 中文轻量,本地部署友好 |
2.3 归一化与相似度
绝大多数模型在归一化后用余弦相似度,等价于内积。检索前务必确认:
import numpy as np
def normalize(x):
return x / np.linalg.norm(x, axis=-1, keepdims=True)
# 归一化后,内积 == 余弦相似度,可以用更快的点积索引
emb = normalize(emb)
2.4 一段最简向量化
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
docs = ["退换货政策:签收后 7 天内可无理由退货。",
"发票申请:订单完成后可在个人中心申请电子发票。"]
doc_vecs = model.encode(docs, normalize_embeddings=True)
q_vec = model.encode(["怎么开发票"], normalize_embeddings=True)
print(q_vec @ doc_vecs.T) # 哪个文档更相关,一眼可见
3. 向量索引:HNSW 与 IVF
3.1 为什么需要索引
10 万条 1024 维向量做暴力检索(Flat)每次要算 1 亿次乘法。数据到百万级就撑不住了。索引的本质是用一点召回损失换取数量级的加速。
3.2 三类索引对比
| 索引 | 原理 | 召回 | 速度 | 内存 | 适用 |
|---|---|---|---|---|---|
| Flat | 暴力精确 | 100% | 慢 | 高 | < 10 万条 |
| IVF | 聚类分桶,只搜近桶 | 高(可调) | 快 | 中 | 百万级 |
| HNSW | 多层图近邻游走 | 很高 | 很快 | 高 | 千万级、低延迟 |
3.3 HNSW 的直觉
HNSW 建了一张分层图:上层稀疏、跨度大,下层稠密、精度高。检索从最上层入口点出发,贪心地朝目标靠近,逐层下降,最后在底层做精细搜索。
两个关键参数:
M 每个节点的邻居数,越大越准也越占内存(常用 16~64)
ef_construction 建图时的候选池大小,越大图质量越好(常用 100~500)
ef_search 查询时的候选池大小,越大召回越高、延迟越大(常用 64~256)
ef_search 是运行时参数,可以按业务动态调整:低延迟场景调小,高精度场景调大。
3.4 IVF 的直觉
IVF 先用 K-Means 把向量聚成 nlist 个桶,查询时只访问最近的 nprobe 个桶:
import faiss
d, nlist = 1024, 256
quantizer = faiss.IndexFlatIP(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT)
index.train(doc_vecs) # 必须先训练聚类中心
index.add(doc_vecs)
index.nprobe = 16 # 只搜最近的 16 个桶
D, I = index.search(q_vec, k=5)
nprobe 越大召回越高。IVF-PQ 再加乘积量化压缩向量,内存可降 10 倍以上,代价是精度损失。
3.5 HNSW 实战
index = faiss.IndexHNSWFlat(d, 32) # M=32
index.hnsw.efConstruction = 200
index.add(doc_vecs)
index.hnsw.efSearch = 128 # 查询时调
D, I = index.search(q_vec, k=5)
3.6 选型决策
数据 < 10 万且要精确 → Flat
内存充足、要低延迟 → HNSW
数据上亿、内存吃紧 → IVF-PQ
需要按条件过滤 → 带 filter 的向量库(Milvus/Qdrant 等)
4. 分块与召回策略
4.1 分块是 RAG 的第一生产力
块太大,噪声多、稀释相关性;块太小,语义不完整。常用策略:
| 策略 | 说明 | 适用 |
|---|---|---|
| 固定长度 | 按 token 数切,带重叠 | 通用基线 |
| 按结构切 | 按标题/段落/表格边界 | 文档类语料 |
| 递归切分 | 先大后小,逐级尝试分隔符 | 混合结构 |
| 语义切分 | 按相邻句向量相似度断层 | 高质量要求 |
def chunk_by_chars(text, size=500, overlap=80):
chunks, i = [], 0
while i < len(text):
chunks.append(text[i:i + size])
i += size - overlap # 重叠避免语义被切断
return chunks
4.2 混合检索:稠密 + 稀疏
纯向量检索对专有名词、编号、代码标识符不敏感——这些恰恰是 BM25 的强项。工业界主流是两者并行再融合:
def rrf_fuse(dense_ids, sparse_ids, k=60):
"""Reciprocal Rank Fusion:按排名倒数加权融合"""
score = {}
for ids in (dense_ids, sparse_ids):
for rank, doc_id in enumerate(ids):
score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1)
return [d for d, _ in sorted(score.items(), key=lambda x: -x[1])]
RRF 的好处是不需要归一化两路分数,只看排名,鲁棒且零调参。
4.3 查询改写
用户问题往往太短。常见增强:
- HyDE:先让 LLM 编一段假想答案,用它去检索;
- 多查询:让 LLM 生成 3~5 个改写版本,各检索一次后合并;
- 子问题分解:复杂问题拆成多个子问题分别检索。
5. 重排:从召回走向精排
5.1 为什么需要重排
双编码器(bi-encoder)为了能预先建索引,查询与文档分别编码,无法建模细粒度交互。交叉编码器(cross-encoder)把「查询+文档」拼在一起送进模型,精度高得多,但没法预建索引,只能对少量候选做。
5.2 两阶段流水线
向量检索 Top-100 → Reranker 精排 → 取 Top-5 送 LLM
(快、粗) (慢、准)
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-large")
pairs = [(q, docs[i]) for i in candidate_ids]
scores = reranker.predict(pairs)
top5 = [candidate_ids[i] for i in np.argsort(-scores)[:5]]
5.3 收益与代价
| 指标 | 只召回 | 加重排 |
|---|---|---|
| 精度 | 基线 | 明显提升 |
| 延迟 | 几十 ms | 增加 100~500 ms |
| 成本 | 低 | 需部署重排模型 |
经验值:召回 100 → 重排取 5,端到端命中率通常能提升 10~25 个百分点,是性价比最高的一步优化。
6. 搭建一条完整 RAG 链路
6.1 索引阶段
import faiss, numpy as np
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
def build_index(texts):
chunks = [c for t in texts for c in chunk_by_chars(t)]
vecs = embedder.encode(chunks, normalize_embeddings=True,
batch_size=64, show_progress_bar=True)
vecs = np.asarray(vecs, dtype="float32")
index = faiss.IndexHNSWFlat(vecs.shape[1], 32)
index.hnsw.efConstruction = 200
index.add(vecs)
return index, chunks
6.2 查询阶段
def retrieve(index, chunks, query, top_k=100, final_k=5, reranker=None):
qv = np.asarray(embedder.encode([query], normalize_embeddings=True),
dtype="float32")
_, ids = index.search(qv, top_k)
cands = [chunks[i] for i in ids[0] if i != -1]
if reranker is None:
return cands[:final_k]
scores = reranker.predict([(query, c) for c in cands])
order = np.argsort(-scores)[:final_k]
return [cands[i] for i in order]
6.3 拼装提示
PROMPT = """请仅依据以下资料回答问题;资料中没有的信息,请回答「资料未提及」。
资料:
{context}
问题:{question}
"""
def answer(llm, question, contexts):
ctx = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(contexts))
return llm(PROMPT.format(context=ctx, question=question))
编号 [1] [2] 是为了让模型能在回答里带引用,方便溯源与人工核验。
7. RAG 评测:命中率、忠实度与端到端
7.1 三个层次的指标
| 层次 | 指标 | 含义 |
|---|---|---|
| 检索 | Recall@K / Hit Rate | 正确文档是否进了 Top-K |
| 检索 | MRR / NDCG | 正确文档排得够不够前 |
| 生成 | 忠实度 | 回答是否全部有资料支撑 |
| 生成 | 答案相关性 | 是否切题 |
| 端到端 | 正确率 / 人工胜率 | 最终用户是否满意 |
7.2 检索指标实现
def recall_at_k(retrieved_ids, gold_ids, k):
hit = set(retrieved_ids[:k]) & set(gold_ids)
return len(hit) / len(gold_ids)
def mrr(retrieved_ids, gold_ids):
for rank, doc_id in enumerate(retrieved_ids, 1):
if doc_id in gold_ids:
return 1.0 / rank
return 0.0
7.3 忠实度:用 LLM 当裁判
JUDGE = """判断回答是否完全由资料支撑,只输出 支持 或 不支持。
资料:{context}
回答:{answer}
"""
def faithfulness(llm, answer, contexts):
verdict = llm(JUDGE.format(context="\n".join(contexts), answer=answer))
return 1.0 if "支持" in verdict and "不支持" not in verdict else 0.0
7.4 建评测集的正确姿势
准备 50~200 条「问题 → 标准答案 → 应命中文档 ID」三元组,覆盖简单/多跳/无答案三类。必须包含无答案样本——否则模型学不会说「资料未提及」,会强行编造。
8. 常见坑与优化清单
| 现象 | 根因 | 处理 |
|---|---|---|
| 检索总是不相关 | 分块过大或 embedding 不匹配语言 | 缩小块、换中文模型、加查询改写 |
| 专有名词查不到 | 纯稠密检索的短板 | 上混合检索(BM25 + 向量) |
| 答案编造 | 提示未约束、无答案样本缺失 | 加「仅依据资料」约束与拒答样本 |
| 延迟高 | 重排候选太多 | 召回降到 50,重排模型换小号 |
| 更新后仍答旧内容 | 索引未增量更新 | 建立文档版本与重建流水线 |
| 相似问题答案矛盾 | 块重叠导致重复片段 | 检索后做去重与多样性(MMR) |
MMR 去重能显著缓解「Top-5 全是同一段话」的尴尬:
def mmr(query_vec, doc_vecs, doc_ids, lam=0.7, k=5):
selected, cands = [], list(range(len(doc_ids)))
while cands and len(selected) < k:
def score(i):
rel = float(query_vec @ doc_vecs[i])
div = max([float(doc_vecs[i] @ doc_vecs[j]) for j in selected],
default=0.0)
return lam * rel - (1 - lam) * div
best = max(cands, key=score)
selected.append(best)
cands.remove(best)
return [doc_ids[i] for i in selected]
9. 总结
9.1 优化优先级
1. 分块策略 ← 收益最大、成本最低
2. 混合召回 ← 解决专有名词与编号
3. 加重排 ← 精度提升 10~25 个点
4. 查询改写 ← 解决短问题与多跳
5. 换更大模型 ← 最后再考虑
9.2 关键决策点
| 问题 | 选择 |
|---|---|
| 数据量 < 10 万 | Flat 索引,先跑通再优化 |
| 要低延迟 | HNSW + 小重排模型 |
| 内存吃紧 | IVF-PQ 压缩 |
| 中文语料 | BGE 系列 + 混合检索 |
| 上线前必须做 | 建带无答案样本的评测集 |
9.3 一句话心法
RAG 的天花板由检索决定,检索的天花板由分块与数据质量决定——先把「能不能检索到」做扎实,再谈生成端的花活。
延伸阅读
- https://plumephp.com/ml-nlp-basics/ — 文本向量化、TF-IDF 与词嵌入基础
- https://plumephp.com/ml-recommender-systems/ — 召回-精排-重排的分层架构
- https://plumephp.com/ml-feature-engineering/ — 文本特征构造与清洗
- https://plumephp.com/ml-model-evaluation/ — 评估方法论与指标选择
- Faiss 官方 Wiki
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。