feat: batch row imports and split index configuration (#1055)

Adds batching to structured data row imports. Previously each row was
sent as a separate WebSocket message, causing one embeddings service
call per row. The bulk import clients now accumulate rows into batches
(default 40) before sending, and the --batch-size CLI argument is
wired through from tg-load-structured-data to the import call.

Splits the schema "indexes" field into two separate configurations:
- "query-indexes": fields for Cassandra exact-match retrieval
- "vector-indexes": fields for vector embedding and semantic search

This avoids wasting compute embedding primary key integers that only
need exact lookup, while allowing each field to independently opt in
to either or both retrieval patterns.

Adds row_id (the primary key value) as a clustering column in the
Cassandra rows table so multiple rows sharing the same index value
no longer silently overwrite each other.

Tests updated for new Cassandra schema.
This commit is contained in:
cybermaggedon 2026-07-22 10:20:06 +01:00 committed by GitHub
parent bdfdbb3c1f
commit 1740e315f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 190 additions and 54 deletions

View file

@ -0,0 +1,75 @@
# Structured Data: Index Split and Composite Keys
## Problem
The schema `"indexes"` field serves two purposes:
- **Cassandra row storage**: creates denormalised copies for exact-match retrieval
- **Vector embeddings**: sends field values to the embeddings service for semantic search
These are different capabilities with different costs. Embedding integer IDs is wasted compute; conversely, a field may benefit from semantic search without needing a Cassandra retrieval copy.
Additionally, the current Cassandra `rows` table uses `index_value` as the sole clustering column. Two rows sharing the same value for an indexed field (e.g. two stars named "Sirius") silently overwrite each other.
## Changes
### 1. Split index configuration
Replace `"indexes"` with:
- `"query-indexes"` -- fields for Cassandra exact-match lookup
- `"vector-indexes"` -- fields for vector embedding and semantic search
A field can appear in both lists.
### 2. Composite clustering key
Add `row_id` (the value of the schema's primary key field) as a second clustering column:
```
Before:
PRIMARY KEY ((collection, schema_name, index_name), index_value)
After:
PRIMARY KEY ((collection, schema_name, index_name), index_value, row_id)
```
This allows multiple rows to share the same index value. Queries by `index_value` alone still work -- Cassandra allows omitting trailing clustering columns.
### 3. Schema config example
```json
{
"name": "stars",
"fields": [
{"name": "id", "type": "integer", "primary_key": true},
{"name": "proper", "type": "string"},
{"name": "spect", "type": "string"},
{"name": "con", "type": "string"}
],
"query-indexes": ["id", "proper", "con"],
"vector-indexes": ["proper", "spect", "con"]
}
```
## Future considerations
1. **Composite primary keys**: The primary key could span multiple columns
(e.g. `"primary_key": ["region", "id"]`). The data model already
supports this -- `row_id` in Cassandra is `frozen<list<text>>` and
`index_value` is already a list. The schema config and writer would
need to resolve multiple fields into the `row_id` value.
2. **Composite query indexes**: Each query index entry could cover multiple
columns (e.g. `"query-indexes": [["last_name", "first_name"], "email"]`).
This would allow exact-match lookup on field combinations. The Cassandra
storage already uses `index_value` as a `frozen<list<text>>`, so the
data model supports it -- the config parsing and index value construction
would need updating.
### Affected components
- `storage/rows/cassandra/write.py` -- table schema, reads `query-indexes`, writes `row_id`
- `embeddings/row_embeddings/embeddings.py` -- reads `vector-indexes`
- `query/row_embeddings/qdrant/service.py` -- no change (consumes embeddings output)
- Row query service -- must handle multiple rows per index value

View file

@ -108,6 +108,7 @@ class TestRowsCassandraIntegration:
processor.ensure_tables = Processor.ensure_tables.__get__(processor, Processor)
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
processor.register_partitions = Processor.register_partitions.__get__(processor, Processor)
processor._apply_schema_config = Processor._apply_schema_config.__get__(processor, Processor)
@ -477,9 +478,9 @@ class TestRowsCassandraIntegration:
# Check that data is passed as a dict (will be map in Cassandra)
insert_call = rows_insert_calls[0]
values = insert_call[0][1]
# Values are: (collection, schema_name, index_name, index_value, data, source)
# values[4] should be the data map
data_map = values[4]
# Values are: (collection, schema_name, index_name, index_value, row_id, data, source)
# values[5] should be the data map
data_map = values[5]
assert isinstance(data_map, dict)
assert data_map["id"] == "123"
assert data_map["name"] == "Test Item"

View file

@ -310,22 +310,7 @@ Bob,bob@email.com,"age with quote,42'''
def test_websocket_connection_failure(self):
"""Test WebSocket connection failure handling"""
input_file = self.create_temp_file("name,email\nJohn,john@email.com", '.csv')
descriptor_file = self.create_temp_file(json.dumps(self.valid_descriptor), '.json')
try:
# Test with invalid URL
with pytest.raises(Exception):
load_structured_data(
api_url="http://invalid-host:9999",
input_file=input_file,
descriptor_file=descriptor_file,
batch_size=1,
flow='obj-ex'
)
finally:
self.cleanup_temp_file(input_file)
self.cleanup_temp_file(descriptor_file)
skip_internal_tests()
# Edge Case Data Tests
def test_extremely_long_lines(self):

View file

@ -64,8 +64,8 @@ class TestRowEmbeddingsProcessor(IsolatedAsyncioTestCase):
index_names = processor.get_index_names(schema)
# Should include primary key and indexed field
assert 'id' in index_names
# Should include only indexed fields, not primary key
assert 'id' not in index_names
assert 'name' in index_names
assert 'email' not in index_names
@ -356,12 +356,10 @@ class TestRowEmbeddingsProcessor(IsolatedAsyncioTestCase):
# Mock the flow
mock_embeddings_request = AsyncMock()
# Return batch of vector sets (one per text)
# 4 unique texts: CUST001, John Doe, CUST002, Jane Smith
# 2 unique texts: John Doe, Jane Smith (only 'name' is indexed)
mock_embeddings_request.embed.return_value = [
[[0.1, 0.2, 0.3]], # vectors for text 1
[[0.2, 0.3, 0.4]], # vectors for text 2
[[0.3, 0.4, 0.5]], # vectors for text 3
[[0.4, 0.5, 0.6]], # vectors for text 4
]
mock_output = AsyncMock()
@ -383,7 +381,7 @@ class TestRowEmbeddingsProcessor(IsolatedAsyncioTestCase):
# Verify it was called with a list of texts
call_args = mock_embeddings_request.embed.call_args
assert 'texts' in call_args.kwargs
assert len(call_args.kwargs['texts']) == 4
assert len(call_args.kwargs['texts']) == 2
# Should have sent output
mock_output.send.assert_called()

View file

@ -195,6 +195,7 @@ class TestRowsCassandraStorageLogic:
processor.session = MagicMock()
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
processor.ensure_tables = MagicMock()
processor.register_partitions = MagicMock()
@ -231,13 +232,14 @@ class TestRowsCassandraStorageLogic:
# Verify using unified rows table
assert "INSERT INTO default.rows" in insert_cql
# Values should be: (collection, schema_name, index_name, index_value, data, source)
# Values should be: (collection, schema_name, index_name, index_value, row_id, data, source)
assert values[0] == "test_collection" # collection
assert values[1] == "test_schema" # schema_name
assert values[2] == "id" # index_name (primary key field)
assert values[3] == ["123"] # index_value as list
assert values[4] == {"id": "123", "value": "test_data"} # data map
assert values[5] == "" # source
assert values[4] == "123" # row_id (primary key value)
assert values[5] == {"id": "123", "value": "test_data"} # data map
assert values[6] == "" # source
@pytest.mark.asyncio
@patch('trustgraph.storage.rows.cassandra.write.async_execute', new_callable=AsyncMock)
@ -261,6 +263,7 @@ class TestRowsCassandraStorageLogic:
processor.session = MagicMock()
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
processor.ensure_tables = MagicMock()
processor.register_partitions = MagicMock()
@ -321,6 +324,7 @@ class TestRowsCassandraStorageBatchLogic:
processor.session = MagicMock()
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
processor.ensure_tables = MagicMock()
processor.register_partitions = MagicMock()
@ -378,6 +382,7 @@ class TestRowsCassandraStorageBatchLogic:
processor.session = MagicMock()
processor.sanitize_name = Processor.sanitize_name.__get__(processor, Processor)
processor.get_index_names = Processor.get_index_names.__get__(processor, Processor)
processor.get_row_id = Processor.get_row_id.__get__(processor, Processor)
processor.build_index_value = Processor.build_index_value.__get__(processor, Processor)
processor.ensure_tables = MagicMock()
processor.register_partitions = MagicMock()
@ -432,7 +437,7 @@ class TestUnifiedTableStructure:
assert "index_value frozen<list<text>>" in rows_cql
assert "data map<text, text>" in rows_cql
assert "source text" in rows_cql
assert "PRIMARY KEY ((collection, schema_name, index_name), index_value)" in rows_cql
assert "PRIMARY KEY ((collection, schema_name, index_name), index_value, row_id)" in rows_cql
# Check row_partitions table creation
partitions_cql = processor.session.execute.call_args_list[1][0][0]

View file

@ -117,13 +117,30 @@ class AsyncBulkClient:
async for raw_message in websocket:
yield json.loads(raw_message)
async def import_rows(self, flow: str, rows: AsyncIterator[Dict[str, Any]], **kwargs: Any) -> None:
async def import_rows(
self, flow: str, rows: AsyncIterator[Dict[str, Any]],
batch_size: int = 40,
**kwargs: Any,
) -> None:
"""Bulk import rows via WebSocket"""
ws_url = self._build_ws_url(f"/api/v1/flow/{flow}/import/rows")
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=self.timeout) as websocket:
batch = []
template = None
async for row in rows:
await websocket.send(json.dumps(row))
if template is None:
template = row
batch.append(row.get("values", row))
if len(batch) >= batch_size:
message = dict(template)
message["values"] = batch
await websocket.send(json.dumps(message))
batch = []
if batch:
message = dict(template)
message["values"] = batch
await websocket.send(json.dumps(message))
async def aclose(self) -> None:
"""Close connections"""

View file

@ -538,7 +538,11 @@ class BulkClient:
async for raw_message in websocket:
yield json.loads(raw_message)
def import_rows(self, flow: str, rows: Iterator[Dict[str, Any]], **kwargs: Any) -> None:
def import_rows(
self, flow: str, rows: Iterator[Dict[str, Any]],
batch_size: int = 40,
**kwargs: Any,
) -> None:
"""
Bulk import structured rows into a flow.
@ -548,6 +552,7 @@ class BulkClient:
Args:
flow: Flow identifier
rows: Iterator yielding row dictionaries
batch_size: Number of rows per batch (default 40)
**kwargs: Additional parameters (reserved for future use)
Example:
@ -566,15 +571,31 @@ class BulkClient:
)
```
"""
self._run_async(self._import_rows_async(flow, rows))
self._run_async(self._import_rows_async(flow, rows, batch_size))
async def _import_rows_async(self, flow: str, rows: Iterator[Dict[str, Any]]) -> None:
async def _import_rows_async(
self, flow: str, rows: Iterator[Dict[str, Any]],
batch_size: int,
) -> None:
"""Async implementation of rows import"""
ws_url = self._build_ws_url(f"/api/v1/flow/{flow}/import/rows")
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=self.timeout) as websocket:
batch = []
template = None
for row in rows:
await websocket.send(json.dumps(row))
if template is None:
template = row
batch.append(row.get("values", row))
if len(batch) >= batch_size:
message = dict(template)
message["values"] = batch
await websocket.send(json.dumps(message))
batch = []
if batch:
message = dict(template)
message["values"] = batch
await websocket.send(json.dumps(message))
def close(self) -> None:
"""Close connections"""

View file

@ -45,6 +45,7 @@ def load_structured_data(
verbose: bool = False,
token: str = None,
workspace: str = "default",
batch_size: int = None,
):
"""
Load structured data using a descriptor configuration.
@ -133,11 +134,11 @@ def load_structured_data(
# Use shared pipeline for full processing (no sample limit)
output_objects, descriptor = _process_data_pipeline(input_file, temp_descriptor.name, collection)
# Get batch size from descriptor
batch_size = descriptor.get('output', {}).get('options', {}).get('batch_size', 1000)
# CLI batch_size overrides descriptor value
effective_batch_size = batch_size if batch_size is not None else descriptor.get('output', {}).get('options', {}).get('batch_size', 40)
# Send to TrustGraph using shared function
imported_count = _send_to_trustgraph(output_objects, api_url, flow, batch_size, token=token, workspace=workspace)
imported_count = _send_to_trustgraph(output_objects, api_url, flow, effective_batch_size, token=token, workspace=workspace)
# Summary
format_info = descriptor.get('format', {})
@ -288,12 +289,12 @@ def load_structured_data(
# Use shared pipeline (no sample_size limit for full load)
output_records, descriptor = _process_data_pipeline(input_file, descriptor_file, collection)
# Get batch size from descriptor or use default
batch_size = descriptor.get('output', {}).get('options', {}).get('batch_size', 1000)
# CLI batch_size overrides descriptor value
effective_batch_size = batch_size if batch_size is not None else descriptor.get('output', {}).get('options', {}).get('batch_size', 40)
# Send to TrustGraph
print(f"🚀 Importing {len(output_records)} records to TrustGraph...")
imported_count = _send_to_trustgraph(output_records, api_url, flow, batch_size, token=token, workspace=workspace)
imported_count = _send_to_trustgraph(output_records, api_url, flow, effective_batch_size, token=token, workspace=workspace)
# Get summary info from descriptor
format_info = descriptor.get('format', {})
@ -572,7 +573,7 @@ def _process_data_pipeline(input_file, descriptor_file, collection, sample_size=
return output_records, descriptor
def _send_to_trustgraph(rows, api_url, flow, batch_size=1000, token=None, workspace="default"):
def _send_to_trustgraph(rows, api_url, flow, batch_size=40, token=None, workspace="default"):
"""Send ExtractedObject records to TrustGraph using Python API"""
from trustgraph.api import Api
@ -584,7 +585,7 @@ def _send_to_trustgraph(rows, api_url, flow, batch_size=1000, token=None, worksp
api = Api(api_url, token=token, workspace=workspace)
bulk = api.bulk()
bulk.import_rows(flow=flow, rows=iter(rows))
bulk.import_rows(flow=flow, rows=iter(rows), batch_size=batch_size)
logger.info(f"Successfully imported {total_records} records to TrustGraph")
@ -968,8 +969,8 @@ For more information on the descriptor format, see:
parser.add_argument(
'--batch-size',
type=int,
default=1000,
help='Number of records to process in each batch (default: 1000)'
default=None,
help='Number of records per import batch (default: 40, or descriptor value)'
)
parser.add_argument(
@ -1050,6 +1051,7 @@ For more information on the descriptor format, see:
verbose=args.verbose,
token=args.token,
workspace=args.workspace,
batch_size=args.batch_size,
)
except FileNotFoundError as e:
print(f"Error: File not found - {e}", file=sys.stderr)

View file

@ -122,10 +122,16 @@ class Processor(CollectionConfigHandler, FlowProcessor):
fields=fields
)
vector_indexes = set(schema_def.get("vector-indexes", []))
for field in fields:
if field.name in vector_indexes:
field.indexed = True
ws_schemas[schema_name] = row_schema
logger.info(
f"Loaded schema: {schema_name} with "
f"{len(fields)} fields for {workspace}"
f"{len(fields)} fields, {len(vector_indexes)} vector-indexed "
f"for {workspace}"
)
except Exception as e:
@ -140,7 +146,7 @@ class Processor(CollectionConfigHandler, FlowProcessor):
"""Get all index names for a schema."""
index_names = []
for field in schema.fields:
if field.primary or field.indexed:
if field.indexed:
index_names.append(field.name)
return index_names

View file

@ -190,11 +190,17 @@ class Processor(FlowProcessor):
fields=fields
)
query_indexes = set(schema_def.get("query-indexes", []))
for field in fields:
if field.name in query_indexes:
field.indexed = True
ws_schemas[schema_name] = row_schema
builder.add_schema(schema_name, row_schema)
logger.info(
f"Loaded schema: {schema_name} with "
f"{len(fields)} fields for {workspace}"
f"{len(fields)} fields, {len(query_indexes)} query-indexed "
f"for {workspace}"
)
except Exception as e:

View file

@ -7,10 +7,13 @@ Uses a single 'rows' table with the schema:
- schema_name: text
- index_name: text
- index_value: frozen<list<text>>
- row_id: text
- data: map<text, text>
- source: text
Each row is written multiple times - once per indexed field defined in the schema.
Each row is written multiple times - once per query-indexed field defined
in the schema. The row_id (primary key value) is included as a clustering
column to allow multiple rows with the same index value.
"""
import asyncio
@ -183,10 +186,16 @@ class Processor(CollectionConfigHandler, FlowProcessor):
fields=fields
)
query_indexes = set(schema_def.get("query-indexes", []))
for field in fields:
if field.name in query_indexes:
field.indexed = True
ws_schemas[schema_name] = row_schema
logger.info(
f"Loaded schema: {schema_name} with "
f"{len(fields)} fields for {workspace}"
f"{len(fields)} fields, {len(query_indexes)} query-indexed "
f"for {workspace}"
)
except Exception as e:
@ -263,9 +272,10 @@ class Processor(CollectionConfigHandler, FlowProcessor):
schema_name text,
index_name text,
index_value frozen<list<text>>,
row_id text,
data map<text, text>,
source text,
PRIMARY KEY ((collection, schema_name, index_name), index_value)
PRIMARY KEY ((collection, schema_name, index_name), index_value, row_id)
)
"""
@ -351,6 +361,14 @@ class Processor(CollectionConfigHandler, FlowProcessor):
self.registered_partitions.add(cache_key)
logger.info(f"Registered partitions for {collection}/{schema_name}: {index_names}")
def get_row_id(self, schema: RowSchema, value_map: Dict[str, str]) -> str:
"""Get the primary key value for a row."""
for field in schema.fields:
if field.primary:
value = value_map.get(field.name)
return str(value) if value is not None else ""
return ""
def build_index_value(self, value_map: Dict[str, str], index_name: str) -> List[str]:
"""
Build the index_value list for a given index.
@ -420,8 +438,8 @@ class Processor(CollectionConfigHandler, FlowProcessor):
# Prepare insert statement
insert_cql = f"""
INSERT INTO {safe_keyspace}.rows
(collection, schema_name, index_name, index_value, data, source)
VALUES (%s, %s, %s, %s, %s, %s)
(collection, schema_name, index_name, index_value, row_id, data, source)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
# Process each row in the batch
@ -434,6 +452,8 @@ class Processor(CollectionConfigHandler, FlowProcessor):
if raw_value is not None:
data_map[field.name] = str(raw_value)
row_id = self.get_row_id(schema, value_map)
# Write one copy per index
for index_name in index_names:
index_value = self.build_index_value(value_map, index_name)
@ -450,7 +470,7 @@ class Processor(CollectionConfigHandler, FlowProcessor):
await async_execute(
self.session,
insert_cql,
(collection, schema_name, index_name, index_value, data_map, source),
(collection, schema_name, index_name, index_value, row_id, data_map, source),
)
rows_written += 1
except Exception as e: