第 5 章 网关:检索组装与漂移重建
MoE 大模型低成本部署框架第 5 章 网关:检索组装与漂移重建
CHAPTER 05

第 5 章 网关:检索组装与漂移重建

15 分钟阅读 · 4,506 字

这一章是 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 的结构补全
在组装时还原——切分负责稳定,渲染负责语序。

这段包含三个可复用的工程判断:

  1. 增量场景下,「稳定」比「完整」更值钱。陈旧块不只是浪费算力,它会留在检索索引里当候选,主动污染后续每一次检索。
  2. 完整性可以在渲染阶段补回来,代价小得多。
  3. 这个结论是实测出来的,并且测试文件与 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,记录页的「检索回来的块」能直接看出来。

三件事值得学:

  1. 不同量纲的分数不能互换。 余弦与重排分不可比,顶替会产生一个「看起来在工作、实际排序已变形」的系统。
  2. 作者明知这个退化有害(⚠ 那段),但没有顺手改,因为它属于行为策略、不属于 bug 修复。
  3. 退化的判定办法被写进文档并指向了可观察的位置(记录页)。

同时它也暴露了一个缺口: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
全部章节