2024 年我做一个公司内部知识库问答 AI:把几百篇产品文档、规章制度喂进去,想让员工随便问、AI 照着文档答。Demo 那天我请同事问了几个我提前准备好的问题,答得头头是道,领导很满意,当场拍板推广。推广开之后,反馈陆续来了:有人问"差旅住宿一晚最多报多少",AI 信誓旦旦给了个数,结果是去年的旧标准;有人问一个文档里明明白白写着的流程,AI 却说"未找到相关信息";最离谱的一次,AI 把两份不同产品的参数掺在一起,编出一个根本不存在的配置,还答得有鼻子有眼。我一开始以为是模型不够强,换了个更强的模型,问题照旧。盯着请求日志看了很久才反应过来:RAG 的回答质量,根本不取决于"生成"那一步的模型有多强,而取决于"检索"那一步到底有没有捞对文档。模型只能基于你塞给它的上下文作答——你检索时捞来的是错的、旧的、不相关的资料,再强的模型也只会"基于错误的材料,一本正经地编出一个错误的答案"。我原来的认知是"RAG 就是把文档丢给 AI,它自己会查、会答",而真相恰恰相反:RAG 系统里真正难、真正决定成败的,是检索这一环;生成,不过是把检索结果复述出来。那次之后我才认真把 RAG 从头搞明白。这篇文章就把它梳理一遍:RAG 到底解决什么问题、完整流程是怎样的、文档怎么切块、检索为什么会捞错、重排在干什么,以及把知识库问答真正做准要避开的那些坑。
问题背景
先把那次事故的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:用 RAG 做内部知识库问答,Demo 一切正常;推广后频繁出现答旧数据、把文档里有的内容答成"未找到"、混淆不同文档、甚至编造出文档里根本不存在的内容。换更强的模型也不见好。
我当时的错误认知:"RAG 就是把文档喂给 AI,模型自己会从里面查、会答;答不好,就是模型不够强,换个强的就行。"
真相:RAG = 检索(Retrieval)+ 生成(Generation),模型只能基于检索到的上下文作答。一次回答的质量上限,是由检索质量决定的——生成这一步无法"修复"一次糟糕的检索。检索捞错了,模型要么基于错误材料编答案,要么(更糟)绕开材料、用自己过时的训练知识硬答。换更强的模型,完全不解决检索这一环的问题。
要把 RAG 做对,需要几块认知:
- RAG 到底解决 LLM 的什么先天缺陷,它的完整流程分哪两个阶段;
- 文档怎么切块,切大、切小各自会出什么问题;
- 检索为什么"向量相似"不等于"内容相关";
- 重排(rerank)在干什么,为什么 top-k 召回之后还要再排一次;
- 检索不到答案时,怎么让模型"老实说不知道",而不是编。
一、RAG 是什么:给大模型一场"开卷考试"
要理解为什么需要 RAG,先得看清大模型有两个绕不开的先天缺陷。
第一,它的知识有截止日期。模型是在某个时间点之前的数据上训练出来的,这个时间点之后发生的事、更新的标准,它一概不知道。第二,它不知道你的私有数据。你公司的内部文档、规章制度、产品手册,从来没进过它的训练集——你直接问,它要么说不知道,要么就凭一个"大概像那么回事"的印象编一个,这就是幻觉。
要让模型能回答"训练集之外"的问题,有两条路。一条是微调:把新知识重新训练进模型的权重里——成本高,而且知识一更新就得重训,不现实。另一条就是 RAG(Retrieval-Augmented Generation,检索增强生成):不动模型,而是把知识放在一个外部知识库里;每次用户提问时,先从知识库里检索出相关的资料片段,连同问题一起拼成 prompt 交给模型,让它照着这份资料来回答。
这本质上是把模型的角色从"闭卷考试"变成了"开卷考试"——它不再靠记忆答题,而是做阅读理解,基于你递给它的材料作答。这正是 RAG 的强项:知识存在外部库里,随时能更新,答案还能溯源到具体文档。但这也是它的命门——你递错了材料,它就照着错材料答错。所以 RAG 做得好不好,关键全在"递什么材料"这件事上。
二、完整流程:索引阶段与查询阶段
一个 RAG 系统,干活分两个阶段,想清楚这两个阶段,后面所有的优化才有地方落。
索引阶段(离线,事先做一次):把知识库里的文档切块(切成一小段一小段),给每一块算出一个 embedding 向量,然后把"文本块 + 它的向量"一起存进向量库。embedding 是关键——它把一段文本映射成一个向量,语义相近的文本,算出来的向量在空间里也相近。
查询阶段(在线,每次提问都走一遍):把用户的问题也算成一个 embedding 向量,拿它去向量库里找向量最接近的若干个文本块(这就是"检索"),再把这些块拼进 prompt,交给大模型生成答案。
下面先把索引和检索这两个最核心的能力用代码实现出来。这里不依赖任何现成的向量数据库,用 numpy 手写一个最小可用的版本,把原理看清楚:
from sentence_transformers import SentenceTransformer
import numpy as np
# 加载 embedding 模型:把一段文本映射成一个向量,语义相近的文本向量也相近
embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
class VectorStore:
"""最小可用的向量库:保存"文本块 + 它的向量",支持按相似度检索。"""
def __init__(self):
self.chunks: list[str] = [] # 原始文本块
self.vectors = None # 每块对应的归一化向量
def add(self, chunks: list[str]) -> None:
# 入库:把每个文本块算成向量,和原文一起存下来
vecs = embedder.encode(chunks, normalize_embeddings=True)
self.chunks.extend(chunks)
self.vectors = (vecs if self.vectors is None
else np.vstack([self.vectors, vecs]))
def search(self, query: str, top_k: int = 4) -> list[tuple[str, float]]:
# 检索:把问题也算成向量,找库里和它最接近的 top_k 个块
q = embedder.encode(query, normalize_embeddings=True)
scores = self.vectors @ q # 向量已归一化,点积即余弦相似度
idx = np.argsort(scores)[::-1][:top_k]
return [(self.chunks[i], float(scores[i])) for i in idx]
有了这个向量库,一个最朴素的 RAG 流程就能跑起来了——索引阶段把文档入库,查询阶段检索出相关片段:
# 索引阶段(离线做一次):把文档入库
docs = [
"报销额度:2024 年起,差旅住宿每晚上限 500 元。",
"请假流程:事假需提前一天在 OA 提交,由直属主管审批。",
"报销流程:发票须在消费后 30 天内提交至财务部。",
]
store = VectorStore()
store.add(docs)
# 查询阶段(在线,每次提问都走一遍):检索出相关片段
hits = store.search("住宿一晚最多能报多少钱", top_k=2)
for text, score in hits:
print(f"[相似度 {score:.3f}] {text}")
# 检索到的片段,接下来会和用户问题一起拼成 prompt 交给大模型作答。
# 注意:用户问的是"住宿报多少",文档里写的是"差旅住宿每晚上限" ——
# 用词并不完全一样,但 embedding 能匹配上,这正是向量检索的价值。
三、切块:RAG 里最被低估的一步
很多人做 RAG,把精力都花在选模型、调 prompt 上,却草草地把文档"每 1000 字切一刀"了事。其实切块(chunking)是直接决定检索质量的一步,它被严重低估了。
为什么切块这么关键?因为检索的最小单位就是"块"——检索捞回来的、最终塞进 prompt 的,是一个个块。块切得好不好,直接决定了"捞回来的东西到底相不相关、完不完整"。
切太大有问题:一个大块里往往混着好几个主题,其中只有一句和问题相关,其余全是噪声。这个块的向量是整段文本的"平均语义",会被无关内容稀释,检索精度下降;就算检索到了,塞进 prompt 的也是一大坨,真正有用的那句被埋在里面。切太小也有问题:一个句子被单独切成一块,它脱离了上下文——"上限是 500 元"这句话,脱离了"差旅住宿"这个上文,就成了一句没头没脑、检索也对不上、模型也用不了的话。
所以切块要在"块够小、噪声少"和"块够完整、有上下文"之间找平衡。最基础的做法是固定长度切块,但一定要带上重叠窗口(overlap)——让相邻两块有一小段重叠,避免一句完整的话正好被切点拦腰斩断:
def chunk_text(text: str, size: int = 300, overlap: int = 50) -> list[str]:
"""按固定长度切块,相邻块之间保留 overlap 个字的重叠。
重叠是为了防止一句话被切点拦腰斩断,导致两边都丢失语义。"""
chunks = []
start = 0
while start < len(text):
end = start + size
chunks.append(text[start:end])
if end >= len(text):
break
start = end - overlap # 回退 overlap 个字,和上一块重叠
return chunks
固定长度切块简单,但它有个硬伤:它完全不看内容,可能正好在一个段落的中间、一句话的中间下刀。更好的做法是按文档结构切块——优先在段落、标题这些自然的语义边界处断开,让每一块自身的语义尽量完整:
import re
def chunk_by_structure(text: str, max_size: int = 500) -> list[str]:
"""按文档结构切块:优先在段落等自然边界处断开,
而不是机械地数字符 —— 这样每一块的语义更完整。"""
# 先按空行把文本切成段落
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, buf = [], ""
for para in paragraphs:
# 当前缓冲再加这一段会超长,就先把缓冲落成一块
if buf and len(buf) + len(para) > max_size:
chunks.append(buf)
buf = para
else:
buf = f"{buf}\n{para}" if buf else para
if buf:
chunks.append(buf)
return chunks
实践中,按结构切块是首选;固定长度 + 重叠则适合那些没有清晰结构的纯文本。无论用哪种,切完都建议抽查几块看看——一块里是不是一个完整的意思,是判断切块好坏最直接的标准。
四、检索:向量"相似"不等于内容"相关"
块切好、入库了,接下来是查询阶段的核心:检索。开头那场事故里"答旧数据""未找到""混淆文档",根子大多都在这一步。这里要破除一个想当然的认知:向量检索返回的"相似",不等于你要的"相关"。
第一个要调的是 top-k——检索返回几个候选块。它是个两难:k 太小,真正相关的块可能没被召回(漏召回);k 太大,会把一堆只是"沾点边"的不相关块也塞进上下文,变成干扰模型的噪声。k 不是越大越好。
第二个、也是更要命的一点:库里压根没有答案的问题,检索依然会返回 k 个结果。向量检索的逻辑是"找最接近的",哪怕全库都和问题无关,它也会把"无关里相对没那么无关"的那几个返回给你——而且分数很低。如果你不管分数照单全收,这些低分噪声进了 prompt,模型就被带着基于无关材料硬答。所以必须设一个相似度阈值:低于阈值的结果直接丢弃,丢完要是一个不剩,就说明"这个问题库里没有答案"——这正是后面"拒答"的依据。
def search_with_threshold(store: VectorStore, query: str,
top_k: int = 8, min_score: float = 0.35
) -> list[tuple[str, float]]:
"""带阈值的检索:top_k 只是"取几个候选",
真正决定能不能用的是相似度阈值 —— 分数太低的宁可不要。"""
hits = store.search(query, top_k=top_k)
# 过滤掉相似度低于阈值的:它们大概率和问题无关
good = [(text, score) for text, score in hits if score >= min_score]
return good # 若返回空列表,说明库里没有能回答这个问题的资料
第三个坑是向量检索的盲区:它擅长"语义相近",但对专有名词、型号、编号这种"必须精确匹配"的东西不敏感——问"X100 的功率",它可能把"X200 的功率"也算得很相似。对策是再加一路关键词检索,和向量检索做混合:向量负责"意思相近",关键词负责"专名精确命中",两路结果取并集,互补:
def hybrid_search(store: VectorStore, query: str, top_k: int = 6
) -> list[str]:
"""混合检索:向量语义检索 + 关键词命中,两路结果取并集。
向量擅长"语义相近",关键词擅长"专有名词精确命中",互补。"""
# 第一路:向量语义检索
vec_hits = [text for text, _ in store.search(query, top_k=top_k)]
# 第二路:关键词检索 —— 块里包含问题中的词就算命中
keywords = [w for w in query.split() if len(w) >= 2]
kw_hits = [c for c in store.chunks
if any(kw in c for kw in keywords)]
# 合并去重,保持先后顺序
seen, merged = set(), []
for text in vec_hits + kw_hits:
if text not in seen:
seen.add(text)
merged.append(text)
return merged[:top_k]
五、重排与上下文构建:先粗筛,再精排
检索召回了一批候选,但它们够准吗?这里要理解 embedding 检索的一个固有局限:它是双塔模型——问题和文档是各自独立编码成向量的,模型在编码文档时根本不知道用户会问什么。这种方式速度快(文档向量能预先算好),但精度有限,它衡量的是"泛泛的语义接近",未必是"精确地回答了这个问题"。
所以业界的标准做法是两阶段检索:第一阶段用 embedding 检索,快,负责从海量文档里召回一批候选(比如 top 8 到 20);第二阶段用 cross-encoder 重排模型(reranker),把"问题 + 某个候选文档"拼在一起送进模型打一个相关性分数——它能看到问题和文档的交互,判断精准得多,但慢,所以只用它给少量候选做精排。一句话:embedding 负责"粗筛召回",reranker 负责"精排选优"。
from sentence_transformers import CrossEncoder
# 重排模型:输入(问题, 文档)一对,直接输出一个相关性分数。
# 它把问题和文档拼一起编码,比双塔 embedding 准,但更慢 ——
# 所以只拿它给"少量候选"做精排,不拿它做全库检索。
reranker = CrossEncoder("BAAI/bge-reranker-base")
def rerank(query: str, candidates: list[str], top_n: int = 3
) -> list[str]:
"""重排:对召回的候选逐一精确打分,取最相关的 top_n。"""
if not candidates:
return []
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs)
# 按精排分数从高到低排序
ranked = sorted(zip(candidates, scores),
key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:top_n]]
精排出最相关的几段后,最后一步是把它们拼成上下文。这里有两个细节:其一,给每段编号,这样可以要求模型在回答里引用"来源[1]",答案就变得可溯源、可核对;其二,呼应"lost in the middle"——模型对 prompt 首尾的信息最敏感,所以把"问题本身"放在末尾再强调一次,别让它淹没在中间:
def build_context(query: str, docs: list[str]) -> str:
"""把检索到的片段拼成上下文。给每段编号,是为了让模型
在回答里能引用"来源[1]",从而让答案可溯源、可核对。"""
blocks = []
for i, doc in enumerate(docs, 1):
blocks.append(f"[{i}] {doc}")
context = "\n\n".join(blocks)
# 把问题放在末尾再说一次:模型对 prompt 首尾最敏感,别让它埋在中间
return (f"已知资料:\n{context}\n\n"
f"请仅依据上面的资料回答。问题:{query}")
六、工程坑:拒答、溯源、模型一致、评估
把 RAG 真正放进生产,还有四个绕不开的工程坑。每一个都直接影响答案能不能信。
坑 1:检索不到时,必须让模型拒答。这是 RAG 最危险的地方。最坏的情况不是"答错",而是"检索为空时,模型绕开资料、用自己过时的训练知识一本正经地编"——开头那个"答了去年旧标准"就是这么来的。对策是双保险:一是在 system prompt 里硬性约束"只能依据资料、资料里没有就明确说不知道";二是在代码里,检索结果为空时直接返回拒答,根本不进生成环节。
坑 2:答案要可溯源。给检索片段编号、要求模型在回答里标注引用了哪条,用户就能点回原文核对。一个不能溯源的 RAG 答案,用户没法判断它是真有依据还是编的,可信度大打折扣。
坑 3:embedding 模型,索引和查询必须用同一个。检索的本质是比较"文档向量"和"问题向量",这两个向量必须出自同一个模型、同一套向量空间,比较才有意义。换了 embedding 模型,整个向量库必须全量重建。还有个细节:有些模型(如 bge 系列)要求查询文本加一个特定前缀指令,文档则不加——这个不一致会悄悄拉低检索效果。
坑 4:RAG 必须评估,而且要分开评估检索和生成。回答不好时,要先定位是"检索没捞到"还是"捞到了但生成没答好"。最该先看的是检索命中率——准备一批测试问题,每条标注答案应来自哪个文档,看检索结果里有没有包含它。命中率低,说明问题出在检索,这时反复改 prompt 是徒劳的,prompt 再好也救不回没被检索到的内容。
先把"坑 1"落地——一个带拒答约束的完整 RAG 问答流程,串起前面所有环节:
SYSTEM_PROMPT = (
"你是知识库问答助手。只能依据用户给出的「已知资料」回答,"
"禁止使用资料之外的任何知识。若资料中没有相关信息,"
"必须直接回答「根据现有资料无法回答此问题」,不要猜测、不要编造。"
)
def answer(client, store: VectorStore, query: str) -> str:
"""完整 RAG 流程:检索 -> 阈值过滤 -> 重排 -> 拼接 -> 生成。"""
# 1) 检索 + 阈值过滤:把明显不相关的候选先挡掉
hits = search_with_threshold(store, query, top_k=8, min_score=0.35)
# 2) 检索为空 —— 直接拒答,绝不进入生成环节让模型自由发挥
if not hits:
return "根据现有资料无法回答此问题。"
# 3) 重排,从候选里精选出最相关的几段
top_docs = rerank(query, [text for text, _ in hits], top_n=3)
# 4) 拼成带编号的上下文,交给模型作答
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_context(query, top_docs)},
],
)
return resp.choices[0].message.content
再把"坑 4"落地——一个评估检索命中率的函数,它是 RAG 调优的指南针:
def evaluate_retrieval(store: VectorStore,
testset: list[dict]) -> float:
"""评估检索命中率:每条测试问题都标注了答案应来自哪个文档,
检查检索结果里有没有包含它。命中率上不去,RAG 一定答不好。"""
hit = 0
for case in testset:
results = store.search(case["question"], top_k=4)
retrieved = [text for text, _ in results]
# 标准答案所在的文档,有没有出现在检索结果里
if any(case["expect"] in text for text in retrieved):
hit += 1
rate = hit / len(testset)
print(f"检索命中率:{hit}/{len(testset)} = {rate:.1%}")
return rate
# RAG 调优要"先治检索":命中率低时,先去调切块、调检索策略,
# 而不是反复改 prompt —— prompt 再好,也救不回没被检索到的内容。
下面这张图把 RAG 从文档索引到生成答案的完整链路串起来,注意"检索为空就拒答"那个分叉:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| RAG | 检索增强生成;不改模型,提问时检索相关资料拼进 prompt 让模型开卷作答 |
| 索引阶段 | 离线:文档切块、算 embedding、连同原文存入向量库 |
| 查询阶段 | 在线:问题算 embedding、检索、拼上下文、生成答案 |
| embedding | 把文本映射成向量,语义相近的文本向量也相近 |
| 切块 chunking | 把文档切成小块,切大塞噪声、切小丢上下文,直接决定检索质量 |
| 重叠窗口 | 相邻块保留一段重叠,防一句话被切点拦腰斩断 |
| top-k | 检索返回的候选数,太小漏召回、太大引入噪声 |
| 相似度阈值 | 低于阈值的候选视为不相关,全被丢弃则判定为"检索不到" |
| rerank 重排 | 用 cross-encoder 给召回的候选精确打分,先粗筛召回再精排 |
| 拒答 | 检索为空时让模型明确说"无法回答",而非用旧知识硬编 |
避坑清单
- RAG 回答质量的上限由检索决定,生成无法修复一次糟糕的检索;答不好先查检索,别先改 prompt。
- 切块切太大,一块里混入太多无关内容,检索精度被稀释;切太小,一句话脱离上下文,检索和使用都对不上。
- 固定长度切块必须带重叠窗口,否则切点会把完整语义拦腰截断,相邻两块都不完整。
- 优先按文档结构(段落、标题)切块,在自然语义边界处断开,每块自身的意思才完整。
- 向量"相似"不等于内容"相关";纯向量检索对专有名词、型号、编号不敏感,要叠加关键词检索做混合。
- top-k 不是越大越好,过大会把只沾边的不相关块也塞进上下文,反而干扰模型。
- 必须设相似度阈值;库里没有答案时检索仍会返回低分结果,阈值是判定"检索不到"和拒答的依据。
- 用两阶段检索:embedding 快速召回较多候选,再用 cross-encoder 重排精选,先粗筛再精排。
- 检索为空时要在代码里直接拒答,别进生成环节,否则模型会绕开资料、用过时知识一本正经地编。
- 索引和查询必须用同一个 embedding 模型,换模型要全量重建索引;RAG 要分开评估检索命中率与生成质量。
总结
回头看那次"AI 答得头头是道却全是错的"的事故,最该记住的不是某个切块函数或某个阈值,而是我上线前那个想当然的假设——"把文档喂给 AI,它自己会查、会答"。这句话把一个本该由我设计的检索系统,当成了模型自带的能力。而真相是:模型不会"查",它只会"读"——读你递给它的那几段材料。RAG 这个词里,Generation(生成)是模型的事,但 Retrieval(检索)从头到尾是你的事。你递对了材料,再普通的模型也能答得又准又有据;你递错了,再强的模型也只是把错误答得更流畅、更像真的。
所以做 RAG,真正的工程重心是回答一连串关于"检索"的问题:文档该怎么切,才能让每一块既不臃肿又不残缺?问题和文档怎么匹配,才能既抓住语义又不漏掉专名?召回的一堆候选怎么精排,才能把最相关的顶上来?库里根本没答案时,怎么让系统老实承认而不是编?这篇文章其实就是顺着这条链展开的:先认清 RAG 是给模型的一场开卷考试、流程分索引和查询两阶段;再深入最被低估的切块;然后是检索——相似不等于相关、要设阈值、要混合关键词;接着是两阶段的重排;最后是拒答、溯源、模型一致、评估这几个工程坑。
你会发现,RAG 和传统的搜索引擎工程,骨子里是同一件事。一个搜索引擎好不好用,从来不取决于结果页那个排版多漂亮,而取决于召回和排序准不准。RAG 只是把"结果页"换成了"大模型生成的一段话"——它把检索质量包装得更不容易被一眼看穿了,因为模型总能把任何材料都说得通顺。但通顺不等于正确。一个 RAG 系统真正的内功,依然是那套古老的检索功夫:怎么切、怎么召回、怎么排序、怎么知道自己没召回到。模型让"生成"这一步变得几乎免费,于是你省下来的精力,理应全部投到"检索"上。
最后想说,RAG 做没做好,差距同样不会在 Demo 阶段暴露——Demo 时你问的都是自己挑过、知道库里有答案的问题,怎么切都答得漂亮。它只在真实用户、问题五花八门、其中很多库里根本没有答案的时候才显形。那时候它一次性给你三类账单:答旧数据(检索没捞到最新的那块)、答非所问(召回了不相关的噪声)、以及最伤信任的一种——库里没有却硬编。所以别等用户拿着一个离谱答案来质问你,在你写下第一行切块代码的时候就该想清楚:我的检索,捞得准吗?捞不到的时候,我的系统会诚实地闭嘴,还是会自信地撒谎?想清楚了,你的 RAG 才不只是 Demo 里那几个漂亮的问答,而是一个用户敢拿它的答案去做决定的系统。
—— 别看了 · 2026