Files
meida_backend/tests/unit/test_retrieval_cache.py
T
xsl a044ea2e47 feat(retrieval): TTL+LRU result cache with generation-based invalidation
TtlCache(全输入进键/惰性过期/容量逐出/命中零 DB)+ search 接线
(空结果不缓存,保住 gap 埋点)+ invalidate_cache 真实现(T-P2-10)。

实现:
- packages/retrieval/cache.py(新建):CacheKey 全输入进键(query/body_shape/
  occasion/entity_types 排序归一/rule_boost 排序序列化哈希/top_k/失效代数);
  TtlCache 惰性过期 + OrderedDict LRU(超限 popitem 最旧)+ hits/misses/
  evictions 计数与 stats();clock 可注入(单测免 sleep,默认 monotonic);
  asyncio 单循环不加锁的前提写在模块 docstring;正确性红线(只缓存不可变
  值、命中零 DB)同处。纯逻辑,不 import 任何项目内模块(值类型由 service
  以 PEP 695 泛型注入,避免循环依赖)。
- packages/retrieval/service.py:__init__ 加 keyword-only cache=None(默认
  禁用,行为与现状一致,既有调用点不受影响);from_settings 按 Settings
  装配进程级共享缓存(retrieval_cache_enabled=False → None;TTL/容量变化
  时重建);search() 在 embed 之前查缓存——命中直接返回(零 DB、零 embed),
  写入与命中都做防御性拷贝(results 是 list,调用方拿不到缓存内引用),
  仅有结果时写缓存(空结果不缓存,record_gap 每次都跑);invalidate_cache()
  签名不变:自增 repo.INVALIDATION_VERSION(代数失效,旧代键永不命中,
  与 T12 的契约保持)+ 清空共享实例(仅为立即回收内存,失效正确性只
  依赖代数)。repo.py 未做任何改动。

测试:
- 单测 tests/unit/test_retrieval_cache.py(21 例,fake repo/embedding +
  注入时钟,不连库):同参二连查零 DB 且结果相等、键分量矩阵(query/
  occasion/body_shape/entity_types/rule_boost/top_k 任一变化 → miss)、
  TTL 过期边界(60.0 过期/59.9 命中)、LRU 逐出与命中率/计数、空结果
  不缓存且 gap 埋点每次触发、invalidate_cache() 代数失效(含自增语义
  保留断言)、cache=None 与现状一致、from_settings 装配开关与共享实例、
  rule_boost 哈希确定性。全量单测 494 passed(基线 473 + 新增 21)。
- 集成 tests/integration/test_retrieval_cache.py(2 例,真实 PG + pgvector):
  发布新实体 + invalidate_cache() 后同参检索立即可见(失效前缓存仍只吐
  旧实体,证明缓存在工作——T-P2-10 验收语义);不失效、TTL=1s 到期
  (asyncio.sleep 1.1s)后新实体自然可见(双路径验收)。集成全量 40 passed。

偏差说明:无(invalidate_cache() 在代数失效之外顺带清空共享缓存实例,
失效正确性只依赖代数,清空仅为立即释放内存)。
2026-08-14 21:16:51 +08:00

360 lines
13 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.
"""检索结果缓存(TtlCache + search 接线):repo/embedding 全打桩,不连库.
覆盖任务文档 §4 的七组断言:命中零 DB、键分量矩阵、TTL 过期、LRU
逐出、空结果不缓存(gap 埋点不被吞)、代数失效、cache=None 一致性;
外加 rule_boost 哈希的确定性与 from_settings 的装配开关。
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from packages.retrieval import repo
from packages.retrieval import service as service_mod
from packages.retrieval.cache import CacheKey, TtlCache, rule_boost_hash
from packages.retrieval.rerank import ScoredChunk
from packages.retrieval.service import RetrievalService, UserContext
from packages.shared.config import Settings
class FakeEmbedder:
"""满足 EmbeddingProvider Protocol 的最小桩:记录调用,返回固定向量."""
model_name = "fake"
version = "v1"
dimensions = 4
def __init__(self) -> None:
self.calls: list[list[str]] = []
async def embed(self, texts: list[str]) -> list[list[float]]:
self.calls.append(list(texts))
return [[0.25, 0.25, 0.25, 0.25] for _ in texts]
class FakeSession:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class FakeClock:
"""可手动拨动的单调时钟:TTL 断言不靠真 sleep,边界精确到微秒."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
@pytest.fixture
def stubbed_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
"""repo 的两个 IO 函数换成计数桩:``search_calls`` 就是「DB 交互次数」."""
calls: dict[str, Any] = {"search_calls": 0, "gaps": [], "results": []}
async def fake_hybrid_search(
session: Any, query_vec: Any, query_text: str, **kwargs: Any
) -> list[ScoredChunk]:
calls["search_calls"] += 1
return list(calls["results"])
async def fake_record_gap(session: Any, query_text: str, filter_ctx: Any) -> None:
calls["gaps"].append((query_text, filter_ctx))
monkeypatch.setattr(repo, "hybrid_search", fake_hybrid_search)
monkeypatch.setattr(repo, "record_gap", fake_record_gap)
return calls
@pytest.fixture(autouse=True)
def _hermetic_module_state() -> Iterator[None]:
"""用例结束还原模块级全局(失效代数、共享缓存实例),不污染同进程其它用例."""
saved_version = repo.INVALIDATION_VERSION
yield
repo.INVALIDATION_VERSION = saved_version
service_mod._shared_cache = None
def _scored(chunk_id: int = 1) -> ScoredChunk:
return ScoredChunk(
chunk_id=chunk_id,
entity_id=100,
chunk_type="text",
text="chunk",
vec_score=1.0,
kw_score=0.0,
rule_score=0.0,
freshness_score=1.0,
final_score=0.55,
)
def _key(query_text: str = "q", **overrides: Any) -> CacheKey:
defaults: dict[str, Any] = {
"version": 1,
"query_text": query_text,
"top_k": 5,
"body_shape": None,
"occasion": None,
"entity_types": (),
"rule_boost": None,
}
defaults.update(overrides)
return CacheKey.build(**defaults)
def _svc(cache: TtlCache[Any] | None = None, **kwargs: Any) -> RetrievalService:
return RetrievalService(FakeSession(), embedding=FakeEmbedder(), cache=cache, **kwargs)
# --- 1. 同参数二连查:命中零 DB ------------------------------------------------
async def test_repeated_search_hits_cache_with_zero_db(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = [_scored(1), _scored(2)]
embedder = FakeEmbedder()
cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16)
svc = RetrievalService(FakeSession(), embedding=embedder, cache=cache)
first = await svc.search("面试穿什么")
second = await svc.search("面试穿什么")
assert first == second
assert [c.chunk_id for c in second.results] == [1, 2]
assert stubbed_repo["search_calls"] == 1 # 第二次零 DB
assert embedder.calls == [["面试穿什么"]] # 第二次连 embed 都跳过
assert cache.stats() == {"hits": 1, "misses": 1, "evictions": 0, "entries": 1}
# 返回值是缓存内容的拷贝:调用方原地改 list(哪怕改的是命中返回的对象)
# 也不会污染缓存(红线 2)
second.results.append(_scored(99))
third = await svc.search("面试穿什么")
assert [c.chunk_id for c in third.results] == [1, 2]
# --- 2. 键分量矩阵:任一影响结果的输入变化 → miss -----------------------------
@pytest.mark.parametrize(
("label", "query", "ctx", "top_k"),
[
("query_text", "另一个完全不同的问题", None, None),
("occasion", None, UserContext(occasion="wedding"), None),
("body_shape", None, UserContext(body_shape="pear"), None),
("entity_types", None, UserContext(entity_types=("item",)), None),
("rule_boost", None, UserContext(rule_boost={7: 0.5}), None),
("top_k", None, None, 10),
],
)
async def test_any_key_component_change_is_a_miss(
stubbed_repo: dict[str, Any],
label: str,
query: str | None,
ctx: UserContext | None,
top_k: int | None,
) -> None:
stubbed_repo["results"] = [_scored(1)]
cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16)
svc = _svc(cache)
await svc.search("面试穿什么") # 填充缓存
if top_k is not None:
# top_k 是 service 级参数:同缓存、不同 top_k 的另一个实例
await _svc(cache, top_k=top_k).search("面试穿什么")
else:
await svc.search(query or "面试穿什么", user_ctx=ctx)
assert stubbed_repo["search_calls"] == 2, label # 键不同 → 重新检索
assert cache.misses == 2 and cache.hits == 0, label
assert cache.stats()["entries"] == 2, label # 两个键并存,互不覆盖
# --- 3. TTL 过期 → miss(注入 clock)------------------------------------------
async def test_ttl_expiry_is_a_miss_then_refill(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = [_scored(1)]
clock = FakeClock()
cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16, clock=clock)
svc = _svc(cache)
await svc.search("q")
clock.advance(60.0) # 恰好到达 TTL 边界:>= ttl 视作过期
await svc.search("q")
assert stubbed_repo["search_calls"] == 2
assert cache.misses == 2 and cache.hits == 0
clock.advance(59.9) # 重新填充后未到期 → 命中
await svc.search("q")
assert stubbed_repo["search_calls"] == 2
assert cache.stats() == {"hits": 1, "misses": 2, "evictions": 0, "entries": 1}
# --- 4. LRU 容量超限 → 最旧被逐、计数正确 --------------------------------------
def test_lru_evicts_oldest_beyond_capacity() -> None:
cache: TtlCache[int] = TtlCache(ttl_seconds=60.0, max_entries=2)
cache.put(_key("a"), 1)
cache.put(_key("b"), 2)
cache.put(_key("c"), 3) # 超限:最旧的 a 被逐
assert cache.get(_key("a")) is None
assert cache.get(_key("b")) == 2
assert cache.stats() == {"hits": 1, "misses": 1, "evictions": 1, "entries": 2}
def test_lru_recency_updates_on_hit() -> None:
"""命中会把条目移到"最新"端:之后超限逐出的是另一个最旧条目."""
cache: TtlCache[int] = TtlCache(ttl_seconds=60.0, max_entries=2)
cache.put(_key("a"), 1)
cache.put(_key("b"), 2)
assert cache.get(_key("a")) == 1 # a 变为最新
cache.put(_key("c"), 3) # 逐出的应是 b,不是 a
assert cache.get(_key("b")) is None
assert cache.get(_key("a")) == 1
assert cache.evictions == 1
def test_ttl_cache_rejects_invalid_params() -> None:
with pytest.raises(ValueError, match="ttl_seconds"):
TtlCache(ttl_seconds=0, max_entries=8)
with pytest.raises(ValueError, match="max_entries"):
TtlCache(ttl_seconds=60.0, max_entries=0)
# --- 5. 空结果不缓存:gap 埋点每次都跑 -----------------------------------------
async def test_empty_results_are_not_cached(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = []
session = FakeSession()
cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16)
svc = RetrievalService(session, embedding=FakeEmbedder(), cache=cache)
first = await svc.search("不存在的知识点")
second = await svc.search("不存在的知识点")
assert first.degraded and second.degraded
assert stubbed_repo["search_calls"] == 2 # 每次都真检索
assert len(stubbed_repo["gaps"]) == 2 # gap 埋点不被缓存吞掉
assert cache.stats() == {"hits": 0, "misses": 2, "evictions": 0, "entries": 0}
assert session.commits == 2 # 每次埋点都照常提交
# --- 6. invalidate_cache() 后同参数 → miss(代数失效)-------------------------
async def test_invalidate_cache_invalidates_by_generation(
stubbed_repo: dict[str, Any],
) -> None:
stubbed_repo["results"] = [_scored(1)]
cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16)
svc = _svc(cache)
await svc.search("面试穿什么")
await svc.search("面试穿什么")
assert stubbed_repo["search_calls"] == 1 # 命中
service_mod.invalidate_cache()
await svc.search("面试穿什么")
assert stubbed_repo["search_calls"] == 2 # 代数失效 → 重新检索
await svc.search("面试穿什么")
assert stubbed_repo["search_calls"] == 2 # 新代重新缓存,再次命中
def test_invalidate_cache_bumps_generation(stubbed_repo: dict[str, Any]) -> None:
"""自增 repo.INVALIDATION_VERSION 的既有语义保留(T12 依赖该契约)."""
before = repo.INVALIDATION_VERSION
service_mod.invalidate_cache()
assert before + 1 == repo.INVALIDATION_VERSION
# --- 7. cache=None → 行为与现在完全一致 ----------------------------------------
async def test_cache_none_searches_db_every_time(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = [_scored(1)]
embedder = FakeEmbedder()
svc = RetrievalService(FakeSession(), embedding=embedder) # 不传 cache
first = await svc.search("面试穿什么")
second = await svc.search("面试穿什么")
assert first == second
assert stubbed_repo["search_calls"] == 2
assert len(embedder.calls) == 2
async def test_cache_none_degraded_path_records_gap_every_time(
stubbed_repo: dict[str, Any],
) -> None:
stubbed_repo["results"] = []
svc = RetrievalService(FakeSession(), embedding=FakeEmbedder())
await svc.search("问不到")
await svc.search("问不到")
assert stubbed_repo["search_calls"] == 2
assert len(stubbed_repo["gaps"]) == 2
# --- rule_boost 哈希:确定性(排序归一 + 不同值不折叠)------------------------
def test_rule_boost_hash_is_insertion_order_insensitive() -> None:
d1 = {1: 0.5, 2: 0.3}
d2 = {2: 0.3}
d2[1] = 0.5
assert rule_boost_hash(d1) == rule_boost_hash(d2)
a = {1: 0.5}
a[2] = 0.3
b = {2: 0.3}
b[1] = 0.5
assert CacheKey.build(version=1, query_text="q", top_k=5, rule_boost=a) == (
CacheKey.build(version=1, query_text="q", top_k=5, rule_boost=b)
)
def test_rule_boost_hash_distinguishes_values_and_empty() -> None:
assert rule_boost_hash({}) != rule_boost_hash({1: 0.0})
assert rule_boost_hash({1: 0.5}) != rule_boost_hash({1: 0.6})
def test_entity_types_order_does_not_change_key() -> None:
"""``= ANY(...)`` 与顺序无关:同集合不同顺序应共享同一条目."""
assert _key(entity_types=("item", "style")) == _key(entity_types=("style", "item"))
# --- from_settings 装配:按 Settings 开/关与参数化 -----------------------------
def test_from_settings_disabled_yields_no_cache(stubbed_repo: dict[str, Any]) -> None:
settings = Settings(_env_file=None, retrieval_cache_enabled=False)
svc = RetrievalService.from_settings(FakeSession(), settings)
assert svc._cache is None
def test_from_settings_attaches_shared_cache_with_settings_params(
stubbed_repo: dict[str, Any],
) -> None:
settings = Settings(
_env_file=None,
retrieval_cache_ttl_seconds=7,
retrieval_cache_max_entries=9,
)
first = RetrievalService.from_settings(FakeSession(), settings)
assert first._cache is not None
assert first._cache.ttl_seconds == 7
assert first._cache.max_entries == 9
# 每请求实例共享同一缓存:跨请求复用是缓存的意义所在
second = RetrievalService.from_settings(FakeSession(), settings)
assert second._cache is first._cache
# TTL/容量变化 → 按新配置重建(进程内配置恒定,重建只在测试/热改配置发生)
changed = Settings(_env_file=None, retrieval_cache_ttl_seconds=1, retrieval_cache_max_entries=9)
third = RetrievalService.from_settings(FakeSession(), changed)
assert third._cache is not None
assert third._cache is not first._cache
assert third._cache.ttl_seconds == 1