Files
group_fqcd_jr/app/service/projection_cleanup_service.py
T
lzf_0626 0c642133d4 修复长期记忆向量链路:投影入队 + 集合名三侧同源 + 召回按客户过滤
语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、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(补集合名三侧契约)。
2026-09-14 21:26:03 +08:00

142 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""投影清理:记忆失效或删除时,清掉它在图库与向量库里的派生数据。
**为什么以画像为准,而不是按标识直接删边**:一条记忆可能对应多条派生边(同一 key 的
证据累积会让 tag 取值变化),直接按 tag_key 删容易删不干净或误删。这里走
「删掉该记忆对应的长期事实 → 重建画像 → 对账修复」,让投影跟着权威源收敛——
最终图里剩下的内容必然与画像一致,而不依赖"我记得这条记忆当初投影成了什么"。
**Milvus 向量**按 `memory_uuid` 删除;集合不存在或客户端不可用时**如实报告未清理**,
绝不伪造成功。这与 `_cleanup_projection` 的审计语义一致:审计只记录适配器返回的真实结论。
"""
import logging
from dataclasses import dataclass
from sqlalchemy import delete, select
from app.core.config import get_settings
from app.infrastructure.db import SessionFactory
from app.infrastructure.milvus_profile_projection import PROFILE_COLLECTION
from app.model.memory import MemoryUnit
from app.model.profile import UserFact
from app.service.profile_assembly_service import ProfileAssemblyService
from app.service.profile_graph_projection_service import ProfileGraphProjectionService
from app.service.relationship_service import RelationshipService
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProjectionCleanupOutcome:
"""与 `app/worker/runtime.py` 的同名结构保持字段一致(该处只按属性取用)。
`cleaned=True` 表示派生数据已确实清理;为假时 `detail` 必须说明真实缺口,
审计会原样记录它。
"""
cleaned: bool
detail: str = ""
class ProjectionCleanupService:
def __init__(self, relationships: RelationshipService | None = None) -> None:
self.relationships = relationships
async def cleanup(self, *, memory_uuid: str, operation: str) -> ProjectionCleanupOutcome:
del operation # 失效与删除对投影的处理一致:都以权威源为准重建
async with SessionFactory() as session:
memory = await session.scalar(
select(MemoryUnit).where(MemoryUnit.memory_uuid == memory_uuid)
)
if memory is None:
# 记忆已经不在库里:投影清理的目标已消失,视为完成(幂等)
return ProjectionCleanupOutcome(True, "memory_not_found")
customer_id = int(memory.customer_id)
fact_key = str(memory.memory_key)
# 该记忆已失效/删除,它对应的事实不应再参与画像
async with SessionFactory() as session, session.begin():
await session.execute(delete(UserFact).where(
UserFact.customer_id == customer_id, UserFact.fact_key == fact_key
))
# 以剩余证据重建画像,再让图跟着画像收敛
details: list[str] = []
try:
async with SessionFactory() as session, session.begin():
await ProfileAssemblyService(session).rebuild(customer_id)
details.append("profile_rebuilt")
except Exception:
logger.warning("profile rebuild failed during cleanup customer_id=%s",
customer_id, exc_info=True)
details.append("profile_rebuild_failed")
graph_ok = await self._cleanup_graph(customer_id, details)
vector_ok = await self._cleanup_vector(memory_uuid, details)
return ProjectionCleanupOutcome(
cleaned=graph_ok and vector_ok and "profile_rebuild_failed" not in details,
detail="; ".join(details),
)
async def _cleanup_graph(self, customer_id: int, details: list[str]) -> bool:
if self.relationships is None:
details.append("graph_client_unavailable")
return False
try:
async with SessionFactory() as session:
outcome = await ProfileGraphProjectionService(
session, self.relationships
).reconcile_customer(customer_id, repair=True)
except Exception:
logger.warning("graph cleanup failed customer_id=%s", customer_id, exc_info=True)
details.append("graph_cleanup_failed")
return False
if outcome.degraded:
details.append(f"graph_degraded:{outcome.reason}")
return False
if not outcome.consistent:
# repair 之后仍不一致:如实报告,不写成清理成功
details.append(
f"graph_still_inconsistent:missing={len(outcome.missing)},"
f"orphaned={len(outcome.orphaned)}"
)
return False
details.append("graph_cleaned")
return True
async def _cleanup_vector(self, memory_uuid: str, details: list[str]) -> bool:
"""删除该记忆的向量。集合不存在(未启用语义召回)时视为无需清理。"""
settings = get_settings()
if not settings.milvus_uri:
details.append("vector_store_not_configured")
return True
try:
from pymilvus import MilvusClient # type: ignore[import-untyped]
client = MilvusClient(
uri=settings.milvus_uri, token=settings.milvus_token or None
)
except Exception:
logger.warning("milvus client unavailable during cleanup", exc_info=True)
details.append("vector_client_unavailable")
return False
try:
# 集合名与投影写入路径**同一个常量**(`PROFILE_COLLECTION`)。此前读的是
# 已删除的 `settings.milvus_collection`(`jr_memory`),该集合不存在
# ⇒ 这里每次都走下面的 `vector_collection_absent` 分支、
# **报清理成功但一个向量都没删**,陈旧向量永久留存。
if PROFILE_COLLECTION not in set(client.list_collections()):
# 记忆向量集合尚未启用:没有需要清理的派生数据
details.append("vector_collection_absent")
return True
client.delete(
collection_name=PROFILE_COLLECTION,
filter=f'memory_uuid == "{memory_uuid}"',
)
details.append("vector_cleaned")
return True
except Exception:
logger.warning("milvus delete failed memory_uuid=%s", memory_uuid, exc_info=True)
details.append("vector_delete_failed")
return False