-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[SDK][Python] Add Qdrant retrieval integration #5423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6d5f6ad
1287550
cc54ef2
d3d8256
e5d6a9a
9b00b9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| --- | ||
| title: Qdrant | ||
| description: Track Qdrant retrieval operations in Opik. | ||
| headline: Qdrant | Opik Documentation | ||
| --- | ||
|
|
||
| Opik can instrument Qdrant retrieval calls and log them as tool spans. | ||
|
|
||
| ## Install | ||
|
|
||
| ```bash | ||
| pip install opik qdrant-client | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```python | ||
| from qdrant_client import QdrantClient | ||
| from opik.integrations.qdrant import track_qdrant | ||
|
|
||
| client = QdrantClient(url="http://localhost:6333") | ||
| client = track_qdrant(client, project_name="retrieval-demo") | ||
|
|
||
| result = client.search( | ||
| collection_name="docs", | ||
| query_vector=[0.1, 0.2, 0.3], | ||
| limit=5, | ||
| ) | ||
| ``` | ||
|
|
||
| ## What gets logged | ||
|
|
||
| - span `type`: `tool` | ||
| - span name format: `qdrant.<operation>` | ||
| - metadata keys: | ||
| - `opik.kind=retrieval` | ||
| - `opik.provider=qdrant` | ||
| - `opik.operation=<operation>` | ||
|
|
||
| Tracked operations include `query`, `query_points`, `search`, `search_batch`, `recommend`, `discover`, and `scroll` when available. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any, Dict, Iterable, Optional, Tuple | ||
|
|
||
| import opik | ||
|
|
||
| Metadata = Dict[str, Any] | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RetrievalTrackingConfig: | ||
| provider: str | ||
| operation_paths: Tuple[str, ...] | ||
| project_name: Optional[str] = None | ||
|
Comment on lines
+11
to
+15
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Finding type:
Other fix methodsPrompt for AI Agents: |
||
|
|
||
|
|
||
| def _resolve_target_and_method(root: Any, path: str) -> Tuple[Optional[Any], Optional[str]]: | ||
| parts = path.split(".") | ||
| target = root | ||
| for part in parts[:-1]: | ||
| target = getattr(target, part, None) | ||
| if target is None: | ||
| return None, None | ||
| return target, parts[-1] | ||
|
|
||
|
|
||
| def _build_metadata(provider: str, operation: str) -> Metadata: | ||
| return { | ||
| "created_from": provider, | ||
| "opik.kind": "retrieval", | ||
| "opik.provider": provider, | ||
| "opik.operation": operation, | ||
| } | ||
|
|
||
|
|
||
| def _patch_operation( | ||
| target: Any, | ||
| method_name: str, | ||
| provider: str, | ||
| operation_name: str, | ||
| project_name: Optional[str], | ||
| ) -> bool: | ||
| method = getattr(target, method_name, None) | ||
| if method is None or not callable(method): | ||
| return False | ||
|
|
||
| if hasattr(method, "opik_tracked"): | ||
| return False | ||
|
|
||
| decorator = opik.track( | ||
| name=f"{provider}.{operation_name}", | ||
| type="tool", | ||
| tags=[provider, "retrieval"], | ||
| metadata=_build_metadata(provider, operation_name), | ||
| project_name=project_name, | ||
| ) | ||
| setattr(target, method_name, decorator(method)) | ||
| return True | ||
|
|
||
|
|
||
| def patch_retrieval_client(target: Any, config: RetrievalTrackingConfig) -> Any: | ||
| if hasattr(target, "opik_tracked"): | ||
| return target | ||
|
|
||
| patched = False | ||
| for operation_path in config.operation_paths: | ||
| operation_target, method_name = _resolve_target_and_method(target, operation_path) | ||
| if operation_target is None or method_name is None: | ||
| continue | ||
|
|
||
| operation_name = operation_path.split(".")[-1] | ||
| patched = ( | ||
| _patch_operation( | ||
| operation_target, | ||
| method_name, | ||
| provider=config.provider, | ||
| operation_name=operation_name, | ||
| project_name=config.project_name, | ||
| ) | ||
| or patched | ||
| ) | ||
|
|
||
| if patched: | ||
| target.opik_tracked = True | ||
|
|
||
| return target | ||
|
|
||
|
|
||
| def as_tuple(paths: Iterable[str]) -> Tuple[str, ...]: | ||
| return tuple(paths) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from .opik_tracker import track_qdrant | ||
|
|
||
| __all__ = ["track_qdrant"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Optional | ||
|
|
||
| from opik.integrations._retrieval_tracker import ( | ||
| RetrievalTrackingConfig, | ||
| as_tuple, | ||
| patch_retrieval_client, | ||
| ) | ||
|
|
||
|
|
||
| def track_qdrant(qdrant_client: Any, project_name: Optional[str] = None) -> Any: | ||
| """Adds Opik tracking wrappers to a Qdrant client. | ||
|
|
||
| The integration is dependency-light and patches known retrieval methods if present. | ||
| """ | ||
| config = RetrievalTrackingConfig( | ||
| provider="qdrant", | ||
| operation_paths=as_tuple( | ||
| [ | ||
| "query", | ||
| "query_points", | ||
| "search", | ||
| "search_batch", | ||
| "recommend", | ||
| "discover", | ||
| "scroll", | ||
| ] | ||
| ), | ||
| project_name=project_name, | ||
| ) | ||
| return patch_retrieval_client(qdrant_client, config) | ||
|
Comment on lines
+12
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New Finding type:
Other fix methodsPrompt for AI Agents:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Commit 9b00b9c addressed this comment by adding a new unit test file |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any, Dict, List | ||
|
|
||
| from opik.integrations.qdrant.opik_tracker import track_qdrant | ||
|
|
||
|
|
||
| @dataclass | ||
| class _Calls: | ||
| values: List[Dict[str, Any]] | ||
|
|
||
|
|
||
| class _TrackSpy: | ||
| def __init__(self) -> None: | ||
| self.calls = _Calls(values=[]) | ||
|
|
||
| def __call__(self, **track_kwargs: Any): # type: ignore[override] | ||
| self.calls.values.append(track_kwargs) | ||
|
|
||
| def decorator(func: Any) -> Any: | ||
| def wrapped(*args: Any, **kwargs: Any) -> Any: | ||
| return func(*args, **kwargs) | ||
|
|
||
| wrapped.opik_tracked = True | ||
| return wrapped | ||
|
|
||
| return decorator | ||
|
|
||
|
|
||
| def _build_qdrant_client() -> Any: | ||
| class Client: | ||
| def search(self) -> Dict[str, Any]: | ||
| return {"ok": True} | ||
|
|
||
| def query_points(self) -> Dict[str, Any]: | ||
| return {"ok": True} | ||
|
|
||
| return Client() | ||
|
|
||
|
|
||
| def test_track_qdrant__patches_methods_with_retrieval_metadata(monkeypatch: Any) -> None: | ||
| import opik.integrations._retrieval_tracker as retrieval_tracker | ||
|
|
||
| track_spy = _TrackSpy() | ||
| monkeypatch.setattr(retrieval_tracker.opik, "track", track_spy) | ||
|
|
||
| client = _build_qdrant_client() | ||
| tracked_client = track_qdrant(client, project_name="proj") | ||
|
|
||
| assert tracked_client is client | ||
| assert tracked_client.opik_tracked is True | ||
| assert len(track_spy.calls.values) == 2 | ||
|
|
||
| operation_names = {call["name"] for call in track_spy.calls.values} | ||
| assert operation_names == {"qdrant.search", "qdrant.query_points"} | ||
|
|
||
| for call in track_spy.calls.values: | ||
| assert call["type"] == "tool" | ||
| assert call["metadata"]["opik.kind"] == "retrieval" | ||
| assert call["metadata"]["opik.provider"] == "qdrant" | ||
| assert "opik.operation" in call["metadata"] | ||
|
|
||
|
|
||
| def test_track_qdrant__idempotent(monkeypatch: Any) -> None: | ||
| import opik.integrations._retrieval_tracker as retrieval_tracker | ||
|
|
||
| track_spy = _TrackSpy() | ||
| monkeypatch.setattr(retrieval_tracker.opik, "track", track_spy) | ||
|
|
||
| client = _build_qdrant_client() | ||
| track_qdrant(client) | ||
| calls_after_first = len(track_spy.calls.values) | ||
| track_qdrant(client) | ||
|
|
||
| assert len(track_spy.calls.values) == calls_after_first |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new Qdrant example is a hand‑pasted snippet without the required one‑line intent/trigger note, required-vs-optional field guidance, or maintenance note pointing to whichever autogenerated canonical example this complements; can we either link to the generated example or add those required doc attributes (intent, applicability, optional/required fields, and pointer to the canonical source and update cadence) when embedding it?
Finding type:
Keep docs accurateWant Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents: