307 lines
12 KiB
Python
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
|