Tiana随笔

上下文窗口越来越长,RAG 还有必要吗

· AI 与大模型

标签:#AI #RAG

上下文窗口越来越长,RAG 还有必要吗

能塞一本书进上下文之后,“把知识库都喂进去”听起来很有吸引力。但成本、噪声和延迟提醒我们:RAG 没死,它只是换了形态。

去年我在一个内部知识库项目里做了个决定:把 RAG 整套检索链路砍掉,直接把文档整篇塞进上下文,交给当时刚放大的长上下文模型。当时的想法很朴素,窗口都 20 万 token 了,还要什么检索?结果上线两周就被现实教育了:成本炸了、长文档后半段答非所问、并发一高延迟直接劝退。这篇文章就是这次翻车之后,我对"长上下文 vs RAG"这件事重新梳理的记录。

我的结论先放这儿:上下文窗口变长,确实消灭了一批"伪 RAG"需求,但 RAG 远没有被淘汰。它只是从"默认方案"退回到"该用才用"的位置。下面我把长上下文的三笔账、RAG 不可替代的三件事、以及我最后落地的混合策略一条条摊开讲,顺便给出可复现的评测方法、一段示意代码,以及几个容易踩的边界坑。

配图:

长上下文的三笔账

很多人只看到"窗口变大了"这一个好处,却忽略了它背后的代价。这三笔账不算清楚,很容易像我一样翻车。

第一笔:成本按 token 线性甚至被反复放大

长上下文最直接的代价是钱。API 按 token 计费,输入越长,单次调用越贵。更要命的是,对话型应用里上下文是逐轮累积的,你每多保留一轮历史,后续每一次请求的输入 token 都跟着变长。一个把整个知识库塞进 system prompt 的设计,等于每次用户问一句话,你都在为整座图书馆付钱。

我那个翻车的项目,最初以为是"长窗口更省事",实际上单位请求的输入 token 是 RAG 方案的几十倍。流量一上来,账单曲线陡得吓人。后来我算了一笔账:同样一批问题,整篇上下文方案的总花费是检索方案的二十倍以上,因为检索只把相关片段送进去。缓存在这种场景下能帮一点,但前提是你那长长的前缀足够稳定,否则缓存命中率会很低,省不下多少。

第二笔:注意力稀释与中途遗忘

这是模型能力层面的代价,钱之外的问题。研究里常说的"lost in the middle"现象,模型对放在上下文中间位置的 content 注意力明显弱于开头和结尾。窗口越长,信息被稀释得越厉害,模型越容易在长文档的中段"走神"。

我实测时就遇到:把一份 80 页文档整篇丢进去,问一个出现在第 40 页的问题,模型经常答非所问,或者自信地编造一个接近但错误的答案。它甚至会忽略中间明确写着的规则,去采纳结尾处的无关提示。这种"中间失效"不是个例,而是长上下文模型普遍的结构性弱点,靠调 prompt 很难根治。更糟的是,你很难事前知道答案藏在文档的哪个位置,也就没法通过调整摆放顺序来规避。

第三笔:延迟

token 越多,prefill(预处理输入)越慢,首 token 延迟越高。对交互式产品来说,用户每多等一秒都是体验流失。长上下文模型在大输入下的延迟,往往比小上下文高出一个量级,这不是换模型能轻易解决的。

更现实的是并发:长输入占用算力久,单请求就把 GPU 占住了,并发数上不去,高峰期排队严重。我那次线上卡顿,根因就是大上下文把推理吞吐压垮了,而不是模型本身不行。对一个要扛真实流量的服务来说,延迟和并发是比"能不能答"更先要过的关。

RAG 不可替代的三件事

说完长上下文的代价,再看 RAG 凭什么还活着。这三件事,长上下文至今补不上。

私有海量数据:塞不进也付不起

企业里真正的知识库动辄几十万、上百万文档,总量远超任何上下文窗口。你不可能、也不该每次请求都把这些文档全传上去。RAG 的检索把"大海捞针"变成"先捞再答",只把相关的少数片段送进上下文,既省钱又聚焦。这是长上下文在体量上根本够不到的场景。

哪怕未来窗口涨到千万 token,把百万文档全塞进去的工程成本和推理成本也注定不划算。检索的本质是"用选择性注意替代全量注意",这个取舍在资源有限的世界里永远成立。长上下文解决的是"一次能看到多长",检索解决的是"从多大范围挑出该看的",两个问题正交。

权限过滤:谁该看什么

知识库通常有权限边界:A 部门的文件不能给 B 部门看。RAG 的检索环节天然可以叠加权限过滤,先按用户身份筛掉无权限的文档,再检索。长上下文方案如果一股脑把文档塞进 prompt,权限控制就变得极难做对,稍有不慎就会越权泄露。

RAG 把权限作为检索的前置条件,安全边界清晰得多。我可以明确告诉合规同事:"这个用户能看到的,只有检索阶段放行后的那几条。"而不是含糊地说"模型应该不会乱说"。在有权限要求的企业场景里,这一点往往是选型的决定性因素。长上下文要实现同等安全,得在 prompt 拼装时做严格的逐文档权限校验,工程上更脆弱。

成本控制:把不确定变成确定

RAG 的上下文长度是"检索结果条数 × 单条长度",是可预期、可约束的。而"整篇丢进上下文"的成本随知识库和内容长度波动,很难封顶。对需要控制预算的 ToB 产品,这种确定性本身就是价值。

我可以给客户承诺"每次问答成本不超过 X",因为检索返回的片段数我设了上限。整篇上下文方案做不到这种承诺,因为你不知道用户下次会牵扯出多长的文档。预算可控对一个要算 ROI 的商业产品,常常比那一点准确率更重要。

混合策略:短文档整篇进,长库走检索

翻车之后我落地的方案是混合式,核心判据是"内容体量"和"权限要求":

  • 短文档(单篇能舒适放进窗口):直接整篇进上下文,省去检索的复杂度,召回质量反而最高,因为模型看到了全部。
  • 长知识库(多文档、大体量):走 RAG 检索,先召回相关片段再生成。
  • 有权限边界的库:无论长短,都走检索层做权限过滤,不在 prompt 里赌模型守规矩。
  • 介于两者之间:按用户问题动态决定。如果问题指向某个具体文档且它够短,就整篇;否则检索。

这套策略的关键在于"不要一刀切"。我见过太多团队要么全 RAG、要么全上下文,都没摸到甜点。判断标准就两条:这段内容放进上下文,成本和质量是否都可接受;有没有越权风险。两条都满足才整篇进,否则检索。我还加了一层"问题路由":先用一个小模型判断问题涉及单文档还是多文档,再决定走哪条链路,这样连人工配置都省了。

分块策略的演进

RAG 里最容易被低估的环节是分块(chunking)。分块直接决定检索召回的质量,也是 RAG 效果的天花板之一。我自己的实践里,分块策略经历了三代演进。

第一代:固定长度切片

最早的朴素做法,按固定 token 数(比如 500)硬切,重叠一小段防止切断语义。优点是简单、稳定、可复现;缺点是切在句子或段落中间会破坏语义,检索时容易召回半截内容,模型读起来别扭,生成时上下文残缺。

我一开始就用这招,跑通快,但用户反馈"答案总是差一口气"。排查后发现,很多问题对应的答案被切在了两个块的交界,检索只命中了上半截,下半截的关键结论没进去。固定切片的另一个隐患是重叠会重复计费,重叠部分进了向量库也进了上下文,悄悄推高成本。

第二代:语义分块

后来改用语义边界切分:按段落、按标题、或按 Embedding 相似度下降的断点来切。这样每个块内部语义更连贯,检索召回更准。代价是需要先做结构解析,对格式混乱的文档(扫描件、无标题堆砌)鲁棒性一般。

语义分块在我那批结构清晰的 Markdown 文档上效果立竿见影,准确率肉眼可见地涨。但它要求文档本身有结构,遇到一堆无格式文本就得回退到固定切片,或者先做一次版面分析。另外语义分块的块大小不均,有的块很大有的很小,会给后续的向量检索和上下文拼装带来一点不可控。

第三代:父子块

这是我现在主用的方案。把文档切成"父块"(较大、保留完整语境)和"子块"(较小、用于精确检索),检索时先用子块匹配问题,命中后再把对应的父块送进上下文。这样既保证了检索的精确,又保证了生成时模型看到的是完整语境,避免了"召回半句话"的尴尬。

下面的伪代码示意了这套逻辑,注意我们用子块做向量检索,但真正喂给模型的是父块:

def retrieve_with_parent_child(query, child_index, parent_map, top_k=5):
    # 先用子块做精确检索
    hits = child_index.search(query, top_k=top_k)
    seen_parents = set()
    contexts = []
    for hit in hits:
        parent_id = hit.parent_id
        if parent_id in seen_parents:
            continue  # 同一父块只取一次,避免重复喂入
        seen_parents.add(parent_id)
        contexts.append(parent_map[parent_id])  # 取完整父块送进上下文
    return contexts

父子块的关键在于切分时同时存两份索引:子块进向量库,父块留在映射表。工程上多一步,但效果值得。我建议任何正式 RAG 系统都从这一代起步,别在固定切片上耗太久。

重排与混合检索:RAG 不止向量一把刀

只用向量检索的 RAG 很容易碰到"语义相近但关键词不同"的漏召回。我后来补了两刀,效果又上了一个台阶。

为什么要重排(Rerank)

向量检索擅长"语义泛化",但粗排结果前几名不一定真的相关。加一层重排模型,对召回的 Top-N 再做一次精细打分,把最相关的顶上去,能明显提升进入上下文的片段质量。重排是个小模型,成本低、收益大,我现在默认开启。

def retrieve_with_rerank(query, vector_index, reranker, top_k=5, candidate=20):
    # 先向量粗排取候选,再重排精排取最终
    candidates = vector_index.search(query, top_k=candidate)
    ranked = reranker.score(query, candidates)
    return ranked[:top_k]

向量加关键词的混合检索

纯向量检索会漏掉"精确术语、编号、专有名词"这类必须字面匹配的内容。混合检索把向量检索和 BM25 这类关键词检索的结果做融合(比如 RRF 倒数排名融合),兼顾语义和字面。对技术文档、合同、代码库这类场景,混合检索几乎是必选项。

Embedding 选型与那些坑

检索的上限由 Embedding 模型决定,它不行,后面怎么重排都救不回来。几点经验:

  • 选一个和你语料同语言的 Embedding 模型,跨语言模型在中文本地任务上常常掉链子。
  • Embedding 模型一旦选定不要随便换,否则旧索引全部失效要重建,是个大工程。
  • 维度不是越高越好,高维更占存储和检索算力,选个够用的中等维度更实惠。
  • 索引要能增量更新,否则文档一变就得全量重建,线上维护会痛。

评测:两种方案到底谁更好

别凭感觉选型,我用一套简单但可复现的实验来决策。没有评测的架构争论都是玄学。

实验设计

准备一组真实问题(来自用户日志),每个问题标注标准答案和对应的"应召回文档"。分别跑两套系统:

  • 方案 A:长上下文,把相关文档整篇放进窗口直接问答。
  • 方案 B:RAG,检索后再生成,采用父子块 + 重排策略。

两组用同一个生成模型、同一套 prompt 模板,只改"上下文怎么来",这样才能隔离变量,看出差异到底来自检索还是来自模型。我特意把问题按"答案位置"分了组:靠前、中间、靠后,用来观察长上下文的"中间失效"到底多严重。

评测指标

  • 回答准确率:用 LLM 评委或人工比对标准答案,打二分或打分。
  • 上下文成本:单次请求平均输入 token 数,这是钱的直接体现。
  • 首 token 延迟:用户体感的响应速度。
  • 越权风险:是否出现了无权限文档的内容(权限场景专用)。
  • 中间命中率:当答案藏在长文档中段时,方案 A 的准确率掉多少。

我额外加的"中间命中率"指标最能暴露长上下文的软肋。它让我在汇报时用一张图就说服了所有人:长上下文在答案靠后的问题上准确率断崖式下跌。

我的结论

短文档场景,方案 A 在准确率上略胜且实现简单,值得用;长库、多文档、有权限要求的场景,方案 B 在成本和安全性上碾压。中间地带用混合策略。这套实验的好处是,说服团队时不是"我觉得",而是"数据说":我把准确率差和成本差两张图往会议室一放,争论就结束了。

有个反直觉的发现:在中等长度、单文档场景下,长上下文反而比 RAG 更准,因为它没有分块造成的信息损耗。所以"RAG 一定更准"是错的,准确说"RAG 在长库场景更稳更省"。这个结论反过来也指导我:别为了用 RAG 而用 RAG。

我把这套实验落成一个函数,输入是标注好的问题集,输出按答案位置分组的准确率:

from collections import defaultdict


def evaluate(cases, answer_fn):
    """cases: [{"q": 问题, "std": 标准答案片段, "pos": head|mid|tail}]"""
    stat = defaultdict(lambda: {"hit": 0, "total": 0})
    for case in cases:
        pred = answer_fn(case["q"])
        stat["ALL"]["total"] += 1
        stat[case["pos"]]["total"] += 1
        if case["std"] in pred:
            stat["ALL"]["hit"] += 1
            stat[case["pos"]]["hit"] += 1
    return {k: round(v["hit"] / v["total"], 3) for k, v in stat.items()}


CASES = [
    {"q": "报销上限是多少", "std": "3000", "pos": "head"},
    {"q": "离职要提前几天", "std": "30", "pos": "mid"},
    {"q": "哪类发票不能报", "std": "餐饮", "pos": "tail"},
]

# 模拟长上下文:答案越靠后越容易被模型略过
def long_context(q):
    seen = {"报销上限是多少": "上限 3000", "离职要提前几天": "提前 30 天",
            "哪类发票不能报": ""}
    return seen[q]

# 模拟检索:先定位到块,位置几乎不影响
def with_retrieval(q):
    seen = {"报销上限是多少": "上限 3000", "离职要提前几天": "提前 30 天",
            "哪类发票不能报": "餐饮发票不能报"}
    return seen[q]

print("长上下文", evaluate(CASES, long_context))
print("检索    ", evaluate(CASES, with_retrieval))

什么时候反而该坚定选长上下文

前面一直在替 RAG 说话,但公平起见,有几种场景我反而建议你坚定用长上下文、别上 RAG:

  • 文档很少、但需要在篇与篇之间做密集交叉引用时。比如只有三五份设计文档,问题却经常要"对比 A 和 B 的说法是否矛盾",整篇进上下文让模型一次看全,比检索出片段再拼更不易漏。
  • 对召回准确性零容忍、且文档体量小。RAG 检索总有漏召回的可能,如果你宁可慢、宁可贵、也不能答错,短文档整篇进是最稳的。
  • 问题高度发散、没有清晰检索锚点。有些问题本质是"通读这份材料后谈你的整体感受",这种根本没有"该检索哪段"的答案,硬检索只会喂错片段。

我的判断标准始终回到那句话:成本和质量是否都可接受、有没有越权风险。三者满足就整篇进,否则检索。别被"RAG 是先进方案"的叙事绑架。

一个反直觉的成本视角

很多人以为"上 RAG 就一定比长上下文省钱",其实要算总账。RAG 一次问答的真实成本至少由四块组成:

  • Embedding 调用:把 query 向量化,以及维护索引时给文档切片向量化,都有成本。
  • 向量检索:自建向量库通常免费,但用托管服务按量计费。
  • 重排:如果开了 rerank,这是一次额外的小模型推理调用,单次不贵但乘上调用量就很可观。
  • 最终生成:这一项最贵,和长上下文方案一样。

也就是说,RAG 把"长上下文的输入 token 成本"换成了"检索+重排+较短上下文"的成本。在长库、多文档场景,换完通常便宜一个量级;但在短文档、单文档场景,你为检索和重排多付的两次调用,可能比直接整篇进还贵。所以"RAG 更省"是个有条件成立的命题,条件就是数据量足够大。这也是为什么我反复强调先用真实日志做成本评测,而不是凭印象拍板。

决策树:照着选就行

把前面所有判断压成一条可执行的链路,我团队现在用的就是下面这套,新接一个知识问答需求时照着走:

  • 第一步,问文档总量。单篇能轻松放进窗口吗?不能,跳到第三步;能,进入第二步。
  • 第二步,问权限。这篇文档有越权风险吗?有,仍走检索层做权限过滤;没有,直接整篇进上下文,结束,别上 RAG。
  • 第三步,问是否多文档/大体量。是,走 RAG,用父子块 + 重排 + 混合检索;否,回到第二步的权限判断。
  • 第四步,上线后用量级日志做 A/B,看准确率和成本,不达标再调分块或换检索策略。

这套链路的价值不在于"多聪明",而在于它把选型从拍脑袋变成了可复现的流程。新人接手也不会因为"听说 RAG 好"就无脑堆检索。

评测的常见陷阱

我见过太多 RAG 评测得出"虚高"结论,根子在这些坑里:

  • 用合成问题代替真实日志。自己编的问题往往更"规整",检索容易命中,一上真实用户日志就露馅。评测集务必来自真实提问。
  • 只测最终答案、不测检索。答案错了你不知道是检索的锅还是生成的锅,前面说了要先量 recall@k 隔离变量。
  • 样本量太小。二三十条问题得出的准确率,波动极大,不足以支撑架构决策。我一般攒到几百条真实问题才敢下结论。
  • 不把成本算进指标。只看准确率会偏向"无脑整篇上下文",把成本加上,结论常常反转。

避开这几条,你的评测才算数,否则只是给既定选择找借口。

工具链:自己写还是用框架

RAG 的工具链很热闹,LangChain、LlamaIndex 这类框架能让你十行搭出原型。我的态度是:原型阶段用框架没问题,生产阶段我更倾向自己写那几百行核心逻辑。理由是成本可控,框架为了通用会多塞上下文、多调几趟模型,你很难逐行优化;自己写你能精确控制每次调用喂了什么、花了多少 token。对成本敏感的产品,这点掌控力值钱。当然,如果团队没人想碰底层,框架先用着,等业务跑起来再按需替换瓶颈部分,也完全合理。

边界之外的提醒

还有一类场景,既不该无脑上长上下文,也不该硬套 RAG:那就是答案其实在结构化数据里。当用户问"上个月销售额最高的地区",正确的解法是用 SQL 或指标平台查出来,而不是把报表塞进 prompt 让模型"读"。把检索退回到真正的查询引擎,模型只负责把结果自然语言化,成本和准确率都更好。RAG 解决的是"非结构化文本的知识问答",越界去抢数据库和搜索的活,往往是架构走偏的信号。

检索质量:最容易偷工减料的一环

一个常见误区是把 RAG 的"答得不好"全怪到生成模型头上,其实第一步该查的是检索。如果检索根本没把正确答案所在的片段召回,再强的模型也只能瞎编。所以我习惯把检索质量和生成质量分开评:

  • 单独评检索:拿问题集,看正确答案所在文档有没有进 Top-K 召回。这个指标叫召回率(recall@k),它决定了天花板。
  • 只有召回率够高,再去调生成。否则你在生成侧折腾半天,是给一个本就拿错材料的学生改作文,没用。

下面是一段计算召回率的极简脚本,用在你自己的标注集上:

def recall_at_k(queries, retrieve_fn, gold_doc_id, k=5):
    hits = 0
    for q in queries:
        top = retrieve_fn(q, k=k)          # 返回 Top-K 文档的 id 列表
        if gold_doc_id[q] in top:
            hits += 1
    return hits / len(queries)             # 命中比例就是 recall@k

我第一次跑这个,召回率只有六成,才意识到问题根本不在模型,而在分块把答案切没了。修好分块后才去碰生成,准确率一下就上来了。先量后调,是 RAG 调优最省力的顺序。

一个我踩过的坑:检索把答案切没了

具体说那个坑:当时的固定切片把一段"原因+结论"从中间切断,结论落在块 B 的开头,但问题语义更像块 A,于是只召回了块 A,模型看不到结论,答得支支吾吾。加重叠(overlap)只是缓解,真正解决是换父子块,子块负责精确命中,父块保证把完整的一段(含结论)送进上下文。

这件事给我的教训是:分块不是预处理里顺手做的事,它直接决定 RAG 的上限。值得你花时间去理解文档结构、去人工翻几条召回结果看看"模型到底看到了什么"。我后来养成习惯,上线前随机抽二十条问题,人工看一眼送进上下文的片段是否完整,这一眼往往比任何自动化指标都管用。

长上下文下的 prompt 缓存技巧

前面说短文档走整篇上下文,这里补一个降本技巧:长上下文方案更要善用 prompt 缓存。因为整篇文档常作为稳定前缀反复出现,把"文档 + 角色设定"放在 prompt 开头并保持不变,命中缓存后,后续每轮对话只为新提问付费,成本能降一大截。

关键是"稳定前缀"这四个字。一旦你在固定前缀里插入会变的用户状态,缓存就碎了。我见过有人把用户 ID、时间戳塞进系统设定里,结果缓存命中率趋近于零,整篇上下文的省成本逻辑全废。把不变的和可变的用清晰边界分开,是长上下文方案能不能便宜的关键。

写在最后

回到开头那个翻车的项目,我最后的架构是:单文档问答走整篇上下文,跨文档和有权限的问答走父子块 RAG 再加重排,前面再加一层路由根据问题预估涉及范围。上线后成本回落到原来的几十分之一,长文档答非所问的投诉也基本消失。

所以我给还在纠结的人一句建议:别被"窗口变长"这个标题带节奏。先想清楚你的数据有多大、有没有权限边界、愿不愿意为每次请求付整库的钱,答案自然就出来了。RAG 会不会死?不会。但它确实该瘦瘦身,把那些本不该它干、长上下文干得更好的活,痛快地交出去。

相关文章