语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、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(补集合名三侧契约)。
317 lines
13 KiB
Python
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
|