mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
## Summary Adds an optional `max_concurrency` setting to Function registration and explicit refresh: - A registered Function version can define the default maximum number of user-code calls in flight for one logical execution. - A refresh can override that default for that execution only. - The value must be a positive `u32` and is independent from `resource_budget`; it is not an account-wide quota. - Omitting the option preserves the previous request payload. Local embedded refreshes reject this Enterprise-only option. Rust, Python sync/async, PyO3, and the existing Node refresh surface forward the option. Node has no Function registration surface today, so no registration API is added there. The generated TypeDoc pages are included for the new public option. ## Validation The native-tested source covers Function version decoding, registration validation and serialization, the Flight SQL registration ticket and returned version, remote refresh forwarding, local rejection, and Node's numeric boundary before conversion. Exact stage results are Rust `1+3` plus four remote cases at `1` each; Python `14/1/49/24`; Node `57` remote and `1` local rejection. Ruff, standard repository Pyright, and Biome passed. The tested Python extension was `21aa256a970f4a16ea8253d68401d0fc573d946412e8752155dfcad63f30654d`; the tested Node addon and generated native declaration were `f6089f861981f96f8ce0f6dbef43de0907b9eeb4b63e84cdd5da14e00d6cbd30` and `4c4ceb205824075d706897c726c215dcf839880c757f0d0582d24cec5f938416`. The final 24-file diff preserves the 22 tested product/test/fixture/source-doc blobs and adds only two generated TypeDoc pages.
390 lines
15 KiB
Plaintext
390 lines
15 KiB
Plaintext
---
|
|
title: "Create a Function"
|
|
tag: "Early access"
|
|
sidebarTitle: "Create Function"
|
|
description: "Register Python as a column: CPU, GPU, and remote-service Functions, called once per row or once per batch."
|
|
icon: code
|
|
keywords: ["function", "udf", "cpu", "gpu", "batch", "scalar", "blob", "remote api", "embedding", "rate limiter"]
|
|
availability:
|
|
embedded: unavailable
|
|
enterprise: available
|
|
---
|
|
|
|
<Badge color="purple">Early access</Badge>
|
|
|
|
Some curation steps cannot be written as a SQL expression: an embedding
|
|
model, an image decode, a call to an external service. A Function is a
|
|
Python callable registered with the database, versioned, and then applied as
|
|
a column or called in a query -- the code runs where the data is, not pulled
|
|
into your process first. This page covers how to shape one; later curation
|
|
tasks such as [Chunking](/curation/chunking) assume you have read it. The
|
|
full registration, listing, refresh and drop lifecycle is covered on the
|
|
[Function management reference](/management/function); the examples below
|
|
focus on shaping a Function for common curation tasks.
|
|
|
|
## Scalar: once per row {#scalar}
|
|
|
|
The plain form takes one row's values and returns one result. Types are
|
|
inferred from the annotations:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
from lancedb import function
|
|
|
|
@function(pip=["numpy==2.2.0"])
|
|
def score(value: float) -> float:
|
|
return value * 2
|
|
|
|
version = db.create_function_async(score).wait()
|
|
table.add_columns({"doubled": version(value=col("value"))})
|
|
```
|
|
</CodeGroup>
|
|
|
|
`pip` declares the worker's dependencies; they are part of the function's
|
|
immutable version, so changing one produces a new version rather than
|
|
altering rows a previous version already filled.
|
|
|
|
## Batch: once per call, many rows at a time {#batch}
|
|
|
|
A function is called once per batch instead of once per row when its
|
|
parameters are annotated `pyarrow.Array`, or when it takes a single
|
|
`pyarrow.RecordBatch` parameter to read more than one column together. The
|
|
same doubling logic, vectorized over a whole batch at once:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
import pyarrow as pa
|
|
import pyarrow.compute as pc
|
|
from lancedb import function
|
|
|
|
@function(
|
|
pip=["numpy==2.2.0"],
|
|
batch_size=32,
|
|
max_concurrency=4,
|
|
row_timeout_seconds=90,
|
|
)
|
|
def score_batch(value: pa.Array) -> pa.Array:
|
|
return pc.multiply(value, 2)
|
|
```
|
|
</CodeGroup>
|
|
|
|
Batching amortizes per-call overhead across many rows, which matters most
|
|
once a function also does something with real fixed cost per call -- loading
|
|
a model, opening a connection, making a network round trip.
|
|
|
|
`batch_size` caps the number of input rows in each execution batch; actual
|
|
batches may be smaller. `max_concurrency` limits user-code calls in flight for
|
|
one Function within one logical execution; it does not limit calls across
|
|
different jobs and is not an account-wide quota. `row_timeout_seconds` bounds
|
|
each Python call: one row for scalar Functions or the entire batch for
|
|
vectorized Functions. It does not include the instance's `__init__`. These
|
|
settings are fixed into the registered version and apply to direct calls,
|
|
computed columns, and materialized-view refreshes alike. A refresh can
|
|
override `max_concurrency` for that execution. Omit it to preserve the
|
|
existing behavior.
|
|
|
|
## Stateful Functions: load once, call many times {#stateful}
|
|
|
|
A `@function`-decorated **class** is the same callable with state. Each remote
|
|
instance calls `__init__` once, calls `__call__` for every row or batch it
|
|
processes, and calls `close()`, if defined, once when it retires:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
from lancedb import function
|
|
|
|
@function
|
|
class scale:
|
|
def __init__(self, factor: float, offset: float = 0.0):
|
|
self.factor = factor
|
|
self.offset = offset
|
|
|
|
def __call__(self, value: float) -> float:
|
|
return value * self.factor + self.offset
|
|
```
|
|
</CodeGroup>
|
|
|
|
Constructor parameters are **initialization fields**, bound once per binding
|
|
rather than read per row -- mixed with per-row columns in the same call:
|
|
`version(value=col("value"), factor=2.0)`. Load models, open clients, and
|
|
build rate limiters in `__init__`; every batch the instance serves then
|
|
reuses them, since an instance's state is shared across its own batches but
|
|
never across other instances or workers. This is the pattern GPU inference
|
|
and remote-API Functions both build on below.
|
|
|
|
## GPU: batch inference, loaded once {#gpu}
|
|
|
|
`db.list_gpu_classes()` lists the GPU classes configured for the deployment.
|
|
Each record reports the operator's hardware description and limits; it does
|
|
not mean a GPU is currently free. Listing requires the System Usage
|
|
permission. Pass a returned class name to `gpu_class` to fix that placement
|
|
in the Function version. `gpu=True` remains required:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
import pyarrow as pa
|
|
from lancedb import function
|
|
|
|
gpu_class = next(
|
|
gpu.name for gpu in db.list_gpu_classes() if gpu.gpu_model == "NVIDIA A10G"
|
|
)
|
|
|
|
@function(
|
|
gpu=True,
|
|
gpu_class=gpu_class,
|
|
pip=["torch==2.12.0", "sentence-transformers==5.7.0"],
|
|
)
|
|
class embed_text:
|
|
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
|
|
import torch
|
|
from sentence_transformers import SentenceTransformer
|
|
|
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
self.model = SentenceTransformer(model_name, device=device)
|
|
|
|
def __call__(self, text: pa.Array) -> pa.Array:
|
|
texts = [t or "" for t in text.to_pylist()]
|
|
embeddings = self.model.encode(texts)
|
|
return pa.array(embeddings.tolist(), type=pa.list_(pa.float32(), list_size=384))
|
|
|
|
version = db.create_function_async(embed_text).wait()
|
|
table.add_columns({"embedding": version(text=col("caption"))})
|
|
```
|
|
</CodeGroup>
|
|
|
|
Older servers that do not implement `GET /v1/gpu/classes` raise
|
|
`NotImplementedError`. Without `gpu_class`, the deployment's default GPU
|
|
class is used. The class is immutable with the Function version; set a
|
|
per-refresh class or aggregate GPU budget through
|
|
[`refresh_column_async`](/feature-engineering/refresh-column#resources).
|
|
|
|
The model loads once per worker instance, in `__init__`; `__call__` only
|
|
encodes the batch it was handed. Registering the same logic as a plain
|
|
function instead would reload the model on every single row.
|
|
|
|
## Calling a remote service {#remote}
|
|
|
|
A function that calls an external API needs a credential without putting it
|
|
in the function's source. A Secret is a database-scoped named credential:
|
|
`db.create_secret` stores the value once, and binding it at registration time
|
|
puts it in the environment variable the function reads, without ever
|
|
returning it from any API or recording it in the function's own definition.
|
|
Combine it with the stateful form so the HTTP client -- and any rate limiter
|
|
around it -- is built once, not reopened on every row. There is no built-in
|
|
retry or per-row error-skip policy -- a function that calls a flaky service
|
|
should handle timeouts and retries itself, inside `__call__`.
|
|
|
|
See [Vend a Secret to a Function](/curation/vend-secret#specify) for the
|
|
full worked example, how the bound value actually reaches a running
|
|
evaluation, and how that differs from rotating or revoking it later.
|
|
|
|
## Read and produce blobs {#blobs}
|
|
|
|
A Function can bind a [Blob v2 column](/management/table#managed-blobs) and
|
|
return another blob, including a blob nested inside a struct field, not only
|
|
at the top level. In row mode, a top-level blob parameter arrives as Python
|
|
`bytes`, or `None` for a nullable input, after the worker has read the
|
|
payload. Return bytes and let the destination table store the Managed
|
|
payload and descriptor.
|
|
|
|
This section extracts a frame from a stored video and captions it,
|
|
continuing as one example across chaining, persisting and incremental reads
|
|
below. `videos` holds raw clips in a `video` Blob v2 column; `frames` names
|
|
one timestamp per clip to extract, with a `selected` flag to skip some:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
import pyarrow as pa
|
|
|
|
videos = db.create_table("videos", schema=pa.schema([
|
|
pa.field("id", pa.utf8(), nullable=False),
|
|
lancedb.blob("video", nullable=False),
|
|
]))
|
|
frames = db.create_table("frames", [
|
|
{"frame_id": 1, "video_id": "clip-1", "ts": 0.5, "selected": True},
|
|
{"frame_id": 2, "video_id": "clip-1", "ts": 1.5, "selected": True},
|
|
])
|
|
```
|
|
</CodeGroup>
|
|
|
|
### Produce a blob {#produce-blobs}
|
|
|
|
Declare blob inputs and outputs explicitly. `extract_frame` reads one frame
|
|
out of a video at a timestamp and returns it as a JPEG, without loading the
|
|
whole clip first -- see [Read a blob incrementally](#incremental) below for
|
|
how the `video: BlobReader` parameter does that:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
from datetime import timedelta
|
|
|
|
from lancedb import BlobReader, col
|
|
from lancedb.functions import function
|
|
|
|
@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_frame_version = db.create_function_async(extract_frame).wait(
|
|
timeout=timedelta(minutes=15)
|
|
)
|
|
```
|
|
</CodeGroup>
|
|
|
|
An empty byte string is a filled result. A scalar output cannot be nullable,
|
|
because a physical NULL marks an unfilled computed row. To return a nullable
|
|
blob as a value, use a named-struct output schema with a nullable blob field;
|
|
the struct itself records whether the result has been filled. Read a nested
|
|
blob with `table.fetch_blobs("payload.image", hits)`.
|
|
|
|
A list or large-list output may also contain Blob v2 elements; inputs and
|
|
fixed-size lists do not support this layout. List-nested values are queryable
|
|
as descriptors, but `fetch_blobs` and `blob_mode="bytes"` cannot fetch their
|
|
payloads.
|
|
|
|
<Warning>
|
|
`output_schema=lancedb.blob(...)` is what makes a persisted result Blob v2
|
|
rather than plain Arrow `binary`. Returning `bytes` from a function whose
|
|
output type is inferred from a bare annotation, with no explicit
|
|
`lancedb.blob(...)` output schema, persists as ordinary `binary`: the column
|
|
still holds the right bytes, but it skips the Managed storage path, descriptor
|
|
reads, and `fetch_blobs`. Declare it explicitly on every function whose output
|
|
should be a blob, not just the first one in a chain.
|
|
</Warning>
|
|
|
|
### Chain a Function on its output {#chain}
|
|
|
|
A second Function can bind a blob another Function produced. Register
|
|
`caption` the same way, consuming a blob instead of producing one:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
@function(gpu=True, pip=["transformers==4.47.0"])
|
|
def caption(jpeg: bytes) -> str:
|
|
... # run a vision-language model over the JPEG, return one sentence
|
|
|
|
caption_version = db.create_function_async(caption).wait(timeout=timedelta(minutes=15))
|
|
```
|
|
</CodeGroup>
|
|
|
|
Compose both in one SQL statement over a join, instead of applying each as
|
|
its own column. The join supplies `video` and `ts` from two different
|
|
tables, and `caption` reads `extract_frame`'s result as it is computed,
|
|
before anything is written:
|
|
|
|
<CodeGroup>
|
|
```sql SQL
|
|
SELECT f.frame_id, extract_frame(v.video, f.ts) AS jpeg,
|
|
caption(extract_frame(v.video, f.ts)) AS caption
|
|
FROM videos v JOIN frames f ON v.id = f.video_id
|
|
WHERE f.selected = true
|
|
```
|
|
</CodeGroup>
|
|
|
|
[`EXPLAIN`](/sql/dql/explain) on a query like this shows `RemoteTake`: the
|
|
blob is read once, where the worker runs, and never pulled into the query
|
|
node first.
|
|
|
|
### Persist it as a column, or a materialized view {#persist}
|
|
|
|
Apply the same registered version with `add_columns` to persist its output as
|
|
an ordinary table column, on a table that already has both of its inputs --
|
|
here, a single-clip table with its own `video` and `ts`, rather than the two
|
|
tables the join above reads from:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
clips = db.create_table("clips", schema=pa.schema([
|
|
pa.field("frame_id", pa.int64(), nullable=False),
|
|
lancedb.blob("video", nullable=False),
|
|
pa.field("ts", pa.float64(), nullable=False),
|
|
]))
|
|
|
|
clips.add_columns({"jpeg": extract_frame_version(video=col("video"), ts=col("ts"))})
|
|
result = clips.refresh_column_async("jpeg").wait(timeout=timedelta(minutes=15))
|
|
assert result.rows_failed == 0
|
|
|
|
hits = clips.search().select(["frame_id", "jpeg"]).with_row_id(True).to_arrow()
|
|
values = clips.fetch_blobs("jpeg", hits).to_pylist()
|
|
```
|
|
</CodeGroup>
|
|
|
|
Or wrap the join and both Functions in a
|
|
[materialized view](/sql/ddl/materialized-view) instead, so the extracted
|
|
frame and its caption stay current as new videos and frames arrive. See
|
|
[Auto Refresh](/enterprise/auto-refresh) for
|
|
keeping either one fresh without calling refresh yourself.
|
|
|
|
On deployments that support computed-column inputs, another Function can
|
|
bind to `col("jpeg")` directly, the same as the SQL join above does through
|
|
its own expression. Refresh the producer first, then the consumer. Once
|
|
outputs are current, a later refresh fills appended rows. A filtered
|
|
refresh preserves payload references for unselected Managed outputs. An
|
|
unfiltered refresh can recompute a whole fragment when it cannot establish
|
|
that the fragment's outputs are current.
|
|
|
|
Function output blobs follow the same compaction and cleanup lifecycle as
|
|
ingested Managed blobs. Retained versions continue to own their payloads;
|
|
cleanup reclaims an object only after its retained references are gone.
|
|
|
|
### Read a blob incrementally {#incremental}
|
|
|
|
Annotate a row Function parameter with `BlobReader` to read a Blob v2 input
|
|
incrementally, the way `extract_frame` above does, instead of materializing
|
|
the whole payload first. The signature selects the access mode without
|
|
another registration option. A minimal example, reading only the first MP4
|
|
box type without loading the rest of the video:
|
|
|
|
<CodeGroup>
|
|
```python Python icon="python"
|
|
from lancedb import BlobReader, function
|
|
|
|
|
|
@function
|
|
def first_box_type(video: BlobReader) -> bytes:
|
|
video.seek(4)
|
|
return video.read(4)
|
|
```
|
|
</CodeGroup>
|
|
|
|
The worker passes a seekable reader backed by the stored blob --
|
|
`video.seekable()` is `True`. `read(size)` reads from the current cursor;
|
|
`read_range(offset, length)` reads a byte range without moving it, the same
|
|
bytes a `seek(offset)` followed by `read(length)` would read. `size()`
|
|
returns the payload size without reading it. Each row call receives its own
|
|
reader, which closes when the call returns or raises. Do not retain it for
|
|
another invocation.
|
|
|
|
Use `video: bytes` when the Function needs the complete payload. Bytes inputs
|
|
materialize before invocation and remain subject to eager input limits.
|
|
Reader inputs keep the unread payload out of the input batch, but calling
|
|
`video.read()` without a size loads all remaining bytes in Python. Reader
|
|
inputs are supported in row mode. Return values remain ordinary values such
|
|
as `bytes`; this feature does not add streamed outputs.
|
|
|
|
## What to read next {#next}
|
|
|
|
<CardGroup cols={2}>
|
|
<Card title="Function" icon="code" href="/management/function">
|
|
List, describe and drop registered Function versions.
|
|
</Card>
|
|
<Card title="Vend a Secret to a Function" icon="key" href="/curation/vend-secret">
|
|
Bind a credential by reference instead of putting it in the function's source.
|
|
</Card>
|
|
<Card title="Chunk records into multiple rows" icon="diagram-subtask" href="/curation/chunking">
|
|
A Function whose output is a list, exploded into one row per element.
|
|
</Card>
|
|
<Card title="Refresh a computed column" icon="arrows-rotate" href="/feature-engineering/refresh-column">
|
|
Filling the column a Function is bound to, and re-filling it as inputs change.
|
|
</Card>
|
|
</CardGroup>
|