From 5db0adc18a219b8e6343839764b17f810d4ce336 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Fri, 7 Aug 2026 19:39:14 +0530 Subject: [PATCH] fix(vector-store): standardize search_vectors output schema --- semantica/vector_store/__init__.py | 3 +- semantica/vector_store/faiss_store.py | 1 + semantica/vector_store/milvus_store.py | 6 + semantica/vector_store/pgvector_store.py | 1 + semantica/vector_store/pinecone_store.py | 1 + semantica/vector_store/qdrant_store.py | 1 + semantica/vector_store/sqlite_vec_store.py | 1 + semantica/vector_store/vector_store.py | 29 ++- semantica/vector_store/weaviate_store.py | 3 +- .../test_backward_compatibility.py | 2 + .../vector_store/test_search_result_schema.py | 246 ++++++++++++++++++ .../test_vector_store_deepdive.py | 3 + 12 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 tests/vector_store/test_search_result_schema.py diff --git a/semantica/vector_store/__init__.py b/semantica/vector_store/__init__.py index 194ef4bd..45b617b4 100644 --- a/semantica/vector_store/__init__.py +++ b/semantica/vector_store/__init__.py @@ -182,7 +182,7 @@ from .pinecone_store import PineconeStore, PineconeClient, PineconeIndex, Pineco from .qdrant_store import QdrantStore, QdrantClient, QdrantCollection, QdrantSearch from .registry import MethodRegistry, method_registry from .sqlite_vec_store import SQLiteVecStore -from .vector_store import VectorIndexer, VectorManager, VectorRetriever, VectorStore +from .vector_store import VectorIndexer, VectorManager, VectorRetriever, VectorStore, SearchResult from .weaviate_store import ( WeaviateStore, WeaviateClient, @@ -196,6 +196,7 @@ __all__ = [ "VectorIndexer", "VectorRetriever", "VectorManager", + "SearchResult", # FAISS "FAISSStore", "FAISSIndex", diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index cd759886..e07dcc1b 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -149,6 +149,7 @@ class FAISSSearch: "score": similarity_score, "distance": dist_val, "metadata": self.index.metadata.get(vector_id, {}), + "vector": None, } ) diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py index 4f8b6f93..8514aac3 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -165,6 +165,12 @@ class MilvusCollection: "score": 1.0 - hit.distance if hit.distance <= 1.0 else 1.0 / (1.0 + hit.distance), + # Milvus collection schema stores only id+vector; no + # metadata field is defined in create_collection(). + # Return empty dict — a future schema migration that + # adds a metadata JSON field is tracked separately. + "metadata": {}, + "vector": None, } ) results.append(batch_results) diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index 6a646cd8..15d6080a 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -445,6 +445,7 @@ class PgVectorStore: "id": vec_id, "score": similarity, "metadata": meta if isinstance(meta, dict) else json.loads(meta), + "vector": None, }) return results diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index f1cc5135..d7a8196e 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -196,6 +196,7 @@ class PineconeIndex: "id": match.id, "score": match.score, "metadata": match.metadata or {}, + "vector": None, } ) diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py index 88e5b397..311636df 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -166,6 +166,7 @@ class QdrantCollection: "id": result.id, "score": result.score, "metadata": result.payload or {}, + "vector": None, } ) diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index 0ef7d9fc..87d43e02 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -414,6 +414,7 @@ class SQLiteVecStore: "id": vec_id, "score": similarity, "metadata": json.loads(meta_json) if meta_json else {}, + "vector": None, } ) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index 86f9c07b..97374e26 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -65,7 +65,7 @@ Author: Semantica Contributors License: MIT """ -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union import concurrent.futures import inspect @@ -78,6 +78,26 @@ from ..embeddings import EmbeddingGenerator from .hybrid_similarity import HybridSimilarityCalculator from .decision_embedding_pipeline import DecisionEmbeddingPipeline +class SearchResult(TypedDict, total=False): + """Canonical schema returned by VectorStore.search_vectors(). + + Required fields (always present): + id – string identifier of the stored vector. + score – float similarity score, higher is better (normalised by each backend). + metadata – dict of associated metadata; empty dict when none is stored. + vector – np.ndarray when the backend returns the raw vector, otherwise None. + + Optional/preserved fields (may be present depending on backend): + distance – raw native distance value preserved for backends that expose it + (FAISS L2, Weaviate cosine); None when not available. + """ + + id: str + score: float + metadata: Dict[str, Any] + vector: Optional[Any] # np.ndarray | None + distance: Optional[float] # preserved when backend exposes it; not required + class VectorStore: """ @@ -634,7 +654,7 @@ class VectorStore: def search_vectors( self, query_vector: np.ndarray, k: int = 10, **options - ) -> List[Dict[str, Any]]: + ) -> List[SearchResult]: """ Search for similar vectors. @@ -687,11 +707,13 @@ class VectorStore: **options, ) - # Add metadata to results if available + # Add metadata to results; guarantee the key always exists. for result in results: vector_id = result.get("id") if vector_id and vector_id in self.metadata: result["metadata"] = self.metadata[vector_id] + elif "metadata" not in result: + result["metadata"] = {} self.progress_tracker.stop_tracking( tracking_id, @@ -1276,6 +1298,7 @@ class VectorRetriever: "id": ids[idx], "vector": vectors[idx], "score": float(similarities[idx]), + "metadata": {}, } ) diff --git a/semantica/vector_store/weaviate_store.py b/semantica/vector_store/weaviate_store.py index 9dd4e9ff..dac04de8 100644 --- a/semantica/vector_store/weaviate_store.py +++ b/semantica/vector_store/weaviate_store.py @@ -177,7 +177,7 @@ class WeaviateQuery: results.append( { "id": str(obj.uuid), - "properties": obj.properties, + "metadata": obj.properties if obj.properties is not None else {}, "distance": obj.metadata.distance if obj.metadata else None, "score": 1.0 - ( @@ -185,6 +185,7 @@ class WeaviateQuery: if obj.metadata and obj.metadata.distance else 0.0 ), + "vector": None, } ) diff --git a/tests/vector_store/test_backward_compatibility.py b/tests/vector_store/test_backward_compatibility.py index 29098ba0..a4d8766a 100644 --- a/tests/vector_store/test_backward_compatibility.py +++ b/tests/vector_store/test_backward_compatibility.py @@ -76,6 +76,8 @@ class TestVectorStoreBackwardCompatibility: assert all("id" in result for result in results) assert all("score" in result for result in results) assert all("vector" in result for result in results) + assert all("metadata" in result for result in results) + assert all(isinstance(result["metadata"], dict) for result in results) def test_search_method_unchanged(self): """Test search method unchanged.""" diff --git a/tests/vector_store/test_search_result_schema.py b/tests/vector_store/test_search_result_schema.py new file mode 100644 index 00000000..d8a88969 --- /dev/null +++ b/tests/vector_store/test_search_result_schema.py @@ -0,0 +1,246 @@ +""" +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 + score : float + metadata : dict (always a dict, {} when none stored) + vector : any (np.ndarray | None; None when backend doesn't return vectors) + +Optional preserved fields (may be present): + distance : float | None (preserved from FAISS, Weaviate; absent 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.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.assertIsInstance(r["metadata"], dict, "'metadata' must be a dict") + test_case.assertIsInstance(r["score"], float, "'score' must be a float") + + +# --------------------------------------------------------------------------- +# 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"} + mock_client.search.return_value = [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"}) + + +# --------------------------------------------------------------------------- +# 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"], {}) + + +# --------------------------------------------------------------------------- +# 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() diff --git a/tests/vector_store/test_vector_store_deepdive.py b/tests/vector_store/test_vector_store_deepdive.py index 3d8d70be..ed31e5d3 100644 --- a/tests/vector_store/test_vector_store_deepdive.py +++ b/tests/vector_store/test_vector_store_deepdive.py @@ -195,6 +195,9 @@ class TestVectorStoreDeepDive(unittest.TestCase): mock_collection_instance.search.assert_called() self.assertEqual(len(results), 1) self.assertEqual(results[0]["id"], 1) + self.assertIn("metadata", results[0]) + self.assertIsInstance(results[0]["metadata"], dict) + self.assertIn("vector", results[0]) @patch('semantica.vector_store.qdrant_store.QdrantClientLib') @patch('semantica.vector_store.qdrant_store.VectorParams')