模型明明会说话,为什么读不懂我的资料:我从零搭了一个 RAG

#RAG#AI Agent#Embedding 共 5,593 字 约 18 分钟

6 月写完第一个工具型 Agent 后,我马上给它加了一个“搜索笔记”函数。三条假数据时效果很好,换成几十篇真实笔记就开始露馅:关键词写得不一样搜不到,整篇塞给模型又太长,搜到的片段缺少上下文时还会把结论拼错。

我原以为问题只是搜索函数太简陋,查资料后才发现,这正是 RAG 要解决的事情。它不要求模型把我的资料重新训练进参数,而是在回答前先检索相关内容,再把证据放进上下文。

听起来只是“先搜后问”,真正做起来却有一长串问题:文档怎么切,什么叫语义相似,取几段最合适,为什么最相似的片段不一定最有用,模型怎样引用来源,以及资料更新后如何避免回答旧版本。

RAG 解决的不是模型笨,而是知识不在当前上下文里

RAG 是 Retrieval-Augmented Generation,中文常译为检索增强生成。它把检索系统和生成模型接在一起:

Text
UTF-8|5 Lines|
离线建库:
原始文档 -> 清洗与切块 -> 生成向量 -> 写入索引

在线查询:
用户问题 -> 查询向量 -> 检索相关块 -> 组织上下文 -> 模型回答

语言模型的参数知识在训练结束后基本固定。我的课程笔记、项目文档和当天修改的接口说明,不会凭空出现在模型参数里。把这些资料重新微调进模型成本高,更新也不方便;直接把所有文档放进提示词,长度和费用很快撑不住。

RAG 选择在推理时取资料。它有几个很实际的好处:

  • 文档更新后重新建索引即可,不必重新训练整个模型
  • 回答时可以附上来源,方便核对
  • 不同用户可以检索各自有权限的数据
  • 模型只读取与当前问题有关的一小部分内容

但 RAG 不是一个“消灭幻觉”的开关。检索不到、检索错、切块切断语义、上下文拼装混乱,模型一样会答错。生成质量的上限,往往先被检索质量卡住。

第一步不是向量化,而是把文档解析干净

很多教程直接从 Embedding 开始,真实文档却没有那么配合。Markdown 有标题和代码块,PDF 可能把双栏文字顺序读乱,网页包含导航栏和广告,表格导出后只剩一串没有列名的值。

如果进入索引的文本已经错位,后面换再好的向量模型也救不回来。我给本地笔记做的第一版清洗包括:

  1. 保留标题层级,让每个片段知道自己属于哪一节
  2. 删除重复导航、页脚和模板文字
  3. 代码块与解释尽量放在一起,不把函数和说明拆开
  4. 为每篇文档保存路径、更新时间和标签等元数据

元数据不会直接成为正文,却决定后面能否过滤和引用:

JSON
UTF-8|8 Lines|
{
  "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。整篇文章只有一个向量时,局部主题会被平均掉;切得太碎,检索到的又可能只剩半句话。

假设原文是:

Text
UTF-8|4 Lines|
TIME_WAIT 通常出现在主动关闭连接的一方。
它要保留一段时间,以便最后一个 ACK 丢失时能够重发,
也让旧连接中延迟到达的报文自然过期。
Linux 上观察到的持续时间与内核实现有关。

如果每 15 个字切一次,可能得到:

Text
UTF-8|2 Lines|
它要保留一段时间,以便最后
一个 ACK 丢失时能够重发,也让

第二块里的“一个 ACK”缺少前文,单独检索回来很难读。更合理的做法是按段落、标题或句子边界切,再保留少量重叠。

Python
UTF-8|27 Lines|
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 目标稍有不同,它把一句话或一个片段编码成固定长度向量,让语义相近的文本在向量空间里距离更近。

例如:

Text
UTF-8|2 Lines|
“程序退出后端口为什么还没释放”
“TIME_WAIT 会保留关闭后的 TCP 状态”

它们没有多少相同词,但语义相关。只做关键词匹配可能错过,向量检索有机会把它们找出来。

最常见的相似度之一是余弦相似度:

cosine(a,b)=ababcosine(a,b)=\frac{a \cdot b}{\|a\|\|b\|}

它比较两个向量方向是否接近,不直接受向量长度影响。值越大通常表示越相似。

下面用字符二元组做一个可运行的玩具向量器。它不是真正的语义 Embedding,但能把“文本转向量、计算余弦、取 top-k”的过程完整跑通:

Python
UTF-8|21 Lines|
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 则先把向量分到多个簇,查询时只搜索最可能的几个簇。

Text
UTF-8|5 Lines|
精确搜索:
query -> 与 100 万个向量全部比较 -> 精确 top-k,成本高

近似搜索:
query -> 先定位候选区域 -> 比较少量候选 -> 近似 top-k,速度快

“近似”意味着可能漏掉真正最近的向量。索引参数通常是在召回率、查询延迟和内存之间取舍。面试问到向量数据库时,我觉得不能只报产品名,还要知道它为什么需要 ANN,以及过滤条件怎样影响搜索。

例如先做向量 top 10,再过滤 user_id,可能十条全属于别人,最后一条也不剩。更可靠的数据库会在搜索过程中结合元数据过滤,或者扩大候选集后再过滤。权限字段尤其不能只在生成答案时处理,未经授权的文本不应该进入模型上下文。

一条最小检索链路应该能独立于大模型测试

把前面的函数接起来,可以先做一个不调用大模型的本地检索器:

Python
UTF-8|30 Lines|
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 常做混合检索:

Text
UTF-8|9 Lines|
查询
├── 稀疏检索: BM25,擅长精确词和稀有词
└── 稠密检索: Embedding,擅长语义相似


      合并与去重


        重排

BM25 会考虑词频、文档长度和某个词在整个语料中是否稀有。像 TIME_WAIT 这种少见词,区分能力很强;“系统”“问题”到处出现,权重就低。

合并两路结果时,可以给分数归一化,也可以用 Reciprocal Rank Fusion,根据各自排名而不是原始分数融合。不同检索器的分数尺度往往不一样,直接相加并不可靠。

top-k 召回之后还需要重排

向量检索追求快速召回,先从大量文档中找几十个候选。它使用一个固定向量概括整段文本,查询和文档之间的细粒度关系可能丢失。

重排模型会同时读取 query 和候选文本,逐条判断相关性,再重新排序。它比向量点积慢,所以只处理初筛后的少量候选。

Text
UTF-8|7 Lines|
100 万个 chunk
    │ ANN 快速召回 30 个

重排模型精排 30 个
    │ 选前 5 个

放入生成上下文

并不是候选越多越好。塞入十段边缘相关资料,模型可能被冲突信息干扰,真正证据反而不突出。RAG 的目标不是把上下文窗口填满,而是用尽量少的文本覆盖回答所需证据。

重排还有一个简单版本:先去重。相邻重叠 chunk 可能同时入选,保留其中信息更完整的一块,把位置留给其他来源,常常比机械增加 top-k 有效。

查询改写能提高召回,也可能改掉用户原意

用户问题不一定适合直接检索。例如“它为什么一直不释放”,如果没有前几轮对话,检索器不知道“它”指端口还是文件。

可以让模型结合对话,把问题改写成独立查询:

Text
UTF-8|6 Lines|
对话:
用户: 我关闭了 TCP 服务
用户: 它为什么一直不释放

改写:
TCP 服务关闭后,端口为什么仍处于 TIME_WAIT 状态

还可以生成多个查询,从不同角度召回:错误信息、概念名称、用户口语表达。多查询提高覆盖率,也增加检索次数和重复结果。

查询改写本身会犯错。模型可能把“文件描述符”改成“文件路径”,导致检索方向变化。因此我会保存原问题和改写结果,在评估中检查两者是否保持同一意图。对订单号、错误码等精确字段,应该原样保留,不交给模型自由改写。

把检索结果放进提示词时,必须要求基于证据回答

检索到文档后,最简单的生成提示可以写成:

Text
UTF-8|11 Lines|
你要根据给定资料回答问题。
如果资料不足,请明确说无法从资料中确认。
回答后列出使用的来源编号。

[来源 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%。如果正确文档总排在第五,召回看起来不错,实际会占用很多上下文,还可能被前面的错误资料干扰。

生成层再看:答案是否回答问题,是否忠于证据,引用是否支持结论,资料不足时有没有拒绝编造。

可以构造几类测试:

Text
UTF-8|5 Lines|
可回答问题: 文档中有明确证据
不可回答问题: 语料中完全没有答案
冲突问题: 新旧文档结论不同
权限问题: 正确文档存在,但当前用户无权访问
精确词问题: 包含错误码、版本号和函数名

不可回答问题尤其重要。一个系统在“有答案时答对”只是及格,在“没有答案时承认不知道”才开始可信。

RAG 和微调解决的不是同一类问题

面试里经常问:有了 RAG,为什么还需要微调?

我现在的理解是,RAG 更适合注入可更新、可引用的外部知识;微调更适合改变模型行为、格式、语气或让它熟悉某类任务模式。

Text
UTF-8|4 Lines|
公司制度每天可能更新 -> RAG
要求固定输出特定 JSON 风格 -> 提示词或微调
让模型知道今天库存多少 -> 查询数据库或 RAG
让模型学会一种分类任务 -> 微调可能合适

把几万篇文档微调进去,不代表模型能准确逐条背出,也很难在删除一篇文档时让参数“忘掉”。反过来,RAG 检索到了资料,也不保证模型会按想要的格式处理。两者可以组合,但不能互相冒充。

Agent 接入 RAG 后,检索变成一种可选择的工具

最简单的 RAG 每次回答都先检索。Agent 场景里,模型可以判断是否需要查知识库,也可以根据第一次结果调整查询。

Text
UTF-8|10 Lines|
用户: 帮我比较项目里的两套缓存方案


Agent 调用 search_knowledge_base("缓存方案")
  │ 返回方案 A,没有方案 B

Agent 改写查询 search_knowledge_base("Redis 本地缓存 对比")
  │ 返回方案 B

Agent 基于两组证据生成比较,并附来源

这种方式比固定检索灵活,但也会增加不稳定性。Agent 可能觉得自己知道而跳过检索,或者不断改写查询。可以规定涉及内部制度、项目状态和用户数据的问题必须调用检索工具;工具返回的文档还要经过权限过滤。

RAG 工具最好返回结构化结果,而不是一大段拼接文本:

JSON
UTF-8|11 Lines|
{
  "results": [
    {
      "chunk_id": "cache-a#3",
      "score": 0.82,
      "text": "...",
      "source": "docs/cache-a.md",
      "updated_at": "2023-07-12"
    }
  ]
}

这样 Agent 能看到来源、时间和分数,外层程序也能记录到底使用了哪些证据。

多用户知识库先做权限过滤,再谈相似度

个人笔记实验里,所有文档都属于我,权限问题很容易被忽略。放到团队系统后,同一个向量库可能同时保存公开手册、项目文档和只有少数人能看的记录。

一个危险做法是先从全库检索,把 top-k 文本交给模型,最后再判断用户能不能看到。权限检查已经太晚了,敏感内容进入模型上下文,即使最终回答没有原样输出,也发生了不必要的数据暴露。

更合理的链路是让身份和权限参与检索:

Text
UTF-8|10 Lines|
用户身份与所属项目


生成 metadata filter


只在允许访问的 chunk 中执行向量和关键词检索


把过滤后的结果送给重排与生成

文档权限变化时,索引也要及时更新。用户退出项目后,缓存中不能继续保留旧检索结果;文档被删除时,向量、关键词索引和查询缓存都要一起失效。只删除原文件,不清向量库,会留下一个通过普通目录找不到、却仍能被语义搜索命中的副本。

查询日志本身也可能包含敏感信息。为了调试保存原问题、命中文档和生成上下文很有帮助,但日志应做脱敏、访问控制和保留期限设置。RAG 的可观察性不能靠无限保存所有人的原始数据换来。

缓存能降低费用,也可能把旧答案留得太久

Embedding 计算和模型生成都需要时间。文档内容不变时,chunk 的向量可以缓存;相同或高度相似的问题,也可以复用部分检索结果。

缓存键不能只使用用户问题。权限范围、索引版本、过滤条件和 Embedding 模型版本变化后,旧结果可能已经不适用:

Text
UTF-8|5 Lines|
cache key = query hash
          + tenant id
          + permission scope
          + index version
          + embedding version

文档更新时,我会给索引生成新版本,让旧缓存自然失效。否则用户刚修改说明书,Agent 仍可能在几小时内回答旧内容,排查时又看不到数据库里有旧文档,因为旧结果藏在缓存里。

缓存属于性能优化,不应该改变正确性。先让无缓存链路可测、可追踪,再根据真实延迟决定缓存哪一层,比一开始把查询、重排和最终答案全部缓存更容易控制。

我最后留下的不是“向量数据库经验”,而是一条可验证链路

刚开始做 RAG 时,我把注意力放在选哪个向量数据库,好像产品选对了,系统就能理解文档。亲手拆完之后,数据库只是其中一环。

真正决定效果的是整条链:原文有没有解析对,chunk 是否保留条件与结论,Embedding 能不能覆盖领域表达,关键词和向量结果怎样融合,重排是否把证据放到前面,模型有没有忠于上下文,引用能不能追到原文。

这条链也改变了我做 Agent 的方式。以前模型答错,我会继续改提示词;现在我先查它看到了什么。如果证据根本没进入上下文,再漂亮的 Prompt 也只是让模型更有礼貌地猜。

下一步我准备给这套检索链写测试,而不是继续手动问几个看起来聪明的问题。RAG 的组件多、参数多,改一个 chunk 大小可能让某些问题变好,另一些问题变差。没有固定数据集和指标,“优化”很容易只是换了一批演示样例。

能搜到资料只是开始。怎样证明 Agent 在不同提问、工具报错和恶意内容下仍然按预期行动,成了我接下来更想研究的问题。