AI 实用 04|RAG:让模型读你自己的资料,而不是靠猜
什么时候真的需要 RAG
先排除两种情况:
- 资料总量很少(几千字),直接塞进提示里就行。这叫"长上下文",不需要 RAG。
- 要模型学会一种风格或固定行为,那该用微调(或直接改系统提示),不是 RAG。
RAG 真正适用的场景:资料多到塞不进上下文(几十万字以上)、内容经常更新、需要给出处("这条规定在哪份文件里")。
核心价值只有一个:让模型的回答有据可查。它不提升模型能力,它只是把"相关资料"在提问时递到模型面前。
完整链路:七个环节
文档 → 解析 → 切分 → 向量化 → 入库
↓
用户提问 → 向量化 → 检索 top-k → 重排 → 拼提示 → 生成(带引用)
每个环节都可能成为短板,但按影响排序是:切分 > 检索 > 重排 > 提示 > 模型。
很多人一上来就换更大的模型,其实问题在切分——把一句完整的话切成了两半,检索出来没有意义。
环节一:切分(这一步决定上限)
固定长度 + 重叠
最朴素也最常用:
def chunk_text(text: str, size: int = 800, overlap: int = 150) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = min(start + size, len(text))
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start += size - overlap
return chunks
按字符数还是按 token 数? 按 token 数更准(因为模型和向量模型都按 token 计费/计算),但字符数实现简单。中文大致 1 字符 ≈ 1~1.5 token,用字符近似够用。
重叠(overlap)的作用:防止一个完整的句子被切断在边界上。150 字符左右的重叠,能让"半句话"在下一块里补全。
按结构切,效果远好于按长度切
如果文档有标题层级,就按标题切:
# 伪代码:按 Markdown 标题切
sections = split_by_heading(markdown) # [(h1, h2, text), ...]
# 每个 section 单独成块;超长的 section 再按长度二次切
每一块都带上它的"路径":
[文档: 员工手册 v3.2 > 第三章 考勤 > 3.2 请假流程]
{正文内容}
这个前缀非常关键——它让检索到的片段自带上下文,模型不会把"3.2 的规则"误当成"第二章的规则"。
表格千万别按行切
一张 30 行的表按长度切,会得到一堆"没有表头的数字"。正确做法:整张表作为一块,或者转成"每行 + 表头"的文字描述。
表:差旅报销标准
| 城市级别 | 住宿上限 | 餐饮补贴 |
| 一线 | 500 | 120 |
| 二线 | 350 | 80 |
转换成:
【差旅报销标准】一线城市:住宿上限 500 元/晚,餐饮补贴 120 元/天;
二线城市:住宿上限 350 元/晚,餐饮补贴 80 元/天。
自然语言的描述,检索命中率通常更高。
块大小怎么定
| 块大小 | 适合 | 代价 |
|---|---|---|
| 200~400 字符 | 精确问答(条款、FAQ) | 上下文太少,复杂问题答不全 |
| 600~1000 字符 | 大多数场景 | 平衡点 |
| 1500 字符以上 | 需要大段推理的文档 | 检索精度下降,噪声多 |
经验起点:800 字符 + 150 重叠,跑一批真实问题再调。
环节二:向量化(embedding)
选模型只看三点:中文效果、维度、是否本地部署。
| 类型 | 代表 | 特点 |
|---|---|---|
| 本地开源 | BGE-M3、bge-large-zh | 免费、可私有化、中文好 |
| 云端 API | 各家 embedding 接口 | 省事、质量稳定、按量计费 |
| 多语言 | multilingual-e5 系列 | 中英混排文档友好 |
# 本地用 sentence-transformers 的典型写法
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3")
vecs = model.encode(["第一段文本", "第二段文本"], normalize_embeddings=True)
一定要做归一化(normalize),然后用余弦相似度。不做归一化时,点积和余弦的关系会乱,检索结果不稳定。
入库和查询必须用同一个模型。换了 embedding 模型,旧向量全部作废,必须重建。
环节三:向量库
| 方案 | 适合 | 备注 |
|---|---|---|
| pgvector(PostgreSQL 扩展) | 已有 PG、数据量中等 | 一条 SQL 就能用,运维成本最低 |
| Qdrant / Milvus | 千万级向量、要高吞吐 | 独立服务,需要运维 |
| Chroma / FAISS | 本地原型、单机 | 简单,不适合多用户生产 |
如果团队已经有 PostgreSQL,直接上 pgvector,别为了"技术先进性"多养一个服务。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text,
heading text, -- 文档路径,拼进提示用
content text,
embedding vector(1024) -- 维度要和 embedding 模型一致
);
-- 建索引(HNSW,数据量大时必须建)
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
-- 检索
SELECT content, heading, 1 - (embedding <=> $1) AS score
FROM chunks
ORDER BY embedding <=> $1
LIMIT 5;
<=> 是余弦距离,1 - 距离 就是相似度。
环节四:检索——纯向量还不够
纯向量检索有个硬伤:它擅长"意思相近",不擅长"精确匹配"。
用户问"DB-2024-117 号文件的第几条",向量检索可能给你一堆语义相关但编号不对的文档。
解决办法是混合检索:
最终得分 = 0.7 × 向量相似度 + 0.3 × 关键词得分(BM25)
关键词部分用 PostgreSQL 自带的全文检索就能做:
SELECT id, ts_rank(to_tsvector('simple', content), plainto_tsquery('simple', $1)) AS bm25
FROM chunks
WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', $1)
ORDER BY bm25 DESC LIMIT 20;
然后把两路结果合并去重,按加权分数排序。中英混排时 simple 配置更稳妥(english 配置会丢掉中文分词)。
top-k 取多少
- 只给 3 条:召回不够,容易漏答案。
- 给 15 条:噪声太多,模型容易被无关内容带偏,还烧 token。
经验值:先取 1020 条候选,重排后留 35 条。
环节五:重排(rerank)
向量检索是"粗筛",rerank 是"精排"。用一个交叉编码器(cross-encoder)逐对计算"问题 + 片段"的相关性,精度明显高于向量相似度,代价是慢。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
pairs = [(question, c) for c in candidates]
scores = reranker.predict(pairs)
top = [c for c, s in sorted(zip(candidates, scores), key=lambda x: -x[1])][:4]
加了 rerank 之后,很多"答非所问"的问题会直接消失。这是投入产出比最高的一步优化,强烈建议加。
环节六:提示怎么拼
你是资料问答助手。只能依据下面提供的资料回答。
【资料】
[1] {heading}
{content}
[2] {heading}
{content}
【规则】
1. 每个结论后面标注引用编号,格式:[1]
2. 资料里没有的信息,直接回答"资料中未提及",不要用常识补充
3. 如果资料之间存在矛盾,指出矛盾并分别引用
4. 不要复述资料原文超过两句
【问题】
{用户问题}
三条规则里最重要是第 2 条。没有这句话,模型几乎一定会用参数知识补全,然后你就分不清哪句是文档说的、哪句是它编的。
配合结构化输出,可靠性还能再上一档:
{
"answer": "...",
"citations": [{"id": 1, "quote": "原文片段"}],
"missing": ["资料中没有的信息点"]
}
环节七:评估——没有评估就是在盲调
最少准备 20~50 条真实问题,每条标注"答案应该来自哪个文档/哪个片段"。
两个指标:
- 检索命中率(Recall@k):正确片段有没有进 top-k。这个指标低,后面再怎么调提示都没用。
- 答案正确率:模型最终回答对不对,引用是否准确。
一个简单的评估脚本:
def evaluate(cases, retriever, answerer, k=5):
hit = 0
correct = 0
for q, expected_doc, expected_ans in cases:
chunks = retriever.search(q, k=k)
if any(expected_doc in c.doc_id for c in chunks):
hit += 1
ans = answerer(q, chunks)
if expected_ans in ans.text: # 粗略判断,能用就行
correct += 1
return hit / len(cases), correct / len(cases)
先测检索,再测答案。检索命中率低于 60%,先去改切分和 embedding,别动提示。
五种典型失败,对应对策
| 现象 | 根因 | 对策 |
|---|---|---|
| 明明有资料却说"未提及" | 检索没召回 | 加大 top-k、上混合检索、调切分 |
| 答得像但不准 | 片段被切碎,缺上下文 | 增大块、加标题前缀、加重叠 |
| 混进无关内容 | top-k 太大、无 rerank | 加 rerank,降到 3~5 条 |
| 自由发挥 | 提示没约束 | 加"只能依据资料" + 引用要求 |
| 数字/编号对不上 | 纯向量检索 | 加关键词检索(BM25/全文索引) |
成本与延迟
一次问答的开销 = 一次 embedding(很小) + 一次向量检索(毫秒级) + 一次生成(大头)。
优化顺序:先压缩喂进去的片段(少而准),再考虑缓存高频问题,最后才是换更便宜的模型。塞 10 条片段比塞 3 条,token 成本是三倍多,效果往往还更差。
一条务实建议
不要一开始就上"企业级知识库平台"。先用 20 份文档 + pgvector + 800 字符切分 + 一个 embedding 模型 + rerank,跑通端到端。跑通之后你会发现,真正要解决的问题是"文档质量"和"权限隔离",而不是技术选型。
RAG 的天花板,常常不在算法,在文档本身写得有多清楚。