2024 年我做一个公司内部的知识助手,想让它能回答员工的问题:"我们的报销流程是什么""某个产品的退货政策怎么规定的""上个月那份技术规范里关于命名的要求是什么"。这些答案,全都写在公司内部的文档里——几百份 Word、PDF、Wiki 页面。第一版我的做法特别直接:接一个大模型,把用户的问题原样发给它,指望它回答。本地一测,问"我们公司的报销流程",模型回了一段:"一般来说,报销需要填写报销单、附上发票、由主管审批……"我当时还觉得能用——直到我拿这答案去对了一下我们真实的报销文档,发现对不上:我们公司报销不走主管,走的是财务系统直接提单;模型说的那套"主管审批",是它凭训练时见过的通用流程编的。我又问了几个更具体的:"某产品的退货政策""某规范的命名要求"——模型要么含糊其辞,要么干脆说"我无法获取你们公司的内部信息"。我这才明白第一版错在哪:大模型的知识,是它在训练时从公开语料里学来的,并固化在了参数里。它从来没读过我们公司的内部文档——那些文档既不在它的训练语料里,它运行时也没有任何途径去访问。它面对"我们公司的报销流程"这个问题,手里只有一堆通用的、公开的报销知识,于是它就拿这些去编一个看起来合理的答案。我一开始以为,解法是把模型微调一遍,拿我们的文档再训练它。试了才知道:微调成本高、周期长,而且文档一更新就得重训,根本跟不上。后来才彻底想明白,真正的解法根本不在模型那一侧:不要试图让模型"记住"我们的知识,而是在用户每次提问的那一刻,先从我们的文档库里把和这个问题相关的几段资料找出来,连同问题一起塞进 prompt,让模型基于这几段真实资料来回答。模型不需要"懂"我们的业务,它只需要有阅读理解能力——你把材料递到它眼前,它照着材料答。这套"先检索、再把资料喂给模型、最后生成答案"的机制,就是 RAG(检索增强生成)。我以为 RAG 不过是"搜一下再问模型",结果真做下来坑一个接一个:文档怎么切、检索几段、模型还是会编、答案对不上来源……那次之后我才认真把 RAG 从头搞明白。这篇文章就把它梳理一遍:为什么大模型答不了你的私有知识、RAG 的本质是什么、文档怎么切分和向量化、向量检索怎么找到相关片段、检索结果怎么拼进 prompt,以及切分粒度、检索数量、答案幻觉、引用溯源这些把 RAG 真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:做一个回答公司内部知识的助手,第一版把用户问题直接发给大模型。结果模型对"我们公司的报销流程"这类问题,凭通用知识编造一个看似合理、实则与公司真实文档对不上的答案;对更具体的内部问题,则直接回"无法获取内部信息"。
我当时的错误认知:"大模型很聪明,把问题发给它就能回答;答不好就拿公司文档微调它。"
真相:大模型的知识在训练时就固化进了参数,它没读过、也无法访问你的私有文档。要让它回答私有知识,正确的做法不是让模型"记住"知识(微调成本高、跟不上文档更新),而是在每次提问时,先从文档库里检索出与问题相关的片段,把这些片段连同问题一起塞进 prompt,让模型基于这些真实片段来回答。这套"检索 + 增强 + 生成"的机制,就是 RAG。
要把 RAG 做好,需要几块认知:
- 为什么大模型答不了你的私有知识,微调为什么不是好解法;
- RAG 的本质是怎样一个"检索—增强—生成"的流程;
- 文档怎么切分成片段,怎么把片段转成向量;
- 怎么用向量相似度,从海量片段里检索出与问题相关的那几段;
- 切分粒度、检索数量、答案幻觉、引用溯源这些工程坑怎么处理。
一、为什么大模型答不了你的私有知识
先把这件最根本的事钉死:大模型的知识是"训练时固化"的,它读不到你的文档。
一个大模型,它的全部知识来自训练阶段——把海量公开语料喂给它,它把规律学进参数里。训练一旦结束,这些参数就定死了。你公司的内部文档,既不在它的训练语料里,它运行的时候,也没有手、没有网络去翻你的文件柜。下面这段代码,就是我那个"把问题直接发给模型"的第一版:
from openai import OpenAI
client = OpenAI()
def naive_ask(question: str) -> str:
# 反面教材:把私有知识问题直接发给模型,指望它知道。
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": question}],
)
return resp.choices[0].message.content
# 问题:模型从没读过你公司的文档。问"我们的报销流程",
# 它只能拿训练时见过的【通用】报销流程编一个,
# 编得很通顺,但和你公司的真实规定对不上。
这段代码的错,不在某一行语法,在它的根本假设:它假设"模型什么都知道"。模型确实见多识广,但它见的是公开世界;你的私有知识,对它是一片盲区。而模型有个特点:它面对盲区里的问题,不倾向于说"我不知道",而倾向于拿手边最接近的通用知识编一个——这就是"幻觉"。
那为什么不微调?微调确实能把知识"塞进"参数,但代价很重:它要准备训练数据、要算力、要时间;更要命的是,你的文档天天在变——今天更新了报销政策,难道明天就重训一遍模型?跟不上。所以正确的思路是:别动模型,动它的输入。模型答不好,不是因为它笨,是因为你没把材料给它。
二、RAG 的本质:检索、增强、生成
把上一节的思路理顺,RAG 的本质就清楚了:它是一个三步流程。
检索(Retrieval):用户提问后,先不急着问模型。拿着这个问题,去你的文档库里搜——找出和这个问题最相关的那几段文字。增强(Augmented):把搜到的这几段文字,作为"参考资料",和用户的原问题拼在一起,组成一个内容更丰富的新 prompt。生成(Generation):把这个"带资料的 prompt"发给模型,让它基于资料生成答案。
这三步连起来,关键的转变是:模型的角色从"一个无所不知的专家"(它做不到),变成了"一个有阅读理解能力的人"(它很擅长)。你不再指望它"知道"你公司的报销流程,你只是把报销文档的相关段落递到它眼前,让它读着回答。它不需要懂你的业务,它只需要会读。理解了这一点,RAG 就不神秘了——它不是什么新模型,它是给一个普通大模型,在提问前配一个图书管理员。下面几节,就把这个图书管理员的工作拆开:先是把书(文档)整理上架,再是按问题找书。
三、文档切分与向量化:把知识变成可检索的片段
要"检索文档",第一步是把文档整理成可检索的形态。这里有两个动作:切分,和向量化。
先说切分。一份文档动辄几千上万字,你不能把整份文档当作一个检索单位——太大了,里面大部分内容和某个具体问题无关。要把它切成一个个小片段(chunk),每段几百字,让每段都聚焦一小块内容。
def split_into_chunks(text: str, chunk_size: int = 300,
overlap: int = 50) -> list:
"""把长文档切成带重叠的小片段。
overlap:相邻片段重叠一部分,避免把一句话从中间切断。"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
# 下一段的起点往回挪 overlap,和上一段重叠一部分
start = end - overlap
return chunks
注意那个 overlap(重叠):如果两段之间一刀切死,正好被切断的那句话——它的前半句在上一段、后半句在下一段——就哪一段都表达不完整了。让相邻片段重叠一小部分,关键的句子至少能完整地落在某一段里。
切完之后是向量化。我们要做的检索,不是"关键词匹配"——用户问"报销流程",文档里写的可能是"费用申请规程",字面对不上,但意思一样。要按意思检索,就得先把每段文字,转成一个能代表它语义的数字向量。这一步靠 embedding 模型完成:
def embed(text: str) -> list:
"""把一段文字转成一个语义向量(embedding)。
意思相近的文字,转出来的向量在空间里也相互靠近。"""
resp = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return resp.data[0].embedding # 一个浮点数列表,如 1536 维
embedding 模型的神奇之处在于:它产出的向量,语义相近的会在空间里彼此靠近。"报销流程"和"费用申请规程"这两段话,字面毫不相同,但它们的向量会离得很近。这就是按"意思"检索的基础。文档切分、每段向量化——书,就算整理好了。
四、向量检索:用相似度找到最相关的片段
书上架了,接下来是按问题找书。
核心思路是:用户的问题,本身也是一段文字,把它也用同一个 embedding 模型转成向量。然后,拿这个"问题向量",去和库里所有的"片段向量"逐一比较距离——离得最近的那几个片段,就是和问题语义上最相关的。衡量两个向量"有多近",常用余弦相似度。先把这个简单的向量库写出来:
import math
class VectorStore:
"""一个最简向量库:存片段和它的向量,支持按相似度检索。"""
def __init__(self):
self._items = [] # 每项是 (片段文本, 向量)
def add(self, text: str, vector: list):
self._items.append((text, vector))
@staticmethod
def _cosine(a: list, b: list) -> 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(self, query_vec: list, top_k: int = 3) -> list:
# 算出 query 和每个片段的相似度,取最高的 top_k 个
scored = [(self._cosine(query_vec, vec), text)
for text, vec in self._items]
scored.sort(reverse=True)
return scored[:top_k]
有了库,把第三节的切分、向量化串起来,就能建索引——把所有文档灌进这个向量库:
def build_index(documents: list) -> VectorStore:
"""把一批文档切分、向量化,全部灌进向量库。"""
store = VectorStore()
for doc in documents:
for chunk in split_into_chunks(doc):
store.add(chunk, embed(chunk)) # 片段 + 它的向量
return store
索引建好,检索就是顺理成章的一步:把用户问题转成向量,丢进库里搜:
def retrieve(store: VectorStore, question: str, top_k: int = 3) -> list:
"""检索:把问题转成向量,从库里找出最相关的 top_k 个片段。"""
query_vec = embed(question) # 问题也转成向量
hits = store.search(query_vec, top_k=top_k)
# 返回片段文本列表(这里丢掉分数,只要内容)
return [text for score, text in hits]
这一步是整个 RAG 的心脏。注意:检索不要求字面匹配——用户问"出差住宿能报多少钱",哪怕文档里写的是"差旅住宿费标准",只要语义接近,它们的向量就接近,这段就会被 retrieve 捞出来。检索到的这几段,就是要喂给模型的"参考资料"。
五、增强与生成:把资料喂给模型,让它照着答
检索拿到了相关片段,RAG 的最后两步——增强、生成——就是把这些片段用对。
增强,就是构造那个"带资料的 prompt"。它的结构有讲究:要明确告诉模型"下面是参考资料",要明确要求它"只能根据资料回答",还要给它一条退路——"资料里没有就说不知道"。这条退路,正是治第一节那个"编答案"病根的:
def build_rag_prompt(question: str, contexts: list) -> str:
"""把检索到的片段拼成参考资料,和问题一起组成增强后的 prompt。"""
refs = "\n\n".join(f"[资料{i + 1}] {c}"
for i, c in enumerate(contexts))
return f"""请只根据下面的【参考资料】回答用户问题。
如果参考资料里没有相关信息,直接回答"根据现有资料无法回答",
绝对不要用资料以外的知识自行编造。
【参考资料】
{refs}
【用户问题】
{question}"""
有了这个 prompt,生成就是最后一步:发给模型。把检索、增强、生成三步串起来,就是完整的 RAG 主流程:
def rag_answer(store: VectorStore, question: str) -> str:
"""RAG 主流程:检索 -> 增强 -> 生成。"""
contexts = retrieve(store, question, top_k=3) # 1. 检索
prompt = build_rag_prompt(question, contexts) # 2. 增强
resp = client.chat.completions.create( # 3. 生成
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
对比一下第一节的 naive_ask:两者都"问了模型一次",但 rag_answer 在问之前,多做了"检索"和"增强"两步。正是这两步,让模型手里有了你公司文档的真实片段。它回答"报销流程"时,照着的是你的资料,而不是它训练时见过的通用流程。问题开头那个"编出来的报销流程",到这里就被根治了。
六、工程坑:切分粒度、检索数量与答案溯源
主流程通了,但要把 RAG 真正做对,还有几个绕不开的工程坑。
坑 1:切分粒度要适中。片段切得太大,一段里混了好几个主题,检索时语义不聚焦,而且喂给模型的资料里夹带大量无关内容;切得太小,一句完整的话被拆碎,单看一个片段缺乏上下文,模型读不懂。没有万能值,要结合文档特点调,并保留重叠。
坑 2:检索数量(top_k)要权衡。top_k 太小,真正有答案的那段可能没被捞进来;太大,一堆不相关的片段也被塞进 prompt,既稀释了关键信息、干扰模型,又浪费 token。一般从 3 到 5 起步,再按效果调。
坑 3:模型仍可能"无视资料"地编。就算 prompt 里写了"只根据资料回答",模型偶尔还是会掺入自己的知识。更稳的做法是:在检索那一步就设一个相似度门槛——如果连最相关的片段,相似度都低于门槛,说明库里根本没有相关内容,这时干脆不问模型,直接回"无法回答"。
def rag_answer_grounded(store: VectorStore, question: str,
min_score: float = 0.3) -> str:
"""带门槛的 RAG:检索不到足够相关的资料,就不让模型作答。"""
query_vec = embed(question)
hits = store.search(query_vec, top_k=3)
# 最相关的片段,相似度都没过门槛 —— 说明库里没有答案
if not hits or hits[0][0] < min_score:
return "根据现有资料无法回答这个问题"
contexts = [text for score, text in hits]
prompt = build_rag_prompt(question, contexts)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
坑 4:答案要能溯源。用户看到一个答案,他怎么知道该不该信?最好的办法是:让答案带上它依据的来源——这段答案是根据哪份文档、哪个片段得出的。这就要求检索时,片段不能只存文本,还要带上它的出处(文档名、章节)。回答时把出处一并返回,用户可以点回原文核对。下面这张图,把一次 RAG 问答的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 知识固化在参数里 | 大模型的知识训练时定死,读不到也访问不了你的私有文档 |
| 微调不是好解法 | 成本高、周期长,文档一更新就得重训,跟不上变化 |
| RAG 三步 | 检索相关片段、把片段增强进 prompt、让模型基于片段生成答案 |
| 文档切分 chunk | 把长文档切成几百字的小片段,每段聚焦一小块内容 |
| 切分重叠 overlap | 相邻片段重叠一部分,避免一句话被从中间切断 |
| 向量化 embedding | 把文字转成语义向量,意思相近的文字向量也相互靠近 |
| 余弦相似度 | 用向量夹角的余弦衡量两段文字语义有多接近,越接近 1 越像 |
| 向量检索 | 问题转向量,和库里片段逐一比相似度,取最高的 top_k 个 |
| 相似度门槛 | 最相关片段都不过门槛就直接回无法回答,不让模型编造 |
| 答案溯源 | 片段带出处,答案附上依据的文档来源,让用户能核对 |
避坑清单
- 大模型的知识在训练时固化进参数,它没读过也无法访问你的私有文档,直接问只会让它编。
- 别用微调让模型记住知识:成本高、跟不上文档更新;正确思路是别动模型、动它的输入。
- RAG 的本质是检索加增强加生成,模型角色从无所不知的专家变成有阅读理解能力的人。
- 长文档必须切成小片段再检索,整份文档当检索单位会让大量无关内容混进来。
- 切分要保留重叠,否则一句完整的话被一刀切断,前后两段都表达不完整。
- 检索要按语义而非关键词,靠 embedding 把文字转成向量,意思相近向量才相近。
- 切分粒度要适中:太大语义不聚焦还夹带无关内容,太小缺上下文模型读不懂。
- top_k 要权衡:太小漏掉真正有答案的片段,太大稀释关键信息又浪费 token。
- 模型仍可能无视资料编造,要在检索处设相似度门槛,过不了门槛就直接回无法回答。
- 片段要带出处,答案要附来源,让用户能点回原文核对,这是 RAG 可信度的关键。
总结
回头看那个"问它公司报销流程、它编了一套对不上的流程"的知识助手,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"大模型很聪明,把问题发给它就能回答"。这句话错在它把模型的"聪明"和"知道"划了等号。模型确实聪明,但聪明指的是它的理解与表达能力;它"知道"什么,则完全取决于训练时喂了它什么。你公司的内部文档不在那批语料里,所以无论模型多聪明,它都不可能"知道"你的报销流程。RAG 想清楚的,正是这件事:不要把宝押在模型"知道",而要押在它"会读"——你负责把对的材料找出来递给它,它负责读懂材料、组织答案。
所以做 RAG,真正的工程量不在"调一次模型"那一下。检索几段文字、拼进 prompt、发给模型,这部分 Demo 里照着文档谁都能跑通。真正的工程量在那条检索链路的每一处:文档你切得对不对,切得太碎模型读不懂上下文、切得太整检索不精准?用户问题和文档用词不一样,你的向量检索捞得到那段真正有答案的文字吗?库里压根没有答案时,你的系统是诚实地说"无法回答",还是又让模型滑回去编一个?模型给出的答案,用户凭什么相信它,你能不能把来源一并摆出来?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么答不了私有知识,再看 RAG 的本质是怎样一个三步流程,然后是文档切分向量化、向量检索、增强生成这三段主干,最后是切分粒度、检索数量、答案溯源这几个把 RAG 真正做对的工程细节。
你会发现,RAG 的思路,和我们平时查资料答问题的经验完全相通。一个再博学的人,你问他一份他从没见过的公司内部规定,他也答不上来——除非你把那份文件递到他手里,他读一遍,再回答你。一个好的图书管理员,你说出你的问题,他不会凭印象瞎答,他会走到书架前,精准地抽出那几本相关的书,翻到相关的那几页,递给你。RAG 做的就是这件事:大模型是那个有阅读理解能力的人,你的文档库是那座书库,而你写的检索代码,就是那个把对的书、翻到对的页、递到模型眼前的图书管理员。模型再聪明,没有这个管理员,它面对你的私有问题也只能靠猜。
最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你拿一个文档里写得明明白白的问题去问,检索一下就命中,模型照着答,跑起来漂亮极了。它只在真实的、五花八门的提问面前才显形。那时候它会用最难堪的方式给你结账:一个用户用了和文档完全不同的说法来问同一件事,你的检索没捞到那段资料,模型在空荡荡的上下文里又编了一个;一个用户问了一件文档里根本没写的事,你的系统没有门槛拦着,模型张口就来;一个用户拿着一个错误答案来投诉,你想查它到底依据了哪段资料出的错,却发现答案里没有任何来源,无从查起。所以别等用户拿着编造的答案来质问你,在你搭第一条检索链路的时候就该想清楚:用户换种说法问,我检索得到吗?库里没有答案时,我拦得住模型编造吗?它给的答案,我溯得到源吗?这几个问题都有了答案,你的 RAG 才不只是 Demo 里那个命中率好看的样子,而是一个无论用户怎么问、问得多刁钻,都能基于真实资料作答、答不了就老实说答不了、且每个答案都查得到出处的可靠系统。
—— 别看了 · 2026