30 秒判断:本篇基于
graphrag 3.2.0(截至发稿为最新发布版,并对照官方main分支核对)。你在用它的全局检索(global_search),或者正打算用它回答"这批资料整体讲了什么"这类问题——这篇把它默认模式(非自适应) 的五步流程拆到代码级,说清三处会直接影响你结果的地方:材料比文档说的多(实际是level <= N的全部报告,不是"某一层")、两处损耗是静默的(map 的批内压缩、reduce 的按分截断)、以及让用户填层号这个旋钮本身在结构上就做不到。一句话结论:Global Search 不做检索——它把选出来的社区报告全量扫一遍,由 map 各自出要点、reduce 再拼成综述,所以成本几乎只由"要扫多少份报告"决定。麻烦在于唯一能控制这个数量的旋钮(层号)本身是失准的:它不等于"只取某一层",也不保证"调大就更细"。
适合谁读:在用 GraphRAG(或类似"预生成摘要 + map-reduce"方案)做全局问答的同学。不需要读过原论文,但要知道什么是社区报告。
阅读成本:约 15 分钟。1 节是一屏流程;2 节讲材料选择(含一个上游 bug 的来龙去脉);3–5 节是三步执行;6 节是选层这个旋钮的结构性问题与另一条路;7 节是参数表(查阅用);最后是小结与下一篇的引子。
证据标记:✅ 实测(本地探针/官方 notebook 输出)· 📄 源码(读
graphrag 3.2.0与官方仓库)· 📎 官方文档/论文 · 🤔 推断。
0. 为什么单独看它
GraphRAG 的四种检索模式(Local / Global / DRIFT / Basic)里,只有 Global Search 能干一件事:回答"跨整个语料"的问题。Local Search 是从一组命中实体出发看局部邻域,Basic Search 是纯向量找相似片段,它们都给不出"这批资料整体有哪些主题"。
它为什么需要单独一套机制?因为"全局问题"根本不是检索问题。 "这批资料里有哪些主要主题"这种问法,向量检索无从下手——top-k 的前提是"能挑出若干条最相关的记录",而"整体讲了什么"的相关性无法定义在任意单条记录上。论文把这层说得很直白(📎 原文):
RAG fails on global questions directed at an entire text corpus, such as "What are the main themes in the dataset?", since this is inherently a query-focused summarization (QFS) task, rather than an explicit retrieval task.
另一条现成的路也不行:直接对源文本做摘要——论文里那个对比条件 TS(把同一套 map-reduce 直接作用在源文本上)恰恰是最贵的一条,token 就是它的 100% 基准。
于是 GraphRAG 的解法是在两者之间插一层"中间粒度" :索引期先把语料压成一批社区报告,查询期不再去捞原文,而是对这批准摘要做一次 map-reduce(每批出要点 → 汇总成一篇)。这就是它省 token 的来源——根层社区摘要只需要 TS 的 2–3%。
一句话背景(不熟悉 GraphRAG 索引的同学):索引期它先从原文抽出一张实体关系图,再用社区发现把图切成若干层的社区——每个社区就是一组强关联实体的集合;然后让 LLM 给每个社区写一份社区报告。查询期拿去当"材料"的,就是这批报告。
那"用户为什么必须自己填社区层级"? 因为"压到多粗"是一个绕不开的权衡,而这个权衡取决于用户想要什么,不是系统能替他猜的:
| 层号 | 社区形态 | 材料特征 | 适合的问题 |
|---|---|---|---|
| 低(L0 = 根) | 社区少而大 | 粗、便宜、偏全景 | "整体有哪些主题" |
| 高 | 社区多而小 | 细、贵(每次要扫 level <= N 的全部报告) | "某个子话题具体怎么做的" |
默认路径下系统不做这个判断——它把这个旋钮交给调用方,各入口给的默认还不一样(Python API 必传但可传 None、CLI 默认 2,见 2.3)。至于"让用户填层号"这个设计本身好不好,6.1 节会说清它的问题。
这条链上还有几个地方会直接影响你看到的结果,而且不看代码是不知道的:材料到底是哪几份、分数是怎么打的、筛选发生在哪一步、引用能追到多细。下面按执行顺序讲。
代码路径:structured_search/global_search/{search.py, community_context.py};两段提示词在 prompts/query/global_search_{map,reduce}_system_prompt.py。
1. 五步,一屏看完
| 步骤 | 做什么 | 成本 | 信息损耗 |
|---|---|---|---|
| 第 0 步 · 选材料 | 从 communities / community_reports 两张表里取出"报告集合"(当前版本 = level <= N 的全部报告) | 纯本地 | 无 |
| 第 1 步 · 装配上下文 | 先用固定种子的随机方法把报告打散,再按 token 预算切批(每批 = 一次 map 调用) | 批数决定 map 次数 | 无(只是切块,不丢内容) |
| 第 2 步 · Map | 每批独立产出"一列点 + 每点 0–100 的重要性分" | 主要成本(批数 × 一次调用) | 有:每批输出受 max_length 限制(默认 1000 词)⇒ 一批承载越多,压缩越狠、细节丢得越多 |
| 第 3 步 · Reduce | 丢掉 0 分、按分降序把点填进新窗口,生成最终答案 | 一次调用 | 有:按 max_data_tokens 累加,超了就 break ⇒ 低分点被整条丢弃 |
| 第 4 步 · 返回 | 答案 + 报告级引用([Data: Reports (id)]) | — | 无 |
用一张图把这条链串起来:
索引期 源文档 ──> 实体关系图 ──> 社区(分层)──> 社区报告 × M
│
查询期 选材料(level ≤ N)──> 打散·切批 ──> map × 批数 ──> reduce ──> 答案
(每批:出要点+打分) (按分拼一篇)
一句话记住分工:map 负责"从每批里摘要点并打分",reduce 负责"在有限窗口里挑最重要的点拼成一篇" 。真正的筛选发生在两处——map 的打分,和 reduce 的按分截断。
另外注意上表最后两列:这条链是有损的,而且两处损耗性质不同——map 是"批内压缩"(摘要天然有损,长度上限让它更狠),reduce 是"整条丢弃"(低分点直接不进最终窗口)。第 0、1 步本身不丢内容。
2. 第 0 步:材料是怎么选的
这一步就定死了后面拿什么当材料。输入是两张表,输出是"报告列表" 。
| 表 | 一行是什么 | 关键列 | title 是什么 |
|---|---|---|---|
communities(层级结构) | 一个社区 | community / level / parent / children / entity_ids / size | "Community N" (索引期直接拼字符串,N = 社区 id) |
community_reports | 一个社区一份 LM 报告 | community / level / summary / full_content / rank / findings | LM 生成的报告标题 |
默认模式(dynamic_community_selection=False)下,代码只做四件事:
- 先把社区表展开成"社区 × 成员实体"的长表——这是"实体"唯一的出场;
- 两张表各按
level <= N砍一刀(N= 调用方给的community_level); - 再按
title分组、每个 title 只留一个社区——它的本意是"每个实体只留它所属的最深社区"(也就是 roll-up),但在当前版本里是个空操作(下一小节说为什么); - 最后用留下的社区去报告表里取报告 ⇒ 输出就是报告集合(不是实体集合、也不是社区集合)。
2.1 那个"空操作":roll-up 本来在干什么
先说它原本的作用:roll-up = "把同一实体的多行压成一行,只留它所属的最深社区",目的是让每个实体恰好由一份报告代表。
为什么需要它?社区是层级结构,一个实体会同时出现在各层的祖先社区里。实体 A 可能同时属于 C5(L0)、C20(L1)、C77(L2);若不处理,把 level <= 2 的报告全喂给模型,同一批内容会被多层报告重复代表——模型看到三份讲同一件事的报告,还会以为它更重要。
roll-up 就是来解决这个"重复代表"的。
而它现在失效了。 失效的原因只有一行实质差别:分组那一行没变,变的是传给它的表。两版对照(📄 源码,链接见文末附录):
v1.x(roll-up 生效) ——入参是实体级的 final_nodes 表,title = 实体名:
def read_indexer_reports(
final_community_reports: pd.DataFrame,
final_nodes: pd.DataFrame, # ← 实体 × 社区的长表(title = 实体名)
community_level: int | None,
...
):
nodes_df = final_nodes
...
if not dynamic_community_selection:
# perform community level roll up
nodes_df = nodes_df.groupby(["title"]).agg({"community": "max"}).reset_index()
filtered_community_df = nodes_df["community"].drop_duplicates()
reports_df = reports_df.merge(filtered_community_df, on="community", how="inner")
v2.0.0 起(roll-up 变成空操作) ——入参换成社区表,title = "Community N" :
def read_indexer_reports(
final_community_reports: pd.DataFrame,
final_communities: pd.DataFrame, # ← 换成社区表
community_level: int | None,
...
):
nodes_df = final_communities.explode("entity_ids") # ← 唯一实质改动
...
if not dynamic_community_selection:
# perform community level roll up
nodes_df = nodes_df.groupby(["title"]).agg({"community": "max"}).reset_index()
filtered_community_df = nodes_df["community"].drop_duplicates()
reports_df = reports_df.merge(filtered_community_df, on="community", how="inner")
groupby(["title"]) 那一行两版一字不差;变的是 title 的含义:旧版里它是实体名 ⇒ "同一实体的多行合并、取最深社区"(roll-up 生效);新版里它是 "Community N" (社区表的 title 在索引期被改写成了编号,每个社区本来就唯一)⇒ 分组等于什么都没做,最终就等价于"把 level <= N 的报告全给出去"。
坏点出现在 v2.0.0(PR #1674,2025-02-07),v3 只是继承;v1.x 不受影响。
一个本地探针能把它演清楚(✅ 实测:用合成层级复刻那个函数,喂两种 title 口径):
合成层级:C5{A,B,C} L0 / C6{D} L0
C20{A,B} C21{C} C22{D} L1
C77{A} C78{B} L2 (满足"子社区完整划分父社区")
community_level = 1
title = "Community N"(当前版本) → 5 份报告 [5,6,20,21,22] ← 就是 "level ≤ 1 的全部"
title = 实体名(旧版本) → 3 份报告(每个实体只留最深那个)
结论:这个参数在当前版本的实际语义 = level <= N 的全部报告,不是"只取第 N 层"、也不是"每个实体取最深社区"。
这件事我们提交给了上游:microsoft/graphrag#2573(📎)。
2.2 于是这个参数的实际语义
- = "报告集合 =
level <= N的全体报告" 。 community_level = None:不过滤 ⇒ 全部社区、所有层的报告。- 它不是"选第 N 层" :API docstring 写的是 "the community level to search at"、notebook 注释写的是 "higher value means more fine-grained"——说法都是"那一层",实现是"≤N 的所有层" ,偏差方向是 "给得比说的多" (把更粗的层也塞了进来)。
- 模型不知道自己读的是哪一层:级别没有被读进上下文对象,喂给模型的表里也没有这一列。这条后面会变成一个"放大器"(见 4.2)。
2.3 层从哪来:三个入口、三套默认
它不由系统自适应决定,而是外部传进来的一个参数——而且"默认值"是各入口自己给的,还不一样:
| 入口 | 默认 |
|---|---|
| Python API | 必传(但可以传 None = 不过滤、全层) |
| CLI / 官方 notebook | 2(不是"全层") |
| 配置 | 没有这一项 |
所以准确的说法是: "用哪一层"没有人替你决定——要么接受所在入口的默认值,要么自己给。
层的高低对应什么,见第 0 节那张表;这里补一条更要紧的:选层是个没有任何先验的普通超参——原论文 §4 专门做实验比较"哪层最好",结论是 L0 明显差、L1–L3 在各数据集与指标上互有胜负、没有通用答案(📎;论文里把层级记作 C0–C3,与本文的 L0–L3 一一对应)。论文需要专门比出这个结论,本身就说明没有默认答案。
3. 第 1 步:装配上下文(它决定 map 分几批)
顺序是:算社区权重 → 按 rank 筛 → 打乱 → 分批 → 批内排序成表 →(可选)前置对话历史。
- 算社区权重:权重 = 该社区内所有实体关联的去重文本块数(由
entities反推),归一化后写进报告的属性(默认名occurrence weight),当批内排序的第一关键字——它衡量的是社区体量,不是相关性;而且只在内存里算、不落盘,所以每次查询都会重算。 - 按
rank筛:默认阈值 0(等于不筛)。注意rank是报告自身的重要性分(索引期由报告提示词打的),不是查询相关度——这个阈值的作用只是让你能砍掉低重要性报告来省 token。 - 打散:先按固定种子把报告随机打散,再切批——固定种子意味着同输入可复现。为什么:报告在表里是按社区 id 排的(id 随深度递增、同父子的子社区相邻),直接切批会让"某一批全是同一层、同一子树",批与批之间不可比(map 需要基于批内数据打分,见后续);打散后每一批都尽量是语料的横切面。
- 切批:逐条累加 token,超预算(默认 12000 token)就切下一批。
- 批内排序:每批内部再按「权重 → rank」降序排 ⇒ 批间打散、批内有序:体量/重要性更高的报告在批内靠前(影响模型的阅读与注意力),同时成表时带上
id,好让要点能引用。 - 对话历史:默认把最近 5 轮用户提问前置到每一批之前——支持多轮指代,且各批看到同样的上文。
4. 第 2 步:Map(并行,一批一次调用)
这是全量扫的廉价实现:不做检索,让每一批自己摘出重要点并打分,把"筛选"推迟到 reduce。
- 输入组装:系统提示词里只有报告表和要求长度,用户问题是单独一条 user 消息——所以 LLM 一定看得到问题(提示词通篇在说"回答用户的问题",但它自己并不包含问题,只看提示词文件容易误读);输出强制 JSON。
- 产出:每批独立产出「一列点 + 每点 0–100 的分数」;批与批并行(并发数取
concurrent_requests:库默认 25,官方 notebook 设 32),让延迟与批数脱钩。 - 输出有长度上限(静默压缩) :每批的输出被限制在默认 1000 词(
map_max_length,提示词里写的就是"限制在 1000 词")。一批里值得说的点越多,就越要挤掉一部分——而模型既不会说"我压掉了哪几条",也不会因此报错或打 0 分。所以这里是概率性的、不显式的丢信息:和第 1 节表格里"有损"那栏对应,也和下面这条"静默降级"是两种不同的静默——一个丢"条内的细节",一个丢"整批"。 - 失败静默降级:JSON 解析失败、或抛异常 ⇒ 该批只产出空答案 + 0 分,只打 warning 日志。好处是不阻塞整条链(其余批照常出结果);代价是答案会缺一块而没有显式提示——正是最该警惕的"静默失败"形态。
4.1 提示词在要求什么
它的角色设定是"只谈提供的表",而且把要点约束写了两遍(这个技巧叫"贴身重申",代价是提示词变长)。约束里主要有七条(📎 原文):
- 只用表里的数据做主要上下文;
- 不知道就说不知道、不要编;
- 每条要点必须有 Description + 0–100 的整数重要度分,且明确" 'I don't know' 型回答必须 0 分";
- 要点要带引用
[Data: Reports (report ids)],单条最多 5 个 id、其余写+more,id 取表格里的 id(不是行号); - 无证据不得写入;
- 保留原意与情态动词;
- 输出严格 JSON。
三处值得注意:
- 打分语义是"对回答用户问题的有用性" ——和社区报告的
rank不是一回事; - 0 分有明确定义("I don't know" ⇒ 0),正好和 reduce 步"丢掉 0 分"衔接;
- map 阶段永远禁止编造:
allow_general_knowledge的注入点在 reduce,不在 map。
4.2 两个追问
追问一:跨批打的分,算不算同一把尺?
不算。每批是独立调用:问题它看得到,但材料只有自己那一批——所以打出的 0–100 是"在这批材料里的相对重要性"(锚在批内),不是跨批可比的绝对分。加详细的评分标准能解决档位语义("80 分大概指什么"),解决不了批内相对锚定("这批里最相关的那条才 60 分")。这也是装配上下文步骤打散报告的原因。
追问二:宏观问题 + 细层,重要性判得准吗?
判得出,但那不是"匹配"而是"归纳" ——模型要把细节升维到主题。这是 LLM 的强项,代价是它只能在信息不足时做全局判断;而这是 map-reduce 的固有属性(上下文窗口有限,就只能分批看),不是这套实现额外多出来的缺陷——GraphRAG 也做了两种缓解:打散让每批尽量是语料的横切面、把用户问题带进打分。剩下的三条边界值得知道:
- 筛选发生在局部,而"重要性"是全局属性:"这条细节在整个体系里重不重要"是相对性问题(取决于别的材料有没有覆盖它),而 map 只看到一批 ⇒ 用局部可见代理全局重要性;reduce 虽然在后一步做了全局合成,但该丢的在 map 那步已经丢了,全局比较来得太晚。
- 层级差越大越不可比,而且有个写在代码里的放大器:
community_level按文档是"选一层"、实现却是把level <= N的各层报告全塞进来(混深度是默认状态);更糟的是level没被读进上下文对象 ⇒ 模型连"手上这份是粗层还是细层"都不知道。 - 宏观问题恰好是"局部最判不准"的那类:点状问题里相关材料自明;宏观问题的"重要性"取决于"整体是否已有别的材料说过同一件事",甚至取决于"这些点加起来能不能答上" ⇒ map 的分数对宏观问题信息量最低,而那正是最需要它的时候。(信息检索里这叫 granularity / abstraction mismatch。)
5. 第 3 步:Reduce(一次调用出最终答案)
- 汇总所有批的点。代码只给每条加一个批号标签(
----Analyst N----)和它的分数——而这个标签不会出现在最终答案里(reduce 提示词明确要求不要提 analyst);真正残留的来源线索是点文本里自带的[Data: Reports (id)],其中 id 就是社区号(map 被要求写进 description、reduce 被要求原样保留); - 丢掉 0 分(0 分的定义就是"我不知道",见 4.1);
- 一条不剩 ⇒ 直接返回固定的"我不知道",不调模型(这种情况零成本,也符合"不要编"的原则);
- 其余按分降序填进新窗口,填到预算为止——超出的低分点直接丢弃。这是整条链里真正"筛选"的一步,代价是丢多少取决于预算大小;
- 生成最终回答。这里还有个开关:开了
allow_general_knowledge就允许引入通用知识——官方注释明说 "may increase hallucinations" 。
第 4 步 · 返回:引用这一级到底给到什么——reduce 提示词明确要求 "preserve all the data references previously included in the analysts' reports" ⇒ 引用有,落在"报告"这一级(报告 id 就是社区号,所以等于到了社区);但它是模型自己写的、没有被校验——map 没写就没有,写错也照样往下传。要回原文还得再走两跳(报告 → 实体/关系/claim(从原文抽出的断言)→ text unit(索引期切出的文本块)),单条引用还有"最多 5 个 id + +more"的抽样上限。
6. 选层这件事:结构性错配,和另一条路
6.1 选层这个旋钮,不该让用户填
前面说"选层是个没有任何先验的超参",这里把它的结构性问题说透。
问题:顶层社区天然大小不一、深度也不齐。设两个顶层社区,一个最深到 L2、一个只到 L1:用户指定 N=2 或 N=1 时他想表达的粒度本该不同,可第二个顶层社区两次都只能给出它那个 L1 子社区(那就是它的最细可得)。
这不是理论担心,可以量化。 上游 issue #1650 里那位用户报过真实分布(L0 27 个 · ~120 节点、L1 更多更小)。按这个分布的两种形状各取一个看(下表的数字是示意,用来说明机制,不是 issue 的原始数据):
| 分支 | N=0 | N=1 | N=2 |
|---|---|---|---|
| 深分支(示意) :L0 40 个实体 → L1 四个各 10 → L2 十六个各 ~2.5 | 1 份(整块 40) | 4 份(各 10) | 16 份(各 ~2.5) |
| 浅分支(示意) :L0 8 个实体,没再细分 | 1 份(整块 8) | 1 份(同一份) | 1 份(同一份) |
后果:N=1 → N=2 的"变细"只作用在深分支上,浅分支纹丝不动;而进到同一个上下文表里的报告,规模能从"2.5 个实体"跨到"40 个实体"——模型还看不到 level。
也就是说:用户用一个一维的层号,去表达一个二维的东西(相关性 × 可得深度);信息必然有损,用户表达不准、系统实现不准都是这个压缩的后果。
三条替代路线:
- 查询驱动的树遍历——就是下面 6.2 那条(按 query 相关度剪枝),它恰好绕开"层号 ≠ 粒度"这个错配;
- 把旋钮换成"资源口径" :目标报告数 / token 预算 / "覆盖多少比例的 text unit"。这类量用户有直觉("我要便宜一点 / 全一点"),系统也能自己做好(按预算在每棵子树上分);
- 至少把"最细可得"说清楚:如果一个主题在库里只被粗层覆盖,正确行为是"给已有最细的 + 明确声明这是最细可得",而不是假装它就该这么粗。
一个必须承认的边界(🤔):粒度其实是"问题与语料共同决定"的,不只是问题决定的。就算 query 判得再准,如果某棵子树没长下去,也拿不到细报告。
6.2 另一条路:Dynamic Community Selection
开 dynamic_community_selection=True ⇒ 不再"用固定层的全部报告",改为自顶向下按 query 相关度剪枝社区树。
- 此时
community_level的语义变成"cap(最多下探到哪一层)" ——API docstring 原文:"you can still provide community_level [to] cap the maximum level to search"; - 配置里的
dynamic_search_max_level(默认 2)是这条路径的兜底上限; - 输出是"一组可能跨层的社区",不是一个层(父相关就留、子也相关就继续留,
keep_parent控制是否连父一起保留); - 代价:每个候选社区一次 LLM 打分调用,且剪枝剪错会漏召回。
7. 参数表(官方 notebook 用的参数值)
上下文侧:use_community_summary=False(用完整报告而非短摘要)· shuffle_data=True · include_community_rank=True · min_community_rank=0 · include_community_weight=True · normalize_community_weight=True · max_context_tokens=12_000 · context_name="Reports"。
调用侧:map max_tokens=1000 / temperature=0.0;reduce max_tokens=2000 / temperature=0.0;max_data_tokens=12_000(注释:模型窗口 8k 时建议设 5000)· allow_general_knowledge=False · json_mode=False · concurrent_coroutines=32 · response_type="multiple paragraphs"(也可 prioritized list / single paragraph / multi-page report)。
一个例外:shuffle_data 在 query/factory.py 里是写死的 True——标准入口(CLI 与 graphrag.api)没有开关,只有绕过 factory 自己构造 GlobalSearch 才改得动。
8. 小结:三句话
- 它的本质是"全量扫报告 + map 摘点 + reduce 拼" ——不做检索,成本由"要扫多少报告"决定。
- 进模型的"材料"比官方文档说的多:
community_level = N实际给的是level <= N的全部报告(上游空操作,已提 issue)——不只是你要的那一层,更粗的层也一并塞了进去;而模型还不知道自己手上这份是粗层还是细层。 - 最要紧的一条:那个层号参数几乎没法用——好的系统不该把"用哪一层"这个决策交给用户;而且即便用户有心,它在结构上也做不到(一维层号 ≠ "相关性 × 可得深度")。真要控制粒度,应该换成资源口径(报告数 / token / 覆盖比例),或者让系统按 query 自己选——后者就是 6.2 那条路。
下一篇:Dynamic Community Selection
这篇拆的是默认模式。6.2 里还有另一条路只给了轮廓:Dynamic Community Selection——不再是"用固定层的全部报告",而是自顶向下按 query 相关度剪枝社区树,此时同名参数的含义变成"最多下探到哪一层",代价是每个候选社区一次 LLM 打分调用、且剪错会漏召回。
它值得单独拆,因为它正面回应了 6.1 的那个结论——"层号 ≠ 粒度"。但"换一个更聪明的选法"是否就解决了?还有一串没核:打分提示词具体怎么问、阈值怎么定、keep_parent 到底影响什么、成本落在哪一步、以及"剪错漏召回"以什么形态出现。
下一篇逐行核这些。
附:核对方式
- 版本:
graphrag 3.2.0(Python 3.11);代码路径与提示词按官方main分支核对(钉在提交769542fb) - 主要核对位置(📄):
query/structured_search/global_search/{search.py, community_context.py}、query/indexer_adapters.py(read_indexer_reports/read_community_reports)、prompts/query/global_search_{map,reduce}_system_prompt.py、config/defaults.py(GlobalSearchDefaults) - 2.1 那两版代码的原文(可自行比对):
v1.2.2graphrag/query/indexer_adapters.py /v3.2.0packages/graphrag/graphrag/query/indexer_adapters.py - 官方材料(📎):原论文 From Local to Global: A Graph RAG Approach to Query-Focused Summarization(§3.1.6 与 §4)、官方 global search notebook、issue #2573 与 #1650
- 本地探针:
data/tmp/_graphrag_rollup_probe.py(用合成层级复刻read_indexer_reports,比对两种title口径 ⇒ 证明当前版本退化为level <= N) - 本文的核对脚本与笔记都在开源仓库:github.com/lipeidonggz…
如果你在用 Global Search 做过实际调参(比如 L0–L3 的选择依据),欢迎在评论区说说你的场景和结论——如果再多几个"哪层最好"的实测样本,第 6.1 节那张表就能从"结构性问题"变成"该怎么选"的经验。