Files
lancedb/docs/web/curation/create-function.mdx
T
Wyatt Alt 8ee7698545 feat(python): allow Blob v2 under a list in Function outputs
A Function that produces several blobs per row, such as frames from a video, could not declare `pa.list_(lancedb.blob("item"))` as its output: signatures rejected Blob v2 under any collection.

Outputs now accept Blob v2 inside list and large-list fields; the server stores each element as a Blob v2 value. Inputs, fixed-size lists and maps still reject it (lance stores Blob v2 under neither), and the `udf()` docstring now says where Blob v2 may appear.
2026-11-03 08:14:30 +08:00

234 lines
8.5 KiB
Plaintext

---
title: "Create a Function"
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
---
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.
## 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 udf
@udf(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 udf
@udf(pip=["numpy==2.2.0"])
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.
## Stateful Functions: load once, call many times {#stateful}
A `@udf`-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 udf
@udf
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 udf
gpu_class = next(
gpu.name for gpu in db.list_gpu_classes() if gpu.gpu_model == "NVIDIA A10G"
)
@udf(
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:
<CodeGroup>
```python Python icon="python"
import os
from lancedb import EnvVarSecret, udf
db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"])
@udf
class embed_remote:
def __init__(self, model: str = "text-embedding-3-small"):
import os
from openai import OpenAI
self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
self.model = model
def __call__(self, text: str) -> list[float]:
response = self.client.embeddings.create(input=text, model=self.model)
return response.data[0].embedding
version = db.create_function_async(
embed_remote,
secrets=[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")],
).wait()
```
</CodeGroup>
A mistyped Secret name is caught at registration; a mistyped environment
variable name only surfaces on the first rows, since nothing records which
variables a function reads. 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__`.
## Blob v2 input and output {#blob}
A Function can bind a [Blob v2 column](/management/table#managed-blobs) and
return another one, including a blob nested inside a struct field, not only
at the top level. In row mode, a blob parameter arrives as Python `bytes`
(or a `BlobReader` for incremental, seekable access, so a function never
has to materialize a payload it only partially needs):
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
import lancedb
from lancedb import BlobReader, udf
@udf(
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
```
</CodeGroup>
`output_schema=lancedb.blob(...)` is what makes the result Blob v2 rather
than plain Arrow `binary` -- returning `bytes` with no explicit blob output
schema still holds the right bytes, but skips the Managed storage path and
`fetch_blobs`. Declare it explicitly on every function whose output should
be a blob.
A Blob v2 field may also be an element of an output list or large list. These
values are queryable as Blob descriptors, but the row-id blob fetch APIs and
`blob_mode="bytes"` cannot fetch payloads inside lists.
## What to read next {#next}
<CardGroup cols={2}>
<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>