修复长期记忆向量链路:投影入队 + 集合名三侧同源 + 召回按客户过滤

语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、episode 不投 rebuild 事件——已在前两个提交修复)。

R2 投递层:dispatch_profile_rebuild 只做图投影,没有任何 Milvus 投递,
  长期记忆的向量从未被写入过(实测客户 9001 在 memory_sync_outbox 里 0 行)。
  新增 _enqueue_memory_vector_projection(),在画像重建后投
  MemorySyncOutbox(target_store="milvus")。走事件而不是同步写,是为了拿
  Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
  ⚠️ payload 必须带 version(正整数):适配器 _coerce_profile_version 缺它
  直接抛 ValueError(实测踩到,事件立刻 failed)。

R4 读取层:写的集合与读的集合不是同一个 ——
  写(MilvusProfileProjection)用 "user_long_term_memory_v1",
  读(bootstrap.get_vector_memory_adapter)与删(projection_cleanup_service)
  却用 settings.milvus_collection = "jr_memory",而该集合从未被创建。
  ⇒ 召回:MilvusClient 构造不校验集合存在,适配器"构造成功"但每次 search
     抛异常被 VectorMemoryAdapter 吞成 degraded → 召回恒
     degraded_reasons=('milvus_unavailable',)、向量命中恒 0 条;
  ⇒ 清理:jr_memory 不在集合列表 → 走 vector_collection_absent 分支 →
     报清理成功但一个向量都没删,陈旧向量永久留存。
  修法:PROFILE_COLLECTION 成为唯一常量,读/删两侧直接引用它;
  并删除 Settings.milvus_collection 配置项、清掉 .env.example 的
  MILVUS_COLLECTION —— 写侧从来没读过它,一个只在契约一侧生效的配置项
  比没有配置项更危险(Settings 的 extra="ignore" 会让其他环境残留的该
  变量被安全忽略)。

顺带:
- 语义检索把客户过滤下推到 Milvus(filter="customer_id == N")。此前不带
  过滤,别家客户的命中会白占 limit 名额,稀释本客户的召回条数。
- upsert 在 sources 为空时先返回,不再无条件 load_collection ——
  "本来就没有可写内容"不该被记成投递失败(10001/10002 那两条事件即如此
  重试 5 次进死信)。

回归守卫:tests/unit/infrastructure/test_memory_vector_collection_consistency.py
  断言读侧与删侧用的都是 PROFILE_COLLECTION,且被删掉的配置项不得回归。
  这个缺陷能活下来,正是因为两侧单测全绿而接缝无人守。

验证(走生产装配、进程内调用,未重启你正在跑的 API 窗口):
  读侧集合打印 user_long_term_memory_v1(修前为 jr_memory);
  召回 degraded=False / reasons=() / sources 含 milvus,且排序随 query 语义
  变化(投资期限→horizon 0.288 > risk_level 0.201;风险偏好→risk_level 0.287);
  query="进取型" 双通道合并且 vector_score=0.9987;query=None 走 mysql 全量。
  pytest tests/unit tests/contract → 1432 passed, 2 skipped, 1 failed
  (唯一失败是同事正在改的投顾页面,与记忆链路无关)。

文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md(新增,四层根因+证据)、
  docs/37-记忆投影链路实现说明.md(补集合名三侧契约)。
This commit is contained in:
2026-09-14 21:26:03 +08:00
parent 3a1065ca1e
commit 0c642133d4
12 changed files with 515 additions and 11 deletions
@@ -0,0 +1,204 @@
# 记忆召回"恒空"根因与修复(第二轮)
> 承接 `记忆系统排查报告-2026-09-14.md`(第一轮:断头路 / 身份语义 / 引用校验)与
> `记忆系统修复文档-2026-09-14.md`(F1/F3/F2/F5)。
> 本轮目标是回答"**为什么语义召回一条数据都没有**",结论是**四个根因叠在一起**,
> 全部已修并留下可复现证据。技术细节见 `docs/37-记忆投影链路实现说明.md`。
---
## 〇、一句话结论
**"没数据"不是一件事,是四件事串在一起。** 四个根因分层:**投递层**(事件根本没产生)→
**门禁层**(产生了却被重试计数挡住)→ **写入层**(集合不存在 + 空内容被误判为失败)→
**读取层**(写进了 A 集合、从 B 集合查)。前三层修好后召回依然是空的,因为第四层
把读和写指向了两个不同的集合 —— **这是最隐蔽的一层,两侧单测全绿**。
| 你提的现象 | 根因 | 状态 |
|---|---|---|
| ① 语义召回(Milvus)没数据 | R1+R2+R3+R4(四层叠加) | ✅ **已修,端到端验证通过** |
| ② 员工身份召回恒空 | 授权范围语义未定义 | ❌ **未修 —— 需你拍板**(见 §4.1) |
| ③ 记忆更新传不到画像 | R1(重试计数把事件挡住) | ✅ **已修,有版本证据** |
---
## 一、四个根因
### R1(门禁层)重复聚合把 `retry_count` 刷到 1405,事件被永久过滤
- **现象**:`memory_sync_outbox` 里 40 条 `profile.rebuild_requested` 全部 `retry_count=1405`。
- **机制**:`episode_worker._persist()` 每次都调 `_touch_retry()`,**重复聚合也照样 +1**。
消费端只领 `retry_count < worker_retry_limit`(=3) 的事件 ⇒ 一旦超过 3 就**永远领不到,
且不会报错、不会进死信**(它连被领取的资格都没有)。
- **修法**:删掉 `_touch_retry` 及其调用点;补回归用例
`test_repeated_aggregation_never_bumps_retry_count`。存量数据已订正(40 行归零)。
- **证据**:修前 pending 40 → 修后 **0**。
### R2(投递层)`dispatch_profile_rebuild` **只有图投影,没有任何向量投递**
- **现象**:客户 9001 的记忆在 09-13、09-14 都更新过,而 `memory_sync_outbox` 里
**它一行都没有**(全表仅 4 行,是 10001/10002 开户测评的画像投影)。
- **机制**:该处置函数只调 `ProfileGraphProjectionService`(写 Neo4j),
**Milvus 侧完全没有投递代码** ⇒ 长期记忆的向量从未被写入过。
- **修法**:新增 `WorkerRuntime._enqueue_memory_vector_projection()`,在画像重建后投一条
`MemorySyncOutbox(target_store="milvus", aggregate_type="profile")`。走事件而非同步写,
是为了拿 Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
⚠️ payload 必须带 `version`(正整数):适配器的 `_coerce_profile_version` 缺它直接
抛 `ValueError`(实测踩到,事件立刻 failed)。
### R3(投递层)`episode_worker` 从不投 `profile.rebuild_requested`
- **机制**:抽取出记忆后没有触发画像重建 ⇒ 记忆进了 `memory_unit`,画像永远不动。
- **修法**:`record_evidence` 返回非空时投 `DomainEventOutbox(event_type="profile.rebuild_requested",
trigger="episode_extraction")`。
- **证据**:伪造一个片段跑消费,`extracted=[191]`,rebuild 事件 **3 → 4**。
### R4(读取层)**写的集合和读的集合不是同一个** ← 最隐蔽,也是最后一根稻草
| 侧 | 位置 | 集合名 |
|---|---|---|
| **写** | `MilvusProfileProjection.PROFILE_COLLECTION` | `user_long_term_memory_v1` ✅ |
| **读** | `bootstrap.get_vector_memory_adapter()` | `settings.milvus_collection` = **`jr_memory`** ❌ |
| **删** | `ProjectionCleanupService._cleanup_vector()` | 同上 ❌ |
`.env` 里 `MILVUS_COLLECTION=jr_memory`,而 **`jr_memory` 从未被创建**
(实测 `list_collections()` 只有 `fin_faq_collection` / `fin_policy_collection` /
`fin_product_collection` / `user_long_term_memory_v1`)。于是:
- **读**:`MilvusClient` 构造不校验集合存在 ⇒ 适配器"构造成功",每次 `search` 抛异常被
`VectorMemoryAdapter.search` 吞掉 → `degraded=True` → 召回**恒**
`degraded_reasons=('milvus_unavailable',)`、向量命中恒 0 条。
写侧日志一切正常,所以"写入成功却说没数据"看似矛盾,**实际是查错了集合**。
- **删**:`jr_memory` 不在集合列表 ⇒ 走 `vector_collection_absent` 分支 ⇒
**报"清理成功"但一个向量都没删**,陈旧向量永久留存。
**修法(三侧锁死成一个常量,把配置项删掉)**:
1. `PROFILE_COLLECTION` 成为唯一常量,注释写明"这里改就三侧一起改";
2. 读侧(`bootstrap.py`)、删侧(`projection_cleanup_service.py`)改为直接引用它;
3. **删除 `Settings.milvus_collection` 配置项**,并清掉 `.env` / `.env.example` 里的
`MILVUS_COLLECTION`:写侧从来没读过它,一个**只在契约一侧生效**的配置项比没有配置项
更危险。`Settings` 的 `extra="ignore"` 保证其他环境残留的该变量被安全忽略。
4. 回归守卫:`tests/unit/infrastructure/test_memory_vector_collection_consistency.py`。
**同批附带**:语义检索此前**不带客户过滤**,别家客户的命中会白占 `limit` 名额;
现在 `VectorMemoryAdapter.search(..., customer_id=N)` 会带
`filter="customer_id == N"` 下推到 Milvus。
### 附:两个"助推"因素(同批修掉)
- **向量集合本身不存在**:`user_long_term_memory_v1` 此前从未创建过,用
`python tools/setup_milvus_profile_collection.py` 建好(幂等,已存在只做结构比对)。
- **`upsert` 无条件先 `load_collection`**:集合不存在时抛错 ⇒ "**本来就没有可写内容**"
的事件被记成**投递失败**、重试 5 次进死信(实测 10001/10002 那两条就是这样死的)。
现改为 `sources` 为空时**先返回**,留给"零写入"与"没跑"可区分的日志。
---
## 二、端到端验证证据(2026-09-14)
### 2.1 记忆 → 画像(现象 ③)
`profile_snapshots`**版本号真的动了**:
**v7 @ 2026-09-10 13:57 → v10 @ 2026-09-14 13:06:57**,
`user_facts.preference:risk_level = "进取型"`(来自伪造的验证片段)。不是"跑了没报错",
是**库里的值变了**。
### 2.2 记忆 → 向量库(现象 ① 的写入侧)
集合 `user_long_term_memory_v1` 现有 2 行,都是客户 9001:
| memory_uuid | memory_key | content | version |
|---|---|---|---|
| `187e3b7c…` | `preference:risk_level` | 进取型 | 5 |
| `fdae7288…` | `preference:horizon` | 约三年 | 4 |
`memory_sync_outbox` 该客户的行**全部 `processed`**(清理掉 1 行验证残留后:
`[('milvus','processed',2), ('neo4j','processed',2)]`)。
### 2.3 语义召回(现象 ① 的读取侧)—— 走**生产装配**,不是测试替身
```powershell
& D:\conda\envs\jr_py313\python.exe -X utf8 - <脚本:build_memory_recall_service → recall()>
```
读侧集合打印为 `user_long_term_memory_v1`(修前是 `jr_memory`),召回结果:
| query | degraded | sources | 命中与排序 |
|---|---|---|---|
| `投资期限` | **False** | `['milvus']` | `horizon`(0.288) > `risk_level`(0.201) |
| `风险偏好` | **False** | `['milvus']` | `risk_level`(0.287) > `horizon`(0.154) |
| `三年` | **False** | `['milvus','mysql']` | `horizon` 双通道,**vector_score 0.810** |
| `进取型` | **False** | `['milvus','mysql']` | `risk_level` 双通道,**vector_score 0.9987** |
| `货币基金` | **False** | `['milvus']` | 正文不含该词 ⇒ 只有弱相关向量命中(0.255) |
| `None` | **False** | `['mysql']` | 结构化全量 2 条(conf 1.0 / 0.95) |
**判定**:`degraded=False`、`degraded_reasons=()`、**排序随 query 语义变化**、
双通道按 `memory_uuid` 正确合并 —— 这是真实语义信号,不是"接口不报错"。
### 2.4 回归
- `pytest tests/unit tests/contract` → **1432 passed, 2 skipped, 1 failed**
(唯一失败是同事正在改的投顾页面 `test_advisor_workspace_registers_documented_operation_endpoints`,
与记忆链路无关)。
- 新增/更新用例:集合名三侧同源守卫 4 条、召回客户过滤断言 1 条、
episode worker 重试计数回归 1 条(共 +4 通过)。
---
## 三、演示前必做(否则前端看到的仍是旧行为)
1. **重启 API 进程**:`get_vector_memory_adapter()` 是 `lru_cache` 且旧进程仍持有
`jr_memory` 那版代码,**不重启则接口依旧返回 `milvus_unavailable`**。
(我在验证时刻意用生产装配在进程内调用,没有动你正在跑的窗口。)
2. **确保常驻 Worker 在跑**:`python -X utf8 -m app.worker`。记忆抽取是
`EPISODE_INTERVAL_ROUNDS = 30` 轮一次的节奏,`--once` **碰不到** episode 链路。
3. 行情有效期只有 15 分钟,演示前先 `python tools/sync_market_prices.py`。
---
## 四、未修 / 待你决策
### 4.1 ⚠️ 员工身份召回恒空 —— **卡在你的一个决策上**
`governance.recall` 场景下员工查客户记忆恒空。**技术上已定位**:召回入口按"身份"取
客户范围,而**员工的可见范围没有权威定义**,所以要么查不到、要么可能越权。
修它需要你先定**授权口径**,二选一:
- **(A) 按 `sys_customer_assignment` 归属**:员工只能召回"分配给我"的客户。
语义最严(谁负责谁可见),但依赖分配表数据完整 —— 数据没维护就会什么都查不到。
- **(B) 按角色 `data_scope`**:如投顾可见其服务范围内全部客户。
更贴近真实组织,但需要 `data_scope` 有明确定义,越权面更大。
**我倾向 (A)**:金融场景"最小可见"优先,且它天然可审计。**你拍板后我再动手改。**
### 4.2 其他(不阻塞演示)
| 项 | 说明 |
|---|---|
| `EpisodeExtractionConsumer` 幂等哈希不覆盖记忆内容 | `snapshot_hash` 只算 `fin_customer_profile`+`fin_risk_assessment`,**"只有记忆变了"会被判定 `changed=False` 短路**,`_memory_sources()` 根本不执行。建议把 `memory_uuid+version` 纳入哈希,或拆成 `snapshot_changed`/`memory_changed` 两个判据。 |
| `ProjectionReconciliationService.mark_replay` 无调用方 | 有方法没入口(无 CLI / 无 Worker 接线),对账重放实际跑不起来。 |
| 投顾线两个生产者不发 `memory_sources` | `profile_governance_service` / `risk_questionnaire_service` 的 payload 仍缺该键(`docs/37` §8.2 已登记)。消费端有兜底所以不死信,但根治在投顾线。 |
| `today_profit_loss` 硬编码 0 | 业务决策项,见 `软件需求文档-2026-09-14.md` **Q22**。 |
---
## 五、本轮改动文件
**新增**
- `tests/unit/infrastructure/test_memory_vector_collection_consistency.py`(集合名三侧同源守卫)
- 本文件
**修改**
- `app/core/config.py`(**删除 `milvus_collection` 配置项**)
- `app/infrastructure/milvus_profile_projection.py`(集合名唯一常量 + 空内容早返回)
- `app/infrastructure/vector_memory.py`(`search()` 支持 `customer_id` 下推过滤)
- `app/service/memory_recall_service.py`(语义检索传 `customer_id`)
- `app/service/agent/bootstrap.py`(读侧改用 `PROFILE_COLLECTION`)
- `app/service/projection_cleanup_service.py`(删侧改用 `PROFILE_COLLECTION`)
- `app/worker/runtime.py`(`_enqueue_memory_vector_projection`)
- `app/worker/episode_worker.py`(去掉 `_touch_retry`;投 `profile.rebuild_requested`)
- `tests/unit/service/test_memory_recall_service.py`(断言客户过滤)
- `tests/unit/worker/test_episode_worker.py`(重试计数回归用例)
- `.env.example`(移除 `MILVUS_COLLECTION`)
- `docs/37-记忆投影链路实现说明.md`(补集合名契约与根因)