RAG:让模型用上你的数据
RAG(Retrieval-Augmented Generation)= 先检索,再生成。
它是当前落地最广的 AI 技术形态。原因很简单:知识会变、企业数据私有、模型无法重新训练。
一、完整链路
离线建库(文档变了才重跑):
1
文档解析PDF/Word/HTML → 纯文本
2
清洗去页眉页脚、修表格
3
切分按结构切成 chunk
4
向量化每个 chunk → 向量
5
入库向量 + 原文 + 元数据
在线查询(每次请求都走):
1
问题改写补全指代、同义扩展
2
混合检索向量 + 关键词召回
3
重排精排出真正相关的几条
4
组装上下文带编号、控长度
5
生成带引用作答,允许说不知道
效果不好的时候,按这个链路的顺序逐段排查,而不是直接换模型。
二、切分(Chunking):最关键又最被忽视
| 策略 | 做法 | 适用 |
|---|---|---|
| 固定长度 | 每 500 字切一刀,留 50 字重叠 | 通用基线 |
| 按结构切 | 按标题/段落/表格切 | 文档类首选 |
| 语义切分 | 按句子相似度变化处切 | 长段落、散文 |
| 父子切分 | 小块检索,返回大块上下文 | 精度与完整性的平衡 |
| 按问答对 | 每条 FAQ 一个 chunk | 客服知识库 |
python
def chunk_by_heading(text: str, max_chars=600, overlap=80):
"""按 Markdown 标题切,超长再按段落二次切。输出带标题路径的 chunk。"""
chunks, buf, path = [], [], []
for line in text.splitlines():
if line.startswith("#"):
if buf:
chunks.append((" > ".join(path), "\n".join(buf)))
buf = []
level = len(line) - len(line.lstrip("#"))
path = path[:level - 1] + [line.strip("# ").strip()]
else:
buf.append(line)
if buf:
chunks.append((" > ".join(path), "\n".join(buf)))
out = []
for heading, body in chunks:
if len(body) <= max_chars:
out.append({"heading": heading, "text": body})
else:
for i in range(0, len(body), max_chars - overlap):
out.append({"heading": heading, "text": body[i:i + max_chars]})
return out三个立竿见影的改进
- 每个 chunk 前置标题路径(
产品手册 > 退款政策 > 时效)—— 大幅提升可检索性与答案准确性 - 保留重叠(10%–15%)—— 防止答案被切在边界
- 600–1000 字/块 —— 太小丢上下文,太大稀释精度
三、向量化与检索
python
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("BAAI/bge-m3") # 多语言、中文友好
def embed(texts):
return model.encode(texts, normalize_embeddings=True, batch_size=32)
# 建库
embs = embed([c["text"] for c in chunks]) # (N, d),已归一化
# 查询:余弦相似度 = 内积
q = embed(["退款需要多久"])[0]
scores = embs @ q
top = np.argsort(-scores)[:20]| 选择 | 说明 |
|---|---|
| embedding 模型 | 中文优先 bge-m3 / bge-large-zh;多语言 multilingual-e5 |
| 向量库 | 小规模(<10万)用 FAISS / numpy 足够;生产用 Milvus/Qdrant/pgvector |
| 归一化 | 归一化后内积 = 余弦,省一次计算 |
混合检索:向量 + 关键词
纯向量检索的弱点:专有名词、型号、编号、精确匹配会漏。
python
from rank_bm25 import BM25Okapi
tokenized = [c["text"].lower().split() for c in chunks]
bm25 = BM25Okapi(tokenized)
def hybrid_search(query, k=20, alpha=0.6):
vec_scores = embs @ embed([query])[0] # 0~1
kw_scores = np.array(bm25.get_scores(query.lower().split()))
kw_scores = kw_scores / (kw_scores.max() + 1e-8)
fused = alpha * vec_scores + (1 - alpha) * kw_scores
return np.argsort(-fused)[:k]
# 更标准的做法:RRF(倒数排名融合),无需调权重
def rrf(rank_lists, k=60, top=20):
score = {}
for ranks in rank_lists:
for r, idx in enumerate(ranks):
score[idx] = score.get(idx, 0) + 1 / (k + r + 1)
return sorted(score, key=score.get, reverse=True)[:top]四、重排(Rerank):性价比最高的提升
检索召回 20 条,用交叉编码器精排出真正相关的 5 条。
python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
cands = [chunks[i]["text"] for i in top]
pairs = [(query, c) for c in cands]
rerank_scores = reranker.predict(pairs)
best = [cands[i] for i in np.argsort(-rerank_scores)[:5]]| 环节 | 速度 | 精度 |
|---|---|---|
| 向量检索(双塔) | 极快(百万级) | 中 |
| 关键词检索 | 快 | 精确匹配强 |
| Cross-Encoder 重排 | 慢(几十条) | 高 |
标准架构:召回 50–100 → 重排 5–10 → 交给 LLM。 实测通常能提升 10–25 个点的答案准确率。
五、上下文组装:怎么把片段喂给模型
python
PROMPT = """你是{domain}助手。只依据下面的资料回答,不要编造。
<资料>
{context}
</资料>
规则:
1. 每个结论后用 [编号] 标注来源
2. 资料中找不到答案时,回答「资料中未提及」,并说明需要什么信息
3. 引用原文时保持准确,不要改写数字与条款
问题:{question}
"""
def build_context(docs, max_chars=6000):
parts, total = [], 0
for i, d in enumerate(docs, 1):
block = f"[{i}] {d['heading']}\n{d['text']}"
if total + len(block) > max_chars:
break
parts.append(block); total += len(block)
return "\n\n".join(parts)要点:
- 编号 + 要求引用 → 可溯源,便于人工核查,也显著降低幻觉
- 显式允许「不知道」 → 比强制作答准确得多
- 按相关度排序放置,最相关的放最前(首尾位置效应)
六、查询改写:解决「问题不像文档」的问题
用户问「这个能退吗」,文档写的是「退货政策」。直接检索可能失败。
| 技术 | 做法 |
|---|---|
| 查询扩展 | 让 LLM 生成 2–3 个同义改写,多路检索后融合 |
| HyDE | 让 LLM 先「编一个假答案」,用它去检索(假答案和真文档更接近) |
| 多轮改写 | 结合会话历史把「那它呢」补全为完整问题 |
| 子问题拆分 | 复杂问题拆成多个检索 |
python
def multi_query(question, n=3):
prompt = f"把下面的问题改写成 {n} 个不同表述的检索查询,每行一个,不要解释:\n{question}"
return [question] + [l.strip() for l in call_llm(prompt).splitlines() if l.strip()]七、RAG 效果差?按顺序排查
| 症状 | 先查 | 常见原因 |
|---|---|---|
| 答「不知道」但文档里有 | 检索 | 切分太大/太小、embedding 不适配、缺关键词检索 |
| 答错但检索到了对的内容 | 生成 | Prompt 没约束、上下文太杂、模型太弱 |
| 检索到无关内容 | 索引 | 切分破坏语义、chunk 太短无上下文 |
| 数字/条款答错 | 解析 | PDF 解析丢表格、OCR 错字 |
| 多跳问题答不出 | 架构 | 需要多轮检索或 Agent,不是单次 RAG |
| 新旧政策混答 | 版本 | 索引没有版本/时间过滤 |
排查工具:把「检索到的片段」打印出来。 90% 的 RAG 问题在这一步就能看清。
八、评测 RAG:拆成两段评
| 环节 | 指标 | 怎么测 |
|---|---|---|
| 检索 | Recall@k、MRR、命中率 | 标注「问题 → 正确文档 id」 |
| 生成 | 忠实度(无编造)、答案相关性、完整性 | LLM-as-Judge + 人工抽检 |
分开评的价值:如果 Recall@5=0.95 但答案准确率只有 60%,问题一定在生成端(Prompt/模型),换 embedding 是浪费时间。
九、进阶方向
| 方向 | 说明 |
|---|---|
| GraphRAG | 抽取实体关系建图,解决多跳与全局性问题 |
| 父子/多层索引 | 小块检索、大块生成 |
| 自适应检索 | 判断「要不要检索」,简单问题直接答 |
| 结构化 RAG | 表格/数据库走 Text-to-SQL,而不是塞进向量库 |
| 增量更新 | 文档变更时按 chunk 增量重建,不全量重跑 |