数据截至 (上游 commit db80b6a7c591)
EvolveMem:AutoResearch 自进化检索
30 秒导读: 一般的检索系统调好参数就"冻"在那里了。EvolveMem 这一支反过来——它把检索机制本身(打分怎么算、三视图怎么融合、答案怎么生成)当成一个可以自动调优的东西,部署后继续跑一个闭环:做题 → 诊断哪题错了、为什么错 → LLM 提一个配置改动 → 护栏检查这改动是否真的涨分 → 涨了就留、不涨就退回 → 再来一轮。人不介入,配置自己爬着往上走。
本章只讲自进化这一支。SimpleMem 的静态检索管线(意图规划 + 三视图召回 + 反思)在 02-hybrid-retrieval.md 讲;本章讲的是"让那套检索的配置自己进化"的引擎。文本压缩管线见 01-text-pipeline.md,总览见 index.md。
1. 这是什么(零基础也能懂)
一句话定义: EvolveMem 是一个"会自己调检索参数"的引擎——它把检索器的一大堆开关(用几路召回、怎么融合、要不要拆问题、要不要二次校验答案……)交给一个 LLM,让 LLM 看着每道题错在哪去提改动,再用一套护栏保证改动确实有用才留下。
它解决什么问题。 假设你搭了一个记忆检索系统,在自己的问答集上跑,发现有些题答不对。传统做法是人去看失败案例、猜"是不是召回太少了/是不是该开实体消歧",手动改配置、再跑一遍——反复试。EvolveMem 把这个"看失败→改配置→再验证"的循环自动化了:诊断由 LLM 做,验证与保留由引擎做。
它给谁用。 两类入口,难度和保真度不同:
| 入口 | 面向 | 能进化什么 |
|---|---|---|
EvolveMem/run_evolution.py(论文级) | 复现论文数字的研究者 | 全维度:全局超参 + 每类别覆写 + adapter 专用 prompt + 抽取参数 |
simplemem.optimize()(降级级) | 想给自己 SimpleMem 后端调参的用户 | 只调全局检索超参(无 adapter、无 per-category、无抽取进化) |
用起来什么样。 降级入口就一行:
# 示意,基于 simplemem/__init__.py:25-50 与 optimize.py:25 的真实签名
import simplemem
config = simplemem.optimize(mem, dev_questions) # mem 已 finalize,dev_questions 是 (问题, 标准答案) 列表
config.save("my_config.json") # 存下进化出的最优检索配置
# 部署时加载,检索行为就用进化出来的参数
config = simplemem.load_config("my_config.json")
mem = simplemem.SimpleMem(config=config)
一句话直觉/类比。 把它当成自动做超参搜索的爬山者,但爬山的"下一步往哪走"不是随机也不是网格,而是一个 LLM 看着错题给出的诊断建议;而"这一步到底要不要迈出去",由一套只认分数的护栏说了算。
2. 顶层全景(它大概怎么转)
核心是一个闭环。 每一轮(round)都完整走一遍下面这条链,然后把"这一轮学到的东西"带进下一轮:
┌──────── ─────────────────────────────────────┐
│ 一轮 evolution round │
│ │
memories ─┐ │ ① Index 建三视图索引 MultiViewIndex │
├──▶ ② Retrieve+Answer 用当前 RetrievalConfig 做题、生成答案 │
qa_pairs ─┘ │ ③ Evaluate 逐题算 token-F1,统计 zero/类别 │
▲ │ ④ Guard elitist:比上一轮好才留,否则退回 │
│ │ ⑤ Diagnose LLM 看错题 → 找根因 │
下一轮用 │ │ ⑥ Propose LLM 提 ≤2 个配置改动 │
新配置 ────┘ │ ⑦ Apply 改动落到 RetrievalConfig │
└─────────────────────────────────────────────┘
停机条件:达到 max_rounds,或连续 max_consec_noaccept 轮没被接受
怎么读这张图: ①→⑦ 是一轮内的顺序;右侧回边表示"⑦ 改出的新配置喂给下一轮的 ②"。注意 ④ Guard 排在 ⑤ Diagnose 之前——这样被拒绝的轮会先把配置退回到擂主,LLM 诊断时看到的是擂主状态而不是刚被否掉的坏状态。
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
EvolutionEngine.evolve | 主循环:串起 Index→Answer→Eval→Guard→Diagnose→Apply | simplemem/evolver/evolution.py:402 |
RetrievalConfig | 进化的动作空间——每个字段都是一个可调维度 | simplemem/evolver/multi_retriever.py:38 |
MultiViewIndex / retrieve_multiview | 三视图(语义/BM25/结构化)召回 + 融合 | simplemem/evolver/multi_retriever.py:219,653 |
MemoryDiagnostics.diagnose | 分类失败(弃答/答错/低分)+ LLM 找根因 | simplemem/evolver/diagnosis.py:297 |
MemoryDiagnostics.suggest_config_update | 把诊断变成"改哪几个字段"的配置 delta | simplemem/evolver/diagnosis.py:422 |
MetaEvolutionAnalyzer.analyze | 跨轮看历史:停滞→探索、回归→回退、卡死→提新维度 | simplemem/evolver/meta_analysis.py:186 |
run_optimization + _wrap/_resolve 适配器 | 把用户的 SimpleMem 后端喂进引擎(降级入口) | simplemem/evolver/optimize.py:25 |
主线走一遍(高层)。 输入是一堆已抽取的 memories 和一批 (问题, 标准答案);引擎用当前配置做题、打分,LLM 诊断失败并提改动,护栏判断该改动值不值得留;循环若干轮后,吐出 EvolutionResult,里面的 final_config 就是历史最优配置。
3. 核心原理(逐个机制,由浅入深)
3.1 可进化的动作空间:RetrievalConfig
它要解决的小问题: "自进化"必须先定义"能改什么"。RetrievalConfig 就是这个动作空间——诊断 LLM 每轮能提议调整的所有旋钮都在这一个 dataclass 里。
思路/直觉。 源码注释把这点说得很直白:"The config's total surface area is the evolution action space — every field here is a dimension diagnosis can propose adjusting"(multi_retriever.py:42-44)。所以给它加字段 = 给进化多一个可探索维度;而且新字段必须带向后兼容默认值,这样没被动过的配置行为不变。
几个关键维度分组:
| 维度组 | 代表字段 | 作用 |
|---|---|---|
| 三视图 top-k | semantic_top_k / keyword_top_k / structured_top_k / max_context | 每路召回多少、最终喂给答案 LLM 多少 |
| 融合 | fusion_mode、weight_semantic/keyword/structured | 三路怎么合并:first_found/weighted_sum/rrf/*_only |
| 实体消歧 | enable_entity_swap、entity_swap_*_k | 对抗类"换名字"问题:剥掉查询里的人名重搜 |
| 问题分解 | enable_query_decomposition、enable_intent_planning | 多跳题拆成子问题分别召回再合 |
| 答案校验 | enable_answer_verification、verification_style | 二次 LLM 复核候选答案,修 "Unknown"/格式偏差 |
| 每类别覆写 | per_category_overrides | {类别: {字段: 值}},只对某个弱类别改配置,不动其它类 |
字段定义见 multi_retriever.py:47-203;融合的三种真实算法(RRF 用 60 偏移、weighted_sum 先按视图归一化再加权)在 _fuse(multi_retriever.py:586-650)。
关键细节: per_category_overrides 是"精准手术"——当某一个类别远落后于其它类别时,LLM 被引导用它只改那一类(诊断 prompt 决策规则 #6,diagnosis.py:177),避免全局改动伤到本来正常的类别。
3.2 闭环:Evaluate → Diagnose → Propose → Guard → Repeat
它要解决的小问题: 光有动作空间不够——得有个东西决定每轮往哪个方向迈一步,并且保证迈出去别越走越差。
思路/直觉。 把它拆成四个角色,分工明确:
- Evaluate(引擎) 逐题算 token-level F1,统计有多少题拿 0 分、每个类别多少分。
- Diagnose(LLM) 看失败样本,把失败分成弃答 / 答错 / 低分,找根因。
- Propose(LLM) 基于诊断提至多 2 个字段改动。
- Guard(引擎) 只认分数:改动确实涨分才留,否则退回。
主循环真实骨架: 一轮内先建索引、做题、打分:
# simplemem/evolver/evolution.py:482-495(真实源码,略简注释)
index = MultiViewIndex(memories, self.embedder) # ① 建三视图索引
qa_results = self._evaluate_qa(index, qa_pairs, ret_config) # ② 做题 + 生成答案
overall_f1 = sum(r.f1 for r in qa_results) / len(qa_results) # ③ 平均 F1
zero_count = sum(1 for r in qa_results if r.f1 == 0) # 有多少题 0 分
打完分先过 Guard(见 3.3),再 diagnose,再 suggest_config_update。诊断这一步刻意排在 Guard 之后,是为了让 LLM 在"被拒绝并已退回"的场景下看到擂主配置(evolution.py:606-614)。
Diagnose 的输出结构: DiagnosisReport(diagnosis.py:235)带失败分桶(abstention_count 弃答 / wrong_answer_count 答错 / low_f1_count 低分)、category_f1、以及 LLM 给的 parameter_suggestions。
Propose 的护栏在 prompt 里: 诊断 prompt 有一条硬规则—— 每轮最多提 2 个字段,per_category_overrides 整块算 1 个改动;超出会被截断(diagnosis.py:72-78)。而且 suggest_config_update 会按影响力分层重排提议(adapter flag > 架构 flip > 标量微调),保证有限的预算名额优先给高 ROI 的改动(_impact_tier,diagnosis.py:506-526)。
3.3 护栏:elitist 爬山 + acceptance_threshold + max_consec_noaccept
它要解决的小问题: LLM 的诊断不一定对。如果每轮"照单全收",很容易出现源码注释说的失败模式——"round-1 solves everything, rest is noise"(evolution.py:203-206):第一轮碰巧涨了一大截,后面全是噪声甚至倒退。护栏就是防这个。
三道护栏。
| 护栏 | 字段(默认) | 作用 |
|---|---|---|
| elitist 爬山 | elitist=True | 严格爬山:只有改动让 F1 涨过阈值才采纳,否则退回擂主配置 |
| 采纳阈值 | acceptance_threshold=0.003 | delta F1 要 > 0.003 才算"接受"并重置停滞计数 |
| 停滞上限 | max_consec_noaccept=5 | 连续 5 轮没被接受就停机 |
| 步长上限 | max_changes_per_round=2 | 每轮最多改 2 个顶层字段,好把效果归因到单一方向 |
配置定义在 EvolutionConfig(evolution.py:188-210)。
采纳逻辑的巧妙处:接受阈值和"记为最优"是分开的两件事。 直觉上你会以为"没过阈值就丢弃",但源码做得更细:
- 严格涨分(
overall_f1 > best_f1)就更新best_config——哪怕 delta 只有 +0.002,低于 +0.003 阈值,也留作新基线(叫 SOFT-ACCEPT),不退回。 - 阈值只用于 ACCEPT/REJECT 标签和停滞计数:过阈值 → 重置计数;没过但仍涨 → 也重置计数(慢但真的进步,不该早停);真的没涨 → 计数 +1 并把配置退回擂主。
# simplemem/evolver/evolution.py:545-577(真实源码,节选)
improved_strict = overall_f1 > best_f1
if improved_strict:
best_f1 = overall_f1; best_config = asdict(ret_config) # 任何真涨都记为最优
if accepted_this_round:
consec_noaccept = 0 # 过阈值:重置停滞
else:
if improved_strict:
consec_noaccept = 0 # 小涨也重置(SOFT-ACCEPT)
else:
consec_noaccept += 1 # 真没涨:计数+1
for k, v in best_config.items(): # 退回擂主配置
if hasattr(ret_config, k): setattr(ret_config, k, v)
归因用的 pending_diff 与 attempt_history。 每轮"进入本轮的那个改动"记在 pending_diff 里,评估后连同 delta、是否接受一起塞进 attempt_history(evolution.py:455-535)。这份历史会喂回诊断 LLM,让它看见自己试过什么、什么被拒了,避免重复提同一个失败改动(诊断 prompt 明确禁止 re-propose 被标 REJECTED 的移动,diagnosis.py:84-86)。
3.4 停滞时的探索激励:MetaEvolutionAnalyzer
它要解决的小问题: 爬山会卡在局部最优——连着几轮都在原地小打小闹,谁来判断"该跳出去了"?
思路/直觉。 单轮诊断只看当前快照;MetaEvolutionAnalyzer 看整段历史,做单轮诊断做不了的战略决策(meta_analysis.py:1-20)。它有几个确定性触发器:
| 触发条件 | 判据(启发式) | 决策 |
|---|---|---|
| 回归 | 最新轮比上轮跌 > 2pp | revert 回退到历史最优轮 |
| 子类别卡死 | 某子类别连续 3 轮 < 20% | propose_new_dim 提一个全新框架维度 |
| 停滞 | 最近 3 轮跨度 < 1pp | explore 挑一个没试过的 fusion_mode 强行扰动 |
| 单类别落后 | 某子类别比均值低 ≥ 20pp | focus_subcategory 用 per-category 覆写聚焦它 |
判据函数见 _is_regression/_is_stagnant/_stuck_subcategories(meta_analysis.py:114-159);停滞时轮换未试过的融合模式在 analyze 里(meta_analysis.py:242-260)。诊断 prompt 里也有一条对称的 ANTI-PLATEAU 规则:连续 2+ 轮被拒时,必须动用一个没用过的架构级能力(意图规划 / MMR / 分解),而不是继续拧标量旋钮(diagnosis.py:88-97)。
"提新维度"这一支的意义。 当所有旋钮都试遍、还是有一类题动不了,meta 会写下一个 new_dimension_proposal——即"框架自己指出它该往哪长",落盘到 meta_proposals.jsonl(evolution.py:737-748)。这是给人看的"框架级成长"信号。
重要护栏交互: 在
elitist=True时,meta 的revert与字段合并会被跳过——因为 elitist 自己已经在回退,两边都做会双重回退、污染attempt_history(evolution.py:694-734)。所以默认模式下 meta 主要起"停滞探索 / 提新维度"的作用,回退交给 elitist。
3.5 三种初始配置的用意:弱 / 强 / 已进化
它要解决的小问题: 从哪个配置开始进化,直接决定了"进化能证明什么"。
思路/直觉。 三个工厂函数给了三种起点(evolution.py:47-143):
| 工厂函数 | 起点是什么 | 用意 |
|---|---|---|
weak_initial_config | 纯 BM25 + 小 k,关掉语义/融合/反思/实体消歧/类别技巧 | 让 evolved-minus-static 的差值成为卖点 |
strong_initial_config | 等价于先前工作的强基线(多视图 + 实体消歧) | 可比性 / 消融对照 |
evolved_config | 全维度打开 + 每类别覆写的完整配置 | 进化目标态 / maturation_round 直接跳到的成熟态 |
为什么弱初始是刻意的。 源码注释讲得很清楚:"we want the evolved-minus-static delta to be the paper's headline"(evolution.py:50-54)。弱初始把每个可进化旋钮都设成"弱但安全"的值,给进化留足上爬空间;同时强调它不是被砍到低于先前工作——纯 BM25 本身是站得住的基线,只是每个旋钮都待在弱值等着诊断去打开。
maturation 快捷路径。 EvolutionConfig.maturation_round 若设了,到那一轮会直接把配置灌成 evolved_config()(evolution.py:459-472)——相当于"跳过慢慢爬,直接成熟"的对照手段。
4. 深入实现:simplemem.optimize() 如何把用户后端适配进引擎
这是"降级"入口。 simplemem.optimize() 刻意不是论文级入口(optimize.py:6-17)。它砍掉三样东西:
- 你的
dev_questions只当(q, answer),没有 per-category 标签; - 用
mem里已建好的 memories,不跑新的抽取循环; adapter=None,没有 benchmark 专用 prompt。
于是 per-category 覆写、adapter prompt、抽取进化全部禁用,剩下的就是对全局检索超参的 LLM 驱动搜索(top_k、max_context、fusion、weights、全局开关)。
适配靠四个函数(都放在 optimize.py,刻意不碰 SimpleMem 自己的代码):
| 适配器 | 干什么 | 位置 |
|---|---|---|
_resolve_backend | 把 AutoMemory 拆到 SimpleMemSystem;校验后端有 llm_client | optimize.py:97 |
_wrap_llm_call | 把 SimpleMem 的 chat_completion 适配成引擎要 的 (messages, max_tokens, temperature)→str 签名(丢掉 max_tokens) | optimize.py:113 |
_wrap_embedder | 用 _SentenceTransformerEncodeAdapter 包住,让 encode(texts, normalize_embeddings=True) 能直接调 | optimize.py:131,143 |
_memories_from_backend | 把 MemoryEntry 对象转成 MultiViewIndex 吃的 dict(读 content/keywords/persons/entities/topic/…) | optimize.py:150 |
一次调用的完整数据流:
# simplemem/evolver/optimize.py:46-67(真实源码,节选)
backend = _resolve_backend(mem) # 拆包 + 校验后端
llm_call = _wrap_llm_call(backend) # 适配 LLM 签名
embedder = _wrap_embedder(backend) # 适配 embedder
initial_memories = _memories_from_backend(backend) # MemoryEntry -> dict
qa_pairs = _qa_pairs_from_tuples(dev_questions) # (q,a) -> dict,category=0 占位
engine = EvolutionEngine(llm_call, embedder, evo_config, adapter=None)
result = engine.evolve(sessions=[], qa_pairs=qa_pairs, initial_memories=initial_memories)
几个"降级"的实现细节:
- 类别塌缩成单轨。
_qa_pairs_from_tuples给每题填category=0占位(optimize.py:177-198),这个数字不撞 LoCoMo(1-5)也不撞 MemBench 类别 id,于是所有 per-category 菜谱塌缩成一条全局轨道。 - kwargs 过滤。 只有
EvolutionConfig真有的字段才透传进去(hasattr(EvolutionConfig, k),optimize.py:54)。 - 回吐 Config。
result.final_config(历史最优)被翻译回 SimpleMem 的Config:semantic_top_k→k_sem、max_context→context_budget、融合权重、各enable_*开关、evolved=True等(optimize.py:69-89)。
5. 巧妙之处(可借鉴的技术)
- 动作空间即 dataclass。 把"能进化什么"完全等同于一个 dataclass 的字段集,进化循环直接遍历
ret_config.__dataclass_fields__应用改动(evolution.py:771-784)——加字段就等于加维度,不用改进化代码。这条注释还特意说明:用 dataclass 字段而非手维护的EVOLVABLE_FIELDS元组,新字段自动可进化。 - 接受阈值 ≠ 是否记为最优。 小于阈值但为正的 delta 仍作 SOFT-ACCEPT 留作新基线(
evolution.py:536-565),避免"退回步骤丢掉小而真的收益"。这是很容易写错的地方。 - Guard 在 Diagnose 之前。 先退回再诊断,保证 LLM 永远在擂主状态上做诊断(
evolution.py:500-614)——顺序本身就是一道护栏。 - attempt_history 喂回 LLM。 把"试过什么、被拒了什么"结构化回灌给诊断 prompt,让 LLM 不重复踩坑(
evolution.py:528-535+diagnosis.py:69-86)。 - 弱初始是"卖点工程"。 刻意选弱起点让 evolved-minus-static 的差值最大化,同时保证起点仍是 reviewer-proof 的合法基线(
evolution.py:47-59)。 - elitist 与 meta 的去重。 明确识别"两套回退机制会双重回退"并在 elitist 下跳过 meta 回退(
evolution.py:694-734),是难得的对"多个自动化决策者互相打架"的防御。
6. 边界与局限
- 诊断质量 = LLM 质量。 整条链的"往哪走"完全依赖诊断 LLM;
_wrap_llm_call出错时直接吞成空串返回(optimize.py:121-126),坏诊断只会被护栏拦成"不采纳",不会主动纠错。 - 降级入口丢掉很多进化能力。
simplemem.optimize()没有 adapter、没有 per-category、不跑抽取进化,类别被塌缩成单轨(optimize.py:6-17,177-198)——想要论文级效果得走run_evolution.py。 - 只支持文本后端。
_resolve_backend要求后端暴露llm_client,否则直接TypeError(optimize.py:105-109);多模态 Omni 后端不适用(见 04-omni-and-cross.md)。 - 护栏防退化,不防"卡在 局部最优"。 elitist 保证单调,但真正跳出局部最优要靠 meta 的探索/提新维度,而这些在 elitist 下部分被抑制——所以进化可能早停在
max_consec_noaccept而非全局最优。 - 爬山靠单点评估。 每轮只在一份 dev 集上评估,没有交叉验证;dev 集小的时候容易过拟合到这批题。
7. 横向对比
同属 SimpleMem 家族,本章这一支和其它三章的取舍不同:
- 对 02-hybrid-retrieval.md(混合检索): 02 讲的是静态的意图规划 + 三视图召回 + 反思;本章是让那套检索的配置自己进化。EvolveMem 的
RetrievalConfig里enable_intent_planning/reflection_rounds这些字段,进化时就是在决定"要不要开 02 讲的那些能力"。 - 对 04-omni-and-cross.md: 04 讲多模态与跨会话;EvolveMem 目前只吃文本后端(
optimize.py:105-109),两者是正交的能力面。
同 shelf 的其它记忆项目里,自进化这一支的独特处在于用 LLM 做诊断驱动的爬山搜索 + 只认分数的回退护栏,而不是固定的启发式调参或纯网格/贝叶斯超参搜索。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 主进化循环 | simplemem/evolver/evolution.py:402 | EvolutionEngine.evolve |
| elitist 采纳/退回逻辑 | simplemem/evolver/evolution.py:510 | evolve(elitist 分支) |
| 应用配置改动(遍历 dataclass 字段) | simplemem/evolver/evolution.py:771 | evolve(EVOLVABLE_FIELDS / __dataclass_fields__) |
| 进化配置与护栏参数 | simplemem/evolver/evolution.py:188 | EvolutionConfig |
| 弱/强/已进化初始配置 | simplemem/evolver/evolution.py:47,77,89 | weak_initial_config / strong_initial_config / evolved_config |
| 轮/整体结果结构 | simplemem/evolver/evolution.py:215,237 | RoundResult / EvolutionResult |
| 动作空间定义 | simplemem/evolver/multi_retriever.py:38 | RetrievalConfig |
| 三视图索引 | simplemem/evolver/multi_retriever.py:219 | MultiViewIndex |
| 召回 + 融合入口 | simplemem/evolver/multi_retriever.py:653,586 | retrieve_multiview / _fuse |
| 失败诊断 | simplemem/evolver/diagnosis.py:297 | MemoryDiagnostics.diagnose |
| 配置改动提议(步长上限 + 影响力分层) | simplemem/evolver/diagnosis.py:422 | suggest_config_update |
| 诊断报告结构 | simplemem/evolver/diagnosis.py:235 | DiagnosisReport |