第 5 章 网关:检索组装与漂移重建
这一章是 1M 能力的全部机制。引擎原生窗口硬固定 262144、超窗即 400、从不截断,
所以「1M」这件事完全发生在~/rag-proxy/rag_proxy.py里。
本章按组装的实际顺序走:分块 → 嵌入 → 检索打分 → 渲染 → 漂移判据 → 预算收敛,
每一段都给出参数取值与它被定成这个值的实测理由。
5.1 组装的总公式
assemble(rag_proxy.py:1181)的预算分配是整个机制的骨架:
gen_reserve = min(body.get("max_tokens") or CFG["gen_reserve_default"], CFG["gen_reserve_max"])
budget = CFG["max_context"] - CFG["ctx_slack"] - gen_reserve
turns = parse_turns(messages)
recent, recent_turns, recent_msg_idx, recent_tokens = recent_window(messages, turns, CFG["recent_window_tokens"])
head_tokens = system + system消息 + tools 的 JSON
memory_budget = max(0, budget - head_tokens - recent_tokens - CFG["memory_min_tokens"])
翻译成一句话:
262144 窗口
− 8 ctx_slack(计数残差缓冲)
− 8192 默认输出预留(gen_reserve_default,上限 32768)
− 系统提示词与工具定义(逐字保留,绝不进检索)
− 65536 最近逐字窗口(回合对齐)
− 4096 memory_min_tokens(检索块的最低保底)
────────────────────────────
= memory_budget 检索块预算
送进引擎的永远 ≤ 262144。 这就是「1M 会话」的全部实现方式:
模型看到的永远是一个 256K 窗口,其中 65K 是逐字的最近历史,其余是检索出来的片段。
文件开头对这件事的表述(:19-20):
最近逐字窗口(回合对齐,不会停在回合中间)+ 检索块(更早历史,按原始次序渲染并带回合结构标签);
检索块在 TTL 内保持稳定(前缀缓存友好),只在 TTL 到期 / 窗口将满时重建。
5.2 分块:一个结构单元 = 一块
parse_turns(:525)把消息流解析成回合,Unit(:495)是结构单元:
一条用户输入、一次工具调用、一次工具返回、一段回答,各成一个单元。
chunk_turn(:630)把一个回合切成块,规则是「一个结构单元 = 一块,块绝不跨回合」。
只有当单条消息本身超过 chunk_tokens = 768 时,才按自然边界再切成 part / n_parts。
切分顺序是 行 → 段 → 句(split_unit,:570)。
为什么不合并相邻单元
这是本章最值得学的决策过程。合并相邻单元看起来更自然(上下文更完整),
但它有一个致命副作用,docstring 写得很直白:
为什么不在块里合并相邻单元(实测过,见
test_chunking.py §7与README §5e):
合并会让进行中的回合每来一条消息就把最后一块重新切一遍——
40 轮真实 agent 会话逐条到达要白做 100 次嵌入、留下 100 个陈旧块
(陈旧块还留在检索索引里当候选,等于自己给自己加噪声)。
逐单元成块是唯一的零重嵌策略:一条消息一旦到达就不再变,它的块边界与哈希从此恒定,向量直接复用。
回合的连贯性不靠「合并进同一块」来保证,而由render_block的回合标签 +expand_context的结构补全
在组装时还原——切分负责稳定,渲染负责语序。
这段包含三个可复用的工程判断:
- 增量场景下,「稳定」比「完整」更值钱。陈旧块不只是浪费算力,它会留在检索索引里当候选,主动污染后续每一次检索。
- 完整性可以在渲染阶段补回来,代价小得多。
- 这个结论是实测出来的,并且测试文件与 README 章节都留了出处。
切口的历史事故
split_unit(:573)记下旧版的一个具体 bug:
旧版是「768 token 字符窗口 + 128 字符重叠」,实测把
…retry=2\nline 27…切成下一块的第一行
现在的做法是按自然边界切,target = max(400, target_tokens * CFG["chars_per_token"])。
trim_at_line_tail(:601)负责尾部截断时「把切口往前挪到行首」——
注释补了一句:「最近窗口里的巨型消息也别从行中间开始」。
代码里保留失败样例的原文(…retry=2\nline 27…)是很好的习惯:
下一个人看到这段就不会再犯同样的错,而且能验证修复是否真的有效。
5.3 最近逐字窗口:回合对齐(recent_window,:916)
从后往前按整回合收集逐字保留的消息,预算 recent_window_tokens = 65536。
docstring 给出了这条规则的由来:
回合对齐是关键:旧版按单条消息从后往前装,窗口可能停在回合中间——
模型看到一个孤零零的工具返回,不知道它由哪个工具调用产生、回答的是哪个问题(语序失真的另一半来源)。
这里窗口边界永远落在回合边界上:要么整回合在窗口里,要么整回合交给检索。
「孤零零的工具返回」这个描述非常准确:模型看到一段没有调用者的工具输出,
就会开始猜测它属于哪个调用,这正是幻觉的一种成因。
巨型回合的兜底
同一个 docstring 还处理了一个边界情况:
最后一个回合本身就超预算时(一次性灌入的巨型回合):先从头部整单元整单元地丢,
只剩一条还装不下时才在那条消息内部按行截尾;被剪掉的部分仍在会话分块里,可由检索块按相关性召回
(否则组装完全失效:检索拿不到候选,请求原样超窗被引擎 400 拒绝)。
注意括号里那句:否则组装完全失效。剪掉的内容必须仍然可检索,
否则「压缩最近窗口」这个动作本身就会把内容彻底丢掉。
5.4 嵌入:异步、批量、可降级
embed_texts(:692)调用config.json的embed_url(http://10.42.0.67:8080/embeddings,模型bge-m3)。embedding_worker(:703)是后台线程,embed_batch = 16,embed_timeout_s = 30,embed_wait_s = 120。enqueue_embed(:744)在分块后把新块排队嵌入;计数器embeds_queued/embeds_done/embed_errors。- 查询向量有缓存(
EMB_CACHE+EMB_LOCK)。
嵌入是异步的,这意味着一个真实后果:新到的块可能还没有向量。
build_memory_block 对此的处理是(:1096 附近):
query embedding not ready - degrading→ 返回空检索块
也就是「这一轮没有检索块,只有最近窗口」,请求照常成功。
这就是 §4.8 那条降级承诺在代码里的具体落点。
5.5 检索打分:三路加权 + MMR
build_memory_block(:1049)的打分链路:
cands = cosine_matrix(sess, qv) # 全部块 vs 查询向量
cands = 排除已在最近窗口里的回合
cands.sort(by cosine); cands = cands[:200] # retrieve_candidates
top = cands[:200] # rerank_candidates
rr = rerank(query, [c.text for c in top]) # bge-reranker-v2-m3
recency = exp(-(n - c.idx) / max(32.0, n * 0.5))
s = 0.55 * rerank + 0.30 * recency + 0.15 * role_weight(role)
role_weight(:975):user 1.0、assistant 0.8、system 0.5、tool 0.4。
用户说的话权重最高,工具返回权重最低——这个取值反映了一个判断:
在 agent 会话里,工具输出量大而信息密度低,用户消息才是意图所在。
recency 用指数衰减,半衰期随会话长度自适应(分母 max(32, n*0.5)),
会话越长,近因衰减越慢。这是为了避免长会话里「只有最后几十轮可被检索」的退化。
MMR 去冗余
lam = 0.7 # mmr_lambda
# 贪心选:λ * 相关分 − (1−λ) * 与已选块的最高相似度(用 chunk 自己的向量)
注释说明目的:防同一话题刷屏。填充文本高度相似时,没有 MMR 就会选出十几个近似重复的块,
挤掉真正不同的证据。
重排器不可用时:置 0,不用余弦顶替
这段注释(:1116)是全书最想推荐的一处判断:
重排器不可用 → 没有重排分(这里保持原行为:置 0 而不是拿余弦顶替)。
⚠ 这会让总分退化成「最近优先」:早期事实即使余弦明显更高(实测 0.73 vs 0.43)也选不上。
这是行为选择,不是记录功能的一部分,所以没有擅自改;现象与判定办法写在
~/docs/services/rag-config-testing.md §7,记录页的「检索回来的块」能直接看出来。
三件事值得学:
- 不同量纲的分数不能互换。 余弦与重排分不可比,顶替会产生一个「看起来在工作、实际排序已变形」的系统。
- 作者明知这个退化有害(⚠ 那段),但没有顺手改,因为它属于行为策略、不属于 bug 修复。
- 退化的判定办法被写进文档并指向了可观察的位置(记录页)。
同时它也暴露了一个缺口:COUNTERS 里没有 rerank_unavailable,这条降级发生时运维并不知道。
第 19 章把它列为观测盲区。
5.6 渲染:块顺序 = 回合顺序(render_block,:980)
[Earlier conversation excerpts retrieved for the current question]
--- turn 12 · user → assistant(tool_call: vault_get) → tool_result ---
[user]
...
[tool_result part 1/3]
...
docstring:
按原始先后顺序渲染检索块,每换一条回合打一行结构标签。
块顺序 = 回合顺序(不是分数顺序),模型读到的仍是事情发生的次序;
结构标签(user → assistant(tool_call: vault_get) → tool_result)让每个片段带上出处——
这正是 §2.6「张冠李戴」的对症处理:片段可归属,模型就不容易把相似事实从别的回合搬过来。
按分数排序检索结果是最常见的 RAG 错误之一。 它把时间线打乱,
模型会把「A 回合里发生的事」归因到「B 回合」。这里显式按 (turn, idx, part) 排序,
并给每个片段打上出处标签。
expand_context(:1000)做结构补全:选中一个块后,按 context_expand_ratio = 0.25
把它的相邻块也带上,避免「只拿到答案的一半」。
5.7 漂移重建:这一章的核心判据
问题:TTL 判据看不见「块还有没有用」
组装块在 assembly_ttl_s = 10800(3 小时)内复用,这是前缀缓存友好的关键。
但复用判据只看时间与窗口,不看块对当前问题还有没有用。代码注释给出了这个缺口造成的实测后果(:1214 起):
覆盖度判据:TTL 判定只看时间与窗口,不看块对当前问题还有没有用——
提问阶段于是拿到灌入阶段为别的查询建好的旧块
(实测同一份语料 TTL=3h 时召回从 6/6 掉到 0/6)。
6/6 → 0/6。 同一份语料、同一个测试,仅仅因为「块是早先为别的问题建的」,召回全部归零。
这就是漂移重建存在的理由。
为什么用重排器,而不用嵌入余弦
同一段注释接着说:
判据用重排器问一句:「这个块里有没有真能回答当前问题的内容?」分数最高那块都答不上 → 重建。
为什么不用嵌入余弦:句式相同的事实块它分不开(实测 0.70 vs 0.48,块里塞满填充后差距更小,
于是「全历史更好 vs 块内最好」几乎永远不超阈值——实测 23 次组装只触发 1 次重建,召回 2/6);
重排器是交叉编码器,同一组文档实测 0.80 vs 0.0004。
这组数字是全书最有说服力的一处对比:
| 判据 | 同一组文档的两类得分 | 结果 |
|---|---|---|
| 嵌入余弦 | 0.70 vs 0.48 | 差距太小,23 次组装只触发 1 次重建,召回 2/6 |
| 交叉编码重排 | 0.80 vs 0.0004 | 差距悬殊,判据可用 |
嵌入模型衡量「像不像」,交叉编码器衡量「答不答得上」。 漂移判据需要的是后者。
实现与代价
fresh = sess.stable_block and (now - sess.stable_block_at < CFG["assembly_ttl_s"])
need_compact = memory_budget <= 0 # 最近窗口已吃满预算:必须重建
if fresh and not need_compact and CFG.get("block_drift_rebuild") and chosen:
docs = [c.text[:600] for c in chosen[:128]] # block_check_chars / block_check_docs
sc = rerank(q, docs)
if sc and max(sc) < CFG["block_relevance_min"]: # 0.30
fresh = False
COUNTERS["block_rebuilds"] += 1
注释把代价也算清了:
代价:每次超窗请求多一次重排调用(块内优先级最高的 ≤128 块、每块截 600 字符),
换来的是「同话题追问照旧复用(前缀缓存不动),只有真漏了才付重建的钱」。
判据的成本被刻意压到最低:只重排块内优先级最高的 128 块、每块只取前 600 字符。
判相关性够用,且便宜。
为什么复用这么重要
rag_proxy.py:1932 的注释给了量化后果:
随后同会话生成从「复用前缀」退化为「整段重读」(实测 TTFT 0.1s→0.4s+,长提示更甚)。
而第 2 章约束 6 给的是另一个方向的数字:64K 提示词复用 13.4 秒、重读 0.79 秒……
准确说,docs/项目背景与问题总览.md §4 记的是「复用把 64k 提示词 TTFT 从 13.4s 降到 0.79s」。
两个数字方向一致:复用与重读之间差一个数量级。组装块之所以要 TTL 内稳定,全部理由就在这里。
5.8 预算收敛:必须装得下(assemble 内两轮循环)
第一轮:丢块(:1283 起,最多 4 轮)
超预算时先砍检索块,每轮保留 70%:
keep_n = max(1, int(len(chosen) * 0.7))
info["shrunk"] = f"-{dropped} blocks"
4 轮砍完仍超预算,就整个丢掉检索块:info["shrunk"] = "block dropped"。
第二轮:砍消息(:1301 起,最多 64 轮)
注释写得非常硬:
最后一道保险:丢块之后仍超预算(估算偏差 / 计数不可用 / 图片展开)时继续砍,必须收敛到装得下:
先把最长的非 system 消息从头部砍(砍掉的头部内容仍在会话分块里,检索还能带回),
没有可砍的大消息了就丢最近窗口里最旧的整条消息。引擎对超窗请求直接 400、从不截断,
所以这里绝不能「砍不动就作罢」——那正是把 400 交回客户端的那条路。
策略顺序很清楚:先砍最长的非 system 消息(保留尾部 60%,最少 2048 字符,按行截尾),
再丢最近窗口里最旧的整条。system 消息全程不动。
砍无可砍时,注释写明交给窗口守卫压 max_tokens 兜底。
落账口径:拿不准就往大算
:1331 的注释:
组装结果按引擎口径落账:精确计数 + 图片展开;计数不可用时用上界估算。
拿不准的数字一律往大算——它直接决定clamp_max_tokens给客户端留多少max_tokens,
往小算就会放行一个引擎必然 400 的请求(生产实测:网关记 219,021、引擎数到 231,775 → 400)。
不确定时的偏差方向要选对。 这里往大算的代价是「客户端少拿一点输出预算」,
往小算的代价是「请求被引擎拒」。前者可接受,后者不可。
5.9 参数总表
config.json 的取值(2026-10-19 实测),按机制分组:
窗口与预算
| 参数 | 值 | 含义 |
|---|---|---|
max_context |
262144 | 引擎原生窗口 |
ctx_slack |
8 | 计数残差缓冲 |
gen_reserve_default / gen_reserve_max |
8192 / 32768 | 输出预留 |
recent_window_tokens |
65536 | 最近逐字窗口 |
memory_min_tokens |
4096 | 检索块最低保底 |
query_max_tokens |
2048 | 查询串上限 |
clamp_margin_tokens / clamp_margin_div |
512 / 64 | 钳制余量 |
retry_on_overfit |
True | 照引擎 400 答复重发 |
分块
| 参数 | 值 |
|---|---|
structure_chunking |
True |
chunk_tokens / chunk_overlap_tokens |
768 / 128 |
chars_per_token |
2.5 |
tok_per_char_cjk / _digit / _other |
1.05 / 1.05 / 0.7 |
msg_overhead_tokens |
8 |
image_tokens |
1024 |
检索与打分
| 参数 | 值 |
|---|---|
embed_url / embed_model |
10.42.0.67:8080/embeddings / bge-m3 |
rerank_url |
10.42.0.91:8080/v1/rerank |
retrieve_candidates / rerank_candidates |
200 / 200 |
mmr_lambda |
0.7 |
score_w_rerank / score_w_recency / score_w_role |
0.55 / 0.30 / 0.15 |
context_expand_ratio |
0.25 |
embed_batch / embed_timeout_s / embed_wait_s |
16 / 30 / 120 |
rerank_timeout_s / count_timeout_s |
30 / 10 |
复用与漂移
| 参数 | 值 |
|---|---|
assembly_ttl_s |
10800(3h) |
rerank_cache_ttl_s |
10800 |
block_drift_rebuild |
True |
block_relevance_min |
0.30 |
block_check_docs / block_check_chars |
128 / 600 |
session_ttl_s / max_sessions |
86400(24h) / 64 |
口径提醒:早期调研稿把
assembly_ttl_s记成 1800 秒、session_ttl_s记成 1800 秒,
与实际config.json不符。本书以实测配置文件为准。
5.10 这套机制在哪里失败
机制讲完,必须讲它做不到的事,否则这一章会变成宣传。
BABILong:15 题对 2 题(bench/1M上下文精度测试报告-2026-10-07.md)。
生产 300 轮长会话:7 个散落事实召回 3 个(docs/reports/上下文召回测试.md)。
NIAH 网格:15/15 全对(同一份报告)。
三组数字放在一起才有意义:
- NIAH 测的是「一句话埋在长文本里,能不能找到」——检索组装对此完全够用。
- BABILong 测的是「需要跨全文做推理/聚合的任务」——检索组装对此不够。
- 生产 300 轮测的是「事实散落在真实对话里」——召回率 43%。
失败模式是「自信地答错」(docs/项目背景与问题总览.md 问题 #2)。
检索没召回相关块时,模型不会说「我不知道」,它会基于最近窗口编一个合理答案。
根因在机制本身:当填充文本彼此相似时,检索会偏向最近的块
(score_w_recency = 0.30 与近因指数衰减都在推这个方向),远处的事实就丢了。
MMR 与角色权重能缓解,不能消除。
所以第 1 章那句推论要在这里落地:1M 能力等于检索能力。
网关这套机制把「能不能跑」解决了,「准不准」取决于嵌入与重排的质量,
而那是一个尚未做到位的环节。第 19 章把这条列为未解问题 #2。
5.11 本章小结
- 组装公式:262144 − 8 − 输出预留 − 系统/工具 − 65536 最近窗口 − 4096 保底 = 检索块预算。
- 分块以结构单元为粒度、绝不跨回合,理由是零重嵌;合并相邻单元会产生陈旧块并污染检索索引。
- 最近窗口按整回合对齐,避免模型看到「孤零零的工具返回」。
- 打分三路加权(0.55 重排 + 0.30 近因 + 0.15 角色)+ MMR λ=0.7 去冗余。
- 重排器不可用时置 0 而不用余弦顶替:量纲不可比,宁可显式退化。代价是排序退化为近因优先,且无计数器。
- 渲染按回合顺序而非分数顺序,带出处标签,对症「张冠李戴」。
- 漂移重建用交叉编码器问「这块答得上眼当前问题吗」,阈值 0.30,只查 128 块 × 600 字符。
嵌入余弦做不到(0.70 vs 0.48 对比 0.80 vs 0.0004)。 - 两轮收敛保证「必须装得下」;不确定数字一律往大算。
- 这套机制让 1M 能跑,但 BABILong 2/15 与生产 3/7 说明「准不准」还没解决。
下一章:06 引擎启动与内存计划 —— 进入 C++ 引擎内部。
本章出处
| 内容 | 出处 |
|---|---|
| 组装公式与预算 | rag_proxy.py:1181-1200 |
| 结构单元成块与零重嵌理由 | rag_proxy.py:630-645 docstring;test_chunking.py §7、README §5e |
| 旧切口的 bug 样例 | rag_proxy.py:573 |
| 回合对齐理由 | rag_proxy.py:916-930 docstring |
| 三路加权与 MMR | rag_proxy.py:1116-1145;config.json |
| 重排不可用时置 0 | rag_proxy.py:1116;~/docs/services/rag-config-testing.md §7 |
| 渲染按回合顺序 | rag_proxy.py:980 docstring |
| 漂移重建判据与实测数字 | rag_proxy.py:1214-1235 注释(6/6→0/6;0.70 vs 0.48;23 次触发 1 次、召回 2/6;0.80 vs 0.0004) |
| 复用 vs 重读的 TTFT | rag_proxy.py:1932(0.1s→0.4s+);docs/项目背景与问题总览.md §4(13.4s→0.79s) |
| 收敛循环 | rag_proxy.py:1283-1335 |
| BABILong 2/15、NIAH 15/15、生产 3/7 | bench/1M上下文精度测试报告-2026-10-07.md、docs/reports/上下文召回测试.md |