fix(evidence): fail closed when BM25 is unavailable
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
@@ -59,10 +61,10 @@ def dense_result_id(base_url: str, collection: str, query: list[float]) -> int:
|
||||
|
||||
|
||||
def test_pinned_qdrant_image_indexes_and_queries_italian_bm25_server_side():
|
||||
with DockerContainer(qdrant_image()).with_exposed_ports(6333) as qdrant:
|
||||
with DockerContainer(qdrant_image()).with_exposed_ports(6333) as qdrant, ExitStack() as cleanup:
|
||||
base_url = f"http://{qdrant.get_container_host_ip()}:{qdrant.get_exposed_port(6333)}"
|
||||
wait_for_qdrant(base_url)
|
||||
collection = "italian_bm25_contract"
|
||||
collection = f"italian_bm25_contract_{uuid4().hex}"
|
||||
request_ok(
|
||||
"PUT",
|
||||
f"{base_url}/collections/{collection}",
|
||||
@@ -70,6 +72,7 @@ def test_pinned_qdrant_image_indexes_and_queries_italian_bm25_server_side():
|
||||
"vectors": {"size": 4, "distance": "Cosine"},
|
||||
},
|
||||
)
|
||||
cleanup.callback(request_ok, "DELETE", f"{base_url}/collections/{collection}")
|
||||
legacy_points = [
|
||||
{"id": 10, "vector": [1.0, 0.0, 0.0, 0.0], "payload": {"record_kind": "schema_table"}},
|
||||
{"id": 11, "vector": [0.0, 1.0, 0.0, 0.0], "payload": {"record_kind": "schema_column"}},
|
||||
|
||||
@@ -32,6 +32,7 @@ class FakeQdrantHttp:
|
||||
self.dimension = dimension
|
||||
self.distance = distance
|
||||
self.collection = None
|
||||
self.sparse_vectors: dict[str, dict] | None = None
|
||||
self.payload_indexes: set[str] = set()
|
||||
self.points: dict[str, dict] = {}
|
||||
self.calls: list[tuple[str, str, dict | None]] = []
|
||||
@@ -56,7 +57,11 @@ class FakeQdrantHttp:
|
||||
"result": {
|
||||
"config": {
|
||||
"params": {
|
||||
"vectors": {"size": self.dimension, "distance": self.distance}
|
||||
"vectors": {"size": self.dimension, "distance": self.distance},
|
||||
**(
|
||||
{"sparse_vectors": self.sparse_vectors}
|
||||
if self.sparse_vectors is not None else {}
|
||||
),
|
||||
}
|
||||
},
|
||||
"payload_schema": {
|
||||
@@ -69,6 +74,7 @@ class FakeQdrantHttp:
|
||||
self.collection = json
|
||||
self.dimension = json["vectors"]["size"]
|
||||
self.distance = json["vectors"]["distance"]
|
||||
self.sparse_vectors = json.get("sparse_vectors")
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic/index":
|
||||
@@ -182,6 +188,15 @@ def _store(
|
||||
)
|
||||
|
||||
|
||||
def _ready_collection_with_bm25(fake: FakeQdrantHttp) -> None:
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
fake.sparse_vectors = {"bm25": {"modifier": "idf"}}
|
||||
|
||||
|
||||
def test_point_id_is_deterministic_uuidv5():
|
||||
assert point_id("demo", "memory", "memory:1") == str(
|
||||
uuid5(NAMESPACE_URL, "thothii:demo:memory:memory:1")
|
||||
@@ -324,6 +339,20 @@ def test_health_fails_when_required_payload_indexes_are_missing_without_creating
|
||||
assert fake.payload_indexes == set()
|
||||
|
||||
|
||||
def test_health_reports_when_evidence_bm25_is_unavailable_without_disabling_dense_callers():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
|
||||
health = _store(fake).health()
|
||||
|
||||
assert health.ok is True
|
||||
assert health.bm25_compatible is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("record", "semantic_kind"),
|
||||
[
|
||||
@@ -363,6 +392,7 @@ def test_upsert_serializes_qdrant_point_payloads(record, semantic_kind):
|
||||
|
||||
def test_evidence_upsert_sends_dense_and_server_side_italian_bm25():
|
||||
fake = FakeQdrantHttp()
|
||||
_ready_collection_with_bm25(fake)
|
||||
store = _store(fake)
|
||||
record = _write_record(
|
||||
"demo:gen:11111111111111111111111111111111:chunk:1",
|
||||
@@ -391,6 +421,7 @@ def test_evidence_upsert_sends_dense_and_server_side_italian_bm25():
|
||||
|
||||
def test_evidence_search_uses_filtered_dense_and_bm25_prefetches_with_default_rrf():
|
||||
fake = FakeQdrantHttp()
|
||||
_ready_collection_with_bm25(fake)
|
||||
store = _store(fake)
|
||||
generation = "gen:" + "1" * 32
|
||||
store.upsert("evidence", [
|
||||
@@ -477,6 +508,19 @@ def test_evidence_search_refuses_dense_only_fallback():
|
||||
store.search(["evidence"], [0.2] * 1024, limit=5, kinds=["evidence"])
|
||||
|
||||
|
||||
def test_hybrid_evidence_search_refuses_a_collection_without_bm25():
|
||||
fake = FakeQdrantHttp()
|
||||
_ready_collection_with_bm25(fake)
|
||||
fake.sparse_vectors = None
|
||||
store = _store(fake)
|
||||
|
||||
with pytest.raises(VectorStoreError, match="BM25 collection configuration"):
|
||||
store.search(
|
||||
["evidence"], [0.2] * 1024, limit=5, kinds=["evidence"],
|
||||
query_text="cardiomiopatia", query_language="italian",
|
||||
)
|
||||
|
||||
|
||||
def test_search_excludes_inconsistent_semantic_kind_in_bound_workspace():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake)
|
||||
|
||||
@@ -102,6 +102,7 @@ class QdrantVectorStore:
|
||||
write_reachable=False,
|
||||
write_detail=str(exc),
|
||||
expected_dimension=self._expected_dimension,
|
||||
bm25_compatible=None,
|
||||
)
|
||||
|
||||
dimension = info["config"]["params"]["vectors"]["size"]
|
||||
@@ -118,6 +119,7 @@ class QdrantVectorStore:
|
||||
expected_dimension=self._expected_dimension,
|
||||
observed_dimensions=dimensions,
|
||||
dimension_compatible=compatible,
|
||||
bm25_compatible=self._bm25_compatible(info),
|
||||
)
|
||||
|
||||
def search(
|
||||
@@ -176,6 +178,7 @@ class QdrantVectorStore:
|
||||
raise VectorStoreError("Hybrid BM25 is only available for Evidence")
|
||||
if query_text.strip() == "" or query_language not in _BM25_LANGUAGES:
|
||||
raise VectorStoreError("Evidence BM25 query is invalid")
|
||||
self._ensure_collection(strict=False, require_bm25=True)
|
||||
shared_filter = {"must": filter_must}
|
||||
response = self._call(
|
||||
"POST",
|
||||
@@ -225,7 +228,10 @@ class QdrantVectorStore:
|
||||
|
||||
def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int:
|
||||
validate_collection(collection)
|
||||
self._ensure_collection(strict=True)
|
||||
self._ensure_collection(
|
||||
strict=True,
|
||||
require_bm25=any(record.sparse_text is not None for record in records),
|
||||
)
|
||||
points = []
|
||||
for write_record in records:
|
||||
validate_collection_kinds(collection, [write_record.record.kind])
|
||||
@@ -377,7 +383,15 @@ class QdrantVectorStore:
|
||||
else "Embedding dimension does not match configured dimension"
|
||||
)
|
||||
|
||||
def _ensure_collection(self, *, strict: bool) -> dict | None:
|
||||
@staticmethod
|
||||
def _bm25_compatible(info: dict) -> bool:
|
||||
sparse_vectors = info.get("config", {}).get("params", {}).get("sparse_vectors")
|
||||
if not isinstance(sparse_vectors, dict):
|
||||
return False
|
||||
bm25 = sparse_vectors.get("bm25")
|
||||
return isinstance(bm25, dict) and bm25.get("modifier") == "idf"
|
||||
|
||||
def _ensure_collection(self, *, strict: bool, require_bm25: bool = False) -> dict | None:
|
||||
response = self._call("GET", f"/collections/{self._collection}", None, allow_missing=True)
|
||||
if response is None:
|
||||
if not strict:
|
||||
@@ -420,6 +434,8 @@ class QdrantVectorStore:
|
||||
f"/collections/{self._collection}/index",
|
||||
{"field_name": field_name, "field_schema": "keyword"},
|
||||
)
|
||||
if require_bm25 and not self._bm25_compatible(result):
|
||||
raise VectorStoreError("Evidence BM25 collection configuration mismatch")
|
||||
return result
|
||||
|
||||
def _scroll(self, must: list[dict]) -> list[dict]:
|
||||
|
||||
@@ -367,6 +367,7 @@ class CorpusPipeline:
|
||||
if (
|
||||
not health.ok
|
||||
or health.dimension_compatible is not True
|
||||
or health.bm25_compatible is False
|
||||
or health.expected_dimension != self.embedding_dimensions
|
||||
or health.observed_dimensions != (self.embedding_dimensions,)
|
||||
):
|
||||
|
||||
@@ -30,6 +30,7 @@ class VectorHealth:
|
||||
expected_dimension: int | None = None
|
||||
observed_dimensions: tuple[int, ...] = ()
|
||||
dimension_compatible: bool | None = None
|
||||
bm25_compatible: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
Reference in New Issue
Block a user