RAG 检索增强生成完全指南:从一次"问它公司报销流程、它编了一套对不上的流程"看懂 RAG

2024 年我做一个公司内部的知识助手,想让它回答员工的问题:报销流程是什么、某产品的退货政策怎么规定、某份技术规范里关于命名的要求是什么。这些答案全写在公司内部的几百份文档里。第一版我的做法特别直接:接一个大模型把用户问题原样发给它,指望它回答。本地一测问我们公司的报销流程,模型回了一段看起来很合理的流程,我还觉得能用,直到拿去对真实文档发现对不上——我们公司报销不走主管走财务系统直接提单,模型说的那套主管审批是它凭训练时见过的通用流程编的。我这才明白第一版错在哪:大模型的知识是训练时从公开语料学来固化在参数里的,它从没读过我们公司的内部文档,运行时也没有任何途径访问,它面对我们公司报销流程这个问题手里只有通用的公开知识于是就编一个看起来合理的答案。我一开始以为解法是把模型微调一遍,试了才知道成本高周期长文档一更新就得重训根本跟不上。后来才彻底想明白真正的解法不在模型那一侧:不要试图让模型记住我们的知识,而是在用户每次提问的那一刻先从文档库里把和这个问题相关的几段资料找出来,连同问题一起塞进 prompt,让模型基于这几段真实资料来回答,模型不需要懂我们的业务它只需要有阅读理解能力。这套先检索再把资料喂给模型最后生成答案的机制就是 RAG。本文从头梳理:为什么大模型答不了你的私有知识、RAG 的本质是检索增强生成、文档怎么切分和向量化、向量检索怎么用相似度找到相关片段、检索结果怎么拼进 prompt,以及切分粒度、检索数量、答案幻觉、引用溯源这些把 RAG 真正做对要避开的坑。核心一句:别把宝押在模型知道,要押在它会读,你负责把对的材料找出来递给它。

2024 年我做一个公司内部的知识助手,想让它能回答员工的问题:"我们的报销流程是什么""某个产品的退货政策怎么规定的""上个月那份技术规范里关于命名的要求是什么"。这些答案,全都写在公司内部的文档里——几百份 Word、PDF、Wiki 页面。第一版我的做法特别直接:接一个大模型,把用户的问题原样发给它,指望它回答。本地一测,问"我们公司的报销流程",模型回了一段:"一般来说,报销需要填写报销单、附上发票、由主管审批……"我当时还觉得能用——直到我拿这答案去对了一下我们真实的报销文档,发现对不上:我们公司报销不走主管,走的是财务系统直接提单;模型说的那套"主管审批",是它凭训练时见过的通用流程编的。我又问了几个更具体的:"某产品的退货政策""某规范的命名要求"——模型要么含糊其辞,要么干脆说"我无法获取你们公司的内部信息"。我这才明白第一版错在哪:大模型的知识,是它在训练时从公开语料里学来的,并固化在了参数里。它从来没读过我们公司的内部文档——那些文档既不在它的训练语料里,它运行时也没有任何途径去访问。它面对"我们公司的报销流程"这个问题,手里只有一堆通用的、公开的报销知识,于是它就拿这些去一个看起来合理的答案。我一开始以为,解法是把模型微调一遍,拿我们的文档再训练它。试了才知道:微调成本高、周期长,而且文档一更新就得重训,根本跟不上。后来才彻底想明白,真正的解法根本不在模型那一侧:不要试图让模型"记住"我们的知识,而是在用户每次提问的那一刻,先从我们的文档库里把和这个问题相关的几段资料找出来,连同问题一起塞进 prompt,让模型基于这几段真实资料来回答。模型不需要"懂"我们的业务,它只需要有阅读理解能力——你把材料递到它眼前,它照着材料答。这套"先检索、再把资料喂给模型、最后生成答案"的机制,就是 RAG(检索增强生成)。我以为 RAG 不过是"搜一下再问模型",结果真做下来坑一个接一个:文档怎么切、检索几段、模型还是会编、答案对不上来源……那次之后我才认真把 RAG 从头搞明白。这篇文章就把它梳理一遍:为什么大模型答不了你的私有知识、RAG 的本质是什么、文档怎么切分和向量化、向量检索怎么找到相关片段、检索结果怎么拼进 prompt,以及切分粒度、检索数量、答案幻觉、引用溯源这些把 RAG 真正做对要避开的坑。

问题背景

先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:做一个回答公司内部知识的助手,第一版把用户问题直接发给大模型。结果模型对"我们公司的报销流程"这类问题,凭通用知识编造一个看似合理、实则与公司真实文档对不上的答案;对更具体的内部问题,则直接回"无法获取内部信息"。

我当时的错误认知:"大模型很聪明,把问题发给它就能回答;答不好就拿公司文档微调它。"

真相:大模型的知识在训练时就固化进了参数,它没读过、也无法访问你的私有文档。要让它回答私有知识,正确的做法不是让模型"记住"知识(微调成本高、跟不上文档更新),而是在每次提问时,先从文档库里检索出与问题相关的片段,把这些片段连同问题一起塞进 prompt,让模型基于这些真实片段来回答。这套"检索 + 增强 + 生成"的机制,就是 RAG。

要把 RAG 做好,需要几块认知:

  • 为什么大模型答不了你的私有知识,微调为什么不是好解法;
  • RAG 的本质是怎样一个"检索—增强—生成"的流程;
  • 文档怎么切分成片段,怎么把片段转成向量;
  • 怎么用向量相似度,从海量片段里检索出与问题相关的那几段;
  • 切分粒度、检索数量、答案幻觉、引用溯源这些工程坑怎么处理。

一、为什么大模型答不了你的私有知识

先把这件最根本的事钉死:大模型的知识是"训练时固化"的,它读不到你的文档

一个大模型,它的全部知识来自训练阶段——把海量公开语料喂给它,它把规律学进参数里。训练一旦结束,这些参数就定死了。你公司的内部文档,既不在它的训练语料里,它运行的时候,也没有手、没有网络去翻你的文件柜。下面这段代码,就是我那个"把问题直接发给模型"的第一版:

from openai import OpenAI

client = OpenAI()


def naive_ask(question: str) -> str:
    # 反面教材:把私有知识问题直接发给模型,指望它知道。
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": question}],
    )
    return resp.choices[0].message.content
    # 问题:模型从没读过你公司的文档。问"我们的报销流程",
    # 它只能拿训练时见过的【通用】报销流程编一个,
    # 编得很通顺,但和你公司的真实规定对不上。

这段代码的错,不在某一行语法,在它的根本假设:它假设"模型什么都知道"。模型确实见多识广,但它见的是公开世界;你的私有知识,对它是一片盲区。而模型有个特点:它面对盲区里的问题,不倾向于说"我不知道",而倾向于拿手边最接近的通用知识一个——这就是"幻觉"。

那为什么不微调?微调确实能把知识"塞进"参数,但代价很重:它要准备训练数据、要算力、要时间;更要命的是,你的文档天天在变——今天更新了报销政策,难道明天就重训一遍模型?跟不上。所以正确的思路是:别动模型,动它的输入。模型答不好,不是因为它笨,是因为你没把材料给它

二、RAG 的本质:检索、增强、生成

把上一节的思路理顺,RAG 的本质就清楚了:它是一个三步流程

检索(Retrieval):用户提问后,先不急着问模型。拿着这个问题,去你的文档库里——找出和这个问题最相关的那几段文字。增强(Augmented):把搜到的这几段文字,作为"参考资料",和用户的原问题拼在一起,组成一个内容更丰富的新 prompt。生成(Generation):把这个"带资料的 prompt"发给模型,让它基于资料生成答案。

这三步连起来,关键的转变是:模型的角色从"一个无所不知的专家"(它做不到),变成了"一个有阅读理解能力的人"(它很擅长)。你不再指望它"知道"你公司的报销流程,你只是把报销文档的相关段落递到它眼前,让它读着回答。它不需要懂你的业务,它只需要会读。理解了这一点,RAG 就不神秘了——它不是什么新模型,它是给一个普通大模型,在提问前配一个图书管理员。下面几节,就把这个图书管理员的工作拆开:先是把书(文档)整理上架,再是按问题找书。

三、文档切分与向量化:把知识变成可检索的片段

要"检索文档",第一步是把文档整理成可检索的形态。这里有两个动作:切分,和向量化。

先说切分。一份文档动辄几千上万字,你不能把整份文档当作一个检索单位——太大了,里面大部分内容和某个具体问题无关。要把它切成一个个小片段(chunk),每段几百字,让每段都聚焦一小块内容。

def split_into_chunks(text: str, chunk_size: int = 300,
                      overlap: int = 50) -> list:
    """把长文档切成带重叠的小片段。
    overlap:相邻片段重叠一部分,避免把一句话从中间切断。"""
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        # 下一段的起点往回挪 overlap,和上一段重叠一部分
        start = end - overlap
    return chunks

注意那个 overlap(重叠):如果两段之间一刀切死,正好被切断的那句话——它的前半句在上一段、后半句在下一段——就哪一段都表达不完整了。让相邻片段重叠一小部分,关键的句子至少能完整地落在某一段里。

切完之后是向量化。我们要做的检索,不是"关键词匹配"——用户问"报销流程",文档里写的可能是"费用申请规程",字面对不上,但意思一样。要按意思检索,就得先把每段文字,转成一个能代表它语义的数字向量。这一步靠 embedding 模型完成:

def embed(text: str) -> list:
    """把一段文字转成一个语义向量(embedding)。
    意思相近的文字,转出来的向量在空间里也相互靠近。"""
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=text,
    )
    return resp.data[0].embedding      # 一个浮点数列表,如 1536 维

embedding 模型的神奇之处在于:它产出的向量,语义相近的会在空间里彼此靠近。"报销流程"和"费用申请规程"这两段话,字面毫不相同,但它们的向量会离得很近。这就是按"意思"检索的基础。文档切分、每段向量化——书,就算整理好了。

四、向量检索:用相似度找到最相关的片段

书上架了,接下来是按问题找书

核心思路是:用户的问题,本身也是一段文字,把它也用同一个 embedding 模型转成向量。然后,拿这个"问题向量",去和库里所有的"片段向量"逐一比较距离——离得最近的那几个片段,就是和问题语义上最相关的。衡量两个向量"有多近",常用余弦相似度。先把这个简单的向量库写出来:

import math


class VectorStore:
    """一个最简向量库:存片段和它的向量,支持按相似度检索。"""

    def __init__(self):
        self._items = []        # 每项是 (片段文本, 向量)

    def add(self, text: str, vector: list):
        self._items.append((text, vector))

    @staticmethod
    def _cosine(a: list, b: list) -> float:
        # 余弦相似度:两个向量夹角的余弦,越接近 1 越相似
        dot = sum(x * y for x, y in zip(a, b))
        na = math.sqrt(sum(x * x for x in a))
        nb = math.sqrt(sum(y * y for y in b))
        return dot / (na * nb + 1e-9)

    def search(self, query_vec: list, top_k: int = 3) -> list:
        # 算出 query 和每个片段的相似度,取最高的 top_k 个
        scored = [(self._cosine(query_vec, vec), text)
                  for text, vec in self._items]
        scored.sort(reverse=True)
        return scored[:top_k]

有了库,把第三节的切分、向量化串起来,就能建索引——把所有文档灌进这个向量库:

def build_index(documents: list) -> VectorStore:
    """把一批文档切分、向量化,全部灌进向量库。"""
    store = VectorStore()
    for doc in documents:
        for chunk in split_into_chunks(doc):
            store.add(chunk, embed(chunk))      # 片段 + 它的向量
    return store

索引建好,检索就是顺理成章的一步:把用户问题转成向量,丢进库里搜:

def retrieve(store: VectorStore, question: str, top_k: int = 3) -> list:
    """检索:把问题转成向量,从库里找出最相关的 top_k 个片段。"""
    query_vec = embed(question)                 # 问题也转成向量
    hits = store.search(query_vec, top_k=top_k)
    # 返回片段文本列表(这里丢掉分数,只要内容)
    return [text for score, text in hits]

这一步是整个 RAG 的心脏。注意:检索不要求字面匹配——用户问"出差住宿能报多少钱",哪怕文档里写的是"差旅住宿费标准",只要语义接近,它们的向量就接近,这段就会被 retrieve 捞出来。检索到的这几段,就是要喂给模型的"参考资料"。

五、增强与生成:把资料喂给模型,让它照着答

检索拿到了相关片段,RAG 的最后两步——增强、生成——就是把这些片段用对

增强,就是构造那个"带资料的 prompt"。它的结构有讲究:要明确告诉模型"下面是参考资料",要明确要求它"只能根据资料回答",还要给它一条退路——"资料里没有就说不知道"。这条退路,正是治第一节那个"编答案"病根的:

def build_rag_prompt(question: str, contexts: list) -> str:
    """把检索到的片段拼成参考资料,和问题一起组成增强后的 prompt。"""
    refs = "\n\n".join(f"[资料{i + 1}] {c}"
                       for i, c in enumerate(contexts))
    return f"""请只根据下面的【参考资料】回答用户问题。
如果参考资料里没有相关信息,直接回答"根据现有资料无法回答",
绝对不要用资料以外的知识自行编造。

【参考资料】
{refs}

【用户问题】
{question}"""

有了这个 prompt,生成就是最后一步:发给模型。把检索、增强、生成三步串起来,就是完整的 RAG 主流程:

def rag_answer(store: VectorStore, question: str) -> str:
    """RAG 主流程:检索 -> 增强 -> 生成。"""
    contexts = retrieve(store, question, top_k=3)   # 1. 检索
    prompt = build_rag_prompt(question, contexts)   # 2. 增强
    resp = client.chat.completions.create(          # 3. 生成
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.choices[0].message.content

对比一下第一节的 naive_ask:两者都"问了模型一次",但 rag_answer 在问之前,多做了"检索"和"增强"两步。正是这两步,让模型手里有了你公司文档的真实片段。它回答"报销流程"时,照着的是你的资料,而不是它训练时见过的通用流程。问题开头那个"编出来的报销流程",到这里就被根治了。

六、工程坑:切分粒度、检索数量与答案溯源

主流程通了,但要把 RAG 真正做对,还有几个绕不开的工程坑。

坑 1:切分粒度要适中。片段切得太大,一段里混了好几个主题,检索时语义不聚焦,而且喂给模型的资料里夹带大量无关内容;切得太小,一句完整的话被拆碎,单看一个片段缺乏上下文,模型读不懂。没有万能值,要结合文档特点调,并保留重叠

坑 2:检索数量(top_k)要权衡。top_k 太小,真正有答案的那段可能没被捞进来;太大,一堆不相关的片段也被塞进 prompt,既稀释了关键信息、干扰模型,又浪费 token。一般从 3 到 5 起步,再按效果调。

坑 3:模型仍可能"无视资料"地编。就算 prompt 里写了"只根据资料回答",模型偶尔还是会掺入自己的知识。更稳的做法是:在检索那一步就设一个相似度门槛——如果连最相关的片段,相似度都低于门槛,说明库里根本没有相关内容,这时干脆不问模型,直接回"无法回答"。

def rag_answer_grounded(store: VectorStore, question: str,
                        min_score: float = 0.3) -> str:
    """带门槛的 RAG:检索不到足够相关的资料,就不让模型作答。"""
    query_vec = embed(question)
    hits = store.search(query_vec, top_k=3)
    # 最相关的片段,相似度都没过门槛 —— 说明库里没有答案
    if not hits or hits[0][0] < min_score:
        return "根据现有资料无法回答这个问题"
    contexts = [text for score, text in hits]
    prompt = build_rag_prompt(question, contexts)
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.choices[0].message.content

坑 4:答案要能溯源。用户看到一个答案,他怎么知道该不该信?最好的办法是:让答案带上它依据的来源——这段答案是根据哪份文档、哪个片段得出的。这就要求检索时,片段不能只存文本,还要带上它的出处(文档名、章节)。回答时把出处一并返回,用户可以点回原文核对。下面这张图,把一次 RAG 问答的完整路径串起来:

关键概念速查

概念 / 手段 说明
知识固化在参数里 大模型的知识训练时定死,读不到也访问不了你的私有文档
微调不是好解法 成本高、周期长,文档一更新就得重训,跟不上变化
RAG 三步 检索相关片段、把片段增强进 prompt、让模型基于片段生成答案
文档切分 chunk 把长文档切成几百字的小片段,每段聚焦一小块内容
切分重叠 overlap 相邻片段重叠一部分,避免一句话被从中间切断
向量化 embedding 把文字转成语义向量,意思相近的文字向量也相互靠近
余弦相似度 用向量夹角的余弦衡量两段文字语义有多接近,越接近 1 越像
向量检索 问题转向量,和库里片段逐一比相似度,取最高的 top_k 个
相似度门槛 最相关片段都不过门槛就直接回无法回答,不让模型编造
答案溯源 片段带出处,答案附上依据的文档来源,让用户能核对

避坑清单

  1. 大模型的知识在训练时固化进参数,它没读过也无法访问你的私有文档,直接问只会让它编。
  2. 别用微调让模型记住知识:成本高、跟不上文档更新;正确思路是别动模型、动它的输入。
  3. RAG 的本质是检索加增强加生成,模型角色从无所不知的专家变成有阅读理解能力的人。
  4. 长文档必须切成小片段再检索,整份文档当检索单位会让大量无关内容混进来。
  5. 切分要保留重叠,否则一句完整的话被一刀切断,前后两段都表达不完整。
  6. 检索要按语义而非关键词,靠 embedding 把文字转成向量,意思相近向量才相近。
  7. 切分粒度要适中:太大语义不聚焦还夹带无关内容,太小缺上下文模型读不懂。
  8. top_k 要权衡:太小漏掉真正有答案的片段,太大稀释关键信息又浪费 token。
  9. 模型仍可能无视资料编造,要在检索处设相似度门槛,过不了门槛就直接回无法回答。
  10. 片段要带出处,答案要附来源,让用户能点回原文核对,这是 RAG 可信度的关键。

总结

回头看那个"问它公司报销流程、它编了一套对不上的流程"的知识助手,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"大模型很聪明,把问题发给它就能回答"。这句话错在它把模型的"聪明"和"知道"划了等号。模型确实聪明,但聪明指的是它的理解与表达能力;它"知道"什么,则完全取决于训练时喂了它什么。你公司的内部文档不在那批语料里,所以无论模型多聪明,它都不可能"知道"你的报销流程。RAG 想清楚的,正是这件事:不要把宝押在模型"知道",而要押在它"会读"——你负责把对的材料找出来递给它,它负责读懂材料、组织答案。

所以做 RAG,真正的工程量不在"调一次模型"那一下。检索几段文字、拼进 prompt、发给模型,这部分 Demo 里照着文档谁都能跑通。真正的工程量在那条检索链路的每一处:文档你切得对不对,切得太碎模型读不懂上下文、切得太整检索不精准?用户问题和文档用词不一样,你的向量检索捞得到那段真正有答案的文字吗?库里压根没有答案时,你的系统是诚实地说"无法回答",还是又让模型滑回去编一个?模型给出的答案,用户凭什么相信它,你能不能把来源一并摆出来?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么答不了私有知识,再看 RAG 的本质是怎样一个三步流程,然后是文档切分向量化、向量检索、增强生成这三段主干,最后是切分粒度、检索数量、答案溯源这几个把 RAG 真正做对的工程细节。

你会发现,RAG 的思路,和我们平时查资料答问题的经验完全相通。一个再博学的人,你问他一份他从没见过的公司内部规定,他也答不上来——除非你把那份文件递到他手里,他读一遍,再回答你。一个好的图书管理员,你说出你的问题,他不会凭印象瞎答,他会走到书架前,精准地抽出那几本相关的书,翻到相关的那几页,递给你。RAG 做的就是这件事:大模型是那个有阅读理解能力的人,你的文档库是那座书库,而你写的检索代码,就是那个把对的书、翻到对的页、递到模型眼前的图书管理员。模型再聪明,没有这个管理员,它面对你的私有问题也只能靠猜。

最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你拿一个文档里写得明明白白的问题去问,检索一下就命中,模型照着答,跑起来漂亮极了。它只在真实的、五花八门的提问面前才显形。那时候它会用最难堪的方式给你结账:一个用户用了和文档完全不同的说法来问同一件事,你的检索没捞到那段资料,模型在空荡荡的上下文里又编了一个;一个用户问了一件文档里根本没写的事,你的系统没有门槛拦着,模型张口就来;一个用户拿着一个错误答案来投诉,你想查它到底依据了哪段资料出的错,却发现答案里没有任何来源,无从查起。所以别等用户拿着编造的答案来质问你,在你搭第一条检索链路的时候就该想清楚:用户换种说法问,我检索得到吗?库里没有答案时,我拦得住模型编造吗?它给的答案,我溯得到源吗?这几个问题都有了答案,你的 RAG 才不只是 Demo 里那个命中率好看的样子,而是一个无论用户怎么问、问得多刁钻,都能基于真实资料作答、答不了就老实说答不了、且每个答案都查得到出处的可靠系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

接口幂等设计完全指南:从一次"网络重试把用户扣款扣了两次"看懂幂等

2026-5-21 20:07:31

技术教程

消息队列削峰完全指南:从一次"秒杀活动瞬间把数据库打挂"看懂异步削峰

2026-5-21 20:15:48

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索