mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
Python UDF registration could not set `batch_size` or `row_timeout_seconds`, and the Rust registration model discarded these fields before submitting SQL. Expose both optional controls and preserve them through registration, with invalid values rejected and unchanged canonical bytes when the controls are omitted. The timeout applies to each Python call: one row for scalar Functions and one batch for vectorized Functions. Initialization keeps its existing service deadline. Validation at `f58e55c535a7df310db14d691b329402ca4baaf5`: 6 Rust registration-contract tests, 2 outbound-SQL tests, and all 103 Python slice2 tests passed after rebuilding the native extension. Rust formatting and the repository Python CI checks (Ruff 0.9.9, python/ scope) passed. A real REST + Flight SQL server integration also passed using the rebuilt SDK: `batch_size=2` limits observed calls in a direct SELECT over 8 rows, initial materialized-view creation, and append/refresh to 12 rows. This CPU host test verifies SDK-to-runtime passthrough; it does not exercise GPU initialization. Refs ENT-3246. The original GPU initialization and video-decoding scenario still needs verification on a current deployment; this SDK change does not close that investigation. The additional repository-root style checks report existing docs/web-tests findings (nine format files and 48 lint diagnostics); all affected files are unchanged from main. These are outside the Python CI job scope. Rust compatibility: callers constructing `FunctionRegistrationRequest` must initialize the two new optional fields (`None` preserves defaults); the request no longer implements `Eq`. The final head `798f463c9f68ab73e28c9a1ff06b5e4ee3e5d01f` adds creation-guide documentation and integrates main’s namespace repr/optional OCI embedding changes. The Function registration implementation is unchanged. All 103 Python slice2 tests passed again on EC2 using the unchanged native extension; Rust and server-bridge evidence remains applicable.
245 lines
8.9 KiB
Plaintext
245 lines
8.9 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"],
|
|
batch_size=32,
|
|
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. `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__`. Both settings are fixed into the
|
|
registered version. Omit them to use adaptive batching and the service's
|
|
default per-call deadline.
|
|
|
|
## 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>
|