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 完全不可比)。
264 lines
9.9 KiB
Python
264 lines
9.9 KiB
Python
"""kb_chunk / kb_gap 数据访问。只有本模块写这两张表(架构 §8.3).
|
||
|
||
两条红线写在这里,改代码前先读:
|
||
|
||
1. **硬过滤零漏**(T-P2-04 / FR-KB-06):draft/禁忌实体必须在 SQL 的
|
||
WHERE 里排除,不能靠重排权重压低 —— 权重是排序问题,过滤是安全问题。
|
||
2. **没有版本号的向量不可重跑**(T-P2-03):写入必须携带
|
||
embedding_model/version;重跑按实体整组替换而不是追加。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from collections.abc import Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import RowMapping, delete, select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from packages.domain.ids import next_id
|
||
from packages.retrieval.embedding import EmbeddingProvider
|
||
from packages.retrieval.rerank import (
|
||
DEFAULT_RERANK_WEIGHTS,
|
||
RecallRow,
|
||
ScoredChunk,
|
||
rerank,
|
||
)
|
||
from packages.shared.errors import AppError, ErrorCode
|
||
from packages.shared.models import KbChunk, KbGap
|
||
|
||
# 硬过滤 + 双路召回 + RRF 的 CTE 骨架(架构 §8.3)。
|
||
# 用原生 SQL 而不是 ORM 表达式:``<=>`` 余弦距离、ts_rank、jsonb ``?`` 操作符
|
||
# 与窗口函数在 ORM 层拼写出来可读性更差,CTE 结构才是这份查询的可维护形态。
|
||
# 参数一律显式 CAST,让 asyncpg 在 NULL/数组参数上也能推断类型。
|
||
#
|
||
# jsonb NULL 语义是两条容易被写错的规则,明确如下:
|
||
# - occasion:没声明 occasions 的实体在按场景过滤时**不命中**(它没有声明
|
||
# 适配该场景,宁可少给不能错给);
|
||
# - avoid_body_shapes:没声明禁忌的实体**必须保留**(NULL ? x 是 NULL,直接
|
||
# NOT 会把好实体一起丢掉),所以用 COALESCE 空数组兜底 —— 没写禁忌 ≠ 全都禁忌。
|
||
_SEARCH_SQL = text(
|
||
"""
|
||
WITH filtered AS (
|
||
SELECT c.chunk_id,
|
||
c.entity_id,
|
||
c.chunk_type,
|
||
c.text,
|
||
c.tsv,
|
||
e.updated_at,
|
||
c.embedding <=> CAST(:query_vec AS vector) AS vec_distance,
|
||
websearch_to_tsquery('simple', CAST(:query_text AS text)) AS kw_query,
|
||
ts_rank(c.tsv, websearch_to_tsquery('simple', CAST(:query_text AS text)))
|
||
AS kw_tsrank
|
||
FROM kb_chunk c
|
||
JOIN kb_entity e ON e.entity_id = c.entity_id
|
||
WHERE e.status = 'published'
|
||
AND (CAST(:entity_types AS text[]) IS NULL
|
||
OR e.entity_type = ANY(CAST(:entity_types AS text[])))
|
||
AND (CAST(:occasion AS text) IS NULL
|
||
OR e.attrs -> 'occasions' ? CAST(:occasion AS text))
|
||
AND (CAST(:body_shape AS text) IS NULL
|
||
OR NOT (COALESCE(e.attrs -> 'avoid_body_shapes', '[]'::jsonb)
|
||
? CAST(:body_shape AS text)))
|
||
),
|
||
vec_ranked AS (
|
||
SELECT chunk_id,
|
||
ROW_NUMBER() OVER (ORDER BY vec_distance, chunk_id) AS vec_rank,
|
||
vec_distance
|
||
FROM filtered
|
||
ORDER BY vec_distance, chunk_id
|
||
LIMIT CAST(:recall_k AS bigint)
|
||
),
|
||
kw_ranked AS (
|
||
SELECT chunk_id,
|
||
ROW_NUMBER() OVER (ORDER BY kw_tsrank DESC, chunk_id) AS kw_rank,
|
||
kw_tsrank
|
||
FROM filtered
|
||
-- 过滤必须用 @@ 而不是 ts_rank > 0:ts_rank 对不匹配文本也返回
|
||
-- 极小的正 epsilon(~1e-20),靠分数阈值拦截会把无关 chunk 全放进来。
|
||
WHERE kw_tsrank > 0 AND tsv @@ kw_query
|
||
ORDER BY kw_tsrank DESC, chunk_id
|
||
LIMIT CAST(:recall_k AS bigint)
|
||
)
|
||
SELECT f.chunk_id,
|
||
f.entity_id,
|
||
f.chunk_type,
|
||
f.text,
|
||
f.updated_at,
|
||
v.vec_rank,
|
||
v.vec_distance,
|
||
k.kw_rank,
|
||
k.kw_tsrank
|
||
FROM vec_ranked v
|
||
FULL OUTER JOIN kw_ranked k ON k.chunk_id = v.chunk_id
|
||
JOIN filtered f ON f.chunk_id = COALESCE(v.chunk_id, k.chunk_id)
|
||
"""
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ChunkInput:
|
||
"""待向量化写入的一个知识片段。tsv 是生成列,不在这里出现。"""
|
||
|
||
text: str
|
||
chunk_type: str = "text"
|
||
|
||
|
||
def to_pgvector_literal(vec: Sequence[float]) -> str:
|
||
"""把浮点向量序列化成 PG 的 vector 字面量 ``'[0.1,0.2,...]'``.
|
||
|
||
asyncpg 对扩展类型没有二进制编解码器,文本格式是官方支持的传参方式;
|
||
SQL 侧再显式 ``::vector`` 转型,PG 负责解析。
|
||
"""
|
||
return "[" + ",".join(repr(float(x)) for x in vec) + "]"
|
||
|
||
|
||
async def upsert_chunks(
|
||
session: AsyncSession,
|
||
entity_id: int,
|
||
chunks: Sequence[ChunkInput],
|
||
embedding: EmbeddingProvider,
|
||
) -> list[KbChunk]:
|
||
"""整实体替换式写入 kb_chunk(可重跑判据 T-P2-03).
|
||
|
||
kb_chunk 没有业务自然键(text 改一个字就是新 chunk),所以 upsert 的
|
||
粒度是**实体**:先删该实体全部旧向量再插入新组 —— 重跑(无论换没换
|
||
模型)不留脏数据,embedding_model/version 逐行携带,换模型名即视为
|
||
不同版本。
|
||
"""
|
||
for chunk in chunks:
|
||
if not chunk.text.strip():
|
||
raise AppError(
|
||
ErrorCode.VALIDATION_FAILED,
|
||
message="知识片段文本不能为空",
|
||
data={"entity_id": entity_id},
|
||
)
|
||
|
||
vectors = await embedding.embed([c.text for c in chunks])
|
||
if len(vectors) != len(chunks):
|
||
raise AppError(
|
||
ErrorCode.INTERNAL_ERROR,
|
||
message="嵌入实现返回的向量数与输入文本数不一致",
|
||
data={"model": embedding.model_name},
|
||
)
|
||
for vec in vectors:
|
||
if len(vec) != embedding.dimensions:
|
||
raise AppError(
|
||
ErrorCode.INTERNAL_ERROR,
|
||
message="嵌入实现返回的维度与声明不一致",
|
||
data={
|
||
"model": embedding.model_name,
|
||
"declared": embedding.dimensions,
|
||
"actual": len(vec),
|
||
},
|
||
)
|
||
|
||
# 同一实体的旧向量在事务内先删后插:失败整体回滚,不会出现半新半旧。
|
||
await session.execute(delete(KbChunk).where(KbChunk.entity_id == entity_id))
|
||
rows = [
|
||
KbChunk(
|
||
chunk_id=next_id(),
|
||
entity_id=entity_id,
|
||
chunk_type=chunk.chunk_type,
|
||
text=chunk.text,
|
||
embedding=vec,
|
||
embedding_model=embedding.model_name,
|
||
embedding_version=embedding.version,
|
||
)
|
||
for chunk, vec in zip(chunks, vectors, strict=True)
|
||
]
|
||
session.add_all(rows)
|
||
return rows
|
||
|
||
|
||
async def hybrid_search(
|
||
session: AsyncSession,
|
||
query_vec: Sequence[float],
|
||
query_text: str,
|
||
*,
|
||
entity_types: Sequence[str] | None = None,
|
||
occasion: str | None = None,
|
||
body_shape: str | None = None,
|
||
rule_boost: Mapping[int, float] | None = None,
|
||
weights: Mapping[str, float] | None = None,
|
||
top_k: int = 50,
|
||
) -> list[ScoredChunk]:
|
||
"""硬过滤 → 向量召回 → 关键词召回 → RRF 融合重排(架构 §8.3).
|
||
|
||
硬过滤(published / entity_types / occasion / avoid_body_shapes 禁忌)
|
||
全部发生在 ``filtered`` CTE 里,两路召回只从过滤后的集合里取 top N ——
|
||
这是"零漏"的结构保证,而非概率保证。空 ``entity_types`` 视为不限类型。
|
||
"""
|
||
if top_k <= 0:
|
||
return []
|
||
result = await session.execute(
|
||
_SEARCH_SQL,
|
||
{
|
||
"query_vec": to_pgvector_literal(query_vec),
|
||
"query_text": query_text,
|
||
"entity_types": list(entity_types) if entity_types else None,
|
||
"occasion": occasion,
|
||
"body_shape": body_shape,
|
||
"recall_k": top_k,
|
||
},
|
||
)
|
||
rows: list[RecallRow] = [_to_recall_row(row) for row in result.mappings().all()]
|
||
return rerank(
|
||
rows,
|
||
weights=weights if weights is not None else DEFAULT_RERANK_WEIGHTS,
|
||
rule_boost=rule_boost,
|
||
now=datetime.now(UTC),
|
||
top_k=top_k,
|
||
)
|
||
|
||
|
||
def _to_recall_row(row: RowMapping) -> RecallRow:
|
||
"""把裸行收敛成 RecallRow:SQL 返回的 Any 在边界处一次性转干净。"""
|
||
return RecallRow(
|
||
chunk_id=int(row["chunk_id"]),
|
||
entity_id=int(row["entity_id"]),
|
||
chunk_type=str(row["chunk_type"]),
|
||
text=str(row["text"]),
|
||
updated_at=row["updated_at"],
|
||
vec_rank=int(row["vec_rank"]) if row["vec_rank"] is not None else None,
|
||
vec_distance=float(row["vec_distance"]) if row["vec_distance"] is not None else None,
|
||
kw_rank=int(row["kw_rank"]) if row["kw_rank"] is not None else None,
|
||
kw_tsrank=float(row["kw_tsrank"]) if row["kw_tsrank"] is not None else None,
|
||
)
|
||
|
||
|
||
async def record_gap(
|
||
session: AsyncSession,
|
||
query_text: str,
|
||
filter_ctx: Mapping[str, Any],
|
||
) -> None:
|
||
"""无结果 query 落 kb_gap:同 query_text + filter_ctx 则 hit_count += 1(T-P2-06).
|
||
|
||
kb_gap 只有普通索引没有唯一约束,``ON CONFLICT`` 用不了;用事务级
|
||
咨询锁把「查-改-插」串行化,保证同 key 并发时仍是一行、计数不丢。
|
||
锁在事务提交/回滚时自动释放,由调用方的 session_scope 兜底。
|
||
"""
|
||
ctx = dict(filter_ctx)
|
||
payload = json.dumps(
|
||
{"query_text": query_text, "filter_ctx": ctx},
|
||
sort_keys=True,
|
||
ensure_ascii=False,
|
||
)
|
||
digest = hashlib.sha256(payload.encode("utf-8")).digest()[:8]
|
||
lock_key = int.from_bytes(digest, "big", signed=True)
|
||
await session.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
||
|
||
found = await session.execute(
|
||
select(KbGap).where(KbGap.query_text == query_text, KbGap.filter_ctx == ctx)
|
||
)
|
||
gap = found.scalar_one_or_none()
|
||
if gap is None:
|
||
session.add(KbGap(gap_id=next_id(), query_text=query_text, filter_ctx=ctx, hit_count=1))
|
||
else:
|
||
gap.hit_count += 1
|
||
gap.last_seen_at = datetime.now(UTC)
|