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 de56e6568bf [Python] Optionally write chunk content to Qdrant payload
(#40502)
de56e6568bf is described below
commit de56e6568bfa7cab0daf0dfa49dded1bdda842e6
Author: Wangshu <[email protected]>
AuthorDate: Thu Oct 15 06:56:56 2026 -0700
[Python] Optionally write chunk content to Qdrant payload (#40502)
Add QdrantWriteConfig.content_payload_key. When set, the converter stores
EmbeddableItem.content_string under that key in the point payload next to
the metadata, so points retrieved from a collection written by Beam carry
the chunk text. The default (None) keeps the current behavior of writing
only the metadata.
A key that collides with a metadata key raises ValueError instead of
overwriting it, an empty key is rejected at construction time, and the
item's metadata dict is copied rather than mutated.
Fixes #40476
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
CHANGES.md | 1 +
sdks/python/apache_beam/ml/rag/ingestion/qdrant.py | 17 +++++-
.../apache_beam/ml/rag/ingestion/qdrant_it_test.py | 25 +++++++++
.../apache_beam/ml/rag/ingestion/qdrant_test.py | 64 ++++++++++++++++++++++
4 files changed, 106 insertions(+), 1 deletion(-)
diff --git a/CHANGES.md b/CHANGES.md
index 14c7e676f18..c787b83dde7 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -72,6 +72,7 @@
* (Python) Expanded the SDK worker heap dump
(`--experiments=enable_heap_dump`) with process RSS, CPython allocator/GC
stats, and glibc `mallinfo2` native-heap/fragmentation stats to help
distinguish native-heap from Python-object memory growth
([#39244](https://github.com/apache/beam/issues/39244)).
* The `disableCounterMetrics`, `disableStringSetMetrics` and
`disableBoundedTrieMetrics` experiments are now honored by the Python SDK, as
they already were in Java (Python)
([#38746](https://github.com/apache/beam/issues/38746)).
* (Go) Pipeline-level `--resource_hints` are now forwarded to expansion
services, so they apply to cross-language transforms as they do in the Java and
Python SDKs. The `max_active_bundles_per_worker` hint name is now also accepted
by `--resource_hints` ([#23893](https://github.com/apache/beam/issues/23893)).
+* `QdrantWriteConfig` can now optionally store chunk content in the point
payload via `content_payload_key`, so retrieved points include the chunk text
(Python) ([#40476](https://github.com/apache/beam/issues/40476)).
* ReadFromBigQuery now supports Lakehouse runtime catalog (BigLake metastore)
tables with `method=DIRECT_READ`, using 4-part
`project.catalog.namespace.table` identifiers. Previously
`project:catalog.namespace.table` was silently mis-parsed and tables that
report no `numBytes` failed to split (Python)
([#39597](https://github.com/apache/beam/issues/39597)).
* (Python) Added `AwsOptions` with `--aws_*` pipeline options (e.g.
`--aws_access_key_id`, `--aws_region_name`, `--aws_endpoint_url`) that set
defaults for all AWS service clients. The existing `--s3_*` options in
`S3Options` override these defaults for the S3 client only
([#40528](https://github.com/apache/beam/issues/40528)).
* (Python) Added `--aws_credentials_secret_manager` so AWS credentials
(`--aws_access_key_id`, `--aws_secret_access_key`, `--aws_session_token` and
their `--s3_*` overrides) can be secret specs resolved through a secret manager
such as `GoogleCloudSecretManager`, instead of plain-text values
([#40528](https://github.com/apache/beam/issues/40528)).
diff --git a/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py
b/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py
index abe9efc56cb..a1e3fd7c8cf 100644
--- a/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py
+++ b/sdks/python/apache_beam/ml/rag/ingestion/qdrant.py
@@ -183,6 +183,11 @@ class QdrantWriteConfig(VectorDatabaseWriteConfig):
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.
+ content_payload_key: Optional payload key under which the item's content
+ (``EmbeddableItem.content_string``) is stored, so that the chunk text is
+ returned with search results. If ``None`` (default), content is not
+ written and only ``metadata`` is stored as the payload. A ``ValueError``
+ is raised at conversion time if the key collides with a metadata key.
"""
connection_params: QdrantConnectionParameters
@@ -193,12 +198,15 @@ class QdrantWriteConfig(VectorDatabaseWriteConfig):
kwargs: dict[str, Any] = field(default_factory=dict)
dense_embedding_key: str = "dense"
sparse_embedding_key: str = "sparse"
+ content_payload_key: Optional[str] = None
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")
+ if self.content_payload_key is not None and not self.content_payload_key:
+ raise ValueError("content_payload_key must be a non-empty string")
def create_write_transform(self) -> beam.PTransform[EmbeddableItem, Any]:
return _QdrantWriteTransform(self)
@@ -222,10 +230,17 @@ class QdrantWriteConfig(VectorDatabaseWriteConfig):
id = (
int(item.id)
if isinstance(item.id, str) and item.id.isdigit() else item.id)
+ payload = dict(item.metadata) if item.metadata else {}
+ if self.content_payload_key is not None:
+ if self.content_payload_key in payload:
+ raise ValueError(
+ f"content_payload_key '{self.content_payload_key}' collides "
+ f"with an existing metadata key for item {item.id}")
+ payload[self.content_payload_key] = item.content_string
return models.PointStruct(
id=id,
vector=vector,
- payload=item.metadata if item.metadata else None,
+ payload=payload if payload else None,
)
return convert
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
index b82ce2b6e74..7148b222932 100644
--- a/sdks/python/apache_beam/ml/rag/ingestion/qdrant_it_test.py
+++ b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_it_test.py
@@ -134,6 +134,31 @@ class TestQdrantIngestion(unittest.TestCase):
)
self.assertEqual(expected_record, points_by_id[int(item.id)])
+ def test_write_with_content_payload_key(self):
+ write_config = QdrantWriteConfig(
+ connection_params=self._connection_params,
+ collection_name=self._collection_name,
+ batch_size=len(TEST_CORPUS),
+ content_payload_key="content",
+ )
+
+ with TestPipeline(is_integration_test=True) as p:
+ _ = p | beam.Create(TEST_CORPUS) | write_config.create_write_transform()
+
+ points, _ = self.client.scroll(
+ collection_name=self._collection_name,
+ limit=100,
+ with_payload=True,
+ with_vectors=False,
+ )
+ points_by_id = {p.id: p for p in points}
+ self.assertEqual(len(points_by_id), len(TEST_CORPUS))
+
+ for item in TEST_CORPUS:
+ expected_payload = dict(item.metadata or {})
+ expected_payload["content"] = item.content_string
+ self.assertEqual(expected_payload, points_by_id[int(item.id)].payload)
+
def test_write_sparse_embeddings_only(self):
sparse_corpus = [
EmbeddableItem(
diff --git a/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py
b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py
index ff4ee14e97a..f969b09cb56 100644
--- a/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py
+++ b/sdks/python/apache_beam/ml/rag/ingestion/qdrant_test.py
@@ -269,6 +269,70 @@ class TestQdrantCreateConverter(unittest.TestCase):
self.assertIsInstance(result, models.PointStruct)
self.assertIn("dense", result.vector)
+ def test_content_not_in_payload_by_default(self):
+ item = EmbeddableItem(
+ id="1",
+ content=Content(text="hello"),
+ embedding=Embedding(dense_embedding=[1.0]),
+ metadata={"source": "test"},
+ )
+ result = self.convert(item)
+ self.assertEqual(result.payload, {"source": "test"})
+
+ def test_content_payload_key_writes_content(self):
+ config = QdrantWriteConfig(
+ connection_params=QdrantConnectionParameters(location=":memory:"),
+ collection_name="test",
+ content_payload_key="content",
+ )
+ item = EmbeddableItem(
+ id="1",
+ content=Content(text="hello"),
+ embedding=Embedding(dense_embedding=[1.0]),
+ metadata={"source": "test"},
+ )
+ result = config.create_converter()(item)
+ self.assertEqual(result.payload, {"source": "test", "content": "hello"})
+ # metadata of the input item must not be mutated
+ self.assertEqual(item.metadata, {"source": "test"})
+
+ def test_content_payload_key_without_metadata(self):
+ config = QdrantWriteConfig(
+ connection_params=QdrantConnectionParameters(location=":memory:"),
+ collection_name="test",
+ content_payload_key="text",
+ )
+ item = EmbeddableItem(
+ id="1",
+ content=Content(text="hello"),
+ embedding=Embedding(dense_embedding=[1.0]),
+ )
+ result = config.create_converter()(item)
+ self.assertEqual(result.payload, {"text": "hello"})
+
+ def test_content_payload_key_collision_raises(self):
+ config = QdrantWriteConfig(
+ connection_params=QdrantConnectionParameters(location=":memory:"),
+ collection_name="test",
+ content_payload_key="source",
+ )
+ item = EmbeddableItem(
+ id="1",
+ content=Content(text="hello"),
+ embedding=Embedding(dense_embedding=[1.0]),
+ metadata={"source": "test"},
+ )
+ with self.assertRaises(ValueError):
+ config.create_converter()(item)
+
+ def test_empty_content_payload_key_raises(self):
+ with self.assertRaises(ValueError):
+ QdrantWriteConfig(
+ connection_params=QdrantConnectionParameters(location=":memory:"),
+ collection_name="test",
+ content_payload_key="",
+ )
+
@unittest.skipIf(not QDRANT_AVAILABLE, "qdrant dependencies not installed.")
class TestQdrantWriteFnBatching(unittest.TestCase):