projectdavid 1.31.0__py3-none-any.whl → 1.38.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- projectdavid/clients/assistants_client.py +7 -13
- projectdavid/clients/file_processor.py +102 -107
- projectdavid/clients/messages_client.py +24 -39
- projectdavid/clients/runs.py +156 -211
- projectdavid/clients/synchronous_inference_wrapper.py +52 -24
- projectdavid/clients/threads_client.py +32 -12
- projectdavid/clients/vector_store_manager.py +110 -21
- projectdavid/clients/vectors.py +47 -30
- projectdavid/clients/vision-file_processor.py +462 -0
- projectdavid/clients/vision_vectors.py +1058 -0
- projectdavid/decorators.py +64 -0
- projectdavid/entity.py +24 -5
- projectdavid/synthesis/reranker.py +4 -2
- projectdavid/utils/function_call_suppressor.py +40 -0
- {projectdavid-1.31.0.dist-info → projectdavid-1.38.1.dist-info}/METADATA +6 -7
- {projectdavid-1.31.0.dist-info → projectdavid-1.38.1.dist-info}/RECORD +19 -15
- {projectdavid-1.31.0.dist-info → projectdavid-1.38.1.dist-info}/WHEEL +1 -1
- {projectdavid-1.31.0.dist-info → projectdavid-1.38.1.dist-info}/licenses/LICENSE +0 -0
- {projectdavid-1.31.0.dist-info → projectdavid-1.38.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
"""projectdavid.clients.vector_store_client
|
|
2
|
+
---------------------------------------
|
|
3
|
+
|
|
4
|
+
Token-scoped HTTP client + local Qdrant helper for vector-store operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import uuid
|
|
10
|
+
import warnings
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
from PIL import Image
|
|
17
|
+
from projectdavid_common import UtilsInterface, ValidationInterface
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
from qdrant_client.http import models as qdrant
|
|
20
|
+
|
|
21
|
+
from projectdavid.clients.file_processor import FileProcessor
|
|
22
|
+
from projectdavid.clients.vector_store_manager import VectorStoreManager
|
|
23
|
+
from projectdavid.decorators import experimental
|
|
24
|
+
from projectdavid.synthesis import reranker, retriever
|
|
25
|
+
from projectdavid.synthesis.llm_synthesizer import synthesize_envelope
|
|
26
|
+
from projectdavid.utils.vector_search_formatter import make_envelope
|
|
27
|
+
|
|
28
|
+
load_dotenv()
|
|
29
|
+
log = UtilsInterface.LoggingUtility()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summarize_hits(query: str, hits: List[Dict[str, Any]]) -> str:
|
|
33
|
+
lines = [f"• {h['meta_data']['file_name']} (score {h['score']:.2f})" for h in hits]
|
|
34
|
+
return f"Top files for **{query}**:\n" + "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
# Exceptions
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
class VectorStoreClientError(Exception):
|
|
41
|
+
"""Raised on any client-side or API error."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# Helper schema
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
class VectorStoreFileUpdateStatusInput(BaseModel):
|
|
48
|
+
status: ValidationInterface.StatusEnum = Field(
|
|
49
|
+
..., description="The new status for the file record."
|
|
50
|
+
)
|
|
51
|
+
error_message: Optional[str] = Field(
|
|
52
|
+
None, description="Error message if status is 'failed'."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- #
|
|
57
|
+
# Main client
|
|
58
|
+
# --------------------------------------------------------------------------- #
|
|
59
|
+
class VectorStoreClient:
|
|
60
|
+
"""
|
|
61
|
+
Thin HTTP+Qdrant wrapper.
|
|
62
|
+
|
|
63
|
+
• All API requests scoped by X-API-Key.
|
|
64
|
+
• create_vector_store() no longer takes user_id; ownership from token.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------ #
|
|
68
|
+
# Construction / cleanup
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
base_url: Optional[str] = None,
|
|
73
|
+
api_key: Optional[str] = None,
|
|
74
|
+
*,
|
|
75
|
+
vector_store_host: str = "localhost",
|
|
76
|
+
file_processor_kwargs: Optional[dict] = None,
|
|
77
|
+
):
|
|
78
|
+
|
|
79
|
+
self.base_url = (base_url or os.getenv("BASE_URL", "")).rstrip("/")
|
|
80
|
+
self.api_key = api_key or os.getenv("API_KEY")
|
|
81
|
+
if not self.base_url:
|
|
82
|
+
raise VectorStoreClientError("BASE_URL is required.")
|
|
83
|
+
|
|
84
|
+
self._base_headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
85
|
+
if self.api_key:
|
|
86
|
+
self._base_headers["X-API-Key"] = self.api_key
|
|
87
|
+
else:
|
|
88
|
+
log.warning("No API key — protected routes will fail.")
|
|
89
|
+
|
|
90
|
+
self._sync_api_client = httpx.Client(
|
|
91
|
+
base_url=self.base_url, headers=self._base_headers, timeout=30.0
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Local helpers ---------------------------------------------------
|
|
95
|
+
self.vector_manager = VectorStoreManager(vector_store_host=vector_store_host)
|
|
96
|
+
self.identifier_service = UtilsInterface.IdentifierService()
|
|
97
|
+
|
|
98
|
+
# 🔶 forward kwargs into the upgraded FileProcessor
|
|
99
|
+
|
|
100
|
+
self.file_processor = FileProcessor(
|
|
101
|
+
**(
|
|
102
|
+
file_processor_kwargs
|
|
103
|
+
or {
|
|
104
|
+
"use_gpu": False,
|
|
105
|
+
"use_detection": True,
|
|
106
|
+
"use_geo": True,
|
|
107
|
+
"use_ocr": True,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
log.info("VectorStoreClient → %s", self.base_url)
|
|
113
|
+
|
|
114
|
+
# Context support ------------------------------------------------------ #
|
|
115
|
+
def __enter__(self):
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *_exc):
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
async def __aenter__(self):
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __aexit__(self, *_exc):
|
|
125
|
+
await self.aclose()
|
|
126
|
+
|
|
127
|
+
# Cleanup -------------------------------------------------------------- #
|
|
128
|
+
async def aclose(self):
|
|
129
|
+
await asyncio.to_thread(self._sync_api_client.close)
|
|
130
|
+
|
|
131
|
+
def close(self):
|
|
132
|
+
try:
|
|
133
|
+
loop = asyncio.get_running_loop()
|
|
134
|
+
if loop.is_running():
|
|
135
|
+
warnings.warn(
|
|
136
|
+
"close() inside running loop — use `await aclose()`",
|
|
137
|
+
RuntimeWarning,
|
|
138
|
+
)
|
|
139
|
+
self._sync_api_client.close()
|
|
140
|
+
return
|
|
141
|
+
except RuntimeError:
|
|
142
|
+
pass
|
|
143
|
+
asyncio.run(self.aclose())
|
|
144
|
+
|
|
145
|
+
# Low-level HTTP helpers ---------------------------------------------- #
|
|
146
|
+
async def _parse_response(self, resp: httpx.Response) -> Any:
|
|
147
|
+
try:
|
|
148
|
+
resp.raise_for_status()
|
|
149
|
+
return None if resp.status_code == 204 else resp.json()
|
|
150
|
+
except httpx.HTTPStatusError as exc:
|
|
151
|
+
log.error("API %d – %s", exc.response.status_code, exc.response.text)
|
|
152
|
+
raise VectorStoreClientError(
|
|
153
|
+
f"API {exc.response.status_code}: {exc.response.text}"
|
|
154
|
+
) from exc
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
raise VectorStoreClientError(f"Invalid response: {resp.text}") from exc
|
|
157
|
+
|
|
158
|
+
async def _request(self, method: str, url: str, **kwargs) -> Any:
|
|
159
|
+
retries = 3
|
|
160
|
+
for attempt in range(1, retries + 1):
|
|
161
|
+
try:
|
|
162
|
+
async with httpx.AsyncClient(
|
|
163
|
+
base_url=self.base_url,
|
|
164
|
+
headers=self._base_headers,
|
|
165
|
+
timeout=30.0,
|
|
166
|
+
) as client:
|
|
167
|
+
resp = await client.request(method, url, **kwargs)
|
|
168
|
+
return await self._parse_response(resp)
|
|
169
|
+
except (
|
|
170
|
+
httpx.TimeoutException,
|
|
171
|
+
httpx.NetworkError,
|
|
172
|
+
httpx.HTTPStatusError,
|
|
173
|
+
) as exc:
|
|
174
|
+
retryable = isinstance(
|
|
175
|
+
exc, (httpx.TimeoutException, httpx.NetworkError)
|
|
176
|
+
) or (
|
|
177
|
+
isinstance(exc, httpx.HTTPStatusError)
|
|
178
|
+
and exc.response.status_code >= 500
|
|
179
|
+
)
|
|
180
|
+
if retryable and attempt < retries:
|
|
181
|
+
backoff = 2 ** (attempt - 1)
|
|
182
|
+
log.warning(
|
|
183
|
+
"Retry %d/%d %s %s in %ds – %s",
|
|
184
|
+
attempt,
|
|
185
|
+
retries,
|
|
186
|
+
method,
|
|
187
|
+
url,
|
|
188
|
+
backoff,
|
|
189
|
+
exc,
|
|
190
|
+
)
|
|
191
|
+
await asyncio.sleep(backoff)
|
|
192
|
+
continue
|
|
193
|
+
raise VectorStoreClientError(str(exc)) from exc
|
|
194
|
+
raise VectorStoreClientError("Request failed after retries")
|
|
195
|
+
|
|
196
|
+
# Internal async ops -------------------------------------------------- #
|
|
197
|
+
async def _create_vs_async(
|
|
198
|
+
self,
|
|
199
|
+
name: str,
|
|
200
|
+
vector_size: int,
|
|
201
|
+
distance_metric: str,
|
|
202
|
+
config: Optional[Dict[str, Any]],
|
|
203
|
+
vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
|
|
204
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
205
|
+
shared_id = self.identifier_service.generate_vector_id()
|
|
206
|
+
# forward multi-vector schema if given
|
|
207
|
+
self.vector_manager.create_store(
|
|
208
|
+
collection_name=shared_id,
|
|
209
|
+
vector_size=vector_size,
|
|
210
|
+
distance=distance_metric.upper(),
|
|
211
|
+
vectors_config=vectors_config,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
payload = {
|
|
215
|
+
"shared_id": shared_id,
|
|
216
|
+
"name": name,
|
|
217
|
+
"vector_size": vector_size,
|
|
218
|
+
"distance_metric": distance_metric.upper(),
|
|
219
|
+
"config": config or {},
|
|
220
|
+
}
|
|
221
|
+
resp = await self._request("POST", "/v1/vector-stores", json=payload)
|
|
222
|
+
return ValidationInterface.VectorStoreRead.model_validate(resp)
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------ #
|
|
225
|
+
# NEW admin‑aware creation helper
|
|
226
|
+
# ------------------------------------------------------------------ #
|
|
227
|
+
async def _create_vs_for_user_async(
|
|
228
|
+
self,
|
|
229
|
+
owner_id: str,
|
|
230
|
+
name: str,
|
|
231
|
+
vector_size: int,
|
|
232
|
+
distance_metric: str,
|
|
233
|
+
config: Optional[Dict[str, Any]],
|
|
234
|
+
vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
|
|
235
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
236
|
+
shared_id = self.identifier_service.generate_vector_id()
|
|
237
|
+
# forward multi-vector schema if given
|
|
238
|
+
self.vector_manager.create_store(
|
|
239
|
+
collection_name=shared_id,
|
|
240
|
+
vector_size=vector_size,
|
|
241
|
+
distance=distance_metric.upper(),
|
|
242
|
+
vectors_config=vectors_config,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
payload = {
|
|
246
|
+
"shared_id": shared_id,
|
|
247
|
+
"name": name,
|
|
248
|
+
"vector_size": vector_size,
|
|
249
|
+
"distance_metric": distance_metric.upper(),
|
|
250
|
+
"config": config or {},
|
|
251
|
+
}
|
|
252
|
+
resp = await self._request(
|
|
253
|
+
"POST",
|
|
254
|
+
"/v1/vector-stores",
|
|
255
|
+
json=payload,
|
|
256
|
+
params={"owner_id": owner_id},
|
|
257
|
+
)
|
|
258
|
+
return ValidationInterface.VectorStoreRead.model_validate(resp)
|
|
259
|
+
|
|
260
|
+
async def _add_file_async(
|
|
261
|
+
self, vector_store_id: str, p: Path, meta: Optional[Dict[str, Any]]
|
|
262
|
+
) -> ValidationInterface.VectorStoreFileRead:
|
|
263
|
+
processed = await self.file_processor.process_file(p)
|
|
264
|
+
texts, vectors = processed["chunks"], processed["vectors"]
|
|
265
|
+
line_data = processed.get("line_data") or [] # ← NEW
|
|
266
|
+
|
|
267
|
+
base_md = meta or {}
|
|
268
|
+
base_md.update({"source": str(p), "file_name": p.name})
|
|
269
|
+
|
|
270
|
+
file_record_id = f"vsf_{uuid.uuid4()}"
|
|
271
|
+
|
|
272
|
+
# Build per‑chunk payload, now including page/lines if present
|
|
273
|
+
chunk_md = []
|
|
274
|
+
for i in range(len(texts)):
|
|
275
|
+
payload = {
|
|
276
|
+
**base_md,
|
|
277
|
+
"chunk_index": i,
|
|
278
|
+
"file_id": file_record_id,
|
|
279
|
+
}
|
|
280
|
+
if i < len(line_data): # ← NEW
|
|
281
|
+
payload.update(line_data[i]) # {'page': …, 'lines': …}
|
|
282
|
+
chunk_md.append(payload)
|
|
283
|
+
|
|
284
|
+
self.vector_manager.add_to_store(
|
|
285
|
+
store_name=vector_store_id,
|
|
286
|
+
texts=texts,
|
|
287
|
+
vectors=vectors,
|
|
288
|
+
metadata=chunk_md,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
resp = await self._request(
|
|
292
|
+
"POST",
|
|
293
|
+
f"/v1/vector-stores/{vector_store_id}/files",
|
|
294
|
+
json={
|
|
295
|
+
"file_id": file_record_id,
|
|
296
|
+
"file_name": p.name,
|
|
297
|
+
"file_path": str(p),
|
|
298
|
+
"status": "completed",
|
|
299
|
+
"meta_data": meta or {},
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
return ValidationInterface.VectorStoreFileRead.model_validate(resp)
|
|
303
|
+
|
|
304
|
+
async def _search_vs_async(
|
|
305
|
+
self,
|
|
306
|
+
vector_store_id: str,
|
|
307
|
+
query_text: Union[str, List[float]],
|
|
308
|
+
top_k: int,
|
|
309
|
+
filters: Optional[Dict] = None,
|
|
310
|
+
vector_store_host: Optional[str] = None,
|
|
311
|
+
vector_field: Optional[str] = None, # allow caller override
|
|
312
|
+
) -> List[Dict[str, Any]]:
|
|
313
|
+
"""
|
|
314
|
+
Internal: run ANN search against the specified vector field or auto-detect by store size.
|
|
315
|
+
|
|
316
|
+
If `vector_field` is provided, it will be used directly. Otherwise:
|
|
317
|
+
• 1024-D → caption_vector
|
|
318
|
+
• 3-D → geo_vector
|
|
319
|
+
• others → default vector (text)
|
|
320
|
+
"""
|
|
321
|
+
# pick local vs. override host
|
|
322
|
+
vector_manager = (
|
|
323
|
+
VectorStoreManager(vector_store_host=vector_store_host)
|
|
324
|
+
if vector_store_host
|
|
325
|
+
else self.vector_manager
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# fetch store info to inspect schema
|
|
329
|
+
store = self.retrieve_vector_store_sync(vector_store_id)
|
|
330
|
+
|
|
331
|
+
# determine the query vector and target field
|
|
332
|
+
if vector_field is not None:
|
|
333
|
+
# if caller passed a raw vector list, use it; otherwise treat as caption search
|
|
334
|
+
if isinstance(query_text, list):
|
|
335
|
+
vec = query_text
|
|
336
|
+
else:
|
|
337
|
+
vec = self.file_processor.encode_clip_text(query_text).tolist()
|
|
338
|
+
else:
|
|
339
|
+
# auto-detect based on stored vector dimensionality
|
|
340
|
+
if store.vector_size == 1024:
|
|
341
|
+
# image/caption space
|
|
342
|
+
vec = self.file_processor.encode_clip_text(query_text).tolist()
|
|
343
|
+
vector_field = "caption_vector"
|
|
344
|
+
elif store.vector_size == 3:
|
|
345
|
+
# geo space; query_text must be a raw 3-D list
|
|
346
|
+
if not isinstance(query_text, list):
|
|
347
|
+
raise VectorStoreClientError(
|
|
348
|
+
"Geo search requires a 3-element vector; pass raw unit-sphere list"
|
|
349
|
+
)
|
|
350
|
+
vec = query_text
|
|
351
|
+
vector_field = "geo_vector"
|
|
352
|
+
else:
|
|
353
|
+
# fallback to text embedding
|
|
354
|
+
vec = self.file_processor.encode_text(query_text).tolist()
|
|
355
|
+
vector_field = None # use default
|
|
356
|
+
|
|
357
|
+
# perform the search on the selected vector column
|
|
358
|
+
return vector_manager.query_store(
|
|
359
|
+
store_name=store.collection_name,
|
|
360
|
+
query_vector=vec,
|
|
361
|
+
top_k=top_k,
|
|
362
|
+
filters=filters,
|
|
363
|
+
vector_field=vector_field,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def _delete_vs_async(
|
|
367
|
+
self, vector_store_id: str, permanent: bool
|
|
368
|
+
) -> Dict[str, Any]:
|
|
369
|
+
qres = self.vector_manager.delete_store(vector_store_id)
|
|
370
|
+
await self._request(
|
|
371
|
+
"DELETE",
|
|
372
|
+
f"/v1/vector-stores/{vector_store_id}",
|
|
373
|
+
params={"permanent": permanent},
|
|
374
|
+
)
|
|
375
|
+
return {
|
|
376
|
+
"vector_store_id": vector_store_id,
|
|
377
|
+
"status": "deleted",
|
|
378
|
+
"permanent": permanent,
|
|
379
|
+
"qdrant_result": qres,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async def _delete_file_async(
|
|
383
|
+
self, vector_store_id: str, file_path: str
|
|
384
|
+
) -> Dict[str, Any]:
|
|
385
|
+
fres = self.vector_manager.delete_file_from_store(vector_store_id, file_path)
|
|
386
|
+
await self._request(
|
|
387
|
+
"DELETE",
|
|
388
|
+
f"/v1/vector-stores/{vector_store_id}/files",
|
|
389
|
+
params={"file_path": file_path},
|
|
390
|
+
)
|
|
391
|
+
return {
|
|
392
|
+
"vector_store_id": vector_store_id,
|
|
393
|
+
"file_path": file_path,
|
|
394
|
+
"status": "deleted",
|
|
395
|
+
"qdrant_result": fres,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async def _list_store_files_async(
|
|
399
|
+
self, vector_store_id: str
|
|
400
|
+
) -> List[ValidationInterface.VectorStoreFileRead]:
|
|
401
|
+
resp = await self._request("GET", f"/v1/vector-stores/{vector_store_id}/files")
|
|
402
|
+
return [
|
|
403
|
+
ValidationInterface.VectorStoreFileRead.model_validate(item)
|
|
404
|
+
for item in resp
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
async def _update_file_status_async(
|
|
408
|
+
self,
|
|
409
|
+
vector_store_id: str,
|
|
410
|
+
file_id: str,
|
|
411
|
+
status: ValidationInterface.StatusEnum,
|
|
412
|
+
error_message: Optional[str] = None,
|
|
413
|
+
) -> ValidationInterface.VectorStoreFileRead:
|
|
414
|
+
payload = VectorStoreFileUpdateStatusInput(
|
|
415
|
+
status=status, error_message=error_message
|
|
416
|
+
).model_dump(exclude_none=True)
|
|
417
|
+
resp = await self._request(
|
|
418
|
+
"PATCH",
|
|
419
|
+
f"/v1/vector-stores/{vector_store_id}/files/{file_id}",
|
|
420
|
+
json=payload,
|
|
421
|
+
)
|
|
422
|
+
return ValidationInterface.VectorStoreFileRead.model_validate(resp)
|
|
423
|
+
|
|
424
|
+
async def _get_assistant_vs_async(
|
|
425
|
+
self, assistant_id: str
|
|
426
|
+
) -> List[ValidationInterface.VectorStoreRead]:
|
|
427
|
+
resp = await self._request(
|
|
428
|
+
"GET", f"/v1/assistants/{assistant_id}/vector-stores"
|
|
429
|
+
)
|
|
430
|
+
return [
|
|
431
|
+
ValidationInterface.VectorStoreRead.model_validate(item) for item in resp
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
async def _attach_vs_async(self, vector_store_id: str, assistant_id: str) -> bool:
|
|
435
|
+
await self._request(
|
|
436
|
+
"POST",
|
|
437
|
+
f"/v1/assistants/{assistant_id}/vector-stores/{vector_store_id}/attach",
|
|
438
|
+
)
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
async def _detach_vs_async(self, vector_store_id: str, assistant_id: str) -> bool:
|
|
442
|
+
await self._request(
|
|
443
|
+
"DELETE",
|
|
444
|
+
f"/v1/assistants/{assistant_id}/vector-stores/{vector_store_id}/detach",
|
|
445
|
+
)
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
# Sync facade helpers ------------------------------------------------ #
|
|
449
|
+
def _run_sync(self, coro):
|
|
450
|
+
try:
|
|
451
|
+
loop = asyncio.get_running_loop()
|
|
452
|
+
if loop.is_running():
|
|
453
|
+
raise VectorStoreClientError("Sync call inside running loop")
|
|
454
|
+
except RuntimeError:
|
|
455
|
+
pass
|
|
456
|
+
return asyncio.run(coro)
|
|
457
|
+
|
|
458
|
+
# ──────────────────────────────────────────────────────────────────
|
|
459
|
+
# Helpers (private)
|
|
460
|
+
# ──────────────────────────────────────────────────────────────────
|
|
461
|
+
@staticmethod
|
|
462
|
+
def _normalise_hits(raw_hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
463
|
+
"""
|
|
464
|
+
Ensure each hit dict contains a top‑level 'meta_data' key so that all
|
|
465
|
+
downstream components (reranker, synthesizer, envelope builder) can
|
|
466
|
+
rely on a stable schema.
|
|
467
|
+
"""
|
|
468
|
+
normalised: List[Dict[str, Any]] = []
|
|
469
|
+
for h in raw_hits:
|
|
470
|
+
md = h.get("meta_data") or h.get("metadata") or {}
|
|
471
|
+
normalised.append(
|
|
472
|
+
{
|
|
473
|
+
"text": h["text"],
|
|
474
|
+
"score": h["score"],
|
|
475
|
+
"meta_data": md,
|
|
476
|
+
"vector_id": h.get("vector_id"),
|
|
477
|
+
"store_id": h.get("store_id"),
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
return normalised
|
|
481
|
+
|
|
482
|
+
# Public API ---------------------------------------------------------- #
|
|
483
|
+
def create_vector_store(
|
|
484
|
+
self,
|
|
485
|
+
name: str,
|
|
486
|
+
*,
|
|
487
|
+
vector_size: int = 384,
|
|
488
|
+
distance_metric: str = "Cosine",
|
|
489
|
+
config: Optional[Dict[str, Any]] = None,
|
|
490
|
+
vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
|
|
491
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
492
|
+
"""
|
|
493
|
+
Create a new store owned by this API key.
|
|
494
|
+
|
|
495
|
+
If `vectors_config` is provided, it should map each vector
|
|
496
|
+
field name to its Qdrant VectorParams (size + distance).
|
|
497
|
+
"""
|
|
498
|
+
return self._run_sync(
|
|
499
|
+
self._create_vs_async(
|
|
500
|
+
name,
|
|
501
|
+
vector_size,
|
|
502
|
+
distance_metric,
|
|
503
|
+
config,
|
|
504
|
+
vectors_config,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
@experimental
|
|
509
|
+
def create_vector_vision_store(
|
|
510
|
+
self,
|
|
511
|
+
name: str,
|
|
512
|
+
*,
|
|
513
|
+
vector_size: int = 384,
|
|
514
|
+
distance_metric: str = "Cosine",
|
|
515
|
+
config: Optional[Dict[str, Any]] = None,
|
|
516
|
+
vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
|
|
517
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
518
|
+
|
|
519
|
+
if not vectors_config:
|
|
520
|
+
vectors_config = {
|
|
521
|
+
# Raw visual embeddings (OpenCLIP ViT-H/14 → 1024-D)
|
|
522
|
+
"image_vector": qdrant.VectorParams(
|
|
523
|
+
size=1024, distance=qdrant.Distance.COSINE
|
|
524
|
+
),
|
|
525
|
+
# Language embeddings of your BLIP-2 captions → 1024-D
|
|
526
|
+
"caption_vector": qdrant.VectorParams(
|
|
527
|
+
size=1024, distance=qdrant.Distance.COSINE
|
|
528
|
+
),
|
|
529
|
+
# Object-region embeddings (YOLO crop + Sentence-BERT) → 1024-D
|
|
530
|
+
"region_vector": qdrant.VectorParams(
|
|
531
|
+
size=1024, distance=qdrant.Distance.COSINE
|
|
532
|
+
),
|
|
533
|
+
# Geo-location unit vectors (RegioNet) → 3-D
|
|
534
|
+
"geo_vector": qdrant.VectorParams(
|
|
535
|
+
size=3, distance=qdrant.Distance.COSINE
|
|
536
|
+
),
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return self._run_sync(
|
|
540
|
+
self._create_vs_async(
|
|
541
|
+
name,
|
|
542
|
+
vector_size,
|
|
543
|
+
distance_metric,
|
|
544
|
+
config,
|
|
545
|
+
vectors_config,
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
@experimental
|
|
550
|
+
def create_vector_vision_store_for_user(
|
|
551
|
+
self,
|
|
552
|
+
owner_id: str,
|
|
553
|
+
name: str,
|
|
554
|
+
*,
|
|
555
|
+
vector_size: int = 384,
|
|
556
|
+
distance_metric: str = "Cosine",
|
|
557
|
+
config: Optional[Dict[str, Any]] = None,
|
|
558
|
+
vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
|
|
559
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
560
|
+
"""
|
|
561
|
+
Admin-only: create a store on behalf of another user.
|
|
562
|
+
Pass `vectors_config` to define a multi-vector schema.
|
|
563
|
+
"""
|
|
564
|
+
if not vectors_config:
|
|
565
|
+
|
|
566
|
+
vectors_config = {
|
|
567
|
+
# Raw visual embeddings (OpenCLIP ViT-H/14 → 1024-D)
|
|
568
|
+
"image_vector": qdrant.VectorParams(
|
|
569
|
+
size=1024, distance=qdrant.Distance.COSINE
|
|
570
|
+
),
|
|
571
|
+
# Language embeddings of your BLIP-2 captions → 1024-D
|
|
572
|
+
"caption_vector": qdrant.VectorParams(
|
|
573
|
+
size=1024, distance=qdrant.Distance.COSINE
|
|
574
|
+
),
|
|
575
|
+
# Object-region embeddings (YOLO crop + Sentence-BERT) → 1024-D
|
|
576
|
+
"region_vector": qdrant.VectorParams(
|
|
577
|
+
size=1024, distance=qdrant.Distance.COSINE
|
|
578
|
+
),
|
|
579
|
+
# Geo-location unit vectors (RegioNet) → 3-D
|
|
580
|
+
"geo_vector": qdrant.VectorParams(
|
|
581
|
+
size=3, distance=qdrant.Distance.COSINE
|
|
582
|
+
),
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return self._run_sync(
|
|
586
|
+
self._create_vs_for_user_async(
|
|
587
|
+
owner_id,
|
|
588
|
+
name,
|
|
589
|
+
vector_size,
|
|
590
|
+
distance_metric,
|
|
591
|
+
config,
|
|
592
|
+
vectors_config,
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
def create_vector_store_for_user(
|
|
597
|
+
self,
|
|
598
|
+
owner_id: str,
|
|
599
|
+
name: str,
|
|
600
|
+
*,
|
|
601
|
+
vector_size: int = 384,
|
|
602
|
+
distance_metric: str = "Cosine",
|
|
603
|
+
config: Optional[Dict[str, Any]] = None,
|
|
604
|
+
vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
|
|
605
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
606
|
+
"""
|
|
607
|
+
Admin-only: create a store on behalf of another user.
|
|
608
|
+
Pass `vectors_config` to define a multi-vector schema.
|
|
609
|
+
"""
|
|
610
|
+
return self._run_sync(
|
|
611
|
+
self._create_vs_for_user_async(
|
|
612
|
+
owner_id,
|
|
613
|
+
name,
|
|
614
|
+
vector_size,
|
|
615
|
+
distance_metric,
|
|
616
|
+
config,
|
|
617
|
+
vectors_config,
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# ───────────────────────────────────────────────────────────────
|
|
622
|
+
# Convenience: ensure a per-user “file_search” store exists
|
|
623
|
+
# ───────────────────────────────────────────────────────────────
|
|
624
|
+
# unchanged … (get_or_create_file_search_store)
|
|
625
|
+
|
|
626
|
+
def list_my_vector_stores(self) -> List[ValidationInterface.VectorStoreRead]:
|
|
627
|
+
"""List all non-deleted stores owned by *this* API-key’s user."""
|
|
628
|
+
return self._run_sync(self._list_my_vs_async())
|
|
629
|
+
|
|
630
|
+
# ───────────────────────────────────────────────────────────────
|
|
631
|
+
# NEW: real per-user listing (admin-only)
|
|
632
|
+
# ───────────────────────────────────────────────────────────────
|
|
633
|
+
async def _list_vs_by_user_async(self, user_id: str):
|
|
634
|
+
resp = await self._request(
|
|
635
|
+
"GET",
|
|
636
|
+
"/v1/vector-stores/admin/by-user",
|
|
637
|
+
params={"owner_id": user_id},
|
|
638
|
+
)
|
|
639
|
+
return [ValidationInterface.VectorStoreRead.model_validate(r) for r in resp]
|
|
640
|
+
|
|
641
|
+
def get_stores_by_user(
|
|
642
|
+
self,
|
|
643
|
+
_user_id: str,
|
|
644
|
+
) -> List[ValidationInterface.VectorStoreRead]: # noqa: ARG002
|
|
645
|
+
"""
|
|
646
|
+
⚠️ **Deprecated** – prefer impersonating the user’s API-key or using
|
|
647
|
+
the newer RBAC endpoints, but keep working for legacy code.
|
|
648
|
+
"""
|
|
649
|
+
warnings.warn(
|
|
650
|
+
"`get_stores_by_user()` is deprecated; use `list_my_vector_stores()` or "
|
|
651
|
+
"`VectorStoreClient(list_my_vector_stores)` with an impersonated key.",
|
|
652
|
+
DeprecationWarning,
|
|
653
|
+
stacklevel=2,
|
|
654
|
+
)
|
|
655
|
+
return self._run_sync(self._list_vs_by_user_async(_user_id))
|
|
656
|
+
|
|
657
|
+
# ───────────────────────────────────────────────────────────────
|
|
658
|
+
# Convenience: ensure a per-user “file_search” store exists
|
|
659
|
+
# ───────────────────────────────────────────────────────────────
|
|
660
|
+
def get_or_create_file_search_store(self, user_id: Optional[str] = None) -> str:
|
|
661
|
+
"""
|
|
662
|
+
Return the *oldest* vector-store named **file_search** for ``user_id``;
|
|
663
|
+
create one if none exist.
|
|
664
|
+
|
|
665
|
+
Parameters
|
|
666
|
+
----------
|
|
667
|
+
user_id : Optional[str]
|
|
668
|
+
• If **None** → operate on *this* API-key’s stores
|
|
669
|
+
• If not None → *admin-only* – look up / create on behalf of ``user_id``
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
str
|
|
674
|
+
The vector-store **id**.
|
|
675
|
+
"""
|
|
676
|
+
|
|
677
|
+
# 1️⃣ Fetch candidate stores
|
|
678
|
+
if user_id is None:
|
|
679
|
+
# Normal user context – only see caller-owned stores
|
|
680
|
+
stores = self.list_my_vector_stores()
|
|
681
|
+
else:
|
|
682
|
+
# Admin context – may inspect another user’s stores
|
|
683
|
+
stores = self.get_stores_by_user(_user_id=user_id)
|
|
684
|
+
|
|
685
|
+
file_search_stores = [s for s in stores if s.name == "file_search"]
|
|
686
|
+
|
|
687
|
+
if file_search_stores:
|
|
688
|
+
# 2️⃣ Pick the *earliest* (oldest created_at) to keep things stable
|
|
689
|
+
chosen = min(
|
|
690
|
+
file_search_stores,
|
|
691
|
+
key=lambda s: (s.created_at or 0),
|
|
692
|
+
)
|
|
693
|
+
log.info(
|
|
694
|
+
"Re-using existing 'file_search' store %s for user %s",
|
|
695
|
+
chosen.id,
|
|
696
|
+
user_id or "<self>",
|
|
697
|
+
)
|
|
698
|
+
return chosen.id
|
|
699
|
+
|
|
700
|
+
# 3️⃣ Nothing found → create a fresh store
|
|
701
|
+
if user_id is None:
|
|
702
|
+
new_store = self.create_vector_store(name="file_search")
|
|
703
|
+
else:
|
|
704
|
+
# Requires admin API-key
|
|
705
|
+
new_store = self.create_vector_store_for_user(
|
|
706
|
+
owner_id=user_id,
|
|
707
|
+
name="file_search",
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
log.info(
|
|
711
|
+
"Created new 'file_search' store %s for user %s",
|
|
712
|
+
new_store.id,
|
|
713
|
+
user_id or "<self>",
|
|
714
|
+
)
|
|
715
|
+
return new_store.id
|
|
716
|
+
|
|
717
|
+
def add_file_to_vector_store(
|
|
718
|
+
self,
|
|
719
|
+
vector_store_id: str,
|
|
720
|
+
file_path: Union[str, Path],
|
|
721
|
+
user_metadata: Optional[Dict[str, Any]] = None,
|
|
722
|
+
) -> ValidationInterface.VectorStoreFileRead:
|
|
723
|
+
p = Path(file_path)
|
|
724
|
+
if not p.is_file():
|
|
725
|
+
raise FileNotFoundError(f"File not found: {p}")
|
|
726
|
+
return self._run_sync(self._add_file_async(vector_store_id, p, user_metadata))
|
|
727
|
+
|
|
728
|
+
def delete_vector_store(
|
|
729
|
+
self,
|
|
730
|
+
vector_store_id: str,
|
|
731
|
+
permanent: bool = False,
|
|
732
|
+
) -> Dict[str, Any]:
|
|
733
|
+
return self._run_sync(self._delete_vs_async(vector_store_id, permanent))
|
|
734
|
+
|
|
735
|
+
def delete_file_from_vector_store(
|
|
736
|
+
self,
|
|
737
|
+
vector_store_id: str,
|
|
738
|
+
file_path: str,
|
|
739
|
+
) -> Dict[str, Any]:
|
|
740
|
+
return self._run_sync(self._delete_file_async(vector_store_id, file_path))
|
|
741
|
+
|
|
742
|
+
def list_store_files(
|
|
743
|
+
self,
|
|
744
|
+
vector_store_id: str,
|
|
745
|
+
) -> List[ValidationInterface.VectorStoreFileRead]:
|
|
746
|
+
return self._run_sync(self._list_store_files_async(vector_store_id))
|
|
747
|
+
|
|
748
|
+
def update_vector_store_file_status(
|
|
749
|
+
self,
|
|
750
|
+
vector_store_id: str,
|
|
751
|
+
file_id: str,
|
|
752
|
+
status: ValidationInterface.StatusEnum,
|
|
753
|
+
error_message: Optional[str] = None,
|
|
754
|
+
) -> ValidationInterface.VectorStoreFileRead:
|
|
755
|
+
return self._run_sync(
|
|
756
|
+
self._update_file_status_async(
|
|
757
|
+
vector_store_id, file_id, status, error_message
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def get_vector_stores_for_assistant(
|
|
762
|
+
self,
|
|
763
|
+
assistant_id: str,
|
|
764
|
+
) -> List[ValidationInterface.VectorStoreRead]:
|
|
765
|
+
return self._run_sync(self._get_assistant_vs_async(assistant_id))
|
|
766
|
+
|
|
767
|
+
def attach_vector_store_to_assistant(
|
|
768
|
+
self,
|
|
769
|
+
vector_store_id: str,
|
|
770
|
+
assistant_id: str,
|
|
771
|
+
) -> bool:
|
|
772
|
+
return self._run_sync(self._attach_vs_async(vector_store_id, assistant_id))
|
|
773
|
+
|
|
774
|
+
def detach_vector_store_from_assistant(
|
|
775
|
+
self,
|
|
776
|
+
vector_store_id: str,
|
|
777
|
+
assistant_id: str,
|
|
778
|
+
) -> bool:
|
|
779
|
+
return self._run_sync(self._detach_vs_async(vector_store_id, assistant_id))
|
|
780
|
+
|
|
781
|
+
def retrieve_vector_store_sync(
|
|
782
|
+
self,
|
|
783
|
+
vector_store_id: str,
|
|
784
|
+
) -> ValidationInterface.VectorStoreRead:
|
|
785
|
+
resp = self._sync_api_client.get(f"/v1/vector-stores/{vector_store_id}")
|
|
786
|
+
resp.raise_for_status()
|
|
787
|
+
return ValidationInterface.VectorStoreRead.model_validate(resp.json())
|
|
788
|
+
|
|
789
|
+
def vector_file_search_raw(
|
|
790
|
+
self,
|
|
791
|
+
vector_store_id: str,
|
|
792
|
+
query_text: str,
|
|
793
|
+
top_k: int = 5,
|
|
794
|
+
filters: Optional[Dict] = None,
|
|
795
|
+
vector_store_host: Optional[str] = None,
|
|
796
|
+
vector_field: Optional[str] = None, # ← NEW
|
|
797
|
+
) -> List[Dict[str, Any]]:
|
|
798
|
+
return self._run_sync(
|
|
799
|
+
self._search_vs_async(
|
|
800
|
+
vector_store_id,
|
|
801
|
+
query_text,
|
|
802
|
+
top_k,
|
|
803
|
+
filters,
|
|
804
|
+
vector_store_host,
|
|
805
|
+
vector_field,
|
|
806
|
+
)
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
810
|
+
# MID‑LEVEL: envelope but **no** rerank / synthesis
|
|
811
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
812
|
+
def simple_vector_file_search(
|
|
813
|
+
self,
|
|
814
|
+
vector_store_id: str,
|
|
815
|
+
query_text: str,
|
|
816
|
+
top_k: int = 5,
|
|
817
|
+
filters: Optional[Dict] = None,
|
|
818
|
+
) -> Dict[str, Any]:
|
|
819
|
+
"""
|
|
820
|
+
Run a semantic search against *vector_store_id* and return the results
|
|
821
|
+
wrapped in an OpenAI‑compatible envelope (file_search_call + assistant
|
|
822
|
+
message with file_citation annotations).
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
vector_store_id: The store ID to query.
|
|
826
|
+
query_text: Natural‑language search text.
|
|
827
|
+
top_k: Maximum hits to retrieve.
|
|
828
|
+
filters: Optional Qdrant payload filter dict.
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
dict: JSON‑serialisable envelope identical to the OpenAI format.
|
|
832
|
+
"""
|
|
833
|
+
# 1️⃣ Raw hits (list[dict] from VectorStoreManager.query_store)
|
|
834
|
+
raw_hits = self.vector_file_search_raw(
|
|
835
|
+
vector_store_id=vector_store_id,
|
|
836
|
+
query_text=query_text,
|
|
837
|
+
top_k=top_k,
|
|
838
|
+
filters=filters,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# 2️⃣ Normalise / enrich each hit so downstream code never crashes
|
|
842
|
+
hits: List[Dict[str, Any]] = []
|
|
843
|
+
for h in raw_hits:
|
|
844
|
+
md = h.get("meta_data") or h.get("metadata") or {}
|
|
845
|
+
hits.append(
|
|
846
|
+
{
|
|
847
|
+
"text": h["text"],
|
|
848
|
+
"score": h["score"],
|
|
849
|
+
"meta_data": md,
|
|
850
|
+
"vector_id": h.get("vector_id"),
|
|
851
|
+
"store_id": h.get("store_id"),
|
|
852
|
+
}
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
# 3️⃣ Generate human‑friendly answer text (LLM call or simple template)
|
|
856
|
+
answer_text = summarize_hits(query_text, hits)
|
|
857
|
+
|
|
858
|
+
# 4️⃣ Wrap everything into an OpenAI envelope
|
|
859
|
+
return make_envelope(query_text, hits, answer_text)
|
|
860
|
+
|
|
861
|
+
# ────────────────────────────────────────────────────────────────
|
|
862
|
+
# End‑to‑end: retrieve → (rerank) → synthesize → envelope
|
|
863
|
+
# ────────────────────────────────────────────────────────────────
|
|
864
|
+
def attended_file_search(
|
|
865
|
+
self,
|
|
866
|
+
vector_store_id: str,
|
|
867
|
+
query_text: str,
|
|
868
|
+
k: int = 20,
|
|
869
|
+
vector_store_host: Optional[str] = None,
|
|
870
|
+
) -> Dict[str, Any]:
|
|
871
|
+
"""
|
|
872
|
+
Run a full file search with optional cross-encoder rerank and envelope synthesis.
|
|
873
|
+
|
|
874
|
+
Parameters
|
|
875
|
+
----------
|
|
876
|
+
vector_store_id : str
|
|
877
|
+
The ID of the target vector store to query.
|
|
878
|
+
query_text : str
|
|
879
|
+
The natural-language search text.
|
|
880
|
+
k : int, optional
|
|
881
|
+
The maximum number of hits to retrieve (default is 20).
|
|
882
|
+
vector_store_host : Optional[str], optional
|
|
883
|
+
An optional override for the default vector store host.
|
|
884
|
+
|
|
885
|
+
Returns
|
|
886
|
+
-------
|
|
887
|
+
Dict[str, Any]
|
|
888
|
+
An OpenAI-style envelope containing the synthesized response.
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
# 1️⃣ Retrieve initial candidates (now with optional vector_store_host passthrough)
|
|
892
|
+
hits = retriever.retrieve(
|
|
893
|
+
self,
|
|
894
|
+
vector_store_id=vector_store_id,
|
|
895
|
+
query=query_text,
|
|
896
|
+
k=k,
|
|
897
|
+
vector_store_host=vector_store_host,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# 2️⃣ Optional cross-encoder / LLM rerank
|
|
901
|
+
hits = reranker.rerank(query_text, hits, top_k=min(len(hits), 10))
|
|
902
|
+
|
|
903
|
+
# 3️⃣ Normalize schema (guarantee 'meta_data')
|
|
904
|
+
hits = self._normalise_hits(hits)
|
|
905
|
+
|
|
906
|
+
# 4️⃣ Abstractive synthesis → OpenAI-style envelope
|
|
907
|
+
return synthesize_envelope(
|
|
908
|
+
query_text,
|
|
909
|
+
hits,
|
|
910
|
+
api_key=self.api_key, # Project-David key
|
|
911
|
+
base_url=self.base_url, # Same backend
|
|
912
|
+
provider_api_key=os.getenv("HYPERBOLIC_API_KEY"), # Hyperbolic key
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
# ────────────────────────────────────────────────────────────────
|
|
916
|
+
# End‑to‑end: retrieve → (rerank) → synthesize → envelope
|
|
917
|
+
# ────────────────────────────────────────────────────────────────
|
|
918
|
+
def unattended_file_search(
|
|
919
|
+
self,
|
|
920
|
+
vector_store_id: str,
|
|
921
|
+
query_text: str,
|
|
922
|
+
k: int = 20,
|
|
923
|
+
vector_store_host: Optional[str] = None,
|
|
924
|
+
) -> Dict[str, Any]:
|
|
925
|
+
"""
|
|
926
|
+
Perform a search over the file vector store and return normalized retrieval hits.
|
|
927
|
+
|
|
928
|
+
This method executes a bare search pipeline: it retrieves vector-based candidates
|
|
929
|
+
using semantic similarity, optionally applies reranking (e.g., cross-encoder or LLM-based),
|
|
930
|
+
and normalizes the result schema. It does not perform synthesis or construct an OpenAI-style envelope.
|
|
931
|
+
|
|
932
|
+
Use this when you want direct access to retrieved content for custom downstream handling,
|
|
933
|
+
logging, inspection, or separate orchestration logic.
|
|
934
|
+
|
|
935
|
+
Parameters
|
|
936
|
+
----------
|
|
937
|
+
vector_store_id : str
|
|
938
|
+
The ID of the vector store to search within.
|
|
939
|
+
query_text : str
|
|
940
|
+
The user query in natural language.
|
|
941
|
+
k : int, optional
|
|
942
|
+
The number of top hits to retrieve (default is 20).
|
|
943
|
+
vector_store_host : Optional[str], optional
|
|
944
|
+
Optional override for the vector store host (e.g., when calling remote Qdrant).
|
|
945
|
+
|
|
946
|
+
Returns
|
|
947
|
+
-------
|
|
948
|
+
Dict[str, Any]
|
|
949
|
+
A normalized list of retrieval results (each with metadata and score),
|
|
950
|
+
without abstraction, synthesis, or formatting.
|
|
951
|
+
"""
|
|
952
|
+
|
|
953
|
+
# 1️⃣ Retrieve initial candidates (now with optional vector_store_host passthrough)
|
|
954
|
+
hits = retriever.retrieve(
|
|
955
|
+
self,
|
|
956
|
+
vector_store_id=vector_store_id,
|
|
957
|
+
query=query_text,
|
|
958
|
+
k=k,
|
|
959
|
+
vector_store_host=vector_store_host,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# 2️⃣ Optional cross-encoder / LLM rerank
|
|
963
|
+
hits = reranker.rerank(query_text, hits, top_k=min(len(hits), 10))
|
|
964
|
+
|
|
965
|
+
# 3️⃣ Normalize schema (guarantee 'meta_data')
|
|
966
|
+
hits = self._normalise_hits(hits)
|
|
967
|
+
|
|
968
|
+
return hits
|
|
969
|
+
|
|
970
|
+
@experimental
|
|
971
|
+
def image_similarity_search(
|
|
972
|
+
self,
|
|
973
|
+
vector_store_id: str,
|
|
974
|
+
img: Image.Image,
|
|
975
|
+
k: int = 10,
|
|
976
|
+
vector_store_host: Optional[str] = None,
|
|
977
|
+
) -> List[Dict[str, Any]]:
|
|
978
|
+
vec = self.file_processor.encode_image(img).tolist()
|
|
979
|
+
return self.vector_file_search_raw(
|
|
980
|
+
vector_store_id=vector_store_id,
|
|
981
|
+
query_text=vec,
|
|
982
|
+
top_k=k,
|
|
983
|
+
filters=None,
|
|
984
|
+
vector_store_host=vector_store_host,
|
|
985
|
+
vector_field="image_vector",
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
@experimental
|
|
989
|
+
def search_images(
|
|
990
|
+
self,
|
|
991
|
+
vector_store_id: str,
|
|
992
|
+
query: Union[str, Image.Image, List[float]],
|
|
993
|
+
*,
|
|
994
|
+
modality: Optional[str] = None,
|
|
995
|
+
k: int = 10,
|
|
996
|
+
vector_store_host: Optional[str] = None,
|
|
997
|
+
) -> List[Dict[str, Any]]:
|
|
998
|
+
"""
|
|
999
|
+
Unified image search across multiple modalities, with appropriate reranking:
|
|
1000
|
+
|
|
1001
|
+
- If `query` is a str → caption search (reranked)
|
|
1002
|
+
- If `query` is a PIL.Image.Image → visual search (no rerank)
|
|
1003
|
+
- If `query` is a list[float] → raw vector search
|
|
1004
|
+
- `modality` override: one of 'caption', 'image', 'region', 'geo'
|
|
1005
|
+
"""
|
|
1006
|
+
# Map modality to (vector_field, encoder)
|
|
1007
|
+
field_map = {
|
|
1008
|
+
"caption": (
|
|
1009
|
+
"caption_vector",
|
|
1010
|
+
lambda q: self.file_processor.encode_clip_text(q).tolist(),
|
|
1011
|
+
),
|
|
1012
|
+
"image": (
|
|
1013
|
+
"image_vector",
|
|
1014
|
+
lambda q: self.file_processor.encode_image(q).tolist(),
|
|
1015
|
+
),
|
|
1016
|
+
"region": (
|
|
1017
|
+
"region_vector",
|
|
1018
|
+
lambda q: self.file_processor.encode_text(q).tolist(),
|
|
1019
|
+
),
|
|
1020
|
+
"geo": ("geo_vector", lambda q: q), # assume q is raw 3-D vector
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
# Auto-detect if not provided
|
|
1024
|
+
if modality is None:
|
|
1025
|
+
if isinstance(query, str):
|
|
1026
|
+
modality = "caption"
|
|
1027
|
+
elif isinstance(query, Image.Image):
|
|
1028
|
+
modality = "image"
|
|
1029
|
+
elif isinstance(query, list):
|
|
1030
|
+
modality = "image"
|
|
1031
|
+
else:
|
|
1032
|
+
raise VectorStoreClientError(f"Unsupported query type: {type(query)}")
|
|
1033
|
+
|
|
1034
|
+
modality = modality.lower()
|
|
1035
|
+
if modality not in field_map:
|
|
1036
|
+
raise VectorStoreClientError(f"Unknown modality '{modality}'")
|
|
1037
|
+
|
|
1038
|
+
vector_field, encoder = field_map[modality]
|
|
1039
|
+
vec = encoder(query)
|
|
1040
|
+
|
|
1041
|
+
# 1️⃣ ANN search
|
|
1042
|
+
hits = self.vector_file_search_raw(
|
|
1043
|
+
vector_store_id=vector_store_id,
|
|
1044
|
+
query_text=vec,
|
|
1045
|
+
top_k=k,
|
|
1046
|
+
filters=None,
|
|
1047
|
+
vector_store_host=vector_store_host,
|
|
1048
|
+
vector_field=vector_field,
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# 2️⃣ Rerank for text-based modalities
|
|
1052
|
+
if modality in ("caption", "region"):
|
|
1053
|
+
hits = reranker.rerank(
|
|
1054
|
+
query if isinstance(query, str) else "", hits, top_k=min(len(hits), k)
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
# 3️⃣ Normalize and return
|
|
1058
|
+
return self._normalise_hits(hits)
|