Skip to content

Commit 753826a

Browse files
authored
Merge pull request #663 from aurelio-labs/ashraq/fix-qdrant
fix: async errors and implement missing methods in qdrantindex
2 parents bbc1a28 + fe7e4a5 commit 753826a

1 file changed

Lines changed: 155 additions & 71 deletions

File tree

semantic_router/index/qdrant.py

Lines changed: 155 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,55 @@ def _build_filter(self, base_filter: Optional[Any] = None) -> Optional[Any]:
211211

212212
return models.Filter(must=[ns_condition, base_filter])
213213

214+
def _point_ids_for_utterances(self, routes_to_delete: dict) -> list[str]:
215+
"""Compute deterministic point IDs for the given route→utterance mapping.
216+
217+
Uses the same uuid5 scheme as ``add()`` so IDs are derived without
218+
a round-trip to Qdrant.
219+
"""
220+
ids = []
221+
for route, utterances in routes_to_delete.items():
222+
for utterance in utterances:
223+
key = (
224+
f"{self.namespace}:{route}:{utterance}"
225+
if self.namespace is not None
226+
else f"{route}:{utterance}"
227+
)
228+
ids.append(str(uuid.uuid5(uuid.NAMESPACE_DNS, key)))
229+
return ids
230+
214231
def _remove_and_sync(self, routes_to_delete: dict):
215-
"""Remove and sync the index.
232+
"""Remove specific utterances from the index.
216233
217-
:param routes_to_delete: The routes to delete.
234+
:param routes_to_delete: Dict mapping route name to list of utterances to remove.
218235
:type routes_to_delete: dict
219236
"""
220-
logger.error("Sync remove is not implemented for QdrantIndex.")
237+
from qdrant_client import models
238+
239+
ids = self._point_ids_for_utterances(routes_to_delete)
240+
if ids:
241+
self.client.delete(
242+
self.index_name,
243+
points_selector=models.PointIdsList(points=ids),
244+
)
245+
246+
async def _async_remove_and_sync(self, routes_to_delete: dict):
247+
"""Asynchronously remove specific utterances from the index.
248+
249+
:param routes_to_delete: Dict mapping route name to list of utterances to remove.
250+
:type routes_to_delete: dict
251+
"""
252+
from qdrant_client import models
253+
254+
ids = self._point_ids_for_utterances(routes_to_delete)
255+
if ids:
256+
if self.aclient is not None:
257+
await self.aclient.delete(
258+
self.index_name,
259+
points_selector=models.PointIdsList(points=ids),
260+
)
261+
else:
262+
self._remove_and_sync(routes_to_delete)
221263

222264
def add(
223265
self,
@@ -283,57 +325,107 @@ def add(
283325
batch_size=batch_size,
284326
)
285327

286-
def get_utterances(self, include_metadata: bool = False) -> List[Utterance]:
287-
"""Gets a list of route and utterance objects currently stored in the index.
328+
def _get_all(
329+
self, prefix: Optional[str] = None, include_metadata: bool = False
330+
) -> tuple[list[str], list[dict]]:
331+
"""Retrieves all vector IDs (and optionally payloads) via scroll.
288332
289-
:param include_metadata: Whether to include function schemas and metadata in
290-
the returned Utterance objects - QdrantIndex does not currently support this
291-
parameter so it is ignored. If required for your use-case please reach out to
292-
semantic-router maintainers on GitHub via an issue or PR.
293-
:type include_metadata: bool
294-
:return: A list of Utterance objects.
295-
:rtype: List[Utterance]
333+
:param prefix: Unused for Qdrant; filtering is handled by namespace.
334+
:param include_metadata: Whether to include payload dicts in the return value.
335+
:return: Tuple of (ids, metadata_list).
296336
"""
297-
# Check if collection exists first
298337
if not self.client.collection_exists(self.index_name):
299-
return []
338+
return [], []
300339

301340
from qdrant_client import grpc
302341

303342
results = []
304343
next_offset = None
305344
stop_scrolling = False
306-
scroll_filter = self._build_filter()
307345
try:
308346
while not stop_scrolling:
309347
records, next_offset = self.client.scroll(
310348
self.index_name,
311349
limit=SCROLL_SIZE,
312350
offset=next_offset,
313351
with_payload=True,
314-
scroll_filter=scroll_filter,
352+
scroll_filter=self._build_filter(),
315353
)
316354
stop_scrolling = next_offset is None or (
317355
isinstance(next_offset, grpc.PointId)
318356
and next_offset.num == 0
319357
and next_offset.uuid == ""
320358
)
321-
322359
results.extend(records)
360+
except ValueError as e:
361+
logger.warning(f"Index likely empty, error: {e}")
362+
return [], []
363+
364+
ids = [str(r.id) for r in results]
365+
metadata = [r.payload or {} for r in results] if include_metadata else []
366+
return ids, metadata
367+
368+
async def _async_get_all(
369+
self, prefix: Optional[str] = None, include_metadata: bool = False
370+
) -> tuple[list[str], list[dict]]:
371+
"""Async version of _get_all.
372+
373+
:param prefix: Unused for Qdrant; filtering is handled by namespace.
374+
:param include_metadata: Whether to include payload dicts in the return value.
375+
:return: Tuple of (ids, metadata_list).
376+
"""
377+
if self.aclient is None:
378+
return self._get_all(prefix=prefix, include_metadata=include_metadata)
323379

324-
utterances: List[Utterance] = [
325-
Utterance(
326-
route=x.payload[SR_ROUTE_PAYLOAD_KEY],
327-
utterance=x.payload[SR_UTTERANCE_PAYLOAD_KEY],
328-
function_schemas=None,
329-
metadata=x.payload.get("metadata", {}),
380+
if not await self.aclient.collection_exists(self.index_name):
381+
return [], []
382+
383+
from qdrant_client import grpc
384+
385+
results = []
386+
next_offset = None
387+
stop_scrolling = False
388+
try:
389+
while not stop_scrolling:
390+
records, next_offset = await self.aclient.scroll(
391+
self.index_name,
392+
limit=SCROLL_SIZE,
393+
offset=next_offset,
394+
with_payload=True,
395+
scroll_filter=self._build_filter(),
396+
)
397+
stop_scrolling = next_offset is None or (
398+
isinstance(next_offset, grpc.PointId)
399+
and next_offset.num == 0
400+
and next_offset.uuid == ""
330401
)
331-
for x in results
332-
]
402+
results.extend(records)
333403
except ValueError as e:
334404
logger.warning(f"Index likely empty, error: {e}")
335-
return []
336-
return utterances
405+
return [], []
406+
407+
ids = [str(r.id) for r in results]
408+
metadata = [r.payload or {} for r in results] if include_metadata else []
409+
return ids, metadata
410+
411+
def get_utterances(self, include_metadata: bool = False) -> List[Utterance]:
412+
"""Gets a list of route and utterance objects currently stored in the index.
413+
414+
:param include_metadata: Whether to include metadata in the returned Utterance objects.
415+
:type include_metadata: bool
416+
:return: A list of Utterance objects.
417+
:rtype: List[Utterance]
418+
"""
419+
_, payloads = self._get_all(include_metadata=True)
420+
return [
421+
Utterance(
422+
route=p.get(SR_ROUTE_PAYLOAD_KEY, ""),
423+
utterance=p.get(SR_UTTERANCE_PAYLOAD_KEY, ""),
424+
function_schemas=None,
425+
metadata=p.get("metadata", {}) if include_metadata else {},
426+
)
427+
for p in payloads
428+
]
337429

338430
def delete(self, route_name: str):
339431
"""Delete records from the index.
@@ -479,13 +571,28 @@ async def aquery(
479571
]
480572
return np.array(scores), route_names
481573

482-
def aget_routes(self):
483-
"""Asynchronously get all routes from the index.
574+
def get_routes_tuples(self) -> list[tuple]:
575+
"""Synchronously get route and utterance objects as tuples.
484576
485-
:return: A list of routes.
486-
:rtype: List[str]
577+
:return: A list of (route_name, utterance, function_schemas, metadata) tuples.
578+
:rtype: list[tuple]
487579
"""
488-
logger.error("Sync remove is not implemented for QdrantIndex.")
580+
utterances = self.get_utterances(include_metadata=True)
581+
return [
582+
(u.route, u.utterance, u.function_schemas, u.metadata) for u in utterances
583+
]
584+
585+
async def aget_routes(self) -> list[tuple]:
586+
"""Asynchronously get a list of route and utterance objects currently
587+
stored in the index.
588+
589+
:return: A list of (route_name, utterance, function_schemas, metadata) tuples.
590+
:rtype: list[tuple]
591+
"""
592+
utterances = await self.aget_utterances(include_metadata=True)
593+
return [
594+
(u.route, u.utterance, u.function_schemas, u.metadata) for u in utterances
595+
]
489596

490597
def delete_index(self):
491598
"""Delete the index.
@@ -767,43 +874,20 @@ async def aadd(
767874
)
768875

769876
async def aget_utterances(self, include_metadata: bool = False) -> List[Utterance]:
770-
"""Asynchronously gets a list of route and utterance objects currently stored in the index, including metadata."""
771-
if self.aclient is None:
772-
logger.warning(
773-
"Cannot use async get_utterances with an in-memory Qdrant instance; falling back to sync get_utterances."
774-
)
775-
return self.get_utterances(include_metadata=include_metadata)
776-
from qdrant_client import grpc
877+
"""Asynchronously gets a list of route and utterance objects currently stored in the index.
777878
778-
results = []
779-
next_offset = None
780-
stop_scrolling = False
781-
scroll_filter = self._build_filter()
782-
try:
783-
while not stop_scrolling:
784-
records, next_offset = await self.aclient.scroll(
785-
self.index_name,
786-
limit=SCROLL_SIZE,
787-
offset=next_offset,
788-
with_payload=True,
789-
scroll_filter=scroll_filter,
790-
)
791-
stop_scrolling = next_offset is None or (
792-
isinstance(next_offset, grpc.PointId)
793-
and next_offset.num == 0
794-
and next_offset.uuid == ""
795-
)
796-
results.extend(records)
797-
utterances: List[Utterance] = [
798-
Utterance(
799-
route=x.payload[SR_ROUTE_PAYLOAD_KEY],
800-
utterance=x.payload[SR_UTTERANCE_PAYLOAD_KEY],
801-
function_schemas=None,
802-
metadata=x.payload.get("metadata", {}),
803-
)
804-
for x in results
805-
]
806-
except ValueError as e:
807-
logger.warning(f"Index likely empty, error: {e}")
808-
return []
809-
return utterances
879+
:param include_metadata: Whether to include metadata in the returned Utterance objects.
880+
:type include_metadata: bool
881+
:return: A list of Utterance objects.
882+
:rtype: List[Utterance]
883+
"""
884+
_, payloads = await self._async_get_all(include_metadata=True)
885+
return [
886+
Utterance(
887+
route=p.get(SR_ROUTE_PAYLOAD_KEY, ""),
888+
utterance=p.get(SR_UTTERANCE_PAYLOAD_KEY, ""),
889+
function_schemas=None,
890+
metadata=p.get("metadata", {}) if include_metadata else {},
891+
)
892+
for p in payloads
893+
]

0 commit comments

Comments
 (0)