2024 年,我接了一个看起来不难的活:给公司做一个【内部知识问答助手】。需求很朴素——员工问"年假怎么算""报销流程是什么""A 产品的某个参数是多少",它能照着公司的文档,准确地答出来。我手里有一大堆资料:员工手册、几套产品文档、一堆制度说明。我的第一版方案,简单粗暴:把【所有这些文档】,一股脑全拼进 系统提示词 里,后面再跟上用户的问题——我想,把全部资料都给它,它总该能答对了吧?结果一跑,问题接二连三。第一,资料拼起来有二十多万字,远远超过了模型的上下文窗口,我只能粗暴地截断,一截断,后半部分的文档就【彻底丢了】。第二,就算是能塞进去的那部分,每问一个问题,我都要为这二十多万字的输入【付一次钱】,一天下来 token 账单贵得我肉疼。第三,也是最让我意外的:就算某次资料恰好没被截断、相关段落确实在里面,模型【还是会答错】——它会在那一大堆文字里"迷失",把那个真正相关的段落看漏,然后开始编。我不死心,又试了另一条路:拿这些文档去【微调】一个模型,想让它把这些知识"学进脑子里"。微调更折腾——贵、慢,而且公司文档每周都在改,我总不能每周重新微调一次;更糟的是,微调完它【照样会编】。我卡在这里很久,直到想明白一件最朴素的事:我一直在试图让模型【记住】公司的全部知识——要么靠塞进 prompt"临时背一遍",要么靠微调"长期记进脑子"。可我真正需要的,根本不是让它"记住一切",而是让它在【被问到的那一刻】,能去【查一下】,精准地找到那相关的三五段话,然后【只把这三五段】递到它面前。这件事逼着我把"塞 prompt"和"微调"为什么都不对、RAG(检索增强生成)到底是什么、它那个"离线建库 + 在线检索"的链路怎么搭、以及工程上的那些坑,彻底理清了一遍。本文是这份梳理的完整复盘。
问题背景:一个"把整个文档库塞进 prompt"的问答助手
需求:做一个内部知识问答助手,照着公司文档准确回答员工提问
我的方案 A:把【所有公司文档】一股脑拼进系统提示词
遇到的三个问题:
- ★ 文档拼起来 20 多万字,远超模型上下文窗口 -> 只能粗暴截断
-> 后半部分文档彻底丢失
- ★ 每问一个问题,都为这 20 多万字输入付一次钱 -> token 账单极贵
- ★★ 就算相关段落没被截断、确实在里面,模型还是会"迷失"在
一大堆文字里,看漏它,然后开始编
我的方案 B:拿文档去微调一个模型,让它把知识"学进脑子"
- ★ 微调贵、慢
- ★ 公司文档每周都在改,不可能每周重新微调一次
- ★★ 微调完,它照样会编
★★ 想明白的根:我一直在试图让模型【记住】公司的全部知识 ——
塞 prompt 是"临时背一遍",微调是"长期记进脑子"。
可我真正需要的,不是让它"记住一切",而是让它在被问到
的那一刻,能去【查一下】,找到相关的三五段话,
然后【只把这三五段】递到它面前。
★ "查一下、找到相关的、只把相关的给它" —— 这就是 RAG
(Retrieval-Augmented Generation,检索增强生成)。
★ 本文要做的:把塞 prompt 和微调为什么都不对、RAG 的
完整链路、以及工程上的坑,彻底讲透。
为什么"塞 prompt"和"微调"都解决不了问题
# === ★ 先彻底搞清:我那两条路,各自错在哪 ===
# === ★ 错误一:把全部文档"塞进 prompt" ===
# ★ ★ 问题 1 —— 上下文窗口装不下。模型一次能"读"的
# 文字量(上下文窗口)是有上限的。你的知识库稍微大
# 一点,就根本塞不进去,只能截断 —— 截断 = 丢知识。
# ★ ★ 问题 2 —— 贵。模型按输入的 token 量收费。你每问
# 一句话,都把整个知识库重新付费"读"一遍,绝大部分
# 内容和这个问题【毫不相关】,纯属浪费。
# ★ ★ 问题 3 —— "迷失在中间"(lost in the middle)。
# 就算塞进去了,当上下文特别长时,模型对【中间部分】
# 的注意力会明显下降。那个真正相关的段落,如果不巧
# 在中间,很容易被它【看漏】。
# ★ 结论:把"无关的一大堆"和"相关的一点点"混在一起喂,
# 不仅贵、装不下,还会真的拖垮回答质量。
# === ★ 错误二:用文档去"微调"模型 ===
# ★ ★ 这是一个更深的误解。很多人以为微调能给模型
# "注入新知识"。★★ 但微调真正擅长的,是教模型一种
# 【风格、格式、行为模式】 —— 比如"用更专业的语气"
# "总是输出 JSON" —— 它教的是【怎么说】,不是【说什么】。
# ★ ★ 指望微调"记住事实",效果很差:① 它学得不牢,
# 照样会把细节记错、张冠李戴;② 知识一更新,就得
# 重新微调,极其笨重;③ 微调本身贵、慢、要数据要算力。
# ★ 结论:微调改的是模型的"能力和风格",不是它的
# "知识库"。拿它当知识库用,是用错了工具。
# === ★★ 关键区分:"知识"和"能力",是两回事 ===
# ★ ★ 这是想通这一切的钥匙。一个模型身上有两样东西:
# - ★【能力】:它理解语言、推理、组织表达的本事 ——
# 这是它【与生俱来】的,微调可以微微调整它。
# - ★【知识】:它回答某个具体问题所需要的【事实材料】
# —— 你公司的年假政策、某产品的参数。
# ★ ★★ 这两样东西,就【不该】用同一种方式处理。能力,
# 长在模型里;而知识,尤其是【会变的、私有的】知识,
# 就【不该硬塞进模型】 —— 它应该待在一个【外部的、
# 随时能更新的知识库】里,等到需要时,再【取出来】
# 交给模型的"能力"去处理。
# === ★ 于是,正确的思路浮现了 ===
# ★ 别让模型"记住"知识。让模型保持它的"能力",然后
# 在它每次回答前,【从外部知识库里,检索出和当前
# 问题相关的那一小部分知识】,临时递给它。
# ★ ★ 模型负责"理解和表达"(它的能力),知识库负责
# "提供准确的事实"(外部的、可更新的) —— 各司其职。
# 这,就是 RAG 的全部出发点。
# === 小结 ===
# ★ 塞 prompt 三个错:① 上下文窗口装不下知识库稍大就
# 要截断丢知识;② 贵,每问一句都把整个知识库重新付费
# 读一遍绝大部分无关;③ "迷失在中间",上下文特别长时
# 模型对中间部分注意力下降,相关段落在中间易被看漏。
# ★ 微调的错:微调真正擅长的是教模型风格格式行为模式
# (教"怎么说"),不是注入新知识(不是"说什么");
# 指望它记事实学得不牢、知识一更新就要重新微调、本身
# 贵慢。★★ 钥匙是区分"知识"和"能力":能力是模型理解
# 推理表达的本事与生俱来长在模型里,知识是回答具体
# 问题所需的事实材料 —— 会变的私有的知识不该硬塞进
# 模型,该待在一个外部的随时能更新的知识库里需要时
# 取出来。★ 正确思路:别让模型记住知识,在它每次回答
# 前从外部知识库检索出和当前问题相关的那一小部分临时
# 递给它 —— 模型负责理解表达,知识库负责提供准确事实,
# 这就是 RAG 的出发点。
RAG 的核心思路:先检索,再增强,后生成
# === ★ 把 RAG 这件事的本质,一次说清 ===
# === ★ RAG 三个字母,就是它的三个步骤 ===
# ★ ★ R —— Retrieval(检索):用户的问题来了,先不急着
# 让模型回答。先拿这个问题,去外部知识库里【搜一搜】,
# 找出和它【最相关】的几段文字。
# ★ ★ A —— Augmented(增强):把刚检索到的那几段文字,
# 连同用户的原始问题,一起拼成一个新的 prompt —— 用
# 这几段"参考资料"去【增强】这次提问。
# ★ ★ G —— Generation(生成):把这个"问题 + 参考资料"
# 的 prompt 发给模型,让它【依据给定的参考资料】,
# 生成最终回答。
# ★ 一句话:RAG = 带着一份"刚查到的、相关的小抄"去问模型。
# === ★ RAG 为什么能根治前面那三个问题 ===
# ★ ★ 治"装不下 / 贵":每次只把【相关的三五段】(可能
# 就几百字)塞进 prompt,而不是整个知识库。又省钱、
# 又绝不会超窗口。
# ★ ★ 治"迷失 / 编造":递给模型的,是一份【高度相关、
# 且很短】的参考资料。模型不用在汪洋大海里捞针,它
# 照着这份小抄答就行 —— 准确率大幅提升。
# ★ ★ 治"知识会变":知识在【外部知识库】里。文档改了,
# 你只需要更新知识库,模型【一个字都不用动】。
# === ★ RAG 的两个阶段:离线建库 + 在线问答 ===
# ★ ★ 阶段一(离线,只做一次 / 偶尔更新):把你的文档,
# 预先处理成一个【可以高效检索】的知识库。这一步在
# 用户提问之前就做好了。
# ★ ★ 阶段二(在线,每次提问都走一遍):用户问问题 ->
# 去知识库检索 -> 拼 prompt -> 模型生成回答。
# ★ 后面两节,分别细讲这两个阶段。
# === ★ 一个关键前提:检索,得能"按语义"检索 ===
# ★ ★ 传统的检索是"关键词匹配" —— 你搜"年假",它只能
# 找到字面上有"年假"二字的文档。但用户可能问"我能
# 休几天假",这句话里【没有"年假"两个字】。
# ★ ★ RAG 要的检索,是【按意思】检索 —— 能理解"休几天
# 假"和"年假政策"说的是【同一件事】。这种"按语义
# 找相关"的能力,靠的是【向量(embedding)】 ——
# 下一节细讲。
# === 小结 ===
# ★ RAG 三个字母三个步骤:R 检索(问题来了先拿它去外部
# 知识库搜出最相关的几段文字)、A 增强(把检索到的几段
# 连同原始问题拼成新 prompt,用这几段参考资料增强提问)、
# G 生成(把"问题+参考资料"发给模型让它依据给定资料
# 生成回答)—— 一句话 RAG = 带着一份刚查到的相关小抄
# 去问模型。★★ 它根治前面三个问题:治装不下和贵(每次
# 只塞相关三五段不是整个库)、治迷失和编造(递给模型的
# 是高度相关且很短的资料不用大海捞针)、治知识会变
# (知识在外部库里,文档改了只更新库模型一字不动)。
# ★ RAG 两阶段:离线建库(只做一次/偶尔更新,把文档预先
# 处理成可高效检索的知识库)、在线问答(每次提问走一遍:
# 检索→拼 prompt→生成)。★ 关键前提:检索得能按语义
# 检索 —— 传统关键词匹配只能找字面有"年假"的,但用户
# 可能问"我能休几天假"没这俩字,RAG 要的是按意思检索、
# 能理解二者是同一件事,这靠向量 embedding。
离线阶段:把文档变成一个"可检索的知识库"
# === ★ 阶段一:在用户提问之前,先把知识库建好 ===
# === ★ 第 1 步:切分(Chunking)—— 把长文档切成小块 ===
# ★ ★ 为什么要切:① 检索时,你想要的是"那相关的一小段",
# 不是"那一整篇三万字的文档" —— 给模型的资料要短、要
# 准。② 后面要做向量化,一个向量也表达不了一整篇长文。
# ★ ★ 怎么切:把文档切成一个个"块"(chunk),每块通常
# 几百字。★ 关键技巧:相邻块之间要有一点【重叠】
# (overlap),比如每块末尾和下一块开头重复几十字 ——
# 防止一句完整的话,正好被切在两块的交界处而断掉。
# ★ ★ 切分质量,直接决定 RAG 的上限。最好顺着文档的
# 自然结构(按段落、按标题)切,别无脑按字数硬切。
# === ★ 第 2 步:向量化(Embedding)—— 把每块变成一串数字 ===
# ★ ★ 这是 RAG 的技术核心。Embedding 模型,能把一段文字,
# 转换成一串数字(一个【向量】)。这串数字,代表了
# 这段文字的【语义】。
# ★ ★★ 神奇之处:语义【相近】的两段文字,它们的向量,
# 在空间里的【距离也相近】。"年假怎么算"和"年休假
# 计算规则",文字不一样,但它俩的向量会【挨得很近】。
# ★ 这就是"按语义检索"能实现的根本原因 —— 把"找意思
# 相近的文字",变成了"找距离相近的向量",而后者,
# 是计算机可以高速计算的。
# === ★ 第 3 步:入库 —— 存进"向量数据库" ===
# ★ ★ 你把每个 chunk 都向量化之后,得到一堆【(向量,
# 原文)】的对子。把它们存进一个专门的【向量数据库】
# (如 Milvus、Qdrant、pgvector 等)。
# ★ ★ 向量数据库的看家本领,就一个:给它一个查询向量,
# 它能【极快地】从千万条向量里,找出和它【距离最近
# 的 Top-K 个】。这正是检索那一步要的。
# === ★ 离线阶段的产物 ===
# ★ ★ 走完这三步,你就有了一个知识库:你的文档,被切成
# 了很多块,每块都带着一个"语义坐标"(向量),存在
# 一个能按语义快速搜索的数据库里。
# ★ 文档更新了,你只需要把【变动的那部分】重新切分、
# 向量化、更新进库 —— 不用动模型,也不用全量重来。
# === 小结 ===
# ★ 离线阶段三步把知识库建好。第 1 步切分:把长文档切成
# 一个个几百字的块,因为检索要的是相关的一小段不是整篇、
# 且一个向量表达不了一整篇长文;相邻块要有一点重叠防
# 一句完整的话被切在交界处断掉;最好顺文档自然结构
# (段落标题)切别无脑按字数硬切,切分质量直接决定 RAG
# 上限。★★ 第 2 步向量化:embedding 模型把每段文字转成
# 一串代表其语义的数字(向量),神奇之处是语义相近的
# 文字向量在空间里距离也相近,于是"找意思相近的文字"
# 变成"找距离相近的向量"——后者计算机能高速算,这是
# 按语义检索的根本。★ 第 3 步入库:把每个(向量,原文)
# 对子存进专门的向量数据库(Milvus/Qdrant/pgvector),
# 它的看家本领是给一个查询向量能极快从千万条里找出
# 距离最近的 Top-K 个。★ 产物是一个能按语义快速搜索的
# 知识库,文档更新只需把变动部分重新处理更新进库,不用
# 动模型也不用全量重来。
# ★ 离线阶段 第 1 步:切分 —— 带重叠地把长文档切成小块
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:
# ★ 当前块加上这段还没超长,就继续累积
if len(buf) + len(para) <= chunk_size:
buf = (buf + '\n\n' + para).strip()
else:
if buf:
chunks.append(buf)
# ★★ 关键:新块的开头,带上上一块末尾的 overlap 个字
# —— 防止一句完整的话被切在两块交界处而断掉
tail = buf[-overlap:] if buf else ''
buf = (tail + '\n\n' + para).strip()
if buf:
chunks.append(buf)
return chunks
# ★ 离线阶段 第 2、3 步:把每块向量化,连同原文一起入库
from openai import OpenAI
client = OpenAI()
def embed(texts):
# ★ 把一批文字,送进 embedding 模型,得到一批向量
resp = client.embeddings.create(model='text-embedding-3-small', input=texts)
return [d.embedding for d in resp.data]
def build_index(documents, collection):
# ★ documents:你所有文档的原始长文本列表
for doc in documents:
# ★ 第 1 步:切分 —— 把长文档切成带重叠的小块
chunks = split_into_chunks(doc['text'])
# ★ 第 2 步:向量化 —— 一次性把这篇文档的所有块都转成向量
vectors = embed(chunks)
# ★ 第 3 步:入库 —— (向量, 原文, 来源) 三件一组存进向量数据库
rows = []
for chunk, vec in zip(chunks, vectors):
rows.append({
'vector': vec, # ★ 语义坐标,检索时用它算距离
'text': chunk, # ★ 原文,检索命中后要把它递给模型
'source': doc['source'], # ★★ 来源,生成答案时用来标注引用
})
collection.insert(rows)
# ★★ 建库只在文档变动时做;文档没变,这个库就一直复用
print(f'入库完成,知识库现有 {collection.count()} 个 chunk')
在线阶段:一次问答,完整地走一遍 RAG 链路
# === ★ 阶段二:用户每问一个问题,实时走一遍这条链路 ===
# === ★ 第 1 步:把"问题"也向量化 ===
# ★ ★ 用户问"我入职半年能休几天年假"。要去知识库里按
# 语义找相关段落,第一件事:用【和建库时同一个】
# embedding 模型,把这个问题也转成一个向量。
# ★ ★★ 划重点:问题和文档,必须用【同一个】embedding
# 模型。两个模型产出的向量,坐标系不一样,硬算距离
# 得到的是【一堆没有意义的乱数】。
# === ★ 第 2 步:向量检索 —— 取出 Top-K 个最相关的块 ===
# ★ ★ 拿问题向量,去向量数据库里查:和它【距离最近】的
# K 个 chunk 是哪些?(K 通常取 3~8)。
# ★ ★ 数据库飞快地返回这 K 个块的【原文】。注意:返回的
# 是【你当初存进去的那段原文】,不是向量 —— 向量只是
# 用来"算距离、找最近"的中间媒介。
# ★ 这 K 段原文,就是这次提问的"参考资料 / 小抄"。
# === ★ 第 3 步:拼 prompt —— 把"资料 + 问题"组装起来 ===
# ★ ★ 把检索到的 K 段原文,和用户的原始问题,拼成一个
# 结构清晰的 prompt。这个 prompt 里要【明确告诉模型】:
# - "下面是参考资料,请【只依据】这些资料回答";
# - ★★ "如果资料里没有答案,就【直说不知道】,
# 【绝对不要编】" —— 这一句,是压制幻觉的关键。
# === ★ 第 4 步:生成 —— 让模型依据资料作答 ===
# ★ 把这个 prompt 发给大模型,它就【照着这份小抄】,
# 组织出最终答案。它的"能力"用在了刀刃上:理解资料、
# 归纳表达,而不是去"回忆"它本就不知道的私有知识。
# === ★★ 第 5 步:带上引用 —— 让答案可追溯 ===
# ★ ★ 每个 chunk 入库时都存了 source(来自哪篇文档)。
# 生成答案时,把这次用到的 chunk 的 source 一并带出来,
# 附在答案后面 —— "本回答依据《员工手册》第 3 章"。
# ★ 引用让答案【可验证】:用户能去核对,你也能定位是
# 哪段资料导致了错误回答。这是 RAG 相比"裸问模型"的
# 一个巨大优势 —— 别浪费掉它。
# === 小结 ===
# ★ 在线阶段每次提问实时走五步。第 1 步把问题也向量化,
# ★★ 必须用和建库时同一个 embedding 模型,否则两套
# 坐标系硬算距离得到一堆乱数。第 2 步向量检索:拿问题
# 向量去库里查距离最近的 Top-K 个 chunk(K 通常 3~8),
# 数据库返回的是当初存进去的原文不是向量。第 3 步拼
# prompt:把 K 段原文和原始问题组装起来,明确告诉模型
# "只依据这些资料回答""资料里没有就直说不知道绝不编"
# —— 后一句是压制幻觉的关键。第 4 步生成:模型照着小抄
# 作答,能力用在理解归纳表达上而不是回忆它本不知道的
# 私有知识。★★ 第 5 步带引用:每个 chunk 入库存了
# source,生成时把用到的 chunk 的 source 带出来附在
# 答案后,让答案可追溯可验证 —— 这是 RAG 相比裸问模型
# 的巨大优势别浪费掉。
# ★ 在线阶段:一次问答,完整地走一遍 RAG 链路
def answer_with_rag(question, collection, top_k=5):
# ★ 第 1 步:把问题向量化 —— 必须用和建库时【同一个】模型
q_vector = embed([question])[0]
# ★ 第 2 步:向量检索 —— 取回距离最近的 top_k 个 chunk
hits = collection.search(q_vector, limit=top_k)
# ★ 命中的是当初存进去的原文 + 来源,不是向量
contexts = [(h['text'], h['source']) for h in hits]
# ★ 第 3 步:拼 prompt —— 把"资料 + 问题"组装起来
refs = '\n\n'.join(
f'[资料{i + 1}] 来源:{src}\n{text}'
for i, (text, src) in enumerate(contexts)
)
system = (
'你是知识问答助手。铁律:\n'
'1. 只能【依据下面的参考资料】回答,不得使用资料外的知识。\n'
'2. ★ 如果资料里没有答案,直接回答"资料中未提及",绝不编造。\n'
'3. 回答末尾,列出你引用了哪几条资料的来源。'
)
user = f'=== 参考资料 ===\n{refs}\n\n=== 问题 ===\n{question}'
# ★ 第 4 步:生成 —— 让模型依据资料作答
resp = client.chat.completions.create(
model='gpt-4o-mini',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
)
answer = resp.choices[0].message.content
# ★★ 第 5 步:把这次用到的来源带出来 —— 让答案可追溯
sources = sorted({src for _, src in contexts})
return {'answer': answer, 'sources': sources}
工程选型与坑:决定 RAG 成败的,往往不是模型
# === ★ RAG 跑通不难,跑好很难 —— 坑都在这一节 ===
# === ★★ 第一真相:RAG 的天花板,是【检索】决定的 ===
# ★ ★ 一个最反直觉、却最重要的事实:RAG 答得好不好,
# 【主要不取决于你用多强的生成模型】。
# ★ ★ 想清楚这个链条:如果【检索】这一步,没把真正相关
# 的那几段捞出来 —— 那么不管后面的生成模型多强,它
# 手里拿到的都是【错的资料】,它只能基于错资料作答,
# 或者老实说"不知道"。★ 检索错了,后面全错。
# ★ 结论:优化 RAG,第一精力要花在【提升检索的准确率】
# 上,而不是急着换更贵的生成模型。
# === ★ 坑 1:chunk 切多大?—— 太大太小都不行 ===
# ★ ★ 切太大:一个 chunk 里塞了好几个主题,它的向量是
# 这几个主题的"模糊平均",语义不聚焦,检索精度下降;
# 命中后塞进 prompt 的无关内容也多。
# ★ ★ 切太小:一句话被切得太碎,丢了上下文 —— "它的
# 保修期是一年"这个 chunk,单独看,"它"是谁?没法用。
# ★ 没有万能值。一般几百字起步,再按你的文档类型和实际
# 检索效果去调。★ 别忘了前面说的【重叠】。
# === ★ 坑 2:只靠向量检索不够 —— 加一层"重排序" ===
# ★ ★ 向量检索快,但它的"相关性判断"比较粗。常见做法:
# 先用向量检索【粗筛】出 Top-20~50 个候选,再用一个
# 更精细、更慢的【重排序模型(reranker)】,对这几十个
# 候选逐一精算相关度,挑出真正最好的 Top-3~5。
# ★ 粗筛保证"快"和"召回",重排保证"准" —— 这种两段式,
# 是提升检索质量性价比最高的一招。
# === ★ 坑 3:纯语义检索也会漏 —— 混合检索 ===
# ★ ★ 向量检索擅长"按意思找",但它有时会【漏掉精确的
# 关键词】 —— 比如产品型号"X-2000"、错误码"E-45",
# 这种"字面必须一模一样"的词,语义检索反而不灵。
# ★ ★ 解法:混合检索(hybrid search)—— 同时跑【向量
# 检索】和【传统关键词检索】,再把两边的结果融合。
# 语义的归语义,精确的归关键词,各补各的短板。
# === ★ 坑 4:RAG 一定要【评估】,别凭感觉 ===
# ★ ★ "我改了 chunk 大小,感觉好像准了点" —— 这种凭
# 感觉的优化,等于没优化。你必须建一个【评估集】:
# 几十上百条"标准问题 + 这问题应命中的文档"。
# ★ 每次调参,都拿评估集【量化地】跑一遍:检索的命中率
# 是多少?答案的准确率是多少?★ 用数字驱动优化。
# === ★ 坑 5:RAG、长上下文、微调 —— 别再搞混 ===
# ★ ★ 长上下文模型出来后,有人说"窗口够大,直接全塞,
# 不用 RAG 了"。但前面讲的【贵】【迷失在中间】两个
# 问题,长上下文【依然存在】 —— 知识库一大照样塞不下,
# 每次还要为海量无关 token 付费。RAG"只取相关那一点"
# 的价值,不会过时。
# ★ ★ 三者各管一摊,记牢:RAG 管【知识】(可更新的事实),
# 微调管【能力和风格】(怎么说话),长上下文管【单次
# 能处理的信息量】。它们是【互补】的,不是替代关系。
# === 认知 ===
# ★ RAG 跑通不难跑好很难。★★ 第一真相:RAG 的天花板由
# 检索决定 —— 答得好不好主要不取决于生成模型多强,
# 检索没把真正相关的捞出来,后面生成模型再强也只是
# 基于错资料作答,检索错了后面全错;优化第一精力花在
# 提升检索准确率而不是急着换更贵的生成模型。★ 坑 1
# chunk 切多大:切太大向量是多主题的模糊平均语义不
# 聚焦,切太小丢上下文("它的保修期一年"里"它"是谁),
# 没有万能值几百字起步按效果调别忘重叠。★ 坑 2 只靠
# 向量检索不够,加重排序:向量检索粗筛 Top-20~50,
# 再用更精细更慢的 reranker 精算挑出 Top-3~5,粗筛
# 保召回重排保准。★ 坑 3 纯语义检索会漏精确关键词
# (型号 X-2000、错误码 E-45),用混合检索同时跑向量
# 和关键词再融合。★ 坑 4 RAG 一定要评估别凭感觉,
# 建几十上百条标准问答的评估集,每次调参量化跑命中率
# 和准确率用数字驱动。★★ 坑 5 别把 RAG、长上下文、
# 微调搞混:长上下文也有贵和迷失中间的问题,RAG 只取
# 相关那点的价值不过时;三者互补 —— RAG 管知识、微调
# 管能力和风格、长上下文管单次信息量。
# ★ 工程实践:两段式检索 —— 向量粗筛 + reranker 精排
def retrieve_with_rerank(question, collection, recall_k=30, final_k=5):
q_vector = embed([question])[0]
# ★ 第 1 段:向量检索【粗筛】—— 召回一大批候选(快,保召回)
candidates = collection.search(q_vector, limit=recall_k)
# ★★ 第 2 段:reranker 精排 —— 对每个候选,精算它和问题的相关度
# rerank 模型同时看【问题】和【候选原文】,比纯向量距离准得多
pairs = [(question, c['text']) for c in candidates]
scores = rerank_model.compute_scores(pairs)
# ★ 按精排分数从高到低排,只留真正最相关的 final_k 个
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [c for c, _ in ranked[:final_k]]
# ★ 评估:用标准问答集,量化地衡量"检索命中率",别凭感觉调参
def eval_retrieval(eval_set, collection):
hit = 0
for case in eval_set:
# ★ case 里写明"这问题的答案应该来自哪篇文档"
got = retrieve_with_rerank(case['question'], collection)
got_sources = {c['source'] for c in got}
# ★★ 只要检索结果里命中了应命中的来源,这条就算过
if case['expect_source'] in got_sources:
hit += 1
rate = hit / len(eval_set)
print(f'检索命中率:{rate:.1%} ({hit}/{len(eval_set)})')
return rate
命令速查
RAG 检索增强生成:两个阶段
=============================================================
离线建库 切分(chunking,带重叠) -> 向量化(embedding)
-> 入库(向量数据库:Milvus / Qdrant / pgvector)
只在文档变动时做,文档没变就一直复用
在线问答 问题向量化(★同一 embedding 模型) -> 向量检索 Top-K
-> reranker 精排 -> 拼 prompt(资料+问题) -> 生成
-> 带引用返回
塞 prompt / 微调 / RAG 各管什么
-------------------------------------------------------------
塞 prompt 把全部知识硬塞 装不下 / 贵 / 迷失在中间
微调 改的是能力和风格 教"怎么说",不是注入"说什么"
RAG 外挂可更新知识库 被问到时只取相关那几段
提升 RAG 检索质量的四招
-------------------------------------------------------------
chunk 大小 太大语义不聚焦,太小丢上下文,几百字起步带重叠
重排序 向量粗筛 Top-N -> reranker 精排出 Top-K
混合检索 向量检索 + 关键词检索,补"精确词"的短板
评估集 标准问答量化跑命中率,用数字驱动调参
口诀:别让模型"记住一切",让它在被问到时"查一下"
知识归外部知识库,能力归模型,各司其职
RAG 的天花板由"检索"决定,不是生成模型
避坑清单
- 把整个文档库塞进 prompt,会超上下文窗口被迫截断丢知识、每次提问都为海量无关 token 付费、还会"迷失在中间"看漏相关段落
- 微调不是用来注入知识的,它擅长教模型风格格式行为模式("怎么说"),指望它记事实学得不牢、知识一更新就要重新微调
- 要分清"知识"和"能力":能力长在模型里、微调只能微微调整它,知识尤其是会变的私有知识该待在外部可更新的知识库里
- 切分 chunk 别无脑按字数硬切,优先顺文档自然结构(段落、标题)切,相邻块要带重叠防一句完整的话被切在交界处断掉
- 问题和文档必须用同一个 embedding 模型向量化,两个模型产出的向量坐标系不同,硬算距离得到的是一堆没有意义的乱数
- 拼 prompt 时必须明确写"只依据资料回答""资料里没有就直说不知道绝不编造",否则模型照样会幻觉
- RAG 的天花板由检索决定,检索没捞出真正相关的段落,后面生成模型再强也是基于错资料作答,优化第一精力要花在检索
- 只靠向量检索精度不够,要加一层 reranker 重排序:向量粗筛 Top-N、reranker 精排 Top-K;精确关键词还要靠混合检索补
- RAG 必须建评估集量化衡量,凭感觉"好像准了点"等于没优化,要用检索命中率、答案准确率这些数字驱动调参
- 别把 RAG、长上下文、微调搞混:长上下文也有贵和迷失中间的问题,三者互补——RAG 管知识、微调管能力、长上下文管单次信息量
总结
这一趟把 RAG 彻底理清的过程,纠正了我一个特别顽固、又特别隐蔽的执念——我一直想让模型"记住"我公司的全部知识。复盘到最深,我发现这个执念背后,是我把模型当成了一个"无所不知的大脑":既然它那么聪明,那我只要想办法把知识"装进"这个大脑——要么塞进 prompt 让它"临时背一遍",要么微调让它"长期记进去"——它就该什么都答得上来。我在这两条路上撞了很久的墙:塞 prompt,装不下、贵、还迷失;微调,贵、慢、知识一变就废、而且它压根记不牢。墙撞够了,我才被逼着想明白一件最朴素、却被我忽略了太久的事:模型身上,其实有两样【完全不同】的东西——一样是【能力】,它理解语言、推理、归纳表达的本事;另一样是【知识】,它回答某个具体问题所需要的那些事实材料。我之前所有的挣扎,都源于我把这两样东西【混为一谈】,试图用同一种方式——把东西"装进模型"——去处理它们。可一旦把它们分开看,答案就自己浮现了:能力,是模型与生俱来的,就让它长在模型里;而知识,尤其是【会变的、私有的】知识,就【不该】硬塞进模型——它应该待在一个外部的、随时能更新的知识库里,等到模型真正【被问到】的那一刻,再精准地【取出相关的那一小部分】,递到它面前。这,就是 RAG 的全部出发点:别让模型"记住一切",而要让它在被问到时,能"查到、并用上正确的那一点点"。这个认知一旦转过来,我看很多 AI 工程问题的眼光,忽然就连成了一条线。我想起之前理清 Function Calling 时的领悟——模型只负责"出主意",真正的"执行"攥在我自己的程序手里;我又想起理清 Prompt 注入时的领悟——模型吐出来的东西不可信,真正的"把关"建在我自己的代码里。现在 RAG 又告诉了我同一件事的第三个侧面——模型不负责"存储事实",真正的"知识"放在我自己维护、随时可更新的知识库里。这三件事,本质上是【同一个原则】:模型,是一个能力很强、但你不能、也不该把所有重担都压在它身上的"大脑"。它最擅长的,是【理解和表达】;而那些它不擅长、也不该由它独自承担的——精确的执行、安全的把关、事实的存储——都应该由你,在模型之外,用确定的、可控的工程手段稳稳地接住。想通了 RAG,我也就不再纠结"模型够不够强"这个问题了。一个真正可靠的 AI 应用,从来不是靠一个"什么都会、什么都记得"的超级模型堆出来的,而是靠你想清楚【哪些事该交给模型、哪些事该留在模型之外】,然后把这条边界,一刀一刀,亲手划清楚。
—— 别看了 · 2026