RAG 检索增强生成完全指南:从一次"问公司年假、大模型张口胡编"看懂 RAG

2024 年我做一个企业内部知识库问答。需求很常见:员工想知道公司的规章制度产品文档里写了什么,直接问让大模型回答。第一版我做得很直接:把员工的问题原封不动发给大模型,模型生成答案返回。本地用一些常识性问题试效果挺好,可一上线给同事用问题立刻就来了。有人问我们公司的年假有几天,模型给的答案是一般为 5 到 15 天,这是它从训练数据里学到的泛泛常识根本不是我们公司的规定。更吓人的是有人问一个具体的产品参数,模型没有丝毫犹豫给出了一个看起来非常专业实际上完全是编的数字。我第一反应是换个更强的模型,换了没用,它照样不知道我们公司的事照样编。我又想把公司所有文档都拼进 prompt,文档稍微一多请求直接报错内容撑爆了上下文窗口。我盯着这些问题想了很久才彻底想明白,第一版错在一个根本认知上:我以为大模型什么都懂我问它就行了,可它不懂你的私有数据。大模型的知识冻结在它训练那一刻,它从来没见过你公司的内部文档,问它要么说不知道要么一本正经地编一个这就是幻觉。真正的解法是 RAG 检索增强生成:先去知识库里把和问题真正相关的那一小段资料找出来,再把这一小段连同问题一起发给模型让模型看着资料回答。本文从头梳理:为什么直接问大模型答不了私有知识、为什么把所有文档拼进 prompt 也不行、RAG 的本质是什么、怎么做文档切块和向量化、怎么做检索和生成,以及切块大小检索门槛引用来源这些把 RAG 真正做对要避开的坑。

2024 年我做一个企业内部知识库问答。需求很常见:员工想知道公司的规章制度、产品文档里写了什么,直接问,让大模型回答。第一版我做得很直接:把员工的问题原封不动发给大模型,模型生成答案返回。本地用一些常识性问题试,效果挺好。可一上线给同事用,问题立刻就来了。有人问"我们公司的年假有几天",模型给的答案是"一般为 5 到 15 天"——这是它从训练数据里学到的泛泛常识,根本不是我们公司的规定。更吓人的是,有人问一个具体的产品参数,模型没有丝毫犹豫,给出了一个看起来非常专业、实际上完全是编的数字。同事还真信了,拿去用,出了岔子。我第一反应是:模型不够聪明,换个更强的模型。换了,没用——它照样不知道我们公司的事,照样编。我又想,那把公司所有文档都拼进 prompt,一起发给模型,它不就知道了?我试了,文档稍微一多,请求直接报错——内容撑爆了上下文窗口。我盯着这些问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"大模型什么都懂,我问它就行了"。可它不懂你的私有数据。大模型的知识,是冻结在它训练那一刻的——它从来没见过你公司的内部文档,你问它,它要么老实说不知道,要么一本正经地编一个,这就是幻觉。而"把所有文档拼进去"也走不通:文档一多就爆窗口,就算不爆也又贵又抓不住重点。真正的解法,是先去你的知识库里,把和这个问题真正相关的那一小段资料找出来,再把这一小段连同问题一起发给模型,让模型"看着这段资料回答"。这,就是 RAG(检索增强生成)。我以为做知识库问答不过是"调一下模型",结果真做下来才发现完全是另一套思路。这篇文章就把它梳理一遍:为什么直接问大模型答不了私有知识、为什么把所有文档拼进 prompt 也不行、RAG 的本质是什么、怎么做文档切块和向量化、怎么做检索和生成,以及切块大小、检索门槛、引用来源这些把 RAG 真正做对要避开的坑。

问题背景

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

现象:一个企业知识库问答,把员工问题直接发给大模型。问公司内部的事(年假天数、产品参数),模型要么答的是泛泛常识、不是本公司规定,要么一本正经地编造一个看起来很专业的答案。试图把全部文档拼进 prompt,请求直接撑爆上下文窗口报错。

我当时的错误认知:"大模型知识渊博,把问题发给它就能得到答案;答不好就换个更强的模型,或者把所有文档都塞进 prompt。"

真相:大模型的知识冻结在训练时刻,它不包含你的私有数据,问它私有问题只会得到常识或幻觉。换更强的模型治不了这个病。而"全量拼接文档"也不行:文档一多就超窗口,不超也、且关键信息淹没在无关内容里抓不住重点。正确的做法是 RAG:把知识库文档切块、向量化建成索引;每次提问时,先检索出与问题最相关的少量片段,把这些片段连同问题一起发给模型,让模型基于这些片段回答。

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

  • 为什么直接问大模型,私有知识答不了、还会幻觉;
  • 为什么"把所有文档拼进 prompt"在工程上走不通;
  • RAG 的本质——先检索、再生成,把私有知识喂给模型;
  • 怎么做文档切块、向量化,把知识库建成可检索的索引;
  • 切块大小、检索门槛、引用来源这些工程坑怎么处理。

一、为什么直接问大模型,私有知识它答不了

先把这件最根本的事钉死:大模型的知识,是冻结在它训练那一刻的;你公司的私有文档,它从来没见过,所以它根本无从知道、只能猜。

大模型是在一个巨大但固定的语料上训练出来的,训练一旦结束,它的知识就定格了。这意味着两件事:一,训练之后发生的事它不知道;二——这才是知识库问答的命门——任何没进过它训练语料的东西,比如你公司内部的、私有的文档,它压根没见过。下面这段代码,就是我那个会"一本正经胡编"的第一版:

from openai import OpenAI

client = OpenAI()


def ask_llm_direct(question: str) -> str:
    # 反面教材:直接把问题丢给模型,指望它知道公司内部的事。
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "你是公司知识库助手。"},
            {"role": "user", "content": question},
        ],
    )
    return resp.choices[0].message.content
    # 问题:模型的知识冻结在训练那一刻,它【从没见过】
    # 你公司的规章制度、产品文档。问"我们公司年假有几天",
    # 它要么老实说不知道,要么【一本正经地编一个】—— 这就是幻觉。

这段代码本身没有 bug,语法、调用都对。它的问题在于用错了地方:它默认"模型知道答案",可对私有问题来说,这个前提根本不成立。更危险的是模型的态度——它不会因为"不知道"就闭嘴,它被训练得很擅长把话说得流畅、自信。于是它会用一个极其专业、极其笃定的语气,给你一个完全是编的答案。这种带着十足自信的错误,就是幻觉,也是知识库问答里最害人的东西——因为用户分辨不出来

所以问题的根子很清楚:我们要回答的问题,答案不在模型脑子里,而在我们自己的文档里。模型缺的不是"聪明",而是"资料"。要让它答对,就得想办法在提问的时候,把对应的资料一起递给它

二、为什么"把所有文档拼进 prompt"也不行

顺着上一节的结论——"得把资料递给模型"——最直接的念头就是:那我把公司所有文档,全都拼进 prompt,一起发过去不就行了?我当时就是这么想、也这么做的:

def ask_with_all_docs(question: str, all_docs: list) -> str:
    # 反面教材:把【整个知识库】所有文档拼进 prompt 一起发。
    context = "\n\n".join(all_docs)
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "根据下面的资料回答问题。"},
            {"role": "user",
             "content": f"资料:\n{context}\n\n问题:{question}"},
        ],
    )
    return resp.choices[0].message.content
    # 三个问题:
    # 1. 文档一多,context 直接【撑爆上下文窗口】,请求报错;
    # 2. 就算没爆,每次提问都把全部文档重发一遍,token 费用惊人;
    # 3. 关键信息埋在一大堆无关文档里,模型反而【抓不住重点】。

这个想法的方向是对的——把资料递给模型——但方式错得离谱。它有三个致命问题。第一,装不下:模型的上下文窗口有硬性的 token 上限,一个真实公司的知识库,几十上百份文档,拼起来轻松冲破这个上限,请求直接被拒。第二,烧钱:就算侥幸没爆,你每问一个问题,都把整个知识库重新发一遍、按 token 重新付一遍钱,成本高得荒谬。第三——也最反直觉——效果反而差:把一份真正相关的资料,埋进几十份无关文档里发给模型,模型的注意力会被大量噪声稀释,它抓不住那个真正该看的重点,回答质量不升反降。

所以"递资料"这个方向对,但不能递全部。真正该做的是:每次提问,只挑出和这个问题真正相关的那一小撮资料,把这一小撮——而不是全部——递给模型。问题就变成了:怎么从一大堆文档里,快速挑出"和这个问题相关"的那一小撮?这,正是 RAG 要解决的。

三、RAG 的本质:先检索,再生成

上两节把死结摆清楚了:模型没有私有知识(第一节),而私有知识又不能全量塞给它(第二节)。RAG(Retrieval-Augmented Generation,检索增强生成)的破局点,就藏在它名字的顺序里:先检索(Retrieval),再生成(Generation)

它把"回答一个问题"拆成了泾渭分明的两步。第一步,检索:拿着用户的问题,去你自己的知识库里,找出最相关的少量几段资料——不是全部,就几段。第二步,生成:把这几段资料,连同原问题,一起组织成一个 prompt 发给大模型,并明确要求它"只根据这几段资料来回答"。

这两步,各自解决一个前面的死结。"检索"这一步,解决了"模型没有私有知识"——知识从你的库里现取,不依赖模型脑子里有没有。"只取几段"这一点,解决了"不能全量塞"——发给模型的资料量很小且可控,不爆窗口、不烧钱、没噪声。而大模型在这套流程里,角色也变了:它不再是那个"什么都得自己知道"的回答者,它退化成一个纯粹的"阅读理解 + 总结"引擎——给它一段资料、一个问题,它负责读懂资料、组织出通顺的回答。它最擅长的恰恰就是这个。RAG 的全部精妙,就在于把"知识"和"语言能力"拆开了:知识归你的知识库,语言能力归模型。理解了这个拆分,剩下的就是工程问题——而第一个工程问题是:怎么让一个知识库变得"可检索"?

四、文档切块与向量化:把知识库建成索引

要让知识库"可检索",得先做两件事:切块,和向量化

先说切块。一份文档,动辄几千上万字,整篇作为检索的最小单位太粗了——你问一个很具体的小问题,却检索回来一整篇文档,无关内容还是一大堆。所以要先把每篇文档,切成若干段小块(chunk),让检索的粒度变细。切块有个关键细节:相邻的块之间要留一点重叠,免得一句完整的话,正好被切在两块的边界上、语义被生生割裂。

def split_into_chunks(text: str, chunk_size: int = 500,
                      overlap: int = 50) -> list:
    """把一篇长文档,切成带重叠的小块。"""
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        # 下一块的起点往前回退 overlap 个字,
        # 让相邻两块有一段重叠,避免一句话被切在边界上、语义割裂。
        start = end - overlap
    return chunks

再说向量化。切好块之后,怎么衡量"一个块和一个问题相不相关"?靠字面匹配关键词太弱(用户的问法和文档的写法往往对不上)。正确的办法是 embedding:用一个 embedding 模型,把每个文本块转成一个向量——它的妙处是,语义越接近的两段文本,转出来的向量在空间里就越靠近

import numpy as np


def embed(text: str) -> np.ndarray:
    """把一段文本,转成一个语义向量(embedding)。"""
    resp = client.embeddings.create(
        model="text-embedding-3-small", input=text)
    return np.array(resp.data[0].embedding)

把这两件事合起来,就能把整个知识库建成一个可检索的索引:每篇文档进来,先切块,每块算出向量,然后把"(向量, 块原文, 来源文档名)"逐条存起来。注意要把来源一起存——后面要靠它告诉用户"这个答案出自哪份文档"。

class VectorStore:
    """最简向量库:存一批 (向量, 块原文, 来源) 三元组。"""

    def __init__(self):
        self.items = []     # 每项是 (向量, chunk 文本, 来源文档名)

    def add_document(self, name: str, text: str):
        """一篇文档入库:切块 -> 每块算向量 -> 逐块存进库。"""
        for chunk in split_into_chunks(text):
            vec = embed(chunk)
            self.items.append((vec, chunk, name))

知识库一旦这样建好,它就不再是一堆死文档,而是一个按语义组织、可以快速查询的索引。下一步,就是拿用户的问题,去这个索引里检索

五、检索:把问题向量化,找最相似的片段

索引建好了,检索这一步就顺理成章:把用户的问题,用同一个 embedding 模型转成向量,然后在向量库里,找出和它最靠近的那几个块。衡量"靠近",用的是余弦相似度(值越接近 1 越像)。

    def search(self, query: str, top_k: int = 3) -> list:
        """检索:把 query 向量化,返回最相似的 top_k 个块。"""
        if not self.items:
            return []
        q_vec = embed(query)
        scored = []
        for vec, chunk, name in self.items:
            # 余弦相似度:两个向量越"同向",值越接近 1
            sim = float(np.dot(q_vec, vec) /
                        (np.linalg.norm(q_vec) * np.linalg.norm(vec)))
            scored.append((sim, chunk, name))
        # 按相似度从高到低排序,取最靠前的 top_k 个
        scored.sort(key=lambda x: x[0], reverse=True)
        return scored[:top_k]

但这里有个不能省的判断:search 永远会返回 top_k 个"最相似"的块——可"最相似"不等于"真的相关"。如果用户问的问题,知识库里压根没有对应的资料,那么"最相似"的那几个块,相似度其实低得可怜,它们是"矮子里拔将军"。所以检索之后,必须再加一道相似度门槛:分数太低的块,说明知识库里没有相关内容,宁可丢掉,也别把无关的东西塞给模型。

def retrieve(store: VectorStore, question: str,
             top_k: int = 3, min_score: float = 0.3) -> list:
    """检索并过滤:只保留相似度过得了门槛的块。"""
    hits = store.search(question, top_k=top_k)
    # 关键:相似度太低,说明知识库里根本没有相关内容。
    # 这种"矮子里拔将军"的块,宁可不要,也别塞给模型当噪声。
    good = [(score, chunk, name) for score, chunk, name in hits
            if score >= min_score]
    return good

到这里,我们已经能稳定地从知识库里,捞出和问题真正相关的那一小撮资料了。最后一步,就是把它们交给模型,让模型读着它们把答案写出来。

六、生成:把片段塞进 prompt 与工程坑

检索拿到了相关片段,生成这一步,核心就是组织 prompt:把片段和问题拼在一起,并给模型清晰的指令。这里有两个要点:一是明确要求模型"只根据资料回答、不要编造";二是处理"一个片段都没检索到"的情况——这时绝不能让模型自由发挥,要明确告诉它"没有资料,请如实说不知道"。

def build_rag_prompt(question: str, hits: list) -> str:
    """把检索到的片段,拼成给模型的 prompt。"""
    if not hits:
        # 没检索到任何相关片段:明确告诉模型"没资料",
        # 逼它如实说不知道,而不是放任它凭空编造。
        return (f"知识库中没有找到相关资料。"
                f"请直接告诉用户你无法回答这个问题。\n问题:{question}")
    blocks = []
    for i, (score, chunk, name) in enumerate(hits, 1):
        # 每段资料都带上来源,后面好让模型注明引用
        blocks.append(f"[资料{i}] (来源:{name})\n{chunk}")
    context = "\n\n".join(blocks)
    return (f"只根据下面的资料回答问题,不得编造资料里没有的内容。"
            f"回答时请注明引用了哪条资料。\n\n{context}\n\n问题:{question}")

把检索和生成串起来,就是完整的 RAG 流程。对比第一节那个 ask_llm_direct,差别一目了然:

def answer_with_rag(store: VectorStore, question: str) -> str:
    """完整的 RAG 流程:检索 -> 拼 prompt -> 生成。"""
    hits = retrieve(store, question)              # 第一步:检索
    prompt = build_rag_prompt(question, hits)     # 把片段拼进 prompt
    resp = client.chat.completions.create(        # 第二步:生成
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "你是严谨的知识库助手。"},
            {"role": "user", "content": prompt},
        ],
    )
    return resp.choices[0].message.content
    # 对比第一节的 ask_llm_direct:模型不再凭空回答,
    # 它说的每一句话,背后都有检索回来的【真实资料】兜底。

主干通了,但要把 RAG 真正做对,还有几个工程坑绕不开。坑 1:切块大小是个权衡。块切得太大,一个块里混了好几个主题,检索精度下降、还浪费 token;切得太小,一句话的上下文被切碎,模型读不全。500 字上下是个常见的起点,但要按你的文档实际去调坑 2:top_k 和门槛要一起调。top_k 太小,可能漏掉真正有用的片段;太大,又把噪声引回来了。min_score 门槛太高,会把本来有用的也滤掉;太低,放进来的就是垃圾。这两个值,得拿真实问题去测。坑 3:检索不到时,务必让模型说"不知道"。这是 RAG 对抗幻觉的最后一道闸——上面 build_rag_prompt 里那个 if not hits 分支,绝不能省。坑 4:答案要带来源。让模型注明"引用了资料几、来源是哪份文档",用户就能自己去核对,可信度和可追溯性都大不一样。坑 5:知识库更新后要重建索引,而且建库和检索必须用同一个 embedding 模型,换了模型,向量空间就对不上了。下面这张图,把一次 RAG 问答的完整路径串起来:

关键概念速查

概念 / 手段 说明
知识冻结 大模型的知识定格在训练时刻,不包含训练后的事和你的私有数据
幻觉 模型对不知道的问题不闭嘴,会用自信的语气编一个错误答案
全量拼接的坑 所有文档塞进 prompt 会爆窗口、烧钱,且噪声多导致抓不住重点
RAG 的本质 先检索出相关片段再生成,把知识和语言能力拆开
文档切块 把长文档切成小块让检索粒度变细,相邻块留重叠避免语义割裂
向量化 用 embedding 把文本块转成向量,语义越近向量越靠近
检索 top-k 把问题向量化,按余弦相似度取最相似的若干个块
相似度门槛 最相似不等于真相关,分数太低的块要丢弃,别当噪声塞给模型
检索不到要说不知道 没命中片段时明确让模型如实回答不知道,这是对抗幻觉的闸门
来源引用 答案注明出自哪份文档,用户可核对,可信度和可追溯性大不同

避坑清单

  1. 大模型知识冻结在训练时刻,不包含私有数据,问私有问题只会得到常识或幻觉。
  2. 模型对不知道的问题不会闭嘴,会用自信专业的语气编造答案,这种幻觉对用户最害人。
  3. 把所有文档拼进 prompt 行不通:会爆上下文窗口、每次提问重发全部很烧钱、噪声多反而答不好。
  4. RAG 的本质是先检索后生成,把知识交给知识库、把语言能力交给模型,两者拆开。
  5. 长文档要切成小块让检索粒度变细,相邻块之间留重叠,避免一句话被切在边界语义割裂。
  6. 用 embedding 把文本块转成向量,靠余弦相似度衡量块和问题相不相关,而不是字面匹配关键词。
  7. 检索返回的最相似不等于真相关,要加相似度门槛,分数太低说明库里没有,宁可丢弃。
  8. 一个片段都没检索到时,务必明确让模型如实说不知道,这是 RAG 对抗幻觉的最后闸门。
  9. 切块大小和 top-k、门槛都要拿真实问题去调,太大太小都有代价,没有放之四海的默认值。
  10. 答案要带来源引用方便用户核对,知识库更新后要重建索引,且建库与检索必须用同一 embedding 模型。

总结

回头看那次"问公司年假、模型张口胡编"的事故,以及我后来在 RAG 上接连踩的坑,最该记住的不是某一段检索代码,而是我动手前那个想当然的判断——"大模型什么都懂,我问它就行了"。这句话错在它把大模型当成了一个"无所不知的神谕"。可它不是神谕,它更像一个读书破万卷、但毕业之后就再没接触过新信息的优等生:他语言组织能力极强,你给他一段材料,他能飞快读懂、总结得漂亮;但你要是问他一件他书本里没有的事——比如你们公司上周开会定了什么——他不知道,可他又不好意思说不知道,于是。RAG 想清楚的,正是这件事:不要去这个优等生他不可能知道的东西,而要先把相关的材料找出来、递到他面前,再让他看着材料回答。你用的,始终是他最强的那个能力——读懂和表达;而绕开了他最弱的那个环节——他脑子里到底有没有这个知识。

所以做 RAG,真正的工程量不在"调用模型"那一下。那一下,和第一节那个会胡编的 ask_llm_direct 几乎一模一样。真正的工程量,在检索那半边:你的文档,切块切得合不合理,会不会把一句话拦腰斩断?你的向量化,能不能让"换了种说法的问题"也匹配上对应的资料?你的检索,top_k 取几、相似度门槛卡多少,才能既不漏掉有用的、又不放进来一堆噪声?最关键的是那个门槛:当用户问了一个知识库里根本没有答案的问题,你的系统是诚实地说"我这儿没有相关资料",还是放任模型故态复萌、又编一个?RAG 做得好不好,很大程度上就取决于这道闸关没关严。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么答不了私有知识、为什么不能全量塞,再看清 RAG 先检索后生成的本质,然后是切块向量化、检索、生成这三段主干,最后是切块大小、检索门槛、来源引用这几个把 RAG 真正做对的工程细节。

你会发现,RAG 的思路,和一个靠谱的研究员回答问题的方式,惊人地相似。你问一个不靠谱的人一个专业问题,他会凭印象、凭感觉立刻给你一个答案——这是 ask_llm_direct,答案听着挺顺,但你不知道哪句是真的。而一个靠谱的研究员,你问他同一个问题,他不会马上张嘴:他会先说"我查一下资料"——他转身去翻文献、找档案(这是检索),找到几份真正相关的,摊在桌上(这是把片段拼进 prompt),然后对着这些资料组织出回答,还会告诉你"这个结论出自哪份文件"(这是来源引用)。他甚至有一种难得的诚实:翻遍了资料也没找到,他会坦白说"这个我手头没有资料,答不了"——而不是硬编一个。RAG,就是想让你的系统,从那个张口就来的不靠谱的人,变成这个凡事先查证、答必有出处的研究员

最后想说,RAG 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你自己塞几篇文档、问几个文档里明明白白写着的问题,检索做得糙一点、门槛设得松一点,跑起来都"能答对",你只会觉得"RAG 嘛,不就这样"。它只在真实的、文档成百上千份、用户问题千奇百怪的知识库面前才显形。那时候它会用最难堪的方式给你结账:做不好,用户问一个知识库里其实有答案的问题,你的检索却没捞对片段,模型只好说不知道,用户觉得"这破系统真没用";更糟的是,用户问一个知识库里没有的问题,你那道门槛又没关严,模型故态复萌编了一个,用户信了、用了、出了事——你费这么大劲做 RAG,最后还是败给了幻觉。而做了,它会安安静静地,把用户的每一个问题,都先翻一遍你的知识库,有答案就带着出处准确地答,没答案就老老实实说没有。所以别等同事拿着一个胡编的答案来找你,在你给知识库问答写下第一行代码的时候就该想清楚:模型答的这句话,背后有没有真实资料?我的检索,捞得准吗?捞不到的时候,我拦得住它编吗?这几个问题都有了答案,你的知识库问答才不只是 Demo 里那个塞几篇文档就能跑的样子,而是一个无论文档多少、问题多刁,都能有一句说一句、答必有据的可靠系统。

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

分布式锁完全指南:从一次"加了锁还超卖、库存被扣成负数"看懂分布式锁

2026-5-21 20:47:34

技术教程

接口幂等性完全指南:从一次"用户点了两下、扣了两次钱"看懂幂等设计

2026-5-21 20:58:38

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