Files
meida_backend/tests/integration/test_hybrid_retrieval.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

298 lines
11 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.
"""混合检索集成:真实 PostgreSQL + pgvector,嵌入用 DeterministicEmbedder(不联网).
证明三件单测证明不了的事(任务文档 §4):
1. 硬过滤零漏 —— 禁忌/draft 实体即使向量最近也绝不出现(T-P2-04 红线);
2. 关键词路径在 tsv 'simple' 分词下真实命中;
3. 无结果 query 落 kb_gap 且 hit_count 递增(T-P2-06)。
外加:向量化重跑整组替换且逐行带版本(T-P2-03)、kb_asset partial 索引可用。
Run: MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m integration -q --no-cov
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest
from sqlalchemy import delete, select, text
from packages.domain.ids import next_id
from packages.retrieval.embedding import DeterministicEmbedder
from packages.retrieval.repo import ChunkInput, hybrid_search, upsert_chunks
from packages.retrieval.service import RetrievalService, UserContext
from packages.shared.db import dispose_engine, session_scope
from packages.shared.models import KbAsset, KbChunk, KbEntity, KbGap
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
def _unique(name: str) -> str:
return f"it-t8-{name}-{uuid.uuid4().hex[:10]}"
@pytest.fixture(autouse=True)
async def _hermetic_engine() -> AsyncIterator[None]:
"""每个用例前后重建全局 engine:asyncpg 连接绑定创建它的事件循环,
pytest-asyncio 给每个用例一个新 loop,复用池里旧连接会报
"attached to a different loop"。app 系用例由 lifespan 的 dispose_engine
兜底;直接用 session_scope 的本文件必须自己管。"""
await dispose_engine()
yield
await dispose_engine()
@pytest.fixture
async def clean_kb() -> AsyncIterator[dict[str, list[Any]]]:
"""登记本用例造的脏数据并在结束时清掉:集成库长活,不能留假实体."""
registry: dict[str, list[Any]] = {"entities": [], "gaps": []}
yield registry
async with session_scope() as session:
if registry["entities"]:
ids = registry["entities"]
await session.execute(delete(KbChunk).where(KbChunk.entity_id.in_(ids)))
await session.execute(delete(KbAsset).where(KbAsset.entity_id.in_(ids)))
await session.execute(delete(KbEntity).where(KbEntity.entity_id.in_(ids)))
for query_text in registry["gaps"]:
await session.execute(delete(KbGap).where(KbGap.query_text == query_text))
async def _add_entity(
registry: dict[str, list[Any]],
*,
entity_type: str = "item",
status: str = "published",
attrs: dict[str, Any] | None = None,
) -> int:
entity_id = next_id()
registry["entities"].append(entity_id)
async with session_scope() as session:
session.add(
KbEntity(
entity_id=entity_id,
entity_type=entity_type,
name=_unique(entity_type),
attrs=attrs or {},
status=status,
)
)
return entity_id
class RenamedEmbedder:
"""换名复用确定性向量:验证 model/version 逐行落库与整组替换(T-P2-03)."""
def __init__(self, model_name: str, version: str) -> None:
self._inner = DeterministicEmbedder(dimensions=1024)
self.model_name = model_name
self.version = version
self.dimensions = 1024
async def embed(self, texts: list[str]) -> list[list[float]]:
return await self._inner.embed(texts)
# --- 1. 硬过滤零漏(T-P2-04 红线)--------------------------------------------
async def test_hard_filter_never_leaks_taboo_or_draft(clean_kb: dict[str, list[Any]]) -> None:
embedder = DeterministicEmbedder(dimensions=1024)
query_text = "pear shape body dress advice"
# 防御性登记:若回归导致空结果被记进 kb_gap,用例结束也能清干净。
clean_kb["gaps"].append(query_text)
taboo_id = await _add_entity(
clean_kb, attrs={"occasions": ["wedding"], "avoid_body_shapes": ["apple"]}
)
draft_id = await _add_entity(clean_kb, status="draft")
good_id = await _add_entity(clean_kb, attrs={"occasions": ["wedding"]})
async with session_scope() as session:
# 禁忌与 draft 的 chunk 和 query 完全同文:不过滤的话它们必然霸占
# 向量第一名(距离 0)—— 这样断言"零出现"才有说服力。
await upsert_chunks(session, taboo_id, [ChunkInput(text=query_text)], embedder)
await upsert_chunks(session, draft_id, [ChunkInput(text=query_text)], embedder)
await upsert_chunks(
session,
good_id,
[ChunkInput(text="soft knit sweater paired with a high waist skirt")],
embedder,
)
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder)
outcome = await svc.search(
query_text,
user_ctx=UserContext(body_shape="apple", occasion="wedding", entity_types=("item",)),
)
assert outcome.degraded is False
assert outcome.results, "正常 published 实体应被召回"
assert {r.entity_id for r in outcome.results} == {good_id}
# 对照组:去掉禁忌/场景过滤后禁忌实体确实在库里且可被召回 ——
# 证明上面拦截它的是硬过滤,而不是数据没写进去。draft 永远不出现。
query_vec = (await embedder.embed([query_text]))[0]
async with session_scope() as session:
control = await hybrid_search(session, query_vec, query_text)
control_entities = {r.entity_id for r in control}
assert taboo_id in control_entities
assert draft_id not in control_entities
# --- 2. 关键词路径(tsv 'simple' 分词)----------------------------------------
async def test_keyword_recall_ranks_matching_chunk_first(
clean_kb: dict[str, list[Any]],
) -> None:
embedder = DeterministicEmbedder(dimensions=1024)
entity_id = await _add_entity(clean_kb)
matching = "breathable linen shirt keeps you cool in summer"
unrelated = "heavy wool coat for freezing winter nights"
async with session_scope() as session:
await upsert_chunks(
session,
entity_id,
[ChunkInput(text=matching), ChunkInput(text=unrelated)],
embedder,
)
# 查询文本与两个 chunk 都不同文 → 向量路分不出高下(近似正交),
# 排序由关键词路(0.2 权重、归一后差距 1.0)决定。
async with session_scope() as session:
results = await hybrid_search(
session, (await embedder.embed(["linen shirt summer"]))[0], "linen shirt summer"
)
assert [r.text for r in results] == [matching, unrelated]
assert results[0].kw_score == pytest.approx(1.0)
assert results[1].kw_score == 0.0
# --- 3. 向量化可重跑(T-P2-03)------------------------------------------------
async def test_upsert_chunks_is_versioned_and_rerunnable(
clean_kb: dict[str, list[Any]],
) -> None:
entity_id = await _add_entity(clean_kb)
async with session_scope() as session:
first = await upsert_chunks(
session,
entity_id,
[ChunkInput(text="rule one"), ChunkInput(text="rule two")],
RenamedEmbedder("bge-m3", "v1"),
)
assert {c.embedding_model for c in first} == {"bge-m3"}
assert {c.embedding_version for c in first} == {"v1"}
# 换模型名(= 换版本)重跑:整组替换,不残留旧版本向量
async with session_scope() as session:
await upsert_chunks(
session,
entity_id,
[ChunkInput(text="rule one"), ChunkInput(text="rule two"), ChunkInput(text="rule new")],
RenamedEmbedder("bge-m4", "v2"),
)
async with session_scope() as session:
rows = (
(await session.execute(select(KbChunk).where(KbChunk.entity_id == entity_id)))
.scalars()
.all()
)
assert len(rows) == 3
assert {r.embedding_model for r in rows} == {"bge-m4"}
assert {r.embedding_version for r in rows} == {"v2"}
# --- 4. 无结果 query 落 kb_gap(T-P2-06)--------------------------------------
async def test_no_result_query_lands_in_kb_gap_with_counter(
clean_kb: dict[str, list[Any]],
) -> None:
query_text = _unique("missing")
clean_kb["gaps"].append(query_text)
svc_embedder = DeterministicEmbedder(dimensions=1024)
async with session_scope() as session:
svc = RetrievalService(session, embedding=svc_embedder)
first = await svc.search(query_text, user_ctx=UserContext(entity_types=("nonexistent",)))
second = await svc.search(query_text, user_ctx=UserContext(entity_types=("nonexistent",)))
assert first.degraded and first.results == []
assert second.degraded and second.results == []
async with session_scope() as session:
gaps = (
(await session.execute(select(KbGap).where(KbGap.query_text == query_text)))
.scalars()
.all()
)
assert len(gaps) == 1 # 同 query + 同 filter_ctx 只有一行
assert gaps[0].hit_count == 2
assert gaps[0].filter_ctx["entity_types"] == ["nonexistent"]
# --- 5. kb_asset 按 entity 查询 + partial 索引可用 -----------------------------
async def test_tryon_ready_assets_query_uses_partial_index(
clean_kb: dict[str, list[Any]],
) -> None:
entity_id = await _add_entity(clean_kb)
async with session_scope() as session:
session.add_all(
[
KbAsset(
asset_id=next_id(),
entity_id=entity_id,
asset_type="front",
cos_key=_unique("front"),
tryon_ready=True,
review_status="approved",
),
KbAsset(
asset_id=next_id(),
entity_id=entity_id,
asset_type="side",
cos_key=_unique("side"),
tryon_ready=False,
review_status="pending",
),
]
)
async with session_scope() as session:
ready = (
(
await session.execute(
select(KbAsset).where(
KbAsset.entity_id == entity_id, KbAsset.tryon_ready.is_(True)
)
)
)
.scalars()
.all()
)
assert len(ready) == 1
assert ready[0].tryon_ready is True
# 关闭顺序扫描逼 planner 走索引,证明 partial 索引真的可用
# (SET LOCAL 只影响当前事务,随 session_scope 提交即失效)。
await session.execute(text("SET LOCAL enable_seqscan = off"))
plan_lines = (
(await session.execute(text("EXPLAIN SELECT asset_id FROM kb_asset WHERE tryon_ready")))
.scalars()
.all()
)
plan_text = "\n".join(str(line) for line in plan_lines)
assert "ix_kb_asset_tryon_ready" in plan_text