# T14 · 检索结果缓存与发布失效 > 对应任务:`T-P2-10` | 需求:发布后检索缓存自然失效(Sprint 2 验收行)| 分支:`task/t14-retrieval-cache` | worktree:`/home/xsl/meida_backend-t14` > 先读《README.md(协作规程)》,遵守全部纪律。 ## 1. 目标 给 `RetrievalService.search` 加**进程内结果缓存**(TTL + 容量上限 + 代数失效),把协调提交里的 `invalidate_cache()` 桩变成真实现。缓存解决的是"同一 query 高频重复检索"(Agent 多轮、黄金集回归);失效保证知识发布后**不吐旧结果**。 ## 2. 已就绪的契约(协调提交 `9c18021` 已提供) - **配置**(config.py 已加,.env.example 已示例):`retrieval_cache_enabled=True`、`retrieval_cache_ttl_seconds=60`、`retrieval_cache_max_entries=512`。 - **钩子已存在**:`packages/retrieval/service.py` 模块级 `invalidate_cache()`(当前只是 `repo.INVALIDATION_VERSION += 1`)与 `packages/retrieval/repo.py` 的 `INVALIDATION_VERSION = 1`——运营后台(T12)已经在调它。**你实现真缓存,不改钩子签名**。 - `RetrievalService.__init__(session, *, embedding, weights, top_k)` 与 `from_settings(session, settings)`——缓存参数经 `from_settings` 读 Settings 注入(**不改 `__init__` 既有签名**,加带默认值的 keyword-only 参数)。 ## 3. 实现要点 允许改动:`packages/retrieval/cache.py`(新建)、`packages/retrieval/service.py`(缓存接线)、测试文件。**不改 repo.py 的既有函数与 INVALIDATION_VERSION 语义**。 1. **`packages/retrieval/cache.py`**(纯逻辑,重点单测): - `CacheKey`:`(version, query_text, body_shape, occasion, entity_types, rule_boost_hash, top_k)`——**全部影响结果的输入都必须进键**,缺一个就会串结果(rule_boost 是 dict[int,float],排序后序列化再哈希); - `TtlCache`:容量上限用**简单 LRU**(`OrderedDict` 移动末尾 + 超限 popitem(last=False));TTL 用惰性过期(读时比对时间戳,过期视作 miss);**线程安全不需要**(asyncio 单循环,无 await 竞态窗口——注释写明这个前提,别加锁); - 指标:`hits`/`misses`/`evictions` 计数器(Prometheus 可后续接,先暴露属性)+ `stats()` 方法。 2. **service.py 接线**: - `RetrievalService` 增加 `cache: TtlCache | None`(None = 禁用,行为与现在完全一致); - `search()`:`cache` 非 None 且非 `degraded` 路径——命中直接返回缓存 `SearchOutcome`;未命中照常检索后**只在有结果时**写缓存(空结果不缓存:`record_gap` 每次都要跑,否则缺口计数被缓存吞掉); - `from_settings` 按 Settings 装配缓存;**模块级 `invalidate_cache()`**:把 service 模块持有的共享缓存实例(或 repo.INVALIDATION_VERSION,二选一,选实现更干净的那个)作废——调用方(admin 发布编排)不需要知道细节。 3. **正确性红线**(写进 cache.py docstring):缓存的是 `SearchOutcome`(frozen dataclass,不可变)——**绝不缓存可变对象引用**;命中路径不得复用 session(缓存命中时 DB 零交互)。 ## 4. 测试要求 - 单测 `tests/unit/test_retrieval_cache.py`(fake repo/embedding,不连库): 1. 同参数二连查:第二次零 DB 调用(fake 计数断言)、结果相等; 2. 任一键分量变化(query/occasion/body_shape/rule_boost/top_k)→ miss; 3. TTL 过期 → miss(注入 clock); 4. LRU 容量超限 → 最旧被逐、计数正确; 5. 空结果不缓存(degraded 路径每次都触发 gap 记录 fake 调用); 6. `invalidate_cache()` 后同参数 → miss(代数失效); 7. cache=None → 行为与现在一致。 - 集成 `tests/integration/test_retrieval_cache.py`(新文件): 1. seed 实体 + chunk → search 命中 → **发布新实体(直接调 kb service 或 SQL 置 published)+ `invalidate_cache()`** → 再 search 能看到新实体(**这是 T-P2-10 的验收语义**); 2. 不失效等 TTL 过期(TTL 配 1s,sleep 1.1s)→ 新实体同样可见(双保险)。 ## 5. 禁改清单 通用禁改之外:`packages/retrieval/repo.py` 的既有函数、`packages/{kb,rules}/**`、`apps/**`、所有已有测试文件。T12 会调 `invalidate_cache()`——**签名不变**是你与它的契约。 ## 6. 验收标准(T-P2-10) 1. 同参重复检索零 DB 交互(单测断言); 2. 发布后新内容立即可见(invalidate 路径)且 TTL 到期自然可见(双路径集成测试); 3. 影响结果的输入全量进缓存键(rule_boost 哈希); 4. 空结果不缓存(gap 埋点不被吞); 5. 全部检查绿(mypy 范围照 T6 文档同口径 + `packages/retrieval`)。 ## 7. 提交信息 ``` feat(retrieval): TTL+LRU result cache with generation-based invalidation TtlCache(全输入进键/惰性过期/容量逐出/命中零 DB)+ search 接线 (空结果不缓存,保住 gap 埋点)+ invalidate_cache 真实现(T-P2-10)。 偏差说明:<如有写在这里,无则写"无"> ```