记忆深度搜索:search_memory 核心工具 #74

Merged
Yushu merged 2 commits from feature/69-search-memory into main 2026-07-25 05:49:15 +00:00
Member

Closes #69

What

新增模型可主动调用的 core 原生工具 search_memory(query),补齐 arise 三块注入里缺失的 L3(无限深度语义搜索,ADR-0031)——冻结快照只在会话起始算一次 top-K 就冻结、触发注入只认关键词精确匹配,用户提模糊回指("还记得我们聊过的那个想法吗")时模型此前没有任何主动深挖手段。

实现要点

  • 复用既有召回基建,不另起检索算法:从 _recall_events 抽出 _scored_events(chat_id, query_text, *, limit)(embed → 向量检索 → is_event_visible 兜底复核 → 四因子打分),两个消费入口只差候选池大小。recall.py 零改动,_recall_events 可观察行为逐行不变(同样的存储调用、同样的顺序与实参)。
  • 不受冻结快照 top-K 限制:新增 config arise_search_candidate_pool(默认 20)与 arise_recall_top_k(默认 5)并列。两者都做成 config 是有意的——"池子比常驻 top-K 宽"这条不变量牵涉两个数,只把其中一个做成可配的,运维调大 top_k 就会让工具静默退化成比快照还窄。
  • 结果不进 prefix-cache:冻结快照在工具循环之前就定死在 context[2],工具结果只 append 在尾部,handler 不写任何存储,后续轮次重算的快照也捞不到它。
  • 查询净化只做截断兜底(ADR-0031 否决了 MemPalace 式四级语义抽取):超长查询会稀释查询向量导致召回静默失真,硬截断保留尾部 MAX_QUERY_CHARS=200——模型真要违规裹挟上下文,精炼后的意图通常落在末尾。
  • 计入 run_limit:ADR-0031 明确它是推理工具,不属 send_message 类豁免——刻意不加进 RUN_LIMIT_EXEMPT_TOOL_NAMES
  • 返回结构带 content+time+importance(ADR-0031 留实现期定):模型据此才能判断"这是半年前的事了"并在措辞上自然表达不确定性;刻意不回召回分数——那是内部权重调参的产物(ADR-0011"待标定"),暴露等于把不稳定的内部量纲写进对外契约。
  • 恒可见、不受渐进解锁门控(同 delegate_task/register_callback 先例):它服务于回答用户追问而非自发行为,且天然自限——冷启动库里没事件就搜不到。

Review

/code-review 两轴(Standards + Spec)已跑,两轴都抓到真问题:

  • Spec 轴(最严重,变异验证抓到)test_a_sensitive_event_from_another_chat_is_not_searchable空转的——SentLog.search_events 自己就带同一条可见性过滤,事件在存储层就被滤掉,新增的"防御性冗余"那层根本没被触达;reviewer 把那层删掉,9 个测试照样全绿。改为用故意不过滤的假存储把泄漏事件直接喂进去,才真正锁住兜底层;另补一条反向测试防"什么都返回不了"式假通过。已变异复核:删掉过滤层,新测试确实挂。
  • Spec 轴:AC「结果不进 prefix-cache」实现正确但零测试覆盖——补断言搜索前后两次 complete() 的冻结快照逐字节相同(泄漏真要发生正是在第二次调用的下标 2 上)。
  • Standards 轴:候选池此前写死成模块常量,与可配的 arise_recall_top_k 构成半硬编码的不变量——挪进 config(见上)。
  • 删掉一条与纯函数测试重复、且硬编码 > 3 不对应任何配置的近重言式断言。

Tests

全仓库 977 个测试通过(新增 20 个:sanitize_query/render_search_hits 纯函数、工具分发(查询→结果、空/缺失参数、超长截断落到 embedding 调用上)、run_limit 计入、不受 top-K 限制的核心差异点、prefix-cache 不泄漏、可见性兜底两向)。

变异测试复核了三处守卫:候选池调成 3 挂 3 条;关掉截断分支挂 3 条;删掉可见性兜底过滤挂新写的那条(旧版不挂——这正是修它的原因)。

Closes #69 ## What 新增模型可主动调用的 core 原生工具 `search_memory(query)`,补齐 arise 三块注入里缺失的 L3(无限深度语义搜索,ADR-0031)——冻结快照只在会话起始算一次 top-K 就冻结、触发注入只认关键词精确匹配,用户提模糊回指("还记得我们聊过的那个想法吗")时模型此前没有任何主动深挖手段。 ## 实现要点 - **复用既有召回基建,不另起检索算法**:从 `_recall_events` 抽出 `_scored_events(chat_id, query_text, *, limit)`(embed → 向量检索 → `is_event_visible` 兜底复核 → 四因子打分),两个消费入口只差候选池大小。`recall.py` 零改动,`_recall_events` 可观察行为逐行不变(同样的存储调用、同样的顺序与实参)。 - **不受冻结快照 top-K 限制**:新增 config `arise_search_candidate_pool`(默认 20)与 `arise_recall_top_k`(默认 5)并列。两者都做成 config 是有意的——"池子比常驻 top-K 宽"这条不变量牵涉两个数,只把其中一个做成可配的,运维调大 top_k 就会让工具静默退化成比快照还窄。 - **结果不进 prefix-cache**:冻结快照在工具循环之前就定死在 `context[2]`,工具结果只 append 在尾部,handler 不写任何存储,后续轮次重算的快照也捞不到它。 - **查询净化只做截断兜底**(ADR-0031 否决了 MemPalace 式四级语义抽取):超长查询会稀释查询向量导致召回**静默**失真,硬截断保留**尾部** `MAX_QUERY_CHARS=200`——模型真要违规裹挟上下文,精炼后的意图通常落在末尾。 - **计入 `run_limit`**:ADR-0031 明确它是推理工具,不属 `send_message` 类豁免——刻意不加进 `RUN_LIMIT_EXEMPT_TOOL_NAMES`。 - **返回结构带 `content`+`time`+`importance`**(ADR-0031 留实现期定):模型据此才能判断"这是半年前的事了"并在措辞上自然表达不确定性;刻意不回召回分数——那是内部权重调参的产物(ADR-0011"待标定"),暴露等于把不稳定的内部量纲写进对外契约。 - **恒可见、不受渐进解锁门控**(同 `delegate_task`/`register_callback` 先例):它服务于回答用户追问而非自发行为,且天然自限——冷启动库里没事件就搜不到。 ## Review `/code-review` 两轴(Standards + Spec)已跑,两轴都抓到真问题: - **Spec 轴(最严重,变异验证抓到)**:`test_a_sensitive_event_from_another_chat_is_not_searchable` 是**空转的**——`SentLog.search_events` 自己就带同一条可见性过滤,事件在存储层就被滤掉,新增的"防御性冗余"那层根本没被触达;reviewer 把那层删掉,9 个测试照样全绿。改为用**故意不过滤**的假存储把泄漏事件直接喂进去,才真正锁住兜底层;另补一条反向测试防"什么都返回不了"式假通过。已变异复核:删掉过滤层,新测试确实挂。 - **Spec 轴**:AC「结果不进 prefix-cache」实现正确但零测试覆盖——补断言搜索前后两次 `complete()` 的冻结快照逐字节相同(泄漏真要发生正是在第二次调用的下标 2 上)。 - **Standards 轴**:候选池此前写死成模块常量,与可配的 `arise_recall_top_k` 构成半硬编码的不变量——挪进 config(见上)。 - 删掉一条与纯函数测试重复、且硬编码 `> 3` 不对应任何配置的近重言式断言。 ## Tests 全仓库 977 个测试通过(新增 20 个:`sanitize_query`/`render_search_hits` 纯函数、工具分发(查询→结果、空/缺失参数、超长截断落到 embedding 调用上)、run_limit 计入、不受 top-K 限制的核心差异点、prefix-cache 不泄漏、可见性兜底两向)。 变异测试复核了三处守卫:候选池调成 3 挂 3 条;关掉截断分支挂 3 条;删掉可见性兜底过滤挂新写的那条(旧版不挂——这正是修它的原因)。
补齐 arise 三块注入里缺失的 L3(无限深度语义搜索,ADR-0031)——冻结快照只在
会话起始算一次 top-K 就冻结、触发注入只认关键词精确匹配,用户提模糊回指
("还记得我们聊过的那个想法吗")时模型此前没有任何主动深挖手段。

复用四因子召回既有基建,不另起检索算法或评分公式:从 `_recall_events` 抽出
`_scored_events`(embed→检索→可见性兜底复核→打分),两个消费入口只差候选池
大小——冻结快照走 recall_top_k(每轮要装进 context 且要能被 prefix-cache
冻结所以必须小),search_memory 走更大的 SEARCH_CANDIDATE_POOL。既有召回
排序算法与断言零改动。

结果只作为工具返回值进 context,不写冻结快照、不进 prefix-cache(本轮才算)。
可见性照常走 is_event_visible——这个工具不是绕过敏感度/知情门的后门。计入
run_limit(ADR-0031 明确它是推理工具,不属 send_message 类豁免)。

查询净化只做截断兜底(ADR-0031 否决了 MemPalace 式四级语义抽取):超长查询
会稀释查询向量导致召回静默失真,硬截断保留尾部——模型真要违规裹挟上下文,
精炼后的意图通常落在末尾。

返回结构带 content+time+importance(ADR-0031 留实现期定):模型据此才能判断
"这是半年前的事了"并在措辞上自然表达不确定性;刻意不回召回分数,那是内部
权重调参的产物,暴露等于把不稳定的内部量纲写进对外契约。

工具恒可见、不受渐进解锁门控(同 delegate_task/register_callback 先例)——
它服务于回答用户追问而非自发行为,且天然自限:冷启动库里没事件就搜不到。
**最严重的一条(Spec 轴变异验证抓到)**:
`test_a_sensitive_event_from_another_chat_is_not_searchable` 是空转的——
`SentLog.search_events` 自己就带同一条 `is_event_visible` 过滤,事件在存储层
就被滤掉了,本片新增在 `_scored_events` 里的那层"防御性冗余"根本没被触达。
reviewer 把那层过滤整段删掉,9 个测试照样全绿。改为用一个**故意不过滤**的假
存储把泄漏出来的敏感事件直接喂进去,才真正锁住兜底那层;另补一条反向测试
(同样的泄漏形状换成本 chat 非敏感事件必须照常返回),防止"什么都返回不了"
式的假通过。已变异复核:删掉过滤那层,新测试确实挂。

**AC「结果不进 prefix-cache」此前零覆盖**:实现是对的(冻结快照在工具循环
之前定死在 context[2],工具结果只 append 尾部),但没有任何断言。泄漏真要
发生是在第二次 complete() 的下标 2 上,补断言两次调用的冻结快照逐字节相同,
外加一条"结果确实到过模型手里"防假通过。

**Standards 轴:候选池挪进 config**。"池子比常驻 top-K 宽"这条不变量牵涉两个
数,此前只有 `arise_recall_top_k` 可配、池子写死成模块常量——运维把 top_k
调到 25 就会让工具静默退化成比快照还窄,而原测试只验 `Config()` 默认值抓不到。
改为 `arise_search_candidate_pool` 与之并列,同 recall_top_k 一样经构造参数
注入;不变量测试改为比对两个真实 config 字段。`MAX_QUERY_CHARS` 留在
memory_search.py——它是 sanitizer 自己的边界,与运维旋钮不同性质。

顺带删掉一条与 test_memory_search.py 重复、且硬编码 `> 3` 不对应任何配置的
近重言式断言。
Yushu merged commit 84cd7720d0 into main 2026-07-25 05:49:15 +00:00
Yushu deleted branch feature/69-search-memory 2026-07-25 05:49:16 +00:00
Sign in to join this conversation.
No description provided.