Files
meida_backend/packages/retrieval/embedding.py
T
xsl aa1287d83d feat(retrieval): hybrid search over pgvector with hard filters and gap tracking
DeterministicEmbedder(可换 BGE-M3 的接口位)+ kb_chunk 双索引写入
+ 硬过滤→向量→关键词→RRF 重排管线 + kb_gap 缺口埋点(T-P2-03/04/06,TD-08)。

实现:
- embedding.py:EmbeddingProvider Protocol + DeterministicEmbedder
  (sha256 种子→归一化 1024 维假向量,同文本同向量/异文本近似正交)
  + build_embedding_provider 工厂(未知模型名抛 CONFIG_ERROR)。
- repo.py:upsert_chunks(整实体先删后插,逐行带 embedding_model/version,
  可重跑判据 T-P2-03);hybrid_search(§8.3 CTE:硬过滤 published/
  entity_types/occasion/avoid_body_shapes → <=> 余弦 topN → ts_rank topN
  → RRF);record_gap(咨询锁串行化同 query+filter_ctx 的 hit_count 递增)。
- rerank.py:RRF/权重/新鲜度纯数学(独立于 SQL 以便单测逐项断言)。
- service.py:RetrievalService.search 编排 embed→检索→(空结果)gap 埋点,
  SearchOutcome.degraded 标记降级。

测试:单测 +30(test_embedding 确定性/维度/归一化/工厂拒绝、test_rerank
公式逐项断言、test_retrieval_service 编排打桩);集成 +5
(test_hybrid_retrieval:禁忌实体同文最近向量仍零出现、draft 零出现、
关键词路径、换模型整组替换、kb_gap hit_count=2、partial 索引 EXPLAIN 验证)。
全绿:ruff / mypy(retrieval+shared+domain) / lint-imports / 268 单测 /
21 集成。

偏差说明:
1) 新增 rerank.py(任务文档只点名 embedding/repo/service,但重排数学必须
   不连库可单测,独立模块是最低成本实现,仍属 packages/retrieval/**)。
2) upsert_chunks 的 upsert 粒度是实体(先删后插):kb_chunk 无业务自然键,
   行级 ON CONFLICT 不可行;换模型名=新版本整组替换,不留旧版本残向量。
3) 禁忌过滤用 COALESCE(attrs->avoid_body_shapes, '[]')兜底:jsonb NULL
   的 ? 运算结果是 NULL,直接 NOT 会把未声明禁忌的好实体一并排除。
4) 关键词路过滤用 tsv @@ tsquery 而非 ts_rank>0:ts_rank 对不匹配文本返回
   ~1e-20 正 epsilon,阈值过滤会把无关 chunk 全放进关键词路。
5) RRF 分量按 1/(k+1) 归一到 (0,1],使 0.45/0.2/0.25/0.1 权重量纲可比
   (原始倒数 0.45×1/61 与规则项 0.25×1 完全不可比)。
2026-08-14 20:12:27 +08:00

100 lines
3.8 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.
"""嵌入提供方:接口位 + 确定性假实现(架构 §8.3,TD-08).
当前只有 ``deterministic-v1``:以文本 sha256 为种子生成伪随机向量后 L2 归一化。
它是**测试与 dev 的假实现** —— 真实 BGE-M3 ONNX 加载是后续任务,配置里的
``kb_embedding_device`` 就是为它预留的。接口先定死,届时换模型只改工厂,
检索管线(repo/service)不动。
"""
from __future__ import annotations
import hashlib
from typing import Protocol, runtime_checkable
import numpy as np
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
#: 唯一被承认的假实现模型名(与 Settings.kb_embedding_model 的默认值对应)。
DETERMINISTIC_MODEL_NAME = "deterministic-v1"
@runtime_checkable
class EmbeddingProvider(Protocol):
"""嵌入模型的最小接口。
``model_name`` + ``version`` 会随向量逐行写进 kb_chunk(T-P2-03):
没有版本号的向量不可重跑 —— 换模型即换版本,这是向量化可重跑的判据。
"""
@property
def model_name(self) -> str: ...
@property
def version(self) -> str: ...
@property
def dimensions(self) -> int: ...
async def embed(self, texts: list[str]) -> list[list[float]]:
"""批量嵌入。返回顺序与输入一一对应。"""
...
class DeterministicEmbedder:
"""确定性假嵌入:同文本同向量,不同文本近似正交。
sha256(text) 做种子的标准高斯伪随机向量再 L2 归一化。1024 维空间里
随机向量两两夹角趋于 90°(|dot| ≈ 1/√1024 ≈ 0.03),这让"按余弦距离
排序"的管线在没有真模型时也能被测试驱动:同文本距离 0,异文本距离 ~1。
"""
def __init__(self, *, dimensions: int = 1024, version: str = "v1") -> None:
if dimensions <= 0:
raise ValueError(f"dimensions 必须为正数, 得到 {dimensions}")
self._dimensions = dimensions
self._version = version
@property
def model_name(self) -> str:
return DETERMINISTIC_MODEL_NAME
@property
def version(self) -> str:
return self._version
@property
def dimensions(self) -> int:
return self._dimensions
def _vector(self, text: str) -> np.ndarray:
# 只取 sha256 前 16 字节做种子:PCG64 的种子空间足够,且避免跨平台
# int 大小差异。同一 numpy 版本内逐位确定(假实现不承诺跨大版本稳定,
# 真正的可重跑保证来自 kb_chunk.embedding_model/version 落库)。
seed = int.from_bytes(hashlib.sha256(text.encode("utf-8")).digest()[:16], "big")
vec = np.random.default_rng(seed).standard_normal(self._dimensions)
norm = float(np.linalg.norm(vec))
# 标准正态向量的范数恒 > 0,这里只防御浮点极端值,不做业务分支。
return vec / norm if norm > 0.0 else vec
async def embed(self, texts: list[str]) -> list[list[float]]:
return [self._vector(t).tolist() for t in texts]
def build_embedding_provider(settings: Settings) -> EmbeddingProvider:
"""按 ``settings.kb_embedding_model`` 选择实现。
当前只认 ``deterministic-v1``:出现其它名字说明配置与代码版本不匹配
(例如提前把 KB_EMBEDDING_MODEL 改成 BGE-M3 但 ONNX 加载任务还没合入),
直接拒绝比静默回退到假向量更能暴露问题。
"""
if settings.kb_embedding_model == DETERMINISTIC_MODEL_NAME:
return DeterministicEmbedder(dimensions=settings.kb_vector_dimensions)
raise AppError(
ErrorCode.CONFIG_ERROR,
message=f"未知的嵌入模型: {settings.kb_embedding_model}",
hint="当前仅支持 deterministic-v1,真实 BGE-M3 属后续任务",
data={"model": settings.kb_embedding_model},
)