2024 年我给公司做一个内部知识助手——让员工能用自然语言问规章制度、产品文档、报销流程,AI 直接给答案。第一版做得很直接:把员工的问题原样发给大模型,等它回答。Demo 那天它答得特别流畅,产品经理很满意。可推广到部门里真用起来,反馈很快就变味了:有人问"我们公司年假怎么算",AI 给了一套听起来非常正规、却和我们 HR 手册完全对不上的说法;有人问报销流程,它讲的是一个我们公司早就废止的旧版本;最离谱的一次,它把一个我们公司根本不存在的"弹性工时制度"讲得有条有理,连申请步骤都编得像模像样。我一开始以为是模型不够聪明,想换个更大更贵的模型试试。换了,没用,它照样编。盯着这些回答我才终于想明白:模型不是"不够聪明",它是"没有资料"。大模型的知识,停在它训练数据截止的那一刻,而且只覆盖公开的互联网内容——我们公司的 HR 手册、内部流程文档,它从来没读过,一个字都没有。你问它公司的事,它不会说"我不知道",它会用最像那么回事的语气给你编一个出来——这就是幻觉。要让 AI 准确回答"你自己的"问题,光靠模型本身是不可能的,你必须想办法把相关的资料喂给它。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)要做的事:回答之前,先从你的知识库里检索出相关资料,再把资料连同问题一起塞进 prompt,让模型基于这些资料来回答。我以为 RAG 不过是"先搜一下、再拿去问",结果真做下来,坑一个接一个冒出来:把整篇文档塞进去做检索,效果差到不可用;文档切块,切大了噪声多、切小了语义断;检索回来的明明是一堆不相关的段落;就算把对的资料喂给它了,它有时还是无视资料、继续编……那次之后我才认真把 RAG 从头搞明白。这篇文章就把它梳理一遍:为什么需要 RAG、它整体怎么转、文档怎么切、向量检索怎么做、资料怎么拼进 prompt,以及把 RAG 真正做准要避开的那些坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:内部知识助手直接用大模型回答公司问题,出现一连串幻觉——把根本不存在的制度讲得头头是道、引用早已废止的旧流程、答案与 HR 手册对不上。换用更大更贵的模型,问题依旧。
我当时的错误认知:"模型答错是因为不够聪明,换个更强的模型就好了;就算要喂资料,RAG 也无非就是把文档搜一下、再拿去问模型。"
真相:模型答错公司问题,根本不是智力问题,是它压根没有你的资料——它的知识停在训练截止那一刻,且只有公开内容。RAG 是把"私有的、最新的资料"补给模型的标准方法,但它真正的难点不在"搜一下"那一步,而在整条链路的每一环都会影响最终准度:文档怎么切成 chunk、怎么把"语义相似"变成可计算的"向量距离"、检索回来的结果怎么去噪重排、怎么约束模型老老实实只依据资料回答。任何一环偷懒,RAG 都会以"该检索的没检索到"或"检索到了模型还是乱答"的方式漏掉。
要把 RAG 做准,需要几块认知:
- 为什么大模型答不了"你自己的"问题,RAG 到底补的是什么;
- RAG 的整体流程——索引、检索、生成三个阶段;
- 文档为什么不能整篇塞、也不能乱切,chunk 怎么切才对;
- 怎么把"语义相似"变成"向量距离相近",向量检索怎么做;
- 检索召回、重排、幻觉兜底这些工程坑怎么处理。
一、为什么需要 RAG:大模型的知识边界
先看清大模型不知道什么,才明白 RAG 是来补什么的。
大模型的知识来自训练。这意味着它有两条清晰的边界。第一,时间边界:它的知识停在训练数据截止的那一刻,那之后发生的事、更新的文档,它一概不知道。第二,范围边界:它训练用的是公开的互联网数据,你公司内网里的 HR 手册、产品 wiki、流程文档,从来不在它的训练集里。这两条边界一叠加,结论就很硬:任何"私有的"或"最新的"信息,模型本质上无从知晓。下面这段代码,就是直接拿一个它不可能知道的问题去问它:
from openai import OpenAI
client = OpenAI()
# 反面教材:直接拿大模型回答公司内部问题。
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user",
"content": "我们公司的年假是怎么规定的?"}],
)
print(resp.choices[0].message.content)
# 问题:模型的知识停在训练截止那一刻,它从没读过你公司的规章制度。
# 它【不会】说"我不知道",而会用最像那么回事的语气编一套出来 ——
# 这就是幻觉。模型缺的不是智力,是你公司的那份资料。
这里有个特别要命的细节:模型面对自己不知道的事,默认行为不是沉默或承认无知,而是用流畅的语言生成一个最可能的答案。它本质上是个"预测下一个词"的模型,"我不知道"这种回答在训练语料里出现得太少,所以它倾向于编一个完整、自信、看起来很专业的回答——这恰恰是最危险的,因为用户很难分辨它是答对了还是编得好。
既然问题是"模型没有资料",那解法就自然浮现了:在它回答之前,把资料给它。大模型有一个很强的能力——你在 prompt 里给它一段资料,它能基于这段资料来理解和回答问题。RAG 就是把这件事系统化:它不改造模型本身,而是在模型外面套一层"检索"——用户来问问题时,先去你的知识库里把相关资料找出来,塞进 prompt,模型于是从一个"凭记忆瞎答的人",变成一个"开卷答题、照着资料回答的人"。
二、RAG 的整体流程:索引、检索、生成
把 RAG 拆开看,它其实是三个阶段,理解了这三段,后面每一节的细节才有归属。
第一阶段是索引(Indexing),它离线发生、只做一次。你把知识库里所有文档拿出来,切成一小段一小段(chunk),给每一段算出一个能代表它语义的"向量",然后把这些"文本片段 + 向量"全部存进一个库里。这一步是在为后面的检索提前备料——它不在用户提问时发生,而是事先就准备好的。
第二阶段是检索(Retrieval),它在用户每次提问时发生。用户问了一个问题,你把这个问题也算成一个向量,拿它去索引库里比对,找出向量"最接近"的那几个文本片段——这几段,就是和用户问题语义上最相关的资料。
第三阶段是生成(Generation)。把上一步检索到的资料,连同用户的原始问题,一起拼成一个 prompt 发给大模型,并明确要求它"只依据这些资料回答"。模型于是给出一个有据可查的答案。
这三段里,"生成"那一步调用模型,反而是最简单的;真正决定 RAG 好不好用的,是前面"索引"和"检索"的质量——而它们的质量,又是从最底层一个看似不起眼的动作开始的:怎么把文档切块。
三、文档切分:chunk 不是越大越好,也不是越小越好
为什么文档要切块,不能整篇直接用?两个原因。其一,模型的 prompt 长度有上限,你不可能把一本几十页的手册整个塞进去。其二,也是更关键的——检索的精度要求你切块。如果你拿"一整篇文档"去算一个向量,这个向量是这篇文档所有内容的"平均语义",非常模糊;用户问一个具体问题,根本匹配不上这个模糊的平均值。只有把文档切成话题集中的小片段,每段的向量才足够"聚焦",检索才能精准命中。
那直接按固定字符数硬切呢?这是新手最容易踩的坑:
# 反面教材:按固定字符数硬切,完全不管语义边界。
def naive_split(text: str, size: int = 500):
return [text[i:i + size] for i in range(0, len(text), size)]
# 问题:一刀切下去,极可能把一句完整的话、一条完整的规定从中间劈开 ——
# "年假天数为" 留在了上一个 chunk,"10 天" 落到了下一个 chunk。
# 这样切出来的 chunk,语义是残缺的,拿去做向量检索准度大打折扣。
固定长度硬切的问题是它对语义完全无知:它不知道哪里是句号、哪里是段落结束,它只会数到 500 个字符就一刀下去,经常把一个完整的语义单元拦腰斩断。被斩断的两半,各自的向量都是残缺的,检索时谁也匹配不准。
正确的切分要做到两件事。第一,尊重语义边界——优先沿着段落、句子这些天然的分界去切,让每个 chunk 都是一个相对完整的意思。第二,让相邻 chunk 之间留一点重叠(overlap)——把上一个 chunk 结尾的一小段,也带到下一个 chunk 的开头。这样即使一个关键句恰好落在两个 chunk 的交界处,靠着这段重叠,它仍然能完整地出现在某一个 chunk 里:
def split_with_overlap(text: str, size: int = 500, overlap: int = 80):
"""按段落聚合成 chunk,并让相邻 chunk 重叠一段,避免语义被切断。"""
paras = [p.strip() for p in text.split("\n") if p.strip()]
chunks, buf = [], ""
for para in paras:
# 当前段落还能并进这个 chunk —— 累加上去
if len(buf) + len(para) <= size:
buf += para + "\n"
else:
if buf:
chunks.append(buf.strip())
# 新 chunk 的开头,带上上一块结尾的一小段,保住上下文不断裂
tail = buf[-overlap:] if buf else ""
buf = tail + para + "\n"
if buf.strip():
chunks.append(buf.strip())
return chunks
chunk 的大小是个需要权衡的参数,它没有放之四海的最优值。切得太大,一个 chunk 里混进了好几个话题,它的向量又会变模糊,而且检索命中后塞进 prompt 的无关内容(噪声)也多;切得太小,一个完整的意思被拆得七零八落,每个 chunk 都只剩半句话,上下文严重不足。一般从几百字符起步,再根据你的文档类型(条款类的可以小些,叙述类的需要大些)去调。记住:chunk 的质量,是整个 RAG 准度的地基——地基这里切坏了,后面的向量检索再精妙也救不回来。
四、向量化与向量检索:把"语义相似"变成"距离相近"
chunk 切好了,接下来要解决一个核心问题:用户问"年假怎么算",知识库里的相关 chunk 写的可能是"员工带薪休假天数规定"——这两句话没有一个字相同,但意思高度相关。传统的关键词搜索在这里会失灵。RAG 靠的是语义检索,而语义检索的基石,是 embedding(向量化)。
embedding 模型能把一段文本,映射成一个由几百上千个数字组成的向量。它的神奇之处在于:它训练出来的这个映射,会让语义相近的文本,落在向量空间里相近的位置。"年假怎么算"和"带薪休假天数规定"这两句话,字面毫不相同,但它们的向量会靠得很近。于是"判断两段文本意思像不像"这个模糊的语言问题,就被转化成了"计算两个向量的距离"这个精确的数学问题。索引阶段要做的,就是给每个 chunk 都算出并存下它的向量:
def embed(texts: list[str]) -> list[list[float]]:
"""把一批文本送进 embedding 模型,得到每段文本对应的语义向量。"""
resp = client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return [d.embedding for d in resp.data]
def build_index(chunks: list[str]) -> list[dict]:
"""索引阶段:把每个 chunk 连同它的向量一起存起来,供检索时比对。"""
vectors = embed(chunks)
return [{"text": c, "vector": v} for c, v in zip(chunks, vectors)]
有了索引,检索就清晰了:把用户的问题也用同一个 embedding 模型转成向量,然后逐一计算它和库里每个 chunk 向量的"接近程度",取最接近的几个。衡量两个向量接近程度,最常用的是余弦相似度——它度量两个向量方向的一致性,值越接近 1 表示语义越像:
import math
def cosine(a: list[float], b: list[float]) -> float:
"""余弦相似度:度量两个向量方向的一致性,越接近 1 语义越相近。"""
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb + 1e-9)
def search(index: list[dict], query: str, top_k: int = 3) -> list[dict]:
"""检索阶段:把问题向量化,按余弦相似度取最接近的 top_k 个 chunk。"""
q_vec = embed([query])[0]
scored = [{"text": item["text"], "score": cosine(q_vec, item["vector"])}
for item in index]
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:top_k]
这里的 search 为了讲清原理,用的是"和每个 chunk 逐一比对"的暴力算法。chunk 少时这没问题,但知识库一旦有几十万段,每次提问都全量算一遍就太慢了。生产环境会用专门的向量数据库(如 Milvus、Qdrant、pgvector),它们用近似最近邻(ANN)算法,能在海量向量里飞快地找出最接近的几个——但其内核思想,和上面这段 cosine + 排序完全一样。还有一个极易忽略的点:问题和 chunk 必须用同一个 embedding 模型向量化,不同模型生成的向量空间不通用,混用等于拿两把不同的尺子量东西。
五、把检索结果拼进 Prompt:让模型基于资料回答
检索拿到了最相关的几个 chunk,最后一步是把它们和用户问题拼成 prompt。这一步看着简单,却藏着 RAG 成败的另一半——怎么拼,直接决定模型会不会老实。
关键在于 prompt 里要有一句强约束:明确告诉模型"只准依据下面给的资料回答,资料里没有的,就说没有"。少了这句,模型很可能把你给的资料只当成参考,然后掺进自己训练时记下的"常识"一起作答——而它的常识,恰恰可能就是错的。下面这个 prompt 模板和拼装函数,核心就是那句约束:
RAG_PROMPT = """你是公司知识助手。请【只依据】下面提供的资料回答问题。
如果资料中没有相关信息,必须如实回答"资料中未提及",绝不允许自行编造。
【资料】
{context}
【问题】
{question}
"""
def build_prompt(question: str, chunks: list[dict]) -> str:
# 把检索到的 chunk 拼成 context,并标上编号,方便模型在答案里引用
context = "\n\n".join(
f"[资料{i + 1}] {c['text']}" for i, c in enumerate(chunks))
return RAG_PROMPT.format(context=context, question=question)
把三个阶段——检索、增强(拼 prompt)、生成——串成一个完整的函数,RAG 的主干就成型了:
def rag_answer(index: list[dict], question: str) -> str:
# 1. 检索:从知识库里找出和问题最相关的几段资料
hits = search(index, question, top_k=3)
# 2. 增强:把资料拼进带强约束的 prompt
prompt = build_prompt(question, hits)
# 3. 生成:让模型【基于资料】回答,而不是凭记忆
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
这个 rag_answer 就是文章开头那个问题的答案:它不再让模型凭记忆瞎答,而是先把公司真实的资料检索出来、塞进 prompt,模型于是"开卷答题"。但请注意——它能不能答对,完全押在 search 那一步检索得准不准上。如果检索回来的三段资料压根不相关,这个 prompt 就是在拿错误的资料逼模型作答,结果只会更糟。所以最后一节,全是关于怎么让检索这一环更稳。
六、工程坑:检索召回、重排、幻觉兜底
RAG 的主干通了,但要让它在真实知识库上准起来,还有几个绕不开的工程坑。
坑 1:只靠向量检索,召回不够准——要加一层重排(rerank)。embedding 向量检索速度快,但它的相似度判断比较"粗",有时排在前面的几条并不是真正最相关的。成熟的做法是两段式:第一段用向量检索宽召回,多捞一些候选(比如 20 条);第二段再用一个更强、更慢、但更准的 rerank 模型(交叉编码器),对这 20 条候选逐一精打分,取真正最相关的 3 条。召回阶段要"宁可错收、不可漏掉",精排阶段才"优中选优":
from sentence_transformers import CrossEncoder
# rerank 模型:交叉编码器,直接对(问题, 文档)整体打相关性分,
# 比向量检索的"各自向量算距离"更准 —— 代价是更慢,所以只用于精排。
reranker = CrossEncoder("BAAI/bge-reranker-base")
def search_with_rerank(index, query, recall_k=20, top_k=3):
# 第一段:向量检索宽召回 —— 多捞候选,宁可错收不可漏掉
candidates = search(index, query, top_k=recall_k)
# 第二段:rerank 模型对每个候选精打分 —— 优中选优
pairs = [[query, c["text"]] for c in candidates]
scores = reranker.predict(pairs)
for c, s in zip(candidates, scores):
c["score"] = float(s)
candidates.sort(key=lambda x: x["score"], reverse=True)
return candidates[:top_k]
坑 2:检索不到相关资料时,要主动兜底,不能硬答。设想用户问了一个知识库里根本没有答案的问题。search 依然会返回 top_k 条——它只会返回"最接近的",哪怕最接近的也根本不相关。如果你把这几条不相关的资料照样塞进 prompt,模型就被架在了一个尴尬的位置上,很可能又开始编。解法是设一个相关度阈值:连最相关的那条得分都低于阈值,就说明知识库里确实没有,这时应当直接回答"资料中未提及",根本不要进入生成那一步。同时,把答案依据的资料编号附在后面,让回答可溯源、可核查:
def rag_answer_safe(index, question, min_score=0.35):
hits = search_with_rerank(index, question, top_k=3)
# 兜底一:连最相关的资料得分都过低 —— 知识库里确实没有,别硬答
if not hits or hits[0]["score"] < min_score:
return "资料中未提及该问题,建议咨询相关部门。"
prompt = build_prompt(question, hits)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
answer = resp.choices[0].message.content
# 兜底二:把这次依据的资料编号附在答案后,让回答可溯源、可核查
sources = "、".join(f"[资料{i + 1}]" for i in range(len(hits)))
return f"{answer}\n\n(依据:{sources})"
坑 3:有些问题,向量检索天然抓不准。比如对比型问题("A 方案和 B 方案有什么不同")、汇总型问题("一共有几类假期")、或者答案散落在多篇文档里的问题——这类问题的"答案",并不集中在某一两个 chunk 里,单纯的向量检索很难一次命中。这没有银弹,但有几个常用手段:给 chunk 打上元数据标签(文档来源、日期、分类),检索时先按元数据过滤再算相似度,能挡掉大量噪声、也能天然排除掉那些"过期"的旧文档;对复杂问题,先让模型把它拆成几个子问题分别检索;以及把向量检索和传统关键词检索混合使用(hybrid search),让精确的词和模糊的语义互补。下面这张图,把 RAG 从离线索引到在线问答的完整链路串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| RAG | 回答前先从知识库检索相关资料,再塞进 prompt 让模型基于资料作答 |
| 知识边界 | 模型只知训练截止前的公开知识,你的私有 / 最新文档它从未读过 |
| 三阶段 | 索引(离线切块向量化)、检索(按相似度找资料)、生成(基于资料回答) |
| chunk 切分 | 沿语义边界切、相邻块留重叠,别按固定字符数硬切断句子 |
| embedding | 把文本映射成向量,语义相近的文本向量距离也相近 |
| 余弦相似度 | 度量两向量方向一致性,越接近 1 语义越相关 |
| 向量数据库 | 用近似最近邻算法在海量向量里快速检索,内核同 cosine + 排序 |
| 召回与重排 | 向量检索宽召回候选,再用 rerank 模型精排取最相关的几条 |
| prompt 强约束 | 明确要求模型只依据资料回答,资料没有就说没有,压制幻觉 |
| 相关度阈值 | 最高分低于阈值就直接答"资料中未提及",不进入生成 |
避坑清单
- 模型答错"你公司的问题"不是智力问题,是它没有你的资料;换更大的模型无效,要补的是资料不是参数。
- 模型面对不知道的事默认不沉默而是流畅地编一个,幻觉最危险之处在于它自信、专业、难以分辨真假。
- RAG 是索引、检索、生成三阶段;调模型那步最简单,决定成败的是索引与检索的质量。
- 文档不能整篇做检索——整篇的向量是模糊的平均语义,必须切成话题集中的小 chunk。
- chunk 别按固定字符数硬切,会拦腰斩断句子;要沿段落 / 句子边界切,并让相邻 chunk 留一段重叠。
- chunk 太大向量模糊、噪声多,太小语义残缺、上下文不足;从几百字符起步按文档类型调。
- 问题和 chunk 必须用同一个 embedding 模型向量化,不同模型的向量空间不通用,混用必错。
- prompt 里必须有强约束:只依据资料回答、资料没有就说没有,否则模型会掺入可能错误的"常识"。
- 只靠向量检索召回不够准,要加 rerank:向量检索宽召回候选,再用交叉编码器精排取 top-k。
- 检索最高相关分低于阈值时直接答"资料中未提及",别拿不相关资料硬逼模型作答;答案附资料编号便于溯源。
总结
回头看那个把不存在的制度讲得头头是道的内部助手,以及我后来在 RAG 这条路上接连踩的坑,最该记住的不是某一段向量检索代码,而是我动手前那两个想当然的判断。第一个是"模型答错是因为不够聪明"——错了,它答错是因为没有资料,你公司的文档它一个字都没读过,再聪明的模型也变不出它没见过的信息。第二个是"RAG 就是搜一下再拿去问"——也错了,RAG 是一条有好几个环节的链路,搜只是其中一环,而且是被前面"切块、向量化"和后面"重排、约束"共同决定的一环。
所以做 RAG,真正的工程量根本不在"调用大模型"那一下。embeddings.create 加 chat.completions.create 谁都会写,Demo 里它也确实能像模像样地开卷答题。真正的工程量在这两个调用之间:文档怎么切,切坏了向量就是模糊的;向量怎么检索,检索偏了喂进去的就是错资料;检索回来怎么重排,不排序前几条可能全是噪声;prompt 怎么约束,不约束模型照样无视资料去编;检索不到怎么兜底,不兜底它就硬着头皮答。这篇文章的几节,其实就是顺着 RAG 这条链路一环一环展开的:先想清楚它补的是"资料"而非"智力",再看索引、检索、生成三阶段,然后是切块这个地基、向量检索这个核心,最后是重排和幻觉兜底这几个把它真正做准的工程细节。
你会发现,RAG 的思路和我们做任何"问答系统"的工程经验都是相通的。一个负责任的人回答专业问题,不会全凭脑子里的记忆张口就来,他会先去翻手册、查文档、找最新的规定,然后照着资料回答,资料里没有的就老实说"我得再查查"。RAG 做的,正是把这套"先查证、再回答、查不到就承认"的严谨,装到了大模型身上。大模型负责的是它最擅长的——理解问题、组织语言;而"答案的依据"从哪来、对不对、新不新,这件事的责任,从来都在你搭的这条检索链路上。
最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你问的都是知识库里明明白白写着、一句话就能命中的问题,怎么切、怎么检索都答得漂亮。它只在真实用户五花八门的提问面前才显形:那些换了说法的问题、那些答案散落在多篇文档里的问题、那些知识库里压根没有的问题。那时候它会用最难堪的方式给你结账——把一个废止的旧流程当成现行规定讲给员工,或者把一个根本不存在的制度编得有鼻子有眼。所以别等员工拿着错误的答案去做了错误的事再来找你,在你切下第一个 chunk 的时候就该想清楚:这个 chunk 的语义完整吗?检索偏了我重排了吗?知识库里没有时我兜底了吗?模型想编时我约束住它了吗?这几个问题都有了答案,你的 RAG 才不只是 Demo 里那个对答如流的演示,而是一个真正"言之有据"、可以放心交给员工去用的知识助手。
—— 别看了 · 2026