WeClaw_86|24 个 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
- 📝 作者 CSDN:https://blog.csdn.net/yweng18
- ⭐ 欢迎 Star⭐、Fork🍴、贡献代码🤝
📝 摘要
本文结构概览: 一个数字的两种解释指向两种相反的修法 → 我第一次读代码读错了地方(并说明为什么读代码会错)→ 用「值域枚举法」把生成器当函数来测,反推它的理论上限 → 用语义聚类绕开 pattern 字段独立度量语料多样性 → 发现两种病灶同时存在并互相掩盖 → 闭环验证推翻了我自己的一个预测 → 反事实实验测字段冗余,并诚实标注哪些结论不成立 → 沉淀三条上游排查的方法。
核心问题:当一个指标触及天花板、优化空间为零时,怎么判断瓶颈是不是根本不在你正在优化的那一层?以及一个更具体的问题——「distinct 值只有 24 个」这句话,是在描述数据,还是在描述代码?
关键成果:
- 值域枚举实测:pattern 生成器的理论上限是 50(31 组合形态 + 19 兜底形态),实测 24
- 154/175(88.0%)的经验落在兜底形态上——五类错误判据对真实 error 文本基本失效,pattern 实质退化为「工具名」
- 语义聚类对照:0.95 阈值下语料有 70 个语义簇,而 pattern 只有 24 个 → 压缩发生在生成器
- 分离出两种病灶:
shell组 47 条组内平均余弦 0.983(重复采集)vs参数修正组 5 条余弦 0.724(pattern 在合并不同的问题) - 发现死代码:一个 40 行的关键词提取器对 99.4% 的语料永不可达
- 量化模板噪声:入库向量文本 54.1% 的字符是模板常量
- 诚实标注:反事实实验里 H@1 的 +4.2% 等于 24 条查询中的 1 条,统计上不显著
适合读者:做 RAG / 记忆系统 / 数据管道的工程师;正在为「检索指标上不去」发愁的人;想学「怎么给一个函数做值域测量」的人
阅读时长:约 16 分钟
关键词:采集端、值域枚举、语义聚类、反事实实验、死代码、EBEAC、数据质量
一、一个数字,两种完全相反的修法
第 84 篇里,第一道闸门(语料诊断)给出的第一条事实是:
175 条经验,只有 24 个 distinct
abstract_pattern,去重坍缩率 86.3%。
这条事实之所以是闸门 FAIL 的依据,是因为召回流水线里有一步 _deduplicate_by_pattern——按 pattern 去重。这意味着无论库里有多少条经验,单次召回的可选池上限就是 distinct pattern 的个数。175 条经验,实际可被选中的池子只有 24 条那么宽。
当时我在报告里写的是「改造经验生成质量」。但严格来说,那句话建立在一个没有被验证的解释上。「24」这个数字至少有两种读法:
| 解释 | 该改的地方 | |
|---|---|---|
| A | 语料本身只发生过 24 种情况 | 触发条件——采集得太少、太窄 |
| B | pattern 生成器的值域上限就这么大 | 生成逻辑——采集到了但压缩掉了 |
这两条修法方向完全相反。解释 A 下你应该放宽触发条件、多采集;解释 B 下你放宽触发条件毫无意义——语料涨十倍,distinct pattern 也不会超过那个上限,池子还是那么宽。
更糟的是,两种解释在数据表面上长得一模一样:都表现为「distinct 值很少」。想分开它们,得换个测法。
二、我第一次读代码,读错了地方
这一节本来可以不写。写它是因为它恰好是第 85 篇结论的一次现场复演。
第 85 篇的方法论小节里我写过一句:归因用实验不用推理。这一轮我先违反了一次。
grep 了一下 abstract_pattern,第一个命中的是 experience_store.py 里的 _generate_abstract_pattern——一个 40 行的关键词提取器,把错误文本切词、统计、拼成模式串。看完我的第一反应是:找到了,就是这个提取器太粗糙,24 个 pattern 是它的产物。
然后我顺手看了一眼调用点:
# experience_store.py record() 内部
if not abstract_pattern:
abstract_pattern = self._generate_abstract_pattern(...)
条件调用。那就得看传进来的 abstract_pattern 是什么。往上游走一层,真正的生产者在另一个文件:
# experience_recorder.py L165-166
abstract_pattern = self._generate_pattern(tool_name, failures)
await self._store.record(
trigger=trigger,
diagnosis=diagnosis,
fix_summary=fix_summary,
abstract_pattern=abstract_pattern, # ← 永远非空
outcome="success",
source_type="tool_retry",
...
)
而 _generate_pattern 的最后三行是:
if not patterns:
patterns.append(f"{tool_name}工具重试成功") # ← 兜底
return f"工具试错模式: {'+'.join(patterns)}"
它恒返回非空。 所以 record() 里那个 if not abstract_pattern 分支在这条路径上永不进入——我一开始盯着的那 40 行提取器,对 tool_retry 来源的经验从来没有执行过。
而库里 source_type 的分布是:tool_retry 174 条 / 175 条。
也就是说,那 40 行代码对当前 99.4% 的语料是死代码。我花了十分钟读它、分析它的缺陷、准备把它写成根因——如果不是顺手看了一眼调用点,这一篇的结论会整体建立在一段从未运行的代码上。
这不是「我不够仔细」的问题。条件调用 + 上游兜底这个组合天然会骗人:两个文件各自看都很合理,缺陷只存在于它们的接缝处,而接缝不在任何一个文件里。想稳定地避开这类坑,办法不是读得更仔细,而是别用读代码来确认执行——让代码自己回答。
所以这一轮的第一个测量,就是问生成器它自己能吐出多少种值。
三、值域枚举法:把生成器当函数来测
_generate_pattern 是个静态方法,纯函数,无副作用:
@staticmethod
def _generate_pattern(tool_name: str, failures: list[dict[str, Any]]) -> str:
error_texts = " ".join(f["error"].lower() for f in failures[:3])
patterns = []
if any(kw in error_texts for kw in ["timeout", "超时", "timed out"]):
patterns.append("超时重试")
if any(kw in error_texts for kw in ["connection", "connect", "连接", "network", "网络"]):
patterns.append("连接重试")
if any(kw in error_texts for kw in ["invalid", "参数", "argument", "parameter"]):
patterns.append("参数修正")
if any(kw in error_texts for kw in ["permission", "denied", "权限", "拒绝"]):
patterns.append("权限问题")
if any(kw in error_texts for kw in ["encoding", "parse", "decode", "编码", "解析"]):
patterns.append("编码/解析容错")
if not patterns:
patterns.append(f"{tool_name}工具重试成功")
return f"工具试错模式: {'+'.join(patterns)}"
看清结构之后,它的值域是可以精确算出来的:
- 五个独立的
if,各自贡献一个固定标签 → 非空组合有 2⁵ − 1 = 31 种 - 五个
if全不命中时走兜底,形态由tool_name决定 → 有多少工具就有多少种
但我不想「算」,我想「测」。因为算是推理,测是事实——如果我对某个 if 的判据理解有偏差,算出来的数会错,而测出来的不会。
于是直接 import 生产代码,用合成 error 文本枚举所有非空子集:
# 每类判据取一个代表性关键词,用于合成 error 文本反推值域
PATTERN_PROBES = [
("超时重试", "timeout"),
("连接重试", "connection"),
("参数修正", "invalid"),
("权限问题", "permission"),
("编码/解析容错", "encoding"),
]
from src.core.experience_recorder import ExperienceAutoRecorder
gen = ExperienceAutoRecorder._generate_pattern
combo_domain: set[str] = set()
for k in range(1, len(PATTERN_PROBES) + 1):
for combo in itertools.combinations(PATTERN_PROBES, k):
err = " ".join(kw for _, kw in combo)
combo_domain.add(gen("dummy_tool", [{"error": err}]))
tools = tool_histogram(rows)
# 空集走兜底分支,形态由 tool_name 决定
fallback_domain = {gen(t, [{"error": "something went wrong"}]) for t in tools}
注意这里做的两件事:combo_domain 用一个假工具名跑遍所有判据组合,fallback_domain 用一段刻意不含任何判据关键词的错误文本跑遍库里真实出现过的每个工具名。两个集合合起来就是生成器在当前工具集下的完整可达值域。
跑出来:
五类判据的非空子集数 : 31
实际可达的「组合形态」取值数 : 31
库中出现过的工具数 : 19
「兜底形态」取值数(= 工具数) : 19
生成器值域上限 = 组合 + 兜底 : 50
实测 distinct abstract_pattern : 24
经验总条数 : 175
上限 50,实测 24。 光看这两个数,两种解释还没被分开——24 < 50,似乎说明「还没跑满,所以是语料不够」。
关键的一步是把实测的 24 个值按形态分解:它落在组合形态里,还是兜底形态里?
hit_fallback = actual & fallback_domain
hit_combo = actual & combo_domain
unknown = actual - fallback_domain - combo_domain
其中「兜底形态」(未命中任何判据): 18
其中「组合形态」(命中错误分类) : 5
其中无法归类(非 recorder 生成) : 1
落在兜底形态上的经验条数 : 154 / 175 (88.0%)
这一下就清楚了。
24 个 pattern 里有 18 个是兜底形态——也就是形如 工具试错模式: shell工具重试成功 这种。五类错误判据实际只贡献了 5 个取值。而按经验条数算,154/175(88.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]] = []
for i in range(sim.shape[0]):
for ci, c in enumerate(centers):
if sim[i, c] >= threshold:
members[ci].append(i)
break
else:
centers.append(i)
members.append([i])
return members
贪心聚类不是最优聚类,簇数会略微偏高。这里不追求精确——只要它给出的数量级足以区分 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,经验总数 = 175
0.95 阈值下 70 个语义簇,对 24 个 pattern。
余弦 0.95 已经是相当宽松的合并标准了——两段文本得非常接近才会被判为同一簇。在这个标准下语料仍然有 70 种可区分的语义,而 pattern 只给了 24 个格子。
哪怕把阈值放到 0.85(几乎是「大致相关就算一类」),语料还有 27 簇,仍然多于 24。
解释 B 成立:pattern 生成器在压缩真实存在的差异。放宽触发条件、多采集经验,不会让可选池变宽——它被 min(生成器值域, 语料多样性) 里的左项卡住了。
顺便注意 最大簇 47 这个数字,它在 0.95 / 0.90 / 0.85 三档里完全不动。下一节会回来解释它。
五、两种病灶同时存在,而且互相掩盖
上一节的结论是「生成器在压缩差异」。但这个结论如果直接套到每一组上,会得出错误的修法。因为去重丢的东西,各组性质完全不同。
测法很直接:把每个 pattern 组内部的经验两两算余弦,看组内语义有多一致。
for p, g in sorted(multi.items(), key=lambda kv: -len(kv[1]))[:8]:
pairs = [sim[a, b] for a, b in itertools.combinations(g, 2)]
lo = min(pairs)
avg = sum(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-166
failure_count = len(failures)
trigger = f"{tool_name}.{action_name} 连续失败 {failure_count} 次后重试成功"
error_samples = [f["error"][:100] for f in failures[:3]]
diagnosis = f"工具 {tool_name} 调用出错: {'; '.join(error_samples)}"
fix_summary = f"经过 {failure_count} 次重试后成功执行 {tool_name}.{action_name}"
abstract_pattern = self._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 文本的字段
而这四个字段拼起来,就是写进向量库的文本:
@property
def vector_text(self) -> str:
"""与 _write_experience_sync 写入 ChromaDB 的文本拼接方式完全一致。"""
return f"{self.trigger} {self.diagnosis} {self.fix_summary} {self.abstract_pattern}"
嵌入向量由整段文本决定。模板字符占比越高,不同经验的向量就越靠近——因为它们大部分内容字面相同。
按字符数算一下(diagnosis 里扣掉 工具 X 调用出错: 这个前缀,剩下的算真实信息):
向量文本总字符数 : 35649
其中模板/常量字符数 : 19282 (54.1%)
携带真实错误信息的字符数 : 16367 (45.9%)
入库文本 54.1% 的字符是模板常量。 每一条经验,超过一半的内容和其他所有经验一样。
那么问题来了:把冗余字段去掉,检索会不会变好?
这个问题必须用反事实实验来答,不能靠「显然会」。做法是换掉入库文本的构造方式,重新编码,重新测指标:
variants = {
"T0 全文(生产现状)": lambda r: r.vector_text,
"T1 去 fix_summary": lambda r: f"{r.trigger} {r.diagnosis} {r.abstract_pattern}",
"T2 仅 diagnosis": lambda r: r.diagnosis,
"T3 trigger+diagnosis": lambda r: f"{r.trigger} {r.diagnosis}",
}
(这里是纯向量余弦检索,不经过 recall() 的过滤与时间衰减,所以 T0 应该对齐第 84 篇的「裸向量检索」那一行,而不是生产流水线那一行。)
方案 查询 lex R@3 sem R@3 ALL R@3 ALL H@1 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 trigger+diagnosis 24 100.0% 100.0% 100.0% 95.8% 0.979 67.8
这张表最需要说清楚的是它没证明什么。
T1(去掉 fix_summary)的 H@1 从 95.8% 到 100.0%,MRR 从 0.979 到 1.000。看起来是提升。
但评测集只有 24 条查询。95.8% → 100.0% 的差距,是 24 条里的 1 条。一条查询的翻转,在任何统计意义上都不足以支撑「删掉它能提升检索质量」这个结论。
所以这里唯一能说的是:未观察到 fix_summary 有正面贡献。删掉它,R@3 / H@1 / MRR 都不下降。这是个「无害」证据,不是「有益」证据。
真正稳的收益在最后一列:编码延迟 127.5ms → 97.0ms,降 24%。这个数不依赖 24 条查询的统计功效,它是文本变短的直接结果,可重复、可解释。
T2(只保留 diagnosis,扔掉另外三个字段)指标和 T0 完全一致、延迟只有 53.1ms,也印证了同一件事:另外三个字段对检索没有可观察的贡献,它们主要在消耗算力。
我把这一段单独拎出来讲,是因为这类实验最容易被过度解读。一个「提升了 4.2 个百分点」的结论写进报告,看起来比「未观察到贡献」有力得多,但前者在这个样本量下是不诚实的。指标的小数位不代表精度,样本量才代表精度。
八、顺带清掉的三笔账
沿着采集端往下查,还有三件在第 84 篇被列为「事实」但没深究的事,这里一并结掉。
8.1 一个从未生效的过滤器
recall() 的第一步过滤是 _filter_by_outcome——按经验的成败结果筛选。
而采集端写入时是这样的:
await self._store.record(
...
outcome="success", # ← 硬编码
...
)
硬编码。 所以库里 175 条经验的 outcome 全部是 success。
一个按 outcome 筛选的过滤器,作用在一个 outcome 只有单一取值的数据集上,是结构性空操作——它不是「暂时没起作用」,而是在当前采集逻辑下不可能起作用。第 85 篇的逐层剥离实验里,这一步对 H@1 的贡献实测 +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 总是显式传入非空 pattern,store 的自动生成分支还会走到吗
always_nonempty = all(
gen(t, [{"error": e}]) for t in ("x", "y") for e in ("", "timeout", "怪错误")
)
包括空错误文本在内,_generate_pattern 恒返回非空。配合 source_type 分布 174/175 为 tool_retry,结论是:那 40 行对 99.4% 的语料不可达。
它没有造成任何 bug——死代码不会出错。它的代价是误导排查:它长得很像根因,位置又在检索侧,任何人 grep abstract_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 个关键点:
- 优化空间为零,往上游一层看:检索端 R@3 已经 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 版权协议,转载请附上原文出处链接及本声明。