WeClaw_86|24 个 pattern 的图书馆:为什么检索优化到头了,问题其实在采集端 Hi带娃的我热爱AI 大模型应用落地、意识解码与 AI 开发工具链。 创业路上用技术换时间一起把 AI 变成生产力 WeClaw_8624 个 pattern 的图书馆为什么检索优化到头了问题其实在采集端系列文章第 86 篇- 采集端瓶颈 · 值域枚举法 · 反事实实验 · 语义聚类对照 · 死代码考古 专栏信息《从零到一构建跨平台 AI 助手WeClaw 实战指南》专栏专栏定位面向开发者和技术决策者的实战专栏用真实案例和完整代码带你理解如何构建生产级 AI 应用本文是一次「往上游走」的排查。前两篇都在检索端第 84 篇用五道闸门否决了一个记忆层框架第 85 篇把召回精度的 50 个百分点归因到了一个排序函数。但那两篇都留了一个没回答的问题——175 条经验为什么只有 24 个 distinct pattern这一篇把手伸到检索的上游去测那个生产 pattern 的函数本身。结论比预想的更硬24 不是语料的多样性是生成器的值域。‍ 作者与项目作者简介翁勇刚 WENG YONGGANG新概念龙虾-WeClaw 开发团队负责人一群专注于跨平台 AI 应用的实践者理念“再复杂的技术也能用代码讲清楚” 项目地址https://github.com/wyg5208/weclaw.git 官网地址https://weclaw.link 作者 CSDNhttps://blog.csdn.net/yweng18⭐ 欢迎 Star⭐、Fork、贡献代码 摘要本文结构概览一个数字的两种解释指向两种相反的修法 → 我第一次读代码读错了地方并说明为什么读代码会错→ 用「值域枚举法」把生成器当函数来测反推它的理论上限 → 用语义聚类绕开 pattern 字段独立度量语料多样性 → 发现两种病灶同时存在并互相掩盖 → 闭环验证推翻了我自己的一个预测 → 反事实实验测字段冗余并诚实标注哪些结论不成立 → 沉淀三条上游排查的方法。核心问题当一个指标触及天花板、优化空间为零时怎么判断瓶颈是不是根本不在你正在优化的那一层以及一个更具体的问题——「distinct 值只有 24 个」这句话是在描述数据还是在描述代码关键成果值域枚举实测pattern 生成器的理论上限是 5031 组合形态 19 兜底形态实测 24154/17588.0%的经验落在兜底形态上——五类错误判据对真实 error 文本基本失效pattern 实质退化为「工具名」语义聚类对照0.95 阈值下语料有70 个语义簇而 pattern 只有 24 个 → 压缩发生在生成器分离出两种病灶shell组 47 条组内平均余弦0.983重复采集vs参数修正组 5 条余弦0.724pattern 在合并不同的问题发现死代码一个 40 行的关键词提取器对99.4%的语料永不可达量化模板噪声入库向量文本54.1%的字符是模板常量诚实标注反事实实验里 H1 的 4.2% 等于 24 条查询中的 1 条统计上不显著适合读者做 RAG / 记忆系统 / 数据管道的工程师正在为「检索指标上不去」发愁的人想学「怎么给一个函数做值域测量」的人阅读时长约 16 分钟关键词采集端、值域枚举、语义聚类、反事实实验、死代码、EBEAC、数据质量一、一个数字两种完全相反的修法第 84 篇里第一道闸门语料诊断给出的第一条事实是175 条经验只有24 个 distinctabstract_pattern去重坍缩率 86.3%。这条事实之所以是闸门 FAIL 的依据是因为召回流水线里有一步_deduplicate_by_pattern——按 pattern 去重。这意味着无论库里有多少条经验单次召回的可选池上限就是 distinct pattern 的个数。175 条经验实际可被选中的池子只有 24 条那么宽。当时我在报告里写的是「改造经验生成质量」。但严格来说那句话建立在一个没有被验证的解释上。「24」这个数字至少有两种读法解释该改的地方A语料本身只发生过 24 种情况触发条件——采集得太少、太窄Bpattern 生成器的值域上限就这么大生成逻辑——采集到了但压缩掉了这两条修法方向完全相反。解释 A 下你应该放宽触发条件、多采集解释 B 下你放宽触发条件毫无意义——语料涨十倍distinct pattern 也不会超过那个上限池子还是那么宽。更糟的是两种解释在数据表面上长得一模一样都表现为「distinct 值很少」。想分开它们得换个测法。二、我第一次读代码读错了地方这一节本来可以不写。写它是因为它恰好是第 85 篇结论的一次现场复演。第 85 篇的方法论小节里我写过一句归因用实验不用推理。这一轮我先违反了一次。grep了一下abstract_pattern第一个命中的是experience_store.py里的_generate_abstract_pattern——一个 40 行的关键词提取器把错误文本切词、统计、拼成模式串。看完我的第一反应是找到了就是这个提取器太粗糙24 个 pattern 是它的产物。然后我顺手看了一眼调用点# experience_store.py record() 内部ifnotabstract_pattern:abstract_patternself._generate_abstract_pattern(...)条件调用。那就得看传进来的abstract_pattern是什么。往上游走一层真正的生产者在另一个文件# experience_recorder.py L165-166abstract_patternself._generate_pattern(tool_name,failures)awaitself._store.record(triggertrigger,diagnosisdiagnosis,fix_summaryfix_summary,abstract_patternabstract_pattern,# ← 永远非空outcomesuccess,source_typetool_retry,...)而_generate_pattern的最后三行是ifnotpatterns:patterns.append(f{tool_name}工具重试成功)# ← 兜底returnf工具试错模式:{.join(patterns)}它恒返回非空。所以record()里那个if not abstract_pattern分支在这条路径上永不进入——我一开始盯着的那 40 行提取器对tool_retry来源的经验从来没有执行过。而库里source_type的分布是tool_retry174 条 / 175 条。也就是说那 40 行代码对当前99.4%的语料是死代码。我花了十分钟读它、分析它的缺陷、准备把它写成根因——如果不是顺手看了一眼调用点这一篇的结论会整体建立在一段从未运行的代码上。这不是「我不够仔细」的问题。条件调用 上游兜底这个组合天然会骗人两个文件各自看都很合理缺陷只存在于它们的接缝处而接缝不在任何一个文件里。想稳定地避开这类坑办法不是读得更仔细而是别用读代码来确认执行——让代码自己回答。所以这一轮的第一个测量就是问生成器它自己能吐出多少种值。三、值域枚举法把生成器当函数来测_generate_pattern是个静态方法纯函数无副作用staticmethoddef_generate_pattern(tool_name:str,failures:list[dict[str,Any]])-str:error_texts .join(f[error].lower()forfinfailures[:3])patterns[]ifany(kwinerror_textsforkwin[timeout,超时,timed out]):patterns.append(超时重试)ifany(kwinerror_textsforkwin[connection,connect,连接,network,网络]):patterns.append(连接重试)ifany(kwinerror_textsforkwin[invalid,参数,argument,parameter]):patterns.append(参数修正)ifany(kwinerror_textsforkwin[permission,denied,权限,拒绝]):patterns.append(权限问题)ifany(kwinerror_textsforkwin[encoding,parse,decode,编码,解析]):patterns.append(编码/解析容错)ifnotpatterns:patterns.append(f{tool_name}工具重试成功)returnf工具试错模式:{.join(patterns)}看清结构之后它的值域是可以精确算出来的五个独立的if各自贡献一个固定标签 → 非空组合有 2⁵ − 1 31种五个if全不命中时走兜底形态由tool_name决定 → 有多少工具就有多少种但我不想「算」我想「测」。因为算是推理测是事实——如果我对某个if的判据理解有偏差算出来的数会错而测出来的不会。于是直接 import 生产代码用合成 error 文本枚举所有非空子集# 每类判据取一个代表性关键词用于合成 error 文本反推值域PATTERN_PROBES[(超时重试,timeout),(连接重试,connection),(参数修正,invalid),(权限问题,permission),(编码/解析容错,encoding),]fromsrc.core.experience_recorderimportExperienceAutoRecorder genExperienceAutoRecorder._generate_pattern combo_domain:set[str]set()forkinrange(1,len(PATTERN_PROBES)1):forcomboinitertools.combinations(PATTERN_PROBES,k):err .join(kwfor_,kwincombo)combo_domain.add(gen(dummy_tool,[{error:err}]))toolstool_histogram(rows)# 空集走兜底分支形态由 tool_name 决定fallback_domain{gen(t,[{error:something went wrong}])fortintools}注意这里做的两件事combo_domain用一个假工具名跑遍所有判据组合fallback_domain用一段刻意不含任何判据关键词的错误文本跑遍库里真实出现过的每个工具名。两个集合合起来就是生成器在当前工具集下的完整可达值域。跑出来五类判据的非空子集数 : 31 实际可达的「组合形态」取值数 : 31 库中出现过的工具数 : 19 「兜底形态」取值数 工具数 : 19 生成器值域上限 组合 兜底 : 50 实测 distinct abstract_pattern : 24 经验总条数 : 175上限 50实测 24。光看这两个数两种解释还没被分开——24 50似乎说明「还没跑满所以是语料不够」。关键的一步是把实测的 24 个值按形态分解它落在组合形态里还是兜底形态里hit_fallbackactualfallback_domain hit_comboactualcombo_domain unknownactual-fallback_domain-combo_domain其中「兜底形态」未命中任何判据: 18 其中「组合形态」命中错误分类 : 5 其中无法归类非 recorder 生成 : 1 落在兜底形态上的经验条数 : 154 / 175 (88.0%)这一下就清楚了。24 个 pattern 里有18 个是兜底形态——也就是形如工具试错模式: shell工具重试成功这种。五类错误判据实际只贡献了 5 个取值。而按经验条数算154/17588.0%走的是兜底分支。timeout、connection、invalid、permission、encoding这五组关键词在 88% 的真实错误文本里一个都没匹配上。这意味着 pattern 字段实际上退化成了工具名的一个花哨包装。而_deduplicate_by_pattern按 pattern 去重在 88% 的情况下等价于——每个工具只保留一条经验。一个本该表达「这是哪一类问题」的字段实际表达的是「这是哪个工具」。四、绕开 pattern 字段直接问语料上一节证明了生成器在压缩。但还差一步被压缩掉的是真实差异还是本来就重复的内容如果 47 条shell经验彼此其实就是同一件事重复采集了 47 次那压缩成 1 条毫无损失问题在触发条件重复采集而不在生成器。要回答这个得找一个不依赖 pattern 字段的多样性度量。现成的就有向量。把 175 条经验的入库文本全部编码做贪心单链聚类看在不同相似度阈值下语料能分成多少个簇。def_greedy_clusters(sim,threshold:float)-list[list[int]]:贪心单链聚类按顺序扫描与已有簇心相似度超阈值则并入。centers:list[int][]members:list[list[int]][]foriinrange(sim.shape[0]):forci,cinenumerate(centers):ifsim[i,c]threshold:members[ci].append(i)breakelse:centers.append(i)members.append([i])returnmembers贪心聚类不是最优聚类簇数会略微偏高。这里不追求精确——只要它给出的数量级足以区分 24 和「远大于 24」就够用了。余弦阈值 0.98 → 93 簇最大簇 33单例 72 余弦阈值 0.95 → 70 簇最大簇 47单例 53 余弦阈值 0.90 → 40 簇最大簇 47单例 22 余弦阈值 0.85 → 27 簇最大簇 47单例 11 对照distinct abstract_pattern 24经验总数 1750.95 阈值下 70 个语义簇对 24 个 pattern。余弦 0.95 已经是相当宽松的合并标准了——两段文本得非常接近才会被判为同一簇。在这个标准下语料仍然有 70 种可区分的语义而 pattern 只给了 24 个格子。哪怕把阈值放到 0.85几乎是「大致相关就算一类」语料还有 27 簇仍然多于 24。解释 B 成立pattern 生成器在压缩真实存在的差异。放宽触发条件、多采集经验不会让可选池变宽——它被 min(生成器值域, 语料多样性) 里的左项卡住了。顺便注意最大簇 47这个数字它在 0.95 / 0.90 / 0.85 三档里完全不动。下一节会回来解释它。五、两种病灶同时存在而且互相掩盖上一节的结论是「生成器在压缩差异」。但这个结论如果直接套到每一组上会得出错误的修法。因为去重丢的东西各组性质完全不同。测法很直接把每个 pattern 组内部的经验两两算余弦看组内语义有多一致。forp,ginsorted(multi.items(),keylambdakv:-len(kv[1]))[:8]:pairs[sim[a,b]fora,binitertools.combinations(g,2)]lomin(pairs)avgsum(pairs)/len(pairs)组内条数 组内最低余弦 组内平均余弦 pattern 47 0.946 0.983 工具试错模式: shell工具重试成功 36 0.787 0.919 工具试错模式: paper_lifecycle工具重试成功 32 0.814 0.907 工具试错模式: wechat工具重试成功 9 0.769 0.901 工具试错模式: 权限问题 7 0.923 0.953 工具试错模式: todo工具重试成功 5 0.839 0.885 工具试错模式: stock_query工具重试成功 5 0.638 0.724 工具试错模式: 参数修正 5 0.720 0.794 工具试错模式: file工具重试成功看第一行和倒数第二行shell组47 条组内平均余弦 0.983最低 0.946。这 47 条经验彼此几乎一模一样。这是重复采集——同一类 shell 失败被反复记了 47 次。对这组来说去重丢掉 46 条丢的全是冗余没有信息损失。这也解释了上一节那个「最大簇 47」为什么在三档阈值下都不动它就是这 47 条 shell 经验它们抱得太紧任何阈值都拆不开。参数修正组5 条组内平均余弦 0.724最低 0.638。这 5 条讲的明显不是一件事。pattern 把它们归成一类去重时保留 1 条丢掉 4 条——丢的是真实信息。全局统计单次召回中被去重丢弃的经验条数 : 151 其中与保留项余弦 0.9 的条数 : 57 (37.7% of dropped)151 条被丢弃其中57 条37.7%与保留下来的那条余弦低于 0.9——它们不是冗余是被误判成冗余的不同经验。这就是我想强调的那一点两种病灶同时存在而且在汇总指标上互相掩盖。「去重坍缩率 86.3%」这个数字里一部分shell/todo 这类高余弦组是采集器在重复记账另一部分参数修正/file 这类低余弦组是 pattern 在合并不同的问题。如果只看那个 86.3%你会得出一个笼统的「pattern 太少」然后可能去做一件错的事——比如给 pattern 加更多分类维度。对 shell 组来说加维度不解决问题它需要的是采集去重对参数修正组来说加维度才是对的。汇总指标能告诉你有病不能告诉你有几种病。想分开得下沉到分组粒度看分布。六、闭环验证推翻了我自己的预测到这一步我以为可以收尾了脑子里已经有了一句很漂亮的结论pattern ≈ 工具名 → 每个工具在召回里只有 1 条经验能被选中 → 47 条 shell 经验里只有 1 条有用其余 46 条纯占空间。这句话逻辑上顺写进博客也好看。但它是推理而不是测量。第 85 篇刚教过我这个区别所以我加了一个 Part F查真实的hit_count看实际到底哪些条目被召回过。hit_count 0 的条数 : 64 / 175 (36.6%) 从未被命中过的条数 : 111 (63.4%) 组内条数 曾被命中 组内命中率 pattern 47 2 4.3% shell工具重试成功 36 15 41.7% paper_lifecycle工具重试成功 32 6 18.8% wechat工具重试成功 9 6 66.7% 权限问题 7 5 71.4% todo工具重试成功 5 3 60.0% stock_query工具重试成功 5 5 100.0% 参数修正 5 5 100.0% file工具重试成功shell那行完全符合预测47 条里只有2 条曾被命中4.3%。但paper_lifecycle那行不符合36 条里有15 条被命中过41.7%。参数修正和file组更极端——5 条全部命中过100%。如果「每个工具只有 1 条能被选中」成立这些数字应该都是 1。它们不是。我的推论是错的。错在哪错在把「单次」和「累积」混为一谈。_deduplicate_by_pattern保留的是当次查询相似度最高的那一条。而「相似度最高」随查询变化——查询 X 下保留第 3 条查询 Y 下保留第 17 条。所以单次recall()里每个 pattern 最多贡献 1 条 → 单次可选池上限 distinct pattern 24。这部分成立。累积来看不同查询会保留组内不同的条目 →hit_count 0的条目会散开到多条。这部分我漏了。两者不矛盾是两个不同的量。我的错误是用前者的结论去预测后者的观测。修正之后这个数据反而给了一个更有意思的解读shell组命中率 4.3% 而组内余弦 0.983参数修正组命中率 100% 而组内余弦 0.724。组内余弦极高 组内命中率极低正是「重复采集」的直接证据——因为那 47 条太像了无论什么查询赢的总是同一两条剩下 45 条永远轮不到。这条推论如果不做闭环验证会以一句漂亮但错误的话进入这篇文章。能被数据修正的推论是幸运的没被验证的推论只是运气问题。七、字段本身有多少是废话前面查的都是 pattern。但采集端一共写了四个字段值得一起看# experience_recorder.py L154-166failure_countlen(failures)triggerf{tool_name}.{action_name}连续失败{failure_count}次后重试成功error_samples[f[error][:100]forfinfailures[:3]]diagnosisf工具{tool_name}调用出错:{; .join(error_samples)}fix_summaryf经过{failure_count}次重试后成功执行{tool_name}.{action_name}abstract_patternself._generate_pattern(tool_name,failures)四个字段三个是纯模板trigger变量只有(tool_name, action_name, failure_count)fix_summary变量只有(failure_count, tool_name, action_name)——和trigger完全相同的三个变量只是换了个语序abstract_pattern值域 50 的枚举量已证diagnosis唯一含有真实 error 文本的字段而这四个字段拼起来就是写进向量库的文本propertydefvector_text(self)-str:与 _write_experience_sync 写入 ChromaDB 的文本拼接方式完全一致。returnf{self.trigger}{self.diagnosis}{self.fix_summary}{self.abstract_pattern}嵌入向量由整段文本决定。模板字符占比越高不同经验的向量就越靠近——因为它们大部分内容字面相同。按字符数算一下diagnosis里扣掉工具 X 调用出错:这个前缀剩下的算真实信息向量文本总字符数 : 35649 其中模板/常量字符数 : 19282 (54.1%) 携带真实错误信息的字符数 : 16367 (45.9%)入库文本 54.1% 的字符是模板常量。每一条经验超过一半的内容和其他所有经验一样。那么问题来了把冗余字段去掉检索会不会变好这个问题必须用反事实实验来答不能靠「显然会」。做法是换掉入库文本的构造方式重新编码重新测指标variants{T0 全文生产现状:lambdar:r.vector_text,T1 去 fix_summary:lambdar:f{r.trigger}{r.diagnosis}{r.abstract_pattern},T2 仅 diagnosis:lambdar:r.diagnosis,T3 triggerdiagnosis:lambdar:f{r.trigger}{r.diagnosis},}这里是纯向量余弦检索不经过recall()的过滤与时间衰减所以 T0 应该对齐第 84 篇的「裸向量检索」那一行而不是生产流水线那一行。方案 查询 lex R3 sem R3 ALL R3 ALL H1 ALL MRR p50ms T0 全文生产现状 24 100.0% 100.0% 100.0% 95.8% 0.979 127.5 T1 去 fix_summary 24 100.0% 100.0% 100.0% 100.0% 1.000 97.0 T2 仅 diagnosis 24 100.0% 100.0% 100.0% 95.8% 0.979 53.1 T3 triggerdiagnosis 24 100.0% 100.0% 100.0% 95.8% 0.979 67.8这张表最需要说清楚的是它没证明什么。T1去掉fix_summary的 H1 从 95.8% 到 100.0%MRR 从 0.979 到 1.000。看起来是提升。但评测集只有 24 条查询。95.8% → 100.0% 的差距是24 条里的 1 条。一条查询的翻转在任何统计意义上都不足以支撑「删掉它能提升检索质量」这个结论。所以这里唯一能说的是未观察到fix_summary有正面贡献。删掉它R3 / H1 / MRR 都不下降。这是个「无害」证据不是「有益」证据。真正稳的收益在最后一列编码延迟 127.5ms → 97.0ms降 24%。这个数不依赖 24 条查询的统计功效它是文本变短的直接结果可重复、可解释。T2只保留diagnosis扔掉另外三个字段指标和 T0 完全一致、延迟只有 53.1ms也印证了同一件事另外三个字段对检索没有可观察的贡献它们主要在消耗算力。我把这一段单独拎出来讲是因为这类实验最容易被过度解读。一个「提升了 4.2 个百分点」的结论写进报告看起来比「未观察到贡献」有力得多但前者在这个样本量下是不诚实的。指标的小数位不代表精度样本量才代表精度。八、顺带清掉的三笔账沿着采集端往下查还有三件在第 84 篇被列为「事实」但没深究的事这里一并结掉。8.1 一个从未生效的过滤器recall()的第一步过滤是_filter_by_outcome——按经验的成败结果筛选。而采集端写入时是这样的awaitself._store.record(...outcomesuccess,# ← 硬编码...)硬编码。所以库里 175 条经验的outcome全部是success。一个按outcome筛选的过滤器作用在一个outcome只有单一取值的数据集上是结构性空操作——它不是「暂时没起作用」而是在当前采集逻辑下不可能起作用。第 85 篇的逐层剥离实验里这一步对 H1 的贡献实测 0.0%与此完全一致。这个空操作本身不消耗什么真正的代价在别处它让流水线看起来有「区分成功与失败经验」的能力。任何基于「我们会过滤掉失败经验」这个前提做的设计都建立在一个不存在的能力上。8.2 一项被列为收益的「解除上限」原集成方案里「解除 500 条 LRU 上限」被列为引入外部框架的一项收益。实测库里 175 条距上限 500 还差 325 条时间跨度 2026-05-30 ~ 2026-07-31 两个月。淘汰机制从未触发过。一个从未被触发的限制解除它的收益是零。这类「收益」在方案评审里很常见——它描述的是一个真实存在的技术限制但没有验证这个限制是否已经成为瓶颈。而这一篇给出的证据更进一步真正卡住可选池的不是 500是 24。上限 500 和上限 5000 对当前系统毫无区别因为它在 24 那里就已经饱和了。8.3 一段对 99.4% 语料不可达的代码第二节那个 40 行的关键词提取器。核查方式很朴素# 死代码核查recorder 总是显式传入非空 patternstore 的自动生成分支还会走到吗always_nonemptyall(gen(t,[{error:e}])fortin(x,y)forein(,timeout,怪错误))包括空错误文本在内_generate_pattern恒返回非空。配合source_type分布 174/175 为tool_retry结论是那 40 行对99.4%的语料不可达。它没有造成任何 bug——死代码不会出错。它的代价是误导排查它长得很像根因位置又在检索侧任何人 grepabstract_pattern都会先撞到它。我自己就撞了一次。九、这一轮沉淀下来的三条方法9.1 先问「这个数字在描述数据还是描述代码」「distinct 值只有 24 个」——听起来是在讲数据分布实际上讲的是代码值域。这两者混在一起会让你去优化错的那一层。分辨的办法找一个不经过那段代码的独立度量。本轮用的是向量聚类——它绕开了 pattern 字段直接问语料本身有多少种语义。两个度量一对照70 vs 24压缩发生在哪层立刻清楚。任何「distinct 数很少」「取值集中」「重复率高」的观察都值得先过一遍这个问题。字段的取值分布是生成逻辑与数据分布的乘积看到结果就下结论等于把两个因子当成一个。9.2 纯函数可以直接测值域不用读_generate_pattern是静态方法、无副作用、输入输出都是普通类型。这种函数不需要读懂它可以直接测它合成输入、枚举组合、收集输出集合。好处不只是省时间。读代码得到的是「我认为它能返回这些值」枚举得到的是「它确实返回了这些值」。前者会因为看漏一个if、误解一个判据而错后者不会。而且枚举出的值域可以直接和真实数据求交集——本轮 88% 落在兜底形态这个关键发现就是靠actual fallback_domain一行算出来的纯读代码是得不到这个数的。判断一段逻辑值不值得这么测看三个条件无副作用、输入可合成、输出可比较。满足了就别读去测。9.3 汇总指标定位不了病灶分组分布才行「坍缩率 86.3%」「丢弃 151 条」都是汇总数。它们能告诉你有问题但把两种性质相反的问题重复采集 vs 过度合并压成了一个数。下沉到分组粒度两种病灶立刻分开组内余弦 0.983 的组和 0.724 的组需要完全不同的修法。更进一步把组内余弦和组内命中率交叉看还能读出第三层信息——高余弦 低命中率 那些条目永远轮不到是重复采集的直接证据。这一条和第 85 篇「多测一列候选数」是同一件事的两种形态指标之外要测能区分机制的辅助量。指标回答「好不好」辅助量回答「为什么」而修法只能从后者推出来。十、总结3 个关键点优化空间为零往上游一层看检索端 R3 已经 100%MRR 提升空间也被第 85 篇的排序修复占掉了。看起来没什么可做了——但可选池只有 24 条宽任何检索优化都在这 24 条里做文章。触顶的指标往往是在告诉你「瓶颈不在这一层」字段的取值分布 生成逻辑 × 数据分布看到 distinct 值少先分清是哪个因子造成的。分开的办法是找一个不经过该字段的独立度量本轮语义聚类 70 簇 vs pattern 24 个不显著就说不显著24 条查询上的 4.2 个百分点等于 1 条查询翻转。写「未观察到正面贡献」不如写「提升了 4.2%」好看但后者在这个样本量下是不诚实的。能守住的结论才有复用价值1 个核心公式单次可选池宽度 distinct pattern min(生成器值域, 语料真实多样性) 本轮观测生成器值域上限 50其中 88% 的经验落在兜底形态 → 有效值域 ≈ 工具数 语料语义簇数 70余弦 0.95 → min 卡在左项瓶颈在生成器不在采集量互动环节思考题你的系统里有哪个字段的 distinct 值「少得可疑」它的生成逻辑是不是一个值域有限的枚举函数如果是它的上限是多少——你能算出来还是能测出来本文用向量聚类作为「不经过 pattern 字段的独立度量」。如果你的可疑字段不是文本你会用什么做独立对照找一个你项目里的条件调用if not x: x generate()。上游传进来的那个x有没有可能永远非空讨论话题你有没有优化过一个指标最后发现瓶颈根本不在你优化的那一层当时是什么让你意识到该往上游走的下期预告待定本轮 Mem0 选型的完整记录到这里告一段落——第 84 篇讲决策方法五道闸门、证伪优先第 85 篇讲检索端的排序损害第 86 篇讲采集端的容量天花板。三篇合起来是一个完整的样本一次「不引入」的结论和它顺带挖出来的六个本地修复点。七个探针脚本、README 里的全部实测数据都在仓库的scripts/mem0_probe/下可复现。敬请期待版权声明本文为 CSDN 博主「翁勇刚」的原创文章遵循 CC 4.0 BY-SA 版权协议转载请附上原文出处链接及本声明。