Skip to content
This page has been auto-translated and may contain errors.View in English

RAG ​

docs.scrimba.com

先说说这个名字。RAG 是 retrieval-augmented generation(检索增强生成)的缩写:你自己去找相关的文本,在提问的那一刻把它交给模型,让模型基于你提供的事实来回答,而不是依赖它那些已经"定格"的记忆。这一章要讲的,就是这个"先检索、再回答"的循环是怎么运作的,以及它会在哪里出问题。

先检索、再生成的模式 ​

模型并不知道你公司的帮助文档、你的产品细节,也不知道它训练完成之后发生的任何事情。你要是问它这些,它要么会承认自己不知道,要么更糟——编造一个听起来很像真的答案。RAG 解决这个问题的办法,是在提问的那一刻,把相关文本直接交给模型。

一旦你理解了嵌入(embeddings)——把文本变成向量、再通过"距离"来比较相似度的那个技巧(余弦相似度衡量的就是两个向量指向的接近程度)——这个思路就很容易理解了。检索增强生成 其实就是在正常的模型调用基础上,加了两个步骤:

  1. 检索。 拿用户的问题,在你的文档里搜索最相关的片段,收集排名靠前的匹配结果。
  2. 生成。 把这些片段作为上下文放进提示词,然后让模型只根据这些上下文来回答问题。

模型的通用语言能力负责"写",而你检索出来的文本负责提供"事实"。这样你得到的答案既流畅,又扎根于你自己的内容,而且可以保持最新,因为放进去的内容是你自己控制的。

从预测循环的角度来看,就能明白为什么这个方法这么有效。没有 RAG 时,你是在要求模型从它参数中"定格"下来的模式里回忆一个事实——它可能压根没记住,于是就给出一个自信满满的猜测。有了 RAG,事实就摆在上下文里,模型的任务就从"记住这个"变成了"看看眼前的东西,照着回答"。检索把工作从"靠记忆"变成了"靠阅读"。 用考试来比喻最直观:闭卷考试逼着你去猜,而 RAG 把它变成了开卷考试——相关那一页已经摆在桌上打开着了。

Juno先检索、再生成的模式 RAG 在正常的模型调用上加了两个步骤:先检索和问题最相关的文档,再把它们当作上下文来生成答案。模型提供语言能力,你检索出的文本提供事实。这样它就能基于你的内容来回答,而不是靠记忆瞎编——这正是它不再胡编答案的原因。我一开始也不太信这套,但开卷模型确实比闭卷模型猜得少得多。

模型不携带你的帮助文档、产品细节,也不知道训练截止日期之后的任何事。你照样问它,得到的往往是拒绝回答,或者一个编得挺像真的的答案。检索增强生成 通过在请求时获取相关文本并放进提示词来弥补这个缺口,这样模型就是在"读"这个事实,而不是在"回忆"它。这个机制建立在嵌入(embeddings)之上:每段文本都变成一个向量,相似度就是向量之间的距离(余弦相似度,也就是两个向量夹角的余弦值)。

这个循环分两步:

  1. 检索。 把问题嵌入到和你的文档相同的向量空间里,然后按相似度取出最接近的若干片段。
  2. 生成。 把这些片段拼接进提示词作为上下文,并指示模型只根据这些内容来回答。

检索这一步有个你要设置的旋钮,叫 top-k:你实际保留最接近的多少个片段。k 是一种平衡。太小,你可能漏掉那个真正含有答案的片段,模型就没东西可用。太大,你会往上下文里塞进一堆关联度不高的文本,稀释了有效信号、拉高成本,还会让"迷失在中间"的问题更严重。k 取 3 到 5 是个不错的起点,之后再根据真实问题去调整。

具体做法是这样:把索引结构化成一个记录列表,每条记录保存片段文本和它预先计算好的向量;把提示词结构化成让检索出的上下文放在一个明确划定的区块里,置于问题上方。模型的语言能力负责"写",你检索出的文本负责判定什么是真的。

python
def retrieve(question, index, k=4):
    query_vector = embed(question)
    scored = [
        {"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])}
        for item in index
    ]
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
Juno先检索、再生成的模式 RAG 就是先检索、再生成:把问题嵌入,取出最接近的片段,放进提示词,再根据它们来回答。这里最关键的旋钮是 top-k,即你保留的片段数量:太少会漏掉答案,太多会把答案埋在噪声里,还得为这份"噪声"多付费。从 3 到 5 开始,再根据真实问题去调,别凭感觉。

模型不掌握你的私有语料库,也不知道训练截止日期之后的任何东西,而且它不会主动告诉你自己不知道——不管知不知道,它都用同样自信的语气回答。检索增强生成 这一模式,就是在请求时获取相关文本,并让答案以此为依据,这样正确性就取决于你能掌控的信息源,而不是模型参数里的记忆。它运行在嵌入(embeddings)之上:文本被映射成向量,按余弦相似度排序(两个向量夹角的余弦值,衡量的是语义接近程度,不受文本长度影响)。

"先检索、再生成"这个两步框架没错,但它掩盖了真正下功夫的地方。检索本质上是个搜索问题,只是披着生成的外衣,而整个系统的质量高低,是在检索这一步决定的,不是在模型调用那一步。所以从一开始就应该把"检索-生成"当作"检索-排序-生成"来对待,因为朴素版本——嵌入查询、取最近的 k 个、直接拼进去——会在低阶层还没遇到过的那些失败模式上栽跟头:精确匹配的遗漏、近似重复片段挤占覆盖面,以及过时文本被自信地当作答案抛出来。

值得早早养成的一个习惯是:这是一个可以度量的系统,不是靠感觉。留出一批真实问题,标注它们应该由哪些片段来回答,然后追踪 recall@k(召回率@k):那些本应被检索到的片段,有多大比例真的落进了你的 top k 里。recall@k 是你的天花板。如果正确片段没进入上下文,再怎么打磨提示词都救不回来,因为生成这一步压根拿不到检索本该给它的东西。下游所有环节都被这个数字锁死,所以它是第一个要衡量、也是第一个要改善的指标。

python
def retrieve(question, index, k=8):
    query_vector = embed(question)
    scored = [
        {"text": it["text"], "score": cosine_similarity(query_vector, it["vector"]), "meta": it["meta"]}
        for it in index
    ]
    # 这里先多取一些,再重新排序、缩减到更小的最终集合(见下文)
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
Juno先检索、再生成的模式 RAG 本质上是搜索问题,只是披了一层生成的外衣:质量高低是在检索环节决定的,不是模型调用那一步,所以朴素的"嵌入-取k个-拼接"版本在精确匹配和过时文本上会让你失望。真正锁住整体上限的数字是 recall@k,也就是本该被检索到的片段里,真正落进你 top k 的比例。如果正确片段从没进入上下文,任何提示词都救不了你。先测这个,再去调别的,不然你是在打磨错的那一头。

分块 ​

你不会把整篇文档拿去嵌入。你要先把它们切成更小的 块(chunks)——每块一段话或几句话,再把这些小块嵌入。原因有两个。第一,检索会更精确:你拉出来的是那一段相关内容,而不是整整一本 40 页的手册。第二,小块能塞进上下文窗口,而一份巨大的文档可能塞不进去。

python
# 一个简单的分块器:按段落切分
def chunk(text):
    return [c.strip() for c in text.split("\n\n") if c.strip()]

块的大小是一种平衡,原因要追溯到嵌入(embeddings)。每个块都会变成一个向量,概括它的整体含义。块太大,它就会覆盖好几个话题,那一个向量就成了这些话题的模糊平均值,对任何具体问题都匹配不好。块太小,它就丢失了让它有意义的上下文,向量指向的只是一个片段。段落大小的块,每一块都装着一个连贯的想法。 之后再根据检索效果来调整。

Juno分块 在嵌入之前,先把文档切成更小的块,大致是一段话的长度,再对这些小块做嵌入。更小的块能让检索更精确,也更容易塞进上下文窗口。块的大小是一种平衡:太大就模糊又浪费,太小就丢了上下文。我发现自己答案含糊不清的时候,几乎总是块切得太大了。

你嵌入的是块,不是整篇文档,因为每个块会变成一个向量,必须概括它的全部含义。把文档切成段落大小的片段,分别嵌入。块太大,会把几个话题平均进一个向量里,谁也匹配不精准;块太小,又会丢掉让它有意义的上下文。

所以要把 块大小 当作一个可以调节的旋钮,而不是固定规则。几百个 token 大小的块,往往能干净地容纳一个想法。这个旋钮和 top-k 是相互作用的:块越大,你的上下文预算能容纳的块数就越少,每次请求覆盖到的内容就越少。块越小,精确度提高了,但需要更大的 k 才能拼回一个跨越多个块的完整想法。

第二个旋钮是 重叠(overlap):让相邻的块在边界处共享一段文本,比如 10% 到 20%。没有重叠的话,一句能回答问题的话可能恰好被边界切开,一半在这个块,一半在下一个块,结果两个块都检索不好。重叠会带来一些重复,多用几个向量,但换来的是那些原本会被边界一刀两断的答案。

python
def chunk(text, size=800, overlap=120):
    # 这里的大小和重叠是以字符计算;实际系统里要按 token 来做预算
    out, start = [], 0
    while start < len(text):
        out.append(text[start:start + size])
        start += size - overlap
    return out
Juno分块 块的大小和重叠都是可以调节的旋钮,不是一次调好就完事的设定。目标是让每个块装下一个完整的想法,大概几百个 token,记住这个旋钮和 top-k 是有取舍关系的:块越大,能塞进去的就越少。加上 10% 到 20% 的重叠,这样跨越边界的答案还能完整地留在至少一个块里。边界正是检索悄悄丢答案的地方。

分块决定了检索的召回率是赢是输,而这个胜负在任何一次查询运行之前就已经定了。每个 块 会变成一个向量,必须代表它的整体含义,所以切分方式决定了什么内容是"可被找到"的。当文档本身有结构时,按段落或章节边界切分,比按固定字符窗口切分效果更好,因为一个覆盖单一连贯章节的向量,是比覆盖两个章节各一半的向量更干净的查询目标。

块大小和重叠,是和你的整体预算耦合在一起的,不是自由参数。大小会同时和 top-k、上下文窗口相互制衡:大块每块携带更多上下文,但能塞进去的数量更少,覆盖面就下降;小块提高了精确度,但会把想法切碎,需要更高的 k 才能重新拼合,这既消耗 token,又会重新引入"迷失在中间"的问题。大约 10% 到 20% 的重叠能救回那些跨越边界的答案,代价是索引里出现重复内容。这种重复还有个下游的副作用:几乎一样的重叠片段,可能占满你 top-k 里的好几个位置,却提供的是同样的内容,压缩了实际覆盖面。这也是重新排序(re-ranking)和去重后来登场的原因之一。

大多数人会一直忽略、直到被坑到才想起来的一个成本:分块策略是固化进索引里的,所以改变分块方式意味着要重新嵌入、重新索引整个语料库。这是实打实的计算量、实打实的钱,还有一个新旧向量不能混用的迁移窗口期。要根据你真实的文档和查询,提早决定好块大小,在留出的验证集上测 recall@k,把重新分块当作一次审慎的重新索引,而不是周五随手上线的配置改动。

Juno分块 有结构的地方就按结构切分,因为每个块都是一个代表其整体含义的向量,而大小同时和 top-k、窗口大小相互制衡。10% 到 20% 的重叠能救回跨边界的答案,但也会催生挤占你 top-k 的近似重复内容,这也是重新排序存在的原因之一。别忘了分块的选择是固化在索引里的:改变它意味着一次完整的重新嵌入和重新索引,是实打实的成本和一次迁移,不是配置开关那么简单。

向量存在哪里 ​

如果只是少量的块,你可以把向量留在内存里,用嵌入(embeddings)那一章里的余弦相似度函数来比较——后面的示例就是这么做的。但对于成千上万甚至数百万个块,这样做就会变慢,这时你就需要一个 向量存储(vector store):一种专门用来在大规模数据中快速找到最近向量的数据库。

学习 RAG 并不需要用到向量存储,现在也不用急着选。市面上有不少托管服务和库可以选;重要的是理解这个概念:找个地方存放嵌入向量,并能快速搜索它们。先从内存开始,等数据量超出内存能承受的范围时,再迁移到向量存储。

Juno向量存在哪里 少量的块,直接把向量放内存里、逐一比较就行。数据量大的时候,向量存储是一种能在大规模数据里快速找到最近向量的数据库。学习 RAG 不需要用它:先在内存里做,等数据量超出内存承受范围再迁移到向量存储。第一天就急着挑数据库,没必要。

几百个块的规模,向量放在内存里,你对每一个都和查询逐一打分。这是一种线性扫描:小规模没问题,数据量涨到几千甚至上百万时就会变慢,因为每次查询都要和每一个向量比对一遍。到了这个规模,你就该转向 向量存储——一种为向量建立索引、支持快速最近邻搜索的数据库。

值得记住的一个不对称之处是:嵌入是"一次性投入",而查询是"每次都要付出"。你对每个文档块只做一次嵌入和索引,提前完成,这份工作会被后面每一次提问反复利用。而查询是每次请求都要重新嵌入一次,发生在请求当时,这也是热路径上唯一的嵌入成本。所以那份繁重的索引成本是提前付清、后续分摊的;每次查询的成本只是一次嵌入调用加一次搜索。这就是为什么 RAG 系统一旦建好,提供服务的成本会很低。

学这个模式不需要先选好存储方案。先用一个内存中的记录列表来做,等规模或持久化需求逼上来了再换成向量存储,接口保持一致:嵌入查询、搜索、返回 top-k。

Juno向量存在哪里 在内存里,每次查询都要扫描全部向量,规模小的时候没问题,数据量一大就该上向量存储,享受索引化的最近邻搜索。记住这个不对称性:文档只嵌入、索引一次,之后永久复用,查询却是每次请求都重新嵌入。前期成本一次性付清、后续分摊,所以服务起来才便宜。先从内存开始,等规模或持久化逼上来了再换存储。

在内存里,你做的是线性扫描,把查询和每一个块向量逐一打分,这样做是精确的,但每次查询的复杂度是 O(n),规模到了几万这个量级就撑不住了。向量存储 用一种近似最近邻索引(ANN)取代了这种扫描:它牺牲一点召回率,换来速度上的大幅提升,因为它不再检查每一个向量。这是个可以调节的取舍,在生产环境的规模下,你要接受索引偶尔会漏掉一个真正的最近邻,而这个损失会直接反映在你的 recall@k 上。

嵌入的"一次性"不对称性决定了你的成本结构。索引阶段是对整个语料库一次性嵌入,是一笔较大的前期批量成本;而服务阶段是每次请求嵌入一个查询、运行一次搜索。所以你的稳态延迟预算大致是:嵌入查询,搜索索引,再加上生成调用。搜索通常是耗时最小的一环;生成调用才是延迟的主要来源,而查询嵌入是一笔在你能开始检索之前就必须先付出的固定成本。这几段都值得单独测量,因为有些人明明延迟一直卡在生成这一环,却跑去换更快的向量存储。

然后是"数据过时"这个问题,这是内存里的玩具级实现从来不会暴露给你的失败模式。你的索引是一份快照;而源文档在不断变化。策略变了、文档被编辑了,旧的块还留在索引里指向过去的内容,检索却会满怀信心地把它端上来。所以索引不是一次写入就完事的:你需要一条能重新嵌入变更文档的重新索引路径,理想情况下每个块还要带上元数据(源 id、版本号、时间戳),这样你才能失效或过滤掉过时条目。刷新频率要根据你的真实数据变化速度来定,把"索引是最新的"当作一件需要验证的事,而不是想当然地认为它一直是对的。

Juno向量存在哪里 向量存储用近似最近邻索引取代精确的线性扫描:更快,但可能漏掉一个真正的邻居,这个损失最终会体现在你的 recall@k 上。嵌入是对语料库一次、对每次查询一次,所以稳态延迟大致是"嵌入查询+搜索+生成",而生成调用通常才是大头,所以先测量再怪罪存储。玩具级实现从来不会暴露的陷阱是数据过时:索引是快照,源数据会漂移,所以要建一条重新索引的路径,给块打上版本和时间戳标记,而不是想当然地相信索引是最新的。

让答案有依据、能引用出处 ​

生成这一步靠的是提示词,而提示词怎么写,决定了 RAG 到底能不能真正减少胡编乱造的答案。两条指令承担了大部分工作:告诉模型 只能 根据提供的上下文来回答,以及告诉它,当上下文里没有答案时要说明这一点。

python
context_text = "\n\n".join(retrieved_chunks)

system_prompt = f'''请只根据下面的上下文回答问题。
如果上下文中没有答案,请说"我没有这方面的信息"。
在你的回答中引用上下文的相关部分。

上下文:
"""
{context_text}
"""'''

这里把前面学到的两课融合在了一起。分隔符和明确的"只根据这个来回答",来自提示词工程那一章。允许模型说"我没有这方面的信息",则是应对幻觉的手段:一个有依据、又留了退路的模型,比一个被逼着瞎猜的模型编造得少得多。有依据加上留有退路,正是减少编造答案的关键。 要求它引用出处,也给用户提供了一个可以核实的东西。

Juno让答案有依据、能引用出处 生成这一步靠的是提示词,它决定了 RAG 到底能不能起作用。告诉模型只根据提供的上下文来回答,答案不在里面时就要说明。这种有依据的做法,再加上允许它承认自己不知道,正是减少编造答案的关键。要求它引用出处,给了用户可以核实的东西,也给了你一个线索,知道检索是不是漏了什么。

检索可以把完美的上下文交到模型手里,而模型依然可能不理会它。生成提示词才是把答案和上下文绑定起来的关键,所以要仔细地写:指示模型 只 根据提供的上下文来回答,当上下文里没有答案时要承认,并且要引用它所使用的那一段。

python
context_text = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(retrieved_chunks))

system_prompt = f'''请只根据下面的上下文来回答。
如果上下文中没有答案,请准确回复:"我没有这方面的信息。"
请引用你所用的块编号,比如 [2]。

上下文:
"""
{context_text}
"""'''

给块编号,能把"引用出处"变成一种可以用程序检查的引用:模型指向块 [2],你可以确认块 [2] 是不是真的支持那个说法。这就是你抓住 RAG 中最容易被漏掉的那种失败模式的手段——答案对了,但检索出的块是错的,模型给出了一个听起来正确的答案,而给它的上下文根本不支持这个答案。没有引用的话,你根本分不清一个有依据的答案和一个蒙对了的答案。

留有退路和给出指令一样重要。一个被告知只能根据上下文回答、却没有失败许可的模型,遇到糟糕的检索时依然会硬拼出点东西来。给它一个明确的出口,一句准确的"我没有这方面的信息"字符串,这样检索失误就会表现为一个干净的空白,而不是一个自信满满的编造。这里的消息结构是 OpenAI 那种风格;系统消息和上下文的排列方式因服务商而异,但这个原则是通用的。

Juno让答案有依据、能引用出处 生成提示词把答案和上下文绑定在一起:只根据它来回答,答案不在时要承认,并引用块编号。给块编号,能让引用变得可核查,能抓住那种"答案对了,但检索出的块根本没支持它"的隐蔽失败模式。给模型一个准确的退路字符串,否则一次糟糕的检索就会被硬拼成一个自信的错误答案。结构因服务商而异,但"系统消息+上下文"这个安排是通用的。

有依据这件事,是检索和模型之间的一份契约,也是你手上成本最低的可靠性杠杆,值得在提示词上多花心思。指示模型 只 根据提供的上下文来回答,当上下文不支持某个答案时要返回一个固定的弃答字符串,并引用它所用的块 id。

python
context_text = "\n\n".join(f"[{c['id']}] {c['text']}" for c in retrieved)

system_prompt = f'''请只根据下面的上下文来回答。
如果上下文不支持某个答案,请准确回复:"我没有这方面的信息。"
每个论断都要用方括号引用块 id,例如 [doc12]。

上下文:
"""
{context_text}
"""'''

引用不是装饰;它是让"有依据"这件事变得可度量的手段。有了输出中的块 id,你就可以自动检查被引用的块是否真的包含那个论断,从而分清一个有依据的答案和一个流畅但蒙对的猜测。这也是你侦测那个能击败朴素 RAG 的失败模式的手段:答案听起来大概没错,但检索出的上下文根本不支持它,也就是检索漏了,模型用它参数里的记忆把这个空缺填上了。没有 id 的时候,你只能抽样检查;有了 id,你就可以在评测(evals)(一批带有已知正确答案的问题集,自动打分)里给"依据忠实度"打分,把它当作一个可以持续观察的数字。

两点生产环境的注意事项。弃答这条路径必须成本低、并且是被明确允许的,因为一个没有失败许可的模型总会硬编出点东西来,一次糟糕的检索就会变成一个自信满满的错误答案,而不是一个干净的空白。另外这里的消息结构是 OpenAI SDK 那种风格;系统消息和用户消息的划分方式、上下文怎么挂载,会因服务商而异,所以要把这种安排本身当作可移植的,具体字段名则不是。底层那条不变的规则是:永远不要让模型本身成为信息来源,它只应该是你能引用、能核实的信息来源的"转述者"。

Juno让答案有依据、能引用出处 有依据是一份契约:只根据上下文来回答,用固定字符串弃答,引用块 id,这样"忠实度"就成了你能在评测里打分的东西,而不是靠肉眼判断。引用能帮你抓住那种"答案对了但没依据"的情况,也就是检索漏了、模型用记忆把空缺补上了。让弃答路径成本低且被明确允许,否则一次弱检索就会变成一个自信的错误答案。结构因服务商而异,流程可移植,字段名不可移植。

超越最近邻:混合搜索与重新排序 ​

纯向量搜索靠语义来匹配,大多数时候这正是你想要的。但它有个盲点:它可能漏掉某个关键的确切词。比如搜索一个错误代码,像 E_4021,或者一个具体的产品名称,基于语义的搜索可能会返回一堆同一话题相关的块,却跳过了那个真正包含这个确切字符串的块。

这个问题有个专门的解决办法,叫 混合搜索(hybrid search):把基于语义的搜索和老派的关键词搜索结合起来,让确切的词汇和整体语义都能起作用。刚开始学 RAG 不需要用它。当确切词汇很重要、纯向量搜索又老是漏掉它们时,再用上它。

Juno超越最近邻:混合搜索与重新排序 向量搜索靠语义匹配,大多数时候很好用,直到有人搜索一个确切的代码或产品名称,而正确的块被跳过了。混合搜索加入了普通的关键词搜索,让确切的词汇也能算数。学 RAG 一开始不用管它,等你注意到精确匹配一直被漏掉时再加上。

最近邻向量搜索靠语义来匹配,而这恰恰也是它的弱点。它可能把一个话题相近的块排在前面,而真正包含你需要的那个确切词——错误代码、SKU、函数名、罕见的专有名词——的那一个块反而排后面。嵌入会把这些词模糊成"大致属于这个意义区域",而确切字符串就在这种模糊中丢失了。

混合搜索 同时运行两种检索器,再把结果合并:一个是语义向量搜索,另一个是关键词搜索(经典的词项匹配,通常用 BM25 算法,它根据文档包含查询中多少个确切词、以及这些词有多稀有,来给文档打分)。你从两边各取候选结果,合并排名,这样一个在确切词或语义相近度上任一方面表现好的块都有机会被找出来。这样做实际的好处,就是在那些纯向量搜索会失手的查询上提高了召回率。

第二种技巧是 重新排序(re-ranking):用一个更准确但也更昂贵的模型,对第一轮检索出的候选结果重新打分排序一遍。这个模式是"先多取,再精选"。先用便宜的方式多检索一些,比如前 20 个,然后跑一个重新排序器,让它把每个候选和查询放在一起阅读比较,重新排序,最后只留下真正要发送的前 4 个。第一轮检索快但粗糙;重新排序器慢但精准,所以你只在这个短名单上运行它。结果是拿到更好的最终 k 个结果,却不用为整个索引付出重新排序器的昂贵代价。

Juno超越最近邻:混合搜索与重新排序 纯向量搜索会漏掉像错误代码、SKU 这样的确切字符串,因为嵌入会把它们模糊成一个语义区域。混合搜索加入了关键词匹配(BM25),让确切词也能参与投票,和语义一起发挥作用。重新排序是"先便宜地多取,再精细地筛选"的模式:用快速检索多取一批候选,再用更慢但更准的模型对这个短名单重新打分,只留下最好的几个。这样你能得到更好的最终结果,而不用对整个索引都跑一遍昂贵的打分器。

纯粹的近似最近邻搜索是一个强大的默认选项,同时也是一个已知的失败面。它优化的是语义上的接近程度,所以恰恰在查询依赖某个确切词——错误代码、标识符、SKU、罕见的专有名词——时表现不好,因为嵌入会把那个词压缩进一个语义邻域,而确切字符串在那里会彼此模糊在一起。这不是一个调参能解决的问题;这就是稠密向量本身的特性。

混合搜索 通过同时运行稠密检索(向量,基于语义)和稀疏检索(关键词词项匹配,通常是 BM25,根据确切词频、并按词的稀有度加权来打分),再融合两个结果列表来解决这个问题,常用的融合方式是倒数排名融合(reciprocal rank fusion),它根据每个条目在各自排名中的位置来合并两个排名列表,而不是比较两组不可比的原始分数。这个方法的收益可以用精确匹配查询上的 recall@k 来衡量,这恰恰是纯稠密检索悄悄丢掉的那一部分。代价是要多维护一个索引和一个融合步骤,所以当你的流量里包含标识符和罕见词时,这个投入才划算;如果全是自然语言的同义改写式查询,收益就没那么明显。

重新排序 解决的是另一种遗漏。第一轮检索(稠密、稀疏或混合)是为了在大规模索引上追求速度而设计的,所以排序比较粗糙。重新排序器是一种交叉编码器(cross-encoder,一种把查询和候选块放在一起联合阅读并打分的模型,而不是比较两个预先算好的向量),它更准确,但太慢,没法对整个语料库都跑一遍。所以你先用便宜的检索器多取一批,前 20 到 50 个,再对这个短名单重新排序,只留下最后几个。

在大多数生产环境的 RAG 系统里,这是收益最大的召回率修复手段:它直接对付"答案对了、块却检索错了"这种失败模式,把真正支持答案的那个块排到那些仅仅是话题相关的块之上,而你可以用重新排序前后的 recall@k 来衡量这个提升。要为它做好预算:重新排序器会给热路径带来延迟,还有按查询计费的成本,所以要根据你的延迟目标来定"先多取多少",而不是根据理论上能负担打分多少候选来定。

Juno超越最近邻:混合搜索与重新排序 稠密向量会把确切词汇模糊掉,所以混合搜索让稀疏关键词检索(BM25)和稠密检索并行运行,再用倒数排名融合把两个列表合并起来,从而在标识符和罕见词汇上把 recall@k 赚回来。重新排序才是更大的收获:先便宜地多取一批,再用一个把查询和块放在一起阅读的交叉编码器对短名单重新打分,直接修复"答案对了、块检索错了"的问题,把真正支持答案的块排到前面。重新排序前后都要测 recall@k,并根据你的热路径延迟目标,为重新排序器的延迟和单次查询成本做好预算。

实际操作 ​

一个最简版的 RAG 答案,复用了嵌入(embeddings)那一章里的 search 和 embed 辅助函数:

python
def answer_from_docs(question, index):
    # 1. 检索:找出和问题最匹配的几个片段
    query_vector = embed(question)
    scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
    top = sorted(scored, key=lambda x: x["score"], reverse=True)[:3]

    # 2. 生成:只根据这些片段来回答
    context = "\n\n".join(t["text"] for t in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": f'请只根据下面的上下文回答。如果这里没有,就说你不知道。\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

检索三个最接近的块,把它们放进提示词,让模型据此回答。这就是整个 RAG 流程:嵌入(embeddings)搜索给模型调用喂料,靠一个有依据的提示词把它们串在一起。几乎所有的质量都取决于是否检索到了正确的块。

Juno实际操作 一个最简版的 RAG 答案,就是嵌入搜索给模型调用喂料:检索最接近的块,放进提示词,让模型只根据它们来回答。检索提供事实,有依据的提示词让答案锚定在这些事实上。几乎所有的质量都取决于是否检索到了正确的块,所以功夫要花在这上面。

一个能实际运行的 RAG 调用,就是检索、扎根、生成,加上前面提到的引用机制:

python
def answer_from_docs(question, index, k=4):
    top = retrieve(question, index, k)  # 来自"模式"一节
    context = "\n\n".join(f"[{i}] {t['text']}" for i, t in enumerate(top))
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'请只根据下面的上下文回答。如果这里没有,就回复'
                f'"我没有这方面的信息。"请像 [1] 这样引用块编号。\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

整个流程就是嵌入(embeddings)搜索喂给模型调用,靠一个有依据的提示词把它们黏合在一起。chat.completions 这种写法是 OpenAI 风格的;消息角色和调用签名会因服务商而异,但"检索-扎根-生成"这个流程到处都是一样的。

当答案出错时,按阶段顺序排查。先检查检索出的块:相关文本有没有进入 top?如果没有,问题出在检索这一步,应该提高 k、修正分块方式、加入混合检索或重新排序,而不是去改提示词。如果正确的块确实检索到了,答案还是错的,那才是提示词的问题。按这个顺序排查,能避免你在一个检索本身有问题的情况下,徒劳地去调整提示词的措辞,那样永远收敛不了。

Juno实际操作 一个能实际运行的 RAG 调用,就是检索、带引用地扎根、生成,而 OpenAI 风格的消息结构,在流程上可以移植到其他服务商,只是字段名不一样。按固定顺序排查:先看检索出的块。如果正确文本压根没进来,就去修检索(k 值、分块、混合、重新排序),不要碰提示词。只有当正确的块确实进入了上下文,答案还错,那才是提示词的问题。

实际使用中,"检索-排序-生成"这套流程是一个函数,你要重点监控的是各阶段之间的接缝:

python
def answer_from_docs(question, index, k_fetch=20, k_final=4):
    candidates = retrieve(question, index, k=k_fetch)   # 稠密(或混合)检索
    top = rerank(question, candidates)[:k_final]        # 交叉编码器重新排序
    context = "\n\n".join(f"[{c['id']}] {c['text']}" for c in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'请只根据下面的上下文回答。如果没有依据,就回复'
                f'"我没有这方面的信息。"请像 [doc12] 这样引用 id。\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content, [c["id"] for c in top]

把块 id 和答案一起返回,是让这个系统变得 可观测 的关键一招:你可以记录检索到了什么、引用了什么,以及被引用的 id 是不是真的支持这个答案,而这正是你在评测(evals)里衡量"忠实度"的信号。chat.completions 这种写法是 OpenAI SDK 的风格;把这个流程当作可移植的,字段名则是服务商特定的。

这个流程的延迟和成本分布,值得记在心里,因为优化的方向要跟着时间花在哪里走:

  • 查询嵌入 是一笔固定的小成本。
  • 第一轮检索 很便宜。
  • 重新排序 会带来真实的、按你多取的候选数量放大的单次查询开销。
  • 生成调用 在延迟和金钱成本上都是大头。

所以在你去追求更快的向量存储之前,先确认搜索到底是不是瓶颈;通常瓶颈其实在生成这一环。

排查失败时,也要按阶段顺序来:先看检索到的集合,再看重新排序后的集合,再看引用,最后才看提示词。如果一个错误答案的正确块压根没进入 candidates,那就是检索的问题,再怎么改提示词也修不好一个从来没被拿到的块。

Juno实际操作 整个流程是检索、重新排序、扎根、生成,而把块 id 和答案一起返回,正是让它变得可观测的关键:记录检索结果和被引用的内容,你就能在评测里给忠实度打分,而不是靠猜。留意成本分布,生成通常在延迟和金钱上都占大头,所以在换向量存储之前先确认搜索是不是真的瓶颈。排查按阶段顺序来:先检索、再重新排序、再引用、最后才是提示词,因为再怎么改提示词也修不好一个从没进入候选集合的块。写法是 OpenAI SDK 风格;流程可移植,字段名不可移植。

什么时候不需要 RAG ​

RAG 不是没有成本的,也不是永远的最佳答案。以下情况可以跳过它:

  • 数据量小,塞得进提示词。 如果你的整个知识库只有几段文字,直接粘贴进去就行。RAG 是给那种一次塞不进去太多内容的场景用的。
  • 一个长上下文模型能装下全部内容。 有些模型接受非常大的输入,足以一次性容纳整篇文档,这可能比搭建一套检索系统省事得多。

还要记住真正的限制:RAG 的质量,主要就是检索的质量。 如果搜索这一步返回了错误的块,再完美的提示词也救不回答案。真实 RAG 系统里,大部分的功夫都花在检索出正确的文本上,而不是花在最后那次模型调用上。

嵌入和 RAG 让模型在提问的那一刻获得知识,却完全不用改变模型本身。下一章微调(Fine-tuning)讲的是另一个选项:用你自己的示例去改变模型,以及在什么情况下这比提示词或 RAG 更值得。

Juno什么时候不需要 RAG 当你的数据小到能直接粘贴进提示词,或者一个长上下文模型能一次性装下全部内容时,就可以跳过 RAG。也要记住这个真正的限制:RAG 的质量主要就是检索的质量,如果搜索返回了错误的块,再好的提示词也救不了答案。大部分真正的功夫,都花在检索出正确的文本上,而不是最后那次调用上。

RAG 是一整套 基础设施:一个索引、一个嵌入步骤、一个检索步骤、一条重新索引的路径,只有当数据量大到没法直接发送,而且变化频繁到重新训练不是一个可行选项时,这些开销才值得。当有更轻量的方案能搞定问题时,就跳过它:

  • 语料库能塞进提示词。 几段文字或一份小的政策文档,直接放进上下文就行。只有当内容多到一次塞不下时,RAG 才真正物有所值。
  • 一个长上下文模型能装下整个源文档。 如果文档能塞进一个大窗口,整篇发送可能比搭建、调优一套检索流程更省事,不过你每次调用都要为这些 token 付费,而且依然会遇到"迷失在中间"的问题。

要记住这条支配性的事实:RAG 的质量受限于检索质量。一个完美的提示词,配上错误的块,答案照样是错的,所以真正的功夫和收益都在索引和检索这一步,不在最后那次模型调用上。当你需要改变的是行为本身,而不是补充事实时,下一章的微调(Fine-tuning)会讲到,它解决的是和 RAG 不一样的问题。

Juno什么时候不需要 RAG RAG 是有实际开销的基础设施,所以当语料库能塞进提示词、或者长上下文模型能吞下整个源文档时,就跳过它——不过要记住走长上下文这条路,每次调用都要付 token 费用,而且中间部分依然会"漂移"变差。支配性的事实依然成立:质量受限于检索,一个完美的提示词配上错误的块,答案照样是错的。功夫要花在索引和检索上,不是最后那次调用。

RAG 用一整套你要自己搭建、调优、监控、重新索引的检索系统,换来了时效性和有依据的答案。所以这是一个成本收益的决策,不该是默认选项。当更轻量的方案能达标时,就跳过它:

  • 小而稳定的语料库。 如果它塞得进提示词,又很少变化,就直接放进上下文,再借助提示词缓存让这个固定区块在重复调用时低成本地被复用。这种情况下搭一套检索流程就是白费功夫、没有回报。
  • 长上下文、复用不多的场景。 如果源文档能塞进一个大窗口,整篇发送能省掉搭建的功夫,但你每次请求都要为这些输入 token 付费,而且召回质量在文档中间依然会衰减,所以一旦流量或文档规模上去了,这个方案就会输给 RAG。

支配全局的约束,和贯穿整章的那条一样:端到端的质量受 recall@k 的限制。没有任何提示词、没有任何模型升级、没有任何重新排序,能挽回一个检索从未放进上下文的事实。所以工程投入应该花在检索、分块、混合检索、重新排序、评测、重新索引上,模型调用反而是最容易搞对的那部分,成本最低。同时要把 RAG 和微调放在各自 不同的赛道 上:检索提供的是"什么是真的",并随着信息源变化保持最新;而微调塑造的是"模型怎么回应",拿它来注入事实,既过时又缺乏可追溯的来源。知识用检索,风格用微调,别让其中一个去干另一个的活。

Juno什么时候不需要 RAG RAG 用一套你要自建、要重新索引的系统,换来时效性和有依据的答案,所以当语料库小而稳定时(直接粘进去,靠提示词缓存省钱),或者长上下文窗口能装下、复用又不多时,就跳过它。贯穿全局的约束是 recall@k:任何下游手段都挽不回一个检索从未呈现出来的事实,所以要投入在检索上,不是模型调用上。把赛道分清楚:检索负责"什么是真的",微调负责"听起来怎么样",别让一个去干另一个的活。