2024 年我做一个公司内部的知识库问答系统:把产品手册、技术文档、历史工单几百份资料喂给大模型,让员工能用大白话问问题,模型从这些资料里找出答案。第一版我的想法特别直接:既然要让模型"根据我们的文档回答",那就把所有文档拼成一大段文字,塞进 prompt,后面跟上用户的问题,一起发给模型。本地拿三五份文档测,效果好得让我兴奋。可一接上真实的文档库,第一个请求就报错了——几百份文档拼起来的文本,token 数量远远超过了模型的上下文窗口上限,API 直接拒绝。我退而求其次,改成"只塞前面一部分文档",这下不报错了,但新问题来了:用户问的内容,答案明明在第 200 份文档里,可我只塞了前 30 份,模型压根没看到那段资料,于是它要么回答"我不知道",要么干脆编一个看起来合理的答案。我一开始以为是"塞的文档不够多",可文档再多也塞不下——上下文窗口就那么大。后来我才彻底想明白:让模型"基于我们的知识库回答",真正的难点根本不是把文档喂给模型,而是在海量文档里,先精准地找出和这个问题相关的那几小段,只把这几小段喂给模型。模型不会、也不可能"读完整个文档库再回答"——它的上下文窗口是有限的,你能给它的,永远只是一小撮资料。所以问题的核心,从"怎么把文档给模型"变成了"怎么从一堆文档里检索出最相关的片段"。这个"先检索、再把检索结果喂给模型生成回答"的模式,就是 RAG(Retrieval-Augmented Generation,检索增强生成)。我以为 RAG 不过是"搜一下文档再拼进 prompt",结果真做下来坑一个接一个:用关键词搜根本搜不准、文档怎么切成片段、片段怎么变成能比较相似度的东西、检索回来一堆不相关的怎么办……那次之后我才认真把 RAG 从头搞明白。这篇文章就把它梳理一遍:为什么需要 RAG、关键词检索为什么不够、文本怎么向量化做语义检索、文档怎么切分、一个完整的 RAG 流程怎么转,以及把 RAG 真正做好要避开的那些坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:做一个基于公司内部文档的知识库问答,第一版把所有文档拼进 prompt 一起发给模型——文档一多,拼出来的文本 token 数超过模型上下文窗口上限,请求直接报错;改成只塞一部分文档,又导致答案所在的文档没被塞进去,模型答不出或干脆编造。
我当时的错误认知:"要让模型基于我们的知识库回答,就是把文档都喂给它;塞得越多,它知道的越多,答得越准。"
真相:模型的上下文窗口是有限的,你不可能把整个文档库都塞进去;而且即使塞得下,把大量不相关的文档一起喂进去,也会干扰模型、稀释它的注意力。正确的做法是 RAG:把文档库预处理好(切成片段、转成向量、建索引),用户提问时,先检索出和这个问题最相关的少数几个片段,只把这几个片段连同问题一起喂给模型,让模型基于这几个片段生成回答。RAG 真正的工程量,几乎全在"检索"这一端——检索得准不准,直接决定了答案对不对。
要把 RAG 做好,需要几块认知:
- 为什么不能把文档全塞进 prompt,RAG 到底在解决什么;
- 为什么"关键词检索"做知识库问答不够用;
- 文本怎么变成向量,怎么靠向量做"语义检索";
- 文档为什么要切分、怎么切才合适;
- 一个完整的 RAG 流程怎么串起来,以及检索召回、重排、引用这些工程坑怎么处理。
一、为什么需要 RAG:上下文窗口装不下知识库
先把这件最根本的事钉死:大模型不会"读"你的文档库。
模型的知识,是在训练时"固化"进参数里的,训练截止之后的事、你公司内部的私有文档,它从来没见过。要让它基于你的文档回答,唯一的途径是把文档内容放进 prompt——而 prompt 的长度,被模型的上下文窗口死死卡住。一个文档库动辄几百上千份文档,拼起来的 token 数,轻松就是上下文窗口的几十倍、上百倍。下面这段代码,就是最典型的"全塞进去"的土办法:
def build_prompt_dump(documents: list[str], question: str) -> str:
# 反面教材:把整个文档库一股脑拼进 prompt。
all_text = "\n\n".join(documents)
return f"以下是全部文档:\n{all_text}\n\n请根据上面的文档回答:{question}"
# 问题:documents 稍微多一点,all_text 的 token 数就远远超过
# 模型的上下文窗口上限 —— 请求直接被 API 拒绝。
# 就算侥幸塞得下,大量不相关文档也会干扰模型,让它抓不住重点。
这段代码的错,不在某一行语法,在它的根本假设:它假设"模型能一次性消化整个文档库"。这个假设和模型的物理限制(上下文窗口)直接冲突。而且就算你的文档库小到能勉强塞下,这也不是好做法——把几百段不相关的文字一起丢给模型,等于让它在一大堆噪音里找那一句答案,它的注意力会被稀释,答得反而更差。
所以正确的方向很清楚:不要把整个文档库给模型,要先筛选——在用户提问的那一刻,从文档库里挑出和这个问题最相关的几小段,只把这几小段给模型。模型要的不是"全部资料",而是"恰好够用的、相关的资料"。这个"筛选"动作,就是 RAG 里的"检索"(Retrieval)。那么问题来了:怎么筛?
二、关键词检索的局限:用户的问法千变万化
说到"从文档里找相关内容",第一反应往往是关键词匹配:把用户问题里的词,拿去文档里找,谁包含这些词,谁就相关。
def keyword_search(documents: list[str], query: str) -> list[str]:
# 反面教材:靠关键词字面匹配来检索。
hits = []
for doc in documents:
if query in doc: # 文档里必须【一字不差】地包含这句话
hits.append(doc)
return hits
# 问题:用户问"怎么把密码改掉",文档里写的是"重置登录凭据"——
# 一个字都对不上,这个最该命中的文档,反而一条都搜不到。
这种检索方式的根本问题是:它匹配的是字面,而人的语言是千变万化的。同一个意思,用户可能说"改密码",文档里可能写"重置登录凭据";用户问"系统卡",文档标题是"性能优化指南"。它们语义上高度相关,但字面上一个字都不挨着。关键词检索面对这种情况完全无能为力——它不理解"密码"和"登录凭据"是一回事。
就算你改进一下,不要求整句匹配,改成"问题里的词命中得越多越相关",也只是缓解,治不了根。因为它的能力天花板,就卡在"它只会比字,不会比意思"。而知识库问答,恰恰最需要"比意思":用户用他自己的话问,文档用作者的话写,两边几乎永远对不齐字面。
所以我们真正需要的检索,得能理解语义——能判断出"改密码"和"重置登录凭据"说的是同一件事。要做到这一点,得先解决一个更基础的问题:怎么让"语义的相似度"变成一个计算机能算的数字。这就是下一节的向量化。
三、向量化与语义检索:把"意思"变成可计算的向量
让计算机理解语义,核心手段是 embedding(向量化):用一个专门的模型,把一段文本转换成一串数字组成的向量。这个向量的奇妙之处在于——意思相近的文本,转出来的向量在空间里也"挨得近";意思无关的文本,向量则"离得远"。"改密码"和"重置登录凭据"字面毫不相干,但它们的向量会非常接近。
把文本转成向量,通常调用一个 embedding 模型的 API 即可:
from openai import OpenAI
client = OpenAI()
def embed(text: str) -> list[float]:
"""把一段文本转成向量 —— 这串数字就是这段文本的"语义坐标"。"""
resp = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
# 返回的是一串浮点数(比如 1536 维),意思相近的文本向量也相近
return resp.data[0].embedding
有了向量,"两段文本意思有多近"就变成了"两个向量有多近"——一个纯数学问题。最常用的度量是余弦相似度:它算的是两个向量"方向"的接近程度,结果在 -1 到 1 之间,越接近 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)) # a 的模长
norm_b = math.sqrt(sum(x * x for x in b)) # b 的模长
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
到这里,"语义检索"的原理就完整了:把文档库里每一段文本都 embed 成向量、存起来;用户提问时,把问题也 embed 成向量,然后逐一算它和每段文档向量的余弦相似度,相似度最高的几段,就是和问题语义最相关的几段。这套机制,彻底绕开了"比字面"的死胡同——它比的是"意思"。但在真正建索引之前,还有一个绕不开的前置问题:文档不能整篇整篇地去向量化,得先切开。
四、文档切分:切太大检索不准,切太小丢上下文
为什么不能把一整篇文档作为一个整体去 embed?两个原因。其一,一篇长文档可能讲了五六个主题,把它压成一个向量,这个向量就成了五六个主题的"平均",对哪个主题都不够精准。其二,就算检索命中了,你也只能把整篇长文档塞进 prompt——又回到了"塞不下"的老问题。
所以文档要先切分(chunking):把长文档切成一个个小片段(chunk),每个片段单独 embed、单独检索。最朴素的切法是按固定长度切:
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 80) -> list[str]:
"""把长文本切成带重叠的片段。overlap 让相邻片段共享一段内容。"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
# 关键:下一段的起点往回退 overlap 个字 —— 让相邻片段重叠
start = end - overlap
return chunks
这里有两个参数,背后都是权衡。chunk_size(片段大小):切得太大,一个片段塞进了多个主题,检索就不精准,又回到"一个向量代表太多东西"的问题;切得太小,一句话被拦腰截断,片段丢失了上下文,单独看根本看不懂(比如把"它"和"它指代的东西"切到了两个片段里)。overlap(重叠):为什么相邻片段要重叠一段?因为切分是机械的,很可能正好把一个完整的句子、一个关键的因果关系切成两半。让相邻片段共享一小段内容,就能缓解"答案恰好被切在边界上、哪个片段都不完整"的尴尬。
切分没有一套万能参数。文档结构性强(比如有清晰的小标题),最好按结构切(按段落、按小节),而不是机械地按字数切——按结构切,每个片段天然是一个完整的语义单元。这是 RAG 里很影响效果、却最容易被敷衍的一步。
五、完整的 RAG 流程:索引、检索、生成
前面的零件都齐了:切分、向量化、相似度。现在把它们串成一个完整的 RAG。整个流程分两个阶段——离线建索引和在线问答。
先看建索引。我们需要一个地方存放所有片段和它们的向量,这就是向量存储。这里手写一个最简单的,看清它的内核——它要能"加片段"和"按相似度检索":
class VectorStore:
"""一个最简向量存储:存片段+向量,支持按语义相似度检索。"""
def __init__(self):
self._items: list[tuple[str, list[float]]] = [] # (片段文本, 向量)
def add(self, text: str, vector: list[float]):
self._items.append((text, vector))
def search(self, query_vector: list[float], top_k: int = 3):
"""算 query 和每个片段的相似度,返回最相关的 top_k 个。"""
scored = [(cosine_similarity(query_vector, vec), text)
for text, vec in self._items]
scored.sort(key=lambda x: x[0], reverse=True) # 相似度从高到低
return scored[:top_k]
建索引,就是把每篇文档切分、每个片段向量化、再一起存进这个 store。这一步是离线做的——文档库不变,索引就只建一次,之后所有问答都复用它:
def build_index(documents: list[str]) -> VectorStore:
"""离线建索引:把每篇文档切片、向量化,全部存进向量存储。"""
store = VectorStore()
for doc in documents:
for chunk in chunk_text(doc): # 切片
vec = embed(chunk) # 向量化
store.add(chunk, vec) # 入库
return store
索引建好,就进入在线问答。这一步才是 RAG 的"R + G"合体:先用问题的向量去 store 里检索(Retrieval)出最相关的几个片段,再把这几个片段拼进 prompt、交给模型生成(Generation)回答。拼 prompt 时有个关键约束要写进去——只许根据给定资料回答,资料里没有就说没有,不准编:
def build_rag_prompt(question: str, chunks: list[str]) -> str:
"""把检索到的片段和问题拼成 prompt,并约束模型只能据此回答。"""
context = "\n\n".join(f"[资料 {i + 1}]\n{c}"
for i, c in enumerate(chunks))
return f"""请【只】根据下面的资料回答问题。
如果资料里没有答案,就直接回答"资料中未提及",绝对不要编造。
{context}
问题:{question}
"""
def answer(store: VectorStore, question: str) -> str:
"""完整的 RAG 在线问答:检索相关片段 -> 拼 prompt -> 模型生成。"""
q_vec = embed(question) # 1. 问题向量化
hits = store.search(q_vec, top_k=3) # 2. 检索最相关的片段
chunks = [text for score, text in hits]
prompt = build_rag_prompt(question, chunks) # 3. 拼进 prompt
resp = client.chat.completions.create( # 4. 交给模型生成
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
这段 answer 就是文章开头那个问题的正解。对比一下:土办法是"把全部文档塞进 prompt",而 RAG 是"先检索出最相关的 3 段,只塞这 3 段"。无论你的文档库有 100 份还是 100 万份,喂给模型的永远只是那几段——token 量被稳稳控制住,模型的注意力也聚焦在真正相关的内容上。build_rag_prompt 里那句"资料里没有就说没有、不准编",同样关键:它把模型从"凭印象作答"约束回"基于给定资料作答",这正是 RAG 能压制幻觉的原因——答案有据可查。
六、工程坑:召回质量、重排、引用与更新
主干通了,但要把 RAG 真正做好,还有几个绕不开的工程坑。
坑 1:检索召回质量,是 RAG 的命门。RAG 的回答质量,有一条铁律:检索不到,模型就答不出。如果相关片段根本没被 search 召回,后面的模型再强也没用——它手里压根没有那段资料。所以最该投入精力的不是调 prompt,而是检索。一个实用的手段是给检索结果设一个相似度阈值:相似度太低的片段,说明它和问题其实不相关,与其塞进去当噪音,不如直接丢掉。
def search_with_threshold(store: VectorStore, question: str,
top_k: int = 5, min_score: float = 0.3):
"""带阈值的检索:相似度太低的片段宁可不要,免得当噪音干扰模型。"""
q_vec = embed(question)
hits = store.search(q_vec, top_k=top_k)
good = [(score, text) for score, text in hits if score >= min_score]
if not good:
# 一个够格的片段都没有 —— 知识库里很可能真没有这个问题的答案
return []
return good
坑 2:向量检索召回快,但不够"准",需要重排(rerank)。向量相似度检索很快,适合从海量片段里粗筛出一批候选(比如 top 20),但它对"哪个最相关"的排序并不够精细。生产级 RAG 常加一道重排:用一个更精细、但更慢的模型(rerank 模型),对这 20 个候选重新打分排序,取真正最好的 top 3 喂给模型。"向量粗筛一大批 + 重排精选一小撮",是 RAG 提升召回质量的标准套路。
坑 3:答案要带"出处",可追溯。RAG 相比直接问模型,一个巨大的优势是答案有据可查。你应该在检索时一并记下每个片段来自哪篇文档,生成回答时让模型标注引用,最终把"答案 + 出处"一起给用户。这样用户能自己核对,你也能在答错时快速定位是哪段资料的问题。一个连出处都给不出的 RAG,可信度会大打折扣。
坑 4:索引会过时,要能更新。文档库不是一成不变的——产品手册会改、新工单会进来。如果你的向量索引还是上个月建的,那用户问到新内容时,RAG 自然检索不到。所以要有一套索引更新机制:文档新增或修改时,把对应的片段重新切分、重新向量化、更新进向量库。索引的新鲜度,和检索算法本身一样重要。下面这张图,把 RAG 离线建索引和在线问答两个阶段串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| RAG | 检索增强生成:先从文档库检索相关片段,再把片段喂给模型生成回答 |
| 上下文窗口限制 | 模型能接收的 token 有上限,整个文档库根本塞不进 prompt |
| 关键词检索的局限 | 只比字面,用户问法和文档用词对不上就检索不到,无法理解语义 |
| embedding 向量化 | 把文本转成向量,意思相近的文本向量在空间里也相近 |
| 余弦相似度 | 用向量方向的接近程度衡量两段文本的语义相似度,越接近 1 越相近 |
| 文档切分 chunking | 长文档切成小片段单独索引,切太大不精准,切太小丢上下文 |
| chunk 重叠 | 相邻片段共享一小段内容,缓解答案恰好被切在边界上的问题 |
| 向量检索 | 问题向量和片段向量算相似度,取最相关的 top_k 个片段 |
| 相似度阈值 | 相似度太低的片段直接丢弃,避免不相关内容当噪音干扰模型 |
| 重排 rerank | 向量粗筛一大批候选,再用更精细的模型重排精选,提升召回质量 |
避坑清单
- 模型上下文窗口有限,整个文档库根本塞不进 prompt;塞得下也会因大量噪音稀释注意力而答得更差。
- RAG 的核心不是把文档喂给模型,而是先从海量文档里精准检索出最相关的几小段,只喂这几段。
- 关键词检索只比字面,用户的问法和文档用词几乎永远对不齐,语义相关但字面不同的内容会被漏掉。
- 用 embedding 把文本转成向量,意思相近的向量也相近,语义检索靠的是算向量相似度而非匹配字面。
- 文档必须切分后再索引:切太大一个向量代表太多主题不精准,切太小片段丢失上下文单独看不懂。
- 相邻片段要留重叠(overlap),缓解关键句子或因果关系被机械切分拦腰截断的问题。
- 文档结构性强时优先按段落、小节切分,让每个片段是完整语义单元,效果远好于机械按字数切。
- 检索召回是 RAG 的命门:相关片段没被召回,模型再强也答不出;给检索设相似度阈值过滤噪音。
- 向量检索快但排序不够精细,生产级 RAG 要加重排:向量粗筛一大批,再用精细模型重排选 top 几。
- 拼 prompt 要约束模型只根据给定资料回答、没有就说没有;答案带文档出处;索引要随文档更新保持新鲜。
总结
回头看那个"把整个文档库塞进 prompt、token 直接爆了"的知识库问答,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"要让模型基于我们的知识库回答,就是把文档都喂给它"。这句话错在它没意识到模型有一道物理边界:上下文窗口。你的文档库可以无限增长,但模型一次能"看"的内容,是固定的、有限的。这个矛盾决定了 RAG 必然的形态——不是"把知识给模型",而是"在提问的那一刻,从知识里精准取出够用的一小撮,再给模型"。
所以做 RAG,真正的工程量不在"调用一次模型生成回答"那一下。把检索到的片段拼进 prompt、调 chat.completions,这部分 Demo 里谁都能跑通。真正的工程量在"检索"这一端:文档怎么切分成既精准又不丢上下文的片段?用什么模型做向量化?相似度算出来一批候选,怎么重排出最该用的那几个?检索回来的东西够不够相关,你设阈值过滤了吗?文档更新了,你的索引跟上了吗?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚为什么文档库塞不进 prompt,再看关键词检索为什么不够,然后是向量化、文档切分这两个地基,接着是完整 RAG 流程的串联,最后是召回质量、重排、引用、更新这几个把 RAG 真正做好的工程细节。
你会发现,RAG 的思路和我们做任何"在海量数据里回答问题"的工程经验都是相通的。一个人面对一个他不熟悉的专业问题,他不会、也不可能把整座图书馆都读一遍再回答——他会先查目录、查索引,精准翻到相关的那几页,读完那几页,再组织语言回答。RAG 做的就是同一件事:向量库是那本"语义索引",检索是"翻到相关的几页",模型是那个"读完相关几页后组织语言回答的人"。模型负责它擅长的"理解和表达",而"该读哪几页",是检索系统的职责——这两件事分开,各自做好,才是 RAG。
最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你就三五份文档,问的也都是文档里有明确答案的问题,检索随便搜搜都能命中,跑起来漂亮极了。它只在真实的大文档库、真实千奇百怪的用户问法、真实持续更新的资料面前才显形。那时候它会用最难堪的方式给你结账:用户问的内容明明在文档里,RAG 却检索不到、回一句"资料中未提及";或者检索回来一堆不相关的片段,模型被带偏、给出一个似是而非的错误答案;又或者用户拿着上个月的旧政策去做了决定,因为你的索引几周没更新了。所以别等用户拿着错误答案来投诉,在你建第一个索引的时候就该想清楚:文档我切得合理吗?用户那样问,我检索得到吗?检索回来的片段,真的相关吗?文档更新了,我的索引跟上了吗?这几个问题都有了答案,你的知识库问答才不只是 Demo 里那个对答如流的演示,而是一个无论文档库多大、用户怎么问,都能精准检索、有据回答的可靠系统。
—— 别看了 · 2026