Files
group_fqcd_jr/app/service/memory_recall_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

317 lines
13 KiB
Python

"""组合召回:MySQL 权威召回 + 可选向量召回,Redis 只做可重建的加速层。
设计取舍(对应 P3 缺口 1):
1. `VectorMemoryAdapter` 只有"向量 → 命中"的能力,既不做文本向量化,也不回表。
而本项目没有 text-embedding 端点配置(`Settings` 里只有 Milvus 连接信息),
因此语义召回缺少"文本 → 向量"的第一步。这里不猜造 embedding:调用方须注入
`embed`(生产接模型网关),未注入时向量通道显式降级并记录原因,
MySQL 结构化召回照常返回。适配器侧补 `parse_hits`,把 pymilvus 的嵌套命中
折叠成可回表的 `VectorHit`,避免服务层去猜第三方的返回结构。
2. `MemoryCacheAdapter` 的 get/set 语义可用,但缺"按前缀删除",而客户级联失效
需要清缓存键,因此在适配器上新增 `delete(*keys)`;写入失败仍只返回 False。
"""
import hashlib
import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from typing import Any, Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.memory_cache import MemoryCacheAdapter
from app.infrastructure.vector_memory import VectorMemoryAdapter
from app.model.memory import MemoryUnit
from app.service.memory_service import MemoryService
logger = logging.getLogger(__name__)
CACHE_KEY_PREFIX = "mem:recall"
CACHE_TTL_SECONDS = 300
# 客户级缓存失效需要枚举出真实存在的键:limit 与查询摘要都由调用方决定,
# 因此把常用组合固化为常量,供 `cache_keys` 与生命周期服务共用。
COMMON_LIMITS = (5, 10, 20)
# 空查询与 None 归一化后同键,只保留一个代表值。
COMMON_QUERIES: tuple[str | None, ...] = (None,)
# 向量相似度在综合置信度中的权重上限:语义召回的置信度不得与结构化证据等价。
VECTOR_WEIGHT = 0.7
STRUCTURED_SOURCE = "mysql"
VECTOR_SOURCE = "milvus"
EmbeddingProvider = Callable[[str], Awaitable[list[float]]]
class CacheAdapter(Protocol):
async def get(self, key: str) -> Any: ...
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> bool: ...
async def delete(self, *keys: str) -> int: ...
@dataclass(frozen=True)
class RecallItem:
"""一条召回结果;`sources` 记录它来自哪些通道,`confidence` 是综合置信度。"""
memory_uuid: str
memory_key: str
content: str
memory_type: str
confidence: float
sources: tuple[str, ...]
evidence: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RecallResult:
customer_id: int
items: tuple[RecallItem, ...]
degraded: bool = False
degraded_reasons: tuple[str, ...] = ()
from_cache: bool = False
class MemoryRecallService:
"""组合召回入口;任何外部依赖(Milvus/Redis)故障都只降级,不阻塞主流程。"""
def __init__(
self,
session: AsyncSession,
*,
vector: VectorMemoryAdapter | None = None,
cache: MemoryCacheAdapter | CacheAdapter | None = None,
embed: EmbeddingProvider | None = None,
cache_ttl_seconds: int = CACHE_TTL_SECONDS,
) -> None:
self.session = session
self.memory = MemoryService(session)
self.vector = vector
self.cache = cache
self.embed = embed
self.cache_ttl_seconds = cache_ttl_seconds
async def recall(
self,
customer_id: int,
query: str | None = None,
*,
limit: int = 10,
use_cache: bool = True,
) -> RecallResult:
bounded = max(1, min(limit, 100))
reasons: list[str] = []
cache_key = self.cache_key(customer_id, query, bounded)
if use_cache:
cached, cache_degraded = await self._cache_read(cache_key)
if cached is not None:
return cached
if cache_degraded:
reasons.append("redis_unavailable")
structured = await self._structured(customer_id, query, bounded)
vector_items, vector_reasons = await self._vector(customer_id, query, bounded)
reasons.extend(vector_reasons)
merged = self._merge(structured, vector_items, bounded)
degraded = bool(reasons)
result = RecallResult(
customer_id=customer_id,
items=tuple(merged),
degraded=degraded,
degraded_reasons=tuple(dict.fromkeys(reasons)),
)
if use_cache and self.cache is not None:
await self._cache_write(cache_key, result)
return result
@staticmethod
def cache_key(customer_id: int, query: str | None, limit: int) -> str:
digest = hashlib.sha256((query or "").strip().lower().encode("utf-8")).hexdigest()[:16]
return f"{CACHE_KEY_PREFIX}:{customer_id}:{limit}:{digest}"
@classmethod
def cache_keys(cls, customer_id: int) -> list[str]:
"""某客户常见参数组合下的热缓存键;供客户级失效枚举使用。"""
return [
cls.cache_key(customer_id, query, limit)
for limit in COMMON_LIMITS
for query in COMMON_QUERIES
]
async def invalidate_cache(self, customer_id: int) -> int:
if self.cache is None:
return 0
return await self.cache.delete(*self.cache_keys(customer_id))
async def _structured(
self, customer_id: int, query: str | None, limit: int
) -> list[MemoryUnit]:
"""复用 `MemoryService.recall_with_decay`(含关键词过滤与时间衰减)。"""
return await self.memory.recall_with_decay(customer_id, query, limit=limit)
async def _vector(
self, customer_id: int, query: str | None, limit: int
) -> tuple[list[RecallItem], list[str]]:
if self.vector is None:
return [], []
text = (query or "").strip()
if not text:
return [], []
if self.embed is None:
# 缺少文本向量化能力时,语义通道不可用;这是配置缺口,不是运行时故障。
logger.warning("vector recall skipped: embedding provider not configured")
return [], ["embedding_unavailable"]
try:
embedding = await self.embed(text)
except Exception:
logger.warning("vector recall degraded: embedding failed")
return [], ["embedding_failed"]
if not embedding:
return [], ["embedding_failed"]
# 纵深防御:适配器契约上不抛异常,但召回服务自己对外承诺"外部依赖故障只降级、
# 不阻塞主流程",这条承诺不能寄托在适配器实现上。
try:
found = self.vector.search(embedding, limit=limit, customer_id=customer_id)
if found.degraded:
logger.warning("vector recall degraded: milvus unavailable collection=%s",
self.vector.collection)
return [], ["milvus_unavailable"]
hits = self.vector.parse_hits(found.hits)
except Exception:
logger.warning("vector recall degraded: vector backend raised collection=%s",
self.vector.collection)
return [], ["milvus_unavailable"]
if not hits:
return [], []
scores = {hit.memory_uuid: hit.score for hit in hits}
rows: list[RecallItem] = []
memories = await self._load_by_uuids(customer_id, list(scores))
for memory in memories:
score = scores.get(memory.memory_uuid, 0.0)
rows.append(self._item(
memory,
sources=(VECTOR_SOURCE,),
confidence=min(1.0, abs(score) * VECTOR_WEIGHT),
evidence={"vector_score": round(score, 6)},
))
return rows, []
async def _load_by_uuids(self, customer_id: int, uuids: list[str]) -> list[MemoryUnit]:
if not uuids:
return []
found = await self.session.scalars(
select(MemoryUnit).where(
MemoryUnit.customer_id == customer_id,
MemoryUnit.memory_uuid.in_(uuids),
MemoryUnit.status == "active",
)
)
return list(found)
def _merge(
self, structured: list[MemoryUnit], vector_items: list[RecallItem], limit: int
) -> list[RecallItem]:
"""以 memory_uuid 为身份合并:结构化结果保留,向量命中追加并取较高置信度。"""
merged: dict[str, RecallItem] = {}
for memory in structured:
item = self._item(memory, sources=(STRUCTURED_SOURCE,), confidence=float(
memory.confidence))
merged[item.memory_uuid] = item
for item in vector_items:
existing = merged.get(item.memory_uuid)
if existing is None:
merged[item.memory_uuid] = item
continue
merged[item.memory_uuid] = RecallItem(
memory_uuid=existing.memory_uuid,
memory_key=existing.memory_key,
content=existing.content,
memory_type=existing.memory_type,
confidence=max(existing.confidence, item.confidence),
sources=(STRUCTURED_SOURCE, VECTOR_SOURCE),
evidence={**existing.evidence, **item.evidence},
)
ordered = sorted(merged.values(), key=lambda item: item.confidence, reverse=True)
return ordered[:limit]
def _item(
self,
memory: MemoryUnit,
*,
sources: tuple[str, ...],
confidence: float,
evidence: dict[str, Any] | None = None,
) -> RecallItem:
return RecallItem(
memory_uuid=memory.memory_uuid,
memory_key=memory.memory_key,
content=memory.content,
memory_type=memory.memory_type,
confidence=round(min(1.0, max(0.0, confidence)), 6),
sources=sources,
evidence=evidence or {"mysql_confidence": float(memory.confidence)},
)
async def _cache_read(self, key: str) -> tuple[RecallResult | None, bool]:
"""返回 (命中结果, 是否降级);`degraded` 只在适配器区分出故障时为真。"""
if self.cache is None:
return None, False
try:
cached = await self.cache.get(key)
except Exception:
# 自定义适配器未按契约吞异常时,缓存故障仍不得阻塞主流程。
logger.warning("recall cache read degraded key=%s", key)
return None, True
if getattr(cached, "degraded", False):
logger.warning("recall cache read degraded key=%s", key)
return None, True
raw = getattr(cached, "value", None)
if raw is None:
return None, False
try:
payload = json.loads(raw if isinstance(raw, str) else str(raw))
items = tuple(
RecallItem(
memory_uuid=str(entry["memory_uuid"]),
memory_key=str(entry["memory_key"]),
content=str(entry["content"]),
memory_type=str(entry["memory_type"]),
confidence=float(entry["confidence"]),
sources=tuple(str(source) for source in entry["sources"]),
evidence=dict(entry.get("evidence") or {}),
)
for entry in payload["items"]
)
except (KeyError, TypeError, ValueError):
# 缓存格式不可信:按未命中处理并覆盖回填,绝不返回半解析结果。
logger.warning("recall cache payload ignored key=%s", key)
return None, False
# 降级状态必须与结果一起从缓存恢复,否则缓存命中会把"降级数据"伪装成正常结果。
return RecallResult(
customer_id=int(payload["customer_id"]),
items=items,
degraded=bool(payload.get("degraded")),
degraded_reasons=tuple(
str(reason) for reason in (payload.get("degraded_reasons") or ())
),
from_cache=True,
), False
async def _cache_write(self, key: str, result: RecallResult) -> bool:
if self.cache is None:
return False
payload = {
"customer_id": result.customer_id,
"degraded": result.degraded,
"degraded_reasons": list(result.degraded_reasons),
"items": [asdict(item) for item in result.items],
}
try:
return await self.cache.set(
key, json.dumps(payload, ensure_ascii=False), ttl_seconds=self.cache_ttl_seconds
)
except Exception:
logger.warning("recall cache write degraded key=%s", key)
return False