fix(vector-store): standardize search_vectors output schema

This commit is contained in:
Sameer6305
2026-08-07 19:39:14 +05:30
parent 50758f6f25
commit 5db0adc18a
12 changed files with 292 additions and 5 deletions
+2 -1
View File
@@ -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",
+1
View File
@@ -149,6 +149,7 @@ class FAISSSearch:
"score": similarity_score,
"distance": dist_val,
"metadata": self.index.metadata.get(vector_id, {}),
"vector": None,
}
)
+6
View File
@@ -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)
+1
View File
@@ -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
+1
View File
@@ -196,6 +196,7 @@ class PineconeIndex:
"id": match.id,
"score": match.score,
"metadata": match.metadata or {},
"vector": None,
}
)
+1
View File
@@ -166,6 +166,7 @@ class QdrantCollection:
"id": result.id,
"score": result.score,
"metadata": result.payload or {},
"vector": None,
}
)
@@ -414,6 +414,7 @@ class SQLiteVecStore:
"id": vec_id,
"score": similarity,
"metadata": json.loads(meta_json) if meta_json else {},
"vector": None,
}
)
+26 -3
View File
@@ -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": {},
}
)
+2 -1
View File
@@ -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,
}
)
@@ -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."""
@@ -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()
@@ -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')