AI Context 管理:RAG 与 Long Context
Lewis 等人 2020 年的 RAG 把生成模型的参数记忆和可替换的非参数索引拆开:窗口装不下,才检索。现行 Claude 窗口写在 Anthropic context windows:Fable / Opus / Sonnet 5 默认 1M,Haiku 4.5 仍是 200k。能 count_tokens 塞进去、又需要跨段一起看的,先塞。语料比窗口大、或要按文档热更新的,再上索引。
选 / 不选
先数 token,再谈架构。窗口数字以厂商表为准,不是「越大越好」。Anthropic 自己写:token 增多,准确率和召回会掉,叫 context rot。硬塞到上限附近,不等于还能读中间那一段。
| 条件 | 选 | 不选 |
|---|---|---|
count_tokens 后的语料 + 预留(system / tools / thinking / 输出)仍明显小于窗口 |
整份 stuffing | 先切 512 再 RAG。跨段关系被切掉 |
| 语料大于窗口,或逼近窗口还要多轮、带工具 | RAG,或召回整文件再塞 | 硬塞。输入单独超窗是 400 prompt is too long |
| 问题是「找某一条政策 / 某一个函数」 | RAG + 过 rerank | 为一条事实付整库 input |
| 问题是数据流、多文件依赖、合同条款互指 | stuffing;塞不下就 hybrid(召回整文件) | 单跳 top-k 碎片。第二跳根本不在检索里 |
| 语料稳定、查询重复、且能塞进窗口 | stuffing + prompt cache | 每次按全价 input 重算前缀 |
| 语料常改、要按文件替换 | RAG:只重嵌脏文档 | 每次重塞全库;cache 前缀一起失效 |
| 只要把 PDF / 仓库丢上去问答、不自管切块 | 托管检索,例如 OpenAI file search | 为演示再自建一套向量库 |
预留不要拍脑袋。窗口算的是这一轮的全部:system、历史、工具定义、工具结果、thinking、输出。Anthropic 文档:输入单独超窗 → 400;Claude 4.5 及更新,输入 + max_tokens 超窗可能先接受,生成顶到上限时 stop_reason: "model_context_window_exceeded"。
Token、费用、新鲜度
用目标模型的 token counting,不要用别家的 tokenizer 估 Claude。官方写明:Claude 4.7 及之后的 tokenizer 对同一段文本大约多计 30%,迁移时要按新 model 重数。
费用按 Anthropic pricing 的公开表,2026-09 核对:Sonnet 5 输入 $2 / MTok、输出 $10 / MTok、cache 命中 $0.20 / MTok(0.1×);Haiku 4.5 输入 $1 / MTok、窗口 200k。Claude 4.6 及之后,1M 窗口按标准单价计,没有「超过某 k 另加价」这一档。1M 是默认值,不需要 beta header。Prompt cache 只改账单:缓存前缀仍然占窗口。Haiku 4.5 卡在 200k,同一份 180k 语料在 Sonnet 5 上还有余地,在 Haiku 上已经要为 tools / thinking 发愁。模型路由先看窗口,再看单价。把 Haiku 的 200k 当成「质量悬崖」没有出处;官方只保证超窗会报错,不保证 199k 时中间段落仍然可读。
同一段 200k token 的稳定语料,每次查询都塞进 Sonnet 5:
- 未命中 cache:0.2 × $2 = $0.40 / 次(只算这段 input)
- cache 命中:0.2 × $0.20 = $0.04 / 次
- 5 分钟 cache 写入按 1.25×:第一次约 $0.50
同一查询若只把约 8 × 512 token 的 chunk 送进模型:约 4k token → $0.008 / 次 input。索引侧还要付 embedding,但是一次性、按脏文档摊。
边界因此是算术,不是正确率表:
- 能塞、查询重复、语料稳定:stuffing + cache。几次命中就收回 1.25× 的写入。
- 能塞、但每次语料都变:cache 帮不上。200k 全价 vs 4k 检索,差大约两个数量级,RAG 更便宜。
- 塞不下:没有「再买一点质量」的选项,只能 RAG 或 hybrid。
- 新鲜度:权重里的知识有 cutoff(Sonnet 5 可靠知识到 2026-01)。cutoff 之后的事实,不进索引也不进 prompt,就是编的。RAG 论文用 2016 / 2018 两份 Wikipedia 索引热替换,证明非参数记忆可以整库换掉而不重训生成器。磁盘上的文件改了、索引没重嵌,就是反面。
按查询量摊:100 次/天、每次都是未命中 cache 的 200k stuffing,input 约 $40/天;同样 100 次只送 4k 检索结果,约 $0.80/天。前缀稳定且能 cache 命中时,200k stuffing 降到约 $4/天,和「整库塞进去做跨文件推理」才匹配。查询少、语料每天改、cache 几乎不命中:别为了「有 1M 窗口」付 200k。
先数,再塞(long context)
下面需要 ANTHROPIC_API_KEY。形状按官方 token counting 与 Messages API。本仓没有带着密钥跑过,算可跑形状,未在本仓执行。
from pathlib import Path
import anthropic
MODEL = "claude-sonnet-5"
WINDOW = 1_000_000 # Haiku 4.5: 200_000
HEADROOM = 32_000 # tools / thinking / 输出;按实际再加
client = anthropic.Anthropic()
files = sorted(Path("./data").rglob("*.md"))
corpus = "\n\n".join(f"## {path}\n{path.read_text()}" for path in files)
counted = client.messages.count_tokens(
model=MODEL,
messages=[{"role": "user", "content": corpus}],
)
print("corpus_tokens", counted.input_tokens)
if counted.input_tokens > WINDOW - HEADROOM:
raise SystemExit("does not fit; RAG or hybrid")
msg = client.messages.create(
model=MODEL,
max_tokens=2048,
messages=[
{
"role": "user",
"content": (
f"{corpus}\n\n"
"Question: RateLimiter 的默认 burst 是多少?\n"
"只根据上文回答。找不到就说找不到。"
),
}
],
)
print(msg.stop_reason, msg.usage)stop_reason == "end_turn" 才是正常结束。"model_context_window_exceeded" 是窗口顶满,答案被截断,不是「模型决定停」。"prompt is too long" 出现在 count 漏算或 HEADROOM 不够:请求根本没进生成。
Lost in the Middle 的结论仍然有用:相关段落放在上下文开头或末尾,中间最差。塞进去之后,把问题放在语料后面;关键文件不要埋在 20 份检索结果的正中。这不是正确率数字,是位置实验的方向。
RAG 管线:切块、embedding、k、rerank
自管索引用 LlamaIndex。官方默认 chunk_size=1024、chunk_overlap=20、similarity_top_k 默认 2。改小 chunk 时,同一文档会被切成更多节点,k 要一起加大,否则召回的总字数反而变少。
Embedding 换了必须重索引,查询必须用同一模型。这是官方写明的,不是调参建议。OpenAI embeddings 的 text-embedding-3-large 向量维度和 ada-002 不同,混用会直接变成噪声检索。
Rerank 用 Cohere Rerank:先放大 k,再压到送进 LLM 的 top_n。LlamaIndex 示例是 similarity_top_k=10 + CohereRerank(top_n=2)。k=2 且不做 rerank,等于把排序完全交给 embedding 近似。
下面同样是示意,未在本仓跑过。依赖 llama-index、llama-index-embeddings-openai、llama-index-postprocessor-cohere-rerank,以及 OPENAI_API_KEY / COHERE_API_KEY。
from llama_index.core import Settings, SimpleDirectoryReader, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.postprocessor.cohere_rerank import CohereRerank
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-large")
Settings.text_splitter = SentenceSplitter(chunk_size=512, chunk_overlap=50)
documents = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(documents)
query = "RateLimiter 的默认 burst 是多少?"
retriever = index.as_retriever(similarity_top_k=10)
hits = retriever.retrieve(query)
print("k", len(hits))
for node in hits:
print(round(node.score, 4), node.node.metadata.get("file_name"), node.get_text()[:80])
engine = index.as_query_engine(
similarity_top_k=10,
node_postprocessors=[CohereRerank(top_n=3)],
)
response = engine.query(query)
print(response)
for node in response.source_nodes:
print(node.node.metadata, node.get_text()[:120])先看 hits,再看生成。hits 已经空或文不对题,再调 prompt 没有用。
切块本身有选 / 不选。FAQ、政策、changelog:512 + overlap 50,问句短、答案短,切大了 embedding 被噪声稀释。默认 1024 适合段落完整、问题需要前后句。代码库不要用通用 SentenceSplitter 横切函数。Node Parser 里的 CodeSplitter(language="python", chunk_lines=40, chunk_lines_overlap=15) 按语言边界切。Markdown 带标题的文档用 MarkdownNodeParser,避免标题和表格被拆开。
LlamaIndex 同一页还写了 hybrid search:embedding 经常漏掉精确标识符(函数名、错误码、issue 编号)。关键字能命中、向量打偏时,上 BM25 或向量库自带的 hybrid,而不是把 k 盲目加到 50。k 加大只增加送进 rerank 的候选;标识符对不上,50 个近邻仍然是错的。
Hybrid:检索文件,精读全文
碎片 RAG 解不了「这个模块的数据流怎么走」。需要的不是 512 token 的相似句,是那几个完整文件。
示意:
from pathlib import Path
hits = retriever.retrieve(query)
paths = []
for node in hits:
name = node.node.metadata.get("file_name") or node.node.metadata.get("file_path")
if name and name not in paths:
paths.append(name)
bundle_parts = []
for name in paths[:4]:
path = Path("./data") / name
if path.is_file():
bundle_parts.append(f"## {path}\n{path.read_text()}")
bundle = "\n\n".join(bundle_parts)
counted = client.messages.count_tokens(
model=MODEL,
messages=[{"role": "user", "content": bundle}],
)
if counted.input_tokens > WINDOW - HEADROOM:
raise SystemExit("retrieved files still overflow; drop files or split by path prefix")
msg = client.messages.create(
model=MODEL,
max_tokens=2048,
messages=[{"role": "user", "content": f"{bundle}\n\nQuestion: {query}"}],
)Hybrid 付的是「命中的那几份全文」,不是整库,也不是互不相干的句子。召回阶段仍可能漏文件;漏了就在 paths 里看见,而不是在散文答案里猜。
失败怎么看见
四种病,对应四种看得见的现象。不要用无出处的准确率表代替。
切坏。 标题在 chunk A,表格在 chunk B;函数签名和函数体拆开。看见:get_text() 以半句话或孤零零的 ## 开头;引用的文件名对,贴出来的片段里没有答案。修法:换按结构切的 parser,或切完抽几条「答案必须落在同一 chunk」的问题,看命中的是不是完整块。
Embedding 不一致。 索引用 A 模型,查询用 B;或改了模型没重建。看见:hits 为空,或 score 看起来不低但文本和问题无关;换一个同义词查询,排序乱跳。维度不同的向量在同一张表里,近似最近邻没有定义。LlamaIndex 的句子是硬约束:改 embedding 必须重索引,查询和索引同一模型。
多跳。 「这个函数谁写的、最后一次改是什么时候」要代码 和 git 元数据。单次 embedding 检索只靠近问句字面。看见:source_nodes 只有函数体,没有作者 / 日期;模型把缺失的那一跳补完,引用却指回函数体。修法:拆成两次检索,或 hybrid 拉进整文件再加 git log -L 这类结构化证据,不要指望 top-k 句子当史官。
索引过期。 文件改了,向量还是旧的。看见:答案引用的段落在磁盘上已经不存在,或和当前文件矛盾;检索命中旧 API 名。对照:对一条刚改的事实提问,source_nodes 仍是旧句。修法:按 mtime / git sha 追踪脏文档,只重嵌变更;发布清单里要有「索引落后于仓库」这一项,而不是只看服务在不在。
空命中、错误引用、上下文溢出,是同一条链上的三个检查点:len(hits)==0、citation 对不上原文、prompt is too long / model_context_window_exceeded。生成散文看起来通顺,不在这三个检查点上,等于没测。
对照原文和脏文件,用命令看,不要用印象:
# 示意。persist 路径按本机索引目录改。源文件比 docstore 新 = 索引落后。
find ./data -type f \( -name '*.md' -o -name '*.py' \) -newer ./storage/docstore.json# 示意。引用片段必须能在当前文件里找到;找不到就是错误 citation 或过期索引。
from pathlib import Path
node = response.source_nodes[0]
cited = node.get_text()
name = node.node.metadata.get("file_name")
text = Path("./data", name).read_text()
print("empty_hits", len(hits) == 0)
print("citation_in_file", cited in text)
print("score", None if not hits else hits[0].score)citation_in_file is False 有两种常见原因:切块时改过空白,或索引里的句子已经被删。前者把比对改成「关键句是否在文件中」;后者当过期索引处理。多跳问题则故意造一条「答案不在被检索的那一类文件里」的题:只索引 *.py,问作者和日期,source_nodes 不该变出 git 日志。变出来了,就是模型在补跳。