云计算百科
云计算领域专业知识百科平台

AI 一本正经地胡说八道?80 行 Python 手搓 RAG,把切块、向量、检索、重排每一步都拆开看

从「闭卷考试」到「开卷考试」—— 80 Python 从零搭一条跑得通的 RAG,把切块、向量、检索、重排每一步都拆开看

目录

一、大模型为什么会"一本正经地胡说八道"

二、RAG 三个字母,拆开就是一句大白话

三、RAG 只做两件事,但每件事都有一堆讲究

四、手搓一个最小可运行的 RAG

五、"住酒店"为什么搜不到"住宿"?

六、切块:最不起眼,却最致命的一步

七、生产环境里的 RAG,还装了什么"选装件"

八、三个真实的落地案例

九、RAG 的边界:什么时候你不该用 RAG

十、怎么判断你的 RAG 做得好不好

十一、给零基础的落地路线

十二、常见问题

数据时效声明

参考资料

先讲一个我很喜欢的场景。

某公司内部上线了一个 AI 助手,员工可以问它各种规章制度。上线第一周口碑不错,直到有位同事问:"出差住宿一晚最多能报多少钱?"

助手回答得又快又自信:"根据公司规定,住宿标准为每晚不超过 1500 元,超标部分可申请特批。"

这位同事真的按 1500 元订了酒店。回来报销时才发现,公司规定是一线城市 600 元,没有任何"特批"条款。那个 1500 元,是模型自己编出来的。

更麻烦的是:你很难从回答里看出它在编。它的语气和答对时一模一样,格式工整、条理清晰、甚至还"贴心地"补充了不存在的例外情况。

这就是今天要讲的东西——RAG。它解决的正是这个问题:怎么让 AI 说错的时候,至少不要说得那么理直气壮;以及怎么让它说的话,你能翻到出处。

这篇文章我会把 RAG "三个字母"一路拆到"能跑起来的代码"。你不需要任何 AI 基础,只要会一点点 Python(甚至不会也行,代码我都配了逐行解释),就能完整看懂这条链路到底发生了什么。

而且我不打算只用比喻糊弄你。文章里有三段代码是真的能跑的——你在自己电脑上复制粘贴就能复现,连 API Key 都不用。包括那个"住酒店报多少钱"的真实检索失败案例,我会让你亲眼看它错一次、再修好一次。

一、大模型为什么会"一本正经地胡说八道"

要理解 RAG,得先理解大模型的一个根本特征:它的知识被"冻"在了训练结束的那一刻。

训练完,知识就凝固了

大模型是通过阅读海量文本训练出来的。训练过程中,它把看到的东西压缩进了自己的参数里——你可以粗暴地理解成:它把整个互联网""了一遍。

""有三个麻烦:

第一,背完就定型了。模型训练结束的那一天之后发生的事情,它一概不知道。公司昨天新发的通知、上个月改的报销标准、今天早上更新的产品价格,它的脑子里都没有。

第二,背得不精确。人背书会串行,模型也一样。它记住的是"大概意思""语言规律",而不是一份逐字逐句的档案。你问它某个文件的第 3 条第 2 款写了什么,它答不出来——它可能连这个文件是否存在都不知道。

第三,也是最要命的:它不会说"我不知道"

幻觉不是 bug,是它的本能

这里有个反直觉的点,值得你停下来想一想。

大模型的本质工作,是预测下一个字最可能是什么。你给它一句话,它算出接下来最该出现的词,然后一个字一个字往下接。

注意:它的任务从来不是"说真话",而是"说出最像真的那段话"

所以当它遇到一个知识空档时,它不会停下来,它会用语言规律把空档填平——因为它被训练成"必须接下去"。这个填空出来的内容,语法通顺、逻辑自洽、语气笃定,看起来和真话没有任何区别。

这就是"幻觉"的本质:它不是模型坏掉了,而是它在忠实地执行"把话接完整"这个任务

一个几乎必然发生的推论

理解了上面这些,你会发现一个结论几乎是必然的:

光靠换一个更大、更强的模型,解决不了这个问题。因为模型再强,它的知识依然是凝固的、大概的、无法溯源的。

你可能会想:那我把它训练得更准一点行不行?可以,但代价很大——每次知识更新都要重训一次模型,而且你依然无法保证它对答如流时不编造。

于是有人提出了另一个思路:别指望模型自己记住,我们把资料准备好,在它回答之前递给它。

这就是 RAG

二、RAG 三个字母,拆开就是一句大白话

RAG 是三个英文单词的缩写:Retrieval-Augmented Generation,中文通常翻译成"检索增强生成"

这个名字听起来很学术,但拆开看,它其实就是在描述一个非常朴素的流程。这一节我们把三个字母一个一个掰开。

Retrieval:检索,去把相关资料捞出来

用户提问后,系统先去一个资料库里翻找,把和问题最相关的内容捞出来。

关键是:这个资料库是你自己的,而且是你随时可以改的。公司的制度文档、产品手册、客服工单、历史邮件,都可以放进去。

Augmented:增强,把捞到的东西塞进提示词

"Augmented""增强、补充"的意思。增强的对象是提示词(Prompt)——也就是你发给大模型的那段输入。

原来你发给模型的只有一句"出差住宿能报多少钱";现在你发的是:

提示词 · 发送给大模型的输入

你是公司内部助手。请严格依据下面的资料回答问题,

资料里没有的内容不要编造,直接回答"资料中没有相关信息"

资料:

[1] 差旅标准:住宿标准按城市分级:一线城市每晚不超过 600 ……

问题:出差住宿能报多少钱?

回答:

这一步是整个 RAG 的核心动作。模型看到的不再是一个孤零零的问题,而是"问题 + 一份参考资料"

Generation:生成,让模型照着资料说话

模型基于这些资料生成回答。因为它手里有了依据,回答就变成了"从资料里提取 + 组织语言",而不是"从记忆里硬凑"

而且,因为资料是你给的,你可以顺手做两件事:

  • 要求它标注出处——"依据《差旅标准》第 3 ",用户可以自己去核对;
  • 要求它拒绝回答——资料里没有的内容,明确说"资料中没有相关信息",而不是硬编。
  • 这两件事,就是 RAG 相比"裸模型"最大的价值:答案从"不可验证"变成了"可追溯到具体文档"

    对比项

    裸模型(闭卷考试)

    RAG (开卷考试)

    知识来源

    训练时背下来的

    你当场递给它的资料

    知识更新

    要重新训练模型

    改文档、重建索引就行

    能否溯源

    不能,全靠它自己说

    能,每条答案能指到原文

    遇到不知道的

    大概率编一个

    可以要求它说 " 资料里没有 "

    适用场景

    通用闲聊、写作

    企业知识库、客服、专业问答

    一句话记住RAG 不是让模型变聪明,而是让模型"有资料可查",并且告诉你资料在哪一页。

    这个名字的来历

    RAG 这个词不是营销概念,它有明确的学术出处。

    2020 5 22 日,Facebook AI Research(现 Meta AI)联合伦敦大学学院、纽约大学的研究者,在 arXiv 上发布了一篇论文《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》(论文编号 arXiv:2005.11401),随后发表在 NeurIPS 2020 上。十三位作者中包括 Patrick LewisDouwe Kiela 等人。

    那篇论文里的原始设计是这样的:用 BERT 训练一个检索器(叫 DPR,稠密段落检索),把 2100 万个、每个约 100 词的维基百科段落事先编码成向量、用 FAISS 建好索引;再用 BART 做生成器。用户提问时,检索器先找出相关段落,生成器再基于这些段落产出答案。

    论文的实验结论是:在 Natural QuestionsTriviaQAWebQuestions 三个开放域问答任务上,这套方案超过了纯参数化的模型,而且生成的文本"更具体、更多样、更符合事实"

    七年过去,模型换了好几代,向量库从 FAISS 换成了各式各样的产品,但这套"先检索、再生成"的骨架,一个字都没变。

    三、RAG 只做两件事,但每件事都有一堆讲究

    RAG 听起来像个复杂系统,但它的骨架其实只有两条流水线。

    一条在离线时跑,负责把资料整理成"可被检索"的形态;一条在在线时跑,负责每次回答用户的问题。下面这张图把两条流水线画在一起了。RAG 全景架构图

    阶段一:建库(离线跑一次)

    这一步的目标是:把一堆乱七八糟的文档,变成一个"按意思就能查"的库。

    它包含四个动作:

  • 切块(Chunking)——一份 50 页的 PDF 没法整篇塞给模型,得切成一段一段。每段几百个字是比较常见的做法。
  • 向量化(Embedding)——把每一段文字转换成一串数字(通常几百到几千个数字),这串数字被称为"向量"。它的神奇之处是:意思相近的文字,转换出来的数字串也相似。
  • 写入向量数据库——把这些向量存起来,同时保存原文和来源信息(哪个文件、第几页)。
  • (可选)加元数据——给每段打上标签,比如"哪一年""哪个部门""是否已废止"
  • 这一步跑一次可能几分钟到几小时,取决于资料量。只有资料更新时才需要重跑。

    阶段二:问答(用户每问一次跑一遍)

    这一步的目标是:在几秒钟内,找到最相关的那几段,然后让模型照着说。

  • 用户提问——比如"住酒店一晚上最多能报多少钱"
  • 把问题也转成向量——用和建库时同一个模型,把问题变成同一套坐标系里的一个点。
  • 检索(Retrieval)——在向量库里找"距离问题最近"的几个段落,通常取前 3 20 个。
  • 重排(Rerank)——对检索出来的结果重新打分排序,把真正最相关的放到最前面。
  • 拼成提示词——把问题和检索结果组装成一段完整的输入。
  • 大模型生成——模型基于这些资料产出回答,并标注出处。
  • 一个关键认知

    看完这两条流水线,有个事情值得单独拎出来说:

    RAG 从头到尾没有改变模型本身。

    模型还是那个模型,参数一个都没动。RAG 做的所有事情,都发生在模型的外面——在它张嘴之前,把"该看的材料"摆到它面前。

    这个认知非常重要,它直接解释了 RAG 的两个特点:

    • 为什么它便宜:不需要标注数据、不需要训练、不需要 GPU 集群,改文档就能改答案;
    • 为什么它快:知识更新只需要重建索引,而不是重训模型(一次索引可能几分钟,一次训练要几天)。

    一句话记住RAG 不改模型,改的是喂给模型的东西。

    四、手搓一个最小可运行的 RAG

    前面讲了这么多概念,现在我们来动手。

    下面这段代码不到 90 行,只用了 Python 标准库——不装任何第三方包,不要 API Key,你复制到本地存成 rag_demo.py 就能直接跑。

    它实现了一个完整的 RAG:切块向量化检索拼提示词。虽然简陋,但骨架和工业级系统完全一样。

    Python

    """

    最小可运行 RAG 演示 —— 只用 Python 标准库,不需要任何 API Key

    运行:python rag_demo.py

    """

    import math

    from collections import Counter

    # ============================================================

    # 0 步:知识库(真实项目里是一堆 PDF / Word / 网页,这里手写成小块)

    # ============================================================

    DOCS = [

    ("报销制度", "员工出差产生的交通费、餐费和差旅费,需在返回后 15 个工作日内提交报销单。"

    "单笔金额超过 5000 元,需要部门总监审批;超过 20000 元,还需要财务总监审批。"),

    ("请假制度", "员工请假需要提前 1 个工作日在系统内提交申请。连续请假 3 天以内由直属主管审批,"

    "超过 3 天需要部门总监审批。"),

    ("年假规则", "入职满 1 年享有 5 天年假,之后每满 1 年增加 1 天,上限 15 天。"

    "年假当年有效,最多可以结转 5 天到下一年度。"),

    ("差旅标准", "住宿标准按城市分级:一线城市每晚不超过 600 元,二线城市 450 元,"

    "其他城市 350 元。超标部分需要员工自行承担。"),

    ("故障码说明", "错误码 TS-999 表示网关与鉴权服务之间的握手超时,通常由服务器时钟不同步引起,"

    "建议先检查 NTP 服务状态。"),

    ]

    # ============================================================

    # 1 步:切词

    # 中文不像英文有空格,正常项目会用 jieba 分词;这里为了不装任何库,

    # 用一个偷懒但有效的办法:把连续两个字当作一个""bigram)。

    # ============================================================

    def to_terms(text):

    text = "".join(ch for ch in text if not ch.isspace())

    return [text[i:i + 2] for i in range(len(text) – 1)]

    # ============================================================

    # 2 步:把每个文本块变成一个向量(一串数字)

    # 用经典的 TF-IDF:一个词在本块里出现越多、在所有块里出现越少,权重越高。

    # 真实项目会换成 embedding 模型,但"文本 -> 一串数字"这件事是一样的。

    # ============================================================

    def build_index(docs):

    term_sets = [to_terms(text) for _, text in docs]

    df = Counter() # 记录每个词出现在多少个文本块里

    for terms in term_sets:

    for t in set(terms):

    df[t] += 1

    n = len(docs)

    vectors = []

    for terms in term_sets:

    tf = Counter(terms)

    vec = {}

    for term, count in tf.items():

    idf = math.log((n + 1) / (df[term] + 1)) + 1 # 平滑 IDF

    vec[term] = (1 + math.log(count)) * idf # 对数 TF * IDF

    vectors.append(vec)

    return vectors, df

    # ============================================================

    # 3 步:算"两段文字有多像" —— 余弦相似度

    # 把两个向量看成两支箭,夹角越小越像。取值 0 1,越大越像。

    # ============================================================

    def cosine(a, b):

    common = set(a) & set(b)

    if not common:

    return 0.0

    dot = sum(a[t] * b[t] for t in common)

    norm_a = math.sqrt(sum(v * v for v in a.values()))

    norm_b = math.sqrt(sum(v * v for v in b.values()))

    return dot / (norm_a * norm_b)

    def vectorize_query(query, df, n_docs):

    tf = Counter(to_terms(query))

    vec = {}

    for term, count in tf.items():

    idf = math.log((n_docs + 1) / (df.get(term, 0) + 1)) + 1

    vec[term] = (1 + math.log(count)) * idf

    return vec

    # ============================================================

    # 4 步:检索 —— 把问题也变成向量,然后找出最像的几块

    # ============================================================

    def retrieve(query, docs, vectors, df, top_k=2):

    q_vec = vectorize_query(query, df, len(docs))

    scored = [(cosine(q_vec, d_vec), idx) for idx, d_vec in enumerate(vectors)]

    scored.sort(reverse=True)

    return scored[:top_k]

    # ============================================================

    # 5 步:拼提示词 —— 把检索结果塞进大模型的输入

    # ============================================================

    def build_prompt(query, hits, docs):

    context = "\\n".join(

    "[%d] %s%s" % (rank + 1, docs[idx][0], docs[idx][1])

    for rank, (_, idx) in enumerate(hits)

    )

    return (

    "你是公司内部助手。请严格依据下面的资料回答问题,"

    "资料里没有的内容不要编造,直接回答"资料中没有相关信息"\\n\\n"

    "资料:\\n%s\\n\\n问题:%s\\n\\n回答:" % (context, query)

    )

    if __name__ == "__main__":

    vectors, df = build_index(DOCS)

    print("知识库共 %d 个文本块,建好索引后词表大小 = %d\\n" % (len(DOCS), len(df)))

    for q in ["出差回来多久之内要报销?", "TS-999 是什么问题?"]:

    print("=" * 62)

    print("用户提问:%s" % q)

    hits = retrieve(q, DOCS, vectors, df, top_k=2)

    print("检索结果(相似度越高越相关):")

    for score, idx in hits:

    print(" %.3f <- [%s]" % (score, DOCS[idx][0]))

    print("\\n— 最终送给大模型的提示词 —")

    print(build_prompt(q, hits, DOCS))

    print()

    跑出来是什么样

    我在本机把这段代码跑了一遍,真实输出是这样的:

    运行结果

    知识库共 5 个文本块,建好索引后词表大小 = 221

    ==============================================================

    用户提问:出差回来多久之内要报销?

    检索结果(相似度越高越相关):

    0.057 <- [报销制度]

    0.000 <- [故障码说明]

    最终送给大模型的提示词

    你是公司内部助手。请严格依据下面的资料回答问题,资料里没有的内容不要编造,

    直接回答"资料中没有相关信息"

    资料:

    [1] 报销制度:员工出差产生的交通费、餐费和差旅费,需在返回后 15 个工作日内提交报销单。单笔金额超过 5000 元,需要部门总监审批;超过 20000 元,还需要财务总监审批。

    [2] 故障码说明:错误码 TS-999 表示网关与鉴权服务之间的握手超时,通常由服务器时钟不同步引起,建议先检查 NTP 服务状态。

    问题:出差回来多久之内要报销?

    回答:

    ==============================================================

    用户提问:TS-999 是什么问题?

    检索结果(相似度越高越相关):

    0.196 <- [故障码说明]

    0.000 <- [差旅标准]

    看到那个提示词的最后一行了吗?"回答:"后面是空的。

    因为这一步只是拼好提示词,真正说话的是大模型。把这段文字原样发给任何一个大模型 APIOpenAIClaude、通义千问、DeepSeek 都行),它就会基于你给的资料回答问题——而且因为你在提示词里明确要求了"资料里没有的内容不要编造",它大概率会老老实实答"15 个工作日",或者在你问它没收录的问题时说"资料中没有相关信息"

    这就是一个 RAG 的全部骨架。

    几个值得注意的细节

    跑完这段代码,有几个点你可以留意一下。

    第一,相似度分数都很小。 0.0570.196 这样的数字,看起来像是"几乎不相关"。其实不是——在这个稀疏向量的场景下,绝对数值本身没有意义,我们只看相对排序:谁排第一,谁就是最相关的。生产环境里用真正的 embedding 模型时,分数通常在 0.6 0.9 之间,但同样的道理,排序比数值重要。

    第二,0.000 意味着"一个字都没对上"。 那几行 0.000 表示这个文本块和问题在字面上完全没有共同片段。这个细节很关键,我们下一章就来讲它带来的麻烦。

    第三,注意 top_k=2 这个参数。 它决定"捞几段资料"。捞太少可能漏掉关键信息,捞太多会把噪声一起塞给模型(后面第七节会讲为什么"太多"也是问题)。

    五、"住酒店"为什么搜不到"住宿"?

    上一节的代码跑起来感觉还不错,那是因为我挑了两个"好问题"

    现在我们换一个真实用户会问的问题试试:"住酒店一晚上最多能报多少钱?"

    亲眼看看它怎么错的

    我在同一个脚本里补了一个对照实验:同一份知识库、同一个问题,跑两遍——第一遍用纯字面匹配,第二遍在切词前加一层"语义归一化"

    Python

    # 一张"同义词表"。真实项目里这张表由 embedding 模型自动学出来,不需要手写;

    # 这里手写,只是为了让你亲眼看到"语义归一化"到底在做什么。

    SYNONYMS = [

    ("最多能报多少钱", "金额上限"),

    ("一晚上", "每晚"),

    ("住酒店", "住宿"),

    ("酒店", "住宿"),

    ("宾馆", "住宿"),

    ("多少天", "天数"),

    ("多久", "时间"),

    ]

    def normalize(text, use_synonyms):

    if not use_synonyms:

    return text

    for src, dst in SYNONYMS: # 长词在前,避免"酒店"先被替换掉

    text = text.replace(src, dst)

    return text

    真实输出(两个方案的检索排名对比):

    运行结果

    ##################################################################

    # 方案 A:纯字面匹配(不做任何语义处理)

    ##################################################################

    提问:住酒店一晚上最多能报多少钱?

    1 0.029 年假规则

    2 0.000 故障码说明

    3 0.000 差旅标准

    ##################################################################

    # 方案 B:加一层语义归一化

    ##################################################################

    提问:住酒店一晚上最多能报多少钱?

    1 0.082 差旅标准

    2 0.041 年假规则

    3 0.036 报销制度

    请仔细看方案 A 的结果,因为这里藏着一个非常经典的陷阱。

    正确答案"差旅标准"的得分是 0.000。

    一个字都没对上。为什么?因为文档里写的是"住宿",用户问的是"住酒店";文档里写的是"每晚不超过 600 ",用户问的是"最多能报多少钱"。从字面看,这两句话没有任何共同片段。

    而"年假规则"却拿到了 0.029 分,排到了第一名。

    它凭什么?因为"年假规则"里有一句"最多可以结转 5 ",而问题里有个"最多"。就这么两个字的重合,系统就把一段和住宿毫无关系的年假规定,判定成了最相关内容。

    如果这个系统后面接着大模型,接下来会发生什么?模型会拿到一段关于年假的规定,然后被要求回答住宿报销的问题。运气好,它会说"资料中没有相关信息";运气不好,它会拿着年假的数据硬答——而这是它自己编的,用户完全看不出来

    这就是 embedding 要解决的问题

    方案 B 里我手写了一张同义词表,把"住酒店"映射成"住宿""一晚上"映射成"每晚""最多能报多少钱"映射成"金额上限"。改完之后,得分从 0.000 跳到 0.082,正确答案稳稳排在第一。

    但我必须强调:真实项目里没人会手写这张表。

    这就是 embedding 模型存在的原因。它的工作,就是自动学会"住酒店""住宿"是一回事、"报销""费用"是一回事、"多久""时间期限"是一回事——而且不是靠人列规则,是从海量文本里自己学出来的。

    下面这张图解释它的原理:向量空间示意图

    每一段文字被转换成一串数字,也就是坐标系里的一个点。"差旅标准""住宿费上限""酒店报销"这几个点和用户的问题挨得很近,因为它们讲的是同一件事;而"年假规则""请假流程"被放到了远处。

    检索这件事,到这里就变成了一个非常朴素的几何问题:找出离问题那个点最近的几个点。

    实际项目里怎么用真正的 embedding

    回头看第一节那个"交通费、餐费和差旅费"的例子——真实项目里,你会这样写:

    Python

    # 示意代码:需要安装 openai 库并配置 API Key,本文未实跑

    from openai import OpenAI

    client = OpenAI(api_key="你的 API Key")

    def embed(texts):

    """把一批文本转成向量。一次可以传多条,比一条条调用快得多。"""

    resp = client.embeddings.create(

    model="text-embedding-3-small", # 也可以用 BGE-M3、通义 embedding

    input=texts,

    )

    return [d.embedding for d in resp.data]

    # 建库时:把每个文本块转成向量,存进向量数据库

    chunk_vectors = embed([text for _, text in DOCS])

    # 检索时:把问题也转成同样的向量,然后算距离

    query_vector = embed(["住酒店一晚上最多能报多少钱"])[0]

    要注意一点:建库和检索必须用同一个 embedding 模型。换模型等于换了一整套坐标系,旧的向量就全对不上了——这也是为什么"升级 embedding 模型"在生产环境里往往意味着一整轮全量重建索引。

    踩坑预警:字面匹配在真实用户面前非常脆弱。用户不会按你文档的措辞提问。如果你的 RAG 只做了关键词匹配,它会在演示时表现良好(因为演示问题通常是精心设计的),在真实使用中暴露得一塌糊涂。

    六、切块:最不起眼,却最致命的一步

    前面讲了检索环节的问题。但如果你以为 RAG 的质量天花板在检索,那就错了——真正决定上限的,往往是更早的一步:切块

    这一节我们做一个更小的实验,只改一个变量:切块方式。

    两个真实的切块方案对比

    原文只有一句话:

    文档内容

    住宿标准按城市分级。一线城市每晚不超过600元。二线城市每晚450元。其他城市每晚350元。超标部分需要员工自行承担。

    我们用两种方式切它,然后问同一个问题:"二线城市住宿上限是多少"

    Python

    def fixed_chunk(text, size):

    """切法 A:每 size 个字硬切一刀,不管切在哪"""

    return [text[i:i + size] for i in range(0, len(text), size)]

    def sentence_chunk(text):

    """切法 B:按句子边界切,保证每句话完整"""

    return [s + "" for s in text.split("") if s.strip()]

    真实输出:

    运行结果

    ============================================================

    固定长度切块(每块 20 字)

    ============================================================

    1: 住宿标准按城市分级。一线城市每晚不超过6

    2: 00元。二线城市每晚450元。其他城市每

    3: 350元。超标部分需要员工自行承担。

    提问:二线城市住宿上限是多少

    1 0.163 00元。二线城市每晚450元。其他城市每

    2 0.158 住宿标准按城市分级。一线城市每晚不超过6

    3 0.000 350元。超标部分需要员工自行承担。

    ============================================================

    按句子边界切块

    ============================================================

    1: 住宿标准按城市分级。

    2: 一线城市每晚不超过600元。

    3: 二线城市每晚450元。

    4: 其他城市每晚350元。

    5: 超标部分需要员工自行承担。

    提问:二线城市住宿上限是多少

    1 0.211 二线城市每晚450元。

    2 0.124 住宿标准按城市分级。

    3 0.084 一线城市每晚不超过600元。

    4 0.033 其他城市每晚350元。

    5 0.000 超标部分需要员工自行承担。

    对比一下两组数字,差距非常直观。

    切法 A 的第一名,得 0.163 分;第二名 0.158 分。 两个分数几乎一样,系统分不出谁更相关。更糟的是,第一名那个块长这样:

    文档内容

    00元。二线城市每晚450元。其他城市每

    它以"00"开头——"600 "被切成了"6""00"分在两块里;结尾是"其他城市每""每晚"""字孤零零留在这块,跟下半句分家了。而正确答案"二线城市每晚450"确实在这个块里,但它混在一堆被切碎的残句中间

    切法 B 的第一名,得 0.211 分,而且它就是干净的一句"二线城市每晚450元。" 第二名只有 0.124。差距拉开了一倍,系统一眼就能确定该用哪块。

    为什么差这么一点,后果这么严重

    因为切块的影响会被后面的每一步放大。

    想象一下切法 A 的块被检索出来、送进大模型。模型看到的是"00元。二线城市每晚450元。其他城市每"——一段没有头没有尾的文字。它得猜:"00"是什么东西的 00 元?"其他城市每"后面原本接的是什么?

    它不会说"这段我看不懂",它会猜一个最合理的补全。于是幻觉就这样诞生了。

    这就是为什么我在前面说,RAG 的问题往往不在模型,而在给它喂的材料。切块方式对比图

    没有万能的切法

    那到底该怎么切?很遗憾,答案不是"用一个固定数值就完事"

    NVIDIA 在官方技术博客里公布过一组实测数据,覆盖了多种文档类型和切块策略。几个关键结论挺反直觉的:

    结论

    具体数据

    页面级切分平均表现最好

    平均准确率 0.648 ,且方差最小,是最稳妥的默认选择

    太小和太大都不行

    128 token 2048 token 两块极端表现普遍垫底

    事实型问题适合小块

    财报类数据集在 512 token 达到峰值 0.681

    分析型问题适合大块

    FinanceBench 1024 token 0.579 )反而优于页面级( 0.566

    表格密集文档要当心

    金融表格常跨页,按页切会把一个完整表格劈成两半

    NVIDIA 给出的落地建议也很实在:先把页面级切分当基线,然后根据你的文档类型再挑一到两种策略做小规模对比评测。他们也强调,页面级的另一个好处是"引用稳定"——页码是天然边界,用户说" 8 "你就能定位;而按 token 切的块编号会随参数变化而漂移,引用起来很不方便。

    踩坑预警:切块策略不能照抄别人的配置。同一套参数,换一份语料,效果可能天差地别。你必须在自己的数据上测。

    七、生产环境里的 RAG,还装了什么"选装件"

    到这里,一个能跑的 RAG 你已经完全掌握了。但"能跑""敢上线"之间,还隔着一段距离。

    这一节讲三个工业界几乎标配的"加装件"。它们都不改变基本骨架,只是让检索更准、回答更稳。

    加装件一:给每个小块补上"身份"

    先回到第一节那个经典难题。

    假设你有一堆财报,切块之后有一块长这样:

    文档内容

    公司营收较上一季度增长了 3%

    这块单独拿出来看,你根本不知道它在说哪家公司、哪一年、哪个季度。这就是切块的原罪:切碎了上下文。

    Anthropic 2024 年公布了一个非常巧妙的解法,叫"上下文检索"Contextual Retrieval)。做法是:在把每个块编码成向量之前,先让一个便宜的小模型为它写一段 50 100 词的说明,讲清"这一块在整个文档里是什么位置、讲的是什么",然后把这句说明拼在块的前面。

    刚才那个匿名块就变成了:

    文档内容

    这一块来自 ACME 公司 2023 年第二季度的 SEC 文件,上一季度营收为 3.14 亿美元。

    公司营收较上一季度增长了 3%

    Anthropic 公布的实测数据(用"Top-20 检索失败率"衡量,即正确答案没能进入前 20 名的比例):Anthropic 上下文检索检索失败率对比

    技术组合

    Top-20 失败率

    相对降幅

    纯向量检索(基线)

    5.7%

    加上 BM25 混合检索

    4.8%

    降低 16%

    加上上下文嵌入

    3.7%

    降低 35%

    上下文嵌入 + 上下文 BM25

    2.9%

    降低 49%

    再叠加交叉编码器重排

    1.9%

    降低 67%

    这里要提醒一个小细节,很多二手文章会把它讲错:"降低 67%"是失败率的相对降幅,不是准确率提升 67%。 准确的说法是:从" 100 个问题有 5.7 个检索失败"变成" 100 个问题只有 1.9 个失败"。相对降幅很惊人,绝对数字看着不大——但两个说法都是真的,只是角度不同。

    Anthropic 还给了个成本参考:配合提示词缓存(Prompt Caching),一次性处理 100 万个文档 token 大约只要 1.02 美元。也就是说,给全公司文档做一遍上下文增强,成本可能还不到一杯咖啡。

    加装件二:向量检索和关键词检索一起用

    你可能会问:既然有语义检索,为什么还要加什么 BM25(一种经典的关键词检索算法)?

    因为语义检索有个天生的短板:它对精确的字串不敏感。

    设想用户在技术支持库里搜"错误码 TS-999"。语义模型可能给你返回一堆"关于错误码的通用说明",因为它理解的是"错误码"这个概念;而它可能恰恰漏掉那条标题就叫"TS-999"的文档,因为"TS-999"这种字符串在语义空间里没有多少"意思"可讲。

    BM25 恰好相反。它不懂语义,但它是字面匹配的高手,找 ID、型号、专有名词、错误码特别准。

    所以工业界的标准做法是两路一起跑,再把结果融合。最常用的融合算法叫 RRFReciprocal Rank Fusion,倒数排名融合),思路简单到可以用一句话说清:不比较分数,只看排名,在各路里排得靠前的,融合后也靠前。

    检索方式

    擅长

    短板

    语义向量检索

    理解同义表达、模糊描述

    漏掉精确字符串、编号、型号

    关键词检索( BM25

    精确匹配 ID 、错误码、专有名词

    用户换个说法就完全找不到

    两路融合( RRF

    兼顾上面两者

    要多维护一路索引

    加装件三:注意"中间失落"效应

    最后一个加装件是个"注意事项"——它可能颠覆你对"上下文窗口越大越好"的想象。

    斯坦福等机构的研究者在 2023 年发表了一篇很有名的论文《Lost in the Middle: How Language Models Use Long Contexts》(后发表于 TACL 2024)。他们做了一个精心设计的实验:把包含答案的那份材料,放在上下文的第 15101520 位,看模型答题准确率怎么变。

    结果是那条经典的 U 型曲线Lost in the Middle U 型曲线

    模型对开头结尾的信息用得很好,而夹在中间的信息,准确率会比首尾低 20 30 个百分点。更有意思的是,在某些设置下,把信息放在中间,模型的表现甚至不如"完全不给它这份材料"——这些多出来的文字不但没帮上忙,反而干扰了它。

    这对 RAG 的设计有非常具体的指导意义:

    • 别贪心。检索 20 段资料一股脑塞进去,未必比精选 5 段更好。中间那些段落很可能被"无视"
    • 注意排序。既然首尾位置最受关注,那就把最相关的资料放在最前面或最后面。
    • 重排很值钱。把真正重要的内容重新排到高注意力位置,这件事的收益可能比换一个更强的模型还大。

    一句话记住:检索质量决定了回答质量的上限。而在所有优化手段里,重排(Rerank)往往是性价比最高的一笔投入——它单项就能贡献可观的失败率下降。

    八、三个真实的落地案例

    讲了这么多原理,你可能会想:这事儿真有人在做吗?做得怎么样?

    这一节讲三个有公开记录的真实案例。我尽量用可查证的数字,并用表格把"他们做了什么""得到了什么"对应起来。

    案例一:摩根士丹利的内部 AI 助手

    这是目前公开资料里最常被引用的 RAG 落地案例。

    背景很典型:摩根士丹利财富管理部门有大约 16000 名财务顾问,公司积累了超过 10 万份研究报告和文档、约 100 万页机构知识。顾问们每天要花大量时间在这些资料里翻找答案,而不是服务客户。

    他们 2023 9 月上线了基于 GPT-4 RAG 助手。有两点值得注意:

    第一,他们没有微调模型。 整套方案靠的是检索层——把新发布的研报和监管变更即时索引进去,模型本身不动。

    第二,他们设计了"人在环中"。 顾问在把 AI 输出交给客户之前会自己审核——这在受监管的金融行业是必须的。

    项目

    公开数据

    服务对象

    16000 名财务顾问

    知识库规模

    超过 10 万份文档、约 100 万页

    检索层能处理

    超过 35 万份文档

    采用率

    超过 98% 的顾问团队在使用

    效率提升

    顾问每天节省约 20–45 分钟的信息检索时间

    是否微调模型

    没有,完全靠检索层

    公司负责人的一句原话被广泛引用:"这项技术让你和组织里最聪明的人一样聪明。"

    案例二:微软 PIKE-RAG 在昕诺飞的知识管理场景

    这个案例的价值在于,它展示了"朴素 RAG 不够用"时该怎么办。

    昕诺飞(Signify,原飞利浦照明)服务的是专业用户,产品有数千个型号、参数复杂、文档版本多。他们的客服系统原本已经用了 RAG,但效果卡在瓶颈上。

    卡在哪?三类问题:非标准化的表格(比如不同电流下的电压范围对照表)、电路接线图这类图形、以及需要多步推理的复杂问题。

    微软亚洲研究院的 PIKE-RAG 方案做了三件事:

  • 多模态解析——不只是读文字,还能识别表格结构、解析电路图中的关键参数。官方文章举的例子是:客服问"某型号驱动器在 0.15 安电流下的输出电压",系统能定位文档里的曲线图并推算区间,而这正是传统系统频繁出错的场景。
  • 动态任务分解 + 多跳推理——把一个复杂问题拆成多个子任务逐步求解。官方举的例子是"列出与 G8 系列匹配的全部底座":文档里没直接写,系统先发现"G7 G8 尺寸一致、G7 的底座都兼容 G8",再去检索 G7 的底座列表,最后去查缩写与全称的对照表,才拼出完整答案。
  • 领域思维模式适配——允许企业把行业约定俗成的判断规则写进去,比如"驱动器的最大输出电压应取工作电压范围的最大值,而不是参数表里的最大值"
  • 结果:相比原有系统,回答准确率提升了 12%,而且是在没有针对任何单个问题做定制化调整的前提下取得的。

    维度

    改进前

    改进后

    表格、图形类问题

    无法有效处理,或只提取出混乱片段

    能识别表格结构、解析图中参数

    多步推理问题

    一问一答模式,难以应对

    动态分解为子任务,多跳推理

    领域规则

    靠提示词反复调优

    通过领域提示模块动态注入

    准确率

    基线

    提升 12%

    案例三:一个能落地的评估做法

    第三个案例不讲具体公司,讲一个我觉得最有普适价值的经验,来自一份 2026 年的 RAG 生产实践总结。

    里面提到一家中型 SaaS 公司的客服知识库:

    • 第一个版本用的是朴素方案——单次检索、直接用主流模型生成。用户反馈"答错"的比例大概是 22%
    • 改造之后加了四样东西:BM25 混合检索、重排模型、查询改写、语义缓存。
    • 三个月后:答错率降到 4%,而且因为语义缓存抵消了重排的算力开销,每次查询的成本反而降了 38%

    同一份资料里还提到另一个案例,我觉得更能说明问题:一家律所的内部研究助手,最初上线时用户不信任它——因为系统会编造判例引用

    他们的修法不是换模型,而是加了两层:一是让模型按结构化格式输出引用,二是加了一个"校验器",把每一个引用的出处拿去和检索到的原文比对。改完之后,忠实度指标从 0.71 升到 0.96,用户反馈的幻觉问题下降到接近零。

    这两个案例指向同一个结论:RAG 的质量提升,绝大多数来自工程细节,而不是换更贵的模型。

    九、RAG 的边界:什么时候你不该用 RAG

    看到这里,你可能会觉得 RAG 是个万能药。不是的。这一节专门讲它的边界,以及它和另外两条技术路线的取舍。

    和"微调"比:不是对手,是分工

    很多人纠结"该用 RAG 还是微调"。先看一组真实数据。

    Menlo Ventures 2024 年的企业生成式 AI 调研中(样本为 600 位企业 IT 决策者)发现:

    技术路线

    生产环境采用率( 2024

    上一年

    提示词设计

    居首

    RAG

    51%

    31%

    智能体架构

    12%

    首次出现

    微调

    9%

    RAG 的采用率一年内从 31% 涨到 51%,而微调只有 9%。差了将近六倍。

    这个差距很好理解,因为两者解决的根本不是同一类问题:

    你的需求

    应该选

    知识经常变(政策、产品文档、价格)

    RAG—— 改文档重建索引就生效

    需要溯源、可审计(法律、医疗、金融)

    RAG—— 每条答案能指到原文

    需要特定的说话风格、输出格式

    微调 ——RAG 教不了风格

    对延迟极其敏感

    微调 —— 微调后的模型推理更快

    两者都要

    混合:微调管 " 怎么说 " RAG " 说什么 "

    有一份 2026 年的行业研究甚至拿汽车制造业做实验(用了两个宝马集团的封闭数据集),结论是:在看起来最"必须微调"的高专业度场景里,RAG 依然是成本效益最好的方案,开源模型加上 RAG 之后,质量能追平顶尖的闭源模型。

    和"长上下文"比:窗口大,不等于读得好

    过去两年还有一个反复出现的争论:既然模型上下文窗口已经能到百万 token,那我直接把所有资料塞进去,是不是就不需要 RAG 了?

    Anthropic 在官方文章里给了半肯定的答案,也划了边界:如果你的知识库小于 20 万 token(大约 500 页),你确实可以直接把全部内容放进提示词,根本不需要 RAG。 配合提示词缓存,这种做法还能把延迟降低一倍以上、成本降低最多 90%

    但一旦超过这个规模,RAG 就回来了。原因有三条:

  • 成本:每次提问都把 20 token 的资料重发一遍,和只发 5000 token 的相关片段,成本差了不止一个数量级。Anthropic 自己也算过这笔账:一个 20 token 的窗口,在每月 400 万次查询的量级下,账单是五位数美元起步;而 RAG 方案的有效上下文通常只有 5K token 左右,成本大约是前者的 5%–8%
  • 注意力:前面讲过的"中间失落"效应——塞得越多,中间部分越容易被无视。
  • 新鲜度和权限:资料更新一次,RAG 只需要重新索引那一个文档;长上下文方案要在之后每一次提问里都重复付这份钱。而在企业环境里,不同员工能看的资料范围还不一样,这需要检索层来做权限过滤。
  • 场景

    更合适的选择

    知识库小于 500 页,查询量不大

    直接塞进上下文,不用折腾 RAG

    知识库大、更新频繁

    RAG

    需要跨全库汇总( " 总结我和这个客户的所有会议 "

    长上下文或分层摘要,不是 top-K 检索

    需要引用出处、需要权限隔离

    RAG

    查询量大、对成本敏感

    RAG

    十、怎么判断你的 RAG 做得好不好

    "我感觉它答得还不错"——这是最危险的评估方式。

    生产级的 RAG 必须能被量化评估,否则你连"这次改动是变好了还是变坏了"都说不清。这一节讲三个最常用的指标,以及一个最容易被忽视的失败模式。

    三个核心指标

    指标

    它在问什么

    怎么测

    检索命中率( Recall@K

    正确的资料,有没有被捞进前 K 名?

    准备一批标注好的问题,看标准答案所在的块有没有出现

    忠实度( Faithfulness

    回答里的每句话,都能在检索到的资料里找到依据吗?

    用评估模型逐句比对答案与资料

    答案相关度( Answer Relevancy

    回答有没有真正回答用户的这个问题?

    评估模型打分,或人工抽样

    这三个指标要分开看,因为它们对应的是链路的不同环节:检索命中率低,说明问题出在切块或 embedding;忠实度高但相关度低,说明检索来的资料不对题;相关度高但忠实度低,说明模型在自由发挥。

    有个很实用的做法:在写第一行检索代码之前,先把评估集建起来。 从你的真实业务里挑 50 个有代表性的问题,标注好每个问题的标准答案在哪个文档。这 50 个问题会成为你后续所有优化的标尺——你会发现它逼着你把"感觉"变成"数字"

    最阴险的失败:测试全过,用户不满意

    有一类失败极难发现:你的系统在所有指标上都达标,但用户就是不买账。

    原因通常是:你的测试集是"标准问法",而用户说的是人话。

    测试集里的问题是"出差住宿的报销标准是什么",用户问的是"我上次去上海住的那个酒店,能报多少来着"。前者能命中,后者全线崩溃。

    这也是为什么"查询理解"在生产环境里很重要——系统需要能处理含混的、指代不清的、甚至语法不完整的问题,必要时反问用户澄清,或者把"它理解成了什么"显式说出来让用户纠正。

    还有一类静默失败值得单独提:索引漂移。源文档更新了,向量索引没同步。系统照常返回结果,只是返回的是过期内容。这类问题不会报错,只会慢慢让用户失去信任。解法是给每份文档打上版本和时间戳,检索时按时间过滤。

    十一、给零基础的落地路线

    如果你读到这里,想自己动手做一个,这一节给你一条能走通的路。我不会建议你一步到位,因为那样大概率会卡死在中途。

    五步走

    第一步:先用"不用 RAG"的方式跑通

    把你的文档整理成一个纯文本文件,直接塞进模型上下文,问几个问题看看效果。如果你的资料本来就不到 500 页,你可能根本不需要 RAG——这一步就能帮你省下大量时间。

    第二步:建一个 50 题的小评估集

    从真实业务里挑 50 个问题,标注每个问题的答案在哪个文档。这一步看着枯燥,但它决定了你后面所有优化是不是"瞎调"

    第三步:实现最小可用版本

    按本文第四节的思路,先跑通"切块向量化检索生成"的完整链路,哪怕用的是最朴素的方案。先让它能端到端跑起来,再谈优化。

    第四步:按顺序加装(顺序很重要,别跳)

  • 先把切块策略调对——这是收益最大、成本最低的一步;
  • 再加混合检索(向量 + BM25——这一项通常能带来明显的召回提升;
  • 再加重排——性价比极高的一项;
  • 如果文档有"切碎后失去上下文"的问题,再上上下文增强。
  • 第五步:把评估接进流程

    每改一个参数,跑一遍那 50 题,对比数字。不要凭感觉上线。

    工具怎么选

    环节

    常见选择

    说明

    编排框架

    LangChain / LlamaIndex / 自己写

    自己写能看清每一步,学习阶段推荐;框架适合快速搭生产

    向量数据库

    pgvector / Qdrant / Milvus / Chroma

    已经有 PostgreSQL 的话, pgvector 最省事

    Embedding 模型

    text-embedding-3 系列 / BGE-M3 / 通义

    中文场景建议优先测中文友好的模型

    重排模型

    Cohere Rerank / BGE-reranker

    效果提升明显,是常见的第一步优化

    评估工具

    Ragas / ARES / 自己写脚本

    也可以用本文讲的三个指标自己实现

    LangChain 写同样的链路,代码大概会变成这样(示意代码,未实跑):

    Python

    # 示意代码:需要安装 langchain 相关依赖

    from langchain_community.document_loaders import TextLoader

    from langchain_text_splitters import RecursiveCharacterTextSplitter

    from langchain_openai import OpenAIEmbeddings

    from langchain_chroma import Chroma

    loader = TextLoader("员工手册.txt", encoding="utf-8")

    docs = loader.load()

    # 框架帮你封装了切块:按段落 -> 句子 -> 词的顺序递归切

    splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)

    chunks = splitter.split_documents(docs)

    # 向量库、编码、检索三件事,各一行

    vectorstore = Chroma.from_documents(chunks, OpenAIEmbeddings())

    retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

    hits = retriever.invoke("住酒店一晚上最多能报多少钱")

    for i, d in enumerate(hits, 1):

    print(i, d.page_content[:60], d.metadata.get("source"))

    代码短了很多,代价是每一步都成了黑箱。我的建议是:先用标准库手写一遍,理解每一步;再用框架搭生产版本。

    十二、常见问题

    这一节回答几个我被问过最多的问题。有些答案可能和你的直觉相反。

    Q1:RAG 能彻底消除幻觉吗?

    不能。

    准确的表述是"显著降低"。公开实践里常见的数字是能把一部分幻觉压下去、把答错率从百分之二十几降到个位数,但做不到归零。而且要注意:RAG 甚至可能让错误变得更难发现——因为一个更强的模型会用更自信的语气,把检索到的错误信息包装得更像真的。

    所以配套手段是必须的:要求标注出处、加引用校验、对高风险问题保留人工审核。

    Q2:我需要先把文档整理干净吗?

    需要,而且这一步的价值被严重低估。

    真实的文档里充满了 PDF 表格错乱、页眉页脚混进正文、扫描件没有文字层、同一份文件有五个版本。这些脏数据会直接变成检索噪声。很多团队在检索算法上折腾很久,最后发现问题其实出在文档解析环节。

    建议的顺序是:先做文档解析和清洗,再谈切块,最后才是检索优化。

    Q3:向量数据库该选哪个?

    如果你已经有 PostgreSQL,先用 pgvector——少维护一个组件,比什么都重要。

    只有当你的向量规模(通常到百万级以上)或性能要求确实顶不住时,再考虑上专门的向量数据库。前期过早引入新组件,往往只是在给自己增加运维负担。

    Q4:RAG 和"给模型加联网搜索"是一回事吗?

    原理上是同一类思路——都是"先去拿资料,再基于资料回答"

    区别在资料的范围和可控性:联网搜索面向公开互联网,你无法控制它取到什么;RAG 面向你自己的知识库,你能控制内容、更新节奏和访问权限。企业场景绝大多数用 RAG,因为需要可控和可审计。

    Q5:RAG 是不是已经过时了,现在都讲 Agent?

    恰恰相反。

    进入 2026 年,确实有很多文章在说"RAG 已死",但主流观察是:RAG 不是被取代了,而是被吸收——它变成了智能体工具箱里的一件工具。智能体决定"要不要检索、检索几次、用什么方式检索",而底下干活的仍然是检索层。

    从企业数据看,RAG 的采用率是在持续上升的(31% → 51%),智能体架构则是从零起步、正在增长。这两个不是替代关系。

    数据时效声明

    本文涉及的所有第三方数据均来自公开渠道,采集时间为 2026 年 9 月 17 日

    需要特别说明的是:AI 领域的产品参数、价格和模型能力变化很快,本文引用的具体数字(尤其是价格、模型版本、基准分数)请以官方最新文档为准。以下是几个值得留意的时效点:

    • Anthropic 的上下文检索数据发布于 2024 年,其原始文章在后来有过补充说明;文中引用的成本数字基于当时的提示词缓存价格。
    • Menlo Ventures 的采用率数据来自 2024 年调研(样本 600 位企业 IT 决策者),该机构 2025 年的报告采用了不同的统计口径,两组数字不宜直接比较。
    • NVIDIA 的切块策略基准来自其官方技术博客,测试数据集以英文金融与技术文档为主,中文语料的实际表现需要自行验证。
    • 本文第四、五、六节的代码输出均为本机实跑结果,运行环境为 Python 3.13,可复现。

    参考资料

    下面这些是我在写作过程中实际查阅过的公开资料,按在文中首次出现的顺序排列。其中的链接我都逐条访问核对过,文中引用的数字均能在这些来源中找到。

    • Lewis et al.,《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》,arXiv:2005.11401NeurIPS 2020arxiv.org/abs/2005.11401
    • Anthropic,《Introducing Contextual Retrieval》(www.anthropic.com/news/contextual-retrieval
    • Liu et al.,《Lost in the Middle: How Language Models Use Long Contexts》,TACL 2024
    • NVIDIA Technical Blog,《Finding the Best Chunking Strategy for Accurate AI Responses》(developer.nvidia.com
    • Menlo Ventures,《2024: The State of Generative AI in the Enterprise》(menlovc.com
    • 微软亚洲研究院,《当工业知识遇上 PIKE-RAG:昕诺飞客服效率跃升背后的技术突破》(www.microsoft.com/en-us/research
    • Databricks Mosaic Research 关于长上下文与 RAG 的对比实验(经第三方文献综述转引)
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » AI 一本正经地胡说八道?80 行 Python 手搓 RAG,把切块、向量、检索、重排每一步都拆开看
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!