Files
lancedb/docs/web/curation/chunking.mdx
T
Jack YeandClaude Sonnet 5 5e38120bdc docs: add storage integration SDK examples, abstract RTE write, simplify cache pages (#4865)
## Summary

- **Define Storage Integration**: un-gates the Python (and Rust, for S3)
`create_storage_integration` examples, drafted against #4618 and held
back until it shipped -- it's now in v0.42.0-beta.0+, and the signature
matches the drafts exactly.
- **Naming rules**: drops the `Branch` row and its "Learn more" link,
consistent with de-emphasizing branches elsewhere in this nav.
- **Distributed RTE Write**: removes the REST path, the
`__lance_mem_wal` index name, and "WAL node" terminology in favor of an
abstract framing -- a query node routes writes to RTE nodes, each of
which can host shards from many different tables. Both diagram variants
are updated to match.
- **Data Page Cache / Tiered Index Cache / Cache Prewarm**: drop
Helm-specific and exact-percentage detail (disk-budget math,
`startup_prewarm_block_query_ready`/`healthPort`/failure-policy table)
in favor of a short feature → architecture → why-it-matters shape, each
closing with a contact-sales pointer. Data Page Cache's on-demand
prewarm section is removed outright.

## Note

I wasn't able to get a clean visual render of the updated Distributed
RTE Write diagram in local preview -- Mermaid isn't rendering in
headless Chrome in this environment for *any* page right now, confirmed
against an unmodified reference page showing the identical symptom. The
diagram source follows the exact pattern already shipped and working in
the 21 diagrams from the earlier brand-theming pass, and `mint
validate`/`mint broken-links` both pass clean.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-05 01:40:54 -08:00

222 lines
7.4 KiB
Plaintext

---
title: "Chunk records into multiple rows"
sidebarTitle: "Chunking"
description: "Expand one source row into many derived rows -- documents into passages, videos into clips, images into tiles."
icon: diagram-subtask
keywords: ["chunking", "function", "udf", "lateral", "unnest", "1:N", "passages", "clips", "video", "frames"]
availability:
embedded: unavailable
enterprise: available
---
Every other curation task on this page keeps the row count the same or
smaller: profiling, sampling, joining, deduplicating. Chunking goes the other
way -- one source row becomes many derived rows, each carrying its parent's
columns along with it. Use it to split a document into passages for
embedding, a video into clips, or an image into tiles.
## Define a chunking Function {#function}
A Function that returns a `list` produces more than one value per input row.
A chunker is a Function whose output is a list -- of structs, of strings, of
anything -- registered like any other Function. See
[Create a Function](/curation/create-function) for CPU, GPU and remote-service
Functions, and scalar versus batch calling, if you have not registered one
before.
A function that splits text into fixed-size passages turns a `documents`
table into one row per passage. Registration details, environment and
dependency declarations, and how a version stays immutable once rows are
filled from it, are the same for a chunker as for any other Function --
register it from Python, or the same function from SQL with
[`CREATE FUNCTION`](/sql/ddl/function#create):
**Source: `documents`**
| doc_id | title | text |
| --- | --- | --- |
| 1 | "Intro to AI" | "Machine learning is a method of..." (long text) |
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
from lancedb.functions import function
@function(
output_schema=pa.list_(
pa.struct([
pa.field("chunk_index", pa.int64(), nullable=False),
pa.field("chunk_text", pa.utf8(), nullable=False),
])
),
)
def split_into_passages(text: str) -> list[dict]:
size = 200
return [
{"chunk_index": i, "chunk_text": text[i : i + size]}
for i in range(0, len(text), size)
]
version = db.create_function_async(split_into_passages).wait()
```
```sql SQL icon="database"
CREATE FUNCTION split_into_passages(text utf8)
RETURNS list<struct<chunk_index: int64, chunk_text: utf8>>
LANGUAGE python
AS '
def split_into_passages(text):
size = 200
return [
{"chunk_index": i, "chunk_text": text[i:i+size]}
for i in range(0, len(text), size)
]
'
```
</CodeGroup>
## Explode it in a query {#explode}
Wrap the function call in `UNNEST` in the `SELECT` list, with an alias: it
yields one row per element of the list the function returns, with every
other selected column repeated. Reference the exploded struct's fields
through that alias in a query over this one:
<CodeGroup>
```sql SQL icon="database"
WITH exploded AS (
SELECT doc_id, title, UNNEST(split_into_passages(text)) AS p
FROM documents
)
SELECT doc_id, title, p.chunk_index, p.chunk_text
FROM exploded
```
</CodeGroup>
A document with five 200-character passages becomes five output rows, each
carrying `doc_id` and `title` from its parent. This is the fastest way to
check a chunker before committing to a materialized view: run it over a few
rows and inspect the output shape.
## Video into frames {#video}
Chunking a blob column is the same shape, with the chunker producing
timestamps rather than text spans, and a second Function pulling the actual
frame for each one. `videos` holds raw clips in a
[Blob v2](/management/table#managed-blobs) column, with each row's duration
already known:
**Source: `videos`**
| video_id | video | duration_seconds |
| --- | --- | --- |
| "clip-1" | (Blob v2 bytes) | 12.0 |
A chunker that samples one timestamp per second needs no blob access at all
-- it only looks at `duration_seconds`:
<CodeGroup>
```python Python icon="python"
@function(
output_schema=pa.list_(
pa.struct([
pa.field("frame_index", pa.int64(), nullable=False),
pa.field("ts", pa.float64(), nullable=False),
])
),
)
def sample_frame_timestamps(duration_seconds: float) -> list[dict]:
every = 1.0 # one frame per second
count = int(duration_seconds // every)
return [{"frame_index": i, "ts": i * every} for i in range(count)]
version = db.create_function_async(sample_frame_timestamps).wait()
```
</CodeGroup>
A second Function reads the blob -- incrementally, through a `BlobReader`,
rather than loading the whole clip -- and returns one JPEG per call:
<CodeGroup>
```python Python icon="python"
from datetime import timedelta
from lancedb import BlobReader
@function(
name="extract_frame",
gpu=True,
pip=["av==13.1.0", "pillow==11.0.0"],
input_schema=pa.schema([
lancedb.blob("video"),
pa.field("ts", pa.float64(), nullable=False),
]),
output_schema=lancedb.blob("jpeg", nullable=False),
)
def extract_frame(video: BlobReader, ts: float) -> bytes:
... # decode the frame at ts, encode as JPEG, return its bytes
extract_version = db.create_function_async(extract_frame).wait(timeout=timedelta(minutes=15))
```
</CodeGroup>
Compose both in one query: explode `videos` into one row per sampled
timestamp, then call `extract_frame` on each row to produce its image:
<CodeGroup>
```sql SQL icon="database"
WITH sampled AS (
SELECT video_id, video, UNNEST(sample_frame_timestamps(duration_seconds)) AS f
FROM videos
)
SELECT video_id, f.frame_index, f.ts, extract_frame(video, f.ts) AS jpeg
FROM sampled
```
</CodeGroup>
A 12-second clip becomes 12 output rows, each with its own extracted JPEG. A
Function in `FROM` position yields one row per list element the same way
regardless of what the list holds; whether the second step produces a blob,
a plain value, or another struct does not change the explosion.
## Persist it as a materialized view {#persist}
Wrap either query in [`CREATE MATERIALIZED VIEW`](/sql/ddl/materialized-view)
to keep a real table that stays current as the source changes, instead of
recomputing the explosion on every query:
<CodeGroup>
```sql SQL icon="database"
CREATE MATERIALIZED VIEW chunks AS
WITH exploded AS (
SELECT doc_id, title, UNNEST(split_into_passages(text)) AS p
FROM documents
)
SELECT doc_id, title, p.chunk_index, p.chunk_text
FROM exploded
```
</CodeGroup>
**Derived: `chunks`** (1:N expansion)
| doc_id | title | chunk_index | chunk_text |
| --- | --- | --- | --- |
| 1 | "Intro to AI" | 0 | "Machine learning is a method..." |
| 1 | "Intro to AI" | 1 | "...of teaching computers..." |
The view refreshes like any other materialized view: new source rows are
chunked and inserted, deleted source rows drop their children, and an
updated row's old children are replaced by freshly chunked ones on the next
[refresh](/feature-engineering/refresh-column). A single `UNNEST(...)` --
whether unnesting a Function's list output directly, as above, or an
existing list column -- is the only lateral expansion a materialized view
can carry, and it can't be combined with `GROUP BY`, so keep aggregation or
further curation in a second step over the chunked table.
## Chunk, then curate {#next}
A freshly chunked table is a normal table: [dedupe](/curation/dedupe) it to
collapse near-identical chunks, [sample](/curation/statistical-sampling) it for a quick
look, or [profile it](/curation/aggregate) to check how many chunks each
source row produced. Continue with
[choosing a curation task](/curation#questions).