跳到主要内容

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→Applysimplemem/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把诊断变成"改哪几个字段"的配置 deltasimplemem/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-ksemantic_top_k / keyword_top_k / structured_top_k / max_context每路召回多少、最终喂给答案 LLM 多少
融合fusion_modeweight_semantic/keyword/structured三路怎么合并:first_found/weighted_sum/rrf/*_only
实体消歧enable_entity_swapentity_swap_*_k对抗类"换名字"问题:剥掉查询里的人名重搜
问题分解enable_query_decompositionenable_intent_planning多跳题拆成子问题分别召回再合
答案校验enable_answer_verificationverification_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.003delta 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_diffattempt_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)。它有几个确定性触发器:

触发条件判据(启发式)决策
回归最新轮比上轮跌 > 2pprevert 回退到历史最优轮
子类别卡死某子类别连续 3 轮 < 20%propose_new_dim 提一个全新框架维度
停滞最近 3 轮跨度 < 1ppexplore 挑一个没试过的 fusion_mode 强行扰动
单类别落后某子类别比均值低 ≥ 20ppfocus_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_backendAutoMemory 拆到 SimpleMemSystem;校验后端有 llm_clientoptimize.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_backendMemoryEntry 对象转成 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_semmax_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 的 RetrievalConfigenable_intent_planning/reflection_rounds 这些字段,进化时就是在决定"要不要开 02 讲的那些能力"。
  • 04-omni-and-cross.md: 04 讲多模态与跨会话;EvolveMem 目前只吃文本后端(optimize.py:105-109),两者是正交的能力面。

同 shelf 的其它记忆项目里,自进化这一支的独特处在于用 LLM 做诊断驱动的爬山搜索 + 只认分数的回退护栏,而不是固定的启发式调参或纯网格/贝叶斯超参搜索。


8. 代码地图(导航索引)

主题文件路径符号名
主进化循环simplemem/evolver/evolution.py:402EvolutionEngine.evolve
elitist 采纳/退回逻辑simplemem/evolver/evolution.py:510evolve(elitist 分支)
应用配置改动(遍历 dataclass 字段)simplemem/evolver/evolution.py:771evolve(EVOLVABLE_FIELDS / __dataclass_fields__)
进化配置与护栏参数simplemem/evolver/evolution.py:188EvolutionConfig
弱/强/已进化初始配置simplemem/evolver/evolution.py:47,77,89weak_initial_config / strong_initial_config / evolved_config
轮/整体结果结构simplemem/evolver/evolution.py:215,237RoundResult / EvolutionResult
动作空间定义simplemem/evolver/multi_retriever.py:38RetrievalConfig
三视图索引simplemem/evolver/multi_retriever.py:219MultiViewIndex
召回 + 融合入口simplemem/evolver/multi_retriever.py:653,586retrieve_multiview / _fuse
失败诊断simplemem/evolver/diagnosis.py:297MemoryDiagnostics.diagnose
配置改动提议(步长上限 + 影响力分层)simplemem/evolver/diagnosis.py:422suggest_config_update
诊断报告结构simplemem/evolver/diagnosis.py:235DiagnosisReport
跨轮元分析(停滞/回退/提新维度)simplemem/evolver/meta_analysis.py:186MetaEvolutionAnalyzer.analyze
停滞/回归判据simplemem/evolver/meta_analysis.py:114,120,127_is_regression / _is_stagnant / _stuck_subcategories
降级入口主函数simplemem/evolver/optimize.py:25run_optimization
后端适配器simplemem/evolver/optimize.py:97,113,143,150_resolve_backend / _wrap_llm_call / _wrap_embedder / _memories_from_backend
用户门面simplemem/__init__.py:25optimize