2024 年,我接了个看起来很简单的活:给公司做一个"能回答内部资料问题"的 AI 助手——员工问"我们的报销流程是什么",它能照着公司那几百页的规章制度,准确答出来。我的第一版方案,简单粗暴到我自己都觉得聪明:把那几百页文档,【全部】拼进 prompt 里,后面跟上用户的问题,一起丢给大模型。我心想,大模型这么强,资料都给它了,它还能答不出来?结果第一次运行就报错:context length exceeded——文档太长,把模型的上下文窗口撑爆了。我退而求其次,只塞一部分文档进去,这下不报错了,但新问题来了:用户问"报销流程",我塞进去的恰好是"考勤制度"那几页,模型对着不相干的资料,要么答非所问,要么干脆开始一本正经地胡编。我又试着把文档压缩、做摘要再塞,效果还是飘忽不定。而且每问一次,都要把一大坨文档重新发一遍,token 烧得飞快,一个月账单看得我肉疼。我卡在这里很久,直到一个问题点醒了我:我为什么非要把【整本书】都背给模型听?一个人去考试,也不会把整个图书馆都搬进考场——他只会带【那一本】最相关的参考书,甚至只翻到【那一页】。我缺的,不是一个更大的上下文窗口,而是一个"在海量资料里,精准找到那一页"的能力。这件事逼着我把 RAG(检索增强生成)到底是什么、文档为什么要切分、向量检索怎么靠"语义"找资料、重排和生成怎么做、以及 RAG 的工程选型,彻底理清了一遍。本文是这份梳理的完整复盘。
问题背景:为什么不能把文档全塞进 prompt
需求:做一个 AI 助手,能照着公司内部文档(几百页)准确回答问题
我的第一版(错误)方案:把全部文档拼进 prompt,和问题一起发给模型
踩到的三堵墙:
- ★ 墙 1:上下文窗口装不下
几百页文档 token 数远超模型上下文上限 -> context length exceeded
- ★ 墙 2:塞一部分进去,塞的常常是"不相关"的那部分
用户问报销,塞进去的是考勤制度 -> 模型答非所问 / 开始胡编
- ★ 墙 3:每次提问都重发一大坨文档
token 烧得飞快,账单肉疼,响应还慢
★★ 三堵墙背后是同一个错误认知:
我以为"让模型回答私有知识"= "把私有知识全部喂给它"。
错。真正要做的是:在海量资料里,只【找出那几段最相关的】,
只把这几段喂给模型。—— 这就是 RAG 要解决的事。
★ RAG(Retrieval-Augmented Generation,检索增强生成)一句话:
不微调模型、不把全部资料塞进去,而是【先检索】出和问题
最相关的几小段资料,再把【这几小段】拼进 prompt 让模型回答。
= 给大模型配一场"开卷考试":答题前先翻书,只翻相关的那几页。
★ RAG 的四步流水线(本文逐步拆解):
① 切分(Chunking) :把长文档切成一小块一小块
② 向量化(Embedding):把每一块变成一个能比较"语义"的向量
③ 检索(Retrieval) :问题来了,找出语义最相近的几块
④ 生成(Generation):把这几块 + 问题拼成 prompt,交给模型答
★★ 一个关键认知:RAG 不改模型一个参数。它改变的不是
"模型会什么",而是"模型答题前,手边摆着哪几页纸"。
RAG 是什么:给大模型配一场"开卷考试"
# === ★ 先想清楚:让模型"会"私有知识,到底有几条路 ===
# === ★ 路线对比:微调 vs RAG ===
# ★ 路线 A —— 微调(Fine-tuning):拿你的私有数据去【再训练】
# 模型,把知识"焊"进模型的参数里。
# - 代价:贵、慢、要会调;知识一更新就得重新训练;
# 模型还是会"忘"和"串"(没法保证它精确复述某条规定)。
# ★ 路线 B —— RAG:模型【一个参数都不动】。你另外建一个
# "资料库",每次提问前,先从库里捞出相关资料,塞进 prompt。
# - 代价:要搭一套检索;但知识更新只需更新资料库(秒级)、
# 答案有出处、可控、便宜。
# === ★★ "开卷考试"这个比喻,要吃透 ===
# ★ 闭卷考试 = 纯大模型:它只能凭脑子里"记得"的答。私有
# 知识它从没"学"过,自然答不出,或者瞎编(幻觉)。
# ★ ★ 开卷考试 = RAG:答题前,允许你翻一本【你自己带的
# 参考书】。但考场不让你把整个图书馆搬进去 —— 你得在
# 翻书前,先知道"翻哪一本、翻到哪一页"。
# ★ ★★ RAG 的全部技术含量,就在这"精准翻到那一页"上:
# 它不是"把书给模型",而是"把书里【那一页】给模型"。
# === ★ RAG 为什么能压住"幻觉" ===
# ★ 大模型胡编(幻觉),很多时候是因为它"不知道,但又不肯
# 说不知道",于是顺着语感编一个。
# ★ ★ RAG 把【真实资料】明明白白摆在 prompt 里,并要求
# 模型"只依据给你的资料回答" —— 模型有据可依,就不必
# 编;检索不到资料时,还能让它直接答"我不知道"。
# ★ ★ 附带好处:答案能【标注出处】(来自哪份文档第几段),
# 用户可核对,可信度天差地别。
# === ★ RAG 系统分两个阶段,别混了 ===
# ★ ★ 阶段一 —— 离线建库(Indexing):把你的文档,切分、
# 向量化,灌进一个"向量数据库"。这步【提前做一次】,
# 文档变了才重做。
# ★ ★ 阶段二 —— 在线问答(Querying):用户每来一个问题,
# 就走"检索 -> 拼 prompt -> 模型生成"。这步【每次提问
# 都做】。
# ★ 把这两个阶段分清,后面的工程选型才不会乱。
# === 小结 ===
# ★ 让模型"会"私有知识有两条路:微调把知识焊进参数(贵、
# 慢、知识更新要重训、仍会幻觉),RAG 不动模型一个参数、
# 另建资料库每次提问前检索相关资料塞进 prompt(知识更新
# 只需更新库、答案有出处、可控便宜)。★★ RAG = 给模型
# 配一场开卷考试:不让你把整个图书馆搬进考场,要先精准
# 翻到那一页 —— 全部技术含量就在"精准翻到那一页"。★ 它
# 靠把真实资料摆进 prompt 压住幻觉、还能标出处。系统分
# 两阶段:离线建库(切分+向量化,提前做一次)、在线问答
# (检索+拼 prompt+生成,每次提问都做)。
第一步:文档切分——把知识切成"合适大小"的块
# === ★ RAG 的第一步,也是最被低估的一步:切分(Chunking) ===
# === ★ 为什么必须切,不能整篇存 ===
# ★ ★ 原因 1 —— 检索精度:如果你把一整篇万字长文当成一个
# "块"存进库,那检索时,要么整篇命中、要么整篇不命中。
# 用户问一个具体小问题,你却捞回来一整篇 —— 相关的只有
# 一段,其余全是噪声,还白占 prompt 空间。
# ★ ★ 原因 2 —— prompt 容量:检索出来的块,是要拼进
# prompt 的。块太大,塞两三个就把窗口占满了。
# === ★★ 但切得太碎,也是灾难 ===
# ★ 把文档按"每 50 字一刀"硬切,会把一句完整的话、一个
# 完整的概念,拦腰斩断。检索时捞回半句话,模型看着这
# 半句没头没尾的东西,照样答不好。
# ★ ★ 所以切分的核心矛盾:★ 块要【足够小】(检索才精准)、
# 又要【足够完整】(语义才不断裂)。这是个权衡。
# === ★ 切分的几种常见策略,由糙到精 ===
# ★ ① 定长切分:每 N 个字符一刀。最简单,但最容易切断
# 句子。基本只用于"实在没有结构"的纯文本。
# ★ ② ★ 按结构切分:顺着文档天然的结构 —— 段落、标题、
# Markdown 的 #、列表项 —— 来切。一段是一块,一节是
# 一块。这是【最常用】的,因为天然的结构边界,通常
# 就是语义边界。
# ★ ③ 按句子切分:用标点把文本切成句子,再把相邻句子
# 攒成一块,凑到目标大小为止。
# === ★★ 关键技巧:块与块之间要"重叠"(overlap) ===
# ★ 就算按段落切,也难免有一句关键的话,正好骑在两块的
# 交界处。解法:★ 让相邻的块,【互相重叠一小段】。
# ★ ★ 比如块大小 500 字,重叠设 80 字 —— 那第二块的
# 开头,会把第一块结尾的 80 字【再抄一遍】。这样,
# 骑在边界上的那句话,在两块里都【完整地】出现过,
# 不会被切丢。重叠是低成本、高收益的一招。
# === 小结 ===
# ★ 切分是 RAG 第一步也最被低估。必须切不能整篇存:
# ① 整篇当一块检索要么整篇命中要么不命中,捞回一整篇
# 只有一段相关其余全噪声;② 检索出的块要拼进 prompt,
# 块太大塞两三个就满。★★ 但切太碎也是灾难 —— 按固定
# 字数硬切会把完整句子概念拦腰斩断。核心矛盾:块要
# 足够小(检索精准)又足够完整(语义不断裂)。策略由
# 糙到精:定长切分、★按结构切分(顺段落标题列表,最
# 常用,结构边界通常即语义边界)、按句子攒。★★ 关键
# 技巧是块间留 overlap 重叠一小段,让骑在边界的关键句
# 在相邻两块里都完整出现,不被切丢。
# ★ 文档切分:按段落切,并让相邻块重叠一小段(overlap)
def split_into_chunks(text, chunk_size=500, overlap=80):
# ★ 先按段落(双换行)粗分 —— 顺着文档天然结构
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
chunks, buf = [], ''
for para in paragraphs:
# ★ 攒到一段,加上去还不超 chunk_size,就继续攒
if len(buf) + len(para) <= chunk_size:
buf += para + '\n\n'
else:
if buf:
chunks.append(buf.strip())
# ★★ 关键:新块的开头,带上一块结尾的 overlap 个字
# 骑在边界上的句子,就在两块里都完整出现了
tail = buf[-overlap:] if buf else ''
buf = tail + para + '\n\n'
if buf.strip():
chunks.append(buf.strip())
return chunks
# ★ 每个块,连同它的来源信息一起存 —— 答案要能标出处
def build_chunk_records(doc_name, text):
return [
{'doc': doc_name, 'chunk_id': i, 'text': c}
for i, c in enumerate(split_into_chunks(text))
]
第二步:向量化与检索——靠"语义相似"找到相关块
# === ★ 切好块了,怎么"找出和问题相关的块" ===
# === ★ 为什么不用关键词搜索(像 Ctrl+F) ===
# ★ ★ 关键词搜索,匹配的是【字面】。用户问"怎么报销
# 差旅费",而文档里写的是"出差费用的核销办法" ——
# 一个关键词都对不上,但它俩说的【是同一件事】。
# ★ 私有知识问答,用户的问法和文档的写法,几乎永远
# 对不上字面。要的是按【意思】找,不是按【字】找。
# === ★★ 核心武器:Embedding(向量化)===
# ★ Embedding 模型,能把一段文字,变成一串数字(一个
# "向量",比如 1536 个浮点数)。
# ★ ★★ 它最神奇的性质:【意思相近的文字,变出来的
# 向量,在空间里也挨得近】。"报销差旅费"和"出差
# 费用核销",字面天差地别,但它俩的向量,会离得
# 很近。意思无关的两段话,向量就离得远。
# ★ ★ 于是"按语义找资料"这件玄乎的事,被 Embedding
# 变成了一道实打实的数学题:【算两个向量之间的距离】。
# === ★ 离线阶段:把所有块,向量化、灌进向量库 ===
# ★ 建库时,对每一个文档块,调 Embedding 模型,算出
# 它的向量,然后把(向量 + 块原文 + 出处)一起,
# 存进一个【向量数据库】。
# ★ ★ 向量数据库(如 FAISS、Milvus、pgvector)的专长,
# 就是干一件事:给它一个向量,它能极快地从几百万个
# 向量里,找出【离它最近的 K 个】。
# === ★ 在线阶段:检索就是"算距离、取最近的 K 个" ===
# ★ 用户问题来了,流程是:
# ① 把【用户的问题】,也用【同一个】Embedding 模型,
# 向量化成一个向量;
# ② 拿这个"问题向量",去向量库里查:和它距离最近的
# K 个块向量是哪些(K 一般取 3~5);
# ③ 这 K 个块,就是"和问题最相关的资料"。
# ★ ★ 注意:问题和文档块,【必须用同一个 Embedding
# 模型】向量化 —— 否则两组向量不在一个空间,距离
# 没有意义。这是新手最常翻的车。
# === ★ 衡量"近"的标准:余弦相似度 ===
# ★ 最常用的距离衡量,是【余弦相似度】 —— 看两个向量
# "方向"有多一致。值域 -1~1,越接近 1 越相似。
# ★ 大多数向量库,你不用自己算,指定度量方式即可。
# === 小结 ===
# ★ 找相关块不能用关键词搜索:它匹配字面,而"报销
# 差旅费"和"出差费用核销办法"字面全不同却是一件事,
# 用户问法和文档写法几乎永远对不上字面。★★ 核心
# 武器是 Embedding —— 把文字变成向量,且【意思相近
# 的文字向量在空间里也挨得近】,于是"按语义找资料"
# 变成"算两个向量的距离"这道数学题。离线阶段把每个
# 块向量化连同原文出处灌进向量数据库(FAISS/Milvus/
# pgvector,专长是从百万向量里极快找出最近的 K 个);
# 在线阶段把问题用【同一个】Embedding 模型向量化,
# 去库里取距离最近的 K 个块(K 取 3~5)。★★ 问题和
# 文档块必须用同一个 Embedding 模型,否则向量不在
# 一个空间距离无意义 —— 新手最常翻的车。
# ★ 离线建库:把每个文档块向量化,存进向量库
import numpy as np
def embed(text):
# ★ 调 Embedding 模型,把一段文字变成一个向量
resp = client.embeddings.create(model='text-embedding-3-small', input=text)
return np.array(resp.data[0].embedding, dtype='float32')
def build_index(chunk_records):
index = []
for rec in chunk_records:
rec['vector'] = embed(rec['text']) # ★ 每块算一个向量
index.append(rec)
return index # 真实项目里换成 FAISS / pgvector
# ★ 在线检索:问题向量化,取距离最近的 K 个块
def cosine(a, b):
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def retrieve(index, question, top_k=4):
# ★★ 关键:问题用【同一个】 embed() 向量化,才和块在同一空间
q_vec = embed(question)
scored = [(cosine(q_vec, rec['vector']), rec) for rec in index]
scored.sort(key=lambda x: x[0], reverse=True) # ★ 按相似度降序
return scored[:top_k] # ★ 取最相关的 K 个
第三步:重排与生成——把检索到的资料喂给模型
# === ★ 检索拿到 K 个块之后,还差两步:重排、生成 ===
# === ★ 为什么检索完还要"重排"(Rerank) ===
# ★ ★ 向量检索快,但它的"准",是有上限的:Embedding
# 把一整段话压成一个向量,细节难免丢失。它给出的
# Top-K,里面常混着"看着相关、其实跑题"的块。
# ★ ★ 重排(Rerank)是补这一刀:用一个更精细、但更慢
# 的模型(Cross-Encoder / Rerank 模型),把"问题"
# 和"每一个候选块"成对地、仔细地比一遍,重新打分。
# ★ ★ 标准做法:★ 检索时先用向量库【粗捞】出 20~30 个
# 候选(快、宽);★ 再用 Rerank 模型把这 20~30 个
# 【精挑】出最好的 3~5 个。粗一道、精一道,又快又准。
# === ★★ 生成:prompt 怎么拼,决定答案质量 ===
# ★ 最后一步,是把"精挑出的几个块" + "用户问题",拼成
# 一个 prompt,交给大模型生成答案。这个 prompt 的写法,
# 极其关键,几条铁律:
# ★ ① ★★ 明确指令:"只依据下面提供的资料回答,资料里
# 没有的,就说'根据现有资料无法回答',不要编。"
# —— 这一句,是 RAG 压制幻觉的命门。
# ★ ② ★ 把检索到的资料,清清楚楚地、带着出处地,放进
# prompt(用分隔线、编号标清楚每段来自哪)。
# ★ ③ ★ 要求模型在答案里【标注引用来源】 —— 它答的
# 每个点,来自第几段资料,让用户可核对。
# ★ ④ 资料放前、问题放后,中间用明显的分隔。
# === ★ 一个常被忽略的点:检索不到,要敢说"不知道" ===
# ★ ★ 如果检索回来的块,相似度分数都很低(压根没相关
# 资料),正确的做法【不是】硬把这些不相关的块塞给
# 模型,而是:直接返回"知识库里没有相关内容"。
# ★ 设一个相似度阈值,低于它,就走"答不了"的兜底分支。
# 宁可答"不知道",也别让模型对着烂资料硬编。
# === 小结 ===
# ★ 检索拿到 K 个块后还差两步。① 重排(Rerank):向量
# 检索快但"准"有上限 —— Embedding 把整段压成一个向量
# 丢细节,Top-K 常混着看着相关其实跑题的块;重排用
# 更精细更慢的 Cross-Encoder 把问题和每个候选块成对
# 细比重新打分。★★ 标准做法:向量库先粗捞 20~30 个
# (快、宽),再用 Rerank 精挑 3~5 个(准),粗一道
# 精一道。② 生成:prompt 写法决定答案质量 —— ★★ 明确
# 指令"只依据所给资料回答,没有就说无法回答不要编"
# (压制幻觉的命门)、资料带出处放进 prompt、要求标注
# 引用来源、资料放前问题放后。★ 还要敢说不知道:检索
# 回来相似度都很低就直接返回"知识库没有相关内容",
# 设阈值兜底,别让模型对着烂资料硬编。
# ★ 重排 + 生成:粗捞 -> 精排 -> 拼 prompt -> 让模型作答
SIM_THRESHOLD = 0.35 # ★ 相似度阈值:低于它,判定"没有相关资料"
def answer(index, question):
# ① ★ 粗捞:向量检索宽宽地取 20 个候选
candidates = retrieve(index, question, top_k=20)
# ★★ 检索不到相关资料 —— 敢说不知道,别硬编
if not candidates or candidates[0][0] < SIM_THRESHOLD:
return '根据现有知识库,无法回答这个问题。'
# ② ★ 精排:用 Rerank 模型把 20 个精挑成最好的 4 个
reranked = rerank_model.rank(question, [c[1] for c in candidates])
top = reranked[:4]
# ③ ★ 拼 prompt:资料带出处放前,问题放后
context = '\n\n'.join(
f"[资料{i+1}] (来源:{c['doc']} 第{c['chunk_id']}段)\n{c['text']}"
for i, c in enumerate(top)
)
prompt = (
"你是企业知识库助手。★ 只依据下面提供的资料回答,"
"资料里没有的内容,直接说'根据现有资料无法回答',严禁编造。"
"★ 回答时请标注你引用了哪几段资料。\n\n"
f"=== 资料 ===\n{context}\n\n=== 问题 ===\n{question}"
)
resp = client.chat.completions.create(
model='gpt-4o-mini',
messages=[{'role': 'user', 'content': prompt}],
)
return resp.choices[0].message.content
工程选型:RAG 的几个关键决策
# === ★ 把 RAG 跑通容易,跑好,全在这几个选型上 ===
# === ★ 决策 1:块切多大(chunk_size)===
# ★ ★ 没有标准答案,是个权衡:
# - 块【偏小】(200~300 字):检索精准,命中的噪声少;
# 但单块信息不全,可能要凑好几块才说清一件事。
# - 块【偏大】(800~1000 字):单块上下文完整;但检索
# 精度下降,且容易把 prompt 占满。
# ★ ★ 经验起点:500 字左右、重叠 10%~20%,再按你的
# 文档类型(FAQ 短、技术手册长)和实测效果去调。
# === ★ 决策 2:Embedding 模型怎么选 ===
# ★ ★ 它决定了"语义找得准不准",是 RAG 的地基。看三点:
# - ★ 语言:你的文档是中文,就必须选【对中文友好】的
# Embedding 模型,别用纯英文语料训出来的。
# - ★ 维度与成本:维度高(如 1536/3072)通常更准,但
# 存储和计算更贵。按规模权衡。
# - ★★ 一致性铁律:建库用的 Embedding 模型,和线上
# 查询用的,【必须是同一个】。换模型 = 整个库重灌。
# === ★ 决策 3:向量数据库怎么选 ===
# ★ - 数据量小(几万块)/ 想最快上手:FAISS(库,进程内)、
# 或直接用 pgvector(给你现成的 Postgres 加个扩展)。
# ★ - 数据量大、要分布式、要高可用:Milvus、Qdrant 这类
# 专业向量数据库。
# ★ 别一上来就上重型方案 —— 多数内部知识库,pgvector 够用。
# === ★★ 决策 4:要不要做"混合检索"(Hybrid Search)===
# ★ 纯向量检索,有个短板:它擅长"语义",却可能对【精确
# 关键词】不敏感 —— 比如用户问的是一个【确切的产品
# 型号、错误码、人名】,这种地方,字面精确匹配反而更靠谱。
# ★ ★ 混合检索 = 向量检索(管语义)+ 关键词检索(BM25,
# 管字面),两路结果融合。对"既有概念问题、又有精确
# 术语"的知识库,混合检索通常明显更稳。
# === ★ 决策 5:要不要 Rerank ===
# ★ 数据量小、对延迟极敏感 -> 可以先不上 Rerank,直接用
# 向量 Top-K。★ 但只要你发现"检索回来的块里噪声多、
# 答非所问",上 Rerank(粗捞宽、精排准)几乎总是值得的。
# === 认知 ===
# ★ RAG 跑通容易跑好全在选型:① chunk_size 是权衡 ——
# 偏小检索精准但单块信息不全,偏大上下文完整但检索精度
# 降,经验起点 500 字、重叠 10%~20% 再按文档类型实测调;
# ② Embedding 模型是地基 —— 中文文档必选对中文友好的、
# 维度高通常更准但更贵、★★建库和查询必须用同一个模型
# 换了就整库重灌;③ 向量库 —— 数据量小用 FAISS/pgvector
# 最快上手,量大要分布式才上 Milvus/Qdrant,别一上来上
# 重型;④★★ 混合检索 —— 纯向量擅长语义但对精确关键词
# (型号/错误码/人名)不敏感,向量+BM25 两路融合对又有
# 概念又有术语的库更稳;⑤ Rerank —— 量小对延迟敏感可
# 先不上,一旦发现噪声多答非所问就上,粗捞宽精排准。
RAG 避坑与排查纪律
# === RAG 答得不好,九成不是模型的锅,是检索的锅 ===
# === ★ 排查铁律:答案差,先分清是"检索差"还是"生成差" ===
# ★ ★ RAG 答错,有两种完全不同的病因,先定位是哪种:
# - ★ 病因 A —— 检索差:捞回来的几个块,本身就【不
# 包含】答案。这时模型再强也没用 —— 巧妇难为无米。
# - ★ 病因 B —— 生成差:块里【明明有】答案,但模型
# 没答好(漏了、答偏了、还是编了)。
# ★ ★★ 怎么分:把每次检索【捞回来的原始块,打印出来,
# 人眼看一遍】。答案在块里 -> 是生成差(改 prompt);
# 答案不在块里 -> 是检索差(改切分 / Embedding / 检索)。
# 不做这一步,你就是在瞎调。
# === ★ 坑 1:检索差 —— 多半是切分或 Embedding 的问题 ===
# ★ - 块切太大 -> 一块里混了好几个主题,检索抓不准;
# ★ - 块切太碎 -> 答案被切散在好几块,一次捞不全;
# ★ - Embedding 模型对中文/专业术语不友好 -> 语义找偏。
# === ★ 坑 2:建库和查询用了不同的 Embedding 模型 ===
# ★ ★ 前面强调过,这里再列一次 —— 因为它太致命且太隐蔽:
# 两组向量不在一个空间,检索结果完全是噪声,但程序
# 【不报错】,你只会觉得"RAG 怎么这么蠢"。换过模型,
# 一定整库重灌。
# === ★ 坑 3:文档更新了,索引忘了重建 ===
# ★ RAG 的知识,在【向量库】里,不在模型里。文档改了、
# 新增了,必须重新切分、向量化、更新索引 —— 否则用户
# 拿到的永远是旧知识。要有一套索引更新机制。
# === ★ 坑 4:把"检索不到"硬答成"看似有理" ===
# ★ ★ 知识库里没有的问题,一定要让它走兜底、答"不知道"。
# 最危险的 RAG,是它"对没有依据的问题,也答得头头是道"
# —— 用户会信,然后被坑。设相似度阈值,守住这条线。
# === ★ 坑 5:prompt 里没有"严禁编造"的硬指令 ===
# ★ 只是把资料贴进去、不加约束,模型仍会"参考资料 +
# 自由发挥"。必须明确写死:只依据资料、没有就说没有。
# === ★ 坑 6:只看一两个 case 就下结论 ===
# ★ ★ RAG 效果要靠【一批测试问题】来评估:准备一组
# "标准问题 + 标准答案",每次调完参数,整批跑一遍,
# 看命中率有没有真的变好。别凭手感调。
# === 认知 ===
# ★ RAG 答不好九成不是模型锅是检索锅。★★ 排查铁律:
# 先分清"检索差"还是"生成差" —— 把每次检索捞回的
# 原始块打印出来人眼看,答案在块里就是生成差(改
# prompt),不在块里就是检索差(改切分/Embedding/
# 检索),不做这步就是瞎调。其余坑:检索差多半是切分
# 太大/太碎或 Embedding 对中文术语不友好;★★ 建库和
# 查询用了不同 Embedding 模型 —— 致命且隐蔽,程序不
# 报错只是结果全是噪声;文档更新了忘重建索引,知识在
# 向量库不在模型;把"检索不到"硬答成看似有理最危险,
# 设阈值走兜底;prompt 必须写死"严禁编造";效果要靠
# 一批标准问答整批评估,别凭手感调。
命令速查
RAG 四步流水线
=============================================================
① 切分 Chunking 把长文档切成小块,块间留 overlap 重叠
② 向量化 Embedding 每块算一个向量,意思相近的向量空间里也近
③ 检索 Retrieval 问题向量化,取距离最近的 K 个块
④ 生成 Generation 块+问题拼 prompt,要求只依据资料、标出处
关键参数 / 选型速查
-------------------------------------------------------------
chunk_size 块大小,经验起点 ≈500 字,按文档类型调
overlap 块间重叠,经验 10%~20%,防关键句被切断
top_k 检索取几块,一般 3~5(粗捞可先取 20~30)
Embedding 模型 中文文档选对中文友好的;建库/查询必须同一个
向量数据库 量小 FAISS/pgvector,量大 Milvus/Qdrant
混合检索 向量(语义)+ BM25(精确关键词)两路融合
Rerank 粗捞宽 + 精排准,噪声多/答非所问时上
相似度阈值 低于它判定"无相关资料",走兜底答"不知道"
排查口诀
-------------------------------------------------------------
答得不好,先把检索回来的块打印出来人眼看一遍
答案在块里 -> 生成差,改 prompt(加严禁编造、调指令)
答案不在块里 -> 检索差,调切分 / 换 Embedding / 上混合检索
口诀:RAG 不改模型一个参数,改的是模型答题前手边摆着哪几页纸
答不好九成是检索的锅,不是模型的锅
避坑清单
- 别把整本文档全塞进 prompt,上下文窗口装不下、塞进的常是不相关部分、token 还烧得飞快
- RAG 不微调模型一个参数,它改变的不是模型会什么,而是模型答题前手边摆着哪几页纸
- 文档必须切分,整篇当一块检索要么整篇命中要么不命中,捞回一整篇只有一段相关其余全噪声
- 但也别切太碎,按固定字数硬切会把完整句子和概念拦腰斩断,优先顺着段落标题等天然结构切
- 块与块之间一定要留 overlap 重叠一小段,让骑在边界上的关键句在相邻两块里都完整出现
- 找相关块靠语义不靠关键词,报销差旅费和出差费用核销字面全不同却是一件事,要用 Embedding
- 建库和线上查询必须用同一个 Embedding 模型,用了不同的两组向量不在一个空间检索全是噪声还不报错
- 检索回来相似度都很低就直接答不知道,最危险的 RAG 是对没有依据的问题也答得头头是道
- prompt 里必须写死只依据所给资料回答、没有就说没有、严禁编造,这是 RAG 压制幻觉的命门
- RAG 答不好先把检索回的原始块打印出来人眼看,答案在块里是生成差改 prompt,不在块里是检索差
总结
这一趟把 RAG 彻底理清的过程,纠正了我一个关于"让 AI 变聪明"的、藏得很深的错觉。在我做第一版方案的时候,我脑子里那个等式是天经地义的:想让大模型回答我的私有知识,那我就得想办法,把这些私有知识【装进它脑子里】——要么把文档全塞进 prompt(我以为这是"把书递给它"),要么去微调它(我以为这是"让它把书背下来")。我所有的努力,方向都是同一个:【往模型里灌】。灌不进去(窗口爆了),我就想办法压缩了再灌;灌进去的不对,我就想办法换一批再灌。我从来没怀疑过"灌"这个动作本身——在我看来,模型要"会"一个知识,这个知识就【必须在模型里面】。直到我被那三堵墙撞得头破血流,直到那个"考生不会把整个图书馆搬进考场"的念头击中我,我才忽然看清:我一直在解一道根本不必解的题。我费尽心思想把图书馆塞进模型脑子,可真正聪明的做法,是【让模型脑子保持空的,但给它一个随时能查的图书馆】。模型不需要"记住"我的知识,它只需要在【被问到的那一刻】,手边正好摆着【那一页纸】。复盘到最深,我意识到我混淆了两种截然不同的"拥有知识"的方式。一种是【记忆】——把知识焊进自己的脑子里,这是微调干的事,代价是昂贵、僵硬、知识一变就得重来。另一种是【检索】——脑子里不存,但建立一套"在需要时精准找到它"的能力,这是 RAG 干的事。而我过去,下意识地以为只有第一种才算"真的会"。可仔细想想,人类专家又何尝不是如此?一个顶尖的律师,不是把所有法条都背得滚瓜烂熟,而是他知道"这个问题该查哪部法、翻到哪一条";一个好医生,手边永远摆着最新的指南。他们的"专业",从来不全是"记忆量",更多是一种"精准检索"的能力。我一直想把我的 AI 训练成一个"什么都背下来的学霸",而 RAG 告诉我,我真正该做的,是把它训练成一个"知道去哪里查、并且查得又快又准"的专家。这个认知的转弯,影响了我后来看很多问题的角度:解决一个问题,我不再下意识地问"我要把多少东西准备好、装进去",而是先问"这东西,有没有可能不必装进去,而是用的时候再去取";一个系统要"知道"很多状态,我不再总想着把状态都塞进内存常驻,而会想"哪些可以是按需查询的"。"全部装进来"是一种很有诱惑力的笨办法——它直观,它让你觉得踏实,但它几乎总是最贵、最不灵活的那一个。这次最大的收获,是我给自己换了一道思考的起手式:当我要让一个系统"具备"某种知识或能力时,我不再先问"怎么把它灌进去",我先问"它到底需不需要被灌进去——还是说,我只要给它一条'用时能精准取到'的路就够了"。RAG 这三个字母教给我的,从来不是一套检索的技术拼装,而是一个朴素到我绕了一大圈才真正服气的道理:让一个东西变强,办法不一定是"把更多塞进它内部";更高明的那条路,常常是"让它内部保持轻盈,却为它接上一个又大又准的外部"。模型不必是图书馆,模型只需要,会查图书馆。
—— 别看了 · 2026