This is an automated email from the ASF dual-hosted git repository.

claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 8b07c7a1925 feat(ml): add qdrant ingestion (#38142)
8b07c7a1925 is described below

commit 8b07c7a192581506332b08e0b39dd8d36a44a79a
Author: Michael Gruschke <[email protected]>
AuthorDate: Sun May 31 12:44:45 2026 +0200

    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
---
 CHANGES.md                                         |   3 +-
 sdks/python/apache_beam/ml/rag/ingestion/qdrant.py | 328 ++++++++++++++
 .../apache_beam/ml/rag/ingestion/qdrant_it_test.py | 326 ++++++++++++++
 .../apache_beam/ml/rag/ingestion/qdrant_test.py    | 480 +++++++++++++++++++++
 sdks/python/setup.py                               |  12 +-
 5 files changed, 1143 insertions(+), 6 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 74209bb7499..3b4af42daf8 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -61,6 +61,7 @@
 
 * New highly anticipated feature X added to Python SDK 
([#X](https://github.com/apache/beam/issues/X)).
 * New highly anticipated feature Y added to Java SDK 
([#Y](https://github.com/apache/beam/issues/Y)).
+* (Python) Added [Qdrant](https://qdrant.tech/) VectorDatabaseWriteConfig 
implementation ([#38141](https://github.com/apache/beam/issues/38141)).
 
 ## I/Os
 
@@ -2462,4 +2463,4 @@ Schema Options, it will be removed in version `2.23.0`. 
([BEAM-9704](https://iss
 
 ## Highlights
 
-- For versions 2.19.0 and older release notes are available on [Apache Beam 
Blog](https://beam.apache.org/blog/).
\ No newline at end of file
+- For versions 2.19.0 and older release notes are available on [Apache Beam 
Blog](https://beam.apache.org/blog/).
diff --git a/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py 
b/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py
new file mode 100644
index 00000000000..abe9efc56cb
--- /dev/null
+++ b/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py
@@ -0,0 +1,328 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+from dataclasses import field
+from typing import Any
+from typing import Optional
+
+import grpc
+from objsize import get_deep_size
+
+try:
+  from qdrant_client import QdrantClient
+  from qdrant_client import models
+  from qdrant_client.common.client_exceptions import ResourceExhaustedResponse
+  from qdrant_client.http.exceptions import ResponseHandlingException
+  from qdrant_client.http.exceptions import UnexpectedResponse
+except ImportError:
+  logging.warning("Qdrant client library is not installed.")
+
+import apache_beam as beam
+from apache_beam.ml.rag.ingestion.base import VectorDatabaseWriteConfig
+from apache_beam.ml.rag.types import EmbeddableItem
+
+DEFAULT_WRITE_BATCH_SIZE = 1000
+DEFAULT_MAX_BATCH_BYTE_SIZE = 4 << 20
+
+
+@dataclass
+class QdrantConnectionParameters:
+  """Configuration parameters for connecting to Qdrant service.
+
+  Either `location`, `url`, `host`, or `path` must be provided to establish
+  a connection.
+
+  Args:
+    location:
+      If `str` - use it as a `url` parameter.
+      If `None` - use default values for `host` and `port`.
+    url: either host or str of "<scheme>//<host>:<port>/<prefix>".
+      Default: `None`
+    port: Port of the REST API interface. Default: 6333
+    grpc_port: Port of the gRPC interface. Default: 6334
+    prefer_grpc: If `true` - use gPRC interface whenever possible.
+    https: If `true` - use HTTPS(SSL) protocol. Default: `None`
+    api_key: API key for authentication in Qdrant Cloud. Default: `None`
+    prefix:
+      If not `None` - add `prefix` to the REST URL path.
+      Example: `service/v1` will result in
+      `http://localhost:6333/service/v1/{qdrant-endpoint}` for REST API.
+      Default: `None`
+    timeout:
+      Timeout for REST and gRPC API requests.
+      Default: 5 seconds for REST and unlimited for gRPC
+    host:
+      Host name of Qdrant service.
+      If url and host are None, set to 'localhost'.
+      Default: `None`
+    path: Persistence path for QdrantLocal. Default: `None`
+    **kwargs: Additional arguments passed directly into client initialization
+  """
+
+  location: Optional[str] = None
+  url: Optional[str] = None
+  port: Optional[int] = 6333
+  grpc_port: int = 6334
+  prefer_grpc: bool = False
+  https: Optional[bool] = None
+  api_key: Optional[str] = None
+  prefix: Optional[str] = None
+  timeout: Optional[int] = None
+  host: Optional[str] = None
+  path: Optional[str] = None
+  kwargs: dict[str, Any] = field(default_factory=dict)
+
+  def __post_init__(self):
+    if not (self.location or self.url or self.host or self.path):
+      raise ValueError(
+          "One of location, url, host, or path must be provided for Qdrant")
+
+  @classmethod
+  def for_cloud(
+      cls,
+      url: str,
+      api_key: str,
+      *,
+      prefer_grpc: bool = False,
+      timeout: Optional[int] = None,
+      **kwargs: Any,
+  ) -> "QdrantConnectionParameters":
+    """Connect to Qdrant Cloud. Requires the cluster URL and an API key."""
+    return cls(
+        url=url,
+        api_key=api_key,
+        https=True,
+        prefer_grpc=prefer_grpc,
+        timeout=timeout,
+        kwargs=kwargs,
+    )
+
+  @classmethod
+  def for_host(
+      cls,
+      host: str,
+      port: int = 6333,
+      *,
+      grpc_port: int = 6334,
+      prefer_grpc: bool = False,
+      https: bool = False,
+      api_key: Optional[str] = None,
+      timeout: Optional[int] = None,
+      **kwargs: Any,
+  ) -> "QdrantConnectionParameters":
+    """Connect to a self-hosted Qdrant instance by host and port."""
+    return cls(
+        host=host,
+        port=port,
+        grpc_port=grpc_port,
+        prefer_grpc=prefer_grpc,
+        https=https,
+        api_key=api_key,
+        timeout=timeout,
+        kwargs=kwargs,
+    )
+
+  @classmethod
+  def for_url(
+      cls,
+      url: str,
+      *,
+      api_key: Optional[str] = None,
+      prefer_grpc: bool = False,
+      timeout: Optional[int] = None,
+      **kwargs: Any,
+  ) -> "QdrantConnectionParameters":
+    """Connect using a full URL like 'https://my-qdrant.example.com:6333'."""
+    return cls(
+        url=url,
+        api_key=api_key,
+        prefer_grpc=prefer_grpc,
+        timeout=timeout,
+        kwargs=kwargs)
+
+  @classmethod
+  def local(cls, path: str) -> "QdrantConnectionParameters":
+    """Use an embedded Qdrant instance persisted to the given path."""
+    return cls(path=path)
+
+  @classmethod
+  def in_memory(cls) -> "QdrantConnectionParameters":
+    """Use an embedded in-memory Qdrant instance. Useful for tests."""
+    return cls(location=":memory:")
+
+
+@dataclass
+class QdrantWriteConfig(VectorDatabaseWriteConfig):
+  """Configuration for writing to Qdrant vector database.
+
+  This class defines the parameters needed to write data to a qdrant 
collection,
+  including collection targeting, batching behavior, and operation timeouts.
+
+  Args:
+    connection_params: QdrantConnectionParameters with connection settings.
+    collection_name: Name of the Qdrant collection to write to.
+    timeout: Optional timeout for write operations in seconds. Default is None.
+    batch_size: Number of points to write in each batch. Default is 1000.
+    kwargs: Additional keyword arguments to pass to the client's upsert method.
+    dense_embedding_key: name for the dense vector in the qdrant collection.
+    sparse_embedding_key: name for the sparse vector in the qdrant collection.
+  """
+
+  connection_params: QdrantConnectionParameters
+  collection_name: str
+  timeout: Optional[int] = None
+  batch_size: int = DEFAULT_WRITE_BATCH_SIZE
+  max_batch_byte_size: int = DEFAULT_MAX_BATCH_BYTE_SIZE
+  kwargs: dict[str, Any] = field(default_factory=dict)
+  dense_embedding_key: str = "dense"
+  sparse_embedding_key: str = "sparse"
+
+  def __post_init__(self):
+    if not self.collection_name:
+      raise ValueError("Collection name must be provided")
+    if self.batch_size <= 0:
+      raise ValueError("Batch size must be a positive integer")
+
+  def create_write_transform(self) -> beam.PTransform[EmbeddableItem, Any]:
+    return _QdrantWriteTransform(self)
+
+  def create_converter(
+      self,
+  ) -> Callable[[EmbeddableItem], "models.PointStruct"]:
+    def convert(item: EmbeddableItem) -> "models.PointStruct":
+      if item.dense_embedding is None and item.sparse_embedding is None:
+        raise ValueError(
+            "EmbeddableItem must have at least one embedding (dense or 
sparse)")
+      vector = {}
+      if item.dense_embedding is not None:
+        vector[self.dense_embedding_key] = item.dense_embedding
+      if item.sparse_embedding is not None:
+        sparse_indices, sparse_values = item.sparse_embedding
+        vector[self.sparse_embedding_key] = models.SparseVector(
+            indices=sparse_indices,
+            values=sparse_values,
+        )
+      id = (
+          int(item.id)
+          if isinstance(item.id, str) and item.id.isdigit() else item.id)
+      return models.PointStruct(
+          id=id,
+          vector=vector,
+          payload=item.metadata if item.metadata else None,
+      )
+
+    return convert
+
+
+class _QdrantWriteTransform(beam.PTransform):
+  def __init__(self, config: QdrantWriteConfig):
+    self.config = config
+
+  def expand(self, input_or_inputs: beam.PCollection[EmbeddableItem]):
+    return (
+        input_or_inputs
+        | "Convert to Records" >> beam.Map(self.config.create_converter())
+        | beam.ParDo(_QdrantWriteFn(self.config)))
+
+
+class _QdrantWriteFn(beam.DoFn):
+  def __init__(self, config: QdrantWriteConfig):
+    self.config = config
+    self._client: "Optional[QdrantClient]" = None
+
+  def start_bundle(self):
+    self._batch = []
+    self._batch_byte_size = 0
+
+  def process(self, element, *args, **kwargs):
+    element_byte_size = get_deep_size(element)
+    new_batch_byte_size = self._batch_byte_size + element_byte_size
+
+    is_batch_full = len(self._batch) >= self.config.batch_size
+    is_batch_too_large = new_batch_byte_size > self.config.max_batch_byte_size
+    if (is_batch_full or is_batch_too_large):
+      self._flush()
+    self._batch.append(element)
+    self._batch_byte_size += element_byte_size
+
+  def setup(self):
+    params = self.config.connection_params
+    self._client = QdrantClient(
+        location=params.location,
+        url=params.url,
+        port=params.port,
+        grpc_port=params.grpc_port,
+        prefer_grpc=params.prefer_grpc,
+        https=params.https,
+        api_key=params.api_key,
+        prefix=params.prefix,
+        timeout=params.timeout,
+        host=params.host,
+        path=params.path,
+        check_compatibility=False,
+        **params.kwargs,
+    )
+
+  def teardown(self):
+    if self._client:
+      try:
+        self._client.close()
+      finally:
+        self._client = None
+
+  def finish_bundle(self):
+    self._flush()
+
+  def _flush(self):
+    if not self._batch:
+      return
+    if not self._client:
+      raise RuntimeError("Qdrant client is not initialized")
+
+    max_retries = 3
+    attempt = 1
+    while True:
+      try:
+        self._client.upsert(
+            collection_name=self.config.collection_name,
+            points=self._batch,
+            timeout=self.config.timeout,
+            **self.config.kwargs,
+        )
+        break
+      except ResourceExhaustedResponse as e:
+        time.sleep(e.retry_after_s)
+        # don't count rate-limit against max_retries
+        continue
+      except (UnexpectedResponse, ResponseHandlingException,
+              grpc.RpcError) as e:
+        if attempt > max_retries:
+          raise
+        time.sleep(2**attempt)
+        attempt += 1
+    self._batch = []
+    self._batch_byte_size = 0
+
+  def display_data(self):
+    res = super().display_data()
+    res["collection"] = self.config.collection_name
+    res["batch_size"] = self.config.batch_size
+    res["max_batch_byte_size"] = self.config.max_batch_byte_size
+    return res
diff --git a/sdks/python/apache_beam/ml/rag/ingestion/qdrant_it_test.py 
b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_it_test.py
new file mode 100644
index 00000000000..b82ce2b6e74
--- /dev/null
+++ b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_it_test.py
@@ -0,0 +1,326 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import pytest
+
+import apache_beam as beam
+from apache_beam.ml.rag.ingestion.qdrant import QdrantConnectionParameters
+from apache_beam.ml.rag.ingestion.qdrant import QdrantWriteConfig
+from apache_beam.ml.rag.types import Content
+from apache_beam.ml.rag.types import EmbeddableItem
+from apache_beam.ml.rag.types import Embedding
+from apache_beam.testing.test_pipeline import TestPipeline
+
+# pylint: disable=ungrouped-imports
+try:
+  from qdrant_client import models
+
+  QDRANT_AVAILABLE = True
+except ImportError:
+  QDRANT_AVAILABLE = False
+
+try:
+  from testcontainers.qdrant import QdrantContainer
+except ImportError:
+  QdrantContainer = None
+# pylint: enable=ungrouped-imports
+
+TEST_CORPUS = [
+    EmbeddableItem(
+        id="1",
+        content=Content(text="Test document one"),
+        metadata={"source": "test1"},
+        embedding=Embedding(dense_embedding=[1.0, 0.0]),
+    ),
+    EmbeddableItem(
+        id="2",
+        content=Content(text="Test document two"),
+        metadata={"source": "test2"},
+        embedding=Embedding(dense_embedding=[0.0, 1.0]),
+    ),
+    EmbeddableItem(
+        id="3",
+        content=Content(text="Test document three"),
+        metadata={"source": "test3"},
+        embedding=Embedding(dense_embedding=[-1.0, 0.0]),
+    ),
+]
+
+
[email protected](not QDRANT_AVAILABLE, "qdrant dependencies not installed.")
[email protected](not QdrantContainer, "qdrant test_container not installed.")
[email protected]_docker_in_docker
+class TestQdrantIngestion(unittest.TestCase):
+  @classmethod
+  def setUpClass(cls):
+    cls._container = QdrantContainer()
+    cls._container.start()
+    cls._host = cls._container.get_container_host_ip()
+    cls._port = int(cls._container.get_exposed_port(6333))
+    cls._connection_params = QdrantConnectionParameters(
+        host=cls._host, port=cls._port)
+
+  def setUp(self):
+    self._collection_name = f"test_collection_{self._testMethodName}"
+
+    self.client = self._container.get_client()
+    self.client.create_collection(
+        collection_name=self._collection_name,
+        vectors_config={
+            "dense": models.VectorParams(
+                size=2, distance=models.Distance.COSINE)
+        },
+        sparse_vectors_config={"sparse": models.SparseVectorParams()},
+    )
+    assert self.client.collection_exists(collection_name=self._collection_name)
+
+  @classmethod
+  def tearDownClass(cls):
+    cls._container.stop()
+
+  def test_write_on_non_existent_collection(self):
+    non_existent = "nonexistent_collection"
+    write_config = QdrantWriteConfig(
+        connection_params=self._connection_params,
+        collection_name=non_existent,
+        batch_size=1,
+    )
+
+    with self.assertRaises(Exception):
+      with TestPipeline(is_integration_test=True) as p:
+        _ = p | beam.Create(TEST_CORPUS) | 
write_config.create_write_transform()
+
+  def test_write_dense_embeddings_only(self):
+    write_config = QdrantWriteConfig(
+        connection_params=self._connection_params,
+        collection_name=self._collection_name,
+        batch_size=len(TEST_CORPUS),
+    )
+
+    with TestPipeline(is_integration_test=True) as p:
+      _ = p | beam.Create(TEST_CORPUS) | write_config.create_write_transform()
+
+    count_result = self.client.count(collection_name=self._collection_name)
+    self.assertEqual(count_result.count, len(TEST_CORPUS))
+
+    points, _ = self.client.scroll(
+      collection_name=self._collection_name,
+      limit=100,
+      with_payload=True,
+      with_vectors=True,
+    )
+    points_by_id = {p.id: p for p in points}
+
+    for item in TEST_CORPUS:
+      expected_record = models.Record(
+          id=int(item.id),
+          vector={"dense": item.dense_embedding},
+          payload=item.metadata,
+      )
+      self.assertEqual(expected_record, points_by_id[int(item.id)])
+
+  def test_write_sparse_embeddings_only(self):
+    sparse_corpus = [
+        EmbeddableItem(
+            id="1",
+            content=Content(text="Sparse doc one"),
+            metadata={"source": "sparse1"},
+            embedding=Embedding(sparse_embedding=([0, 1, 2], [0.1, 0.2, 0.3])),
+        ),
+        EmbeddableItem(
+            id="2",
+            content=Content(text="Sparse doc two"),
+            metadata={"source": "sparse2"},
+            embedding=Embedding(sparse_embedding=([1, 3, 5], [0.4, 0.5, 0.6])),
+        ),
+    ]
+
+    write_config = QdrantWriteConfig(
+        connection_params=self._connection_params,
+        collection_name=self._collection_name,
+        batch_size=len(sparse_corpus),
+    )
+
+    with TestPipeline(is_integration_test=True) as p:
+      _ = p | beam.Create(sparse_corpus) | 
write_config.create_write_transform()
+
+    count_result = self.client.count(collection_name=self._collection_name)
+    self.assertEqual(count_result.count, len(sparse_corpus))
+
+    points, _ = self.client.scroll(
+      collection_name=self._collection_name,
+      limit=100,
+      with_payload=True,
+      with_vectors=True,
+    )
+    points_by_id = {p.id: p for p in points}
+
+    for item in sparse_corpus:
+      expected_record = models.Record(
+          id=int(item.id),
+          vector={
+              "sparse": models.SparseVector(
+                  indices=item.sparse_embedding[0],
+                  values=item.sparse_embedding[1],
+              )
+          },
+          payload=item.metadata,
+      )
+      self.assertEqual(expected_record, points_by_id[int(item.id)])
+
+  def test_write_both_dense_and_sparse(self):
+    hybrid_corpus = [
+        EmbeddableItem(
+            id="1",
+            content=Content(text="Hybrid doc one"),
+            metadata={"source": "hybrid1"},
+            embedding=Embedding(
+                dense_embedding=[1.0, 0.0],
+                sparse_embedding=([0, 1], [0.1, 0.2])),
+        ),
+        EmbeddableItem(
+            id="2",
+            content=Content(text="Hybrid doc two"),
+            metadata={"source": "hybrid2"},
+            embedding=Embedding(
+                dense_embedding=[0.0, 1.0],
+                sparse_embedding=([2, 3], [0.3, 0.4])),
+        ),
+    ]
+
+    write_config = QdrantWriteConfig(
+        connection_params=self._connection_params,
+        collection_name=self._collection_name,
+        batch_size=len(hybrid_corpus),
+    )
+
+    with TestPipeline(is_integration_test=True) as p:
+      _ = p | beam.Create(hybrid_corpus) | 
write_config.create_write_transform()
+
+    count_result = self.client.count(collection_name=self._collection_name)
+    self.assertEqual(count_result.count, len(hybrid_corpus))
+
+    points, _ = self.client.scroll(
+      collection_name=self._collection_name,
+      limit=100,
+      with_payload=True,
+      with_vectors=True,
+    )
+    points_by_id = {p.id: p for p in points}
+
+    for item in hybrid_corpus:
+      expected_record = models.Record(
+          id=int(item.id),
+          vector={
+              "dense": item.dense_embedding,
+              "sparse": models.SparseVector(
+                  indices=item.sparse_embedding[0],
+                  values=item.sparse_embedding[1],
+              ),
+          },
+          payload=item.metadata,
+      )
+      self.assertEqual(expected_record, points_by_id[int(item.id)])
+
+  def test_write_with_batching(self):
+    batch_corpus = [
+        EmbeddableItem(
+            id=str(i),
+            content=Content(text=f"Batch doc {i}"),
+            metadata={"batch_id": i},
+            embedding=Embedding(dense_embedding=[1.0, 0.0]),
+        ) for i in range(1, 8)
+    ]
+
+    write_config = QdrantWriteConfig(
+        connection_params=self._connection_params,
+        collection_name=self._collection_name,
+        batch_size=3,
+    )
+
+    with TestPipeline(is_integration_test=True) as p:
+      _ = p | beam.Create(batch_corpus) | write_config.create_write_transform()
+
+    count_result = self.client.count(collection_name=self._collection_name)
+    self.assertEqual(count_result.count, len(batch_corpus))
+
+    points, _ = self.client.scroll(
+      collection_name=self._collection_name,
+      limit=100,
+      with_payload=True,
+      with_vectors=True,
+    )
+    points_by_id = {p.id: p for p in points}
+
+    for item in batch_corpus:
+      expected_record = models.Record(
+          id=int(item.id),
+          vector={
+              "dense": item.dense_embedding,
+          },
+          payload=item.metadata,
+      )
+      self.assertEqual(expected_record, points_by_id[int(item.id)])
+
+  def test_write_with_byte_size_limit(self):
+    byte_size_corpus = [
+        EmbeddableItem(
+            id=str(i),
+            content=Content(text=f"Byte size doc {i}"),
+            metadata={"data": "x" * 9000},
+            embedding=Embedding(dense_embedding=[1.0, 0.0]),
+        ) for i in range(5)
+    ]
+
+    write_config = QdrantWriteConfig(
+        connection_params=self._connection_params,
+        collection_name=self._collection_name,
+        batch_size=100,
+        max_batch_byte_size=15_000,
+    )
+
+    with TestPipeline(is_integration_test=True) as p:
+      _ = (
+          p
+          | beam.Create(byte_size_corpus)
+          | write_config.create_write_transform())
+
+    count_result = self.client.count(collection_name=self._collection_name)
+    self.assertEqual(count_result.count, len(byte_size_corpus))
+
+    points, _ = self.client.scroll(
+      collection_name=self._collection_name,
+      limit=100,
+      with_payload=True,
+      with_vectors=True,
+    )
+    points_by_id = {p.id: p for p in points}
+
+    for item in byte_size_corpus:
+      expected_record = models.Record(
+          id=int(item.id),
+          vector={
+              "dense": item.dense_embedding,
+          },
+          payload=item.metadata,
+      )
+      self.assertEqual(expected_record, points_by_id[int(item.id)])
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py 
b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py
new file mode 100644
index 00000000000..ff4ee14e97a
--- /dev/null
+++ b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py
@@ -0,0 +1,480 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+from unittest import mock
+
+try:
+  from qdrant_client import models
+  from qdrant_client.common.client_exceptions import ResourceExhaustedResponse
+  from qdrant_client.http.exceptions import ResponseHandlingException
+  from qdrant_client.http.exceptions import UnexpectedResponse
+
+  QDRANT_AVAILABLE = True
+except ImportError:
+  QDRANT_AVAILABLE = False
+
+import grpc
+from objsize import get_deep_size
+
+from apache_beam.ml.rag.ingestion.qdrant import QdrantConnectionParameters
+from apache_beam.ml.rag.ingestion.qdrant import QdrantWriteConfig
+from apache_beam.ml.rag.ingestion.qdrant import _QdrantWriteFn
+from apache_beam.ml.rag.types import Content
+from apache_beam.ml.rag.types import EmbeddableItem
+from apache_beam.ml.rag.types import Embedding
+
+
+class TestQdrantConnectionParameters(unittest.TestCase):
+  def test_no_params_raises_value_error(self):
+    with self.assertRaises(ValueError):
+      QdrantConnectionParameters()
+
+  def test_location_is_sufficient(self):
+    QdrantConnectionParameters(location=":memory:")
+
+  def test_url_is_sufficient(self):
+    QdrantConnectionParameters(url="http://localhost:6333";)
+
+  def test_host_is_sufficient(self):
+    QdrantConnectionParameters(host="localhost")
+
+  def test_path_is_sufficient(self):
+    QdrantConnectionParameters(path="/tmp/qdrant")
+
+
+class TestQdrantWriteConfig(unittest.TestCase):
+  def test_empty_collection_name_raises_value_error(self):
+    with self.assertRaises(ValueError):
+      QdrantWriteConfig(
+          connection_params=QdrantConnectionParameters(location=":memory:"),
+          collection_name="",
+      )
+
+  def test_none_collection_name_raises_value_error(self):
+    with self.assertRaises(ValueError):
+      QdrantWriteConfig(
+          connection_params=QdrantConnectionParameters(location=":memory:"),
+          collection_name=None,
+      )
+
+  def test_batch_size_zero_raises_value_error(self):
+    with self.assertRaises(ValueError):
+      QdrantWriteConfig(
+          connection_params=QdrantConnectionParameters(location=":memory:"),
+          collection_name="test",
+          batch_size=0,
+      )
+
+  def test_batch_size_negative_raises_value_error(self):
+    with self.assertRaises(ValueError):
+      QdrantWriteConfig(
+          connection_params=QdrantConnectionParameters(location=":memory:"),
+          collection_name="test",
+          batch_size=-1,
+      )
+
+  def test_display_data(self):
+    config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+        batch_size=100,
+        max_batch_byte_size=5000,
+    )
+    fn = _QdrantWriteFn(config)
+    data = fn.display_data()
+    self.assertEqual(data["collection"], "test")
+    self.assertEqual(data["batch_size"], 100)
+    self.assertEqual(data["max_batch_byte_size"], 5000)
+
+  def test_for_cloud_creates_connection(self):
+    params = QdrantConnectionParameters.for_cloud(
+        url="https://test.cloud.qdrant.io";,
+        api_key="my-key",
+    )
+    self.assertEqual(params.url, "https://test.cloud.qdrant.io";)
+    self.assertEqual(params.api_key, "my-key")
+    self.assertTrue(params.https)
+
+  def test_for_host_creates_connection(self):
+    params = QdrantConnectionParameters.for_host(host="localhost", port=6333)
+    self.assertEqual(params.host, "localhost")
+    self.assertEqual(params.port, 6333)
+
+  def test_in_memory_creates_connection(self):
+    params = QdrantConnectionParameters.in_memory()
+    self.assertEqual(params.location, ":memory:")
+
+  def test_for_url_creates_connection(self):
+    params = QdrantConnectionParameters.for_url(url="http://localhost:6333";)
+    self.assertEqual(params.url, "http://localhost:6333";)
+
+  def test_kwargs_passthrough(self):
+    config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+        kwargs={"parallel": 4},
+    )
+    self.assertEqual(config.kwargs, {"parallel": 4})
+
+
[email protected](not QDRANT_AVAILABLE, "qdrant dependencies not installed.")
+class TestQdrantCreateConverter(unittest.TestCase):
+  def setUp(self):
+    self.config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+    )
+    self.convert = self.config.create_converter()
+
+  def test_dense_embedding_only(self):
+    item = EmbeddableItem(
+        id="1",
+        content=Content(text="test"),
+        embedding=Embedding(dense_embedding=[1.0, 2.0]),
+    )
+    result = self.convert(item)
+    self.assertIsInstance(result, models.PointStruct)
+    self.assertEqual(result.id, 1)
+    self.assertEqual(result.vector, {"dense": [1.0, 2.0]})
+    self.assertIsNone(result.payload)
+
+  def test_sparse_embedding_only(self):
+    item = EmbeddableItem(
+        id="2",
+        content=Content(text="test"),
+        embedding=Embedding(sparse_embedding=([0, 1], [0.5, 0.3])),
+    )
+    result = self.convert(item)
+    self.assertIsInstance(result, models.PointStruct)
+    self.assertIn("sparse", result.vector)
+    sparse_vec = result.vector["sparse"]
+    self.assertIsInstance(sparse_vec, models.SparseVector)
+    self.assertEqual(sparse_vec.indices, [0, 1])
+    self.assertEqual(sparse_vec.values, [0.5, 0.3])
+
+  def test_both_dense_and_sparse(self):
+    item = EmbeddableItem(
+        id="3",
+        content=Content(text="test"),
+        embedding=Embedding(
+            dense_embedding=[1.0, 2.0],
+            sparse_embedding=([0], [0.9]),
+        ),
+    )
+    result = self.convert(item)
+    self.assertEqual(set(result.vector.keys()), {"dense", "sparse"})
+    self.assertEqual(result.vector["dense"], [1.0, 2.0])
+    self.assertEqual(result.id, 3)
+
+  def test_raises_when_no_embedding(self):
+    item = EmbeddableItem(
+        id="4",
+        content=Content(text="test"),
+    )
+    with self.assertRaises(ValueError):
+      self.convert(item)
+
+  def test_string_digit_id_converted_to_int(self):
+    item = EmbeddableItem(
+        id="42",
+        content=Content(text="test"),
+        embedding=Embedding(dense_embedding=[0.1, 0.2]),
+    )
+    result = self.convert(item)
+    self.assertEqual(result.id, 42)
+    self.assertIsInstance(result.id, int)
+
+  def test_non_digit_string_id_preserved(self):
+    item = EmbeddableItem(
+        id="abc-123",
+        content=Content(text="test"),
+        embedding=Embedding(dense_embedding=[0.1, 0.2]),
+    )
+    result = self.convert(item)
+    self.assertEqual(result.id, "abc-123")
+    self.assertIsInstance(result.id, str)
+
+  def test_integer_id_preserved(self):
+    item = EmbeddableItem(
+        id="99",
+        content=Content(text="test"),
+        embedding=Embedding(dense_embedding=[0.1, 0.2]),
+    )
+    result = self.convert(item)
+    self.assertEqual(result.id, 99)
+    self.assertIsInstance(result.id, int)
+
+  def test_none_metadata_becomes_none_payload(self):
+    item = EmbeddableItem(
+        id="1",
+        content=Content(text="test"),
+        embedding=Embedding(dense_embedding=[0.1, 0.2]),
+        metadata={},
+    )
+    result = self.convert(item)
+    self.assertIsNone(result.payload)
+
+  def test_custom_vector_keys(self):
+    config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+        dense_embedding_key="my_dense",
+        sparse_embedding_key="my_sparse",
+    )
+    convert = config.create_converter()
+    item = EmbeddableItem(
+        id="1",
+        content=Content(text="test"),
+        embedding=Embedding(
+            dense_embedding=[1.0],
+            sparse_embedding=([0], [0.5]),
+        ),
+    )
+    result = convert(item)
+    self.assertIn("my_dense", result.vector)
+    self.assertIn("my_sparse", result.vector)
+    self.assertNotIn("dense", result.vector)
+    self.assertNotIn("sparse", result.vector)
+
+  def test_payload_includes_metadata(self):
+    item = EmbeddableItem(
+        id="1",
+        content=Content(text="test"),
+        embedding=Embedding(dense_embedding=[1.0]),
+        metadata={
+            "source": "test", "score": 0.95
+        },
+    )
+    result = self.convert(item)
+    self.assertEqual(result.payload, {"source": "test", "score": 0.95})
+
+  def test_convert_from_text_factory(self):
+    item = EmbeddableItem.from_text("hello", metadata={"source": "test"})
+    item.embedding = Embedding(dense_embedding=[0.5, 0.5])
+    result = self.convert(item)
+    self.assertIsInstance(result, models.PointStruct)
+    self.assertIn("dense", result.vector)
+
+
[email protected](not QDRANT_AVAILABLE, "qdrant dependencies not installed.")
+class TestQdrantWriteFnBatching(unittest.TestCase):
+  def setUp(self):
+    self.config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+        batch_size=3,
+    )
+    self.fn = _QdrantWriteFn(self.config)
+    self.fn._client = mock.MagicMock()
+    self.fn.start_bundle()
+
+  def test_batch_size_triggers_flush_correctly(self):
+    client = self.fn._client
+    for i in range(5):
+      self.fn.process(
+          EmbeddableItem(
+              id=str(i),
+              content=Content(text="test"),
+              embedding=Embedding(dense_embedding=[float(i)]),
+          ))
+    self.fn.finish_bundle()
+
+    self.assertEqual(client.upsert.call_count, 2)
+    first = client.upsert.call_args_list[0][1]["points"]
+    second = client.upsert.call_args_list[1][1]["points"]
+    self.assertEqual(len(first), 3)
+    self.assertEqual(len(second), 2)
+    self.assertEqual(first[0].id, "0")
+    self.assertEqual(first[1].id, "1")
+    self.assertEqual(first[2].id, "2")
+    self.assertEqual(second[0].id, "3")
+    self.assertEqual(second[1].id, "4")
+
+  def test_partial_batch_flushed_on_finish_bundle(self):
+    for i in range(2):
+      self.fn.process(
+          EmbeddableItem(
+              id=str(i),
+              content=Content(text="test"),
+              embedding=Embedding(dense_embedding=[float(i)]),
+          ))
+    self.fn.finish_bundle()
+
+    points = self.fn._client.upsert.call_args[1]["points"]
+    self.assertEqual(len(points), 2)
+
+  def test_byte_size_exceeded_triggers_flush(self):
+    item = EmbeddableItem(
+        id="1",
+        content=Content(
+            text="a" * 256,
+            image=b"x" * 1024,
+        ),
+    )
+    item_size = get_deep_size(item)
+
+    config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+        batch_size=10,
+        max_batch_byte_size=item_size * 2,
+    )
+    fn = _QdrantWriteFn(config)
+    fn._client = mock.MagicMock()
+    fn.start_bundle()
+    client = fn._client
+
+    for i in range(3):
+      fn.process(
+          EmbeddableItem(
+              id=str(i),
+              content=Content(
+                  text="a" * 256,
+                  image=b"x" * 1024,
+              ),
+          ))
+    fn.finish_bundle()
+
+    self.assertEqual(client.upsert.call_count, 2)
+    first = client.upsert.call_args_list[0][1]["points"]
+    second = client.upsert.call_args_list[1][1]["points"]
+    self.assertEqual(len(first), 2)
+    self.assertEqual(len(second), 1)
+
+
[email protected](not QDRANT_AVAILABLE, "qdrant dependencies not installed.")
+class TestQdrantWriteFnRetries(unittest.TestCase):
+  def setUp(self):
+    self.config = QdrantWriteConfig(
+        connection_params=QdrantConnectionParameters(location=":memory:"),
+        collection_name="test",
+    )
+    self.fn = _QdrantWriteFn(self.config)
+    self.fn._client = mock.MagicMock()
+    self.fn._batch = [
+        EmbeddableItem(
+            id="1",
+            content=Content(text="test"),
+            embedding=Embedding(dense_embedding=[1.0]),
+        )
+    ]
+    self.fn._batch_byte_size = 100
+
+  def test_retry_on_unexpected_response(self):
+    self.fn._client.upsert.side_effect = [
+        UnexpectedResponse(429, "error", b"", None),
+        None,
+    ]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 2)
+    mock_sleep.assert_called_once_with(2)
+
+  def test_retry_on_response_handling_exception(self):
+    self.fn._client.upsert.side_effect = [
+        ResponseHandlingException(Exception("error")),
+        None,
+    ]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 2)
+    mock_sleep.assert_called_once_with(2)
+
+  def test_retry_on_grpc_error(self):
+    self.fn._client.upsert.side_effect = [
+        grpc.RpcError("error"),
+        None,
+    ]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 2)
+    mock_sleep.assert_called_once_with(2)
+
+  def test_rate_limit_does_not_increment_attempt(self):
+    exc = ResourceExhaustedResponse("rate limited", 0)
+    exc.retry_after_s = 0.01
+    self.fn._client.upsert.side_effect = [exc, None]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 2)
+    mock_sleep.assert_called_once_with(0.01)
+
+  def test_multiple_rate_limits_dont_exhaust_retries(self):
+    exc = ResourceExhaustedResponse("rate limited", 0)
+    exc.retry_after_s = 0.01
+    self.fn._client.upsert.side_effect = [exc, exc, exc, None]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 4)
+    self.assertEqual(mock_sleep.call_count, 3)
+
+  def test_rate_limit_then_error_then_success(self):
+    exc_rate = ResourceExhaustedResponse("rate limited", 0)
+    exc_rate.retry_after_s = 0.01
+    exc_error = UnexpectedResponse(429, "error", b"", None)
+    self.fn._client.upsert.side_effect = [exc_error, exc_rate, None]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 3)
+    self.assertEqual(mock_sleep.call_args_list[0], mock.call(2))
+    self.assertEqual(mock_sleep.call_args_list[1], mock.call(0.01))
+
+  def test_exponential_backoff_values(self):
+    self.fn._client.upsert.side_effect = [
+        UnexpectedResponse(429, "e1", b"", None),
+        UnexpectedResponse(429, "e2", b"", None),
+        UnexpectedResponse(429, "e3", b"", None),
+        None,
+    ]
+    with mock.patch("time.sleep") as mock_sleep:
+      self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 4)
+    self.assertEqual(mock_sleep.call_args_list[0], mock.call(2))
+    self.assertEqual(mock_sleep.call_args_list[1], mock.call(4))
+    self.assertEqual(mock_sleep.call_args_list[2], mock.call(8))
+
+  def test_raises_after_max_retries(self):
+    self.fn._client.upsert.side_effect = [
+        UnexpectedResponse(429, "e1", b"", None),
+        UnexpectedResponse(429, "e2", b"", None),
+        UnexpectedResponse(429, "e3", b"", None),
+        UnexpectedResponse(429, "e4", b"", None),
+    ]
+    with mock.patch("time.sleep") as mock_sleep:
+      with self.assertRaises(UnexpectedResponse):
+        self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 4)
+    self.assertEqual(mock_sleep.call_count, 3)
+
+  def test_raises_on_last_non_rate_limit_attempt(self):
+    exc_rate = ResourceExhaustedResponse("rate limited", 0)
+    exc_rate.retry_after_s = 0.01
+    self.fn._client.upsert.side_effect = [
+        exc_rate,
+        UnexpectedResponse(429, "e1", b"", None),
+        UnexpectedResponse(429, "e2", b"", None),
+        UnexpectedResponse(429, "e3", b"", None),
+        UnexpectedResponse(429, "e4", b"", None),
+    ]
+    with mock.patch("time.sleep") as mock_sleep:
+      with self.assertRaises(UnexpectedResponse):
+        self.fn._flush()
+    self.assertEqual(self.fn._client.upsert.call_count, 5)
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/sdks/python/setup.py b/sdks/python/setup.py
index dbdef30aef9..961f890e1ed 100644
--- a/sdks/python/setup.py
+++ b/sdks/python/setup.py
@@ -166,6 +166,7 @@ dataframe_dependency = [
 ]
 
 milvus_dependency = ['pymilvus>=2.5.10,<3.0.0']
+qdrant_dependency = ['qdrant-client>=1.15.0']
 
 # google-adk / OpenTelemetry require protobuf>=5; tensorflow-transform in
 # ml_test is pinned to versions that require protobuf<5 on Python 3.10. Those
@@ -508,7 +509,7 @@ if __name__ == '__main__':
               'scikit-learn>=0.20.0,<1.8.0',
               'sqlalchemy>=1.3,<3.0',
               'psycopg2-binary>=2.8.5,<3.0',
-              'testcontainers[mysql,kafka,milvus]>=4.0.0,<5.0.0',
+              'testcontainers[mysql,kafka,milvus,qdrant]>=4.0.0,<5.0.0',
               'cryptography>=41.0.2',
               # TODO(https://github.com/apache/beam/issues/36951): need to
               # further investigate the cause
@@ -607,14 +608,14 @@ if __name__ == '__main__':
               'tf2onnx>=1.16.1,<1.17',
           ] + ml_base_core,
           'p310_ml_test': [
-              'datatable',
-          ] + ml_base,
+            'datatable',
+          ] + ml_base + qdrant_dependency,
           'p312_ml_test': [
               'datatable',
-          ] + ml_base,
+          ] + ml_base + qdrant_dependency,
           # maintainer: milvus tests only run with this extension. Make sure it
           # is covered by docker-in-docker test when changing py version
-          'p313_ml_test': ml_base + milvus_dependency,
+          'p313_ml_test': ml_base + milvus_dependency + qdrant_dependency,
           'aws': ['boto3>=1.9,<2'],
           'azure': [
               'azure-storage-blob>=12.3.2,<13',
@@ -686,6 +687,7 @@ if __name__ == '__main__':
           'xgboost': ['xgboost>=1.6.0,<2.1.3', 'datatable==1.0.0'],
           'tensorflow-hub': ['tensorflow-hub>=0.14.0,<0.16.0'],
           'milvus': milvus_dependency,
+          'qdrant': qdrant_dependency,
           'vllm': ['openai==1.107.1', 'vllm==0.10.1.1', 'triton==3.3.1']
       },
       zip_safe=False,


Reply via email to