RAG 完整指南:从基础检索到进阶架构
RAG:给 AI 外挂知识库 🧠
大语言模型的知识都"冻结"在训练数据里——它不知道你公司上周更新的产品手册,不知道今天早上发生的新闻,更不知道你硬盘里那份从未公开过的合同。想让模型回答这些问题,无非两条路:把新知识塞进训练数据重新训练(成本高、周期长、还容易"学了新的忘了旧的"),或者在提问时把相关资料现场"喂"给它。RAG(Retrieval-Augmented Generation,检索增强生成)走的是第二条路:不改模型参数,只在推理时动态检索外部知识,拼进提示词里,让模型"现学现答"。
打个比方:不做 RAG 的大模型像一个闭卷考试的学生,只能凭记忆答题,记不清的地方就开始"合理编造";做了 RAG 的大模型像一个开卷考试的学生,答题前先翻书找到相关章节,再照着写。开卷不一定次次考满分——翻错了页、抄漏了行照样会错——但至少答案有据可查,而且书更新了它立刻就能用上新内容。整篇文章要讲的,其实就是"怎么把这本书翻得又快又准"。
1. 为什么必须是"检索"而不是"全部塞进去"
理论上可以把所有文档一股脑塞进提示词,让模型自己找答案,但这在实践中行不通:
所以更现实的做法是:只把"最可能相关"的一小撮内容找出来,精准注入上下文。这正是检索要解决的问题。
用一组直观的数字体会一下"全塞进去"有多离谱:假设一家公司有 50 万篇文档,平均每篇 2000 字(约 3000 Token),全量就是 15 亿 Token。就算模型真有这么大的窗口,按主流大模型每百万输入 Token 数美元的价格,回答一个问题的输入成本就要几千美元,延迟更是以分钟计。而 RAG 只需检索出最相关的 5~10 个片段(几千 Token),成本和延迟都降到千分之一以下,答案质量往往还更高——因为噪声更少。
| 方案 | 单次问答携带 Token | 相对成本 | 典型延迟 | 知识更新方式 |
|---|---|---|---|---|
| 全量塞入上下文 | 数亿级 | 极高(基本不可行) | 分钟级 | 每次重新组装全部资料 |
| 微调/继续训练 | 低(知识进参数) | 训练成本高、周期长 | 毫秒级 | 需重新训练 |
| RAG 检索注入 | 数千级 | 低、可控 | 百毫秒~秒级 | 更新知识库即刻生效 |
2. RAG 的完整链路
一次完整的 RAG 由"离线构建知识库"和"在线问答"两个阶段组成:
| 阶段 | 步骤 | 作用 |
|---|---|---|
| 离线(建库) | ① 文档切块(Chunking) | 把长文档切成大小适中的片段,便于精确检索 |
| 离线(建库) | ② 向量化(Embedding) | 用嵌入模型把每个片段转成高维向量,捕捉语义 |
| 离线(建库) | ③ 存入向量数据库 | 建立索引,支持大规模、低延迟的相似度检索 |
| 在线(问答) | ④ 检索(Retrieval) | 把用户问题也转成向量,找出最相似的若干片段 |
| 在线(问答) | ⑤ 注入上下文(Augmentation) | 把检索到的片段拼接进 Prompt,作为回答依据 |
| 在线(问答) | ⑥ 生成(Generation) | 大模型基于问题 + 检索片段生成最终回答 |
切块与向量化的具体工程细节(比如向量数据库选型、索引结构 HNSW/IVF)属于向量数据库这门单独的技术,这里不再展开;本文的重点放在"检索质量怎么调优"和"架构怎么进阶"上。
需要强调的一点是:RAG 的整体质量遵循"木桶效应",最终答案的上限由链路中最短的那块板决定。切块切碎了,后面检索再准也捞不到完整信息;检索召回率低,模型再强也是"无米之炊";检索都对但 Prompt 写得烂,模型照样跑偏。所以调优 RAG 的正确姿势不是"哪个环节看着不顺眼就改哪个",而是先分层度量、定位到最短的板,再对症下药——这也是后文会反复强调"分层评估"的原因。
3. 一个简化的 RAG 流程示例
# 简化版 RAG 流程:建库 + 问答
class SimpleRAG:
def __init__(self, embedding_model, vector_store, llm):
self.embedding_model = embedding_model
self.vector_store = vector_store
self.llm = llm
def build_knowledge_base(self, documents):
"""离线阶段:切块 -> 向量化 -> 入库"""
for doc in documents:
chunks = self.split_into_chunks(doc, chunk_size=500, overlap=50)
vectors = self.embedding_model.encode(chunks)
self.vector_store.add(chunks, vectors)
def split_into_chunks(self, text, chunk_size, overlap):
"""最朴素的定长切块:每 chunk_size 个字符切一段,相邻块重叠 overlap"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - overlap
return chunks
def answer(self, question, top_k=5):
"""在线阶段:检索 -> 注入上下文 -> 生成"""
query_vector = self.embedding_model.encode([question])[0]
retrieved_chunks = self.vector_store.search(query_vector, top_k=top_k)
context = "\n\n".join(retrieved_chunks)
prompt = f"""请仅根据下面的资料回答问题,如果资料中没有相关信息,请明确说不知道。
资料:
{context}
问题:{question}
回答:"""
return self.llm.generate(prompt)这个流程能跑通,但离"好用"还有距离——它假设"向量相似 = 语义相关",也假设"固定长度切块"是合理的。这两个假设在真实场景里都会翻车,这就引出了后面两个进阶方向。
4. 真实案例:一个"能跑但不好用"的客服 RAG 是怎么翻车的
某电商团队第一版客服 RAG 就是照上面这段代码搭的:把几千篇帮助文档按 500 字定长切块,用一个通用中文 embedding 模型建库,检索 Top-5 直接喂给模型。上线一周后客服满意度不升反降,复盘发现几类典型问题:
这三个坑分别对应后文要讲的三件事:混合检索(补关键词/单号召回)、更聪明的切分(别把一条政策切两半)、以及元数据过滤(按订单状态精确定位)。可以说,一个朴素 RAG 从"能跑"到"好用"的全部功夫,都藏在这些细节里。
RAG 进阶①:为什么查不准,怎么查准 🔍
RAG 系统上线后最常见的抱怨是"答非所问",根源往往不在生成环节,而在检索环节——检索到的资料本身就不对,模型再强也是"巧妙地瞎编"。
1. 朴素向量检索的局限
向量检索本质是在做"语义相似度"匹配,但"语义相似"不等于"真正相关",至少有三类典型翻车场景:
2. 混合检索:向量 + 关键词各取所长
混合检索(Hybrid Search)把稠密向量检索(Dense Retrieval,捕捉语义)和稀疏关键词检索(如 BM25,捕捉精确词面匹配)结合起来,再融合两路结果。
def hybrid_search(query, vector_store, bm25_index, top_k=10, alpha=0.5):
"""
alpha 控制向量检索与关键词检索的权重,alpha 越大越偏向语义匹配
"""
# 向量检索:返回 (chunk_id, 相似度分数)
dense_results = vector_store.search(query, top_k=top_k * 2)
# 关键词检索:返回 (chunk_id, BM25 分数)
sparse_results = bm25_index.search(query, top_k=top_k * 2)
# 分数归一化后加权融合(Reciprocal Rank Fusion 是更常用的融合方式)
scores = {}
for rank, (chunk_id, _) in enumerate(dense_results):
scores[chunk_id] = scores.get(chunk_id, 0) + alpha * (1 / (rank + 1))
for rank, (chunk_id, _) in enumerate(sparse_results):
scores[chunk_id] = scores.get(chunk_id, 0) + (1 - alpha) * (1 / (rank + 1))
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [chunk_id for chunk_id, _ in ranked[:top_k]]##### 2.1 稠密 vs 稀疏:两种检索到底差在哪
要理解为什么要"混合",得先看清两路检索各自的脾气。稀疏检索(BM25 是最经典的代表)本质是"数词":一个词在查询里出现、又在文档里出现,就加分,还会根据这个词在整个语料里有多稀有(IDF)来调节权重——越罕见的词命中越值钱。它的向量是"稀疏"的(几万维词表里只有出现过的词非零),所以精确、可解释、对专有名词和长尾词天然友好,但完全不懂同义词——"番茄"和"西红柿"在它眼里是两个毫不相干的词。
稠密检索则把整句话压进一个几百到几千维的稠密向量里,靠的是语义相近的句子在向量空间里也相近,所以它懂"番茄=西红柿"、懂"怎么退款≈退货流程",但对没在训练语料里见过的新词、型号、编号很容易抓瞎。
| 维度 | 稀疏检索(BM25) | 稠密检索(向量) |
|---|---|---|
| 匹配依据 | 词面精确匹配 + IDF 权重 | 语义相似度 |
| 同义词/改写 | 基本无能为力 | 天然擅长 |
| 专有名词/型号/编号 | 强 | 弱,容易混淆 |
| 长尾/罕见词 | 强 | 弱(训练里没见过就抓瞎) |
| 可解释性 | 高(能指出命中了哪个词) | 低(黑盒向量距离) |
| 是否需要训练 | 不需要(统计方法) | 需要好的 embedding 模型 |
一句话总结:稀疏检索"抠字眼",稠密检索"懂意思",真实查询里两种能力都需要,所以生产系统几乎都上混合检索。
##### 2.2 RRF:更稳的融合方式
上面伪代码里用 `alpha` 手动加权两路的"倒数排名分",实际上这已经很接近工业界最常用的融合算法——RRF(Reciprocal Rank Fusion,倒数排名融合)。RRF 的精妙之处在于它只看"排名"不看"原始分数",从而绕开了一个大坑:向量相似度分(比如 0.82)和 BM25 分(比如 14.3)根本不在一个量纲上,直接加权相加毫无意义,硬要归一化又对异常值特别敏感。RRF 直接把每一路的排名 `rank` 代进公式 `1 / (k + rank)`,`k` 是个平滑常数(经验值 60),谁排得靠前谁分高,多路一相加就完事。
def reciprocal_rank_fusion(result_lists, k=60, top_k=10):
"""
通用 RRF 融合:输入任意多路检索结果,输出融合后的排序。
result_lists: List[List[chunk_id]],每一路已按相关性从高到低排好序
k: 平滑常数,经验值 60,越大则不同排名之间的分差越平缓
"""
fused_scores = {}
for single_list in result_lists:
for rank, chunk_id in enumerate(single_list):
# 注意 rank 从 0 开始,所以用 rank + 1 表示真实名次
fused_scores[chunk_id] = fused_scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1)
ranked = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
return [chunk_id for chunk_id, _ in ranked[:top_k]]
# 使用示例:融合向量、BM25、以及一路"标题精确匹配"的结果
def hybrid_search_rrf(query, vector_store, bm25_index, title_index, top_k=10):
dense = [cid for cid, _ in vector_store.search(query, top_k=30)]
sparse = [cid for cid, _ in bm25_index.search(query, top_k=30)]
title = [cid for cid, _ in title_index.search(query, top_k=30)]
return reciprocal_rank_fusion([dense, sparse, title], k=60, top_k=top_k)RRF 的两个实战好处:一是新增/删除一路检索器几乎零成本,不用重新调权重;二是对"某一路偶尔给出一个高分离群项"有天然的鲁棒性,因为它只认排名不认绝对分。缺点是它平等对待每一路,如果你明确知道某一路质量更高,可以给它的贡献乘一个权重系数。
3. 重排序(Reranking):用更贵但更准的模型精修候选集
向量检索为了追求速度,用的是"双塔模型"——问题和文档分别独立编码成向量,再算距离,这种结构没法让问题和文档做深度的相互对照。重排序引入一个"交叉编码器"(Cross-Encoder),把问题和每个候选片段一起输入模型,直接打分,精度显著更高,但因为要对每个候选单独跑一次模型,成本也更高。所以典型做法是"先粗后精"两阶段:
def retrieve_with_rerank(query, vector_store, reranker, coarse_k=50, final_k=5):
# 第一阶段:粗召回
candidates = vector_store.search(query, top_k=coarse_k)
# 第二阶段:精排——reranker 直接对 (query, chunk) 打相关性分
pairs = [(query, chunk) for chunk in candidates]
scores = reranker.predict(pairs)
reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [chunk for chunk, _ in reranked[:final_k]]##### 3.1 双塔模型 vs 交叉编码器:图解为什么后者更准
用一个比喻说清两者的区别。双塔模型(Bi-Encoder)像两个人各自把一篇文章读完写成一句话摘要,然后比谁的摘要更像——它们从头到尾没交流过,各写各的,最后只比对两句摘要。好处是文档的"摘要"(向量)可以离线算好存起来,查询来了只需算一次查询向量再比距离,快到能在几百万文档里毫秒级检索。代价是问题和文档之间从未"对过话",很多细粒度的相关性信号在各自压缩成向量时就丢了。
交叉编码器(Cross-Encoder)则是把问题和文档拼在一起塞进同一个模型,让模型内部的注意力机制在问题的每个词和文档的每个词之间充分交互,最后直接输出一个相关性分数。它能捕捉"这个文档里的这句话正好精确回答了问题里的这个点"这种细节,所以准得多。代价是无法预计算——每个 (问题, 文档) 组合都得现场跑一次模型,成本正比于候选数量,绝不可能拿来在百万文档里做首轮检索。
这就是"先粗后精"两阶段架构的根本原因:用快而糙的双塔模型把候选从百万缩到几十,再用慢而精的交叉编码器在这几十个里挑出真正的前几名。
| 维度 | 双塔模型 Bi-Encoder | 交叉编码器 Cross-Encoder |
|---|---|---|
| 问题与文档是否交互 | 否,各自独立编码 | 是,拼在一起深度交互 |
| 能否离线预计算 | 能(文档向量提前存好) | 不能(必须现场算) |
| 单次相对成本 | 低 | 高(约为前者的几十到上百倍) |
| 精度 | 中 | 高 |
| 适用阶段 | 首轮大规模粗召回 | 小候选集精排 |
##### 3.2 用 sentence-transformers 调一个真实的 Reranker
上面的 `reranker.predict` 是个抽象接口,落到实处最常用的是 sentence-transformers 库的 `CrossEncoder`,配一个开源重排模型(如 bge-reranker、mxbai-rerank 系列)。下面是一段可直接运行的完整示例:
from sentence_transformers import CrossEncoder
# 加载一个多语言重排模型(首次运行会自动下载权重)
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
def rerank(query, candidates, final_k=5):
"""
query: 用户问题(字符串)
candidates: 粗召回回来的候选片段列表 List[str]
返回:按相关性排序后的 (片段, 分数) 列表,取前 final_k 个
"""
# 构造 (query, passage) 句对,模型对每一对输出一个相关性 logit
pairs = [[query, passage] for passage in candidates]
scores = reranker.predict(pairs) # 返回一维分数数组
scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return scored[:final_k]
# 端到端串联:混合检索粗召回 -> 交叉编码器精排
def search_pipeline(query, vector_store, bm25_index, reranker_fn):
coarse_ids = hybrid_search(query, vector_store, bm25_index, top_k=50)
coarse_texts = [vector_store.get_text(cid) for cid in coarse_ids]
top = reranker_fn(query, coarse_texts, final_k=5)
return [text for text, score in top if score > 0.0] # 顺手用分数阈值过滤明显不相关的如果不想自己部署模型,也可以直接调云端重排 API(如 Cohere Rerank、Jina Reranker),接口大同小异:传一个 query 和一批文档,返回排序和分数。云端 API 的好处是免运维、支持超长文档,代价是每次调用有网络延迟和费用。
##### 3.3 调用商用重排 API 的等价写法
import requests
def cohere_rerank(query, documents, top_n=5, api_key="YOUR_KEY"):
"""调用商用重排 API 的通用写法,返回排序后的文档下标与分数"""
resp = requests.post(
"https://api.cohere.com/v1/rerank",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": "rerank-multilingual-v3.0",
"query": query,
"documents": documents,
"top_n": top_n,
},
timeout=10,
)
resp.raise_for_status()
results = resp.json()["results"]
# 每个 result 含 index(对应原 documents 下标)和 relevance_score
return [(documents[r["index"]], r["relevance_score"]) for r in results]一个常被忽略的收益:重排分数本身是绝对相关性的良好估计,可以拿来做"置信度阈值"——分数普遍偏低时说明知识库里压根没有相关内容,此时应该让系统直接回答"没有找到相关资料",而不是硬把几个不相关片段塞给模型逼它编。这是抑制幻觉的一个极其廉价又有效的手段。
4. 查询改写:让"问题"更适合被检索到
用户的原始提问往往不是"检索友好"的形式,查询改写(Query Rewriting/Transformation)在检索前先对问题做一次加工:
def hyde_retrieve(query, llm, vector_store, top_k=5):
# 第一步:让模型生成一段"假设"能回答该问题的文档片段
hypo_prompt = f"请写一段简短的文字来回答这个问题(即使你并不确定答案是否准确):{query}"
hypothetical_doc = llm.generate(hypo_prompt)
# 第二步:用这段假想文档(而非原始问题)去做向量检索
return vector_store.search_by_text(hypothetical_doc, top_k=top_k)##### 4.1 多查询改写(Multi-Query)的完整实现
同义扩展的思路可以做得更彻底:让 LLM 一次性生成多个语义等价但措辞不同的查询变体,每个变体各跑一次检索,再用 RRF 融合。这对"用户提问措辞刁钻、和文档用词差异大"的场景特别有效。
def multi_query_retrieve(query, llm, vector_store, n_variants=4, top_k=10):
# 第一步:让模型把原问题改写成多个不同角度的等价查询
rewrite_prompt = f"""请把下面的问题改写成 {n_variants} 个措辞不同但意思相同的检索查询,
每行一个,不要编号,不要多余解释。
原问题:{query}"""
variants_text = llm.generate(rewrite_prompt)
queries = [q.strip() for q in variants_text.split("\n") if q.strip()]
queries.append(query) # 别忘了把原始问题也加进去
# 第二步:每个查询各检索一路,收集为多个有序列表
result_lists = []
for q in queries:
ids = [cid for cid, _ in vector_store.search(q, top_k=top_k)]
result_lists.append(ids)
# 第三步:用 RRF 融合所有路的结果
return reciprocal_rank_fusion(result_lists, k=60, top_k=top_k)##### 4.2 查询分解(Query Decomposition):把复合问题拆开各个击破
当一个问题里明显包含多个独立的信息需求时("对比 A 和 B 的定价,并说明哪个更适合初创团队"),与其寄希望于一次检索凑齐所有材料,不如先让 LLM 把它拆成若干原子子问题,每个子问题独立检索,最后把所有材料汇总给模型综合作答。
def decompose_and_retrieve(question, llm, retriever, top_k=4):
# 第一步:把复合问题拆成若干可独立检索的子问题
decompose_prompt = f"""把下面这个问题拆解成 2-4 个可以各自独立检索的子问题,
每行一个子问题,不要编号。如果问题本身很简单无需拆解,就只输出原问题。
问题:{question}"""
sub_questions = [
s.strip() for s in llm.generate(decompose_prompt).split("\n") if s.strip()
]
# 第二步:逐个子问题检索,带上子问题本身作为材料的标注
gathered = []
for sub_q in sub_questions:
chunks = retriever.search(sub_q, top_k=top_k)
gathered.append(f"【子问题:{sub_q}】\n" + "\n".join(chunks))
# 第三步:把所有材料 + 原问题交给模型综合作答
context = "\n\n".join(gathered)
final_prompt = f"""请综合下面按子问题分组的资料,完整回答用户的原始问题。
{context}
原始问题:{question}
回答:"""
return llm.generate(final_prompt)##### 4.3 查询改写的另一面:别过度改写
查询改写不是免费午餐,每一次改写都是一次额外的 LLM 调用,会增加延迟和成本,还可能"帮倒忙"——把一个本来精确的问题改写得更模糊。实践中的经验是:对简短、口语化、含歧义的问题做改写收益最大;对已经很明确、含专有名词的问题("报错 ORA-01555 怎么解决")反而应该原样直查,甚至偏向关键词检索。所以成熟系统往往会先做一次轻量的"查询路由/分类",判断这个问题该走哪条检索路径,而不是无脑对所有问题都套一遍改写。
三种检索优化技术的对比:
| 技术 | 解决的问题 | 额外成本 | 适用场景 |
|---|---|---|---|
| 混合检索(向量+BM25) | 精确关键词/专有名词召回不足 | 需要额外维护一套关键词索引 | 查询中含产品型号、报错代码、人名等 |
| 重排序 Reranking | 粗召回排序不够精准 | 每次查询多一次模型推理,延迟增加 | 对最终答案精度要求高的场景 |
| 查询改写 / HyDE | 用户提问和文档表述方式不匹配 | 多一次 LLM 调用生成改写/假想文档 | 口语化提问、专业文档库 |
| 多查询 Multi-Query | 单一措辞召回覆盖面不足 | 多次 LLM 改写 + 多路检索 | 用户措辞与文档差异大、召回率优先 |
| 查询分解 | 一个问题含多个独立信息需求 | 多次检索 + 一次综合 | 对比类、多要素复合问题 |
这些技术并不互斥,生产级 RAG 系统常常是"查询路由 → 查询改写/分解 → 混合检索粗召回 → 重排序精排"串联使用。
5. 元数据过滤:先缩小范围,再谈相似度
很多真实查询自带"硬约束":只查 2025 年的财报、只查"已发货"状态的订单、只查"人力资源"部门发布的制度。这些约束用向量相似度去逼近既不精确也浪费算力——正确做法是把它们做成结构化的元数据(metadata),在向量检索前或检索中先做一次精确过滤,把搜索空间缩小到符合约束的子集里,再在子集内比相似度。
def build_indexed_chunks(documents):
"""建库时给每个 chunk 附上结构化元数据"""
indexed = []
for doc in documents:
for chunk in split_into_chunks(doc.text, chunk_size=500, overlap=50):
indexed.append({
"text": chunk,
"metadata": {
"doc_id": doc.id,
"department": doc.department, # 部门
"year": doc.year, # 年份
"doc_type": doc.doc_type, # 文档类型:政策/手册/财报...
"updated_at": doc.updated_at, # 更新时间戳
"access_level": doc.access_level # 权限等级
}
})
return indexed
def filtered_search(query, vector_store, filters, top_k=5):
"""带元数据过滤的检索:先按 filters 精确筛,再在子集内比相似度"""
# 大多数向量库(如 Qdrant / Weaviate / Milvus)都支持 filter 参数
return vector_store.search(
query=query,
top_k=top_k,
filter={
"must": [
{"key": "year", "match": {"value": filters["year"]}},
{"key": "department", "match": {"value": filters["department"]}},
{"key": "access_level", "range": {"lte": filters["max_access_level"]}},
]
}
)元数据过滤还有个常被忽视却极其重要的用途——权限隔离。企业知识库里,普通员工不该检索到只有管理层可见的文档。把权限等级、可见部门做成元数据,在检索时按当前用户的身份强制过滤,是实现"千人千面、各看各的"和数据合规的最干净方式,远比在生成阶段让模型"自觉不要透露"要可靠。
RAG 进阶②:切得好,量得出 ✂️
如果说检索算法决定了"能不能从一堆片段里挑对",那切分策略决定的是"这堆片段本身够不够好"——喂给检索器的原材料质量上不去,后面再怎么调检索算法也是治标不治本。
1. 从"定长切块"到更聪明的切分策略
最朴素的做法是按固定字符数/Token 数切块(如前面伪代码里的 `chunk_size=500`),实现简单,但经常在句子中间、表格中间甚至代码块中间硬切一刀,破坏语义完整性。三种更进阶的策略:
语义切分(Semantic Chunking):不按固定长度切,而是按语义边界切——比如逐句计算相邻句子的向量相似度,当相似度出现明显下降(说明话题变了)时就在此处断开,让每个 chunk 内部话题尽量单一、连贯。
父文档切分(Parent Document Retrieval):检索用的是"小块",但真正塞给模型的是"大块"。具体做法是把文档先切成较大的"父块"(比如完整的一个章节),再把每个父块进一步切成更小的"子块"用于向量化和检索;检索命中子块后,不直接把子块喂给模型,而是回溯找到它所属的父块,把父块的完整上下文交给模型。这样既保证了检索的精确度(小块语义单一,向量更准),又保证了生成时上下文的完整度(大块信息更全)。
上下文化检索(Contextual Retrieval):这是 Anthropic 提出的一种切分增强手段。普通切块有个通病——一个 chunk 脱离原文档后常常"不知所云",比如一段财报里写着"公司该季度营收增长了 3%",脱离上下文后完全不知道"该季度"是哪季度、"公司"是哪家公司。Contextual Retrieval 的做法是:在切块之后、向量化之前,让大模型为每个 chunk 生成一小段"文档级上下文摘要"(例如"这段摘自 ACME 公司 2025 年第三季度财报,描述云业务部门的营收情况"),拼接在 chunk 前面再做向量化和索引。这样每个 chunk 的向量表示自带背景信息,检索命中率明显提升。
def contextualize_chunk(llm, full_document, chunk):
"""为每个 chunk 生成简短的文档级上下文,再拼接到 chunk 前面"""
prompt = f"""这是完整文档:
{full_document}
这是文档中的一个片段:
{chunk}
请用1-2句话说明这个片段在整篇文档中的上下文背景(比如属于哪个章节、涉及哪个主体),
不要复述片段内容本身,只给出定位信息。"""
context_note = llm.generate(prompt)
return f"{context_note}\n\n{chunk}"
def build_contextual_index(llm, embedding_model, vector_store, documents):
for doc in documents:
chunks = split_into_chunks(doc, chunk_size=500, overlap=50)
contextual_chunks = [contextualize_chunk(llm, doc, c) for c in chunks]
vectors = embedding_model.encode(contextual_chunks)
vector_store.add(contextual_chunks, vectors)##### 1.1 递归字符切分:定长切块的"文明升级版"
在"定长"和"语义"之间,有个性价比极高的折中方案——递归字符切分(RecursiveCharacterTextSplitter),它是 LangChain 里的默认切分器,也是绝大多数生产系统的首选。核心思想是:按一个"分隔符优先级列表"从粗到细依次尝试切分——先按段落(`\n\n`)切,如果切出来的块还太大,就退而按行(`\n`)切,再不行按句号切,最后才按字符硬切。这样它会尽量在自然的语义边界上下刀,只有实在没办法时才"硬切"。
def recursive_split(text, chunk_size=500, overlap=50,
separators=None):
"""
递归字符切分:按分隔符优先级从粗到细尝试,尽量在自然边界切分。
separators 默认按"段落 -> 行 -> 句 -> 词 -> 字符"逐级降级。
"""
if separators is None:
separators = ["\n\n", "\n", "。", "!", "?", ";", " ", ""]
def _split(text, seps):
# 已经足够短,直接作为一个块返回
if len(text) <= chunk_size or not seps:
return [text]
sep = seps[0]
# 用当前分隔符切;空字符串分隔符表示按字符逐个切
pieces = list(text) if sep == "" else text.split(sep)
chunks, buffer = [], ""
for piece in pieces:
candidate = buffer + (sep if buffer else "") + piece
if len(candidate) <= chunk_size:
buffer = candidate
else:
if buffer:
chunks.append(buffer)
# 单个 piece 仍然超长,用更细的分隔符递归处理
if len(piece) > chunk_size:
chunks.extend(_split(piece, seps[1:]))
buffer = ""
else:
buffer = piece
if buffer:
chunks.append(buffer)
return chunks
raw_chunks = _split(text, separators)
# 加入相邻块重叠:把上一块尾部的 overlap 个字符接到下一块开头
result = []
for i, ch in enumerate(raw_chunks):
if i > 0 and overlap > 0:
tail = raw_chunks[i - 1][-overlap:]
ch = tail + ch
result.append(ch)
return result##### 1.2 按 Token 切分:和模型窗口对齐
按字符数切分有个隐患:不同语言、不同内容的"字符数"和"Token 数"换算比例差异很大(中文一个字往往约等于 1~2 个 Token,英文一个 Token 约等于 4 个字符,代码里的符号更是密集)。而模型上下文窗口、embedding 模型的最大输入长度、以及计费,全都是按 Token 算的。所以对长度敏感的场景(尤其要严格控制注入上下文的 Token 预算时),应该直接按 Token 切分。
import tiktoken
def token_based_split(text, max_tokens=256, overlap_tokens=32,
encoding_name="cl100k_base"):
"""按 Token 数切分,保证每个 chunk 不超过 embedding/模型的 Token 上限"""
enc = tiktoken.get_encoding(encoding_name)
token_ids = enc.encode(text)
chunks = []
start = 0
while start < len(token_ids):
end = start + max_tokens
piece_ids = token_ids[start:end]
chunks.append(enc.decode(piece_ids))
if end >= len(token_ids):
break
start = end - overlap_tokens # 重叠 overlap_tokens 个 token,避免边界信息割裂
return chunks##### 1.3 句子窗口检索:检索一句,返回一窗
父文档检索还有个更精细的变体——句子窗口检索(Sentence Window Retrieval)。它把文档切到"单句"粒度做向量化和检索(句子粒度语义最纯,向量最准),但每个句子在建库时会记住自己在原文里前后各 N 句的邻居;检索命中某一句后,返回给模型的不是这孤零零一句,而是"命中句 + 前后各 N 句"组成的一个小窗口,从而补足上下文。
def build_sentence_window_index(document, window_size=2):
"""把文档切成句子,每个句子记录它前后 window_size 句作为返回窗口"""
sentences = split_into_sentences(document) # 按标点切句
records = []
for i, sent in enumerate(sentences):
left = max(0, i - window_size)
right = min(len(sentences), i + window_size + 1)
records.append({
"text": sent, # 用这句话做向量化和检索
"window": "".join(sentences[left:right]), # 命中后实际返回这个窗口
"position": i,
})
return records
def sentence_window_retrieve(query, vector_store, top_k=5):
"""检索命中的是单句,但取出它预存的上下文窗口交给模型"""
hits = vector_store.search(query, top_k=top_k)
return [hit["window"] for hit in hits] # 返回窗口而非单句##### 1.4 chunk 大小到底怎么选
chunk_size 是 RAG 里最没有"标准答案"、又最影响效果的旋钮之一。太小则每个块信息不完整、检索命中了也答不全;太大则一个块混入多个话题,向量被"平均"得失焦、还浪费上下文预算。一个可操作的经验区间:
| chunk 大小(Token) | 特点 | 适用场景 |
|---|---|---|
| 100~200 | 语义纯、检索精准、易命中 | FAQ、短问答、精确事实查找 |
| 256~512 | 平衡点,最常用 | 通用知识库、技术文档 |
| 512~1024 | 上下文完整、适合需要推理综合 | 长篇政策、法律条款、论文 |
| 1024 以上 | 信息全但易失焦、成本高 | 配合父文档检索的"父块"粒度 |
没有普适最优值,正确做法是拿自己的真实问答集做小规模 A/B——固定其他变量,只扫 chunk_size,看检索 Recall 和最终答案质量怎么变。重叠(overlap)通常取 chunk_size 的 10%~20%,作用是防止把一句话/一个关键信息正好切在两块的接缝处。
四种切分策略对比:
| 策略 | 核心思路 | 优点 | 代价 |
|---|---|---|---|
| 定长切块 | 按固定字符/Token 数切 | 实现简单、速度快 | 经常破坏语义完整性 |
| 递归字符切分 | 按分隔符优先级从粗到细切 | 尽量落在自然边界、性价比高 | 仍是启发式,遇特殊格式需调分隔符 |
| 语义切分 | 按语义边界(相似度骤降处)切 | 每个 chunk 话题连贯 | 需要额外计算句间相似度,切分不等长 |
| 父文档 / 句子窗口 | 小块检索、大块(窗口)返回 | 兼顾检索精度和上下文完整度 | 需要维护父子/窗口两级结构 |
| 上下文化检索 | 为每个 chunk 附加 LLM 生成的文档级摘要 | 显著提升脱离上下文后的检索命中率 | 建库阶段要为每个 chunk 多调一次 LLM,成本上升 |
2. Embedding 模型选型:检索质量的地基
在纠结检索算法之前,先得选对 embedding 模型——它把文本变成向量,是整个语义检索的地基。地基没打好,上层再花哨也没用。选型时主要看几个维度:
from sentence_transformers import SentenceTransformer
# 自托管一个多语言 embedding 模型
model = SentenceTransformer("BAAI/bge-m3") # 支持中英等多语言、最长 8192 token
def embed_documents(texts):
# normalize_embeddings=True 让向量归一化,之后可直接用点积当余弦相似度
return model.encode(texts, normalize_embeddings=True, batch_size=32)
def embed_query(query):
# 有些模型建议给"查询"加特定指令前缀以提升检索效果,务必看模型卡说明
return model.encode(
[f"为这个句子生成用于检索的表示:{query}"],
normalize_embeddings=True,
)[0]| 选型维度 | 偏向自托管开源模型 | 偏向云端 API 模型 |
|---|---|---|
| 数据合规 | 数据不出内网,适合敏感场景 | 需评估数据传输合规性 |
| 成本 | 一次性算力投入,边际成本近零 | 按调用量付费,量大时贵 |
| 质量 | 头部开源已很强,但需自己调优 | 通常开箱即用质量高 |
| 运维 | 需自己部署、扩容、更新 | 免运维 |
| 定制 | 可用自有数据微调 | 一般不可微调 |
一个高频踩坑点:建库和检索必须用同一个 embedding 模型的同一版本。一旦换了 embedding 模型,整个向量库都得重新编码(re-index),因为不同模型产出的向量空间互不兼容,新问题向量和旧文档向量根本不在一个坐标系里,算出来的相似度全是噪声。
3. 怎么评估一个 RAG 系统好不好
很多团队评估 RAG 系统的方式是"人工看几个回答顺不顺眼",这种方式定位不了问题出在哪一环。RAG 是一个两段式流水线(检索 + 生成),必须分层评估,否则"最终答案差"这个结论完全无法指导优化方向——到底是没检索到对的资料,还是检索到了但模型没用好?
检索层评估:只看"找回来的片段对不对",跟生成质量无关。
def mrr(retrieved_lists, relevant_ids_list):
"""retrieved_lists: 每次查询返回的排序后 chunk_id 列表
relevant_ids_list: 每次查询对应的真正相关 chunk_id 集合"""
reciprocal_ranks = []
for retrieved, relevant_ids in zip(retrieved_lists, relevant_ids_list):
rank = next(
(i + 1 for i, cid in enumerate(retrieved) if cid in relevant_ids),
None
)
reciprocal_ranks.append(1 / rank if rank else 0)
return sum(reciprocal_ranks) / len(reciprocal_ranks)再补两个最常用的检索指标实现——Recall@k 和归一化折损累计增益 NDCG@k。NDCG 比单纯的 Recall 更细腻,它同时考虑了"相关片段有没有召回"和"是不是排在靠前的位置",还能处理"分档相关性"(完全相关 vs 部分相关):
import math
def recall_at_k(retrieved, relevant_ids, k):
"""Top-k 里命中的相关片段数 / 全部相关片段数"""
top_k = retrieved[:k]
hit = sum(1 for cid in top_k if cid in relevant_ids)
return hit / len(relevant_ids) if relevant_ids else 0.0
def ndcg_at_k(retrieved, relevance_map, k):
"""
relevance_map: {chunk_id: 相关性等级},如 2=高度相关, 1=部分相关, 0=不相关
DCG 对靠前位置的命中给更高权重;IDCG 是理想排序下的 DCG,用于归一化。
"""
def dcg(scores):
return sum(rel / math.log2(i + 2) for i, rel in enumerate(scores))
gains = [relevance_map.get(cid, 0) for cid in retrieved[:k]]
ideal = sorted(relevance_map.values(), reverse=True)[:k]
idcg = dcg(ideal)
return dcg(gains) / idcg if idcg > 0 else 0.0生成层评估:假设检索结果已经给定,只看"模型基于这些资料回答得好不好"。
之所以要分层评估,是因为"最终答案不好"可能来自完全不同的病根:如果 Recall 很低,说明问题出在切分或检索策略,调 Prompt 没用;如果 Recall 很高但 Faithfulness 很低,说明资料找对了但模型在瞎编,需要在生成端下功夫(比如更强的"仅依据资料回答"指令约束)。混着看只能看到"不好",分层看才能定位"哪里不好、该改哪一环"。
##### 3.1 用 LLM 当裁判:RAGAS 风格的忠实度评估
忠实度、答案相关性这类指标没法用简单规则算,业界主流做法是"用一个强模型当裁判"(LLM-as-a-Judge),RAGAS 是这套方法的代表框架。忠实度的核心思路很巧妙:先让裁判把回答拆成一条条独立的原子论断,再逐条判断每条论断能否被检索到的资料支持,最后算"被支持的论断数 / 总论断数"。
def faithfulness_score(answer, contexts, judge_llm):
"""
忠实度 = 回答中能被检索资料支持的论断比例。
answer: 模型的最终回答
contexts: 检索到的资料片段列表
judge_llm: 充当裁判的强模型
"""
# 第一步:把回答拆解成一条条可独立验证的原子论断
decompose_prompt = f"""把下面这段回答拆解成一系列独立的、可单独验证的简单陈述句,
每行一句,不要编号。
回答:{answer}"""
claims = [c.strip() for c in judge_llm.generate(decompose_prompt).split("\n") if c.strip()]
if not claims:
return 1.0
# 第二步:逐条判断该论断能否被资料支持
context_text = "\n\n".join(contexts)
supported = 0
for claim in claims:
verify_prompt = f"""资料:
{context_text}
陈述:{claim}
这条陈述能否由上面的资料直接推出或支持?只回答 YES 或 NO。"""
verdict = judge_llm.generate(verify_prompt).strip().upper()
if verdict.startswith("YES"):
supported += 1
return supported / len(claims)##### 3.2 答案相关性与上下文精度
答案相关性的一个经典算法(RAGAS 采用)同样很有意思:让裁判"反过来"从答案里推断出"这个答案是在回答什么问题",生成几个反推问题,再计算这些反推问题和原始问题的向量相似度——如果答案切题,反推出的问题就该和原问题很像;如果答案跑题或答非所问,反推问题就会飘。
def answer_relevance_score(question, answer, judge_llm, embed_fn, n=3):
"""从答案反推问题,再和原问题比相似度,衡量答案是否切题"""
gen_prompt = f"""根据下面这个回答,反推出它最可能在回答的 {n} 个问题,每行一个:
回答:{answer}"""
reverse_questions = [
q.strip() for q in judge_llm.generate(gen_prompt).split("\n") if q.strip()
]
q_vec = embed_fn(question)
sims = []
for rq in reverse_questions:
rq_vec = embed_fn(rq)
# 向量已归一化时,点积即余弦相似度
sims.append(sum(a * b for a, b in zip(q_vec, rq_vec)))
return sum(sims) / len(sims) if sims else 0.0##### 3.3 建一套评估集:让优化有据可依
上面这些指标要发挥作用,前提是有一套"评估集"(黄金问答对)。手工标注成本高,实践中常用一个折中办法:让 LLM 从知识库文档里自动"出题"——给它一个 chunk,让它生成一个"答案就在这个 chunk 里"的问题,这个 chunk 就是该问题的黄金相关片段。这样能快速攒出几百上千条评估样本,再人工抽检修正。
def generate_eval_dataset(chunks, llm, questions_per_chunk=1):
"""从知识库 chunk 自动生成 (问题, 黄金片段) 评估对"""
dataset = []
for chunk in chunks:
prompt = f"""根据下面这段资料,提出 {questions_per_chunk} 个用户可能会问、
且答案完全包含在这段资料里的问题,每行一个,不要编号:
资料:{chunk['text']}"""
questions = [q.strip() for q in llm.generate(prompt).split("\n") if q.strip()]
for q in questions:
dataset.append({"question": q, "gold_chunk_id": chunk["id"]})
return dataset有了评估集,每次改动切分策略、换 embedding 模型、加不加 reranker,都能跑一遍拿到量化对比,而不是靠"感觉好像好了点"。这是 RAG 工程从"玄学调参"走向"数据驱动"的分水岭。
RAG 进阶③:单次检索不够时 🔄
前面讨论的都是"一次提问、一次检索"的场景,但很多真实问题天生需要多轮信息拼接才能回答,比如"跟去年同期相比,A 产品线和 B 产品线哪个增速更快、主要驱动因素是什么"——这需要先分别查到两条产品线各自的历史数据,再做对比和归因,单次检索根本凑不齐所有信息。
1. Agentic RAG:把"要不要检索、检索什么"交给模型自己判断
传统 RAG 是一条写死的流水线:收到问题就检索、检索完就生成,中间没有任何判断环节。Agentic RAG 把检索变成模型可以自主调用的一个"工具",模型自己决定:这个问题需不需要检索?需要检索几次?每次检索什么关键词?检索到的资料够不够回答,还是要接着查?
def agentic_rag(question, llm, retriever, max_iterations=4):
"""让模型自主决定检索动作,直到它认为信息足够为止"""
gathered_context = []
for _ in range(max_iterations):
decision_prompt = f"""问题:{question}
已收集到的资料:
{gathered_context if gathered_context else "(暂无)"}
请判断:现有资料是否足以回答这个问题?
如果足够,回复:ANSWER
如果不够,回复:SEARCH: <你想检索的具体查询词>"""
decision = llm.generate(decision_prompt)
if decision.startswith("SEARCH:"):
sub_query = decision.replace("SEARCH:", "").strip()
new_chunks = retriever.search(sub_query, top_k=5)
gathered_context.extend(new_chunks)
else:
break
final_prompt = f"资料:\n{gathered_context}\n\n问题:{question}\n请给出最终回答:"
return llm.generate(final_prompt)##### 1.1 更现代的写法:用 function calling 把检索做成工具
上面用字符串协议(`SEARCH:` / `ANSWER`)让模型表达意图,在真实工程里更稳的方式是用模型原生的"工具调用"(function calling / tool use)能力,把检索声明成一个结构化工具,让模型通过标准的工具调用接口来触发检索,框架自动解析参数、执行、把结果回灌给模型。这样既不用手写脆弱的字符串解析,还能让模型一次并行调用多个工具(比如同时查知识库和查数据库)。
# 以 Claude 的工具调用为例声明一个检索工具
tools = [{
"name": "search_knowledge_base",
"description": "在企业知识库中检索与查询相关的文档片段,返回最相关的若干段落。",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "检索查询词,应聚焦、具体"},
"top_k": {"type": "integer", "description": "返回片段数量,默认 5"},
},
"required": ["query"],
},
}]
def agentic_rag_with_tools(question, client, retriever, max_turns=5):
messages = [{"role": "user", "content": question}]
for _ in range(max_turns):
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
# 模型没有再调用工具,说明它认为信息够了,直接产出答案
tool_uses = [b for b in resp.content if getattr(b, "type", None) == "tool_use"]
if not tool_uses:
return resp
# 执行模型请求的每一次检索,把结果作为 tool_result 回灌
tool_results = []
for tu in tool_uses:
chunks = retriever.search(tu.input["query"], top_k=tu.input.get("top_k", 5))
tool_results.append({
"type": "tool_result",
"tool_use_id": tu.id,
"content": "\n\n".join(chunks),
})
messages.append({"role": "user", "content": tool_results})
return resp2. 多跳检索(Multi-hop Retrieval):用上一步查到的结果去查下一步
多跳检索是 Agentic RAG 里最常见的一种具体模式:先检索出问题的一部分信息,把这部分信息作为线索,再去检索下一步需要的信息,像走迷宫一样一跳一跳逼近答案。经典场景是"桥接型"问题——"写《XX》这本书的作者出生在哪个城市?",需要先查到"XX 的作者是谁"(第一跳),再拿这个作者名去查"这个人出生在哪"(第二跳),两跳之间的查询词完全不同,单次检索无论如何都凑不出来。
def multi_hop_retrieve(question, llm, retriever):
# 第一跳:检索能回答"子问题1"的资料
sub_q1 = llm.generate(f"要回答「{question}」,第一步需要先查清楚什么?只给出子问题。")
hop1_chunks = retriever.search(sub_q1, top_k=3)
# 从第一跳结果里提取出关键实体/线索,构造第二跳的查询
bridge_entity = llm.generate(f"根据资料:{hop1_chunks}\n\n提取出回答「{sub_q1}」的关键实体。")
sub_q2 = llm.generate(f"结合「{bridge_entity}」,构造用于回答「{question}」的下一步检索查询。")
hop2_chunks = retriever.search(sub_q2, top_k=3)
all_context = hop1_chunks + hop2_chunks
return llm.generate(f"资料:{all_context}\n\n问题:{question}\n回答:")3. GraphRAG:用知识图谱补上"实体关系"这一课
向量检索擅长找"语义相近的一段文字",但天生不擅长回答依赖"实体之间关系"的问题,比如"A 公司的哪些高管同时在 B 公司任职"——这种问题的答案不藏在某一段连续的文字里,而是分散在图谱式的关联关系中。GraphRAG 的思路是提前从文档中抽取出实体(人物、公司、产品等)和实体间的关系,构建成知识图谱,检索时不只做向量相似度匹配,还沿着图谱的边做多跳关系查询,把"关联路径"上涉及的信息一并取出来给模型。
GraphRAG 还有个向量检索给不了的杀手锏能力——回答"全局性/总览型"问题,比如"这几百份访谈里反复出现的三大主题是什么"。这类问题没有任何单一片段能回答,必须俯瞰全局。微软提出的 GraphRAG 方案会在建图后对图做社区检测(把紧密关联的实体聚成"社区"),并为每个社区预先生成一份摘要;遇到全局问题时,先让各社区摘要分别作答,再把这些局部答案汇总成最终回答(map-reduce 式)。
def extract_graph_from_document(llm, document):
"""用 LLM 从文档中抽取 (实体, 关系, 实体) 三元组,构建知识图谱的原料"""
prompt = f"""从下面的文本中抽取实体和它们之间的关系,
以三元组列表输出,每行格式为:实体1 | 关系 | 实体2
文本:{document}"""
triples = []
for line in llm.generate(prompt).split("\n"):
parts = [p.strip() for p in line.split("|")]
if len(parts) == 3:
triples.append(tuple(parts))
return triples
def graph_rag_query(question, graph_db, vector_store, llm, hops=2):
"""结合图谱关系遍历与向量检索的混合查询"""
# 第一步:从问题中识别出起始实体
entities = llm.generate(f"从问题中提取出涉及的关键实体,逗号分隔:{question}")
seed_entities = [e.strip() for e in entities.split(",") if e.strip()]
# 第二步:沿图谱的边做多跳遍历,收集关联子图
subgraph = graph_db.traverse(seed_entities, max_hops=hops)
# 第三步:把结构化的关系路径 + 向量检索到的文本证据一起交给模型
text_evidence = vector_store.search(question, top_k=5)
context = f"实体关系:\n{subgraph}\n\n文本证据:\n{text_evidence}"
return llm.generate(f"资料:{context}\n\n问题:{question}\n回答:")三种进阶模式的对比:
| 模式 | 核心机制 | 检索次数 | 最适合的问题类型 |
|---|---|---|---|
| Agentic RAG | 模型自主决定是否检索、检索几次 | 动态,由模型判断 | 简单和复杂问题混杂、需要按需检索 |
| 多跳检索 | 用上一步检索结果构造下一步查询 | 固定的多步 | "桥接型"问题,答案分散在需顺序追溯的多处 |
| GraphRAG | 基于知识图谱做关系遍历 | 沿图谱边多跳 | 依赖实体间显式关系、或需全局总览的问题 |
4. 长上下文窗口会取代 RAG 吗
随着模型上下文窗口越做越长,一直有一种声音认为"以后直接把资料全塞进去就行了,RAG 会被淘汰"。更贴近现实的判断是——这是两种适用场景不同的方案,而非替代关系:
| 维度 | 长上下文窗口 | RAG |
|---|---|---|
| 数据规模 | 适合中小规模、能整体塞入窗口的资料 | 适合海量数据(远超窗口容量)中做精确检索 |
| 任务性质 | 适合需要"整体理解、跨段落综合"的任务 | 适合"从大量资料中定位到具体几条事实"的任务 |
| 成本 | 每次调用都要重新处理全部上下文,Token 成本高 | 只检索必要片段,成本更可控 |
| 时效性 | 资料更新需要重新组装整个上下文 | 知识库更新后,检索能立刻用上新内容,无需改动模型调用方式 |
| 精度 | 上下文越长,"大海捞针"式的关键信息丢失风险越高 | 检索环节本身就是做"降噪",精准定位相关片段 |
实践中更常见的是两者结合:用 RAG 从海量知识库里筛出一小批高度相关的资料,再依靠模型较长的上下文窗口把这批资料一次性看全、综合作答——检索负责"降维找准",长上下文负责"看全用好",两者互补而非二选一。
RAG 进阶④:让答案可信、可查、可复用 🔎
检索准了、上下文切好了,还有一步决定用户敢不敢用你的 RAG——答案得让人信得过、查得到出处。企业场景尤其如此:法务不会接受一个"看起来很像那么回事"但没法溯源的合同解读,财务也不会照着一个不知道从哪来的数字做决策。
1. 引用溯源:每句话都能点回原文
最有效的可信度手段是让模型在回答时明确标注每个论断来自哪个来源,用户点一下就能跳回原文核对。实现方式是给每个检索到的片段编号,在 Prompt 里要求模型用 `[编号]` 的形式标注引用,最后把编号映射回真实文档。
def answer_with_citations(question, retrieved_chunks, llm):
"""让模型在回答里标注引用编号,并把编号映射回来源元数据"""
# 给每个片段编号,同时保留其来源信息
numbered = []
source_map = {}
for i, chunk in enumerate(retrieved_chunks, start=1):
numbered.append(f"[{i}] {chunk['text']}")
source_map[i] = {
"doc_title": chunk["metadata"]["doc_title"],
"url": chunk["metadata"].get("url"),
"page": chunk["metadata"].get("page"),
}
context = "\n\n".join(numbered)
prompt = f"""请仅根据下面带编号的资料回答问题。
在每个论断后面用方括号标注它依据的资料编号,如 [1] 或 [2][3]。
如果资料中没有相关信息,请明确说不知道,不要编造。
资料:
{context}
问题:{question}
回答(记得标注引用编号):"""
answer = llm.generate(prompt)
return {"answer": answer, "sources": source_map}引用溯源不只是"显得专业",它还是一道防幻觉的闸门:当模型被强制要求"每句话都得挂一个来源编号"时,它编造无中生有内容的倾向会显著降低——因为它找不到编号可挂。上线时可以再加一道校验:解析回答里出现的所有引用编号,如果出现了 source_map 里不存在的编号,说明模型在"伪造引用",此时应触发告警或让它重答。
2. 语义缓存:把重复问题的钱省下来
生产环境里大量问题是高度重复或近似的("怎么退款""退款怎么弄""如何申请退货款"本质是同一个问题)。如果每次都完整跑一遍"检索 + 生成",既慢又费钱。语义缓存的思路是:把问题的向量和它对应的答案缓存起来,新问题进来先算向量,如果和缓存里某个问题的相似度超过阈值,就直接返回缓存答案,跳过整条流水线。
class SemanticCache:
def __init__(self, embed_fn, threshold=0.95):
self.embed_fn = embed_fn
self.threshold = threshold # 相似度阈值,越高越"严格",越不容易误命中
self.cache = [] # [(向量, 问题, 答案)]
def get(self, question):
q_vec = self.embed_fn(question)
best_sim, best_answer = 0.0, None
for vec, _, answer in self.cache:
sim = sum(a * b for a, b in zip(q_vec, vec)) # 归一化向量点积=余弦相似度
if sim > best_sim:
best_sim, best_answer = sim, answer
# 只有相似度足够高才算命中缓存
return best_answer if best_sim >= self.threshold else None
def set(self, question, answer):
self.cache.append((self.embed_fn(question), question, answer))
def cached_rag(question, cache, rag_pipeline):
hit = cache.get(question)
if hit is not None:
return hit # 缓存命中,直接返回,省下检索 + 生成的全部开销
answer = rag_pipeline(question)
cache.set(question, answer)
return answer语义缓存的关键是阈值调校:调太低会误把不同问题当成同一个(返回错答案),调太高则命中率低、省不了多少钱。另外要注意缓存失效——知识库更新后,旧答案可能已经过时,所以缓存通常要设过期时间,或在知识库变更时清空相关缓存。对时效性极强的场景(如库存、价格),语义缓存要慎用。
3. 多模态 RAG:让知识库里的图表也能被检索
真实文档里大量关键信息藏在图片、图表、扫描件、PPT 截图里——一张架构图、一个财报柱状图、一张产品实拍图,纯文本 RAG 完全看不到。多模态 RAG 让这些视觉内容也进入检索范围,主流有两条技术路线:
def index_multimodal_document(page, vision_llm, embedding_model, vector_store):
"""图转文路线:为页面中的每张图生成描述后一并入库"""
# 文本部分照常切块入库
for chunk in split_into_chunks(page.text, chunk_size=500, overlap=50):
vec = embedding_model.encode([chunk])[0]
vector_store.add(chunk, vec, metadata={"type": "text", "page": page.number})
# 图片部分:用多模态模型生成详细描述,把描述当文本索引
for img in page.images:
caption = vision_llm.describe(
img,
prompt="详细描述这张图/图表的内容,包括其中的数据、趋势、标签和关键结论。"
)
vec = embedding_model.encode([caption])[0]
vector_store.add(
caption, vec,
metadata={"type": "image", "page": page.number, "image_ref": img.id}
)
def multimodal_answer(question, vector_store, vision_llm, top_k=5):
"""检索命中图片时,把原图而非仅描述交给多模态模型作答"""
hits = vector_store.search(question, top_k=top_k)
text_context, image_refs = [], []
for hit in hits:
if hit["metadata"]["type"] == "image":
image_refs.append(hit["metadata"]["image_ref"])
text_context.append(hit["text"])
return vision_llm.generate(
prompt=f"资料:\n{text_context}\n\n问题:{question}",
images=[load_image(ref) for ref in image_refs],
)RAG 进阶⑤:从"能演示"到"能上线" 🚀
RAG 在 Demo 里跑通和在生产环境里稳定服务几万用户,是两个难度量级。这一节聊聊把 RAG 推上线要额外操心的工程事项。
1. 上线后要监控什么
RAG 系统上线不是终点,而是持续优化的起点。监控要同时覆盖"系统健康"和"回答质量"两个层面:
| 监控维度 | 具体指标 | 说明 |
|---|---|---|
| 延迟 | 检索延迟、生成延迟、端到端 P95/P99 | 分环节埋点,才能定位慢在哪一步 |
| 成本 | 每次问答的 embedding/LLM Token 消耗 | 结合缓存命中率评估省了多少 |
| 检索质量 | 平均重排分、"零命中"比例 | 重排分普遍偏低说明知识库有盲区 |
| 回答质量 | 用户点赞/点踩、"未找到资料"占比 | 点踩样本是最宝贵的优化线索 |
| 幻觉信号 | 引用校验失败率、忠实度抽样分 | 定期抽样跑 LLM 裁判评估 |
| 反馈闭环 | 用户追问率、人工转接率(客服场景) | 追问多说明首答质量不够 |
import time
def instrumented_rag(question, retriever, reranker, llm, logger):
"""带全链路埋点的 RAG,用于生产监控"""
trace = {"question": question, "ts": time.time()}
t0 = time.time()
candidates = retriever.search(question, top_k=50)
trace["retrieval_ms"] = (time.time() - t0) * 1000
trace["candidate_count"] = len(candidates)
t1 = time.time()
reranked = reranker(question, candidates, final_k=5)
trace["rerank_ms"] = (time.time() - t1) * 1000
trace["top_rerank_score"] = reranked[0][1] if reranked else 0.0
# 重排最高分过低,标记为疑似"知识库无相关内容"
trace["low_confidence"] = trace["top_rerank_score"] < 0.3
t2 = time.time()
context = [text for text, _ in reranked]
answer = llm.generate(f"资料:\n{context}\n\n问题:{question}\n回答:")
trace["generation_ms"] = (time.time() - t2) * 1000
trace["total_ms"] = (time.time() - trace["ts"] * 1000)
logger.emit(trace) # 打到监控系统(Prometheus / 日志平台 / 数据仓库)
return answer, trace2. 知识库怎么持续更新
静态建库一次就完事的 RAG 只存在于教程里。真实知识库天天在变——新文档入库、旧文档修订、过期文档下线。要处理好增量更新,几个要点:
import hashlib
def incremental_update(new_docs, vector_store, embedding_model, doc_hashes):
"""基于内容哈希的增量更新:只处理真正变化的文档"""
for doc in new_docs:
content_hash = hashlib.sha256(doc.text.encode("utf-8")).hexdigest()
if doc_hashes.get(doc.id) == content_hash:
continue # 内容没变,跳过
# 内容变了:先删掉该文档的旧 chunk,再重新切块入库
vector_store.delete(filter={"doc_id": doc.id})
chunks = recursive_split(doc.text, chunk_size=500, overlap=50)
vectors = embedding_model.encode(chunks)
vector_store.add(
chunks, vectors,
metadata=[{"doc_id": doc.id, "updated_at": doc.updated_at} for _ in chunks]
)
doc_hashes[doc.id] = content_hash3. 安全与合规不能事后补
RAG 直接把企业内部数据接进了对外的问答接口,安全边界必须前置设计:
真实案例集:RAG 在各行业怎么落地 🏢
理论说再多,不如看几个真实场景里 RAG 是怎么被"组装"起来的。以下案例综合了业界常见落地模式。
案例一:企业内部知识库问答
某中型科技公司有几万篇散落在 Confluence、Notion、Google Drive 里的内部文档,新员工找信息全靠问同事。他们搭了一套 RAG 助手:
效果:新员工"找信息"平均耗时从十几分钟降到一两分钟,重复咨询同事的问题显著减少。踩过的坑:早期没做权限过滤,一度检索到了 HR 的薪酬草稿,紧急下线补上过滤才重新上线。
案例二:客服智能问答
某电商把客服 RAG 从第 4 节那个"翻车版"迭代成了生产版:
效果:机器人首次解决率显著提升,人工坐席压力下降。关键经验是"知道自己不知道"——低置信度果断转人工,比强答一个错答案对客户满意度友好得多。
案例三:法律与财报文档检索
律所和金融机构对 RAG 的诉求是"绝对准确 + 可溯源",容错率极低:
关键经验:这类场景宁可让模型多说"资料中未提及",也绝不能让它"合理推测"——一次编造的数字可能导致重大决策失误。
案例四:代码库问答
面向大型代码库的"问代码"助手("这个鉴权中间件在哪定义的""下单流程调用了哪些服务"):
各行业落地要点速览:
| 场景 | 最吃紧的能力 | 关键技术组合 |
|---|---|---|
| 企业知识库 | 覆盖全、权限隔离 | 混合检索 + 元数据权限过滤 + 溯源 |
| 客服问答 | 精准 + 兜底 | 查询路由 + 重排阈值 + 语义缓存 |
| 法律/财报 | 绝对准确、可溯源 | 上下文化检索 + 父文档 + 强制引用 + 多模态 |
| 代码库问答 | 精确标识符 + 关系 | 语法切分 + BM25 + 调用图 + Agentic |
常见坑与最佳实践 ⚠️
高频踩坑清单
| 坑 | 症状 | 根因 | 解法 |
|---|---|---|---|
| 建库检索用不同 embedding 模型 | 相似度全是噪声、召回全错 | 两个向量空间不兼容 | 建库/检索严格用同一模型同一版本 |
| chunk 一刀切太大或太小 | 太小答不全、太大失焦 | 未按内容/评估集调 chunk_size | 用真实评估集扫参,按内容类型分别设 |
| 只用纯向量检索 | 专有名词、编号查不到 | 向量不擅长精确匹配 | 上混合检索(向量 + BM25) |
| 不做重排就直接喂 Top-K | 相关片段没排在前面被截掉 | 双塔召回排序粗糙 | 加 Cross-Encoder 重排 |
| 检索到不相关内容也硬答 | 一本正经地胡说八道 | 无置信度兜底 | 用重排分阈值,低置信度就说"没找到" |
| 无引用、无法溯源 | 用户不敢信、出错没法查 | 未做引用标注 | 强制标注来源编号 + 引用校验 |
| 知识库更新后答案还是旧的 | 答过时信息 | 无增量更新/缓存失效机制 | 增量索引 + 缓存过期 |
| 忽略权限,人人可查全部 | 越权看到敏感文档 | 未在检索层做权限过滤 | 元数据权限硬过滤 |
| 只看最终答案不分层评估 | 优化像无头苍蝇 | 检索/生成混在一起看 | 分层评估:检索层 + 生成层各自度量 |
| 对所有问题无脑套查询改写 | 简单问题被改糊、延迟高 | 未做查询路由 | 先分类路由,按需改写 |
最佳实践清单
总结 📌
RAG 的本质是"给 AI 外挂一个可实时更新、可精确检索的知识库",让模型从"闭卷硬背"变成"开卷现查"。但从"能跑"到"好用"的距离,全在细节里:切分决定原材料质量,检索决定能不能找对,重排决定排序精不精,评估决定优化有没有方向,而引用、缓存、监控、安全则决定它能不能真正上线扛住生产流量。
一张表收束全文,把各能力层、要解决的核心问题和主力技术串起来:
| 能力层 | 要解决的核心问题 | 主力技术 | 何时该上 |
|---|---|---|---|
| 切分 Chunking | 原材料语义是否完整 | 递归切分 / 语义切分 / 父文档 / 上下文化检索 | 建库第一步,必做 |
| 向量化 Embedding | 语义能否被准确表示 | 选对多语言/领域模型、维度与长度匹配 | 建库地基,必做 |
| 检索 Retrieval | 能不能找对、找全 | 混合检索(向量+BM25)+ RRF 融合 | 召回不全时,强烈推荐 |
| 查询处理 | 提问是否检索友好 | 查询路由 / 改写 / 分解 / HyDE / Multi-Query | 口语化/复合问题多时 |
| 重排 Reranking | 相关片段排得够不够前 | Cross-Encoder 精排 + 置信度阈值 | 对精度要求高时,强烈推荐 |
| 多步推理 | 单次检索凑不齐信息 | Agentic RAG / 多跳 / GraphRAG | 复杂/关系型/全局型问题 |
| 可信与复用 | 答案能否被信任、溯源、复用 | 引用溯源 / 语义缓存 / 多模态 | 生产环境必做 |
| 评估 Evaluation | 优化有没有方向 | 分层指标 + LLM 裁判(RAGAS 风格) | 贯穿始终,越早越好 |
| 生产化 | 能不能稳定上线 | 监控 / 增量更新 / 权限与安全 | 上线前必做 |
一句话记住 RAG 调优的心法:分层度量,定位最短的板,从简单基线出发按需加码,让系统学会说"我不知道"。 剩下的,都是在这条主线上打磨细节。
---
附录 A:一条端到端的最小可用 RAG 流水线
下面把"切分 → 向量化 → 混合检索 → 重排 → 生成 → 引用"串成一段可读的伪代码,帮你把前文各环节落到一个整体里。
from rank_bm25 import BM25Okapi
def build_index(docs):
# 1) 递归切分:512 token 分块,重叠 64
chunks = []
for d in docs:
chunks += recursive_split(d, size=512, overlap=64)
# 2) 向量化 + BM25 双索引
vectors = embed([c.text for c in chunks])
vector_store.add(chunks, vectors)
bm25 = BM25Okapi([tokenize(c.text) for c in chunks])
return chunks, bm25
def retrieve(query, chunks, bm25, k=20):
# 3) 混合检索:向量 topK + BM25 topK
dense = vector_store.search(embed([query])[0], k=k)
sparse_scores = bm25.get_scores(tokenize(query))
sparse = top_k_by_score(chunks, sparse_scores, k=k)
# 4) RRF 融合
fused = reciprocal_rank_fusion([dense, sparse], k=60)
return fused[:k]
def answer(query, chunks, bm25):
candidates = retrieve(query, chunks, bm25)
# 5) Cross-Encoder 重排,取前 5
reranked = cross_encoder_rerank(query, candidates)[:5]
# 6) 置信度阈值:都太低就坦白不知道
if reranked[0].score < 0.3:
return "抱歉,我在现有资料里没找到相关信息。"
# 7) 带引用生成
context = "\n\n".join(
f"[{i+1}] {c.text}" for i, c in enumerate(reranked)
)
prompt = (
"只依据下面资料回答,并在句末用 [编号] 标注来源;"
"资料没提到就说不知道。\n\n" + context + "\n\n问题:" + query
)
return call_llm(prompt)附录 B:RAG 上线前自查清单
| 维度 | 检查项 | 达标标准 |
| --- | --- | --- |
| 检索质量 | 评估集 Recall@10 | ≥ 0.85 |
| 生成质量 | 答案忠实度(无幻觉) | ≥ 0.90 |
| 溯源 | 每条结论可回溯到片段 | 100% 覆盖 |
| 延迟 | P95 端到端响应 | ≤ 3s |
| 成本 | 单次问答 token 成本 | 在预算内 |
| 兜底 | 零命中/低置信度会坦白 | 有明确话术 |
| 安全 | 权限过滤 + PII 脱敏 + 注入防护 | 全部就位 |
| 可观测 | 延迟/成本/零命中率/点踩埋点 | 全部上报 |
附录 C:常见问答
Q:切多大合适?
A:没有万能值。事实问答类偏小(256-512 token)利于精确定位;需要上下文连贯的偏大(512-1024)。务必带重叠(10%-20%),避免句子被拦腰切断。最终以评估集 Recall 为准,别凭感觉。
Q:一定要上重排吗?
A:召回不准、topK 里"对的片段排在后面"时收益最大。Cross-Encoder 精排通常能把 nDCG 提升 10%-20%,代价是每条候选多一次前向。候选控制在 20-50 条内延迟可接受。
Q:GraphRAG 什么时候值得上?
A:问题需要跨多个实体做关系推理、或需要"全局摘要"式回答(如"这批文档整体讲了什么趋势")时。它建库成本高(要抽实体关系、建社区摘要),普通问答别过度设计。
Q:如何压低幻觉?
A:三板斧——强约束提示(只依据资料)、置信度阈值兜底(找不到就说不知道)、强制引用溯源(让答案每句都能对回原文)。三者叠加基本能把编造压到很低。
附录 D:RAG 评估最小指标脚本
评估要分层:先量检索(找没找对),再量生成(答得对不对、有没有编)。下面用一段伪代码把两层指标算出来。
def recall_at_k(retrieved_ids, gold_ids, k=10):
hit = len(set(retrieved_ids[:k]) & set(gold_ids))
return hit / max(len(gold_ids), 1)
def faithfulness(answer, context, judge_llm):
# 用 LLM 裁判:答案里每句是否都能从 context 找到支撑
prompt = (
"判断答案是否完全由上下文支撑,只回 0-1 的小数。\n"
f"上下文:{context}\n答案:{answer}"
)
return float(judge_llm(prompt))
# 汇总一个评估集
scores = {"recall": [], "faith": []}
for case in eval_set:
r = retrieve(case["q"])
a = answer(case["q"])
scores["recall"].append(recall_at_k([d.id for d in r], case["gold"]))
scores["faith"].append(faithfulness(a, r, judge_llm))
print("Recall@10:", sum(scores["recall"]) / len(scores["recall"]))
print("Faithfulness:", sum(scores["faith"]) / len(scores["faith"]))把这段接进 CI,每次改切分/检索/提示都自动跑一遍,指标回退超阈值就阻断合并——RAG 的迭代才算走上工程正轨。