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