Skip to content

Commit 8b07c7a

Browse files
feat(ml): add qdrant ingestion (#38142)
* feat(ml): add qdrant ingestion refactor: use local qdrant implementation for tests chore: clean up imports chore: add qdrant dependency to ml_test extra chore: run precommit chore: add comment to CHANGES.md fix: guard against import error fix: import * fix: typing * chore: add docstring * chore: move change to 2.74.0 release notes * fix: move batch initialization for qdrant sink into start_bundle * feat: add byte size limit for qdrant ingestion * fix: add positive batch_size check for qdrant config * feat: add factory methods for qdrant connection params * feat: add retry to qdrant ingestion * chore: add unit tests * fix: import linting errors on qdrant * fix: use direct runner for qdrant integration tests * fix: safe guard qdrant client close * fix: use testcontainers for qdrant it test * chore: fix import order * fix: move test client creation into setup * chore: mark qdrant integration tests as require_docker_in_docker * chore: move changes msg to 2.75
1 parent 8364da7 commit 8b07c7a

5 files changed

Lines changed: 1143 additions & 6 deletions

File tree

CHANGES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161

6262
* New highly anticipated feature X added to Python SDK ([#X](https://github.com/apache/beam/issues/X)).
6363
* New highly anticipated feature Y added to Java SDK ([#Y](https://github.com/apache/beam/issues/Y)).
64+
* (Python) Added [Qdrant](https://qdrant.tech/) VectorDatabaseWriteConfig implementation ([#38141](https://github.com/apache/beam/issues/38141)).
6465

6566
## I/Os
6667

@@ -2462,4 +2463,4 @@ Schema Options, it will be removed in version `2.23.0`. ([BEAM-9704](https://iss
24622463

24632464
## Highlights
24642465

2465-
- For versions 2.19.0 and older release notes are available on [Apache Beam Blog](https://beam.apache.org/blog/).
2466+
- For versions 2.19.0 and older release notes are available on [Apache Beam Blog](https://beam.apache.org/blog/).
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import logging
18+
import time
19+
from collections.abc import Callable
20+
from dataclasses import dataclass
21+
from dataclasses import field
22+
from typing import Any
23+
from typing import Optional
24+
25+
import grpc
26+
from objsize import get_deep_size
27+
28+
try:
29+
from qdrant_client import QdrantClient
30+
from qdrant_client import models
31+
from qdrant_client.common.client_exceptions import ResourceExhaustedResponse
32+
from qdrant_client.http.exceptions import ResponseHandlingException
33+
from qdrant_client.http.exceptions import UnexpectedResponse
34+
except ImportError:
35+
logging.warning("Qdrant client library is not installed.")
36+
37+
import apache_beam as beam
38+
from apache_beam.ml.rag.ingestion.base import VectorDatabaseWriteConfig
39+
from apache_beam.ml.rag.types import EmbeddableItem
40+
41+
DEFAULT_WRITE_BATCH_SIZE = 1000
42+
DEFAULT_MAX_BATCH_BYTE_SIZE = 4 << 20
43+
44+
45+
@dataclass
46+
class QdrantConnectionParameters:
47+
"""Configuration parameters for connecting to Qdrant service.
48+
49+
Either `location`, `url`, `host`, or `path` must be provided to establish
50+
a connection.
51+
52+
Args:
53+
location:
54+
If `str` - use it as a `url` parameter.
55+
If `None` - use default values for `host` and `port`.
56+
url: either host or str of "<scheme>//<host>:<port>/<prefix>".
57+
Default: `None`
58+
port: Port of the REST API interface. Default: 6333
59+
grpc_port: Port of the gRPC interface. Default: 6334
60+
prefer_grpc: If `true` - use gPRC interface whenever possible.
61+
https: If `true` - use HTTPS(SSL) protocol. Default: `None`
62+
api_key: API key for authentication in Qdrant Cloud. Default: `None`
63+
prefix:
64+
If not `None` - add `prefix` to the REST URL path.
65+
Example: `service/v1` will result in
66+
`http://localhost:6333/service/v1/{qdrant-endpoint}` for REST API.
67+
Default: `None`
68+
timeout:
69+
Timeout for REST and gRPC API requests.
70+
Default: 5 seconds for REST and unlimited for gRPC
71+
host:
72+
Host name of Qdrant service.
73+
If url and host are None, set to 'localhost'.
74+
Default: `None`
75+
path: Persistence path for QdrantLocal. Default: `None`
76+
**kwargs: Additional arguments passed directly into client initialization
77+
"""
78+
79+
location: Optional[str] = None
80+
url: Optional[str] = None
81+
port: Optional[int] = 6333
82+
grpc_port: int = 6334
83+
prefer_grpc: bool = False
84+
https: Optional[bool] = None
85+
api_key: Optional[str] = None
86+
prefix: Optional[str] = None
87+
timeout: Optional[int] = None
88+
host: Optional[str] = None
89+
path: Optional[str] = None
90+
kwargs: dict[str, Any] = field(default_factory=dict)
91+
92+
def __post_init__(self):
93+
if not (self.location or self.url or self.host or self.path):
94+
raise ValueError(
95+
"One of location, url, host, or path must be provided for Qdrant")
96+
97+
@classmethod
98+
def for_cloud(
99+
cls,
100+
url: str,
101+
api_key: str,
102+
*,
103+
prefer_grpc: bool = False,
104+
timeout: Optional[int] = None,
105+
**kwargs: Any,
106+
) -> "QdrantConnectionParameters":
107+
"""Connect to Qdrant Cloud. Requires the cluster URL and an API key."""
108+
return cls(
109+
url=url,
110+
api_key=api_key,
111+
https=True,
112+
prefer_grpc=prefer_grpc,
113+
timeout=timeout,
114+
kwargs=kwargs,
115+
)
116+
117+
@classmethod
118+
def for_host(
119+
cls,
120+
host: str,
121+
port: int = 6333,
122+
*,
123+
grpc_port: int = 6334,
124+
prefer_grpc: bool = False,
125+
https: bool = False,
126+
api_key: Optional[str] = None,
127+
timeout: Optional[int] = None,
128+
**kwargs: Any,
129+
) -> "QdrantConnectionParameters":
130+
"""Connect to a self-hosted Qdrant instance by host and port."""
131+
return cls(
132+
host=host,
133+
port=port,
134+
grpc_port=grpc_port,
135+
prefer_grpc=prefer_grpc,
136+
https=https,
137+
api_key=api_key,
138+
timeout=timeout,
139+
kwargs=kwargs,
140+
)
141+
142+
@classmethod
143+
def for_url(
144+
cls,
145+
url: str,
146+
*,
147+
api_key: Optional[str] = None,
148+
prefer_grpc: bool = False,
149+
timeout: Optional[int] = None,
150+
**kwargs: Any,
151+
) -> "QdrantConnectionParameters":
152+
"""Connect using a full URL like 'https://my-qdrant.example.com:6333'."""
153+
return cls(
154+
url=url,
155+
api_key=api_key,
156+
prefer_grpc=prefer_grpc,
157+
timeout=timeout,
158+
kwargs=kwargs)
159+
160+
@classmethod
161+
def local(cls, path: str) -> "QdrantConnectionParameters":
162+
"""Use an embedded Qdrant instance persisted to the given path."""
163+
return cls(path=path)
164+
165+
@classmethod
166+
def in_memory(cls) -> "QdrantConnectionParameters":
167+
"""Use an embedded in-memory Qdrant instance. Useful for tests."""
168+
return cls(location=":memory:")
169+
170+
171+
@dataclass
172+
class QdrantWriteConfig(VectorDatabaseWriteConfig):
173+
"""Configuration for writing to Qdrant vector database.
174+
175+
This class defines the parameters needed to write data to a qdrant collection,
176+
including collection targeting, batching behavior, and operation timeouts.
177+
178+
Args:
179+
connection_params: QdrantConnectionParameters with connection settings.
180+
collection_name: Name of the Qdrant collection to write to.
181+
timeout: Optional timeout for write operations in seconds. Default is None.
182+
batch_size: Number of points to write in each batch. Default is 1000.
183+
kwargs: Additional keyword arguments to pass to the client's upsert method.
184+
dense_embedding_key: name for the dense vector in the qdrant collection.
185+
sparse_embedding_key: name for the sparse vector in the qdrant collection.
186+
"""
187+
188+
connection_params: QdrantConnectionParameters
189+
collection_name: str
190+
timeout: Optional[int] = None
191+
batch_size: int = DEFAULT_WRITE_BATCH_SIZE
192+
max_batch_byte_size: int = DEFAULT_MAX_BATCH_BYTE_SIZE
193+
kwargs: dict[str, Any] = field(default_factory=dict)
194+
dense_embedding_key: str = "dense"
195+
sparse_embedding_key: str = "sparse"
196+
197+
def __post_init__(self):
198+
if not self.collection_name:
199+
raise ValueError("Collection name must be provided")
200+
if self.batch_size <= 0:
201+
raise ValueError("Batch size must be a positive integer")
202+
203+
def create_write_transform(self) -> beam.PTransform[EmbeddableItem, Any]:
204+
return _QdrantWriteTransform(self)
205+
206+
def create_converter(
207+
self,
208+
) -> Callable[[EmbeddableItem], "models.PointStruct"]:
209+
def convert(item: EmbeddableItem) -> "models.PointStruct":
210+
if item.dense_embedding is None and item.sparse_embedding is None:
211+
raise ValueError(
212+
"EmbeddableItem must have at least one embedding (dense or sparse)")
213+
vector = {}
214+
if item.dense_embedding is not None:
215+
vector[self.dense_embedding_key] = item.dense_embedding
216+
if item.sparse_embedding is not None:
217+
sparse_indices, sparse_values = item.sparse_embedding
218+
vector[self.sparse_embedding_key] = models.SparseVector(
219+
indices=sparse_indices,
220+
values=sparse_values,
221+
)
222+
id = (
223+
int(item.id)
224+
if isinstance(item.id, str) and item.id.isdigit() else item.id)
225+
return models.PointStruct(
226+
id=id,
227+
vector=vector,
228+
payload=item.metadata if item.metadata else None,
229+
)
230+
231+
return convert
232+
233+
234+
class _QdrantWriteTransform(beam.PTransform):
235+
def __init__(self, config: QdrantWriteConfig):
236+
self.config = config
237+
238+
def expand(self, input_or_inputs: beam.PCollection[EmbeddableItem]):
239+
return (
240+
input_or_inputs
241+
| "Convert to Records" >> beam.Map(self.config.create_converter())
242+
| beam.ParDo(_QdrantWriteFn(self.config)))
243+
244+
245+
class _QdrantWriteFn(beam.DoFn):
246+
def __init__(self, config: QdrantWriteConfig):
247+
self.config = config
248+
self._client: "Optional[QdrantClient]" = None
249+
250+
def start_bundle(self):
251+
self._batch = []
252+
self._batch_byte_size = 0
253+
254+
def process(self, element, *args, **kwargs):
255+
element_byte_size = get_deep_size(element)
256+
new_batch_byte_size = self._batch_byte_size + element_byte_size
257+
258+
is_batch_full = len(self._batch) >= self.config.batch_size
259+
is_batch_too_large = new_batch_byte_size > self.config.max_batch_byte_size
260+
if (is_batch_full or is_batch_too_large):
261+
self._flush()
262+
self._batch.append(element)
263+
self._batch_byte_size += element_byte_size
264+
265+
def setup(self):
266+
params = self.config.connection_params
267+
self._client = QdrantClient(
268+
location=params.location,
269+
url=params.url,
270+
port=params.port,
271+
grpc_port=params.grpc_port,
272+
prefer_grpc=params.prefer_grpc,
273+
https=params.https,
274+
api_key=params.api_key,
275+
prefix=params.prefix,
276+
timeout=params.timeout,
277+
host=params.host,
278+
path=params.path,
279+
check_compatibility=False,
280+
**params.kwargs,
281+
)
282+
283+
def teardown(self):
284+
if self._client:
285+
try:
286+
self._client.close()
287+
finally:
288+
self._client = None
289+
290+
def finish_bundle(self):
291+
self._flush()
292+
293+
def _flush(self):
294+
if not self._batch:
295+
return
296+
if not self._client:
297+
raise RuntimeError("Qdrant client is not initialized")
298+
299+
max_retries = 3
300+
attempt = 1
301+
while True:
302+
try:
303+
self._client.upsert(
304+
collection_name=self.config.collection_name,
305+
points=self._batch,
306+
timeout=self.config.timeout,
307+
**self.config.kwargs,
308+
)
309+
break
310+
except ResourceExhaustedResponse as e:
311+
time.sleep(e.retry_after_s)
312+
# don't count rate-limit against max_retries
313+
continue
314+
except (UnexpectedResponse, ResponseHandlingException,
315+
grpc.RpcError) as e:
316+
if attempt > max_retries:
317+
raise
318+
time.sleep(2**attempt)
319+
attempt += 1
320+
self._batch = []
321+
self._batch_byte_size = 0
322+
323+
def display_data(self):
324+
res = super().display_data()
325+
res["collection"] = self.config.collection_name
326+
res["batch_size"] = self.config.batch_size
327+
res["max_batch_byte_size"] = self.config.max_batch_byte_size
328+
return res

0 commit comments

Comments
 (0)