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 完全不可比)。
100 lines
3.8 KiB
Python
100 lines
3.8 KiB
Python
"""嵌入提供方:接口位 + 确定性假实现(架构 §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},
|
||
)
|