模型明明会说话,为什么读不懂我的资料:我从零搭了一个 RAG
6 月写完第一个工具型 Agent 后,我马上给它加了一个“搜索笔记”函数。三条假数据时效果很好,换成几十篇真实笔记就开始露馅:关键词写得不一样搜不到,整篇塞给模型又太长,搜到的片段缺少上下文时还会把结论拼错。
我原以为问题只是搜索函数太简陋,查资料后才发现,这正是 RAG 要解决的事情。它不要求模型把我的资料重新训练进参数,而是在回答前先检索相关内容,再把证据放进上下文。
听起来只是“先搜后问”,真正做起来却有一长串问题:文档怎么切,什么叫语义相似,取几段最合适,为什么最相似的片段不一定最有用,模型怎样引用来源,以及资料更新后如何避免回答旧版本。
RAG 解决的不是模型笨,而是知识不在当前上下文里
RAG 是 Retrieval-Augmented Generation,中文常译为检索增强生成。它把检索系统和生成模型接在一起:
离线建库:
原始文档 -> 清洗与切块 -> 生成向量 -> 写入索引
在线查询:
用户问题 -> 查询向量 -> 检索相关块 -> 组织上下文 -> 模型回答语言模型的参数知识在训练结束后基本固定。我的课程笔记、项目文档和当天修改的接口说明,不会凭空出现在模型参数里。把这些资料重新微调进模型成本高,更新也不方便;直接把所有文档放进提示词,长度和费用很快撑不住。
RAG 选择在推理时取资料。它有几个很实际的好处:
- 文档更新后重新建索引即可,不必重新训练整个模型
- 回答时可以附上来源,方便核对
- 不同用户可以检索各自有权限的数据
- 模型只读取与当前问题有关的一小部分内容
但 RAG 不是一个“消灭幻觉”的开关。检索不到、检索错、切块切断语义、上下文拼装混乱,模型一样会答错。生成质量的上限,往往先被检索质量卡住。
第一步不是向量化,而是把文档解析干净
很多教程直接从 Embedding 开始,真实文档却没有那么配合。Markdown 有标题和代码块,PDF 可能把双栏文字顺序读乱,网页包含导航栏和广告,表格导出后只剩一串没有列名的值。
如果进入索引的文本已经错位,后面换再好的向量模型也救不回来。我给本地笔记做的第一版清洗包括:
- 保留标题层级,让每个片段知道自己属于哪一节
- 删除重复导航、页脚和模板文字
- 代码块与解释尽量放在一起,不把函数和说明拆开
- 为每篇文档保存路径、更新时间和标签等元数据
元数据不会直接成为正文,却决定后面能否过滤和引用:
{
"document_id": "network-note-03",
"path": "notes/network/tcp.md",
"title": "TCP 连接关闭",
"section": "TIME_WAIT 的作用",
"updated_at": "2023-07-10",
"tags": ["TCP", "Linux"]
}我后来排查过一次“答案总引用旧配置”,根因不是模型偏爱旧内容,而是索引里同时存在新旧两个版本,删除文档时只删了路径记录,没有删除旧 chunk。RAG 的数据管道也需要增删改,不能只会不断追加。
切块大小决定了检索精度与上下文完整度
向量检索通常不直接把一整篇长文当成一个单位,而是切成较小的 chunk。整篇文章只有一个向量时,局部主题会被平均掉;切得太碎,检索到的又可能只剩半句话。
假设原文是:
TIME_WAIT 通常出现在主动关闭连接的一方。
它要保留一段时间,以便最后一个 ACK 丢失时能够重发,
也让旧连接中延迟到达的报文自然过期。
Linux 上观察到的持续时间与内核实现有关。如果每 15 个字切一次,可能得到:
它要保留一段时间,以便最后
一个 ACK 丢失时能够重发,也让第二块里的“一个 ACK”缺少前文,单独检索回来很难读。更合理的做法是按段落、标题或句子边界切,再保留少量重叠。
def chunk_paragraphs(
paragraphs: list[str],
max_chars: int = 180,
overlap_paragraphs: int = 1,
) -> list[str]:
chunks: list[str] = []
start = 0
while start < len(paragraphs):
current: list[str] = []
size = 0
end = start
while end < len(paragraphs):
paragraph = paragraphs[end].strip()
added = len(paragraph) + (1 if current else 0)
if current and size + added > max_chars:
break
current.append(paragraph)
size += added
end += 1
chunks.append("\n".join(current))
next_start = max(start + 1, end - overlap_paragraphs)
start = next_start
return chunks重叠能减少边界处的信息丢失,但也会制造重复。检索结果里如果连续返回三个高度重叠的块,表面上 top 3 都很相关,实际上只提供了一份证据,还浪费上下文。
chunk 大小没有一个适用于所有项目的数字。FAQ 的每个问答可以天然成为一块,代码文档适合按函数和类切,长篇论文可能按章节和段落。面试里如果只回答“固定切 500 token,重叠 50”,听起来像背参数;更重要的是说明为什么这样切,以及用什么数据验证。
Embedding 把问题和文档映射到同一个向量空间
上一篇讲过 token 的 Embedding。RAG 使用的文本 Embedding 目标稍有不同,它把一句话或一个片段编码成固定长度向量,让语义相近的文本在向量空间里距离更近。
例如:
“程序退出后端口为什么还没释放”
“TIME_WAIT 会保留关闭后的 TCP 状态”它们没有多少相同词,但语义相关。只做关键词匹配可能错过,向量检索有机会把它们找出来。
最常见的相似度之一是余弦相似度:
它比较两个向量方向是否接近,不直接受向量长度影响。值越大通常表示越相似。
下面用字符二元组做一个可运行的玩具向量器。它不是真正的语义 Embedding,但能把“文本转向量、计算余弦、取 top-k”的过程完整跑通:
import math
from collections import Counter
def bigrams(text: str) -> list[str]:
compact = "".join(text.lower().split())
return [compact[index : index + 2] for index in range(len(compact) - 1)]
def embed(text: str) -> Counter[str]:
return Counter(bigrams(text))
def cosine(left: Counter[str], right: Counter[str]) -> float:
common = left.keys() & right.keys()
dot = sum(left[key] * right[key] for key in common)
left_norm = math.sqrt(sum(value * value for value in left.values()))
right_norm = math.sqrt(sum(value * value for value in right.values()))
if left_norm == 0 or right_norm == 0:
return 0.0
return dot / (left_norm * right_norm)真实系统会使用训练好的 Embedding 模型,它能捕捉比字符重叠更深的语义关系。玩具实现的价值是让我看清向量数据库并没有“理解答案”,它保存向量和元数据,根据距离找邻居。语义能力主要来自 Embedding 模型,数据库负责高效检索。
向量数据库快在近似最近邻,不是换了一个神秘存储格式
只有几百个 chunk 时,可以把查询向量与所有文档向量逐个计算,时间复杂度接近 O(n)。文档达到几十万、几百万后,每次全量比较太慢,需要近似最近邻索引,也就是 ANN。
HNSW 会构建多层图,高层负责跨越较远距离,低层做精细搜索。查询时不必访问每个向量,就能找到大概率接近的邻居。IVF 则先把向量分到多个簇,查询时只搜索最可能的几个簇。
精确搜索:
query -> 与 100 万个向量全部比较 -> 精确 top-k,成本高
近似搜索:
query -> 先定位候选区域 -> 比较少量候选 -> 近似 top-k,速度快“近似”意味着可能漏掉真正最近的向量。索引参数通常是在召回率、查询延迟和内存之间取舍。面试问到向量数据库时,我觉得不能只报产品名,还要知道它为什么需要 ANN,以及过滤条件怎样影响搜索。
例如先做向量 top 10,再过滤 user_id,可能十条全属于别人,最后一条也不剩。更可靠的数据库会在搜索过程中结合元数据过滤,或者扩大候选集后再过滤。权限字段尤其不能只在生成答案时处理,未经授权的文本不应该进入模型上下文。
一条最小检索链路应该能独立于大模型测试
把前面的函数接起来,可以先做一个不调用大模型的本地检索器:
from dataclasses import dataclass
@dataclass(frozen=True)
class Document:
doc_id: str
text: str
DOCUMENTS = [
Document("tcp", "TIME_WAIT 通常由主动关闭连接的一方进入,用于处理延迟报文。"),
Document("inode", "文件被删除后,如果仍有进程打开,数据块不会立即释放。"),
Document("float", "0.1 无法用有限二进制小数精确表示,因此浮点计算会有误差。"),
]
INDEX = [(document, embed(document.text)) for document in DOCUMENTS]
def retrieve(query: str, top_k: int = 2) -> list[tuple[float, Document]]:
query_vector = embed(query)
scored = [
(cosine(query_vector, vector), document)
for document, vector in INDEX
]
return sorted(scored, key=lambda item: item[0], reverse=True)[:top_k]
for score, document in retrieve("文件删了为什么磁盘还没有空出来"):
print(round(score, 3), document.doc_id, document.text)这个字符二元组版本很可能不如真正 Embedding,甚至会因为用词差异把正确文档排低。这恰好适合做对照:替换 Embedding 实现前后,其他建库、检索和评估代码可以保持不变。
我把检索单独写成函数,是因为 RAG 出错时要先判断是“没搜到”还是“搜到了但模型没用”。如果所有逻辑藏在一个框架调用里,最后答案错了,只能盯着提示词反复修改。
只用向量检索,会输给精确关键词
语义向量擅长找意思接近的文本,但遇到错误码、函数名、订单号和版本号时,关键词检索往往更可靠。
用户问 EADDRINUSE,文档里也写着 EADDRINUSE。这时没有必要猜语义,精确词匹配就是强信号。用户问“端口被占用”,向量检索又可能找到只写了 Address already in use 的段落。
所以实际 RAG 常做混合检索:
查询
├── 稀疏检索: BM25,擅长精确词和稀有词
└── 稠密检索: Embedding,擅长语义相似
│
▼
合并与去重
│
▼
重排BM25 会考虑词频、文档长度和某个词在整个语料中是否稀有。像 TIME_WAIT 这种少见词,区分能力很强;“系统”“问题”到处出现,权重就低。
合并两路结果时,可以给分数归一化,也可以用 Reciprocal Rank Fusion,根据各自排名而不是原始分数融合。不同检索器的分数尺度往往不一样,直接相加并不可靠。
top-k 召回之后还需要重排
向量检索追求快速召回,先从大量文档中找几十个候选。它使用一个固定向量概括整段文本,查询和文档之间的细粒度关系可能丢失。
重排模型会同时读取 query 和候选文本,逐条判断相关性,再重新排序。它比向量点积慢,所以只处理初筛后的少量候选。
100 万个 chunk
│ ANN 快速召回 30 个
▼
重排模型精排 30 个
│ 选前 5 个
▼
放入生成上下文并不是候选越多越好。塞入十段边缘相关资料,模型可能被冲突信息干扰,真正证据反而不突出。RAG 的目标不是把上下文窗口填满,而是用尽量少的文本覆盖回答所需证据。
重排还有一个简单版本:先去重。相邻重叠 chunk 可能同时入选,保留其中信息更完整的一块,把位置留给其他来源,常常比机械增加 top-k 有效。
查询改写能提高召回,也可能改掉用户原意
用户问题不一定适合直接检索。例如“它为什么一直不释放”,如果没有前几轮对话,检索器不知道“它”指端口还是文件。
可以让模型结合对话,把问题改写成独立查询:
对话:
用户: 我关闭了 TCP 服务
用户: 它为什么一直不释放
改写:
TCP 服务关闭后,端口为什么仍处于 TIME_WAIT 状态还可以生成多个查询,从不同角度召回:错误信息、概念名称、用户口语表达。多查询提高覆盖率,也增加检索次数和重复结果。
查询改写本身会犯错。模型可能把“文件描述符”改成“文件路径”,导致检索方向变化。因此我会保存原问题和改写结果,在评估中检查两者是否保持同一意图。对订单号、错误码等精确字段,应该原样保留,不交给模型自由改写。
把检索结果放进提示词时,必须要求基于证据回答
检索到文档后,最简单的生成提示可以写成:
你要根据给定资料回答问题。
如果资料不足,请明确说无法从资料中确认。
回答后列出使用的来源编号。
[来源 tcp#2]
TIME_WAIT 通常由主动关闭连接的一方进入……
[来源 tcp#5]
SO_REUSEADDR 的行为与系统实现和旧连接状态有关……
问题:服务退出后为什么端口还没释放?来源编号应由程序附加,不能让模型凭空生成。最终引用还要检查编号是否真的存在,引用片段是否支持对应结论。
模型可能利用参数知识补充上下文没有的信息。普通聊天里这也许有用,知识库问答里却会让“基于内部文档”失去意义。系统需要明确策略:只允许依据检索资料,还是允许通用知识但必须区分来源。
当资料互相冲突时,也不要让模型偷偷选一个。最好把版本、更新时间和来源一起提供,让它说明冲突,或者由程序优先选择最新且已发布的文档。
RAG 最常见的失败,往往发生在生成之前
我把失败分成几层:
| 层次 | 典型问题 |
|---|---|
| 文档处理 | PDF 顺序错乱、表格丢列名、旧版本未删除 |
| 切块 | 结论与条件被切开,chunk 太大或太碎 |
| 检索 | 查询改写错误、Embedding 不适合领域、过滤条件遗漏 |
| 重排 | 相关证据被排到后面,重复块占满位置 |
| 生成 | 忽略资料、混合多个来源、引用不存在的编号 |
如果答案错了就立刻改 Prompt,可能是在修最后一层,却没有碰到真正原因。调试时应该保留完整链路:原问题、改写查询、候选及分数、过滤结果、最终上下文和模型回答。
这和 Agent 的执行轨迹很像。RAG 也需要可观察性,否则只能看到一句错误答案,不知道证据在哪里丢掉了。
评估 RAG 要把“搜得对”和“答得对”分开
我给笔记库手工整理了一小批问题,并标记每个问题应该命中哪些文档。数量不大,但比凭感觉试几个问题有用。
检索层可以看:
- Recall@k:正确证据是否出现在前 k 个结果中
- MRR:第一个正确结果排得有多靠前
- 命中率:一组问题中有多少至少召回一个正确块
假设十个问题中,正确证据有九次出现在 top 5,Recall@5 就是 90%。如果正确文档总排在第五,召回看起来不错,实际会占用很多上下文,还可能被前面的错误资料干扰。
生成层再看:答案是否回答问题,是否忠于证据,引用是否支持结论,资料不足时有没有拒绝编造。
可以构造几类测试:
可回答问题: 文档中有明确证据
不可回答问题: 语料中完全没有答案
冲突问题: 新旧文档结论不同
权限问题: 正确文档存在,但当前用户无权访问
精确词问题: 包含错误码、版本号和函数名不可回答问题尤其重要。一个系统在“有答案时答对”只是及格,在“没有答案时承认不知道”才开始可信。
RAG 和微调解决的不是同一类问题
面试里经常问:有了 RAG,为什么还需要微调?
我现在的理解是,RAG 更适合注入可更新、可引用的外部知识;微调更适合改变模型行为、格式、语气或让它熟悉某类任务模式。
公司制度每天可能更新 -> RAG
要求固定输出特定 JSON 风格 -> 提示词或微调
让模型知道今天库存多少 -> 查询数据库或 RAG
让模型学会一种分类任务 -> 微调可能合适把几万篇文档微调进去,不代表模型能准确逐条背出,也很难在删除一篇文档时让参数“忘掉”。反过来,RAG 检索到了资料,也不保证模型会按想要的格式处理。两者可以组合,但不能互相冒充。
Agent 接入 RAG 后,检索变成一种可选择的工具
最简单的 RAG 每次回答都先检索。Agent 场景里,模型可以判断是否需要查知识库,也可以根据第一次结果调整查询。
用户: 帮我比较项目里的两套缓存方案
│
▼
Agent 调用 search_knowledge_base("缓存方案")
│ 返回方案 A,没有方案 B
▼
Agent 改写查询 search_knowledge_base("Redis 本地缓存 对比")
│ 返回方案 B
▼
Agent 基于两组证据生成比较,并附来源这种方式比固定检索灵活,但也会增加不稳定性。Agent 可能觉得自己知道而跳过检索,或者不断改写查询。可以规定涉及内部制度、项目状态和用户数据的问题必须调用检索工具;工具返回的文档还要经过权限过滤。
RAG 工具最好返回结构化结果,而不是一大段拼接文本:
{
"results": [
{
"chunk_id": "cache-a#3",
"score": 0.82,
"text": "...",
"source": "docs/cache-a.md",
"updated_at": "2023-07-12"
}
]
}这样 Agent 能看到来源、时间和分数,外层程序也能记录到底使用了哪些证据。
多用户知识库先做权限过滤,再谈相似度
个人笔记实验里,所有文档都属于我,权限问题很容易被忽略。放到团队系统后,同一个向量库可能同时保存公开手册、项目文档和只有少数人能看的记录。
一个危险做法是先从全库检索,把 top-k 文本交给模型,最后再判断用户能不能看到。权限检查已经太晚了,敏感内容进入模型上下文,即使最终回答没有原样输出,也发生了不必要的数据暴露。
更合理的链路是让身份和权限参与检索:
用户身份与所属项目
│
▼
生成 metadata filter
│
▼
只在允许访问的 chunk 中执行向量和关键词检索
│
▼
把过滤后的结果送给重排与生成文档权限变化时,索引也要及时更新。用户退出项目后,缓存中不能继续保留旧检索结果;文档被删除时,向量、关键词索引和查询缓存都要一起失效。只删除原文件,不清向量库,会留下一个通过普通目录找不到、却仍能被语义搜索命中的副本。
查询日志本身也可能包含敏感信息。为了调试保存原问题、命中文档和生成上下文很有帮助,但日志应做脱敏、访问控制和保留期限设置。RAG 的可观察性不能靠无限保存所有人的原始数据换来。
缓存能降低费用,也可能把旧答案留得太久
Embedding 计算和模型生成都需要时间。文档内容不变时,chunk 的向量可以缓存;相同或高度相似的问题,也可以复用部分检索结果。
缓存键不能只使用用户问题。权限范围、索引版本、过滤条件和 Embedding 模型版本变化后,旧结果可能已经不适用:
cache key = query hash
+ tenant id
+ permission scope
+ index version
+ embedding version文档更新时,我会给索引生成新版本,让旧缓存自然失效。否则用户刚修改说明书,Agent 仍可能在几小时内回答旧内容,排查时又看不到数据库里有旧文档,因为旧结果藏在缓存里。
缓存属于性能优化,不应该改变正确性。先让无缓存链路可测、可追踪,再根据真实延迟决定缓存哪一层,比一开始把查询、重排和最终答案全部缓存更容易控制。
我最后留下的不是“向量数据库经验”,而是一条可验证链路
刚开始做 RAG 时,我把注意力放在选哪个向量数据库,好像产品选对了,系统就能理解文档。亲手拆完之后,数据库只是其中一环。
真正决定效果的是整条链:原文有没有解析对,chunk 是否保留条件与结论,Embedding 能不能覆盖领域表达,关键词和向量结果怎样融合,重排是否把证据放到前面,模型有没有忠于上下文,引用能不能追到原文。
这条链也改变了我做 Agent 的方式。以前模型答错,我会继续改提示词;现在我先查它看到了什么。如果证据根本没进入上下文,再漂亮的 Prompt 也只是让模型更有礼貌地猜。
下一步我准备给这套检索链写测试,而不是继续手动问几个看起来聪明的问题。RAG 的组件多、参数多,改一个 chunk 大小可能让某些问题变好,另一些问题变差。没有固定数据集和指标,“优化”很容易只是换了一批演示样例。
能搜到资料只是开始。怎样证明 Agent 在不同提问、工具报错和恶意内容下仍然按预期行动,成了我接下来更想研究的问题。