2024 年我做一个公司内部的知识库问答助手。需求很实在:员工问"我们的报销流程是什么",助手要根据公司那几百篇制度文档,给出准确的回答。第一版我做得很直接:把所有文档拼成一大段,塞进 prompt,后面接上员工的问题,一起发给大模型。本地我拿两三篇文档测——完美,问什么答什么。可一接入真实的那几百篇文档,问题就一个接一个砸下来。第一个:文档加起来几十万字,直接超出模型的 context 上限,API 当场报错。我删掉一批文档,勉强不报错了,第二个问题来了:每次请求都把几十万字发出去,token 费用高得吓人,响应还慢。可最要命的是第三个:就算塞进去了,模型答得也不准——答案明明白纸黑字写在第 80 篇文档里,模型却像没看见一样,答得驴唇不对马嘴。我盯着这三个问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"把所有资料一股脑给模型,它自己会从里面找出答案"。可这个想法三处都错:其一,模型的 context 是有硬上限的,你塞不下一整个知识库;其二,context 里的 token 是要花钱的,每个问题都重发整个知识库,贵得离谱;其三,也是最隐蔽的——模型对长文本中间部分的注意力会显著衰减(业界叫 "lost in the middle"),你把答案埋在几十万字的中段,模型大概率读不到。这三件事合起来,逼出了唯一正确的思路:不要把所有文档都给模型,而要先用检索从知识库里挑出和问题最相关的那几个片段,只把这几个片段连同问题给模型。这,就是 RAG(检索增强生成)。我以为它不过是"先搜一下再问",结果真做下来,坑一个接一个。这篇文章就把它梳理一遍:为什么把整个知识库塞进 prompt 行不通、RAG 的本质是什么、文档怎么切块、怎么把文本变成向量、怎么用向量检索找出最相关的片段,以及 chunk 大小、重叠、top-k、prompt 拼装、幻觉这些把 RAG 真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个知识库问答助手,把公司几百篇制度文档全部拼进 prompt 再提问。文档总量几十万字,超出模型 context 上限报错;删减后勉强能跑,但每次请求token 费用极高、响应慢;而且即便文档塞进去了,模型对埋在中段的答案视而不见,回答经常驴唇不对马嘴。
我当时的错误认知:"把所有相关资料都丢进 prompt,模型自然会从里面找出答案,资料给得越全越好。"
真相:模型的 context 有硬上限、按 token 收费,而且对长文本中段的注意力会衰减。给得越多,不仅越贵越慢,还越容易把真正的答案淹没掉。正确的做法是 RAG:先把知识库切成小块、把每一块变成向量存起来;问题来了,先把问题也变成向量,用向量相似度检索出最相关的几块;只把这几块连同问题拼进 prompt 交给模型。模型回答的依据,从"一整个知识库"收窄成"几段精准命中的资料"。
要把 RAG 做对,需要几块认知:
- 为什么把整个知识库塞进 prompt 行不通——上限、成本、注意力衰减;
- RAG 的本质——先检索出相关片段,再让模型基于片段生成;
- 文档怎么切块,块切得太大太小各有什么问题;
- 怎么把文本变成向量(embedding),怎么用向量相似度做检索;
- chunk 大小与重叠、top-k 取几、prompt 怎么拼、怎么防幻觉这些工程坑。
一、为什么把整个知识库塞进 prompt 行不通
先把这件最根本的事钉死:模型的 context 是一个又小、又贵、且中间部分注意力会衰减的空间;你不能把整个知识库往里塞,塞进去也未必读得到。
下面这段代码,就是我那个会"超 context、烧钱、还答不准"的第一版——它把所有文档无脑拼进 prompt:
from openai import OpenAI
client = OpenAI()
def answer_naive(question: str, all_docs: list[str]) -> str:
# 反面教材:把知识库里【所有文档】拼成一大段塞进 prompt。
knowledge = "\n\n".join(all_docs)
prompt = f"参考下面的资料回答问题。\n资料:\n{knowledge}\n\n问题:{question}"
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
# 三个问题叠在一起:
# 1. 几百篇文档几十万字,远超模型 context 上限,API 直接报错;
# 2. 就算塞得下,每个问题都重发整个知识库,token 费用高到离谱;
# 3. 答案若埋在长文本中段,模型注意力衰减,大概率读不到。
这段代码没有语法错误,在你拿两三篇短文档测试时完全能用。它的问题是一个错误的容量观:它默认"context 是个无限大、不要钱、每个角落都看得一样清楚的口袋"。意识到"不能全塞"之后,我的下一个念头是:那我不全塞,我挑着塞——用问题里的关键词去文档里匹配,匹配上的才塞:
def answer_keyword(question: str, all_docs: list[str]) -> str:
# 反面教材 2:用关键词匹配挑文档,只塞匹配上的。
hits = [d for d in all_docs if any(w in d for w in question)]
knowledge = "\n\n".join(hits)
prompt = f"参考资料回答问题。\n资料:\n{knowledge}\n\n问题:{question}"
# ……调用模型(略)
# 问题:关键词匹配只认【字面】。
# 用户问"报销流程",文档里写的是"费用核销办法"——
# 一个字都对不上,这篇最该命中的文档反而被漏掉了。
return knowledge
这个"关键词匹配"的方案,看着是进了一步,实则踩了另一个坑。关键词匹配只认字面的字符:用户问"报销流程",而那篇正确的文档标题叫"费用核销办法"——两边讲的明明是一回事,可一个字都对不上,匹配直接漏掉。它不理解语义。所以问题的根子清楚了:你需要的不是"把文档全给模型",也不是"按字面挑文档",而是一种能按"意思"找出相关内容的检索能力。这,正是 RAG 要解决的。
二、RAG 的本质:先检索,再生成
上一节的死结是:全塞塞不下、太贵、读不准,按关键词挑又挑不准。RAG(Retrieval-Augmented Generation,检索增强生成)的破局点就一句话:把"回答一个问题"拆成两步——先"检索",从知识库里按语义找出最相关的几个片段;再"生成",只把这几个片段交给模型作答。
它的运作方式,和你去图书馆查资料几乎一模一样。你要写一篇关于某个主题的报告,你不会把整个图书馆的书全搬到书桌上(那是"全塞 prompt"),你也不会只凭书脊上的字恰好有没有你的关键词来挑书(那是"关键词匹配")。你会先查目录、按主题找出真正相关的那几本,搬到桌上,再对着这几本写报告。RAG 就是这个道理:知识库是图书馆,检索是查目录,模型是那个对着选出来的几本书写报告的你。
这里的关键,是检索必须"按语义"而不是"按字面"。"报销流程"和"费用核销办法"字面不同、意思相同,检索要能认出它们相关。做到这一点的办法,就是把文本变成向量:用一个模型把每段文字映射成一个高维空间里的点,语义越接近的文字,对应的点距离越近。于是"找语义最相关的片段",就变成了一个有精确数学定义的问题——"找空间里离问题这个点最近的几个点"。理解了这个,RAG 的整条流水线就清晰了:它要做三件事——把知识库切块、把每块变成向量存起来、问题来了检索出最近的几块。第一件事是:文档怎么切?
三、文档切块:为什么不能整篇直接存
RAG 的第一步,是把知识库里的文档切成小块(chunk)。为什么不能整篇当成一个单位?因为检索的目的,是只把相关的部分喂给模型。一篇文档可能有上万字、讲了七八个主题,你检索时若以"整篇"为单位,命中了它,就得把整篇上万字都塞进 prompt——又回到了"塞太多"的老问题。切块,就是把检索的粒度调到"一小段",让你能精准地只取出那一小段。
def split_into_chunks(doc: str, chunk_size: int = 500) -> list[str]:
"""把一篇文档切成若干小块。这里用最朴素的策略:
按段落聚合,凑够大约 chunk_size 个字就切一块。"""
chunks, buf = [], ""
for para in doc.split("\n"):
para = para.strip()
if not para:
continue
# 当前块再加这一段就超长了 —— 先把当前块收尾
if len(buf) + len(para) > chunk_size and buf:
chunks.append(buf)
buf = ""
buf += para + "\n"
if buf: # 别忘了最后没收尾的那一块
chunks.append(buf)
return chunks
这个切块函数很朴素:沿着段落把文字攒进 buf,攒到快超 chunk_size 就切一刀。它抓住了切块的骨架,但 chunk_size 这个数字怎么定,本身就是个权衡:切得太大,一块里混进好几个主题,检索精度下降,还是塞得多;切得太小,一句话被拦腰截断,一块话意思不完整,模型拿到半句话也答不好。这个权衡和它的改进办法,留到第六节细说。现在先往下走:文档切成块了,下一步——怎么把这些文字块,变成可以"算距离"的向量?
四、把文本变成向量:embedding
把一段文字变成一个向量,这件事叫 embedding(嵌入)。你不用自己实现它——这是一个专门的模型干的活,各家都提供现成的 embedding API。你给它一段文字,它还你一个数字数组(比如 1536 个浮点数),这个数组就是这段文字在高维语义空间里的坐标。
def embed(text: str) -> list[float]:
"""把一段文字变成一个向量(一串浮点数)。
embedding 模型保证:语义越接近的文字,向量越接近。"""
resp = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return resp.data[0].embedding # 比如长度 1536 的浮点数组
embedding 的魔力,全在它的一个承诺上:语义相近的文字,生成的向量在空间里也相近。"报销流程"和"费用核销办法",字面毫无交集,但 embedding 模型读懂了它们的意思,会把它们映射到挨得很近的两个点。这正是它碾压关键词匹配的地方。有了 embed,我们就能在建知识库时,把每一个 chunk 都预先算好向量,连同原文一起存起来——这份"chunk 原文 + 它的向量"的集合,就是 RAG 的索引:
def build_index(all_docs: list[str]) -> list[dict]:
"""建知识库索引:把每篇文档切块,每块预先算好向量。
这一步在【服务启动时/文档更新时】做一次,不是每次提问都做。"""
index = []
for doc in all_docs:
for chunk in split_into_chunks(doc):
index.append({
"text": chunk, # chunk 的原文,最后要喂给模型
"vector": embed(chunk), # chunk 的向量,用来算相似度
})
return index
# index 里每个元素 = 一段原文 + 它的向量。
# 真实项目里这份索引会存进【向量数据库】,这里用内存 list 示意。
注意 build_index 的时机:它是在服务启动时(或文档有更新时)一次性做好的,不是每次用户提问都重算。给整个知识库算 embedding 有成本,你只想付一次。索引建好了,真正高频发生的事是检索——用户问题来了,怎么从这份索引里,快速找出最相关的几块?
五、向量检索:找出最相关的几个片段
检索这一步,要做的事是:把用户的问题也用 embed 变成一个向量,然后在索引里,找出向量离它最近的那几个 chunk。"离得近"用什么衡量?最常用的是余弦相似度——它衡量两个向量方向有多一致,值越接近 1,代表语义越相关。
import math
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""余弦相似度:衡量两个向量方向有多接近。
结果越接近 1,代表两段文字语义越相关。"""
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
有了"算两段文字有多相关"的尺子,检索就水到渠成了:拿问题的向量,和索引里每一个 chunk 的向量都算一次相似度,再从高到低排序,取最高的前 k 个。这个 k,就是 RAG 里常说的 top-k。
def search(question: str, index: list[dict], top_k: int = 3) -> list[dict]:
"""检索:找出和问题语义最相关的 top_k 个 chunk。"""
q_vec = embed(question) # 问题也变成向量
scored = []
for item in index:
score = cosine_similarity(q_vec, item["vector"])
scored.append((score, item))
# 按相似度从高到低排序,取前 top_k 个
scored.sort(key=lambda pair: pair[0], reverse=True)
return [item for score, item in scored[:top_k]]
# 注意:这里是【全量扫描】算相似度,知识库小没问题;
# 块数上万时要换成【向量数据库】,它用专门的索引算法加速。
这个 search,就是 RAG 的心脏。它做的事,正是第二节说的那个有精确数学定义的问题:在语义空间里,找离问题这个点最近的 k 个点。要补一句:这里是把问题和每一个 chunk 都算一遍——知识库小时这样没问题,但 chunk 数量到了几万、几十万,逐个算就太慢了,那时就该换成专门的向量数据库(如 Faiss、Milvus),它们用专门的近似最近邻索引算法把检索加速到毫秒级。检索到了最相关的几块,最后一步——把它们交给模型。这一步看着简单,坑却不少。
六、工程坑:prompt 拼装、chunk 重叠与幻觉防护
把检索结果交给模型,以及把整条 RAG 流水线串起来,藏着几个不处理就出事的坑。坑 1:拼 prompt 时,必须明确命令模型"只依据给定资料回答"。如果你只是把 chunk 往 prompt 里一放,模型很可能无视资料、用它自己训练时记住的旧知识来答——那就失去了 RAG 的意义。要在 prompt 里把规则讲死:
def build_prompt(question: str, chunks: list[dict]) -> str:
"""把检索到的 chunk 拼进 prompt —— 关键是把规则对模型讲死。"""
context = "\n\n---\n\n".join(c["text"] for c in chunks)
return (
"你是知识库问答助手。请【只依据】下面提供的资料回答问题。\n"
"如果资料里没有相关信息,就直接回答「资料中未提及」,\n"
"【不要】用你自己的知识猜测或编造。\n\n"
f"资料:\n{context}\n\n"
f"问题:{question}"
)
那句"资料里没有就回答『资料中未提及』、不要编造",是 RAG 对抗幻觉的关键一招。把 build_prompt、search 串起来,就是完整的 RAG 问答:
def rag_answer(question: str, index: list[dict]) -> str:
"""完整的 RAG 流程:检索 -> 拼 prompt -> 生成。"""
chunks = search(question, index, top_k=3) # 1. 检索最相关的几块
prompt = build_prompt(question, chunks) # 2. 把它们拼进 prompt
resp = client.chat.completions.create( # 3. 让模型基于资料作答
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
# 对比第一节的 answer_naive:这里送进 prompt 的不再是
# 整个知识库,而是【精准命中的 3 段】—— 不超上限、省 token、
# 答案不会被淹没在长文本中段。
坑 2:chunk 要带"重叠",别切得太干脆。第三节那个切块函数,在块与块的边界是一刀两断的。可万一一句关键的话,正好跨在两块的接缝上——上半句在 A 块尾、下半句在 B 块头,那么无论检索命中 A 还是 B,拿到的都是半句话。解法是让相邻的块互相重叠一小段(overlap),把接缝处的话在两块里各留一份完整的:
def split_with_overlap(doc: str, size: int = 500,
overlap: int = 80) -> list[str]:
"""带重叠的切块:每一块的开头,回退 overlap 个字,
和上一块的结尾重叠 —— 避免关键句子被接缝切断。"""
text = doc.replace("\n", " ")
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
坑 3:top-k 取几,是个权衡。top_k 太小(比如 1),万一唯一命中的那块不够全,答案就不完整;top_k 太大,又把一堆不太相关的块也塞了进去,既费 token,又可能用噪声干扰模型。一般从 3 到 5 起步,再按实际效果调。坑 4:检索可能"一个都不相关"。用户问的问题,知识库里压根没有答案——这时 search 仍会返回相似度最高的几块,但它们的相似度其实很低。可以设一个相似度阈值,最高分都低于阈值,就直接告诉用户"知识库里没有",别硬塞给模型让它对着不相关的资料硬编。坑 5:幻觉防不住时,要给出处。就算 prompt 里命令了"不要编造",模型偶尔仍会越界。更稳的办法是让模型在回答里标注这句话出自哪个 chunk,把核对的能力交还给用户。下面这张图,把一次完整的 RAG 问答串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 全塞 prompt 的问题 | 知识库超出 context 上限、token 费用高、答案埋在中段被忽略 |
| 关键词匹配的局限 | 只认字面字符,报销流程和费用核销办法对不上,漏掉该命中的文档 |
| RAG 的本质 | 先检索出最相关的几个片段,再让模型只基于这几个片段生成回答 |
| 文档切块 chunk | 把文档切成小段,让检索粒度细到一小段,只取相关的部分喂模型 |
| embedding 向量 | 用模型把文字映射成高维向量,语义越近的文字向量越近 |
| 余弦相似度 | 衡量两个向量方向的接近程度,越接近 1 语义越相关 |
| top-k 检索 | 把问题和每块算相似度,取分数最高的前 k 块作为参考资料 |
| chunk 重叠 overlap | 相邻块重叠一小段,避免关键句子被块边界拦腰切断 |
| 相似度阈值 | 最高分都低于阈值说明知识库没有答案,直接告知而非硬编 |
| 只依据资料回答 | prompt 里命令模型只用给定资料、没有就说未提及,对抗幻觉 |
避坑清单
- 别把整个知识库塞进 prompt,会超出 context 上限、token 费用极高,且答案埋在中段会被模型忽略。
- 别用关键词匹配挑文档,它只认字面字符,语义相同但用词不同的文档会被整个漏掉。
- RAG 的本质是先检索后生成,把模型的回答依据从一整个知识库收窄成几段精准命中的资料。
- 文档必须切块,整篇当检索单位会让命中后塞进 prompt 的内容又回到太多的老问题。
- chunk 不能太大也不能太小,太大混多个主题精度低,太小一句话被截断意思不完整。
- 用 embedding 把文字变成向量,它能让语义相近用词不同的文字向量也相近,这是检索的基础。
- 知识库索引在服务启动或文档更新时建一次,不要每次提问都重算 embedding,那很费钱。
- 相邻 chunk 要带 overlap 重叠一小段,否则跨在块边界的关键句子检索到的只有半句。
- top-k 一般从 3 到 5 起步,太小答案不全,太大塞入噪声既费 token 又干扰模型。
- prompt 里要命令模型只依据给定资料回答、没有就说未提及,并设相似度阈值防止硬编幻觉。
总结
回头看那次"把整个知识库塞进 prompt、模型却答得驴唇不对马嘴"的事故,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"把所有资料都给模型,它自然会找出答案,给得越全越好"。这句话错在它把模型的 context,当成了一个无限大、不要钱、每个角落都看得一样清楚的口袋。可它根本不是。模型的 context 是一块又小、又贵、还偏心的空间——它装不下一整个知识库(有硬上限),它每装一个字都在花钱(按 token 计费),而且它读长文本时三心二意(中段注意力衰减)。RAG 想清楚的,正是这件事:既然模型的"书桌"就那么大,那解题的关键就不是"把图书馆搬上书桌",而是"先精准地查好目录,只把真正用得上的那几页搬上来"。RAG 这三个字里,真正的重心是 R(检索)——生成是模型本来就会的,而检索得准不准,才决定了这套系统到底行不行。
所以做 RAG,真正的工程量不在"rag_answer 那三行检索-拼装-生成"的主干上。那个主干,一目了然。真正的工程量,在于检索这一路上的每一个"几"都没有标准答案,都得你拿真实数据去磨:chunk 该切多大?——切大了精度掉,切小了意思碎。相邻块要重叠多少?——不重叠就切断句子,重叠太多又冗余。top-k 取几?——取少了答案不全,取多了引入噪声。相似度低到多少就该判定"知识库里没有"?——这个阈值定不好,模型要么硬编、要么该答的不答。这篇文章的几节,其实就是顺着这条流水线展开的:先想清楚为什么不能全塞、RAG 为什么要先检索,再看切块、embedding、向量检索这三段主干,最后是重叠、top-k、阈值、防幻觉这几个把 RAG 真正做对的工程细节。
你会发现,RAG 的思路,和一个靠谱的研究者怎么回答一个专业问题,完全相通。一个不靠谱的人,会凭印象张口就答(这是模型不带检索、直接用训练知识硬答,容易过时、容易编);一个笨拙的人,会把所有相关的书全堆在面前,然后淹没在资料里(这是全塞 prompt)。而一个靠谱的研究者会怎么做?他会先想清楚问题到底在问什么,然后精准地查到那几篇真正相关的文献,只读这几篇,基于读到的内容作答——并且,如果这几篇文献里确实没有答案,他会老实说"现有资料无法回答",而不是编一个。RAG 做的,就是把这套"先检索、再基于检索结果作答、查无此据就承认"的严谨习惯,用代码固化到一个 AI 系统里。
最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你拿三五篇文档、问几个标准问题,有没有切块、检索准不准,效果看起来都差不多。它只在真实的、知识库有成百上千篇文档、用户问题千奇百怪的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,要么被 API 的 context 超限报错卡住,要么收到一张吓人的 token 账单,要么——最糟的——员工问"报销流程",助手一本正经地编了一套公司里根本不存在的流程,误导了人还没人发现。而做对了,它会安安静静地,在用户问出问题的那一刻,从成百上千篇文档里精准地捞出最相关的那三五段,让模型基于这几段给出一个有据可查的回答——员工问"报销流程",得到的是公司制度里白纸黑字写着的那一套,而不是一段编出来的幻觉。所以别等 context 超限的报错和编造的答案找上门,在你第一次想"把资料给模型"的时候就该停下来想清楚:我是要把整个图书馆搬上桌,还是先查好目录、只搬那几页?我的检索,认得出"报销"和"核销"是一回事吗?知识库里真没有答案时,我的系统是会承认,还是会编?这几个问题都有了答案,你的问答助手才不只是 Demo 里那个问标准问题就能答的样子,而是一个无论知识库多大、问题多刁钻,都能稳稳地检索到位、答得有据的可靠系统。
—— 别看了 · 2026