2024 年我做一个企业内部知识库问答。需求很常见:员工想知道公司的规章制度、产品文档里写了什么,直接问,让大模型回答。第一版我做得很直接:把员工的问题原封不动发给大模型,模型生成答案返回。本地用一些常识性问题试,效果挺好。可一上线给同事用,问题立刻就来了。有人问"我们公司的年假有几天",模型给的答案是"一般为 5 到 15 天"——这是它从训练数据里学到的泛泛常识,根本不是我们公司的规定。更吓人的是,有人问一个具体的产品参数,模型没有丝毫犹豫,给出了一个看起来非常专业、实际上完全是编的数字。同事还真信了,拿去用,出了岔子。我第一反应是:模型不够聪明,换个更强的模型。换了,没用——它照样不知道我们公司的事,照样编。我又想,那把公司所有文档都拼进 prompt,一起发给模型,它不就知道了?我试了,文档稍微一多,请求直接报错——内容撑爆了上下文窗口。我盯着这些问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"大模型什么都懂,我问它就行了"。可它不懂你的私有数据。大模型的知识,是冻结在它训练那一刻的——它从来没见过你公司的内部文档,你问它,它要么老实说不知道,要么一本正经地编一个,这就是幻觉。而"把所有文档拼进去"也走不通:文档一多就爆窗口,就算不爆也又贵又抓不住重点。真正的解法,是先去你的知识库里,把和这个问题真正相关的那一小段资料找出来,再把这一小段连同问题一起发给模型,让模型"看着这段资料回答"。这,就是 RAG(检索增强生成)。我以为做知识库问答不过是"调一下模型",结果真做下来才发现完全是另一套思路。这篇文章就把它梳理一遍:为什么直接问大模型答不了私有知识、为什么把所有文档拼进 prompt 也不行、RAG 的本质是什么、怎么做文档切块和向量化、怎么做检索和生成,以及切块大小、检索门槛、引用来源这些把 RAG 真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个企业知识库问答,把员工问题直接发给大模型。问公司内部的事(年假天数、产品参数),模型要么答的是泛泛常识、不是本公司规定,要么一本正经地编造一个看起来很专业的答案。试图把全部文档拼进 prompt,请求直接撑爆上下文窗口报错。
我当时的错误认知:"大模型知识渊博,把问题发给它就能得到答案;答不好就换个更强的模型,或者把所有文档都塞进 prompt。"
真相:大模型的知识冻结在训练时刻,它不包含你的私有数据,问它私有问题只会得到常识或幻觉。换更强的模型治不了这个病。而"全量拼接文档"也不行:文档一多就超窗口,不超也贵、且关键信息淹没在无关内容里抓不住重点。正确的做法是 RAG:把知识库文档切块、向量化建成索引;每次提问时,先检索出与问题最相关的少量片段,再把这些片段连同问题一起发给模型,让模型基于这些片段回答。
要把 RAG 做好,需要几块认知:
- 为什么直接问大模型,私有知识答不了、还会幻觉;
- 为什么"把所有文档拼进 prompt"在工程上走不通;
- RAG 的本质——先检索、再生成,把私有知识喂给模型;
- 怎么做文档切块、向量化,把知识库建成可检索的索引;
- 切块大小、检索门槛、引用来源这些工程坑怎么处理。
一、为什么直接问大模型,私有知识它答不了
先把这件最根本的事钉死:大模型的知识,是冻结在它训练那一刻的;你公司的私有文档,它从来没见过,所以它根本无从知道、只能猜。
大模型是在一个巨大但固定的语料上训练出来的,训练一旦结束,它的知识就定格了。这意味着两件事:一,训练之后发生的事它不知道;二——这才是知识库问答的命门——任何没进过它训练语料的东西,比如你公司内部的、私有的文档,它压根没见过。下面这段代码,就是我那个会"一本正经胡编"的第一版:
from openai import OpenAI
client = OpenAI()
def ask_llm_direct(question: str) -> str:
# 反面教材:直接把问题丢给模型,指望它知道公司内部的事。
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "你是公司知识库助手。"},
{"role": "user", "content": question},
],
)
return resp.choices[0].message.content
# 问题:模型的知识冻结在训练那一刻,它【从没见过】
# 你公司的规章制度、产品文档。问"我们公司年假有几天",
# 它要么老实说不知道,要么【一本正经地编一个】—— 这就是幻觉。
这段代码本身没有 bug,语法、调用都对。它的问题在于用错了地方:它默认"模型知道答案",可对私有问题来说,这个前提根本不成立。更危险的是模型的态度——它不会因为"不知道"就闭嘴,它被训练得很擅长把话说得流畅、自信。于是它会用一个极其专业、极其笃定的语气,给你一个完全是编的答案。这种带着十足自信的错误,就是幻觉,也是知识库问答里最害人的东西——因为用户分辨不出来。
所以问题的根子很清楚:我们要回答的问题,答案不在模型脑子里,而在我们自己的文档里。模型缺的不是"聪明",而是"资料"。要让它答对,就得想办法在提问的时候,把对应的资料一起递给它。
二、为什么"把所有文档拼进 prompt"也不行
顺着上一节的结论——"得把资料递给模型"——最直接的念头就是:那我把公司所有文档,全都拼进 prompt,一起发过去不就行了?我当时就是这么想、也这么做的:
def ask_with_all_docs(question: str, all_docs: list) -> str:
# 反面教材:把【整个知识库】所有文档拼进 prompt 一起发。
context = "\n\n".join(all_docs)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "根据下面的资料回答问题。"},
{"role": "user",
"content": f"资料:\n{context}\n\n问题:{question}"},
],
)
return resp.choices[0].message.content
# 三个问题:
# 1. 文档一多,context 直接【撑爆上下文窗口】,请求报错;
# 2. 就算没爆,每次提问都把全部文档重发一遍,token 费用惊人;
# 3. 关键信息埋在一大堆无关文档里,模型反而【抓不住重点】。
这个想法的方向是对的——把资料递给模型——但方式错得离谱。它有三个致命问题。第一,装不下:模型的上下文窗口有硬性的 token 上限,一个真实公司的知识库,几十上百份文档,拼起来轻松冲破这个上限,请求直接被拒。第二,烧钱:就算侥幸没爆,你每问一个问题,都把整个知识库重新发一遍、按 token 重新付一遍钱,成本高得荒谬。第三——也最反直觉——效果反而差:把一份真正相关的资料,埋进几十份无关文档里发给模型,模型的注意力会被大量噪声稀释,它抓不住那个真正该看的重点,回答质量不升反降。
所以"递资料"这个方向对,但不能递全部。真正该做的是:每次提问,只挑出和这个问题真正相关的那一小撮资料,把这一小撮——而不是全部——递给模型。问题就变成了:怎么从一大堆文档里,快速挑出"和这个问题相关"的那一小撮?这,正是 RAG 要解决的。
三、RAG 的本质:先检索,再生成
上两节把死结摆清楚了:模型没有私有知识(第一节),而私有知识又不能全量塞给它(第二节)。RAG(Retrieval-Augmented Generation,检索增强生成)的破局点,就藏在它名字的顺序里:先检索(Retrieval),再生成(Generation)。
它把"回答一个问题"拆成了泾渭分明的两步。第一步,检索:拿着用户的问题,去你自己的知识库里,找出最相关的少量几段资料——不是全部,就几段。第二步,生成:把这几段资料,连同原问题,一起组织成一个 prompt 发给大模型,并明确要求它"只根据这几段资料来回答"。
这两步,各自解决一个前面的死结。"检索"这一步,解决了"模型没有私有知识"——知识从你的库里现取,不依赖模型脑子里有没有。"只取几段"这一点,解决了"不能全量塞"——发给模型的资料量很小且可控,不爆窗口、不烧钱、没噪声。而大模型在这套流程里,角色也变了:它不再是那个"什么都得自己知道"的回答者,它退化成一个纯粹的"阅读理解 + 总结"引擎——给它一段资料、一个问题,它负责读懂资料、组织出通顺的回答。它最擅长的恰恰就是这个。RAG 的全部精妙,就在于把"知识"和"语言能力"拆开了:知识归你的知识库,语言能力归模型。理解了这个拆分,剩下的就是工程问题——而第一个工程问题是:怎么让一个知识库变得"可检索"?
四、文档切块与向量化:把知识库建成索引
要让知识库"可检索",得先做两件事:切块,和向量化。
先说切块。一份文档,动辄几千上万字,整篇作为检索的最小单位太粗了——你问一个很具体的小问题,却检索回来一整篇文档,无关内容还是一大堆。所以要先把每篇文档,切成若干段小块(chunk),让检索的粒度变细。切块有个关键细节:相邻的块之间要留一点重叠,免得一句完整的话,正好被切在两块的边界上、语义被生生割裂。
def split_into_chunks(text: str, chunk_size: int = 500,
overlap: int = 50) -> list:
"""把一篇长文档,切成带重叠的小块。"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
# 下一块的起点往前回退 overlap 个字,
# 让相邻两块有一段重叠,避免一句话被切在边界上、语义割裂。
start = end - overlap
return chunks
再说向量化。切好块之后,怎么衡量"一个块和一个问题相不相关"?靠字面匹配关键词太弱(用户的问法和文档的写法往往对不上)。正确的办法是 embedding:用一个 embedding 模型,把每个文本块转成一个向量——它的妙处是,语义越接近的两段文本,转出来的向量在空间里就越靠近。
import numpy as np
def embed(text: str) -> np.ndarray:
"""把一段文本,转成一个语义向量(embedding)。"""
resp = client.embeddings.create(
model="text-embedding-3-small", input=text)
return np.array(resp.data[0].embedding)
把这两件事合起来,就能把整个知识库建成一个可检索的索引:每篇文档进来,先切块,每块算出向量,然后把"(向量, 块原文, 来源文档名)"逐条存起来。注意要把来源一起存——后面要靠它告诉用户"这个答案出自哪份文档"。
class VectorStore:
"""最简向量库:存一批 (向量, 块原文, 来源) 三元组。"""
def __init__(self):
self.items = [] # 每项是 (向量, chunk 文本, 来源文档名)
def add_document(self, name: str, text: str):
"""一篇文档入库:切块 -> 每块算向量 -> 逐块存进库。"""
for chunk in split_into_chunks(text):
vec = embed(chunk)
self.items.append((vec, chunk, name))
知识库一旦这样建好,它就不再是一堆死文档,而是一个按语义组织、可以快速查询的索引。下一步,就是拿用户的问题,去这个索引里检索。
五、检索:把问题向量化,找最相似的片段
索引建好了,检索这一步就顺理成章:把用户的问题,用同一个 embedding 模型转成向量,然后在向量库里,找出和它最靠近的那几个块。衡量"靠近",用的是余弦相似度(值越接近 1 越像)。
def search(self, query: str, top_k: int = 3) -> list:
"""检索:把 query 向量化,返回最相似的 top_k 个块。"""
if not self.items:
return []
q_vec = embed(query)
scored = []
for vec, chunk, name in self.items:
# 余弦相似度:两个向量越"同向",值越接近 1
sim = float(np.dot(q_vec, vec) /
(np.linalg.norm(q_vec) * np.linalg.norm(vec)))
scored.append((sim, chunk, name))
# 按相似度从高到低排序,取最靠前的 top_k 个
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:top_k]
但这里有个不能省的判断:search 永远会返回 top_k 个"最相似"的块——可"最相似"不等于"真的相关"。如果用户问的问题,知识库里压根没有对应的资料,那么"最相似"的那几个块,相似度其实低得可怜,它们是"矮子里拔将军"。所以检索之后,必须再加一道相似度门槛:分数太低的块,说明知识库里没有相关内容,宁可丢掉,也别把无关的东西塞给模型。
def retrieve(store: VectorStore, question: str,
top_k: int = 3, min_score: float = 0.3) -> list:
"""检索并过滤:只保留相似度过得了门槛的块。"""
hits = store.search(question, top_k=top_k)
# 关键:相似度太低,说明知识库里根本没有相关内容。
# 这种"矮子里拔将军"的块,宁可不要,也别塞给模型当噪声。
good = [(score, chunk, name) for score, chunk, name in hits
if score >= min_score]
return good
到这里,我们已经能稳定地从知识库里,捞出和问题真正相关的那一小撮资料了。最后一步,就是把它们交给模型,让模型读着它们把答案写出来。
六、生成:把片段塞进 prompt 与工程坑
检索拿到了相关片段,生成这一步,核心就是组织 prompt:把片段和问题拼在一起,并给模型清晰的指令。这里有两个要点:一是明确要求模型"只根据资料回答、不要编造";二是处理"一个片段都没检索到"的情况——这时绝不能让模型自由发挥,要明确告诉它"没有资料,请如实说不知道"。
def build_rag_prompt(question: str, hits: list) -> str:
"""把检索到的片段,拼成给模型的 prompt。"""
if not hits:
# 没检索到任何相关片段:明确告诉模型"没资料",
# 逼它如实说不知道,而不是放任它凭空编造。
return (f"知识库中没有找到相关资料。"
f"请直接告诉用户你无法回答这个问题。\n问题:{question}")
blocks = []
for i, (score, chunk, name) in enumerate(hits, 1):
# 每段资料都带上来源,后面好让模型注明引用
blocks.append(f"[资料{i}] (来源:{name})\n{chunk}")
context = "\n\n".join(blocks)
return (f"只根据下面的资料回答问题,不得编造资料里没有的内容。"
f"回答时请注明引用了哪条资料。\n\n{context}\n\n问题:{question}")
把检索和生成串起来,就是完整的 RAG 流程。对比第一节那个 ask_llm_direct,差别一目了然:
def answer_with_rag(store: VectorStore, question: str) -> str:
"""完整的 RAG 流程:检索 -> 拼 prompt -> 生成。"""
hits = retrieve(store, question) # 第一步:检索
prompt = build_rag_prompt(question, hits) # 把片段拼进 prompt
resp = client.chat.completions.create( # 第二步:生成
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "你是严谨的知识库助手。"},
{"role": "user", "content": prompt},
],
)
return resp.choices[0].message.content
# 对比第一节的 ask_llm_direct:模型不再凭空回答,
# 它说的每一句话,背后都有检索回来的【真实资料】兜底。
主干通了,但要把 RAG 真正做对,还有几个工程坑绕不开。坑 1:切块大小是个权衡。块切得太大,一个块里混了好几个主题,检索精度下降、还浪费 token;切得太小,一句话的上下文被切碎,模型读不全。500 字上下是个常见的起点,但要按你的文档实际去调。坑 2:top_k 和门槛要一起调。top_k 太小,可能漏掉真正有用的片段;太大,又把噪声引回来了。min_score 门槛太高,会把本来有用的也滤掉;太低,放进来的就是垃圾。这两个值,得拿真实问题去测。坑 3:检索不到时,务必让模型说"不知道"。这是 RAG 对抗幻觉的最后一道闸——上面 build_rag_prompt 里那个 if not hits 分支,绝不能省。坑 4:答案要带来源。让模型注明"引用了资料几、来源是哪份文档",用户就能自己去核对,可信度和可追溯性都大不一样。坑 5:知识库更新后要重建索引,而且建库和检索必须用同一个 embedding 模型,换了模型,向量空间就对不上了。下面这张图,把一次 RAG 问答的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 知识冻结 | 大模型的知识定格在训练时刻,不包含训练后的事和你的私有数据 |
| 幻觉 | 模型对不知道的问题不闭嘴,会用自信的语气编一个错误答案 |
| 全量拼接的坑 | 所有文档塞进 prompt 会爆窗口、烧钱,且噪声多导致抓不住重点 |
| RAG 的本质 | 先检索出相关片段再生成,把知识和语言能力拆开 |
| 文档切块 | 把长文档切成小块让检索粒度变细,相邻块留重叠避免语义割裂 |
| 向量化 | 用 embedding 把文本块转成向量,语义越近向量越靠近 |
| 检索 top-k | 把问题向量化,按余弦相似度取最相似的若干个块 |
| 相似度门槛 | 最相似不等于真相关,分数太低的块要丢弃,别当噪声塞给模型 |
| 检索不到要说不知道 | 没命中片段时明确让模型如实回答不知道,这是对抗幻觉的闸门 |
| 来源引用 | 答案注明出自哪份文档,用户可核对,可信度和可追溯性大不同 |
避坑清单
- 大模型知识冻结在训练时刻,不包含私有数据,问私有问题只会得到常识或幻觉。
- 模型对不知道的问题不会闭嘴,会用自信专业的语气编造答案,这种幻觉对用户最害人。
- 把所有文档拼进 prompt 行不通:会爆上下文窗口、每次提问重发全部很烧钱、噪声多反而答不好。
- RAG 的本质是先检索后生成,把知识交给知识库、把语言能力交给模型,两者拆开。
- 长文档要切成小块让检索粒度变细,相邻块之间留重叠,避免一句话被切在边界语义割裂。
- 用 embedding 把文本块转成向量,靠余弦相似度衡量块和问题相不相关,而不是字面匹配关键词。
- 检索返回的最相似不等于真相关,要加相似度门槛,分数太低说明库里没有,宁可丢弃。
- 一个片段都没检索到时,务必明确让模型如实说不知道,这是 RAG 对抗幻觉的最后闸门。
- 切块大小和 top-k、门槛都要拿真实问题去调,太大太小都有代价,没有放之四海的默认值。
- 答案要带来源引用方便用户核对,知识库更新后要重建索引,且建库与检索必须用同一 embedding 模型。
总结
回头看那次"问公司年假、模型张口胡编"的事故,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"大模型什么都懂,我问它就行了"。这句话错在它把大模型当成了一个"无所不知的神谕"。可它不是神谕,它更像一个读书破万卷、但毕业之后就再没接触过新信息的优等生:他语言组织能力极强,你给他一段材料,他能飞快读懂、总结得漂亮;但你要是问他一件他书本里没有的事——比如你们公司上周开会定了什么——他不知道,可他又不好意思说不知道,于是编。RAG 想清楚的,正是这件事:不要去考这个优等生他不可能知道的东西,而要先把相关的材料找出来、递到他面前,再让他看着材料回答。你用的,始终是他最强的那个能力——读懂和表达;而绕开了他最弱的那个环节——他脑子里到底有没有这个知识。
所以做 RAG,真正的工程量不在"调用模型"那一下。那一下,和第一节那个会胡编的 ask_llm_direct 几乎一模一样。真正的工程量,在检索那半边:你的文档,切块切得合不合理,会不会把一句话拦腰斩断?你的向量化,能不能让"换了种说法的问题"也匹配上对应的资料?你的检索,top_k 取几、相似度门槛卡多少,才能既不漏掉有用的、又不放进来一堆噪声?最关键的是那个门槛:当用户问了一个知识库里根本没有答案的问题,你的系统是诚实地说"我这儿没有相关资料",还是放任模型故态复萌、又编一个?RAG 做得好不好,很大程度上就取决于这道闸关没关严。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么答不了私有知识、为什么不能全量塞,再看清 RAG 先检索后生成的本质,然后是切块向量化、检索、生成这三段主干,最后是切块大小、检索门槛、来源引用这几个把 RAG 真正做对的工程细节。
你会发现,RAG 的思路,和一个靠谱的研究员回答问题的方式,惊人地相似。你问一个不靠谱的人一个专业问题,他会凭印象、凭感觉立刻给你一个答案——这是 ask_llm_direct,答案听着挺顺,但你不知道哪句是真的。而一个靠谱的研究员,你问他同一个问题,他不会马上张嘴:他会先说"我查一下资料"——他转身去翻文献、找档案(这是检索),找到几份真正相关的,摊在桌上(这是把片段拼进 prompt),然后对着这些资料组织出回答,还会告诉你"这个结论出自哪份文件"(这是来源引用)。他甚至有一种难得的诚实:翻遍了资料也没找到,他会坦白说"这个我手头没有资料,答不了"——而不是硬编一个。RAG,就是想让你的系统,从那个张口就来的不靠谱的人,变成这个凡事先查证、答必有出处的研究员。
最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你自己塞几篇文档、问几个文档里明明白白写着的问题,检索做得糙一点、门槛设得松一点,跑起来都"能答对",你只会觉得"RAG 嘛,不就这样"。它只在真实的、文档成百上千份、用户问题千奇百怪的知识库面前才显形。那时候它会用最难堪的方式给你结账:做不好,用户问一个知识库里其实有答案的问题,你的检索却没捞对片段,模型只好说不知道,用户觉得"这破系统真没用";更糟的是,用户问一个知识库里没有的问题,你那道门槛又没关严,模型故态复萌编了一个,用户信了、用了、出了事——你费这么大劲做 RAG,最后还是败给了幻觉。而做对了,它会安安静静地,把用户的每一个问题,都先翻一遍你的知识库,有答案就带着出处准确地答,没答案就老老实实说没有。所以别等同事拿着一个胡编的答案来找你,在你给知识库问答写下第一行代码的时候就该想清楚:模型答的这句话,背后有没有真实资料?我的检索,捞得准吗?捞不到的时候,我拦得住它编吗?这几个问题都有了答案,你的知识库问答才不只是 Demo 里那个塞几篇文档就能跑的样子,而是一个无论文档多少、问题多刁,都能有一句说一句、答必有据的可靠系统。
—— 别看了 · 2026