RAG 是什么,为什么需要

把闭卷考试改成开卷:模型的脑子一点没变,变的只是它手边多了几页刚查来的资料。

30″30 秒看懂 RAG

把大模型想成一位参加闭卷考试的学霸:他读过的书多得吓人,但你公司那份《物流时效手册》他没读过,昨天刚改的价目表他更不可能读过。闭卷状态下问他这些,他只有两个选择——说不知道,或者编一个像模像样的答案。他通常选后者。

RAG 做的事就一件:把这场考试改成开卷。考生手边多了一个活页夹;开考前,一位资料员拿着题目跑进资料室,从几万张活页里挑出最相关的三五页,夹进活页夹。考生翻开活页夹,照着上面的原文答题。

考生的脑子一点没变,变的只是他手边多了几页纸。

图① 30 秒看懂:开卷考试里的五个角色
图① 30 秒看懂:开卷考试里的五个角色
考场里的角色对应的技术概念它到底干了什么
考生大模型(LLM)只负责读活页夹里的内容、组织成人话,不自己去翻书架
题目用户问题既是要回答的东西,也是资料员据以找资料的线索
资料室知识库你的私有文档:制度、手册、合同、运单
一张活页chunk(切块)一张活页装一个完整的意思,切法决定了后面所有环节的上限
活页上的坐标编号Embedding(向量)把一段文字换算成一串数字,意思相近的数字就挨得近
资料员检索器(retriever)拿题目去比坐标,挑出最近的几张活页
活页夹里的那几页context(上下文)被拼进提示词、真正送到模型眼前的内容
答案里标的页码引用出处让人能回头核对,这是知识库问答能不能验收的关键
⛔ 整讲只有一条铁律 没夹进活页夹的那一页,考生再聪明也答不出来。效果的天花板落在「活页怎么切」和「资料员找不找得回来」这两步上,不在提示词上。答得不好时先去看检索回了什么,别一头扎进提示词里改措辞——那是在给一个没拿到资料的人重写命题作文。
这一讲为什么排在 Function Call 之后 两者都是「给模型接外部世界」,但接法不同:Function Call 是模型自己决定要打哪个电话、让你去办事;RAG 是你的程序先把资料查好、塞进它嘴边,模型全程不知道有检索这回事。第 01 节会把这条边界连同微调一起讲死,因为这三者在项目选型时几乎每次都会被混为一谈。

01概念:模型缺的到底是什么

三个硬伤、RAG 的定义,以及它和 Function Call、微调的边界

1.1 闭卷考生的三个硬伤

大模型的知识是训练那一刻冻结的。这一句话直接派生出三个在项目里天天遇到的问题:

硬伤典型现象为什么提示词救不了
知识截止问上周发布的新规,答的是两年前的旧规训练数据里就没有这件事,再怎么强调「请用最新信息」也变不出来
私有知识缺失问公司内部制度、某张运单的状态,全是编的这些资料从未公开,模型不可能在训练时见过
答案不可溯源答得头头是道,但没人能验证它从哪来模型的输出是概率生成的结果,它自己也说不出依据在第几页

第三条最容易被忽略,却往往是企业项目能不能上线的分水岭。客服说错一句赔偿政策、法务引错一条款,代价不是「体验不好」,而是真金白银。一个不能回头核对的答案,在很多场景里等于没有答案。

幻觉不是模型「坏了」 模型被要求回答一个它手上没有依据的问题时,编造是它唯一的出路——生成式模型的工作方式就是「接着往下写最像的内容」,它没有「我不知道」这个默认档位。所以治幻觉有两条路:给它依据(RAG),给它拒答的权利和话术(提示词)。两条都要,缺一条都不够。

1.2 RAG 是什么

RAG(Retrieval-Augmented Generation,检索增强生成)是一种在生成之前先检索、把检索结果拼进提示词的做法。它不是某个框架、某个产品,而是一条流程约定:

检索 Retrieval拿问题去私有资料里找相关片段
增强 Augmented把找到的片段拼进提示词
生成 Generation模型照着这些片段作答

三个词就是三个动作,顺序不能换。关键在于:模型的权重一个比特都没变。同一个模型,今天接物流知识库就是物流专家,明天换成法务文档就是法务助手——换的只是资料员往活页夹里夹什么。

1.3 和 Function Call、微调的边界

这三者在选型会上几乎每次都会被混在一起讨论,因为它们看起来都在「让模型变得更能干」。但它们改的东西完全不同:

图② RAG、Function Call、微调分别改了什么
图② RAG、Function Call、微调分别改了什么
维度RAGFunction Call微调 Fine-tune
改的是什么喂进去的资料模型能发起的动作模型权重本身
谁发起你的程序先检索,模型被动接收模型自己决定调不调、调哪个不涉及,能力已经长在权重里
拿回来的是相似的文本片段函数执行的真实结果
擅长私有文档问答、政策法规、手册制度实时状态、精确计算、执行动作风格、术语习惯、输出格式、领域语感
不擅长实时数值、聚合统计、精确计算开放式的长文档理解频繁变动的知识(改一次就要重训)
资料更新成本重新入库,分钟级接口自己就是最新的重新训练,天级 + 算力钱
能不能标出处,这是它的独有优势能给出函数返回值不能
要不要 GPU推理要,建库只要能跑 Embedding不要要,而且是大头

一句话记住它们的分工:

⛔ 三条路,三个动词 RAG 是「查」,Function Call 是「办」,微调是「教」。问「制度怎么规定的」要查;问「这单现在到哪了」要办(调接口);要它「永远按公司模板说话」得教。拿错工具的典型症状:用微调去装最新知识,训完发现数据又变了;用 RAG 去查实时库存,查回来的是上个月入库的那份快照。

1.4 选型不靠感觉,靠信号

真实项目里很少是纯粹的某一类。「客服既要查产品手册,又要查订单状态」就是 RAG + Function Call 的组合。与其背结论,不如把判断拆成可观察的信号,让分数自己排出来:

route_decide.py —— 把选型拆成可观察的信号选型
"""三条路怎么选:RAG、Function Call、微调的路由打分。

不写死"什么场景一定用什么",而是把判断拆成几个可观察的信号,
每个信号给三条路各自加权。换了业务只需要加信号,不用改结构。

纯标准库,直接运行可看到六个真实需求的判定过程。
"""

# 每个信号是一个 (问题, 三条路的加权) 三元组。
# 权重是经验值,重点在"哪条路被这个信号支持",不在数字本身。
SIGNALS = [
    ("答案写在某份文档里,能指出是哪一段吗", {"rag": 3, "fc": 0, "ft": -1}),
    ("资料是否经常更新(周级或更快)", {"rag": 3, "fc": 1, "ft": -3}),
    ("需要回答时给出出处,让人能去核对", {"rag": 3, "fc": 0, "ft": -2}),
    ("答案要取自实时状态(库存、订单、天气)", {"rag": -1, "fc": 3, "ft": -2}),
    ("要让模型真的去做一个动作(下单、发消息、写库)", {"rag": -2, "fc": 3, "ft": -1}),
    ("答案需要一次精确计算而不是复述", {"rag": -1, "fc": 3, "ft": 0}),
    ("要改变的是说话风格、术语习惯或输出格式", {"rag": 0, "fc": 0, "ft": 3}),
    ("要让模型掌握一种它没见过的任务范式", {"rag": 0, "fc": 0, "ft": 3}),
    ("有数千条以上标注好的样本可用", {"rag": 0, "fc": 0, "ft": 2}),
    ("推理延迟和单条成本必须压到最低", {"rag": -1, "fc": -1, "ft": 2}),
]

NAMES = {"rag": "RAG 检索增强", "fc": "Function Call 函数调用", "ft": "微调 Fine-tune"}


def decide(answers):
    """answers: 与 SIGNALS 等长的布尔列表。返回按得分排序的三条路。"""
    score = {"rag": 0, "fc": 0, "ft": 0}
    for hit, (_q, w) in zip(answers, SIGNALS):
        if hit:
            for k in score:
                score[k] += w[k]
    return sorted(score.items(), key=lambda kv: kv[1], reverse=True)


CASES = [
    ("内部制度问答:员工问年假怎么休",
     [1, 1, 1, 0, 0, 0, 0, 0, 0, 0]),
    ("查某个订单现在到哪了",
     [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]),
    ("让模型按公司模板写周报",
     [0, 0, 0, 0, 0, 0, 1, 0, 1, 1]),
    ("客服问答:既要查产品手册,又要查订单状态",
     [1, 1, 1, 1, 0, 0, 0, 0, 0, 0]),
    ("把长篇法条改写成客户听得懂的话,并注明依据",
     [1, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
    ("医疗问诊:需要专业语感 + 严格的问答格式",
     [0, 0, 0, 0, 0, 0, 1, 1, 1, 0]),
]


def main():
    for title, answers in CASES:
        ranked = decide(answers)
        top, second = ranked[0], ranked[1]
        print("需求:%s" % title)
        for key, val in ranked:
            bar = "█" * max(val, 0)
            print("    %-24s %3d %s" % (NAMES[key], val, bar))
        # 两条路分差小于 2 时,实践中多半是"要一起上",而不是二选一
        verdict = NAMES[top[0]]
        if top[1] - second[1] < 2:
            verdict = "%s + %s(组合)" % (NAMES[top[0]], NAMES[second[0]])
        print("    → 建议:%s\n" % verdict)


if __name__ == "__main__":
    main()

跑出来的六个真实需求,判定如下:

需求判定为什么
员工问年假怎么休RAG答案写在制度文档里,要能指出依据,制度还会改
查某个订单现在到哪了Function Call答案在订单系统的实时状态里,文档里根本没有
按公司模板写周报微调要改的是输出格式和语感,不是知识
客服既查手册又查订单RAG + Function Call两类信号同时强,组合而不是二选一
把法条改写成人话并注明依据RAG(+ 微调调风格)依据必须可溯源,改写风格可以后续微调
医疗问诊,要专业语感 + 严格格式微调语感与格式长在权重里比写在提示词里稳定
先上 RAG,是有工程道理的 三条路里 RAG 的验证成本最低:不用 GPU、不用标注数据、资料改了重新入库就行,做错了推倒重来的代价接近于零。微调正好相反——标注、训练、评估、部署,任何一环判断失误都要重来一遍。所以工程上的常规顺序是:先用 RAG 把能解决的问题解决掉,剩下真正解决不了的,才去考虑微调。

02原理:八个步骤,分成两个阶段

离线建库四步、在线问答四步,以及「为什么不干脆全塞进去」

2.1 两个阶段,跑的次数完全不同

RAG 最容易被讲糊涂的地方,是把八个步骤排成一条直线。实际上它们分属两个阶段,跑的频率差好几个数量级

图③ 离线建库四步与在线问答四步
图③ 离线建库四步与在线问答四步
阶段什么时候跑步骤产物
离线建库资料更新时跑一次① 加载 ② 切分 ③ 向量化 ④ 入库磁盘上的一个向量库目录
在线问答每次提问都跑⑤ 问题向量化 ⑥ 检索 ⑦ 拼提示词 ⑧ 生成一条带出处的答案

这个划分有很强的工程含义:建库慢一点没关系(一万份文档跑两小时也能接受),问答的每一毫秒都要计较(用户在等)。所以那些昂贵的操作——OCR、精细切分、大模型抽摘要——统统往离线挪;在线只留「算一次问题的向量 + 查一次索引 + 调一次模型」。

2.2 离线四步,每一步都在为检索做准备

  1. 加载:把 PDF、Word、网页变成纯文本,同时记住它从哪来(文件名、页码)。来源信息在这一步丢了,后面就再也标不出出处。
  2. 切分:把长文档切成一张张活页。一张活页要装一个完整的意思——这是全流程里最影响效果、却最容易被随手写死的一步。
  3. 向量化:每张活页过一遍 Embedding 模型,得到一串定长浮点数。意思相近的活页,这串数字也接近。
  4. 入库:把「向量 + 原文 + 来源」一起存进向量库,建好索引供快速查找。
向量库里存的不只是向量 只存向量是新手最常见的设计失误:检索回来一堆数字,没法拼进提示词,也没法标出处。向量是用来找的,原文才是用来读的,来源是用来核对的,三样必须一起存。

2.3 在线四步,模型只参与最后一步

  1. 问题向量化:用同一个 Embedding 模型把问题也变成向量。同一个,是硬约束,2.4 节细说。
  2. 检索:在向量库里找与问题向量最接近的 k 张活页。
  3. 拼提示词:把这几张活页的原文塞进一段模板,连同问题一起交给模型。
  4. 生成:模型读活页夹,写答案。

注意前三步完全没有模型参与(Embedding 模型不产出文字,只产出坐标)。所以一个 RAG 系统答得好不好,八成的功夫花在模型之外。

2.4 一条硬约束:建库和提问必须用同一个 Embedding

Embedding 模型定义的是一套坐标系。A 模型把「广州」放在坐标 (0.12, −0.35, …),B 模型可能放在完全不同的位置。用 A 建的库、用 B 去问,等于拿着上海地图在北京找路——语法上一切正常,检索结果全是噪声,而且不会报任何错

⛔ 换 Embedding 模型 = 整库作废 换了模型,旧库里所有向量必须全部重算。这也意味着:选 Embedding 模型要在建库之前想清楚,因为改主意的代价是把整个库重跑一遍。维度对不上时还会直接抛维度错误——那算运气好的,最怕的是维度恰好相同,于是静默地给你一堆错结果。

2.5 「上下文窗口都几十万了,为什么不全塞进去?」

这是学到这里必然会冒出来的疑问。答案不是「塞不下」,而是三笔账都划不来。前两笔能算:

context_cost.py —— 全量塞入与检索后送的成本对账可运行
"""算一笔账:每次提问都把整本资料塞进提示词,代价是多少。

"既然模型的上下文窗口越来越大,为什么不直接把所有文档都塞进去?"
这是学 RAG 时最常见的疑问。答案不是"塞不下",而是三笔账都划不来:
钱、延迟、准确率。这个脚本把前两笔算成具体数字。

纯标准库,直接 python3 context_cost.py 就能跑。
"""

# 中文按字计 token 偏保守:一个汉字通常落在 1 个 token 上下,
# 这里取 1.0,估出来的是量级而不是精确账单。
TOKENS_PER_CHINESE_CHAR = 1.0

# 一份中等规模的企业资料:500 份文档,每份平均 3000 字
DOC_COUNT = 500
CHARS_PER_DOC = 3000

# 检索方案:每次只取回 4 个块,每块 400 字
TOP_K = 4
CHARS_PER_CHUNK = 400

QUERIES_PER_DAY = 2000

# 输入价格,单位:元 / 百万 token。改成自己实际用的模型价格再跑一遍。
PRICE_PER_MTOK = 2.0


def tokens(chars):
    return chars * TOKENS_PER_CHINESE_CHAR


def yuan(tok):
    return tok / 1_000_000 * PRICE_PER_MTOK


def main():
    full_chars = DOC_COUNT * CHARS_PER_DOC
    rag_chars = TOP_K * CHARS_PER_CHUNK

    full_tok = tokens(full_chars)
    rag_tok = tokens(rag_chars)

    print("全量塞入:每次提问送进去 %s 字 ≈ %s token" % (f"{full_chars:,}", f"{int(full_tok):,}"))
    print("检索后送:每次提问送进去 %s 字 ≈ %s token" % (f"{rag_chars:,}", f"{int(rag_tok):,}"))
    print("倍数差距:%.0f 倍" % (full_tok / rag_tok))
    print("-" * 56)

    full_day = yuan(full_tok) * QUERIES_PER_DAY
    rag_day = yuan(rag_tok) * QUERIES_PER_DAY
    print("按 %s 次/天、%.1f 元/百万 token 估算(只算输入):" % (f"{QUERIES_PER_DAY:,}", PRICE_PER_MTOK))
    print("  全量塞入:%10.2f 元/天 %12.2f 元/年" % (full_day, full_day * 365))
    print("  检索后送:%10.2f 元/天 %12.2f 元/年" % (rag_day, rag_day * 365))
    print("  一年省下:%10.2f 元" % ((full_day - rag_day) * 365))
    print("-" * 56)

    # 第三笔账没法用公式算,但必须知道它存在:
    # 上下文里无关内容越多,模型越容易被带偏。检索的价值不只是省钱。
    print("还有一笔算不出来的账:")
    print("  送进去 %s token,其中真正相关的只有 %s token," % (f"{int(full_tok):,}", f"{int(rag_tok):,}"))
    print("  剩下 %.2f%% 全是干扰项。" % ((1 - rag_tok / full_tok) * 100))
    print("  上下文越长,模型越容易漏掉夹在中间的关键句——这是省钱之外的主因。")

    # 上下文窗口能不能装得下,也顺手验一下
    for window in (32_000, 128_000, 1_000_000):
        fit = "装得下" if full_tok <= window else "装不下"
        print("  窗口 %9s token:%s" % (f"{window:,}", fit))


if __name__ == "__main__":
    main()

用「500 份文档 × 每份 3000 字」这个中等规模跑一遍,结果是:

口径全量塞入检索后送 4 块差距
每次提问送进去1,500,000 字 ≈ 150 万 token1,600 字 ≈ 1,600 token938 倍
每天 2000 次的输入成本6000.00 元/天6.40 元/天一年差 218 万元
相关内容占比0.11%接近 100%其余 99.89% 是干扰
128K 窗口装得下吗装不下轻松

第三笔账算不出来,但它才是主因:上下文越长,模型越容易漏掉夹在中间的关键句。把 99.89% 的无关内容摆到它眼前,等于让考生在一整座图书馆里现场找答案——找得到是运气,找不到是常态。检索的价值从来不只是省钱,而是把信噪比从 0.11% 拉到接近 100%

窗口变大改变了什么 长窗口确实让 RAG 变轻松了:以前要把 k 压到 2、块切到 200 字,现在可以放宽到 k=5、块 500 字,容错空间大了很多。但它没有取代 RAG——省钱、降噪、可溯源这三件事,一个更大的窗口一件都解决不了。

2.6 RAG 改变的到底是哪段文本

说到底,RAG 对模型的全部影响,就是把送进去的那段文本改了。把两种提示词并排打印出来,这件事就一目了然:

prompt_with_context.py —— 有无 context 的提示词对照可运行
"""同一个问题、同一个模型,给不给 context 的差别在哪。

这个脚本不联网、不调模型,它只做一件事:把两种提示词完整打印出来,
放在一起看。RAG 改变的就是这段文本,别的什么都没改。

顺带演示三条必须写进模板的纪律:
  1. 明确圈定"只能用已知信息"
  2. 给出兜底话术,让模型有"不知道"可说
  3. 让模型带上出处编号,答案才可核对
"""

QUESTION = "货物 ABC123456 现在到哪了?预计什么时候到?"

CHUNKS = [
    ("物流信息.pdf#p1", "货物编号:ABC123456,发货日期:2023-01-15,当前位置:上海分拨中心。"),
    ("物流信息.pdf#p1", "预计到达日期:2023-01-20。"),
]

NAKED = """{question}"""

GROUNDED = """你是物流信息查询助手。请只根据【已知信息】回答【问题】。

规则:
1. 只使用【已知信息】里出现过的内容,不允许补充任何外部知识。
2. 【已知信息】不足以回答时,回答"资料中未提及",不要推测。
3. 答案末尾用方括号列出你用到的信息来源编号。

【已知信息】
{context}

【问题】
{question}"""


def build_context(chunks):
    """每块前面加编号,模型才有东西可引用。

    只把正文拼成一坨,模型就算想标出处也无从标起——
    "让它给出处"这件事一半以上是在这一步实现的,不是靠提示词多说一句。
    """
    lines = []
    for i, (source, text) in enumerate(chunks, 1):
        lines.append("[%d] (来源:%s%s" % (i, source, text))
    return "\n".join(lines)


def main():
    print("=" * 62)
    print("A. 不做 RAG:模型手上只有问题")
    print("=" * 62)
    print(NAKED.format(question=QUESTION))
    print()
    print("模型此时的处境:它从没见过这份运单。")
    print("它要么说不知道,要么按训练数据里见过的运单格式编一个日期出来。")
    print("这就是幻觉——不是模型坏,是它被要求回答一个手上没有依据的问题。")

    print()
    print("=" * 62)
    print("B. 做了 RAG:同一个问题,前面多了两段检索回来的原文")
    print("=" * 62)
    prompt = GROUNDED.format(context=build_context(CHUNKS), question=QUESTION)
    print(prompt)

    print()
    print("-" * 62)
    print("提示词长度:无 context %d 字符 → 有 context %d 字符"
          % (len(NAKED.format(question=QUESTION)), len(prompt)))
    print("多出来的部分全是原文和规则,没有一个字是模型自己想出来的。")

    # 把检索结果换成空的,看模板还撑不撑得住
    print()
    print("=" * 62)
    print("C. 检索什么都没找到:模板必须能兜住")
    print("=" * 62)
    empty = GROUNDED.format(context="(无)", question="公司年假怎么休?")
    print(empty)
    print()
    print("规则 2 在这里起作用:模型应当回答「资料中未提及」。")
    print("没有这条规则,空 context 反而最容易触发编造。")


if __name__ == "__main__":
    main()

脚本里有三条写模板时的纪律,每一条都对应一类线上事故:

纪律不做会怎样怎么做
圈定范围模型把自己的先验知识和检索内容混着用,分不清哪句有依据明写「只使用【已知信息】里出现过的内容」
给拒答话术检索为空时最容易触发编造明写「不足以回答时回复『资料中未提及』」
每块带编号模型想标出处也无从标起拼 context 时给每块加 [1] [2] 和来源

第三条值得多说一句:「让模型给出处」这件事,一大半是在拼 context 那一步实现的,不是靠提示词多说一句。把原文拼成一坨没有编号的文本,再怎么要求它标注,它也只能瞎编页码。

03最小代码:不装任何库,把五步跑一遍

向量化 = 变数字,检索 = 比夹角,生成 = 拼字符串

讲 RAG 最容易劝退的地方,是一上来就 pip install 四个框架,然后在依赖冲突里耗掉一个下午——学完还是不知道里面发生了什么。这一节反过来:用纯标准库写一个能跑的 RAG 内核,五个步骤一个不少,总共不到八十行。

唯一的简化是把 Embedding 换成了词袋(bag of words):把一段话拆成字,数每个字出现几次,这一串计数就是它的「向量」。它当然远不如真正的 Embedding,但它确实是一个向量,足以让后面四步原样成立。

mini_rag.py —— 五步俱全的最小 RAG 内核可运行
"""最小 RAG 内核:不装任何第三方库,把五个步骤跑一遍。

这份文件的目的不是做一个好用的检索器,而是证明 RAG 没有魔法:
向量化 = 把文字变成一串数字,检索 = 比较两串数字的夹角,
生成 = 把找回来的文字拼进一段提示词。

这里用最朴素的词袋(bag of words)当作向量化,
真实项目里换成 Embedding 模型即可,其余四步一个字都不用改。
"""
import math
import re
from collections import Counter

# 知识库:每一条就是一个 chunk(切好的一块文本)
CHUNKS = [
    "速达物流公司总部在北京市,业务范围是国际快递和仓储管理。",
    "货物编号 ABC123456 的发货日期是 2023-01-15,当前位置在上海分拨中心。",
    "该批货物的预计到达日期是 2023-01-20。",
    "运输公司是快运通,运输方式为陆运,出发地广州,目的地重庆,预计运输时间 3 天。",
    "仓库名称是东方仓储中心,位于深圳市,存储电子产品,常温仓储,当前库存 1000 件。",
]


def tokenize(text):
    """中文按字切,英文数字按词切。

    中文没有空格,直接按空格切会把整句话当成一个词,
    词袋里只剩一个维度,任何两句话的相似度都会变成 0 或 1。
    """
    text = text.lower()
    tokens = re.findall(r"[a-z0-9]+", text)          # 英文与数字整体保留
    tokens += re.findall(r"[\u4e00-\u9fff]", text)   # 汉字逐字拆
    return tokens


def embed(text):
    """把一段文字变成 {词: 次数} 的稀疏向量。

    换成真正的 Embedding 模型时,这里返回的是一个定长浮点数列表,
    下面的 cosine 一行都不用改——它只依赖"向量"这个抽象。
    """
    return Counter(tokenize(text))


def cosine(v1, v2):
    """余弦相似度:两个向量夹角的余弦值,范围 [-1, 1],词袋下恒为 [0, 1]。"""
    common = set(v1) & set(v2)
    dot = sum(v1[k] * v2[k] for k in common)
    n1 = math.sqrt(sum(x * x for x in v1.values()))
    n2 = math.sqrt(sum(x * x for x in v2.values()))
    if n1 == 0 or n2 == 0:
        return 0.0
    return dot / (n1 * n2)


def build_index(chunks):
    """离线阶段:把每个 chunk 算成向量存起来。"""
    return [(c, embed(c)) for c in chunks]


def retrieve(index, question, k=2):
    """在线阶段:问题也算成向量,按相似度排序取前 k 个。"""
    qv = embed(question)
    scored = [(cosine(qv, cv), c) for c, cv in index]
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored[:k]


PROMPT_TEMPLATE = """基于以下已知信息,简洁、专业地回答用户问题。
已知信息里没有的内容一律回答"资料中未提及",不允许编造。

已知信息:
{context}

问题:
{question}"""


def build_prompt(hits, question):
    context = "\n".join(text for _score, text in hits)
    return PROMPT_TEMPLATE.format(context=context, question=question)


if __name__ == "__main__":
    index = build_index(CHUNKS)
    question = "我的快递出发地是哪?预计几天到?"

    hits = retrieve(index, question, k=2)
    for score, text in hits:
        print("%.4f  %s" % (score, text))

    print("-" * 60)
    print(build_prompt(hits, question))

    # 库里根本没有的问题:检索照样会返回分数最高的两条,
    # 挡住编造的是提示词里那句"未提及",不是检索本身。
    print("-" * 60)
    miss = retrieve(index, "公司的年假制度是怎么规定的?", k=2)
    for score, text in miss:
        print("%.4f  %s" % (score, text))

跑起来是这样的:

环节代码里的落点它做了什么
① 加载CHUNKS 常量这里直接写死,真实项目换成文档加载器
② 切分已经是切好的每条就是一张活页
③ 向量化embed()返回 {字: 次数};换成 Embedding 模型时返回定长浮点列表
④ 检索cosine() + retrieve()和每张活页比一次夹角,排序取前 k
⑤ 生成build_prompt()拼进模板;这里不调模型,把提示词打印出来看

注意 tokenize() 里那两行正则:中文逐字拆,英文数字整词保留。直接按空格切会把整句中文当成一个词,词袋里只剩一个维度,任何两句话的相似度不是 0 就是 1——这个坑在第 02 页还会以另一种面目出现。

跑出来的结果,包括不太漂亮的那部分

问「我的快递出发地是哪?预计几天到?」,取回来的两条是:

相似度取回的活页答得上吗
0.3450该批货物的预计到达日期是 2023-01-20。只答了半个
0.3422运输公司是快运通,运输方式为陆运,出发地广州,目的地重庆,预计运输时间 3 天。能答全

能答全的那条排在了第二。这不是 bug,而是词袋方法的真实能力边界:它只会数字重合,「预计」「到达」「日期」这些字在第一条里更密集,于是分数更高。真正的 Embedding 模型能理解「出发地」和「从哪发出」是一回事,词袋不能。

这个「缺陷」正好说明了两件事 第一,k 不能取 1——排第一的不一定是该用的那条,这是后面 top-k 与 Rerank 存在的全部理由。第二,换更好的 Embedding 模型确实能提升效果,但它提升的是「排序准不准」,而不是「有没有找回来」。这两件事要分开量,否则调参时永远说不清是谁的功劳。

问一个库里根本没有的问题

脚本最后故意问了句「公司的年假制度是怎么规定的?」。结果是:照样返回两条,分数 0.2000 和 0.1653,内容全是物流信息。

这是 top-k 检索的天生毛病——它永远返回 k 条,哪怕库里一条相关的都没有。挡住编造的不是检索,而是提示词里那句「已知信息里没有的一律回答『资料中未提及』」,以及第 03 页要讲的分数阈值。

把这条记进肌肉记忆 检索永远有结果,不代表结果相关。任何一个上线的 RAG 系统都必须回答一个问题:「当库里真的没有答案时,它会说什么?」没想清楚这一条就上线,第一个问超纲问题的用户就会拿到一个一本正经的假答案。

04完整案例:把一份属性表变成能问答的知识库

五步一步不少,跑得出真实输出,也跑得出真实的失败

4.1 场景与资料

案例刻意选得很小:一份电商商品的属性表,一共 371 个字符、25 行。小到可以把每一步的中间结果都打印出来逐行核对——这正是学新东西时最需要的。

size_kb.txt —— 知识库原文,每行一条独立事实资料
身高:160-170cm, 体重:90-115斤,建议尺码M。
身高:165-175cm, 体重:115-135斤,建议尺码L。
身高:170-178cm, 体重:130-150斤,建议尺码XL。
身高:175-182cm, 体重:145-165斤,建议尺码2XL。
身高:178-185cm, 体重:160-180斤,建议尺码3XL。
身高:180-190cm, 体重:180-210斤,建议尺码4XL。
面料分类:其他
图案:纯色
领型:翻领
衣门襟:单排扣
颜色:黑色 卡其色 粉色 杏色
袖型:收口袖
适用季节:冬季
袖长:长袖
厚薄:厚款
适用场景:其他休闲
衣长:常规款
版型:宽松型
款式细节:假两件
工艺处理:免烫处理
适用对象:青年
面料功能:保暖
穿搭方式:外穿
销售渠道类型:纯电商(只在线上销售)
材质成分:棉100%

这份资料有个很好的性质:每一行本身就是一条完整、独立的事实。所以切分这一步可以直接按行切——切分规则应当顺着资料的结构走,不是不管什么资料都套一个固定的 chunk_size

4.2 完整代码

size_qa.py —— 加载 → 切分 → 向量化 → 检索 → 拼提示词可运行
"""完整案例:把一份商品属性表变成可问答的知识库。

场景很小,但五步一步不少:加载 → 切分 → 向量化 → 检索 → 拼提示词。
数据就是一份普通的商品属性文本,读者换成自己的文件即可复用。

纯标准库,可以直接运行。真实项目里把 embed() 换成 Embedding 模型、
把线性扫描换成向量库,其余逻辑原封不动。
"""
import math
import os
import re
from collections import Counter

HERE = os.path.dirname(os.path.abspath(__file__))
KB_FILE = os.path.join(HERE, "size_kb.txt")


def load(path):
    """第一步:加载。真实项目里这里换成 PDF / Word / 网页加载器。"""
    with open(path, encoding="utf-8") as f:
        return f.read()


def split_by_line(text):
    """第二步:切分。

    这份资料每一行就是一条独立事实,行本身就是天然的语义边界,
    按行切比按固定字数切更好——切分规则应当顺着资料的结构走,
    而不是不管什么资料都套一个 chunk_size。
    """
    return [ln.strip() for ln in text.splitlines() if ln.strip()]


def tokenize(text):
    text = text.lower()
    return re.findall(r"[a-z0-9]+", text) + re.findall(r"[\u4e00-\u9fff]", text)


def embed(text):
    """第三步:向量化(此处用词袋代替 Embedding 模型)。"""
    return Counter(tokenize(text))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


class MiniStore:
    """第三步的落点:一个最小向量库,只有 add 和 search 两个动作。"""

    def __init__(self):
        self.items = []

    def add(self, chunks):
        for c in chunks:
            self.items.append((c, embed(c)))

    def search(self, query, k=3, score_min=0.0):
        qv = embed(query)
        hits = [(cosine(qv, v), c) for c, v in self.items]
        hits.sort(key=lambda x: x[0], reverse=True)
        return [h for h in hits[:k] if h[0] >= score_min]


TEMPLATE = """你是导购助手,只根据【已知信息】回答顾客问题。
已知信息里没有的,回答"这个资料里没写",不要猜。

【已知信息】
{context}

【顾客问题】
{question}"""


def answer(store, question, k=3, score_min=0.05):
    """第四、五步:检索 + 拼提示词。"""
    hits = store.search(question, k=k, score_min=score_min)
    if not hits:
        return None, "检索没有命中任何内容,直接回复顾客:这个资料里没写。"
    context = "\n".join("- %s" % text for _s, text in hits)
    return hits, TEMPLATE.format(context=context, question=question)


def main():
    raw = load(KB_FILE)
    chunks = split_by_line(raw)
    print("原文 %d 字符 → 切成 %d 块" % (len(raw), len(chunks)))

    store = MiniStore()
    store.add(chunks)
    print("入库完成,库里 %d 条向量\n" % len(store.items))

    questions = [
        "我身高175体重140,穿多大码?",
        "这件衣服是什么材质的?",
        "有没有红色的?",
        "这件衣服适合夏天穿吗?",
    ]

    for q in questions:
        print("=" * 60)
        print("问:%s" % q)
        hits, prompt = answer(store, q)
        if hits is None:
            print(prompt)
            continue
        for score, text in hits:
            print("   %.4f  %s" % (score, text))
        print("-" * 60)
        print(prompt)
        print()


if __name__ == "__main__":
    main()

相比第 03 节的最小内核,这里多了两样后面会一直用到的东西:

新增代码位置为什么需要
MiniStoreadd() / search()把「存」和「查」收成一个对象的两个方法,换成真正的向量库时只换这个类
score_min 阈值search() 的入参低于这个分数的一律丢掉,让系统有能力说「资料里没写」

4.3 跑出来的四个问题

加载 371 字符,切成 25 块,入库 25 条向量。四个问题的结果分别是:

问题Top-1(分数)结果判定
我身高175体重140,穿多大码?身高:175-182cm,体重:145-165斤,建议尺码2XL。(0.4835)检索合理,但答案会错,见下
这件衣服是什么材质的?材质成分:棉100%(0.2582)✅ 正确
有没有红色的?颜色:黑色 卡其色 粉色 杏色(0.3175)✅ 取回了正确的那一行,模型据此答「没有红色」
这件衣服适合夏天穿吗?适用季节:冬季 / 厚薄:厚款✅ 正确

第三个问题值得停一下:知识库里根本没有「红色」这两个字,但系统答得出「没有红色」——因为它取回了完整的颜色行,让模型自己做的判断。这正是 RAG 比关键词搜索强的地方:找回来的是「相关的那一段」,不是「包含这个词的那一段」。

4.4 第一个问题为什么会答错

「身高175体重140」,按表格应当落在身高 170-178cm、体重 130-150斤 → XL 这一行。但检索排第一的是 2XL 那行,第二、三是 M 和 L。三行里偏偏没有正确的 XL。

原因很直白:词袋看到的是「175」这个字符串,而不是 175 这个数。它不知道 175 落在 170-178 这个区间里,只知道「175」和「175-182」有字符重合。

这不是模型的问题,是任务选错了工具 数值区间匹配根本不该交给语义检索。换成再强的 Embedding 模型,它也只是把「175 厘米」和「身高一米七五」对上,仍然做不了区间判断。正确的做法是:尺码这类结构化规则走一段代码(或一次 Function Call)去算,语义检索只负责「材质」「适用季节」这类描述性内容。知道哪些东西不该进知识库,和知道哪些该进,一样重要。

把这一条推广开,下面这张表是做任何一个知识库之前都该过一遍的:

内容类型适合放进知识库吗更合适的做法
制度、条款、手册、FAQ非常适合就是 RAG 的主场
产品描述、工艺说明适合同上
数值区间、尺码表、阶梯价不适合结构化存表,写一段判断逻辑
库存、订单状态、余额不适合Function Call 查实时接口
「本月销量前十」这类聚合不适合模型写 SQL 或直接调统计接口

4.5 把这个案例改成能标出处的

这个案例现在拼出来的 context 是裸文本,模型就算想标出处也无从标起。改法只要动一处:拼 context 时给每块加编号和来源

现在的 context改成带出处的
颜色:黑色 卡其色 粉色 杏色[1](来源:size_kb.txt 第 11 行)颜色:黑色 卡其色 粉色 杏色
材质成分:棉100%[2](来源:size_kb.txt 第 25 行)材质成分:棉100%

切分时把行号一起存进来,拼的时候写进去,提示词里再要求「每个结论后面标出编号」——三处配合,答案才真的可回溯。少任何一处,剩下两处都白做。

4.6 换成真实组件要改什么

这个案例到真实项目之间,只隔着三处替换,流程一行不用动:

现在换成注意什么
embed() 词袋Embedding 模型建库与提问必须同一个,换了要整库重建
MiniStore 线性扫描FAISS / Chroma / pgvector几千条以内线性扫描其实够用,别过早上重型组件
打印提示词真实模型调用密钥一律 os.environ.get(),不写进源码

05骨架模板:把 TODO 填掉就能用

五个函数对应五个步骤,换组件只换函数体

下面这份模板刻意把五步拆成五个互不纠缠的函数。它的价值不在于代码多短,而在于每一个会变的东西都被隔离在一个地方:换文档格式改 load_docs(),换切分策略改 split(),换 Embedding 改 embed(),换向量库改 VectorStore,换模型改 call_llm()。主流程 main() 一行都不用动。

rag_skeleton_min.py —— 五步骨架,六处 TODO模板
"""RAG 骨架模板:把 TODO 填掉就是一个可用的知识库问答。

用法
    1. 把 DOC_DIR 指向自己的资料目录
    2. 按注释换掉 embed / VectorStore / call_llm 三处实现
    3. python3 rag_skeleton_min.py "你的问题"

结构刻意分成五个函数,和 RAG 的五步一一对应。
换向量库、换 Embedding、换模型,改的都只是函数体,主流程不动。
"""
import math
import os
import re
import sys
from collections import Counter

# ---------------------------------------------------------------- 配置
DOC_DIR = os.environ.get("RAG_DOC_DIR", "./docs")   # TODO: 换成自己的资料目录
CHUNK_SIZE = 300        # TODO: 按资料形态调,见「切分」一节
CHUNK_OVERLAP = 50      # TODO: 一般取 chunk_size 的 10%~20%
TOP_K = 4               # TODO: 先从 3~5 试起
SCORE_MIN = 0.0         # TODO: 调完 k 之后再调这个,不要两个一起动


# ------------------------------------------------------- ① 加载
def load_docs(doc_dir):
    """返回 [(来源标识, 全文), ...]。

    TODO: 需要读 PDF / Word / 网页时,在这里接对应的加载器;
    加载器只负责"拿到纯文本 + 记住它从哪来"这两件事。
    """
    docs = []
    if not os.path.isdir(doc_dir):
        return docs
    for name in sorted(os.listdir(doc_dir)):
        path = os.path.join(doc_dir, name)
        if os.path.isfile(path) and name.lower().endswith((".txt", ".md")):
            with open(path, encoding="utf-8") as f:
                docs.append((name, f.read()))
    return docs


# ------------------------------------------------------- ② 切分
def split(text, size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
    """按段落聚合,超长再按字数硬切,块间保留 overlap 个字符。"""
    parts, buf = [], ""
    for para in re.split(r"\n\s*\n", text):
        para = para.strip()
        if not para:
            continue
        if len(buf) + len(para) <= size:
            buf = (buf + "\n" + para).strip()
        else:
            if buf:
                parts.append(buf)
            while len(para) > size:
                parts.append(para[:size])
                para = para[size - overlap:]
            buf = para
    if buf:
        parts.append(buf)
    return parts


# ------------------------------------------------------- ③ 向量化
def embed(text):
    """TODO: 换成真正的 Embedding 模型,返回定长浮点数列表。

    换实现时只需保证:入参是字符串,返回值能被 similarity() 处理。
    ⚠️ 建库与提问必须用同一个模型,换模型就要整库重建。
    """
    tokens = re.findall(r"[a-z0-9]+", text.lower()) + re.findall(r"[\u4e00-\u9fff]", text)
    return Counter(tokens)


def similarity(a, b):
    """余弦相似度。换成浮点向量后改成标准点积除模长即可。"""
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


# ------------------------------------------------------- ④ 存与检索
class VectorStore:
    """TODO: 上生产时换成向量库;接口保持 add / search 两个方法不变。"""

    def __init__(self):
        self.rows = []

    def add(self, source, chunk):
        self.rows.append({"source": source, "text": chunk, "vec": embed(chunk)})

    def search(self, query, k=TOP_K, score_min=SCORE_MIN):
        qv = embed(query)
        scored = [(similarity(qv, r["vec"]), r) for r in self.rows]
        scored.sort(key=lambda x: x[0], reverse=True)
        return [(s, r) for s, r in scored[:k] if s >= score_min]


# ------------------------------------------------------- ⑤ 生成
PROMPT = """请只根据【已知信息】回答【问题】。
【已知信息】里没有的内容,回答"资料中未提及",不要推测。
答案末尾用方括号标出用到的编号。

【已知信息】
{context}

【问题】
{question}"""


def build_prompt(hits, question):
    lines = ["[%d] (%s%s" % (i, r["source"], r["text"])
             for i, (_s, r) in enumerate(hits, 1)]
    return PROMPT.format(context="\n".join(lines) or "(无)", question=question)


def call_llm(prompt):
    """TODO: 换成真实的模型调用,密钥一律走环境变量。

        api_key = os.environ.get("LLM_API_KEY")
        ...
    """
    return "[未接入模型] 下面是本应发给模型的提示词:\n" + prompt


def main():
    question = sys.argv[1] if len(sys.argv) > 1 else "TODO: 在这里写一个测试问题"

    store = VectorStore()
    total = 0
    for source, text in load_docs(DOC_DIR):
        for chunk in split(text):
            store.add(source, chunk)
            total += 1
    print("入库 %d 块(来自 %s)" % (total, DOC_DIR))

    hits = store.search(question)
    for score, row in hits:
        print("  %.4f  %s  %s" % (score, row["source"], row["text"][:40].replace("\n", " ")))

    print("-" * 60)
    print(call_llm(build_prompt(hits, question)))


if __name__ == "__main__":
    main()

六处 TODO 的填法

TODO默认值怎么定
DOC_DIR./docs指向资料目录;先只放三五份做验证,别一上来全量灌
CHUNK_SIZE300按资料形态定,第 02 页有实验;中文按字符算时 200~500 是常见区间
CHUNK_OVERLAP50一般取 chunk_size 的 10%~20%;必须小于 chunk_size,否则窗口挪不动
TOP_K4先从 3~5 试起,用测试集量命中率再定
SCORE_MIN0.0先固定它调 k,再固定 k 调它;两个一起动就说不清是谁的功劳
embed() / call_llm()词袋 / 打印换成真实模型;密钥走 os.environ.get()
为什么把 similarity() 单独拎出来 换成真正的浮点向量后,embed() 的返回类型变了,similarity() 也要跟着改成标准的点积除模长。把它和 embed() 放在一起、都标成 TODO,是提醒这两处必须成对修改——只换其中一个,代码照样能跑,但算出来的分数毫无意义。这类「不报错的错」是 RAG 项目里最难查的一类。

第一次接真实组件的建议顺序

  1. 先只换 call_llm():其余全用词袋,先把「能拿到一个由检索内容支撑的答案」这条路走通。
  2. 再换 embed():换完立刻重跑一遍同样的问题,对比检索结果有没有变好。一次只换一个变量,否则效果变了也不知道是谁的功劳。
  3. 最后才换 VectorStore:几千条以内线性扫描完全够用,先把效果调对,再去解决速度问题。

从这份骨架到能上线,还缺四件事

骨架跑通只意味着「流程对了」。真正对外提供服务之前,下面四件事一件都不能少——它们在教学示例里全被省掉了,却是线上出事最集中的地方:

缺的东西不补会怎样最小补法
元数据没法按部门、日期、版本过滤,只能整库检索入库时把来源、部门、生效日期一起存;存了不用的代价,远小于没存
增量更新改一份文档要整库重建,大库根本受不了给每块一个稳定 ID(文件路径 + 块序号),更新时按文档删旧增新
检索日志用户说「答错了」,你连它当时取回了什么都不知道把问题、命中块 ID、分数、最终答案一起落盘
拒答回路检索为空时拿空 context 去问模型,编造风险最高检索为空直接回复用户,连模型都不调

第三条特别值得早做:检索日志是后面所有优化的原料。没有它,「效果不好」永远只能靠复现;有了它,哪些问题命中率低、哪些块常年排第一却没人用,都是可以直接查出来的。

别在第一步就上向量库 很多人学 RAG 的顺序是反的:先花两天装 Milvus,再花一天调通连接,最后发现检索效果差——而这时候已经分不清是切分的问题、Embedding 的问题,还是索引参数的问题了。先用最笨的线性扫描把效果调对,再换索引。笨方法的好处是它没有任何隐藏变量。

06易错点汇总

按「概念 / 选型 / 流程 / 提示词 / 验收」五类归并

⚠️ 一、概念层面

  • 以为 RAG 让模型「学会」了新知识。 一个字都没学。模型权重完全没动,它只是这一次答题时手边多了几页纸;下一次提问,活页夹是空的,一切从头来过。RAG 没有记忆,每次提问都是独立的一次开卷考试。
  • 把 RAG 和 Function Call 混为一谈。 RAG 由你的程序先检索、再塞给模型,模型全程不知道有检索这回事;Function Call 由模型自己决定调不调、调哪个,拿回的是函数执行的真实结果而不是相似文本。
  • 指望 RAG 解决实时数据问题。 向量库里存的是上次入库那一刻的快照。问「现在库存多少」,它老老实实告诉你上个月的数字,而且语气非常肯定。实时状态要走 Function Call。
  • 指望 RAG 做统计聚合。 「本月哪个仓库发货最多」需要把全部记录扫一遍再算,而检索只会给你最相似的 k 条。这类问题要么写 SQL,要么让模型写 SQL。
  • 把「相似」当成「正确」。 检索返回的是语义最接近的片段,不是正确答案所在的片段。作废的旧版制度和现行制度,在向量空间里几乎挨在一起。

⚠️ 二、选型与边界

  • 该用微调的场景硬上 RAG。 要改的是说话风格、输出格式、领域语感时,把范文塞进 context 只能管一次,而且每次都要付这段 token 的钱。这类需求应该把能力固化进权重。
  • 该用 RAG 的场景硬上微调。 更常见、也更贵:把制度文档做成训练集微调一版,上线两周制度改了,整个流程重来一遍。会变的东西不要往权重里塞。
  • 把数值区间、尺码表、阶梯价这类结构化规则灌进向量库。 第 04 节的实测:问「身高175体重140」,检索取回的三条里偏偏没有正确的那一档——因为词袋和 Embedding 看到的都是字符串,不是数。这类内容存表 + 写判断逻辑。
  • 一上来就装重型向量库。 几千条以内线性扫描完全够用。先用最笨的方式把效果调对,再去解决速度问题;否则效果不好时,你分不清是切分、Embedding 还是索引参数的锅。

⚠️ 三、流程与工程

  • 建库和提问用了不同的 Embedding 模型。 这是最隐蔽的一类事故:不报错,只是结果全是噪声。维度不同时会抛维度错误——那算运气好的;维度恰好相同时,静默地给你一堆错结果。
  • 换了 Embedding 模型却没重建库。 同上。换模型 = 换坐标系 = 整库作废,没有增量更新这条路。
  • 向量库里只存了向量。 检索回来一堆数字,没法拼进提示词,也没法标出处。向量用来找、原文用来读、来源用来核对,三样必须一起存。
  • 加载时把来源信息丢了。 文件名和页码在第一步没记住,后面就再也标不出出处,整个系统的可验收性就此归零。
  • 把昂贵操作放在在线阶段。 OCR、精细切分、抽摘要统统属于离线;在线只留「算一次问题向量 + 查一次索引 + 调一次模型」。分不清这两个阶段,用户就要陪着等。
  • 资料只灌一次就不管了。 带日期、带版本号的内容会过期,而向量库不会自己知道。上线时必须一并定好:谁负责更新、多久更新一次、更新后哪些块要重新入库。

⚠️ 四、提示词与拒答

  • 没有拒答话术。 top-k 永远返回 k 条,哪怕库里一条相关的都没有。第 03 节实测:问一个知识库完全没覆盖的问题,照样返回两条物流信息,分数 0.2000 和 0.1653。检索永远有结果,不代表结果相关。
  • 拿空 context 去问模型。 检索为空时最容易触发编造。这种情况应该直接回复用户,连模型都不用调——省一次调用,也断掉了编造的那条路。
  • 没圈定「只用已知信息」。 模型会把自己的先验知识和检索内容混着用,答案里哪句有依据、哪句是它自己想的,谁都分不清。
  • 拼 context 时不给每块编号。 然后在提示词里要求它标出处——它只能瞎编页码。「能标出处」一大半是拼 context 那一步实现的,不是靠提示词多说一句。
  • 把检索到的原文藏起来不给用户看。 界面上只显示答案,出错时没人能判断是检索错了还是模型答错了,排查只能靠猜。

⚠️ 五、验收与调参

  • 没有测试集就开始调参。 改完一个参数问两句,觉得「好像好点了」,然后上线。没有可重复的数字,所有调参都是自我安慰。
  • k 和阈值一起调。 效果变了也说不清是谁的功劳。固定一个调另一个,这是纪律不是建议。
  • 答得不好,第一反应是改提示词。 排查顺序应该是:资料里有没有 → 有没有落在同一块里 → 在不在 top-k 里 → 过不过阈值 → 排名靠不靠前 → 最后才是提示词。顺序颠倒的典型后果:资料里压根没有这条,却花两天调措辞。
  • 让模型给自己打分。 「答案对不对」这一层目前没有可靠的自动化方案,必须人工核。让模型判自己的卷子,得到的只是一个好看的数字。

07自测题

点击题目展开答案;能把这 12 题说清楚,这一讲就通了

一、概念与边界
RAG 三个字母各代表什么?三个动作的顺序能不能换?

Retrieval-Augmented Generation:先检索(拿问题去私有资料里找相关片段),再增强(把片段拼进提示词),最后生成(模型照着片段作答)。顺序不能换——没检索就没东西可拼,没拼进去模型就看不见。

做完 RAG,模型「学会」新知识了吗?

没有,权重一个比特都没变。它只是这一次答题时手边多了几页纸。下一次提问活页夹是空的,一切从头来过——RAG 没有记忆,每次提问都是独立的一次开卷考试。

RAG、Function Call、微调,一句话各自概括,并说清它们改的是什么。

RAG 是「查」,改的是喂进去的资料;Function Call 是「办」,改的是模型能发起的动作;微调是「教」,改的是模型权重本身。前两者权重不动,只有微调会改写模型。

「查一下这个订单现在到哪了」该用哪条路?为什么不是 RAG?

Function Call。订单状态是实时变化的系统数据,不在任何文档里。就算把订单导出成文档灌进向量库,拿回来的也是上次入库那一刻的快照,而且语气非常肯定——这比答不出来更危险。

为什么工程上一般建议「先上 RAG,解决不了再考虑微调」?

验证成本差好几个数量级。RAG 不用 GPU、不用标注数据、资料改了重新入库就行,做错了推倒重来接近零成本;微调要标注、训练、评估、部署,任何一环判断失误都要整条重来,还要付算力钱。

二、原理与流程
RAG 的八个步骤分属哪两个阶段?各自跑多少次?

离线建库(① 加载 ② 切分 ③ 向量化 ④ 入库):资料更新时跑一次,产物是磁盘上的向量库目录。在线问答(⑤ 问题向量化 ⑥ 检索 ⑦ 拼提示词 ⑧ 生成):每次提问都跑。所以昂贵操作(OCR、精细切分)统统往离线挪。

建库用 A 模型、提问用 B 模型,会发生什么?会报错吗?

Embedding 模型定义的是一套坐标系,两套坐标系不通用。维度不同时会抛维度错误——那算运气好的;维度恰好相同时不报任何错,只是检索结果全是噪声。换模型 = 整库作废,必须全部重算,没有增量更新这条路。

上下文窗口已经很大了,为什么不把全部文档塞进去?

三笔账。以「500 份 × 3000 字」实测:每次送 150 万 token vs 检索后送 1600 token,差 938 倍,按 2000 次/天算一年差 218 万元;128K 窗口根本装不下;最关键的第三笔算不出来——信噪比只有 0.11%,上下文越长模型越容易漏掉夹在中间的关键句。

向量库里除了向量,还必须存什么?

原文来源。向量用来找,原文用来拼进提示词给模型读,来源用来标出处让人核对。只存向量的后果是:检索回来一堆数字,既拼不进提示词,也标不出出处。

三、实践与排错
问一个知识库里完全没有的问题,检索会返回什么?

照样返回 k 条。第 03 节实测:问「公司的年假制度」,返回两条物流信息,分数 0.2000 和 0.1653。top-k 的定义就是「返回最相似的 k 条」,不管它们有多不相似。挡住编造的是提示词里的拒答话术和分数阈值,不是检索本身。

「让模型在答案里标出处」,主要靠提示词还是靠别的?

主要靠拼 context 那一步。给每块加上 [1] [2] 编号和来源标注,模型才有东西可引用。把原文拼成一坨没有编号的文本,再怎么在提示词里要求,它也只能瞎编页码。

RAG 答得不好,排查顺序是什么?为什么改提示词要放在最后?

顺序:① 资料里有没有 → ② 有没有落在同一块里 → ③ 在不在 top-k 里 → ④ 过不过阈值 → ⑤ 排名靠不靠前 → ⑥ 才是提示词。因为每一层都以下一层成立为前提。顺序颠倒的典型后果:资料里压根没有这条,却花两天时间调措辞,怎么调都没用。

术语表

术语含义
RAGRetrieval-Augmented Generation,检索增强生成;生成之前先检索,把检索结果拼进提示词
chunk切块;把长文档切成的一小段,检索与拼提示词的最小单位
Embedding把一段文字换算成一串定长浮点数,意思相近的向量在空间里也接近
向量库存放「向量 + 原文 + 来源」并支持相似度检索的存储组件
retriever检索器;拿问题去向量库里取回最相关的若干块
context上下文;被拼进提示词、真正送到模型眼前的那几块原文
top-k取回相似度最高的 k 块;k 是最常调的那个旋钮
score 阈值相似度低于此值的结果一律丢弃,让系统有能力回答「资料中未提及」
幻觉模型在没有依据时仍然给出笃定答案的现象;RAG 提供依据,提示词提供拒答的权利
引用出处答案中标注所依据的块编号与来源,是知识库问答能否验收的关键
知识截止模型的知识冻结在训练那一刻,之后发生的事情它一概不知
离线建库加载、切分、向量化、入库这四步,只在资料更新时跑
在线问答问题向量化、检索、拼提示词、生成这四步,每次提问都跑
元数据与块一起存的结构化字段(来源、部门、日期、版本),用于标出处与条件过滤
拒答话术提示词里明写的「资料不足时回复资料中未提及」,是挡住编造的最后一道闸
权重冻结RAG 全程不修改模型参数,换知识库等于换专家,不用重新训练
✅ 一句话收束本讲 RAG 没有魔法:它只是在提问之前,替模型把资料翻好、摊在它眼前。考生的脑子没变,变的只是他手边那几页纸——所以后面两讲讲的全是同一件事:那几页纸怎么切、怎么找回来。