Files
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

205 lines
8.6 KiB
Python
Raw Permalink 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.
"""检索编排:embed → 混合检索 →(无结果时)缺口埋点(架构 §8.3).
只做编排不做策略:模型、权重、top_k 全部来自 Settings;``rule_boost`` 的
生产者(规则引擎联动)属后续任务,这里只认接口。检索是内部能力,
没有 HTTP 路由 —— 被 agent / report 的 service 调用。
T-P2-10:有结果的检索进进程内缓存(TTL+LRU,见 cache.py),空结果
不缓存(gap 埋点每次都要跑);发布/归档后调 :func:`invalidate_cache`
整体失效。
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from packages.retrieval import repo
from packages.retrieval.cache import CacheKey, TtlCache
from packages.retrieval.embedding import EmbeddingProvider, build_embedding_provider
from packages.retrieval.rerank import DEFAULT_RERANK_WEIGHTS, ScoredChunk
from packages.shared.config import Settings
from packages.shared.logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class UserContext:
"""调用方给的检索上下文(用户画像/场景),驱动硬过滤与规则加权。
``body_shape`` 是禁忌过滤的输入:命中 ``avoid_body_shapes`` 的实体
绝不出现(FR-KB-06),而不是排得靠后。
"""
body_shape: str | None = None
occasion: str | None = None
entity_types: tuple[str, ...] = ()
rule_boost: Mapping[int, float] = field(default_factory=dict)
def filter_ctx(self) -> dict[str, Any]:
"""落进 kb_gap 的过滤条件快照:缺口是在什么条件下出现的。"""
return {
"body_shape": self.body_shape,
"occasion": self.occasion,
"entity_types": list(self.entity_types),
}
@dataclass(frozen=True, slots=True)
class SearchOutcome:
"""一次检索的结果。``degraded`` 表示无结果并已记入 kb_gap(T-P2-06)。"""
results: list[ScoredChunk]
degraded: bool
# --- 进程级共享缓存(T-P2-10)-------------------------------------------------
#: RetrievalService 每请求新建(各自绑定 session),但缓存的价值在**跨请求**
#: 复用 —— 实例必须模块级共享。惰性创建;Settings 的 TTL/容量变化时重建
#: (进程内配置恒定,重建只发生在测试或热改配置的场景)。
_shared_cache: TtlCache[SearchOutcome] | None = None
def _get_shared_cache(settings: Settings) -> TtlCache[SearchOutcome] | None:
"""按 Settings 返回共享缓存;``retrieval_cache_enabled=False`` → None(禁用)."""
global _shared_cache
if not settings.retrieval_cache_enabled:
return None
if (
_shared_cache is None
or _shared_cache.ttl_seconds != settings.retrieval_cache_ttl_seconds
or _shared_cache.max_entries != settings.retrieval_cache_max_entries
):
_shared_cache = TtlCache(
ttl_seconds=settings.retrieval_cache_ttl_seconds,
max_entries=settings.retrieval_cache_max_entries,
)
return _shared_cache
class RetrievalService:
"""混合检索的编排入口(嵌入 + 检索 + 缺口埋点)."""
def __init__(
self,
session: AsyncSession,
*,
embedding: EmbeddingProvider,
weights: Mapping[str, float] | None = None,
top_k: int = 50,
cache: TtlCache[SearchOutcome] | None = None,
) -> None:
self._session = session
self._embedding = embedding
self._weights: dict[str, float] = (
dict(weights) if weights is not None else dict(DEFAULT_RERANK_WEIGHTS)
)
self._top_k = top_k
# None = 禁用缓存,行为与没有缓存时完全一致(默认值,既有调用点不受影响)。
self._cache = cache
@classmethod
def from_settings(cls, session: AsyncSession, settings: Settings) -> RetrievalService:
"""从 Settings 装配(模型、权重、top_k、缓存一处配置,换模型不发版)."""
return cls(
session,
embedding=build_embedding_provider(settings),
weights=settings.retrieval_rerank_weights,
top_k=settings.retrieval_top_k,
cache=_get_shared_cache(settings),
)
async def search(
self, query_text: str, *, user_ctx: UserContext | None = None
) -> SearchOutcome:
ctx = user_ctx or UserContext()
cache, key = self._cache, None
if cache is not None:
# 全输入进键(含 rule_boost 哈希与失效代数)——缺一个分量就会
# 把 A 条件的结果串给 B 条件(cache.py 红线 1)。命中时 embed
# 与 DB 全部跳过:缓存命中 = DB 零交互。
key = CacheKey.build(
version=repo.INVALIDATION_VERSION,
query_text=query_text,
body_shape=ctx.body_shape,
occasion=ctx.occasion,
entity_types=ctx.entity_types,
rule_boost=ctx.rule_boost,
top_k=self._top_k,
)
cached = cache.get(key)
if cached is not None:
# results 是 list(可变):返回拷贝,调用方原地改 list 不可能
# 污染缓存(cache.py 红线 2)。
return SearchOutcome(results=list(cached.results), degraded=cached.degraded)
# embed 的输入就是 query 原文(DeterministicEmbedder/未来的 BGE-M3 都
# 不需要拼提示词),保持「同 query 同向量」,缓存与复跑才有意义。
query_vec = (await self._embedding.embed([query_text]))[0]
results = await repo.hybrid_search(
self._session,
query_vec,
query_text,
entity_types=ctx.entity_types or None,
occasion=ctx.occasion,
body_shape=ctx.body_shape,
rule_boost=ctx.rule_boost,
weights=self._weights,
top_k=self._top_k,
)
if results:
if cache is not None and key is not None:
# 只缓存有结果的检索。空结果不缓存:degraded 路径的 record_gap
# 每次都要跑,缓存空结果会把缺口计数吞掉。存的是拷贝——返回给
# 调用方的 list 与缓存里的不是同一个对象。
cache.put(key, SearchOutcome(results=list(results), degraded=False))
return SearchOutcome(results=results, degraded=False)
# 无结果也是信号:落 kb_gap 驱动内容生产(T-P2-06)。埋点失败不该
# 让调用方拿到异常 —— 检索空结果本身是合法结局,这里吞掉埋点异常
# 只记日志。query 原文进表不进日志(日志里只留长度与过滤条件)。
try:
await repo.record_gap(self._session, query_text, ctx.filter_ctx())
await self._session.commit()
except Exception:
await self._session.rollback()
logger.error("retrieval_gap_record_failed", query_len=len(query_text))
logger.info("retrieval_gap", query_len=len(query_text), **ctx.filter_ctx())
return SearchOutcome(results=[], degraded=True)
class GapSummary:
"""kb_gap 的一行摘要(缺口看板的数据形状)。V1 用普通类:字段还会随
看板需求演进,先不冻结成 dataclass。"""
def __init__(self, row: Any) -> None:
self.gap_id = row.gap_id
self.query_text = row.query_text
self.filter_ctx = row.filter_ctx
self.hit_count = row.hit_count
self.first_seen_at = row.first_seen_at
self.last_seen_at = row.last_seen_at
async def list_gaps(session: AsyncSession, *, limit: int = 50, offset: int = 0) -> list[GapSummary]:
"""缺口看板:按 hit_count 降序(最常被问到的缺口排最前,T-P2-06)。"""
rows = await repo.list_gaps(session, limit=limit, offset=offset)
return [GapSummary(r) for r in rows]
def invalidate_cache() -> None:
"""发布/归档知识后调用:让检索结果缓存整体失效(T-P2-10)。
代数失效:``repo.INVALIDATION_VERSION`` 自增,之后所有查找都用新代
构键,旧代条目(含检索途中才被写回的)永不命中——无需遍历删除。
共享缓存实例同时清空,只为立刻释放内存,失效正确性只依赖代数。
签名不变:admin 发布编排(T12)直接调用,不需要了解内部机制。
"""
global _shared_cache
repo.INVALIDATION_VERSION += 1
if _shared_cache is not None:
_shared_cache.clear()