RAG 是什么,为什么需要
把闭卷考试改成开卷:模型的脑子一点没变,变的只是它手边多了几页刚查来的资料。
30″30 秒看懂 RAG
把大模型想成一位参加闭卷考试的学霸:他读过的书多得吓人,但你公司那份《物流时效手册》他没读过,昨天刚改的价目表他更不可能读过。闭卷状态下问他这些,他只有两个选择——说不知道,或者编一个像模像样的答案。他通常选后者。
RAG 做的事就一件:把这场考试改成开卷。考生手边多了一个活页夹;开考前,一位资料员拿着题目跑进资料室,从几万张活页里挑出最相关的三五页,夹进活页夹。考生翻开活页夹,照着上面的原文答题。
考生的脑子一点没变,变的只是他手边多了几页纸。

| 考场里的角色 | 对应的技术概念 | 它到底干了什么 |
|---|---|---|
| 考生 | 大模型(LLM) | 只负责读活页夹里的内容、组织成人话,不自己去翻书架 |
| 题目 | 用户问题 | 既是要回答的东西,也是资料员据以找资料的线索 |
| 资料室 | 知识库 | 你的私有文档:制度、手册、合同、运单 |
| 一张活页 | chunk(切块) | 一张活页装一个完整的意思,切法决定了后面所有环节的上限 |
| 活页上的坐标编号 | Embedding(向量) | 把一段文字换算成一串数字,意思相近的数字就挨得近 |
| 资料员 | 检索器(retriever) | 拿题目去比坐标,挑出最近的几张活页 |
| 活页夹里的那几页 | context(上下文) | 被拼进提示词、真正送到模型眼前的内容 |
| 答案里标的页码 | 引用出处 | 让人能回头核对,这是知识库问答能不能验收的关键 |
01概念:模型缺的到底是什么
三个硬伤、RAG 的定义,以及它和 Function Call、微调的边界
1.1 闭卷考生的三个硬伤
大模型的知识是训练那一刻冻结的。这一句话直接派生出三个在项目里天天遇到的问题:
| 硬伤 | 典型现象 | 为什么提示词救不了 |
|---|---|---|
| 知识截止 | 问上周发布的新规,答的是两年前的旧规 | 训练数据里就没有这件事,再怎么强调「请用最新信息」也变不出来 |
| 私有知识缺失 | 问公司内部制度、某张运单的状态,全是编的 | 这些资料从未公开,模型不可能在训练时见过 |
| 答案不可溯源 | 答得头头是道,但没人能验证它从哪来 | 模型的输出是概率生成的结果,它自己也说不出依据在第几页 |
第三条最容易被忽略,却往往是企业项目能不能上线的分水岭。客服说错一句赔偿政策、法务引错一条款,代价不是「体验不好」,而是真金白银。一个不能回头核对的答案,在很多场景里等于没有答案。
1.2 RAG 是什么
RAG(Retrieval-Augmented Generation,检索增强生成)是一种在生成之前先检索、把检索结果拼进提示词的做法。它不是某个框架、某个产品,而是一条流程约定:
三个词就是三个动作,顺序不能换。关键在于:模型的权重一个比特都没变。同一个模型,今天接物流知识库就是物流专家,明天换成法务文档就是法务助手——换的只是资料员往活页夹里夹什么。
1.3 和 Function Call、微调的边界
这三者在选型会上几乎每次都会被混在一起讨论,因为它们看起来都在「让模型变得更能干」。但它们改的东西完全不同:

| 维度 | RAG | Function Call | 微调 Fine-tune |
|---|---|---|---|
| 改的是什么 | 喂进去的资料 | 模型能发起的动作 | 模型权重本身 |
| 谁发起 | 你的程序先检索,模型被动接收 | 模型自己决定调不调、调哪个 | 不涉及,能力已经长在权重里 |
| 拿回来的是 | 相似的文本片段 | 函数执行的真实结果 | 无 |
| 擅长 | 私有文档问答、政策法规、手册制度 | 实时状态、精确计算、执行动作 | 风格、术语习惯、输出格式、领域语感 |
| 不擅长 | 实时数值、聚合统计、精确计算 | 开放式的长文档理解 | 频繁变动的知识(改一次就要重训) |
| 资料更新成本 | 重新入库,分钟级 | 接口自己就是最新的 | 重新训练,天级 + 算力钱 |
| 能不能标出处 | 能,这是它的独有优势 | 能给出函数返回值 | 不能 |
| 要不要 GPU | 推理要,建库只要能跑 Embedding | 不要 | 要,而且是大头 |
一句话记住它们的分工:
1.4 选型不靠感觉,靠信号
真实项目里很少是纯粹的某一类。「客服既要查产品手册,又要查订单状态」就是 RAG + Function Call 的组合。与其背结论,不如把判断拆成可观察的信号,让分数自己排出来:
"""三条路怎么选: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(+ 微调调风格) | 依据必须可溯源,改写风格可以后续微调 |
| 医疗问诊,要专业语感 + 严格格式 | 微调 | 语感与格式长在权重里比写在提示词里稳定 |
02原理:八个步骤,分成两个阶段
离线建库四步、在线问答四步,以及「为什么不干脆全塞进去」
2.1 两个阶段,跑的次数完全不同
RAG 最容易被讲糊涂的地方,是把八个步骤排成一条直线。实际上它们分属两个阶段,跑的频率差好几个数量级:

| 阶段 | 什么时候跑 | 步骤 | 产物 |
|---|---|---|---|
| 离线建库 | 资料更新时跑一次 | ① 加载 ② 切分 ③ 向量化 ④ 入库 | 磁盘上的一个向量库目录 |
| 在线问答 | 每次提问都跑 | ⑤ 问题向量化 ⑥ 检索 ⑦ 拼提示词 ⑧ 生成 | 一条带出处的答案 |
这个划分有很强的工程含义:建库慢一点没关系(一万份文档跑两小时也能接受),问答的每一毫秒都要计较(用户在等)。所以那些昂贵的操作——OCR、精细切分、大模型抽摘要——统统往离线挪;在线只留「算一次问题的向量 + 查一次索引 + 调一次模型」。
2.2 离线四步,每一步都在为检索做准备
- 加载:把 PDF、Word、网页变成纯文本,同时记住它从哪来(文件名、页码)。来源信息在这一步丢了,后面就再也标不出出处。
- 切分:把长文档切成一张张活页。一张活页要装一个完整的意思——这是全流程里最影响效果、却最容易被随手写死的一步。
- 向量化:每张活页过一遍 Embedding 模型,得到一串定长浮点数。意思相近的活页,这串数字也接近。
- 入库:把「向量 + 原文 + 来源」一起存进向量库,建好索引供快速查找。
2.3 在线四步,模型只参与最后一步
- 问题向量化:用同一个 Embedding 模型把问题也变成向量。同一个,是硬约束,2.4 节细说。
- 检索:在向量库里找与问题向量最接近的 k 张活页。
- 拼提示词:把这几张活页的原文塞进一段模板,连同问题一起交给模型。
- 生成:模型读活页夹,写答案。
注意前三步完全没有模型参与(Embedding 模型不产出文字,只产出坐标)。所以一个 RAG 系统答得好不好,八成的功夫花在模型之外。
2.4 一条硬约束:建库和提问必须用同一个 Embedding
Embedding 模型定义的是一套坐标系。A 模型把「广州」放在坐标 (0.12, −0.35, …),B 模型可能放在完全不同的位置。用 A 建的库、用 B 去问,等于拿着上海地图在北京找路——语法上一切正常,检索结果全是噪声,而且不会报任何错。
2.5 「上下文窗口都几十万了,为什么不全塞进去?」
这是学到这里必然会冒出来的疑问。答案不是「塞不下」,而是三笔账都划不来。前两笔能算:
"""算一笔账:每次提问都把整本资料塞进提示词,代价是多少。
"既然模型的上下文窗口越来越大,为什么不直接把所有文档都塞进去?"
这是学 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 万 token | 1,600 字 ≈ 1,600 token | 938 倍 |
| 每天 2000 次的输入成本 | 6000.00 元/天 | 6.40 元/天 | 一年差 218 万元 |
| 相关内容占比 | 0.11% | 接近 100% | 其余 99.89% 是干扰 |
| 128K 窗口装得下吗 | 装不下 | 轻松 | — |
第三笔账算不出来,但它才是主因:上下文越长,模型越容易漏掉夹在中间的关键句。把 99.89% 的无关内容摆到它眼前,等于让考生在一整座图书馆里现场找答案——找得到是运气,找不到是常态。检索的价值从来不只是省钱,而是把信噪比从 0.11% 拉到接近 100%。
2.6 RAG 改变的到底是哪段文本
说到底,RAG 对模型的全部影响,就是把送进去的那段文本改了。把两种提示词并排打印出来,这件事就一目了然:
"""同一个问题、同一个模型,给不给 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,但它确实是一个向量,足以让后面四步原样成立。
"""最小 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 模型能理解「出发地」和「从哪发出」是一回事,词袋不能。
问一个库里根本没有的问题
脚本最后故意问了句「公司的年假制度是怎么规定的?」。结果是:照样返回两条,分数 0.2000 和 0.1653,内容全是物流信息。
这是 top-k 检索的天生毛病——它永远返回 k 条,哪怕库里一条相关的都没有。挡住编造的不是检索,而是提示词里那句「已知信息里没有的一律回答『资料中未提及』」,以及第 03 页要讲的分数阈值。
04完整案例:把一份属性表变成能问答的知识库
五步一步不少,跑得出真实输出,也跑得出真实的失败
4.1 场景与资料
案例刻意选得很小:一份电商商品的属性表,一共 371 个字符、25 行。小到可以把每一步的中间结果都打印出来逐行核对——这正是学新东西时最需要的。
身高: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 完整代码
"""完整案例:把一份商品属性表变成可问答的知识库。
场景很小,但五步一步不少:加载 → 切分 → 向量化 → 检索 → 拼提示词。
数据就是一份普通的商品属性文本,读者换成自己的文件即可复用。
纯标准库,可以直接运行。真实项目里把 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 节的最小内核,这里多了两样后面会一直用到的东西:
| 新增 | 代码位置 | 为什么需要 |
|---|---|---|
MiniStore 类 | add() / 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」有字符重合。
把这一条推广开,下面这张表是做任何一个知识库之前都该过一遍的:
| 内容类型 | 适合放进知识库吗 | 更合适的做法 |
|---|---|---|
| 制度、条款、手册、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 骨架模板:把 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_SIZE | 300 | 按资料形态定,第 02 页有实验;中文按字符算时 200~500 是常见区间 |
CHUNK_OVERLAP | 50 | 一般取 chunk_size 的 10%~20%;必须小于 chunk_size,否则窗口挪不动 |
TOP_K | 4 | 先从 3~5 试起,用测试集量命中率再定 |
SCORE_MIN | 0.0 | 先固定它调 k,再固定 k 调它;两个一起动就说不清是谁的功劳 |
embed() / call_llm() | 词袋 / 打印 | 换成真实模型;密钥走 os.environ.get() |
similarity() 单独拎出来
换成真正的浮点向量后,embed() 的返回类型变了,similarity() 也要跟着改成标准的点积除模长。把它和 embed() 放在一起、都标成 TODO,是提醒这两处必须成对修改——只换其中一个,代码照样能跑,但算出来的分数毫无意义。这类「不报错的错」是 RAG 项目里最难查的一类。
第一次接真实组件的建议顺序
- 先只换
call_llm():其余全用词袋,先把「能拿到一个由检索内容支撑的答案」这条路走通。 - 再换
embed():换完立刻重跑一遍同样的问题,对比检索结果有没有变好。一次只换一个变量,否则效果变了也不知道是谁的功劳。 - 最后才换
VectorStore:几千条以内线性扫描完全够用,先把效果调对,再去解决速度问题。
从这份骨架到能上线,还缺四件事
骨架跑通只意味着「流程对了」。真正对外提供服务之前,下面四件事一件都不能少——它们在教学示例里全被省掉了,却是线上出事最集中的地方:
| 缺的东西 | 不补会怎样 | 最小补法 |
|---|---|---|
| 元数据 | 没法按部门、日期、版本过滤,只能整库检索 | 入库时把来源、部门、生效日期一起存;存了不用的代价,远小于没存 |
| 增量更新 | 改一份文档要整库重建,大库根本受不了 | 给每块一个稳定 ID(文件路径 + 块序号),更新时按文档删旧增新 |
| 检索日志 | 用户说「答错了」,你连它当时取回了什么都不知道 | 把问题、命中块 ID、分数、最终答案一起落盘 |
| 拒答回路 | 检索为空时拿空 context 去问模型,编造风险最高 | 检索为空直接回复用户,连模型都不调 |
第三条特别值得早做:检索日志是后面所有优化的原料。没有它,「效果不好」永远只能靠复现;有了它,哪些问题命中率低、哪些块常年排第一却没人用,都是可以直接查出来的。
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 里 → ④ 过不过阈值 → ⑤ 排名靠不靠前 → ⑥ 才是提示词。因为每一层都以下一层成立为前提。顺序颠倒的典型后果:资料里压根没有这条,却花两天时间调措辞,怎么调都没用。
词术语表
| 术语 | 含义 |
|---|---|
| RAG | Retrieval-Augmented Generation,检索增强生成;生成之前先检索,把检索结果拼进提示词 |
| chunk | 切块;把长文档切成的一小段,检索与拼提示词的最小单位 |
| Embedding | 把一段文字换算成一串定长浮点数,意思相近的向量在空间里也接近 |
| 向量库 | 存放「向量 + 原文 + 来源」并支持相似度检索的存储组件 |
| retriever | 检索器;拿问题去向量库里取回最相关的若干块 |
| context | 上下文;被拼进提示词、真正送到模型眼前的那几块原文 |
| top-k | 取回相似度最高的 k 块;k 是最常调的那个旋钮 |
| score 阈值 | 相似度低于此值的结果一律丢弃,让系统有能力回答「资料中未提及」 |
| 幻觉 | 模型在没有依据时仍然给出笃定答案的现象;RAG 提供依据,提示词提供拒答的权利 |
| 引用出处 | 答案中标注所依据的块编号与来源,是知识库问答能否验收的关键 |
| 知识截止 | 模型的知识冻结在训练那一刻,之后发生的事情它一概不知 |
| 离线建库 | 加载、切分、向量化、入库这四步,只在资料更新时跑 |
| 在线问答 | 问题向量化、检索、拼提示词、生成这四步,每次提问都跑 |
| 元数据 | 与块一起存的结构化字段(来源、部门、日期、版本),用于标出处与条件过滤 |
| 拒答话术 | 提示词里明写的「资料不足时回复资料中未提及」,是挡住编造的最后一道闸 |
| 权重冻结 | RAG 全程不修改模型参数,换知识库等于换专家,不用重新训练 |