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

语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、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
@@ -27,6 +27,15 @@ from app.core.errors import RecoverableAgentError
logger = logging.getLogger(__name__)
#: 长期记忆向量集合名的**唯一真相**(写、读、删三侧共用,见下方说明),不是"某处默认值"。
#:
#: 2026-09-14 故障复盘:本常量与召回/清理两侧读的 `settings.milvus_collection`
#: (`jr_memory`,**该集合从未被创建**)不一致,于是
#: ① 语义召回每次 search 都抛异常 → 降级 `milvus_unavailable` → 召回恒空;
#: ② 记忆删除走 `vector_collection_absent` → 报清理成功但一个向量都没删。
#: 两侧各自单测全绿,接缝没人守。现约定:**这里改,就三侧一起改**
#: (`app/service/agent/bootstrap.py`、`app/service/projection_cleanup_service.py`),
#: 并由 `tests/unit/infrastructure/test_memory_vector_collection_consistency.py` 守住。
PROFILE_COLLECTION = "user_long_term_memory_v1"
VECTOR_DIM = 1024
@@ -60,6 +69,23 @@ class MilvusProfileProjection:
async def upsert(self, payload: dict[str, Any]) -> None:
customer_id, profile_version, sources = self._normalize(payload)
if not sources:
# ⚠️ 零可投影记忆时**直接返回**,不要先去 `load_collection`。
#
# `load_collection` 在集合不存在或未加载时会抛错,客户端把它包装成
# `RecoverableAgentError`(`milvus_profile_vector_client.py:40-47`
# 「画像向量集合不可用」)。而 `upsert` 此前**无条件**在 embed 循环之前调它,
# 于是"本来就没有可写内容"的事件也被记成投递失败、重试 5 次进死信。
#
# 实测正是这样:10001 / 10002 开户风险测评投的两条画像事件,
# `memory_unit` 里没有它们的记忆(`_with_memory_sources` 兜底查出 0 条),
# 在 load_collection 那一步就死了 —— 而 `user_long_term_memory_v1`
# 当时根本不存在。**"没东西可写"不该等同于"写失败"**。
logger.info(
"profile projection skipped: no projectable memory customer_id=%s",
customer_id,
)
return
load_collection = getattr(self._client, "load_collection", None)
if load_collection is not None:
await load_collection(collection_name=self._collection)