Files
semantica/tests/vector_store/test_search_result_schema.py
T
5a0d6ea431 Fix/parse and qdrant vector store (#1508)
* fix(parse): stop infinite recursion in default method dispatch

parse_document and its five sibling dispatchers (parse_web_content,
parse_structured_data, parse_email, parse_code, parse_media) were
registered in the method registry under their own task's "default"
name. Every dispatcher begins with method_registry.get(<task>, method),
so calling e.g. parse_document(file, method="default") found itself in
the registry and re-entered infinitely until RecursionError --
`semantica parse <any file>` crashed before any parsing ran.

Drop the six self-registrations. "default" remains the built-in code
path; users can still register their own "default" (or any other name)
to override it, and the existing "docling" registration is unaffected.

Verified: `semantica parse demo.pdf` now parses successfully (was
RecursionError). No repo code or tests consume
get_parse_method("document", "default"), so removing the entries
changes no behavior besides fixing the crash.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(deps): make resolution satisfiable on Python 3.9 and add parse-pdf extra

Dependency fixes so `uv lock` / `pip install semantica[all]` resolves
across the supported matrix (3.9-3.12):

- requires-python >=3.8 was unsatisfiable (numpy>=2.0.2 needs >=3.9) and
  3.9.0/3.9.1 can never resolve (every cryptography release excludes
  them) -> bump to >=3.9.2 and drop the 3.8 classifier.
- Split recently-raised floors that dropped 3.9 into marker pairs
  (3.9-capped / 3.10-unconstrained), following the pattern already used
  for scikit-learn/requests/etc.: pyarrow extras (>=24 needs 3.10),
  pre-commit 4.6, snowflake-connector 4.6, fastapi 0.129 + starlette 0.53
  (older fastapi caps starlette<0.53).
- Gate docling, litellm (its only 3.9 release pins
  python-dotenv==1.0.1, conflicting with the >=1.2.1 core floor), and
  crewai (no un-yanked 3.9 release) to >=3.10.

Also add a parse-pdf extra: the default PDFParser requires pdfplumber,
but no extra installed it, so `semantica parse file.pdf` failed on a
default install. Follows the parse-docling convention.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(vector-store): make the qdrant backend usable through VectorStore

The qdrant backend could not be used at all through the VectorStore
facade in 0.6.8 - every path raised:

1. store_vectors() only dispatched to backend add/add_vectors methods;
   QdrantStore exposes insert_vectors, so vs.store() raised
   NotImplementedError. Add an insert_vectors branch (uuid-generated
   ids, metadata -> payloads).
2. QdrantStore required an explicit create_collection() before any read
   or write, unlike FAISSStore's automatic index creation. Lazily attach
   the configured collection (config key "collection", default
   "semantica_default") on first insert/search, reusing an existing one.
3. search_points() called client.search(), removed from qdrant-client in
   favor of query_points() - use it when available, fall back otherwise.
4. store() silently dropped plain-string documents (it only extracted
   doc.metadata), so payloads lost the source text; keep them under
   payload "document".

Verified end-to-end against a Qdrant 5 server (docker): embed ->
store -> semantic search returns correctly ranked results whose payloads
carry the original documents.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(vector-store): address Qodo review findings on the qdrant write path

Four findings from the Qodo review of #1508:

1. store_vectors() returned QdrantStore.insert_vectors()' upsert status
   dict although the facade promises the stored vector IDs (decision
   storage indexes the result at position 0). Return the generated or
   caller-supplied IDs after a successful insert instead.
2. insert_vectors() pairs points with zip(vectors, ids), so a shorter
   non-empty id list silently dropped the unpaired vectors while the
   completion message still reported the full batch as inserted. Reject
   the mismatch with ValidationError before any write.
3. _ensure_default_collection() looked up the legacy "collection" config
   key, so the documented collection_name=... option was ignored and
   lazy init always fell back to semantica_default. Prefer
   collection_name, keep "collection" as an alias.
4. The new parse-pdf extra was missing from both aggregate "all"
   bundles, so semantica[all] still shipped without pdfplumber and the
   default PDFParser raised ProcessingError on first use.

Also removes the now-stale strict xfail for qdrant's write dispatch in
test_backend_facade_contract.py — that marker exists precisely to fail
once the wiring lands.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(vector-store): migrate qdrant mocks to the query_points API

The qdrant search path now prefers client.query_points() (qdrant-client
removed client.search), but these tests still mocked the legacy call.
A MagicMock exposes query_points too, so the code took the modern path,
read .points off an unconfigured mock, and all three tests failed on
the branch. Return the hits in response.points as the real client does.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(ci): regenerate requirements-ci.txt for the parse-pdf extra

The CI lockfile check re-resolves pyproject.toml --extra all and diffs
the pinned versions against requirements-ci.txt. The parse-pdf extra
added pdfplumber (+pdfminer-six) to the 'all' bundle without refreshing
the lockfile, so the diff failed and the build job exited 1.

Regenerated with the command from the file header. Only the two new
pins and their 'via' comments changed - every other version is
identical. Verified locally with the CI's own check command (diff of
pkg==ver lines exits clean).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(tests): update stale default-method-registration assertion

test_parse_methods_dynamic_default_resolution asserted that "default"
resolves through the registry to parse_document, which was true only
because of the self-registration this PR removes (it's what caused the
infinite recursion in the first place). Update the assertion to match
the intended post-fix state: "default" is not registered at all, and
falls through to the built-in dispatch path unconditionally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: yanyu <yanyu@polixir.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-09-08 15:47:05 +05:30

300 lines
12 KiB
Python

"""
Test: Search Result Schema Compliance -- Issue #845
Every backend wrapper's search method must return a list of dicts that each
contain the four required canonical fields:
id : str | int
score : float
metadata : dict (always a dict, {} when none stored)
vector : any (np.ndarray | None; None when backend doesn't return vectors)
distance : float | None (preserved from FAISS, Weaviate, Milvus; None elsewhere)
"""
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _assert_canonical_schema(test_case, results):
"""Assert every result satisfies the #845 canonical schema."""
test_case.assertIsInstance(results, list)
for r in results:
test_case.assertIsInstance(r, dict, "result must be a dict")
test_case.assertIn("id", r, "result must contain 'id'")
test_case.assertTrue(isinstance(r["id"], (str, int)), "'id' must be a str or int")
test_case.assertIn("score", r, "result must contain 'score'")
test_case.assertIn("metadata", r, "result must contain 'metadata'")
test_case.assertIn("vector", r, "result must contain 'vector'")
test_case.assertIn("distance", r, "result must contain 'distance'")
test_case.assertIsInstance(r["metadata"], dict, "'metadata' must be a dict")
test_case.assertIsInstance(r["score"], float, "'score' must be a float")
test_case.assertTrue(r["distance"] is None or isinstance(r["distance"], float), "'distance' must be float or None")
# ---------------------------------------------------------------------------
# In-memory backend (no mocking needed)
# ---------------------------------------------------------------------------
class TestInMemorySearchSchema(unittest.TestCase):
def test_search_vectors_canonical_schema(self):
"""In-memory VectorStore.search_vectors() returns canonical schema."""
from semantica.vector_store import VectorStore
store = VectorStore(backend="inmemory", dimension=4)
vectors = [np.array([0.1, 0.2, 0.3, 0.4]), np.array([0.5, 0.6, 0.7, 0.8])]
metadata = [{"type": "a"}, {"type": "b"}]
store.store_vectors(vectors, metadata)
results = store.search_vectors(np.array([0.15, 0.25, 0.35, 0.45]), k=2)
_assert_canonical_schema(self, results)
self.assertEqual(results[0]["metadata"]["type"], "a")
def test_search_vectors_no_metadata_gives_empty_dict(self):
"""In-memory results have metadata={} when no metadata was stored."""
from semantica.vector_store import VectorStore
store = VectorStore(backend="inmemory", dimension=4)
store.store_vectors([np.array([0.1, 0.2, 0.3, 0.4])])
results = store.search_vectors(np.array([0.1, 0.2, 0.3, 0.4]), k=1)
_assert_canonical_schema(self, results)
self.assertEqual(results[0]["metadata"], {})
# ---------------------------------------------------------------------------
# FAISS
# ---------------------------------------------------------------------------
class TestFAISSSearchSchema(unittest.TestCase):
@patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True)
@patch("semantica.vector_store.faiss_store.faiss")
def test_faiss_search_similar_canonical_schema(self, mock_faiss):
from semantica.vector_store.faiss_store import FAISSIndex, FAISSSearch
mock_index = MagicMock()
mock_index.search.return_value = (
np.array([[0.05, 0.2]], dtype=np.float32),
np.array([[0, 1]]),
)
idx = FAISSIndex(mock_index, dimension=4)
idx.vector_ids = ["vec_0", "vec_1"]
idx.metadata = {"vec_0": {"k": "v"}, "vec_1": {}}
searcher = FAISSSearch(idx)
results = searcher.search_similar(np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32), k=2)
_assert_canonical_schema(self, results)
self.assertIn("distance", results[0])
self.assertIsNone(results[0]["vector"])
# ---------------------------------------------------------------------------
# Qdrant
# ---------------------------------------------------------------------------
class TestQdrantSearchSchema(unittest.TestCase):
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_qdrant_search_points_canonical_schema(self):
from semantica.vector_store.qdrant_store import QdrantCollection
mock_client = MagicMock()
mock_hit = MagicMock()
mock_hit.id = "q_1"
mock_hit.score = 0.88
mock_hit.payload = {"category": "x"}
# search_points prefers the modern query_points API; a MagicMock
# exposes it, so the response must carry the hits in .points.
mock_client.query_points.return_value = MagicMock(points=[mock_hit])
coll = QdrantCollection(mock_client, "test_col")
results = coll.search_points(np.array([0.1, 0.2]), limit=1)
_assert_canonical_schema(self, results)
self.assertIsNone(results[0]["vector"])
self.assertEqual(results[0]["metadata"], {"category": "x"})
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
def test_qdrant_unbounded_dot_product_scores_preserve_ranking(self):
"""Qdrant's Dot distance metric is unbounded; normalized scores must
stay strictly ordered instead of collapsing once raw score >= 1.0
(regression for #845 follow-up)."""
from semantica.vector_store.qdrant_store import QdrantCollection
mock_client = MagicMock()
mock_hit_high = MagicMock(id="q_hi", score=50.0, payload={})
mock_hit_mid = MagicMock(id="q_mid", score=2.0, payload={})
mock_hit_low = MagicMock(id="q_lo", score=1.0, payload={})
mock_client.query_points.return_value = MagicMock(
points=[mock_hit_high, mock_hit_mid, mock_hit_low]
)
coll = QdrantCollection(mock_client, "test_col")
results = coll.search_points(np.array([0.1, 0.2]), limit=3)
_assert_canonical_schema(self, results)
scores = [r["score"] for r in results]
self.assertEqual(len(set(scores)), 3, "scores >= 1.0 must not collapse")
self.assertGreater(scores[0], scores[1])
self.assertGreater(scores[1], scores[2])
self.assertTrue(all(0.0 < s < 1.0 for s in scores))
# ---------------------------------------------------------------------------
# Pinecone
# ---------------------------------------------------------------------------
class TestPineconeSearchSchema(unittest.TestCase):
@patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True)
def test_pinecone_search_vectors_canonical_schema(self):
from semantica.vector_store.pinecone_store import PineconeIndex
mock_index = MagicMock()
mock_match = MagicMock()
mock_match.id = "p_1"
mock_match.score = 0.95
mock_match.metadata = {"source": "web"}
mock_index.query.return_value = MagicMock(matches=[mock_match])
pi = PineconeIndex(mock_index)
results = pi.search_vectors([0.1, 0.2], k=1)
_assert_canonical_schema(self, results)
self.assertIsNone(results[0]["vector"])
self.assertEqual(results[0]["metadata"], {"source": "web"})
@patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True)
def test_pinecone_none_metadata_becomes_empty_dict(self):
from semantica.vector_store.pinecone_store import PineconeIndex
mock_index = MagicMock()
mock_match = MagicMock()
mock_match.id = "p_2"
mock_match.score = 0.7
mock_match.metadata = None
mock_index.query.return_value = MagicMock(matches=[mock_match])
pi = PineconeIndex(mock_index)
results = pi.search_vectors([0.1, 0.2], k=1)
_assert_canonical_schema(self, results)
self.assertEqual(results[0]["metadata"], {})
@patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True)
def test_pinecone_unbounded_dotproduct_scores_preserve_ranking(self):
"""Pinecone's dotproduct metric is unbounded; normalized scores must
stay strictly ordered instead of collapsing once raw score >= 1.0
(regression for #845 follow-up)."""
from semantica.vector_store.pinecone_store import PineconeIndex
mock_index = MagicMock()
mock_match_high = MagicMock(id="p_hi", score=50.0, metadata={})
mock_match_mid = MagicMock(id="p_mid", score=2.0, metadata={})
mock_match_low = MagicMock(id="p_lo", score=1.0, metadata={})
mock_index.query.return_value = MagicMock(
matches=[mock_match_high, mock_match_mid, mock_match_low]
)
pi = PineconeIndex(mock_index)
results = pi.search_vectors([0.1, 0.2], k=3)
_assert_canonical_schema(self, results)
scores = [r["score"] for r in results]
self.assertEqual(len(set(scores)), 3, "scores >= 1.0 must not collapse")
self.assertGreater(scores[0], scores[1])
self.assertGreater(scores[1], scores[2])
self.assertTrue(all(0.0 < s < 1.0 for s in scores))
# ---------------------------------------------------------------------------
# Milvus
# ---------------------------------------------------------------------------
class TestMilvusSearchSchema(unittest.TestCase):
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
def test_milvus_search_canonical_schema(self):
from semantica.vector_store.milvus_store import MilvusCollection
mock_collection = MagicMock()
mock_hit = MagicMock()
mock_hit.id = 42
mock_hit.distance = 0.15
mock_collection.search.return_value = [[mock_hit]]
mc = MilvusCollection.__new__(MilvusCollection)
mc.collection = mock_collection
mc.logger = MagicMock()
results = mc.search(
vectors=[np.array([0.1, 0.2])],
anns_field="vector",
param={"metric_type": "L2", "params": {"nprobe": 10}},
limit=1,
)
_assert_canonical_schema(self, results)
self.assertEqual(results[0]["metadata"], {})
self.assertIsNone(results[0]["vector"])
self.assertIn("distance", results[0])
# ---------------------------------------------------------------------------
# Weaviate (direct WeaviateQuery)
# ---------------------------------------------------------------------------
class TestWeaviateSearchSchema(unittest.TestCase):
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
@patch("semantica.vector_store.weaviate_store.MetadataQuery")
def test_weaviate_similarity_search_canonical_schema(self, mock_mq):
from semantica.vector_store.weaviate_store import WeaviateQuery
mock_obj = MagicMock()
mock_obj.uuid = "weaviate-uuid-1"
mock_obj.properties = {"text": "hello", "category": "docs"}
mock_obj.metadata.distance = 0.12
mock_collection = MagicMock()
mock_collection.query.near_vector.return_value = MagicMock(objects=[mock_obj])
wq = WeaviateQuery(mock_collection)
results = wq.similarity_search(np.array([0.1, 0.2]), limit=1)
_assert_canonical_schema(self, results)
self.assertNotIn("properties", results[0])
self.assertEqual(results[0]["metadata"]["text"], "hello")
self.assertIsNone(results[0]["vector"])
self.assertIn("distance", results[0])
self.assertAlmostEqual(results[0]["distance"], 0.12)
# ---------------------------------------------------------------------------
# SearchResult TypedDict is importable
# ---------------------------------------------------------------------------
class TestSearchResultTypeImport(unittest.TestCase):
def test_search_result_importable_from_package(self):
from semantica.vector_store import SearchResult
self.assertTrue(callable(SearchResult))
def test_search_result_importable_from_module(self):
from semantica.vector_store.vector_store import SearchResult
self.assertTrue(callable(SearchResult))
if __name__ == "__main__":
unittest.main()