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() 在代数失效之外顺带清空共享缓存实例, 失效正确性只依赖代数,清空仅为立即释放内存)。
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""检索结果缓存(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
|