Skip to content

Commit c419ff2

Browse files
author
Peter Gustafsson
committed
feat(vector_io): Return structured metadata from vector store search
Signed-off-by: Peter Gustafsson <peter.gustafsson6@gmail.com>
1 parent 0443aa6 commit c419ff2

10 files changed

Lines changed: 136 additions & 10 deletions

File tree

client-sdks/stainless/openapi.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13782,6 +13782,11 @@ components:
1378213782
- type: 'null'
1378313783
description: The search mode to use (e.g., 'vector', 'keyword').
1378413784
default: vector
13785+
include_metadata:
13786+
type: boolean
13787+
title: Include Metadata
13788+
description: Whether to include structured chunk metadata in results.
13789+
default: false
1378513790
type: object
1378613791
required:
1378713792
- query

docs/static/deprecated-ogx-spec.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7447,6 +7447,11 @@ components:
74477447
- type: 'null'
74487448
description: The search mode to use (e.g., 'vector', 'keyword').
74497449
default: vector
7450+
include_metadata:
7451+
type: boolean
7452+
title: Include Metadata
7453+
description: Whether to include structured chunk metadata in results.
7454+
default: false
74507455
type: object
74517456
required:
74527457
- query

docs/static/experimental-ogx-spec.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8817,6 +8817,11 @@ components:
88178817
- type: 'null'
88188818
description: The search mode to use (e.g., 'vector', 'keyword').
88198819
default: vector
8820+
include_metadata:
8821+
type: boolean
8822+
title: Include Metadata
8823+
description: Whether to include structured chunk metadata in results.
8824+
default: false
88208825
type: object
88218826
required:
88228827
- query

docs/static/ogx-spec.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12298,6 +12298,11 @@ components:
1229812298
- type: 'null'
1229912299
description: The search mode to use (e.g., 'vector', 'keyword').
1230012300
default: vector
12301+
include_metadata:
12302+
type: boolean
12303+
title: Include Metadata
12304+
description: Whether to include structured chunk metadata in results.
12305+
default: false
1230112306
type: object
1230212307
required:
1230312308
- query

docs/static/stainless-ogx-spec.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13782,6 +13782,11 @@ components:
1378213782
- type: 'null'
1378313783
description: The search mode to use (e.g., 'vector', 'keyword').
1378413784
default: vector
13785+
include_metadata:
13786+
type: boolean
13787+
title: Include Metadata
13788+
description: Whether to include structured chunk metadata in results.
13789+
default: false
1378513790
type: object
1378613791
required:
1378713792
- query

src/ogx/providers/inline/vector_io/faiss/faiss.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ def _list_op(mv: Any, fv: Any, *, negate: bool) -> bool:
9292
return (mv not in fv) if negate else (mv in fv)
9393

9494

95+
def _metadata_index_key(value: Any) -> Any:
96+
if isinstance(value, dict):
97+
items = ((str(key), _metadata_index_key(item)) for key, item in value.items())
98+
return ("dict", tuple(sorted(items)))
99+
if isinstance(value, list):
100+
return ("list", tuple(_metadata_index_key(item) for item in value))
101+
102+
try:
103+
hash(value)
104+
except TypeError:
105+
return (type(value).__qualname__, repr(value))
106+
return value
107+
108+
95109
_COMPARISON_OPS: dict[str, Any] = {
96110
"eq": lambda mv, fv: mv == fv,
97111
"ne": lambda mv, fv: mv != fv,
@@ -157,7 +171,8 @@ async def initialize(self) -> None:
157171
# Rebuild inverted metadata index from loaded chunks
158172
for pos, chunk in self.chunk_by_index.items():
159173
for key, val in chunk.metadata.items():
160-
self._meta_index.setdefault(key, {}).setdefault(val, set()).add(pos)
174+
index_key = _metadata_index_key(val)
175+
self._meta_index.setdefault(key, {}).setdefault(index_key, set()).add(pos)
161176
except Exception as e:
162177
logger.debug("Failed to deserialize Faiss index", error=str(e), exc_info=True)
163178
raise ValueError(
@@ -204,7 +219,8 @@ async def add_chunks(self, embedded_chunks: list[EmbeddedChunk]):
204219
faiss_pos = indexlen + i
205220
self.chunk_by_index[faiss_pos] = embedded_chunk
206221
for key, val in embedded_chunk.metadata.items():
207-
self._meta_index.setdefault(key, {}).setdefault(val, set()).add(faiss_pos)
222+
index_key = _metadata_index_key(val)
223+
self._meta_index.setdefault(key, {}).setdefault(index_key, set()).add(faiss_pos)
208224

209225
async with self.chunk_id_lock:
210226
self.index.add(embeddings)
@@ -269,16 +285,16 @@ def _resolve_filter_positions(self, filter_obj: Filter) -> set[int]:
269285
# ComparisonFilter
270286
key, value, op_type = filter_obj.key, filter_obj.value, filter_obj.type
271287
if op_type == "eq":
272-
return self._meta_index.get(key, {}).get(value, set()).copy()
288+
return self._meta_index.get(key, {}).get(_metadata_index_key(value), set()).copy()
273289
if op_type == "in":
274290
result: set[int] = set()
275291
for v in value:
276-
result |= self._meta_index.get(key, {}).get(v, set())
292+
result |= self._meta_index.get(key, {}).get(_metadata_index_key(v), set())
277293
return result
278294
if op_type == "nin":
279295
excluded: set[int] = set()
280296
for v in value:
281-
excluded |= self._meta_index.get(key, {}).get(v, set())
297+
excluded |= self._meta_index.get(key, {}).get(_metadata_index_key(v), set())
282298
all_positions = {pos for s in self._meta_index.get(key, {}).values() for pos in s}
283299
return all_positions - excluded
284300
# Range ops and ne: linear scan over chunk_by_index metadata

src/ogx/providers/utils/memory/openai_vector_store_mixin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ async def openai_search_vector_store(
11481148
data = []
11491149
for embedded_chunk, score in zip(response.chunks, response.scores, strict=False):
11501150
chunk = embedded_chunk
1151-
content = self._chunk_to_vector_store_content(chunk)
1151+
content = self._chunk_to_vector_store_content(chunk, include_metadata=request.include_metadata)
11521152

11531153
response_data_item = VectorStoreSearchResponse(
11541154
file_id=chunk.metadata.get("document_id", ""),

src/ogx_api/vector_io/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,13 @@ class VectorStoreSearchResponse(BaseModel):
286286
attributes: dict[str, str | float | bool] | None = None
287287
content: list[VectorStoreContent]
288288

289+
@field_validator("attributes", mode="before")
290+
@classmethod
291+
def _validate_attributes(cls, v: dict[str, Any] | None) -> dict[str, str | float | bool] | None:
292+
if v is None:
293+
return None
294+
return _sanitize_vector_store_attributes(v)
295+
289296

290297
@json_schema_type
291298
class VectorStoreSearchResponsePage(BaseModel):
@@ -822,6 +829,9 @@ class OpenAISearchVectorStoreRequest(BaseModel):
822829
ranking_options: SearchRankingOptions | None = Field(default=None, description="Options for ranking results.")
823830
rewrite_query: bool = Field(default=False, description="Whether to rewrite the query for better results.")
824831
search_mode: str | None = Field(default="vector", description="The search mode to use (e.g., 'vector', 'keyword').")
832+
include_metadata: bool = Field(
833+
default=False, description="Whether to include structured chunk metadata in results."
834+
)
825835

826836

827837
@json_schema_type

tests/unit/providers/vector_io/test_faiss.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,37 @@ async def test_meta_index_populated_on_add_chunks(faiss_index, sample_chunks, sa
173173
assert faiss_index._meta_index["document_id"]["mock-doc-2"] == {1}
174174

175175

176+
async def test_meta_index_supports_structured_metadata(faiss_index, embedding_dimension):
177+
metadata = {
178+
"headings": ["Introduction", "Architecture"],
179+
"page_numbers": [1, 2],
180+
"details": {"language": "en"},
181+
}
182+
chunk = EmbeddedChunk(
183+
content="Chunk text",
184+
chunk_id="structured-metadata-chunk",
185+
metadata=metadata,
186+
chunk_metadata=ChunkMetadata(chunk_id="structured-metadata-chunk", document_id="document-1"),
187+
embedding=np.random.rand(embedding_dimension).astype(np.float32).tolist(),
188+
embedding_model="test-embedding-model",
189+
embedding_dimension=embedding_dimension,
190+
)
191+
192+
await faiss_index.add_chunks([chunk])
193+
194+
from ogx.providers.utils.vector_io.filters import ComparisonFilter
195+
196+
headings = faiss_index._resolve_filter_positions(
197+
ComparisonFilter(key="headings", value=["Introduction", "Architecture"], type="eq")
198+
)
199+
details = faiss_index._resolve_filter_positions(
200+
ComparisonFilter(key="details", value={"language": "en"}, type="eq")
201+
)
202+
assert headings == {0}
203+
assert details == {0}
204+
assert faiss_index.chunk_by_index[0].metadata == metadata
205+
206+
176207
async def test_meta_index_updated_on_delete_chunks(faiss_index, sample_chunks, sample_embeddings, embedding_dimension):
177208
"""After deleting a chunk, its position should be removed and remaining positions shifted."""
178209
from ogx.providers.utils.memory.vector_store import ChunkForDeletion

tests/unit/providers/vector_io/test_vector_io_stores_config.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
import pytest
1313

1414
from ogx_api import (
15+
ChunkMetadata,
1516
EmbeddedChunk,
1617
OpenAICreateVectorStoreRequestWithExtraBody,
18+
OpenAISearchVectorStoreRequest,
1719
QueryChunksResponse,
1820
VectorStore,
1921
)
@@ -196,8 +198,6 @@ async def mock_query_chunks(*args, **kwargs):
196198

197199
# Test that rewrite_query=True doesn't cause an error (it's ignored at mixin level)
198200
# The mixin should process the search request without attempting to rewrite the query
199-
from ogx_api import OpenAISearchVectorStoreRequest
200-
201201
request = OpenAISearchVectorStoreRequest(
202202
query="test query",
203203
max_num_results=5,
@@ -213,6 +213,52 @@ async def mock_query_chunks(*args, **kwargs):
213213
assert result.search_query == ["test query"] # Original query preserved
214214

215215

216+
async def test_search_vector_store_includes_structured_metadata_on_request(vector_io_adapter):
217+
vector_store_id = "test_store_metadata"
218+
vector_io_adapter.openai_vector_stores[vector_store_id] = {
219+
"id": vector_store_id,
220+
"name": "Test Store",
221+
"description": "",
222+
"vector_store_id": "test_db",
223+
"embedding_model": "test/embedding",
224+
}
225+
metadata = {
226+
"document_id": "file-123",
227+
"filename": "guide.pdf",
228+
"headings": ["Introduction", "Architecture"],
229+
"page_numbers": [1, 2],
230+
}
231+
chunk = EmbeddedChunk(
232+
content="Chunk text",
233+
chunk_id="chunk-123",
234+
metadata=metadata,
235+
chunk_metadata=ChunkMetadata(chunk_id="chunk-123", document_id="file-123"),
236+
embedding=[0.1, 0.2],
237+
embedding_model="test/embedding",
238+
embedding_dimension=2,
239+
)
240+
vector_io_adapter.query_chunks = AsyncMock(return_value=QueryChunksResponse(chunks=[chunk], scores=[0.9]))
241+
242+
default_result = await vector_io_adapter.openai_search_vector_store(
243+
vector_store_id=vector_store_id,
244+
request=OpenAISearchVectorStoreRequest(query="architecture"),
245+
)
246+
included_result = await vector_io_adapter.openai_search_vector_store(
247+
vector_store_id=vector_store_id,
248+
request=OpenAISearchVectorStoreRequest(query="architecture", include_metadata=True),
249+
)
250+
251+
assert default_result.data[0].content[0].metadata is None
252+
assert default_result.data[0].attributes == {
253+
"document_id": "file-123",
254+
"filename": "guide.pdf",
255+
"headings": "Introduction, Architecture",
256+
"page_numbers": "1, 2",
257+
}
258+
assert included_result.data[0].content[0].metadata == metadata
259+
assert included_result.data[0].content[0].chunk_metadata == chunk.chunk_metadata
260+
261+
216262
async def test_search_vector_store_propagates_backend_errors(vector_io_adapter):
217263
"""Test that exceptions from the vector store backend propagate to the caller."""
218264
vector_store_id = "test_store_error"
@@ -229,8 +275,6 @@ async def mock_query_chunks(*args, **kwargs):
229275

230276
vector_io_adapter.query_chunks = mock_query_chunks
231277

232-
from ogx_api import OpenAISearchVectorStoreRequest
233-
234278
request = OpenAISearchVectorStoreRequest(query="test query", max_num_results=5)
235279
with pytest.raises(KeyError, match="chunk_content"):
236280
await vector_io_adapter.openai_search_vector_store(

0 commit comments

Comments
 (0)