Files
border-red-post-nalati/backend/tests/test_hybrid.py

307 lines
12 KiB
Python

"""Synthetic coordinates/evidence test fusion semantics, not domain/model quality."""
import math
from dataclasses import replace
from uuid import UUID, uuid4
import pytest
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from test_indexing import build
from test_retrieval import add_item
from test_vectors import ToyProvider, text_version, vector_build, vector_publish
from app.models.retrieval import RetrievalRun
from app.schemas.hybrid import RankedCandidate
from app.services import vectors
from app.services.embedding import EmbeddingError
from app.services.embedding_runtime import get_embedding_provider
from app.services.hybrid import fuse
from app.services.indexing import publish_index
@pytest.fixture
def provider(client):
value = ToyProvider()
client.app.dependency_overrides[get_embedding_provider] = lambda: value
return value
def search(client, path="hybrid", **kwargs):
return client.post(f"/api/retrieval/{path}", json={"query": "query", **kwargs})
def publish_vectors(client):
text_ = text_version(client)
version = vector_build(client, text_)
assert vector_publish(client, version).status_code == 200
return text_, version
def test_rrf_hand_calculation_absent_leg_and_raw_score_independence():
a, b, c = uuid4(), uuid4(), uuid4()
lexical = [
RankedCandidate(chunk_id=a, rank=1, score=1000),
RankedCandidate(chunk_id=b, rank=2, score=1),
]
vector = [
RankedCandidate(chunk_id=b, rank=1, score=-0.9),
RankedCandidate(chunk_id=c, rank=2, score=-1),
]
scores = fuse(lexical, vector)
assert scores == {str(a): 1 / 61, str(b): 1 / 62 + 1 / 61, str(c): 1 / 62}
for candidate in lexical + vector:
candidate.score *= 10000
assert fuse(lexical, vector) == scores
assert fuse([], []) == {}
def test_same_chunk_scope_standalone_rankings_formula_and_history(client, provider):
items = [
add_item(client, content="alpha query extra words words"),
add_item(client, content="query"),
add_item(client, content="beta"),
]
add_item(client, status="pending", content="query alpha")
text_, version = publish_vectors(client)
response = search(client, top_k=3, candidate_k=3)
assert response.status_code == 200, response.text
result = response.json()
lexical = search(client, "chunks", top_k=3).json()
vector = search(client, "vector", top_k=3).json()
assert result["corpus_size"] == lexical["corpus_size"] == vector["corpus_size"] == 3
assert (
result["text_index_version_id"]
== lexical["text_index_version_id"]
== vector["text_index_version_id"]
== text_["id"]
)
assert result["vector_version_id"] == version["id"]
assert result["config"]["space"]["id"] == provider.spec.space_id
assert result["config"]["lexical"]["title_weight"] == 0
assert result["config"]["lexical"]["k1"] == 1.2
assert result["config"]["lexical"]["b"] == 0.75
assert result["config"]["chunking"] == text_["config"]
assert result["lexical_total_hits"] == 2 and result["vector_total_hits"] == 3
assert [c["chunk_id"] for c in result["lexical_candidates"]] == [
c["chunk_id"] for c in lexical["results"]
]
assert [c["chunk_id"] for c in result["vector_candidates"]] == [
c["chunk_id"] for c in vector["results"]
]
# Independently calculate positive-log-IDF BM25 on bodies of lengths 5, 1, 1.
expected = math.log(1 + 1.5 / 2.5) * 2.2 / (1 + 1.2 * (0.25 + 0.75 / (7 / 3)))
assert lexical["results"][0]["knowledge_id"] == items[1]["id"]
assert lexical["results"][0]["score"] == pytest.approx(expected)
for hit in result["results"]:
left = next(
(c for c in result["lexical_candidates"] if c["chunk_id"] == hit["chunk_id"]), None
)
right = next(
(c for c in result["vector_candidates"] if c["chunk_id"] == hit["chunk_id"]), None
)
assert hit["score"] == pytest.approx(
(1 / (60 + left["rank"]) if left else 0) + (1 / (60 + right["rank"]) if right else 0)
)
assert hit["score"] == hit["lexical_contribution"] + hit["vector_contribution"]
assert hit["source_url"] == "https://example.com/"
assert search(client, candidate_k=3, top_k=3).json()["results"] == result["results"]
for value in (result, lexical, vector):
assert client.get(f"/api/retrieval/runs/{value['run_id']}").json() == value
old = client.post("/api/retrieval", json={"query": "query"}).json()
assert old["mode"] == "keyword" and "chunk_id" not in old["results"][0]
history = client.get("/api/retrieval/runs?page_size=100").json()
assert {r["mode"] for r in history["items"]} == {
"keyword",
"vector",
"keyword_chunks",
"hybrid",
}
client.cookies.clear()
assert search(client).status_code == search(client, "chunks").status_code == 200
assert client.get("/api/retrieval/runs").status_code == 401
def test_candidate_cutoffs_disjoint_legs_and_deterministic_tie(client, provider):
first = add_item(client, content="alpha")
second = add_item(client, content="query")
publish_vectors(client)
small = search(client, candidate_k=1, top_k=1).json()
assert small["total_hits"] == 2 and len(small["results"]) == 1
assert small["results"][0]["knowledge_id"] == first["id"]
assert small["results"][0]["lexical_rank"] is None
assert small["results"][0]["lexical_score"] is None
assert small["results"][0]["lexical_contribution"] == 0
assert small["results"][0]["score"] == 1 / 61
full = search(client, candidate_k=2, top_k=2).json()
assert full["results"][0]["knowledge_id"] == second["id"]
assert full["results"][0]["lexical_rank"] == 1 and full["results"][0]["vector_rank"] == 2
def test_chunk_bodies_offsets_overlap_and_category_apply_before_both_rankings(client, provider):
item = add_item(client, title="uniquetitle", content="alpha query 文本。" * 30, category="交通")
add_item(client, content="query", category="其他")
publish_vectors(client)
result = search(client, category="交通", top_k=20, candidate_k=20).json()
assert result["corpus_size"] > 1 and result["total_hits"] == result["corpus_size"]
assert len({h["chunk_id"] for h in result["results"]}) == len(result["results"])
for hit in result["results"]:
assert hit["knowledge_id"] == item["id"]
assert hit["snippet"] == item["content"][hit["snippet_start"] : hit["snippet_end"]]
assert search(client, "chunks", query="uniquetitle").json()["status"] == "no_evidence"
empty = search(client, category="不存在分类").json()
assert empty["status"] == "no_evidence" and empty["results"] == []
assert empty["lexical_candidates"] == empty["vector_candidates"] == []
def test_empty_lexical_leg_and_empty_corpus_are_explicit(client, provider):
add_item(client, content="alpha")
publish_vectors(client)
for query in ["unmatchedword", "!!"]:
result = search(client, query=query).json()
assert result["lexical_candidates"] == [] and result["status"] == "ok"
assert result["results"][0]["score"] == 1 / 61
assert search(client, "chunks", query="!!").status_code == 422
def test_empty_published_corpus(client, provider):
publish_vectors(client)
for path in ["chunks", "hybrid"]:
result = search(client, path).json()
assert result["status"] == "no_evidence" and result["total_hits"] == 0
def test_keyword_chunks_need_no_runtime_or_vector_publication(client):
add_item(client, content="query")
assert search(client, "chunks").status_code == 409
text_version(client)
assert search(client, "chunks").status_code == 200
assert search(client).status_code == 503
@pytest.mark.parametrize(
"payload",
[
{"candidate_k": 1, "top_k": 2},
{"candidate_k": 201},
{"candidate_k": 0},
{"top_k": 21},
{"top_k": 0},
{"mode": "vector"},
{"rrf_k": 10},
{"query": " "},
{"query": "x" * 501},
],
)
def test_request_budget_and_fixed_config_validation(client, provider, payload):
assert search(client, **payload).status_code == 422
@pytest.mark.parametrize("change", ["catalog", "text", "vector"])
def test_query_encoding_releases_locks_and_rechecks_publication(
client, provider, migrated_database, change
):
item = add_item(client, content="alpha query")
text_, first = publish_vectors(client)
replacement_text = build(client)
replacement_vector = vector_build(client, text_)
def mutate(query):
assert query
with Session(migrated_database) as other:
other.execute(text("SET LOCAL lock_timeout='1s'"))
if change == "catalog":
state = vectors.locked_state(other)
state.catalog_revision += 1
other.execute(
text(
"UPDATE knowledge_items SET content='changed synthetic evidence' WHERE id=:id"
),
{"id": item["id"]},
)
other.commit()
elif change == "text":
publish_index(other, UUID(replacement_text["id"]), 1, first["created_by"])
else:
vectors.publish_vectors(
other, UUID(replacement_vector["id"]), 1, first["created_by"]
)
provider.callback = mutate
assert search(client).status_code == 409
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
@pytest.mark.parametrize("change", ["body", "chunks", "vectors", "space"])
def test_corrupt_or_incompatible_snapshot_fails_closed(client, provider, migrated_database, change):
add_item(client, content="alpha query")
publish_vectors(client)
if change == "space":
provider.spec = replace(provider.spec, revision="b" * 40)
else:
with migrated_database.begin() as connection:
connection.execute(
text(
{
"body": "UPDATE knowledge_items SET content='out of band change'",
"chunks": "UPDATE chunks SET text='tampered'",
"vectors": "DELETE FROM chunk_vectors",
}[change]
)
)
assert search(client).status_code == 409
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
@pytest.mark.parametrize("failure,status", [("token", 422), ("runtime", 503)])
def test_model_failure_has_no_fallback_or_partial_history(
client, provider, migrated_database, failure, status
):
add_item(client, content="alpha query")
publish_vectors(client)
def fail(*args, **kwargs):
if failure == "token":
raise EmbeddingError("token budget")
raise OSError("private-runtime-path")
provider.encode = fail
response = search(client)
assert response.status_code == status and "private-runtime-path" not in response.text
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
def test_failed_history_insert_rolls_back_both_leg_result(client, provider, migrated_database):
add_item(client, content="alpha query")
publish_vectors(client)
with migrated_database.begin() as connection:
connection.execute(
text("ALTER TABLE retrieval_runs ADD CONSTRAINT test_no_runs CHECK (false) NOT VALID")
)
assert search(client).status_code == 409
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
def test_stale_history_survives_evidence_change_and_capacity_is_explicit(
client, provider, migrated_database, monkeypatch
):
item = add_item(client, content="alpha query")
publish_vectors(client)
result = search(client).json()
monkeypatch.setattr(vectors, "MAX_VECTOR_CHUNKS", 0)
assert search(client).status_code == search(client, "chunks").status_code == 503
monkeypatch.undo()
assert (
client.patch(
f"/api/knowledge/{item['id']}",
json={"expected_version": item["version"], "content": "updated synthetic body"},
).status_code
== 200
)
assert search(client).status_code == search(client, "chunks").status_code == 409
assert client.get(f"/api/retrieval/runs/{result['run_id']}").json() == result