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