docs: restructure around the three API surfaces

The Documentation tab now reads as a manual over Python, TypeScript and SQL
rather than a Python guide with SQL bolted on.

Basics replaces the Catalog section: what the catalog holds, what each API
surface can do, jobs, agentic skills, and what Lance is. Management is new and
is the CRUD and listing reference for every catalog object, with a column per
language so the gaps are visible -- TypeScript has no function or SQL methods,
SQL needs a deployment.

Search becomes Query and gains a SELECT reference, since an analytical query is
a query whether it came from a builder or a statement.

Jobs is new. Every long-running operation already returned the same handle from
an _async method; nothing documented it.

The SQL function reference gains what Lance registers on top of DataFusion:
contains_tokens, backed by the FTS index, and the JSON accessors, backed by a
JSON scalar index. Geospatial st_* functions are behind a build feature LanceDB
does not ship, which is now stated rather than left to a confusing error.

Removes the Geneva pages' orphaned snippets, merges FTS Examples into Full-Text
Search, and splits Query Optimization into Explain and Explain Analyze.
This commit is contained in:
Jack Ye
2026-09-09 22:20:19 -07:00
parent 44f4b959ca
commit 4015560b83
35 changed files with 1793 additions and 1984 deletions
-53
View File
@@ -1,53 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
# These tests assert the default resource values for Geneva KubeRay cluster nodes.
# If any of these values change, update the tables in:
# docs/geneva/jobs/performance.mdx (the "Geneva defaults" section)
import pytest
def test_head_node_defaults():
# If these change, update the Head Node table in performance.mdx
from geneva.cluster.builder import KubeRayClusterBuilder
builder = KubeRayClusterBuilder()
assert builder._head_cpus == 4
assert builder._head_memory == "8Gi"
assert builder._head_gpus == 0
assert builder._head_node_selector == {"geneva.lancedb.com/ray-head": "true"}
assert builder._head_service_account == "geneva-service-account"
def test_cpu_worker_defaults():
# If these change, update the CPU Workers table in performance.mdx
from geneva.cluster.builder import CpuWorkerBuilder
worker = CpuWorkerBuilder()
assert worker._num_cpus == 4
assert worker._memory == "8Gi"
assert worker._node_selector == {"geneva.lancedb.com/ray-worker-cpu": "true"}
assert worker._replicas == 1
assert worker._min_replicas == 0
assert worker._max_replicas == 100
assert worker._idle_timeout_seconds == 60
# Confirm build produces 0 GPUs
config = worker.build()
assert config.num_gpus == 0
def test_gpu_worker_defaults():
# If these change, update the GPU Workers table in performance.mdx
from geneva.cluster.builder import GpuWorkerBuilder
worker = GpuWorkerBuilder()
assert worker._num_cpus == 8
assert worker._memory == "16Gi"
assert worker._num_gpus == 1
assert worker._node_selector == {"geneva.lancedb.com/ray-worker-gpu": "true"}
assert worker._replicas == 1
assert worker._min_replicas == 0
assert worker._max_replicas == 100
assert worker._idle_timeout_seconds == 60
@@ -1,107 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import os
import pytest
def test_quick_fix_manifest():
# --8<-- [start:quick_fix_manifest]
from geneva.manifest.builder import PipManifestBuilder
manifest = PipManifestBuilder.create("fix").pip(["numpy==1.26.4"]).build()
# --8<-- [end:quick_fix_manifest]
assert manifest.pip == ["numpy==1.26.4"]
def test_env_vars_via_cluster(monkeypatch):
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test-key-id")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
# --8<-- [start:env_vars_via_cluster]
from geneva.cluster.builder import KubeRayClusterBuilder
import os
cluster = (
KubeRayClusterBuilder.create("my-cluster")
.ray_init_kwargs({
"runtime_env": {
"env_vars": {
"AWS_ACCESS_KEY_ID": os.environ["AWS_ACCESS_KEY_ID"],
"AWS_SECRET_ACCESS_KEY": os.environ["AWS_SECRET_ACCESS_KEY"],
}
}
})
.build()
)
# --8<-- [end:env_vars_via_cluster]
assert cluster.kuberay.ray_init_kwargs["runtime_env"]["env_vars"]["AWS_ACCESS_KEY_ID"] == "test-key-id"
def test_pip_manifest(monkeypatch):
import geneva
from unittest.mock import MagicMock, create_autospec
mock_conn = create_autospec(geneva.db.Connection, instance=True)
monkeypatch.setattr("geneva.connect", MagicMock(return_value=mock_conn))
# --8<-- [start:pip_manifest]
import geneva
from geneva.manifest.builder import PipManifestBuilder
manifest = (
PipManifestBuilder.create("my-manifest")
.pip([
"numpy==1.26.4",
"torch==2.0.1",
"attrs==23.2.0",
])
.build()
)
conn = geneva.connect("s3://my-bucket/my-db")
conn.define_manifest("my-manifest", manifest)
with conn.context(cluster="my-cluster", manifest="my-manifest"):
conn.open_table("my-table").backfill("my-column")
# --8<-- [end:pip_manifest]
assert "numpy==1.26.4" in manifest.pip
def test_conda_cluster_path():
# --8<-- [start:conda_cluster_path]
from geneva.cluster.builder import KubeRayClusterBuilder
cluster = (
KubeRayClusterBuilder.create("my-cluster")
.ray_init_kwargs({
"runtime_env": {"conda": "environment.yml"}
})
.build()
)
# --8<-- [end:conda_cluster_path]
assert cluster.kuberay.ray_init_kwargs["runtime_env"]["conda"] == "environment.yml"
def test_conda_cluster_inline():
# --8<-- [start:conda_cluster_inline]
from geneva.cluster.builder import KubeRayClusterBuilder
cluster = (
KubeRayClusterBuilder.create("my-cluster")
.ray_init_kwargs({
"runtime_env": {
"conda": {
"channels": ["conda-forge"],
"dependencies": [
"python=3.10",
"ffmpeg<8",
"torchvision=0.22.1",
],
},
"config": {"eager_install": True},
}
})
.build()
)
# --8<-- [end:conda_cluster_inline]
assert cluster.kuberay.ray_init_kwargs["runtime_env"]["conda"]["channels"] == ["conda-forge"]
@@ -1,248 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Snippets for docs/geneva/udfs/profiling-memory.mdx."""
from unittest.mock import MagicMock
def test_stateful_udf_class():
import geneva
import pyarrow as pa
load_model = MagicMock(return_value=MagicMock(embed=MagicMock(return_value=[0.1] * 512)))
# --8<-- [start:stateful_udf_class]
@geneva.udf(data_type=pa.list_(pa.float32(), 512))
class MyEmbedding:
def __init__(self):
self.model = None
def setup(self):
self.model = load_model() # allocated once per actor
def __call__(self, text: str) -> list[float]:
if self.model is None:
self.setup()
return self.model.embed(text)
# --8<-- [end:stateful_udf_class]
assert MyEmbedding is not None
def test_memray_tracker_udf(monkeypatch):
import sys
load_model = MagicMock(return_value=MagicMock(embed=MagicMock(return_value=[0.1] * 512)))
monkeypatch.setitem(sys.modules, "memray", MagicMock())
# --8<-- [start:memray_tracker_udf]
import os, pathlib, uuid
from typing import Any
import memray
import geneva
import pyarrow as pa
_MEMRAY_OUT_DIR_ENV = "MY_UDF_MEMRAY_OUT_DIR"
@geneva.udf(data_type=pa.list_(pa.float32(), 512))
class MyEmbedding:
def __init__(self):
self.model = None
self._tracker: Any = None # memray.Tracker, when profiling is on
def setup(self):
# Open a memray tracker per worker process, if requested. Each
# worker writes its own .bin file so traces don't collide.
out_dir = os.environ.get(_MEMRAY_OUT_DIR_ENV)
if out_dir:
pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)
bin_path = pathlib.Path(out_dir) / (
f"memray-{os.getpid()}-{uuid.uuid4().hex}.bin"
)
self._tracker = memray.Tracker(
str(bin_path), native_traces=False, follow_fork=False
)
self._tracker.__enter__()
self.model = load_model()
def __call__(self, text: str) -> list[float]:
if self.model is None:
self.setup()
return self.model.embed(text)
# --8<-- [end:memray_tracker_udf]
assert MyEmbedding is not None
def test_ray_cluster_profile(monkeypatch):
from contextlib import contextmanager
table = MagicMock()
@contextmanager
def _mock_cluster(*args, **kwargs):
yield
monkeypatch.setattr("geneva.runners.ray._mgr.ray_cluster", _mock_cluster)
# --8<-- [start:ray_cluster_profile]
from geneva.runners.ray._mgr import ray_cluster
with ray_cluster(
local=True,
extra_env={"MY_UDF_MEMRAY_OUT_DIR": "/tmp/my-udf-profile"},
):
table.backfill("embedding", concurrency=1)
# --8<-- [end:ray_cluster_profile]
table.backfill.assert_called_once_with("embedding", concurrency=1)
def test_log_memory(capsys):
# --8<-- [start:log_memory]
import resource, pyarrow as pa
def log_memory(seq: int) -> None:
rss_bytes = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
# ru_maxrss is bytes on macOS, KiB on Linux:
import sys
if sys.platform != "darwin":
rss_bytes *= 1024
arrow_live = pa.total_allocated_bytes()
print(
f"seq={seq} "
f"rss_mb={rss_bytes // 1024**2} "
f"arrow_live_mb={arrow_live // 1024**2} "
f"gap_mb={(rss_bytes - arrow_live) // 1024**2}",
flush=True,
)
# --8<-- [end:log_memory]
log_memory(0)
captured = capsys.readouterr()
assert "seq=0" in captured.out
assert "rss_mb=" in captured.out
def test_leaky_cache():
# --8<-- [start:leaky_cache]
class BadEmbedding:
def __init__(self):
self.cache: dict[str, list[float]] = {}
def __call__(self, text: str) -> list[float]:
if text not in self.cache:
self.cache[text] = self.model.embed(text)
return self.cache[text]
# --8<-- [end:leaky_cache]
obj = BadEmbedding()
obj.model = MagicMock(embed=MagicMock(return_value=[0.1, 0.2]))
obj("hello")
obj("hello")
assert obj.model.embed.call_count == 1 # second call served from cache
assert len(obj.cache) == 1
def test_bounded_cache():
load_model = MagicMock(return_value=MagicMock(embed=MagicMock(return_value=[0.1, 0.2])))
# --8<-- [start:bounded_cache]
from functools import lru_cache
class GoodEmbedding:
def __init__(self):
self._embed = None
def setup(self):
model = load_model()
self._embed = lru_cache(maxsize=1024)(model.embed)
def __call__(self, text: str) -> list[float]:
if self._embed is None:
self.setup()
return self._embed(text)
# --8<-- [end:bounded_cache]
obj = GoodEmbedding()
assert obj("hello") == [0.1, 0.2]
load_model.assert_called_once()
def test_leaky_aggregator():
import pyarrow as pa
# --8<-- [start:leaky_aggregator]
class BadAggregator:
def __init__(self):
self.history = []
def __call__(self, batch: pa.RecordBatch) -> pa.Array:
self.history.append(batch) # holds every batch ever processed
...
# --8<-- [end:leaky_aggregator]
obj = BadAggregator()
assert isinstance(obj.history, list)
def test_leaky_closure():
import pyarrow as pa
def expensive(x):
return x
# --8<-- [start:leaky_closure]
class BadDeferred:
def __init__(self):
self.work_queue = []
def __call__(self, x: pa.Array) -> pa.Array:
# Lambda captures `x` by reference — the whole Array stays alive
self.work_queue.append(lambda: expensive(x))
...
# --8<-- [end:leaky_closure]
obj = BadDeferred()
assert isinstance(obj.work_queue, list)
def test_torch_inference_mode(monkeypatch):
import sys
mock_torch = MagicMock()
monkeypatch.setitem(sys.modules, "torch", mock_torch)
class TorchUDF:
def __init__(self):
self.model = MagicMock(encode=MagicMock(return_value=[0.1]))
# --8<-- [start:torch_inference_mode]
def __call__(self, text: str) -> list[float]:
with torch.inference_mode(): # <-- prevents autograd graph retention
return self.model.encode(text)
# --8<-- [end:torch_inference_mode]
import torch # resolves to monkeypatched mock above
obj = TorchUDF()
assert obj("hello") == [0.1]
def test_confidence_check():
class DebuggingUDF:
def __init__(self):
self._scratches = []
# --8<-- [start:confidence_check]
def __call__(self, x):
scratch = bytearray(8 * 1024 * 1024) # 8 MiB
self._scratches.append(scratch) # <-- deliberate leak
return ...
# --8<-- [end:confidence_check]
obj = DebuggingUDF()
obj(None)
assert len(obj._scratches) == 1
assert len(obj._scratches[0]) == 8 * 1024 * 1024
@@ -1,311 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import pytest
from unittest.mock import MagicMock
def test_scalar_udtf_iterator():
def extract_video_segment(path, start, end):
return bytes(f"clip:{start}-{end}", "utf-8")
# --8<-- [start:scalar_udtf_iterator]
from geneva import chunker
from typing import Iterator, NamedTuple
class Clip(NamedTuple):
clip_start: float
clip_end: float
clip_bytes: bytes
@chunker
def extract_clips(video_path: str, duration: float) -> Iterator[Clip]:
"""Yields multiple clips per video."""
clip_length = 10.0
for start in range(0, int(duration), int(clip_length)):
end = min(start + clip_length, duration)
clip_data = extract_video_segment(video_path, start, end)
yield Clip(clip_start=start, clip_end=end, clip_bytes=clip_data)
# --8<-- [end:scalar_udtf_iterator]
from geneva.transformer import Chunker
assert isinstance(extract_clips, Chunker)
assert extract_clips.input_columns == ["video_path", "duration"]
assert set(extract_clips.output_schema.names) == {"clip_start", "clip_end", "clip_bytes"}
clips = list(extract_clips.func("/v/a.mp4", 30.0))
assert len(clips) == 3
assert clips[0].clip_start == 0.0
assert clips[2].clip_end == 30.0
def test_scalar_udtf_list_return():
from geneva import chunker
from typing import NamedTuple
class Clip(NamedTuple):
clip_start: float
clip_end: float
clip_bytes: bytes
# --8<-- [start:scalar_udtf_list]
@chunker
def extract_clips(video_path: str, duration: float) -> list[Clip]:
clips = []
for start in range(0, int(duration), 10):
end = min(start + 10, duration)
clips.append(Clip(clip_start=start, clip_end=end, clip_bytes=b"..."))
return clips
# --8<-- [end:scalar_udtf_list]
from geneva.transformer import Chunker
assert isinstance(extract_clips, Chunker)
clips = extract_clips.func("/v/a.mp4", 30.0)
assert len(clips) == 3
assert all(c.clip_bytes == b"..." for c in clips)
def test_scalar_udtf_batch():
import pyarrow as pa
from geneva import chunker
clip_schema = pa.schema([
("clip_start", pa.float64()),
("clip_end", pa.float64()),
])
# --8<-- [start:scalar_udtf_batch]
@chunker(batch=True, output_schema=clip_schema)
def extract_clips(batch: pa.RecordBatch) -> pa.RecordBatch:
"""Process rows in batches. Same 1:N semantic per row."""
...
# --8<-- [end:scalar_udtf_batch]
from geneva.transformer import Chunker
assert isinstance(extract_clips, Chunker)
assert extract_clips.batch is True
def test_create_scalar_udtf_view(monkeypatch):
from typing import Iterator, NamedTuple
from geneva.transformer import chunker
class Clip(NamedTuple):
clip_start: float
clip_end: float
clip_bytes: bytes
@chunker
def extract_clips(video_path: str, duration: float) -> Iterator[Clip]:
for start in range(0, int(duration), 10):
yield Clip(clip_start=start, clip_end=min(start + 10.0, duration), clip_bytes=b"")
import geneva
from unittest.mock import create_autospec
mock_clips = MagicMock()
mock_db = create_autospec(geneva.db.Connection, instance=True)
mock_db.create_udtf_view.return_value = mock_clips
monkeypatch.setattr("geneva.connect", MagicMock(return_value=mock_db))
# --8<-- [start:create_scalar_udtf_view]
import geneva
db = geneva.connect("/data/mydb")
videos = db.open_table("videos")
# Create the 1:N materialized view
clips = db.create_udtf_view(
"clips",
source=videos.search(None).select(["video_path", "metadata"]),
udtf=extract_clips,
)
# Populate — runs the UDTF on every source row
clips.refresh()
# --8<-- [end:create_scalar_udtf_view]
call_kwargs = mock_db.create_udtf_view.call_args
assert call_kwargs.args[0] == "clips"
assert call_kwargs.kwargs["udtf"] is extract_clips
mock_clips.refresh.assert_called_once()
def test_add_columns_scalar_udtf():
import pyarrow as pa
from geneva.transformer import udf
clips = MagicMock()
embed_model = MagicMock()
embed_model.encode.return_value = [0.1] * 512
# --8<-- [start:add_columns_scalar_udtf]
@udf(data_type=pa.list_(pa.float32(), 512))
def clip_embedding(clip_bytes: bytes) -> list[float]:
return embed_model.encode(clip_bytes)
# Add an embedding column to the clips table
clips.add_columns({"embedding": clip_embedding})
# Backfill computes embeddings for all existing clips
clips.backfill("embedding")
# --8<-- [end:add_columns_scalar_udtf]
clips.add_columns.assert_called_once_with({"embedding": clip_embedding})
clips.backfill.assert_called_once_with("embedding")
def test_incremental_refresh():
videos = MagicMock()
clips = MagicMock()
new_video_data = [{"video_path": "/v/c.mp4", "duration": 45.0}]
# --8<-- [start:incremental_refresh]
# Add new videos to the source table
videos.add(new_video_data)
# Incremental refresh — only processes the new videos
clips.refresh()
# --8<-- [end:incremental_refresh]
videos.add.assert_called_once_with(new_video_data)
clips.refresh.assert_called_once()
def test_chaining_udtf_views(monkeypatch):
from typing import Iterator, NamedTuple
from geneva.transformer import chunker
class Clip(NamedTuple):
clip_start: float
clip_end: float
class Frame(NamedTuple):
frame_index: int
frame_bytes: bytes
@chunker
def extract_clips(video_path: str, duration: float) -> Iterator[Clip]:
for start in range(0, int(duration), 10):
yield Clip(clip_start=start, clip_end=min(start + 10.0, duration))
@chunker
def extract_frames(clip_start: float, clip_end: float) -> Iterator[Frame]:
yield Frame(frame_index=0, frame_bytes=b"")
import geneva
from unittest.mock import create_autospec
mock_db = create_autospec(geneva.db.Connection, instance=True)
monkeypatch.setattr("geneva.connect", MagicMock(return_value=mock_db))
db = geneva.connect("/data/mydb")
videos = db.open_table("videos")
# --8<-- [start:chaining_udtf_views]
# videos → clips (1:N)
clips = db.create_udtf_view(
"clips", source=videos.search(None), udtf=extract_clips
)
# clips → frames (1:N)
frames = db.create_udtf_view(
"frames", source=clips.search(None), udtf=extract_frames
)
# --8<-- [end:chaining_udtf_views]
assert mock_db.create_udtf_view.call_count == 2
first_call = mock_db.create_udtf_view.call_args_list[0]
second_call = mock_db.create_udtf_view.call_args_list[1]
assert first_call.kwargs["udtf"] is extract_clips
assert second_call.kwargs["udtf"] is extract_frames
def test_document_chunking_udtf():
# --8<-- [start:document_chunking_udtf]
from geneva import chunker
from typing import Iterator, NamedTuple
class Chunk(NamedTuple):
chunk_index: int
chunk_text: str
@chunker
def chunk_document(text: str) -> Iterator[Chunk]:
"""Split a document into overlapping chunks."""
words = text.split()
chunk_size = 500
overlap = 50
for i, start in enumerate(range(0, len(words), chunk_size - overlap)):
chunk_words = words[start:start + chunk_size]
yield Chunk(chunk_index=i, chunk_text=" ".join(chunk_words))
# --8<-- [end:document_chunking_udtf]
sample_text = " ".join(["word"] * 600)
chunks = list(chunk_document.func(sample_text))
assert len(chunks) == 2
assert chunks[0].chunk_index == 0
assert chunks[1].chunk_index == 1
assert len(chunks[0].chunk_text.split()) == 500
def test_document_chunking_full(monkeypatch):
import pyarrow as pa
from unittest.mock import MagicMock
import geneva
from unittest.mock import create_autospec
mock_chunks_table = MagicMock()
mock_db = create_autospec(geneva.db.Connection, instance=True)
mock_db.create_udtf_view.return_value = mock_chunks_table
monkeypatch.setattr("geneva.connect", MagicMock(return_value=mock_db))
embedding_model = MagicMock()
embedding_model.encode.return_value = [0.1] * 1536
# --8<-- [start:document_chunking_full]
from geneva import connect, chunker, udf
from typing import Iterator, NamedTuple
import pyarrow as pa
class Chunk(NamedTuple):
chunk_index: int
chunk_text: str
@chunker
def chunk_document(text: str) -> Iterator[Chunk]:
"""Split a document into overlapping chunks."""
words = text.split()
chunk_size = 500
overlap = 50
for i, start in enumerate(range(0, len(words), chunk_size - overlap)):
chunk_words = words[start:start + chunk_size]
yield Chunk(chunk_index=i, chunk_text=" ".join(chunk_words))
db = connect("/data/mydb")
docs = db.open_table("documents")
# Create chunked view — inherits doc_id, title, etc. from source
chunks = db.create_udtf_view(
"doc_chunks",
source=docs.search(None).select(["doc_id", "title", "text"]),
udtf=chunk_document,
)
chunks.refresh()
# Add embeddings to chunks for semantic search
@udf(data_type=pa.list_(pa.float32(), 1536))
def embed_text(chunk_text: str) -> list[float]:
return embedding_model.encode(chunk_text)
chunks.add_columns({"embedding": embed_text})
chunks.backfill("embedding") # Backfills embeddings on all existing chunks
# Query — parent columns available alongside chunk columns
chunks.search(None).select(["doc_id", "title", "chunk_text", "embedding"]).to_pandas()
# --8<-- [end:document_chunking_full]
call_kwargs = mock_db.create_udtf_view.call_args
assert call_kwargs.args[0] == "doc_chunks"
assert call_kwargs.kwargs["udtf"] is chunk_document
mock_chunks_table.refresh.assert_called_once()
mock_chunks_table.add_columns.assert_called_once()
mock_chunks_table.backfill.assert_called_once_with("embedding")
@@ -1,52 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
from unittest.mock import MagicMock, create_autospec
def test_registration_udf(monkeypatch):
# Verifies that Table.add_columns exists with the expected signature.
# If this fails, update the Registration row in docs/geneva/udfs/index.mdx.
import geneva
mock_table = create_autospec(geneva.table.Table, instance=True)
my_udf = MagicMock()
# --8<-- [start:registration_udf]
mock_table.add_columns({"col": my_udf})
# --8<-- [end:registration_udf]
mock_table.add_columns.assert_called_once()
def test_registration_scalar_udtf(monkeypatch):
# Verifies that Connection.create_udtf_view accepts a chunker.
# If this fails, update the Registration row in docs/geneva/udfs/index.mdx.
import geneva
mock_db = create_autospec(geneva.db.Connection, instance=True)
monkeypatch.setattr("geneva.connect", MagicMock(return_value=mock_db))
my_source = MagicMock()
my_chunker = MagicMock()
# --8<-- [start:registration_scalar_udtf]
db = geneva.connect("/data/mydb")
db.create_udtf_view("my_view", source=my_source, udtf=my_chunker)
# --8<-- [end:registration_scalar_udtf]
mock_db.create_udtf_view.assert_called_once()
def test_registration_udtf(monkeypatch):
# Verifies that Connection.create_udtf_view exists with the expected signature.
# If this fails, update the Registration row in docs/geneva/udfs/index.mdx.
import geneva
mock_db = create_autospec(geneva.db.Connection, instance=True)
monkeypatch.setattr("geneva.connect", MagicMock(return_value=mock_db))
my_source = MagicMock()
my_udtf = MagicMock()
# --8<-- [start:registration_udtf]
db = geneva.connect("/data/mydb")
db.create_udtf_view("my_view", source=my_source, udtf=my_udtf)
# --8<-- [end:registration_udtf]
mock_db.create_udtf_view.assert_called_once()
+61
View File
@@ -0,0 +1,61 @@
---
title: "Agentic skills"
sidebarTitle: "Agentic Skills"
description: "Give a coding agent a current, maintained reference for the LanceDB APIs."
icon: robot
keywords: ["agents", "plugin", "claude code", "codex", "cursor", "copilot"]
---
A coding agent writing LanceDB code from memory writes whatever was in its
training data -- often an older API, sometimes one that never existed. The
LanceDB agent plugin fixes that by supplying the current reference at the point
the agent needs it.
## The plugin {#plugin}
It ships as the `lancedb` plugin in the
[lancedb-agent-plugins](https://github.com/lancedb/lancedb-agent-plugins)
repository, which doubles as a plugin marketplace. It covers the Python and
TypeScript APIs, which code is portable between a local database and a
deployment, ingestion performance, and branch operations.
<CodeGroup>
```text Claude Code
/plugin marketplace add lancedb/lancedb-agent-plugins
/plugin install lancedb@lancedb
```
```bash Codex icon="terminal"
codex plugin marketplace add lancedb/lancedb-agent-plugins
codex plugin add lancedb@lancedb
```
```bash Other agents icon="terminal"
npx plugins add lancedb/lancedb-agent-plugins
```
</CodeGroup>
The cross-tool installer detects which agent CLIs are on your `PATH` -- Cursor,
GitHub Copilot CLI, VS Code, Grok Build, Kimi Code, Claude Code, Codex -- and
installs through each one's native plugin system.
## What agents are good at here {#workflows}
Two things about LanceDB make it unusually agent-friendly, and both have a
worked tutorial.
<Columns cols={2}>
<Card title="Build an ingestion pipeline" icon="robot" href="/build-with-ai-agents">
Install the plugin and have an agent build a multimodal ingestion pipeline
end to end.
</Card>
<Card title="Run experiments on branches" icon="flask" href="/agent-branch-experiments">
Isolate agent-driven experiments on a branch, evaluate them on a fixed test
set, and promote only the winner.
</Card>
</Columns>
The second is the more interesting pattern. [Branches](/tables/versioning)
give an agent somewhere to be wrong safely: it can rewrite a schema, recompute a
column, or try three embedding models without touching `main`, and you keep only
what measured better.
+94
View File
@@ -0,0 +1,94 @@
---
title: "Python, TypeScript, and SQL"
sidebarTitle: "Python vs SQL"
description: "Three ways to reach the same data, what each one can do, and where each one runs."
icon: code-compare
keywords: ["python", "typescript", "sql", "api", "flight sql", "client"]
---
The same table can be reached three ways: the Python SDK, the TypeScript SDK,
or SQL. They are views onto one engine, not three products -- a table written
from Python is queried from SQL without an export step.
They are not interchangeable, though, and the differences are worth knowing
before you pick one.
## What each one can do {#matrix}
| | Python | TypeScript | SQL |
| --- | :---: | :---: | :---: |
| Create and manage catalog objects | yes | yes | partly |
| Insert, merge, update, delete | yes | yes | -- |
| Filter, project, aggregate, join | yes | yes | yes |
| Vector and full-text search | yes | yes | yes |
| Feature engineering, jobs | yes | yes | -- |
| Runs against a local database | yes | yes | **no** |
Two entries in that table do most of the work of explaining the rest.
## SQL needs a deployment {#sql-is-remote}
<Warning>
SQL is not available on a local database. A connection to a directory or a
bucket has no server to plan and execute a statement, and asking it to raises
`NotImplementedError`.
</Warning>
```python
db = lancedb.connect("./.lancedb") # no SQL
db = lancedb.connect("db://my-database") # SQL
```
This is the single largest behavioural difference between the embedded and
deployed forms, and the reason the SDKs are the portable choice: SDK code moves
between the two by changing the URI, SQL does not.
<Badge color="red">Enterprise</Badge> Queries are served over an
[Arrow Flight SQL](https://arrow.apache.org/docs/format/FlightSql.html)
endpoint, so any Flight SQL client can reach it and results arrive as Arrow
record batches -- no conversion to Python objects unless you ask for one.
## SQL from each language {#sql-clients}
From Python, the connection runs SQL directly:
```python
reader = db.execute_query("SELECT id, text FROM my_docs LIMIT 10")
table = reader.read_all()
```
`execute_query` returns a blocking Arrow reader once results start arriving; it
does not wait for the whole query. `execute_query_async` returns a
[job](/basics/jobs) instead, for a statement you would rather not block on.
<Note>
The TypeScript SDK has no SQL method. Reach the endpoint with a Flight SQL
client instead -- see [connecting a client](/search/sql). This is a gap in the
SDK, not in the endpoint: the same statements work.
</Note>
## Choosing between them {#choosing}
Reach for **the SDKs** when you are writing data, managing the catalog, running
feature engineering, or building an application that should work the same
embedded and deployed. They are the complete surface.
Reach for **SQL** when the question is analytical -- joins across tables,
aggregates, window functions -- or when the thing asking is a BI tool, a
notebook, or a person who already thinks in SQL. It is also the only way to
create a [view](/management/view).
The two compose. A common shape is SDK code to build and maintain tables, and
SQL for the analysis on top.
## What stays the same {#invariants}
Whichever you pick:
- **Types are Arrow types.** A vector column is a `FixedSizeList<Float32>`
everywhere, not a SQL type mapped onto storage. See
[Functions and types](/sql/functions).
- **Reads are Arrow.** Nothing is serialized into language objects on the way
out unless you convert it.
- **The catalog is one catalog.** A table created from TypeScript is visible to
Python and to SQL immediately -- there is no per-language namespace.
+134
View File
@@ -0,0 +1,134 @@
---
title: "The catalog"
sidebarTitle: "Catalog"
description: "What lives in a LanceDB catalog, how the objects nest, and how you connect to one."
icon: sitemap
keywords: ["catalog", "database", "namespace", "table", "view", "function"]
---
A catalog is what LanceDB knows about. Everything you create lives in one and is
addressed through it.
## The objects {#objects}
| Object | What it is |
| --- | --- |
| Database | The root. A connection opens exactly one. |
| [Namespace](/namespaces) | A named grouping inside a database. Can nest. |
| [Table](/tables) | Rows and columns, versioned. What most operations act on. |
| [Materialized view](/feature-engineering/materialized-views) | A table whose contents come from a query, computed and stored. |
| [View](/management/view) | A saved query. Computed when read, not stored. |
| [Function](/feature-engineering/functions) | Registered code, applied to produce column values. |
```
database
└── namespace
├── table
├── materialized view (a table, defined by a query)
├── view (a query, not stored)
└── function
```
A namespace can contain further namespaces, so the hierarchy goes as deep as you
need. A table is reached by its path through it.
This page is about what the objects *are*. For the operations on them --
creating, listing, dropping -- see [Management](/management).
## Connecting {#connecting}
```python
import lancedb
db = lancedb.connect("./.lancedb") # a local directory
db = lancedb.connect("s3://bucket/path") # object storage
db = lancedb.connect("db://my-database", api_key="...") # a deployment
```
The URI decides what kind of database you get, and that decision reaches further
than it looks. A local or object-store URI opens the data directly from your
process. A `db://` URI talks to a deployment, which is what makes server-side
execution -- [functions](/feature-engineering/functions), remote refreshes,
[SQL](/basics/apis) -- available, and what makes a few local-only features
unavailable.
`connect_async` returns an `AsyncConnection` with the same surface as
coroutines.
### What a table handle actually is {#table-handles}
The URI also decides what `open_table` hands back, which is worth knowing
because the two behave the same but fail differently:
- **`LanceTable`** -- direct access to storage (a local path, `file://`, `s3://`
and similar). Your process resolves and reads the table itself.
- **`RemoteTable`** -- catalog-backed access through a deployment (`db://`).
Resolution and execution happen on the server.
![](/static/assets/images/overview/understanding-tables.png)
Both expose the same table API -- create, open, mutate, query. What differs is
where the work happens, and therefore which operations exist at all.
### Storage options {#storage-options}
`storage_options` configures the underlying store -- credentials, endpoints, and
per-table defaults for tables created through this connection:
```python
db = lancedb.connect(
"s3://bucket/path",
storage_options={"new_table_enable_stable_row_ids": "true"},
)
```
<Warning>
`new_table_enable_stable_row_ids` is worth setting deliberately. Stable row ids
cannot be enabled after a table exists, and a
[materialized view](/feature-engineering/materialized-views) requires its source
to have them. A table created without them cannot later become a view's source
without being recreated.
</Warning>
See [Storage](/storage) for the full set.
### Read consistency {#consistency}
`read_consistency_interval` controls how often a connection checks for versions
committed by other writers. The default reads a snapshot taken when the table was
opened, so a long-lived connection will not see other processes' writes until it
reopens.
Set it when several processes write the same table and readers must observe those
writes:
```python
from datetime import timedelta
db = lancedb.connect(uri, read_consistency_interval=timedelta(seconds=5))
```
The cost is a metadata check on that interval. See
[Consistency](/tables/consistency).
## Views and materialized views are different objects {#views}
The names are close enough to mislead. Both are defined by a query over other
tables; the difference is when that query runs.
- A **materialized view** stores its rows. A refresh computes them, and reads are
reads of stored data -- fast, and as current as the last refresh.
- A **view** stores only the query. Every read runs it, so a view is never stale
and never faster than the query it wraps.
Reach for a materialized view when the query is expensive and the data changes
less often than it is read. Reach for a view when correctness matters more than
latency, or when the query is cheap.
## What is not a catalog object {#elsewhere}
**Indexes** belong to a table rather than standing alone, and creating one is a
performance decision -- see [Maintenance](/indexing).
**Jobs** are not stored data at all; they are handles to work the server is
doing. See [Jobs](/basics/jobs).
+181
View File
@@ -0,0 +1,181 @@
---
title: "Jobs"
sidebarTitle: "Jobs"
description: "The handle every long-running operation returns, and how to wait on, inspect, or cancel one."
icon: hourglass-half
keywords: ["job", "async", "background", "create index", "refresh column"]
---
Some operations take longer than a request should. Building an index over a
large table, refreshing a computed column, registering a function -- these run
on the server after the call that started them has returned.
Each is submitted the same way and returns the same handle. Learn the handle
once and every long-running operation behaves alike.
## The `_async` convention {#convention}
An operation that can run in the background has two forms. The plain name
blocks until the work is done and returns its result. The `_async` /
`Async` name submits the work and returns a **job** immediately.
| Blocking | Returns a job | What runs |
| --- | --- | --- |
| `create_index` | `create_index_async` | index build |
| `refresh_column` | `refresh_column_async` | recompute a [computed column](/feature-engineering/computed-columns) |
| `create_function` | `create_function_async` | register a [function](/feature-engineering/functions) |
| `drop_table` | `drop_table_async` | drop a table |
| `execute_query` | `execute_query_async` | a [SQL](/basics/apis) query |
<Note>
The blocking form is not a different operation -- it submits the same job and
waits. Reach for the `_async` form when you want to do something else
meanwhile, run several at once, or survive the client going away.
</Note>
## Submitting and waiting {#waiting}
<CodeGroup>
```python Python icon="python"
job = table.create_index_async("vector")
print(job.id) # the server's id for this operation
print(job.status()) # "running" | "finished" | "failed" | "cancelled"
job.wait() # blocks until terminal; raises if it failed
```
```typescript TypeScript icon="square-js"
const job = await table.createIndexAsync("vector");
console.log(job.id); // the server's id for this operation
console.log(await job.status()); // "running" | "finished" | "failed" | "cancelled"
await job.wait(); // resolves when terminal; rejects if it failed
```
</CodeGroup>
`wait` is the one call that raises on failure. `status` is a point snapshot and
returns the failed state rather than raising, which is what you want in a
polling loop.
In Python `wait` takes an optional timeout and returns the operation's typed
result -- a `RefreshColumnJobResult`, a `FunctionVersion`, or `None` for an
operation that has no result:
```python
from datetime import timedelta
result = table.refresh_column_async("embedding").wait(timeout=timedelta(minutes=30))
```
`TimeoutError` means the timeout elapsed, not that the job stopped. The job is
still running; the handle just gave up watching.
<Note>
The TypeScript `wait()` resolves to `void`. Read the outcome from `result`
after it resolves.
</Note>
## Inspecting a job {#inspecting}
Submitting an operation returns only an id, so the detail fields are empty
until the handle fetches the record. `refresh()` does that; `status()` does it
as a side effect.
<CodeGroup>
```python Python icon="python"
job.refresh()
job.state # last observed state, without another round trip
job.job_type # what kind of job, as the server names it
job.creation_ms # when it was created, epoch milliseconds
job.spec # what it was submitted with
job.result # its terminal result, once it succeeds
job.failure # why it failed, when it did
```
```typescript TypeScript icon="square-js"
await job.refresh();
job.state; // last observed state, without another round trip
job.jobType; // what kind of job, as the server names it
job.creationMs; // when it was created, epoch milliseconds
job.spec; // what it was submitted with
job.result; // its terminal result, once it succeeds
job.failure; // why it failed, when it did
```
</CodeGroup>
Printing the handle renders every field it knows, which is usually faster than
reading them one at a time.
## Events {#events}
`result` only tells you anything once the job has finished. Events are written
as it runs, and they outlive the workers that produced them -- so a job that is
still going, or one that never finished, can still account for what it did.
<CodeGroup>
```python Python icon="python"
events = job.events(limit=5000, filter="state = 'claim_complete'")
```
```typescript TypeScript icon="square-js"
const events = await job.events({ limit: 5000, filter: "state = 'claim_complete'" });
```
</CodeGroup>
A distributed job records a `claim` / `claim_complete` pair per unit of work,
each carrying `rows_processed`. Summing `rows_processed` over the completed
claims is how you get progress on a job that has no percentage.
`filter` is a SQL-like expression over `state`, `updated_by`, `emitted_from`,
`emitted_by`, and `claim_entity`.
<Warning>
The server returns 1000 event rows by default and 10,000 at most, and
**truncates without telling you**. A job that emits an event per fragment will
silently lose its tail unless you pass `limit`.
</Warning>
Events are recorded server-side, so this raises for an operation that ran
in-process.
## Finding and cancelling jobs {#managing}
The connection lists and reopens jobs, so a handle lost to a restarted process
is recoverable from its id.
<CodeGroup>
```python Python icon="python"
db.list_jobs() # every job the server knows about
job = db.open_job(job_id) # reattach to one
db.cancel_job(job_id) # ask it to stop
```
```typescript TypeScript icon="square-js"
await db.listJobs(); // every job the server knows about
const job = await db.openJob(jobId); // reattach to one
await db.cancelJob(jobId); // ask it to stop
```
</CodeGroup>
Cancellation is a request, not a guarantee, and cancelling a job that already
finished does nothing. `JobNotFoundError` means the id does not name a job the
server still has.
<Warning>
A job id is opaque. It correlates with server logs and the calls above, but
parsing it or storing it to resume the job later is not supported.
</Warning>
## In-process jobs {#in-process}
A local connection has no server to run anything, so its operations run in your
process. They still return a handle, so the same code works against both, but
one with nothing behind it: `id` is `None`, `status()` reports `"finished"`,
`wait()` returns immediately, `cancel()` does nothing, and `events()` raises.
This is the difference to expect when moving between a local database and a
deployment -- see [Python, TypeScript, and SQL](/basics/apis).
+115
View File
@@ -0,0 +1,115 @@
---
title: "What about Lance?"
sidebarTitle: "What about Lance"
description: "Lance is the open format underneath. LanceDB is what implements a lakehouse on top of it."
icon: "/static/assets/logo/lance-logo-gray.svg"
keywords: ["lance", "format", "open standard", "lakehouse", "namespace", "catalog"]
---
Two names, two things. [Lance](https://lance.org/) is an open format and
specification. LanceDB is an implementation built on it.
The distinction matters when you are deciding what you are committing to: the
format is a standard other engines read, and your data stays readable by them
whichever LanceDB you run.
## Lance is the open standard {#standard}
Lance is an open-source, columnar lakehouse format for multimodal AI. It
specifies three things:
| | |
| --- | --- |
| **File format** | how bytes are laid out on disk, optimized for random access without giving up scan throughput |
| **Table format** | how files compose into a versioned table -- schema evolution, deletions, indexes |
| **Namespace specification** | how tables are named, grouped, and resolved -- the catalog layer |
Being a specification rather than a product is the point. Object storage holds
the bytes, and open engines -- PyTorch, Ray, Spark, Trino, DuckDB, Polars --
read them without going through LanceDB at all.
<Card
title="Lance format documentation"
icon="/static/assets/logo/lance-logo-gray.svg"
href="https://lance.org/format"
>
The format specification: design, layout, and how it enables the multimodal lakehouse.
</Card>
## LanceDB embedded implements the storage half {#embedded}
Connecting to a directory or a bucket gives you a **storage-only lakehouse**.
Your process opens Lance tables directly and does the work itself: it plans and
executes queries, builds indexes, and writes new versions.
```python
db = lancedb.connect("./.lancedb")
db = lancedb.connect("s3://bucket/path")
```
There is no server, which is what makes it embedded -- and also what bounds it.
There is nothing to run work on your behalf, so there are no server-side
[jobs](/basics/jobs), no [SQL endpoint](/basics/apis), and no shared catalog
beyond what the storage layer itself provides.
## LanceDB Enterprise implements the catalog specification {#enterprise}
<Badge color="red">Enterprise</Badge> A `db://` connection talks to a deployment
that implements the **Lance REST Namespace** -- the REST server defined by the
Lance namespace specification, covering the catalog operations in the
[REST API reference](/api-reference/rest).
```python
db = lancedb.connect("db://my-database", api_key="...")
```
Implementing the specification rather than inventing an interface is what makes
the catalog reachable by anything that speaks it, and what makes
[authorization](/governance/authorization), server-side jobs, and
[SQL](/basics/apis) possible: there is now a server to enforce and to run them.
The tables are the same Lance tables either way. What changes is who resolves
their names and who does the work.
## What the format gives you {#capabilities}
These are properties of Lance, so they hold in both forms:
| Capability | What it enables |
| --- | --- |
| Multimodal storage | Images, video, audio, text, embeddings, annotations and metadata in one table. |
| First-class blob API | Large binary objects in blob columns, with lazy reads and streaming byte access. |
| Fast random access and scans | Sample, shuffle, and fetch individual rows without giving up sequential throughput. |
| Zero-copy schema evolution | Add, drop, rename, or alter columns without rewriting existing data files. |
| Versioned tables | Reproduce experiments and tie artifacts to the exact table version they used. |
| Hybrid search and indexing | Vector search, full-text search, and scalar filters over the same dataset. |
| Open interoperability | Object storage underneath, open engines on top. |
Two of them explain behaviour you will meet elsewhere in these docs.
### Versioning is why old data lingers {#versioning}
Every write creates a version, and old versions are not immediately destroyed --
another reader may be part-way through one. A hundred versions are not a hundred
copies of the data, but they do carry a hundred times the metadata, which
eventually slows queries down.
Deletion works the same way: a deleted row is marked in a deletion file rather
than erased, so the data is recoverable for as long as the version is.
[Compaction](/maintenance/compaction) is what resolves both.
### Column evolution is why feature engineering is cheap {#evolution}
Adding a derived column writes only the new column. Existing data -- the
expensive part, the images and videos -- is untouched. That is what makes
[computed columns](/feature-engineering/computed-columns) a reasonable thing to
do repeatedly on a large table rather than a full rewrite each time.
<Card
title="Learn more about Lance"
icon="/static/assets/logo/lance-logo-gray.svg"
href="https://lance.org/quickstart"
>
Lance is a separate open source project with its own documentation.
</Card>
-94
View File
@@ -1,94 +0,0 @@
---
title: "Database"
sidebarTitle: "Database"
description: "The root of a catalog. Connecting to one, and the operations it holds."
icon: database
---
A database is the root of a catalog. A connection opens exactly one, and every
other object is reached through it.
## Connecting {#connecting}
```python
import lancedb
db = lancedb.connect("./.lancedb") # a local directory
db = lancedb.connect("s3://bucket/path") # object storage
db = lancedb.connect("db://my-database", api_key="...") # Cloud or Enterprise
```
The URI decides what kind of database you get, and that decision reaches
further than it looks. A local or object-store URI opens the data directly from
your process. A `db://` URI talks to a deployment, which is what makes
server-side execution — [functions](/feature-engineering/functions), remote refreshes,
[SQL](/sql) — available, and what makes a few local-only features unavailable.
`connect_async` returns an `AsyncConnection` with the same surface as
coroutines.
### Storage options {#storage-options}
`storage_options` configures the underlying store — credentials, endpoints, and
per-table defaults for tables created through this connection:
```python
db = lancedb.connect(
"s3://bucket/path",
storage_options={"new_table_enable_stable_row_ids": "true"},
)
```
<Warning>
`new_table_enable_stable_row_ids` is worth setting deliberately. Stable row ids
cannot be enabled after a table exists, and a
[materialized view](/feature-engineering/materialized-views) requires its source to have
them. A table created without them cannot later become a view's source without
being recreated.
</Warning>
See [Storage](/storage) for the full set.
## Operations {#operations}
| | |
| --- | --- |
| `db.table_names()` | list the tables in the database |
| `db.create_table(name, data)` | create a table — see [Table](/tables) |
| `db.open_table(name)` | open an existing table |
| `db.drop_table(name)` | drop one table |
| `db.drop_database()` | drop everything in the database |
Each accepts `namespace_path` to act inside a [namespace](/namespaces)
rather than at the root.
```python
db.table_names()
tbl = db.open_table("documents")
db.drop_table("documents")
```
<Warning>
`drop_database()` removes every table in the database. There is no confirmation
and no undo — table [versioning](/tables/versioning) does not survive the
table being dropped.
</Warning>
## Read consistency {#consistency}
`read_consistency_interval` controls how often a connection checks for versions
committed by other writers. The default reads a snapshot taken when the table
was opened, so a long-lived connection will not see other processes' writes
until it reopens.
Set it when several processes write the same table and readers must observe
those writes:
```python
from datetime import timedelta
db = lancedb.connect(uri, read_consistency_interval=timedelta(seconds=5))
```
The cost is a metadata check on that interval. See
[Consistency](/tables/consistency).
-58
View File
@@ -1,58 +0,0 @@
---
title: "Catalog"
sidebarTitle: "Overview"
description: "What lives in a LanceDB catalog: databases, namespaces, tables, views, and functions."
icon: sitemap
---
A catalog is what LanceDB knows about. Everything you create lives in one and is
addressed through it — the objects below, arranged in a hierarchy.
This section covers what each object is and the basic operations on it. Where an
object has depth — how to search a table, how to refresh a view — the page links
to the section that covers it rather than repeating it.
## The objects {#objects}
| Object | What it is |
| --- | --- |
| [Database](/catalog/database) | The root. A connection opens one. |
| [Namespace](/namespaces) | A named grouping of tables inside a database. |
| [Table](/tables) | Rows and columns, versioned. The thing most operations act on. |
| [Materialized view](/feature-engineering/materialized-views) | A table whose contents come from a query, computed and stored. |
| [View](/catalog/view) | A saved query. Computed when read, not stored. |
| [Function](/feature-engineering/functions) | Registered code, applied to produce column values. |
## How they nest {#hierarchy}
```
database
└── namespace
├── table
├── materialized view (a table, defined by a query)
├── view (a query, not stored)
└── function
```
A namespace can contain further namespaces, so the hierarchy is as deep as your
organisation needs. A table is reached by its path through that hierarchy.
## Views and materialized views are different objects {#views}
The names are close enough to mislead. Both are defined by a query over other
tables; the difference is when that query runs.
- A **materialized view** stores its rows. A refresh computes them, and reads
are reads of stored data — fast, and as current as the last refresh.
- A **view** stores only the query. Every read runs it, so a view is never stale
and never faster than the query it wraps.
Reach for a materialized view when the query is expensive and the data changes
less often than it is read. Reach for a view when correctness matters more than
latency, or when the query is cheap.
## What is not here {#elsewhere}
Indexes belong to a table but are covered under [Maintenance](/indexing),
because creating one is a decision about performance rather than about
structure.
+49 -19
View File
@@ -30,6 +30,34 @@
{
"source": "/tables/update",
"destination": "/ingestion/update"
},
{
"source": "/search/fts-examples",
"destination": "/search/full-text-search"
},
{
"source": "/search/optimize-queries",
"destination": "/search/explain"
},
{
"source": "/lance",
"destination": "/basics/lance"
},
{
"source": "/tables-and-namespaces",
"destination": "/basics/catalog"
},
{
"source": "/catalog",
"destination": "/basics/catalog"
},
{
"source": "/catalog/database",
"destination": "/basics/catalog"
},
{
"source": "/sql",
"destination": "/basics/apis"
}
],
"navigation": {
@@ -45,10 +73,19 @@
]
},
{
"group": "Catalog",
"group": "Basics",
"pages": [
"catalog/index",
"catalog/database",
"basics/catalog",
"basics/apis",
"basics/jobs",
"basics/agentic-skills",
"basics/lance"
]
},
{
"group": "Management",
"pages": [
"management/index",
{
"group": "Namespace",
"pages": [
@@ -64,11 +101,7 @@
"tables/schema",
"tables/multimodal"
]
},
"feature-engineering/materialized-views",
"feature-engineering/functions",
"lance",
"tables-and-namespaces"
}
]
},
{
@@ -85,16 +118,18 @@
]
},
{
"group": "Search",
"group": "Query",
"pages": [
"search/index",
"search/select",
"search/vector-search",
"search/multivector-search",
"search/full-text-search",
"search/fts-examples",
"search/hybrid-search",
"search/filtering",
"search/optimize-queries",
"search/explain",
"search/explain-analyze",
"sql/functions",
{
"group": "Reranking",
"pages": [
@@ -120,7 +155,9 @@
"group": "Feature engineering",
"pages": [
"feature-engineering/index",
"feature-engineering/computed-columns"
"feature-engineering/computed-columns",
"feature-engineering/materialized-views",
"feature-engineering/functions"
]
},
{
@@ -152,13 +189,6 @@
"performance"
]
},
{
"group": "SQL",
"pages": [
"sql/index",
"sql/functions"
]
},
{
"group": "Support",
"pages": [
@@ -75,7 +75,7 @@ declared earlier in the same mapping.
## Filling as a job {#async}
<Badge color="red">Cloud and Enterprise</Badge>
<Badge color="red">Enterprise</Badge>
Against a remote database the refresh runs on the server. `refresh_column`
raises there; use `refresh_column_async`, which returns a job you can wait on or
@@ -95,7 +95,7 @@ a long backfill can be started, left, and checked on later.
## Filling with Python instead of SQL {#functions}
<Badge color="red">Cloud and Enterprise</Badge>
<Badge color="red">Enterprise</Badge>
When the value cannot be expressed in SQL — an embedding, a model call, an image
decode — the column is filled by a registered function instead of an expression.
+1 -1
View File
@@ -6,7 +6,7 @@ icon: code
keywords: ["udf", "functions", "create_function", "embedding", "feature engineering"]
---
<Badge color="red">Cloud and Enterprise</Badge>
<Badge color="red">Enterprise</Badge>
Some columns cannot be written as a SQL expression: an embedding, a model call,
an image decode. A function is a Python callable registered with the database,
+1 -1
View File
@@ -25,7 +25,7 @@ filled by a registered function — but each is worth understanding on its own.
| --- | --- | --- | --- |
| [Computed columns](/feature-engineering/computed-columns) | a new column on an existing table | `refresh_column()` | everywhere |
| [Materialized views](/feature-engineering/materialized-views) | a new table derived by a query | `refresh()` | local databases |
| [Functions](/feature-engineering/functions) | a computed column filled by Python | a registered function | Cloud and Enterprise |
| [Functions](/feature-engineering/functions) | a computed column filled by Python | a registered function | Enterprise |
**Computed columns** are the common case. A column is declared with a SQL
expression and starts empty; a refresh gives values to the rows that lack them.
@@ -88,7 +88,7 @@ Enterprise documentation if that is where your data lives.
</Warning>
The direction is the reverse of [functions](/feature-engineering/functions),
which register only against Cloud and Enterprise and raise locally. Worth
which register only against Enterprise and raise locally. Worth
knowing before concluding either is unavailable.
## Working with the view {#working-with-the-view}
-99
View File
@@ -1,99 +0,0 @@
---
title: "Lance format"
sidebarTitle: "Lance format"
description: "Open-source lakehouse format for multimodal AI."
icon: "/static/assets/logo/lance-logo-gray.svg"
---
[Lance](https://lance.org/) is an open-source, columnar lakehouse format for multimodal AI.
It provides a file format, table format, and lightweight catalog spec, allowing developers
to build a complete open lakehouse on top of object storage.
Building on top of open foundations and optimizing the format for random access
(without compromising scan performance) enables
high-performance vector search, full-text search, indexing, and feature engineering capabilities.
[LanceDB](/enterprise) builds on these capabilities so teams can work with one multimodal data layer
instead of moving data across separate storage, search, feature, and training systems.
<Card
title="Lance format documentation"
icon="/static/assets/logo/lance-logo-gray.svg"
href="https://lance.org/format"
>
Visit the Lance format documentation to learn more about its design, features, and how it enables the multimodal lakehouse.
</Card>
## Capabilities of the Lance format {#capabilities-of-the-lance-format}
Capability | What it enables
--- | ---
Multimodal storage | Store images, video, audio, text, embeddings, annotations, metadata, features, and more, all in one table.
First-class blob API | Store large binary objects such as images, video, audio, and model artifacts in blob columns with lazy reads and streaming byte access.
Fast random access and scans | Sample, shuffle, and retrieve individual rows efficiently without giving up high-throughput sequential reads.
Flexible data evolution | Add, drop, rename, or alter columns as datasets change, often without rewriting existing data files.
Versioned tables | Reproduce experiments, restore previous states, and tie downstream artifacts to the exact table version they used.
Hybrid search and indexing | Combine vector search, full-text search, and scalar filters on the same dataset with Lance indexes.
Open lakehouse interoperability | Build on object storage and connect Lance tables to open engines such as PyTorch, Ray, Spark, Trino, DuckDB and Polars.
## Key concepts {#key-concepts}
The following concepts are core to the Lance format:
<Steps>
<Step>
**Arrow-native, columnar storage** and **interoperability** with the open lakehouse ecosystem (including other file formats and compute engines).
</Step>
<Step>
**Zero-copy** data evolution, meaning you can easily add derived columns (like features or embeddings) at a later time, **without full table rewrites**. Only new data is written; expensive existing data (like images/videos) remain untouched.
</Step>
<Step>
Data is **versioned**, with each insert operation creating a new version of the dataset and an update to the manifest that tracks versions via metadata
</Step>
</Steps>
### Data versioning {#data-versioning}
Data in Lance tables are versioned -- this helps keep LanceDB scalable and consistent.
We do not immediately blow away old versions when creating new ones because other clients might be
in the middle of querying the old version. It's important to retain older versions for as long as they
might be queried.
Each version contains metadata and just the new/updated data in your transaction. So if you have 100
versions, they aren't 100 duplicates of the same data. However, they do have 100x the metadata overhead
of a single version, which can result in slower queries.
### Data compaction {#data-compaction}
As you insert more data, your dataset will grow and you'll need to perform compaction to maintain query
throughput (i.e., keep latencies down to a minimum). Compaction is the process of merging fragments
together to reduce the amount of metadata that needs to be managed, and to reduce the number of files
that need to be opened while scanning the dataset.
Running compaction on a Lance dataset will do the following:
- Remove deleted rows from fragments
- Remove dropped columns from fragments
- Merge small fragments into larger ones
Compaction focuses on read performance, not immediate disk reclamation. During compaction, Lance writes
new compacted files while older files are still referenced by previous table versions. This means disk
usage can increase temporarily until old versions are cleaned up.
### Data deletion and recovery {#data-deletion-and-recovery}
Although Lance allows you to delete rows from a dataset, it does not actually delete the data immediately.
It simply marks the row as deleted in the `DataFile` that represents a fragment.
For a given version of the dataset, each fragment can have up to one deletion file (if no rows were ever
deleted from that fragment, it will not have a deletion file). This is important to keep in mind because
it means that the data is still there, and can be recovered if needed, as long as that version still
exists based on your backup policy.
<Card
title="Learn more about Lance"
icon="/static/assets/logo/lance-logo-gray.svg"
href="https://lance.org/quickstart"
>
Lance is a separate open source project. Check out its documentation to learn more.
</Card>
+152
View File
@@ -0,0 +1,152 @@
---
title: "Management"
sidebarTitle: "Overview"
description: "Create, list, and drop the objects in a catalog, from Python, TypeScript, or SQL."
icon: folder-tree
keywords: ["ddl", "create", "drop", "list", "catalog", "management"]
---
Every [catalog object](/basics/catalog) is created, listed, and dropped the same
few ways. This section is the reference for those operations; the pages linked
from it cover what each object is for.
Operations that can take a while have an `_async` form returning a
[job](/basics/jobs).
## The surface at a glance {#surface}
<Note>
SQL requires a deployment -- a local database has no engine to plan a statement.
See [Python, TypeScript, and SQL](/basics/apis).
</Note>
### Namespaces {#namespaces}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| create | `create_namespace` | `createNamespace` | -- |
| list | `list_namespaces` | `listNamespaces` | -- |
| describe | `describe_namespace` | `describeNamespace` | -- |
| exists | `namespace_exists` | -- | -- |
| drop | `drop_namespace` | `dropNamespace` | -- |
See [Namespaces](/namespaces).
### Tables {#tables}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| create | `create_table` | `createTable` | `CREATE TABLE` |
| create empty | `create_table(schema=)` | `createEmptyTable` | `CREATE TABLE` |
| open | `open_table` | `openTable` | -- |
| list | `table_names`, `list_tables` | `tableNames`, `listTables` | `SHOW TABLES` |
| exists | `table_exists` | -- | -- |
| rename | `rename_table` | `renameTable` | -- |
| clone | `clone_table` | `cloneTable` | -- |
| drop | `drop_table`, `drop_table_async` | `dropTable`, `dropTableAsync` | `DROP TABLE` |
| drop all | `drop_all_tables` | `dropAllTables` | -- |
See [Tables](/tables) for creating one and shaping its schema.
### Materialized views {#materialized-views}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| create | `create_materialized_view` | `createMaterializedView` | -- |
| open | `open_materialized_view` | `openMaterializedView` | -- |
| list | `list_materialized_views` | `listMaterializedViews` | `SHOW MATERIALIZED VIEWS` |
| refresh | `refresh` | `refresh` | `REFRESH MATERIALIZED VIEW` |
| drop | `drop_table` | `dropTable` | `DROP TABLE` |
<Note>
`list_materialized_views` reads every table's schema to find them, so it costs
one open per table. `SHOW MATERIALIZED VIEWS` asks the server instead.
</Note>
See [Materialized views](/feature-engineering/materialized-views).
### Views {#views}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| create | -- | -- | `CREATE VIEW` |
| drop | -- | -- | `DROP VIEW` |
<Badge color="red">Enterprise</Badge> Views exist only in SQL -- there is no SDK
method for them, because the thing a view stores *is* a SQL statement. See
[Views](/management/view).
### Functions {#functions}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| create | `create_function`, `create_function_async` | -- | `CREATE FUNCTION` |
| get | `get_function` | -- | -- |
| list | `list_functions` | -- | `SHOW FUNCTIONS` |
| drop | `drop_function` | -- | -- |
<Badge color="red">Enterprise</Badge> A function runs on the server, so there is
no local form. The TypeScript SDK has no function methods; use Python or SQL.
See [Functions](/feature-engineering/functions).
### Jobs {#jobs}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| list | `list_jobs` | `listJobs` | `SHOW JOBS` |
| open | `open_job` | `openJob` | -- |
| cancel | `cancel_job` | `cancelJob` | -- |
| errors | `job.events` | `job.events` | `SHOW ERRORS` |
See [Jobs](/basics/jobs).
### Indexes {#indexes}
| | Python | TypeScript | SQL |
| --- | --- | --- | --- |
| create | `create_index`, `create_index_async` | `createIndex`, `createIndexAsync` | `CREATE INDEX` |
| list | `list_indices` | `listIndices` | -- |
| stats | `index_stats` | `indexStats` | -- |
| drop | `drop_index` | `dropIndex` | `DROP INDEX` |
<Note>
SQL `CREATE INDEX` covers one column and takes no user-chosen name -- the server
names it. For a multi-column or named index, use the SDK.
</Note>
Indexes are a performance decision rather than a structural one, so they are
covered under [Maintenance](/indexing).
## Namespaces qualify everything {#namespace-paths}
Most connection-level operations take a `namespace_path` (`namespacePath`) to
act inside a namespace rather than at the database root:
<CodeGroup>
```python Python icon="python"
db.table_names(namespace_path=["prod", "events"])
tbl = db.open_table("clicks", namespace_path=["prod", "events"])
```
```typescript TypeScript icon="square-js"
await db.tableNames({ namespacePath: ["prod", "events"] });
const tbl = await db.openTable("clicks", { namespacePath: ["prod", "events"] });
```
```sql SQL
SELECT * FROM prod.events.clicks
```
</CodeGroup>
In SQL the path is written as dotted identifiers instead.
## Dropping is not undoable {#dropping}
<Warning>
`drop_table` removes the table and every version of it. Table
[versioning](/tables/versioning) does not survive the table being dropped, and
neither `drop_database` nor `drop_all_tables` asks for confirmation.
</Warning>
Dropping a **view** removes only the saved query. Dropping a **materialized
view** removes stored rows, because a materialized view is a table.
+3 -3
View File
@@ -221,13 +221,13 @@ print(table.index_stats("vector_idx")) # num_unindexed_rows should be ~0
| `LanceScan` with high `bytes_read` / `iops` | Add a missing index, project columns with `select()`, or check that the dataset has been compacted |
| Multiple sequential filters | Reorder filter conditions |
[Optimize Query Performance](/search/optimize-queries) walks through a fully worked before/after example, including how `KNNVectorDistance` and `output_batches` change once indexes are in place.
[Explain Analyze](/search/explain-analyze) walks through a fully worked before/after example, including how `KNNVectorDistance` and `output_batches` change once indexes are in place.
## Where to go next {#where-to-go-next}
<Columns cols={2}>
<Card title="Optimize Query Performance" icon="gauge" href="/search/optimize-queries">
Read execution plans, find the bottleneck.
<Card title="Explain Analyze" icon="gauge" href="/search/explain-analyze">
Execute a query and read where the time went.
</Card>
<Card title="Vector Indexing" icon="layer-group" href="/indexing/vector-index">
Index types, parameters, and tuning in depth.
@@ -1,29 +1,17 @@
---
title: "Optimize Query Performance"
sidebarTitle: Query optimization
description: "Analyze and optimize query performance in LanceDB."
title: "Explain analyze a query plan"
sidebarTitle: Explain Analyze
description: "Execute a query and read the runtime metrics attached to every operator."
icon: "gauge"
---
LanceDB provides two powerful tools for query analysis and optimization: `explain_plan` and `analyze_plan`. Let's take a better look at how they work:
`analyze_plan` executes the query and returns the physical plan annotated with
runtime metrics -- elapsed time, rows produced, bytes read, index comparisons.
Where [`explain_plan`](/search/explain) tells you what a query intends to do,
this tells you what it cost.
| Method | Purpose | Description |
| :------------- | :----------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `explain_plan` | Query Analysis | Print the resolved query plan to understand how the query will be executed. Helpful for identifying slow queries or unexpected query results. |
| `analyze_plan` | Performance Tuning | Execute the query and return a physical execution plan annotated with runtime metrics including execution time, number of rows processed, and I/O stats. Essential for performance tuning and debugging. |
## What it measures {#analyze-a-query-plan}
## Query Analysis Tools {#query-analysis-tools}
### Inspect a query plan {#inspect-a-query-plan}
Reveals the logical query plan before execution, helping you identify potential issues with query structure and index usage. This tool is useful for:
- Verifying query optimization strategies
- Validating index selection
- Understanding query execution order
- Detecting missing indices
### Analyze a query plan {#analyze-a-query-plan}
Executes the query and provides detailed runtime metrics, including:
- Operation duration (`_elapsed_compute_`)
@@ -31,126 +19,6 @@ Executes the query and provides detailed runtime metrics, including:
- Index effectiveness (`_index_comparisons_`, `_indices_loaded_`)
- Resource utilization (`_iops_`, `_requests_`)
Together, these tools offer a comprehensive view of query performance, from planning to execution. Use `explain_plan` to verify your query structure and `analyze_plan` to measure and optimize actual performance.
Metadata filters are prefiltered by default, which usually shows the filter pushed into the
`LanceScan` or index scan. If you set `prefilter=False`, expect a separate `FilterExec` after
search instead; that can be useful for some expensive filters, but it changes both latency and
the number of rows available after filtering.
## Reading the Execution Plan {#reading-the-execution-plan}
To demonstrate query performance analysis, we'll use a table containing 1.2M rows sampled from the [Wikipedia dataset](https://huggingface.co/datasets/wikimedia/wikipedia). Initially, the table has no indices, allowing us to observe the impact of optimization.
Let's examine a vector search query that:
- Filters rows where `identifier` is between 0 and 1,000,000
- Returns the top 100 matches
- Projects specific columns: `chunk_index`, `title`, and `identifier`
<CodeGroup>
```python Python icon="python"
# explain_plan
query_explain_plan = (
table.search(query_embed)
.where("identifier > 0 AND identifier < 1000000")
.select(["chunk_index", "title", "identifier"])
.limit(100)
.explain_plan(True)
)
```
```typescript TypeScript icon="square-js"
// explain_plan
const explainPlan = await table
.search(queryEmbed)
.where("identifier > 0 AND identifier < 1000000")
.select(["chunk_index", "title", "identifier"])
.limit(100)
.explainPlan(true);
```
</CodeGroup>
### Execution Plan Components {#execution-plan-components}
The execution plan reveals the sequence of operations performed to execute your query. Let's examine each component:
```
ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance]
RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title"
CoalesceBatchesExec: target_batch_size=1024
GlobalLimitExec: skip=0, fetch=100
FilterExec: _distance@3 IS NOT NULL
SortExec: TopK(fetch=100), expr=[_distance@3 ASC NULLS LAST], preserve_partitioning=[false]
KNNVectorDistance: metric=l2
FilterExec: identifier@1 > 0 AND identifier@1 < 1000000
LanceScan: uri=***, projection=[vector, identifier], row_id=true, row_addr=false, ordered=false
```
#### 1\. Base Layer (LanceScan) {#1-base-layer-lancescan}
- Initial data scan loading only specified columns to minimize I/O
- Unordered scan enabling parallel processing
```
LanceScan:
- projection=[vector, identifier]
- row_id=true, row_addr=false, ordered=false
```
#### 2\. First Filter {#2-first-filter}
- Apply requested filter on `identifier` column
- Reduces the number of vectors that need KNN computation
```
FilterExec: identifier@1 > 0 AND identifier@1 < 1000000
```
#### 3\. Vector Search {#3-vector-search}
- Computes L2 (Euclidean) distances between query vector and all vectors that passed the filter
```
KNNVectorDistance: metric=l2
```
#### 4\. Results Processing {#4-results-processing}
- Filters out null distance results
- Sorts by distance and takes top 100 results
- Processes in batches of 1024 for optimal memory usage
```
SortExec: TopK(fetch=100)
- expr=[_distance@3 ASC NULLS LAST]
- preserve_partitioning=[false]
FilterExec: _distance@3 IS NOT NULL
GlobalLimitExec: skip=0, fetch=100
CoalesceBatchesExec: target_batch_size=1024
```
#### 5\. Data Retrieval {#5-data-retrieval}
- `RemoteTake` is a key component of Lance's I/O cache
- Handles efficient data retrieval from remote storage locations
- Fetches specific rows and columns needed for the final output
- Optimizes network bandwidth by only retrieving required data
```
RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title"
```
#### 6\. Final Output {#6-final-output}
- Returns only requested columns and maintains column ordering
```python
ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance]
```
This plan demonstrates a basic search without index optimizations: it performs a full scan and filter before vector search.
## Performance Analysis {#performance-analysis}
Let's use `analyze_plan` to run the query and analyze the query performance, which will help us identify potential bottlenecks:
@@ -421,9 +289,11 @@ A well-optimized table should have `num_unindexed_rows ~ 0`.
| | Check whether the dataset has been compacted |
| Multiple sequential filters | Reorder filter conditions |
!!! note "Regular Performance Analysis"
Regularly analyze your query plans to identify and address performance bottlenecks.
The `analyze_plan` output provides detailed metrics to guide optimization efforts.
<Note>
Regularly analyze your query plans to identify and address performance
bottlenecks. The `analyze_plan` output provides detailed metrics to guide
optimization efforts.
</Note>
### 3\. Getting Started with Optimization {#3-getting-started-with-optimization}
@@ -432,52 +302,9 @@ For vector search performance:
- Create ANN index on your vector column(s) as described in the [index guide](/indexing/vector-index/)
- If you often filter by metadata, create [scalar indices](/indexing/scalar-index/) on those columns
## Analyzing non-vector queries {#analyzing-non-vector-queries}
`explain_plan` and `analyze_plan` aren't vector-specific — they're available on every query builder, including FTS and hybrid. The most common reason to look at the plan for a non-vector query is to confirm whether your `where` clause pushed into the scan (good) or ran as a separate `FilterExec` step on top of the search results (often slower, and a hint that the filter column needs a scalar index).
### FTS queries {#fts-queries}
<CodeGroup>
```python Python icon="python"
plan = (
table.search("puppy", query_type="fts")
.where("category = 'animals'", prefilter=True)
.limit(10)
.explain_plan(True)
)
print(plan)
```
```typescript TypeScript icon="square-js"
const plan = await table
.query()
.fullTextSearch("puppy")
.where("category = 'animals'")
.limit(10)
.explainPlan(true);
```
</CodeGroup>
In an indexed FTS plan you should see a `MatchQuery` (or other FTS execution node) reading from the inverted index, with the metadata filter pushed down. If the plan shows a `LanceScan` followed by `FilterExec` over the entire text column, the FTS index either isn't covering the column or the filter isn't using a scalar index — both worth investigating.
### Hybrid queries {#hybrid-queries}
For hybrid queries, `explain_plan` returns the reranker label followed by the vector and FTS sub-plans, indented for readability:
<CodeGroup>
```python Python icon="python"
plan = (
table.search("flower moon", query_type="hybrid")
.where("category = 'film'", prefilter=True)
.limit(10)
.explain_plan(True)
)
print(plan)
# RRFReranker(...)
# <vector sub-plan>
# <FTS sub-plan>
```
</CodeGroup>
`analyze_plan` does the same, but executes both sub-queries and labels them as `Vector Search Plan:` and `FTS Search Plan:` in the output. This is the easiest way to see whether the filter pushed into both halves uniformly, and which half is dominating latency.
For a hybrid query, `analyze_plan` executes both sub-queries and labels them
`Vector Search Plan:` and `FTS Search Plan:` in the output. This is the easiest
way to see whether the filter pushed into both halves uniformly, and which half
is dominating latency.
+197
View File
@@ -0,0 +1,197 @@
---
title: "Explain a query plan"
sidebarTitle: Explain
description: "Read the plan LanceDB resolved for a query, before running it."
icon: "map"
---
`explain_plan` prints the plan LanceDB resolved for a query without executing
it. It answers structural questions -- did the filter push into the scan, is the
vector index being used, which columns are actually read -- and costs nothing to
run, so it is the first thing to reach for when a query behaves unexpectedly.
To measure a query rather than read it, use
[`analyze_plan`](/search/explain-analyze), which executes it and annotates every
operator with runtime metrics.
## What it shows {#inspect-a-query-plan}
Reveals the logical query plan before execution, helping you identify potential issues with query structure and index usage. This tool is useful for:
- Verifying query optimization strategies
- Validating index selection
- Understanding query execution order
- Detecting missing indices
Metadata filters are prefiltered by default, which usually shows the filter pushed into the
`LanceScan` or index scan. If you set `prefilter=False`, expect a separate `FilterExec` after
search instead; that can be useful for some expensive filters, but it changes both latency and
the number of rows available after filtering.
## Reading the Execution Plan {#reading-the-execution-plan}
To demonstrate query performance analysis, we'll use a table containing 1.2M rows sampled from the [Wikipedia dataset](https://huggingface.co/datasets/wikimedia/wikipedia). Initially, the table has no indices, allowing us to observe the impact of optimization.
Let's examine a vector search query that:
- Filters rows where `identifier` is between 0 and 1,000,000
- Returns the top 100 matches
- Projects specific columns: `chunk_index`, `title`, and `identifier`
<CodeGroup>
```python Python icon="python"
# explain_plan
query_explain_plan = (
table.search(query_embed)
.where("identifier > 0 AND identifier < 1000000")
.select(["chunk_index", "title", "identifier"])
.limit(100)
.explain_plan(True)
)
```
```typescript TypeScript icon="square-js"
// explain_plan
const explainPlan = await table
.search(queryEmbed)
.where("identifier > 0 AND identifier < 1000000")
.select(["chunk_index", "title", "identifier"])
.limit(100)
.explainPlan(true);
```
</CodeGroup>
### Execution Plan Components {#execution-plan-components}
The execution plan reveals the sequence of operations performed to execute your query. Let's examine each component:
```
ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance]
RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title"
CoalesceBatchesExec: target_batch_size=1024
GlobalLimitExec: skip=0, fetch=100
FilterExec: _distance@3 IS NOT NULL
SortExec: TopK(fetch=100), expr=[_distance@3 ASC NULLS LAST], preserve_partitioning=[false]
KNNVectorDistance: metric=l2
FilterExec: identifier@1 > 0 AND identifier@1 < 1000000
LanceScan: uri=***, projection=[vector, identifier], row_id=true, row_addr=false, ordered=false
```
#### 1\. Base Layer (LanceScan) {#1-base-layer-lancescan}
- Initial data scan loading only specified columns to minimize I/O
- Unordered scan enabling parallel processing
```
LanceScan:
- projection=[vector, identifier]
- row_id=true, row_addr=false, ordered=false
```
#### 2\. First Filter {#2-first-filter}
- Apply requested filter on `identifier` column
- Reduces the number of vectors that need KNN computation
```
FilterExec: identifier@1 > 0 AND identifier@1 < 1000000
```
#### 3\. Vector Search {#3-vector-search}
- Computes L2 (Euclidean) distances between query vector and all vectors that passed the filter
```
KNNVectorDistance: metric=l2
```
#### 4\. Results Processing {#4-results-processing}
- Filters out null distance results
- Sorts by distance and takes top 100 results
- Processes in batches of 1024 for optimal memory usage
```
SortExec: TopK(fetch=100)
- expr=[_distance@3 ASC NULLS LAST]
- preserve_partitioning=[false]
FilterExec: _distance@3 IS NOT NULL
GlobalLimitExec: skip=0, fetch=100
CoalesceBatchesExec: target_batch_size=1024
```
#### 5\. Data Retrieval {#5-data-retrieval}
- `RemoteTake` is a key component of Lance's I/O cache
- Handles efficient data retrieval from remote storage locations
- Fetches specific rows and columns needed for the final output
- Optimizes network bandwidth by only retrieving required data
```
RemoteTake: columns="vector, identifier, _rowid, _distance, chunk_index, title"
```
#### 6\. Final Output {#6-final-output}
- Returns only requested columns and maintains column ordering
```python
ProjectionExec: expr=[chunk_index@4 as chunk_index, title@5 as title, identifier@1 as identifier, _distance@3 as _distance]
```
This plan demonstrates a basic search without index optimizations: it performs a full scan and filter before vector search.
## Non-vector queries {#analyzing-non-vector-queries}
`explain_plan` is not vector-specific -- it is available on every query builder,
including FTS and hybrid. The most common reason to read the plan for a
non-vector query is to confirm whether your `where` clause pushed into the scan
(good) or ran as a separate `FilterExec` on top of the search results (often
slower, and a hint that the filter column needs a scalar index).
### FTS queries {#fts-queries}
<CodeGroup>
```python Python icon="python"
plan = (
table.search("puppy", query_type="fts")
.where("category = 'animals'", prefilter=True)
.limit(10)
.explain_plan(True)
)
print(plan)
```
```typescript TypeScript icon="square-js"
const plan = await table
.query()
.fullTextSearch("puppy")
.where("category = 'animals'")
.limit(10)
.explainPlan(true);
```
</CodeGroup>
In an indexed FTS plan you should see a `MatchQuery` (or other FTS execution node) reading from the inverted index, with the metadata filter pushed down. If the plan shows a `LanceScan` followed by `FilterExec` over the entire text column, the FTS index either isn't covering the column or the filter isn't using a scalar index — both worth investigating.
### Hybrid queries {#hybrid-queries}
For hybrid queries, `explain_plan` returns the reranker label followed by the vector and FTS sub-plans, indented for readability:
<CodeGroup>
```python Python icon="python"
plan = (
table.search("flower moon", query_type="hybrid")
.where("category = 'film'", prefilter=True)
.limit(10)
.explain_plan(True)
)
print(plan)
# RRFReranker(...)
# <vector sub-plan>
# <FTS sub-plan>
```
</CodeGroup>
Reading the plan tells you the shape. To find out which half dominates latency,
[analyze it](/search/explain-analyze#hybrid-queries).
-561
View File
@@ -1,561 +0,0 @@
---
title: Full-Text Search Examples
sidebarTitle: FTS Examples
description: Worked examples of fuzzy search, boosting, boolean queries, and substring search with LanceDB full-text search.
icon: "book-open"
---
These worked examples build on the concepts from the [Full-Text Search guide](/search/full-text-search). They walk through creating sample tables, building FTS indices, and running fuzzy, phrase, boosted, boolean, and substring queries.
## Fuzzy Search and Boosting Example {#fuzzy-search-and-boosting-example}
### Generate Data {#generate-data}
First, let's create a table with sample text data for testing fuzzy search:
<CodeGroup>
```python Python icon="python"
import lancedb
import numpy as np
import pandas as pd
import random
# Connect to LanceDB
db = lancedb.connect(
uri="db://your-project-slug",
api_key="your-api-key",
region="us-east-1"
)
# Generate 100 rows of random "<noun> <verb> <adverb> <adjective>" text
table_name = "fts-fuzzy-boosting-test"
vectors = [np.random.randn(128) for _ in range(100)]
verbs = ("runs", "hits", "jumps", "drives", "barfs")
adv = ("crazily.", "dutifully.", "foolishly.", "merrily.", "occasionally.")
adj = ("adorable", "clueless", "dirty", "odd", "stupid")
def sentence(nouns):
return " ".join(random.choice(words) for words in (nouns, verbs, adv, adj))
text = [sentence(("puppy", "car")) for _ in range(100)]
text2 = [sentence(("rabbit", "girl", "monkey")) for _ in range(100)]
count = [random.randint(1, 10000) for _ in range(100)]
```
```typescript TypeScript icon="square-js"
import * as lancedb from "@lancedb/lancedb"
const db = await lancedb.connect({
uri: "db://your-project-slug",
apiKey: "your-api-key",
region: "us-east-1"
});
// Generate 100 rows of random "<noun> <verb> <adverb> <adjective>" text
const tableName = "fts-fuzzy-boosting-test-ts";
const n = 100;
const verbs = ["runs", "hits", "jumps", "drives", "barfs"];
const adverbs = ["crazily", "dutifully", "foolishly", "merrily", "occasionally"];
const adjectives = ["adorable", "clueless", "dirty", "odd", "stupid"];
const pick = (words: string[]) => words[Math.floor(Math.random() * words.length)];
const sentence = (nouns: string[]) =>
[nouns, verbs, adverbs, adjectives].map(pick).join(" ");
const vectors = Array.from({ length: n }, () =>
Array.from({ length: 128 }, () => Math.random() * 2 - 1)
);
const text = Array.from({ length: n }, () => sentence(["puppy", "car"]));
const text2 = Array.from({ length: n }, () => sentence(["rabbit", "girl", "monkey"]));
const count = Array.from({ length: n }, () => Math.floor(Math.random() * 10000) + 1);
```
</CodeGroup>
### Create Table {#create-table}
<CodeGroup>
```python Python icon="python"
# Create table with sample data
table = db.create_table(
table_name,
data=pd.DataFrame({
"vector": vectors,
"id": [i % 2 for i in range(100)],
"text": text,
"text2": text2,
"count": count,
}),
mode="overwrite"
)
```
```typescript TypeScript icon="square-js"
// Create table with sample data
const data = makeArrowTable(
vectors.map((vector, i) => ({
vector,
id: i % 2,
text: text[i],
text2: text2[i],
count: count[i],
}))
);
const table = await db.createTable(tableName, data, { mode: "overwrite" });
```
</CodeGroup>
### Construct FTS Index {#construct-fts-index}
Create a full-text search index on the first text column:
<CodeGroup>
```python Python icon="python"
# Create FTS index on first text column
table.create_fts_index("text")
wait_for_index(table, "text_idx")
```
```typescript TypeScript icon="square-js"
// Create FTS index on first text column
await table.createIndex("text", { config: Index.fts() });
await waitForIndex(table, "text_idx");
```
</CodeGroup>
Then, create an index on the second text column:
<CodeGroup>
```python Python icon="python"
# Create FTS index on second text column
table.create_fts_index("text2")
wait_for_index(table, "text2_idx")
```
```typescript TypeScript icon="square-js"
// Create FTS index on second text column
await table.createIndex("text2", { config: Index.fts() });
await waitForIndex(table, "text2_idx");
```
</CodeGroup>
### Basic and Fuzzy Search {#basic-and-fuzzy-search}
Now we can perform basic, fuzzy, and prefix match searches:
#### Basic Exact Search {#basic-exact-search}
<CodeGroup>
```python Python icon="python"
from lancedb.query import MatchQuery
# Basic match (exact search)
basic_match_results = (
table.search(MatchQuery("crazily", "text"))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { MatchQuery } from "@lancedb/lancedb";
// Basic match (exact search)
const basicMatchResults = await table.query()
.fullTextSearch(new MatchQuery("crazily", "text"))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
#### Fuzzy Search with Typos {#fuzzy-search-with-typos}
<CodeGroup>
```python Python icon="python"
# Fuzzy match (allows typos)
fuzzy_results = (
table.search(MatchQuery("craziou", "text", fuzziness=2))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
// Fuzzy match (allows typos)
const fuzzyResults = await table.query()
.fullTextSearch(new MatchQuery("craziou", "text", {
fuzziness: 2,
}))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
#### Prefix based Match {#prefix-based-match}
Prefix-based match allows you to search for documents containing words that start with a specific prefix.
<CodeGroup>
```python Python icon="python"
# Fuzzy match (allows typos)
fuzzy_results = (
table.search(MatchQuery("cra", "text", prefix_length=3))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
// Fuzzy match (allows typos)
const fuzzyResults = await table.query()
.fullTextSearch(new MatchQuery("cra", "text", {
prefixLength: 3,
}))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
### Phrase Match {#phrase-match}
Phrase matching enables you to search for exact sequences of words. Unlike regular text search
which matches individual terms independently, phrase matching requires words to appear in the
specified order with no intervening terms.
<Note>
Phrase queries are supported but only for a single column; providing multiple columns with a quoted phrase raises an error.
</Note>
Phrase matching is particularly useful for:
- Searching for specific multi-word expressions
- Matching exact titles or quotes
- Finding precise word combinations in a specific order
<CodeGroup>
```python Python icon="python"
# Exact phrase match
from lancedb.query import PhraseQuery
phrase_results = (
table.search(PhraseQuery("puppy runs", "text"))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { PhraseQuery } from "@lancedb/lancedb";
// Exact phrase match
const phraseResults = await table.query()
.fullTextSearch(new PhraseQuery("puppy runs", "text"))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
#### Flexible Phrase Match {#flexible-phrase-match}
To provide more flexible phrase matching, LanceDB supports the `slop` parameter. This allows you to match phrases where the terms appear close to each other, even if they are not directly adjacent or in the exact order, as long as they are within the specified `slop` value.
For example, the phrase query "puppy merrily" would not return any results by default. However, if you set `slop=1`, it will match phrases like "puppy jumps merrily", "puppy runs merrily", and similar variations where one word appears between "puppy" and "merrily".
<CodeGroup>
```python Python icon="python"
# Flexible phrase match with slop=1 for 'puppy merrily'
from lancedb.query import PhraseQuery
phrase_results = (
table.search(PhraseQuery("puppy merrily", "text", slop=1))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { PhraseQuery } from "@lancedb/lancedb";
// Flexible phrase match with slop=1 for 'puppy runs'
const phraseResults = await table.query()
.fullTextSearch(new PhraseQuery("puppy runs", "text", { slop: 1 }))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
### Search with Boosting {#search-with-boosting}
Boosting allows you to control the relative importance of different search terms or fields
in your queries. This feature is particularly useful when you need to:
* Prioritize matches in certain columns
* Promote specific terms while demoting others
* Fine-tune relevance scoring for better search results
| Parameter | Type | Default | Description |
| -------------- | ----- | -------- | ------------------------------------------------------------------ |
| positive | Query | required | The primary query terms to match and promote in results |
| negative | Query | required | Terms to demote in the search results |
| negative_boost | float | 0.5 | Multiplier for negative matches (lower values = stronger demotion) |
<CodeGroup>
```python Python icon="python"
from lancedb.query import MatchQuery, BoostQuery, MultiMatchQuery
# Boost data with 'runs' in text more than 'puppy' in text
boosting_results = (
table.search(
BoostQuery(
MatchQuery("runs", "text"),
MatchQuery("puppy", "text"),
negative_boost=0.2,
),
)
.select(["id", "text"])
.limit(100)
.to_pandas()
)
# Search across both text and text2
multi_match_results = (
table.search(MultiMatchQuery("crazily", ["text", "text2"]))
.select(["id", "text", "text2"])
.limit(100)
.to_pandas()
)
# Search with field boosting
multi_match_boosting_results = (
table.search(
MultiMatchQuery("crazily", ["text", "text2"], boosts=[1.0, 2.0]),
)
.select(["id", "text", "text2"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { MatchQuery, BoostQuery, MultiMatchQuery } from "@lancedb/lancedb";
// Boosting Example
const boostingResults = await table.query()
.fullTextSearch(new BoostQuery(new MatchQuery("runs", "text"), new MatchQuery("puppy", "text"), {
negativeBoost: 0.2,
}))
.select(["id", "text"])
.limit(100)
.toArray();
// Search across both text fields
const multiMatchResults = await table.query()
.fullTextSearch(new MultiMatchQuery("crazily", ["text", "text2"]))
.select(["id", "text", "text2"])
.limit(100)
.toArray();
// Search with field boosting
const multiMatchBoostingResults = await table.query()
.fullTextSearch(new MultiMatchQuery("crazily", ["text", "text2"], {
boosts: [1.0, 2.0],
}))
.select(["id", "text", "text2"])
.limit(100)
.toArray();
```
</CodeGroup>
<Card title="Best practices" icon="flag">
- Use fuzzy search when handling user input that may contain typos or variations
- Apply field boosting to prioritize matches in more important columns
- Combine fuzzy search with boosting for robust and precise search results
**Recommendations for optimal FTS performance:**
- Create full-text search indices on text columns that will be frequently searched
- For hybrid search combining text and vectors, see our [hybrid search guide](/search/hybrid-search/)
- For performance benchmarks, check our [benchmark results](/enterprise/benchmarks/)
- For complex queries, use SQL to combine FTS with other filter conditions
</Card>
### Boolean Queries {#boolean-queries}
LanceDB supports boolean logic in full-text search, allowing you to combine multiple queries using `and` and `or` operators. This is useful when you want to match documents that satisfy multiple conditions (intersection) or at least one of several conditions (union).
#### Combining Two Match Queries {#combining-two-match-queries}
In Python, you can combine two MatchQuery objects using either the `and` function or the `&` operator (e.g., `MatchQuery("puppy", "text") and MatchQuery("merrily", "text")`); both methods are supported and yield the same result. Similarly, you can use either the `or` function or the `|` operator to perform an or query.
In TypeScript, boolean queries are constructed using the `BooleanQuery` class with a list of [Occur, subquery] pairs. For example, to perform an AND query:
```sql SQL icon="code"
BooleanQuery([
[Occur.Must, new MatchQuery("puppy", "text")],
[Occur.Must, new MatchQuery("merrily", "text")],
])
```
This approach allows you to specify complex boolean logic by combining multiple subqueries with different Occur values (such as `Must`, `Should`, or `MustNot`).
<Info>
**Which queries are allowed?**
A boolean query must include at least one `SHOULD` or `MUST` clause. Queries that contain only a `MUST_NOT` clause are not allowed.
</Info>
<CodeGroup>
```python Python icon="python"
from lancedb.query import MatchQuery
# Example: Find documents containing both "puppy" and "merrily"
and_query = MatchQuery("puppy", "text") & MatchQuery("merrily", "text")
and_results = (
table.search(and_query)
.select(["id", "text"])
.limit(100)
.to_pandas()
)
# Example: Find documents containing either "puppy" or "merrily"
or_query = MatchQuery("puppy", "text") | MatchQuery("merrily", "text")
or_results = (
table.search(or_query)
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js" expandable=true
import { MatchQuery, BooleanQuery, Occur } from "@lancedb/lancedb";
// Flexible boolean queries with MatchQuery
// Find documents containing both "puppy" and "merrily"
const mustResults = await table
.search(
new BooleanQuery([
[Occur.Must, new MatchQuery("puppy", "text")],
[Occur.Must, new MatchQuery("merrily", "text")],
]),
)
.select(["id", "text"])
.limit(100)
.toArray();
// Find documents containing either "puppy" or "merrily"
const shouldResults = await table
.search(
new BooleanQuery([
[Occur.Should, new MatchQuery("puppy", "text")],
[Occur.Should, new MatchQuery("merrily", "text")],
]),
)
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
<Info>
**How to use booleans?**
- Use `and`/`&`(Python), `Occur.Must`(Typescript) for intersection (documents must match all queries).
- Use `or`/`|`(Python), `Occur.Should`(Typescript) for union (documents must match at least one query).
</Info>
## Substring Search Example {#substring-search-example}
LanceDB supports searching for substrings in text columns using n-gram tokenization. This is useful for finding partial matches within text content.
### Setting Up the Table {#setting-up-the-table}
First, create a table with sample text data and configure n-gram tokenization:
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
import lancedb
db = lancedb.connect(":memory:")
data = pa.table({"text": ["hello world", "lance database", "lance is cool"]})
table = db.create_table("test", data=data)
table.create_fts_index("text", base_tokenizer="ngram")
```
</CodeGroup>
### Basic Substring Search {#basic-substring-search}
With the default n-gram settings (minimum length of 3), you can search for substrings of length 3 or more:
<CodeGroup>
```python Python icon="python"
results = table.search("lan", query_type="fts").limit(10).to_list()
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
results = (
table.search("nce", query_type="fts").limit(10).to_list()
) # spellchecker:disable-line
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
```
</CodeGroup>
### Handling Short Substrings {#handling-short-substrings}
By default, the minimum n-gram length is 3, so shorter substrings like "la" won't match:
<CodeGroup>
```python Python icon="python"
results = table.search("la", query_type="fts").limit(10).to_list()
assert len(results) == 0
```
</CodeGroup>
### Customizing N-gram Parameters {#customizing-n-gram-parameters}
You can customize the n-gram behavior by adjusting the minimum length and using prefix-only matching:
<CodeGroup>
```python Python icon="python"
table.create_fts_index(
"text",
base_tokenizer="ngram",
replace=True,
ngram_min_length=2,
prefix_only=True,
)
```
</CodeGroup>
### Testing Custom N-gram Settings {#testing-custom-n-gram-settings}
With the new settings, you can now search for shorter substrings and use prefix-only matching:
<CodeGroup>
```python Python icon="python"
results = table.search("lan", query_type="fts").limit(10).to_list()
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
results = (
table.search("nce", query_type="fts").limit(10).to_list()
) # spellchecker:disable-line
assert len(results) == 0
results = table.search("la", query_type="fts").limit(10).to_list()
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
```
</CodeGroup>
+552 -3
View File
@@ -324,7 +324,7 @@ to find similar terms within a specified edit distance.
| fuzziness | int | 0 | Maximum edit distance allowed for each term. If not specified, automatically set based on term length: 0 for length ≤ 2, 1 for length ≤ 5, 2 for length > 5 |
| max_expansions | int | 50 | Maximum number of terms to consider for fuzzy matching. Higher values may improve recall but increase search time |
For a complete walkthrough that creates a sample table and demonstrates fuzzy search and relevance boosting, see the [fuzzy search example](/search/fts-examples#fuzzy-search-and-boosting-example).
For a complete walkthrough that creates a sample table and demonstrates fuzzy search and relevance boosting, see [the worked example below](#fuzzy-search-and-boosting-example).
### Search for Substring {#search-for-substring}
@@ -337,9 +337,558 @@ LanceDB supports searching for substrings in the text column, you can set the `b
| prefix_only | bool | false | Whether to only search for prefixes of the n-grams |
## More Examples {#more-examples}
## Fuzzy Search and Boosting Example {#fuzzy-search-and-boosting-example}
For complete worked examples of fuzzy search, prefix matching, phrase matching, boosting, boolean queries, and substring search — including sample data generation and index setup — see [Full-Text Search Examples](/search/fts-examples).
### Generate Data {#generate-data}
First, let's create a table with sample text data for testing fuzzy search:
<CodeGroup>
```python Python icon="python"
import lancedb
import numpy as np
import pandas as pd
import random
# Connect to LanceDB
db = lancedb.connect(
uri="db://your-project-slug",
api_key="your-api-key",
region="us-east-1"
)
# Generate 100 rows of random "<noun> <verb> <adverb> <adjective>" text
table_name = "fts-fuzzy-boosting-test"
vectors = [np.random.randn(128) for _ in range(100)]
verbs = ("runs", "hits", "jumps", "drives", "barfs")
adv = ("crazily.", "dutifully.", "foolishly.", "merrily.", "occasionally.")
adj = ("adorable", "clueless", "dirty", "odd", "stupid")
def sentence(nouns):
return " ".join(random.choice(words) for words in (nouns, verbs, adv, adj))
text = [sentence(("puppy", "car")) for _ in range(100)]
text2 = [sentence(("rabbit", "girl", "monkey")) for _ in range(100)]
count = [random.randint(1, 10000) for _ in range(100)]
```
```typescript TypeScript icon="square-js"
import * as lancedb from "@lancedb/lancedb"
const db = await lancedb.connect({
uri: "db://your-project-slug",
apiKey: "your-api-key",
region: "us-east-1"
});
// Generate 100 rows of random "<noun> <verb> <adverb> <adjective>" text
const tableName = "fts-fuzzy-boosting-test-ts";
const n = 100;
const verbs = ["runs", "hits", "jumps", "drives", "barfs"];
const adverbs = ["crazily", "dutifully", "foolishly", "merrily", "occasionally"];
const adjectives = ["adorable", "clueless", "dirty", "odd", "stupid"];
const pick = (words: string[]) => words[Math.floor(Math.random() * words.length)];
const sentence = (nouns: string[]) =>
[nouns, verbs, adverbs, adjectives].map(pick).join(" ");
const vectors = Array.from({ length: n }, () =>
Array.from({ length: 128 }, () => Math.random() * 2 - 1)
);
const text = Array.from({ length: n }, () => sentence(["puppy", "car"]));
const text2 = Array.from({ length: n }, () => sentence(["rabbit", "girl", "monkey"]));
const count = Array.from({ length: n }, () => Math.floor(Math.random() * 10000) + 1);
```
</CodeGroup>
### Create Table {#create-table}
<CodeGroup>
```python Python icon="python"
# Create table with sample data
table = db.create_table(
table_name,
data=pd.DataFrame({
"vector": vectors,
"id": [i % 2 for i in range(100)],
"text": text,
"text2": text2,
"count": count,
}),
mode="overwrite"
)
```
```typescript TypeScript icon="square-js"
// Create table with sample data
const data = makeArrowTable(
vectors.map((vector, i) => ({
vector,
id: i % 2,
text: text[i],
text2: text2[i],
count: count[i],
}))
);
const table = await db.createTable(tableName, data, { mode: "overwrite" });
```
</CodeGroup>
### Construct the index for these examples {#example-construct-fts-index}
Create a full-text search index on the first text column:
<CodeGroup>
```python Python icon="python"
# Create FTS index on first text column
table.create_fts_index("text")
wait_for_index(table, "text_idx")
```
```typescript TypeScript icon="square-js"
// Create FTS index on first text column
await table.createIndex("text", { config: Index.fts() });
await waitForIndex(table, "text_idx");
```
</CodeGroup>
Then, create an index on the second text column:
<CodeGroup>
```python Python icon="python"
# Create FTS index on second text column
table.create_fts_index("text2")
wait_for_index(table, "text2_idx")
```
```typescript TypeScript icon="square-js"
// Create FTS index on second text column
await table.createIndex("text2", { config: Index.fts() });
await waitForIndex(table, "text2_idx");
```
</CodeGroup>
### Basic and Fuzzy Search {#basic-and-fuzzy-search}
Now we can perform basic, fuzzy, and prefix match searches:
#### Basic Exact Search {#basic-exact-search}
<CodeGroup>
```python Python icon="python"
from lancedb.query import MatchQuery
# Basic match (exact search)
basic_match_results = (
table.search(MatchQuery("crazily", "text"))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { MatchQuery } from "@lancedb/lancedb";
// Basic match (exact search)
const basicMatchResults = await table.query()
.fullTextSearch(new MatchQuery("crazily", "text"))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
#### Fuzzy Search with Typos {#fuzzy-search-with-typos}
<CodeGroup>
```python Python icon="python"
# Fuzzy match (allows typos)
fuzzy_results = (
table.search(MatchQuery("craziou", "text", fuzziness=2))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
// Fuzzy match (allows typos)
const fuzzyResults = await table.query()
.fullTextSearch(new MatchQuery("craziou", "text", {
fuzziness: 2,
}))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
#### Prefix based Match {#prefix-based-match}
Prefix-based match allows you to search for documents containing words that start with a specific prefix.
<CodeGroup>
```python Python icon="python"
# Fuzzy match (allows typos)
fuzzy_results = (
table.search(MatchQuery("cra", "text", prefix_length=3))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
// Fuzzy match (allows typos)
const fuzzyResults = await table.query()
.fullTextSearch(new MatchQuery("cra", "text", {
prefixLength: 3,
}))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
### Phrase Match {#phrase-match}
Phrase matching enables you to search for exact sequences of words. Unlike regular text search
which matches individual terms independently, phrase matching requires words to appear in the
specified order with no intervening terms.
<Note>
Phrase queries are supported but only for a single column; providing multiple columns with a quoted phrase raises an error.
</Note>
Phrase matching is particularly useful for:
- Searching for specific multi-word expressions
- Matching exact titles or quotes
- Finding precise word combinations in a specific order
<CodeGroup>
```python Python icon="python"
# Exact phrase match
from lancedb.query import PhraseQuery
phrase_results = (
table.search(PhraseQuery("puppy runs", "text"))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { PhraseQuery } from "@lancedb/lancedb";
// Exact phrase match
const phraseResults = await table.query()
.fullTextSearch(new PhraseQuery("puppy runs", "text"))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
#### Flexible Phrase Match {#flexible-phrase-match}
To provide more flexible phrase matching, LanceDB supports the `slop` parameter. This allows you to match phrases where the terms appear close to each other, even if they are not directly adjacent or in the exact order, as long as they are within the specified `slop` value.
For example, the phrase query "puppy merrily" would not return any results by default. However, if you set `slop=1`, it will match phrases like "puppy jumps merrily", "puppy runs merrily", and similar variations where one word appears between "puppy" and "merrily".
<CodeGroup>
```python Python icon="python"
# Flexible phrase match with slop=1 for 'puppy merrily'
from lancedb.query import PhraseQuery
phrase_results = (
table.search(PhraseQuery("puppy merrily", "text", slop=1))
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { PhraseQuery } from "@lancedb/lancedb";
// Flexible phrase match with slop=1 for 'puppy runs'
const phraseResults = await table.query()
.fullTextSearch(new PhraseQuery("puppy runs", "text", { slop: 1 }))
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
### Search with Boosting {#search-with-boosting}
Boosting allows you to control the relative importance of different search terms or fields
in your queries. This feature is particularly useful when you need to:
* Prioritize matches in certain columns
* Promote specific terms while demoting others
* Fine-tune relevance scoring for better search results
| Parameter | Type | Default | Description |
| -------------- | ----- | -------- | ------------------------------------------------------------------ |
| positive | Query | required | The primary query terms to match and promote in results |
| negative | Query | required | Terms to demote in the search results |
| negative_boost | float | 0.5 | Multiplier for negative matches (lower values = stronger demotion) |
<CodeGroup>
```python Python icon="python"
from lancedb.query import MatchQuery, BoostQuery, MultiMatchQuery
# Boost data with 'runs' in text more than 'puppy' in text
boosting_results = (
table.search(
BoostQuery(
MatchQuery("runs", "text"),
MatchQuery("puppy", "text"),
negative_boost=0.2,
),
)
.select(["id", "text"])
.limit(100)
.to_pandas()
)
# Search across both text and text2
multi_match_results = (
table.search(MultiMatchQuery("crazily", ["text", "text2"]))
.select(["id", "text", "text2"])
.limit(100)
.to_pandas()
)
# Search with field boosting
multi_match_boosting_results = (
table.search(
MultiMatchQuery("crazily", ["text", "text2"], boosts=[1.0, 2.0]),
)
.select(["id", "text", "text2"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js"
import { MatchQuery, BoostQuery, MultiMatchQuery } from "@lancedb/lancedb";
// Boosting Example
const boostingResults = await table.query()
.fullTextSearch(new BoostQuery(new MatchQuery("runs", "text"), new MatchQuery("puppy", "text"), {
negativeBoost: 0.2,
}))
.select(["id", "text"])
.limit(100)
.toArray();
// Search across both text fields
const multiMatchResults = await table.query()
.fullTextSearch(new MultiMatchQuery("crazily", ["text", "text2"]))
.select(["id", "text", "text2"])
.limit(100)
.toArray();
// Search with field boosting
const multiMatchBoostingResults = await table.query()
.fullTextSearch(new MultiMatchQuery("crazily", ["text", "text2"], {
boosts: [1.0, 2.0],
}))
.select(["id", "text", "text2"])
.limit(100)
.toArray();
```
</CodeGroup>
<Card title="Best practices" icon="flag">
- Use fuzzy search when handling user input that may contain typos or variations
- Apply field boosting to prioritize matches in more important columns
- Combine fuzzy search with boosting for robust and precise search results
**Recommendations for optimal FTS performance:**
- Create full-text search indices on text columns that will be frequently searched
- For hybrid search combining text and vectors, see our [hybrid search guide](/search/hybrid-search/)
- For performance benchmarks, check our [benchmark results](/enterprise/benchmarks/)
- For complex queries, use SQL to combine FTS with other filter conditions
</Card>
### Boolean Queries {#boolean-queries}
LanceDB supports boolean logic in full-text search, allowing you to combine multiple queries using `and` and `or` operators. This is useful when you want to match documents that satisfy multiple conditions (intersection) or at least one of several conditions (union).
#### Combining Two Match Queries {#combining-two-match-queries}
In Python, you can combine two MatchQuery objects using either the `and` function or the `&` operator (e.g., `MatchQuery("puppy", "text") and MatchQuery("merrily", "text")`); both methods are supported and yield the same result. Similarly, you can use either the `or` function or the `|` operator to perform an or query.
In TypeScript, boolean queries are constructed using the `BooleanQuery` class with a list of [Occur, subquery] pairs. For example, to perform an AND query:
```sql SQL icon="code"
BooleanQuery([
[Occur.Must, new MatchQuery("puppy", "text")],
[Occur.Must, new MatchQuery("merrily", "text")],
])
```
This approach allows you to specify complex boolean logic by combining multiple subqueries with different Occur values (such as `Must`, `Should`, or `MustNot`).
<Info>
**Which queries are allowed?**
A boolean query must include at least one `SHOULD` or `MUST` clause. Queries that contain only a `MUST_NOT` clause are not allowed.
</Info>
<CodeGroup>
```python Python icon="python"
from lancedb.query import MatchQuery
# Example: Find documents containing both "puppy" and "merrily"
and_query = MatchQuery("puppy", "text") & MatchQuery("merrily", "text")
and_results = (
table.search(and_query)
.select(["id", "text"])
.limit(100)
.to_pandas()
)
# Example: Find documents containing either "puppy" or "merrily"
or_query = MatchQuery("puppy", "text") | MatchQuery("merrily", "text")
or_results = (
table.search(or_query)
.select(["id", "text"])
.limit(100)
.to_pandas()
)
```
```typescript TypeScript icon="square-js" expandable=true
import { MatchQuery, BooleanQuery, Occur } from "@lancedb/lancedb";
// Flexible boolean queries with MatchQuery
// Find documents containing both "puppy" and "merrily"
const mustResults = await table
.search(
new BooleanQuery([
[Occur.Must, new MatchQuery("puppy", "text")],
[Occur.Must, new MatchQuery("merrily", "text")],
]),
)
.select(["id", "text"])
.limit(100)
.toArray();
// Find documents containing either "puppy" or "merrily"
const shouldResults = await table
.search(
new BooleanQuery([
[Occur.Should, new MatchQuery("puppy", "text")],
[Occur.Should, new MatchQuery("merrily", "text")],
]),
)
.select(["id", "text"])
.limit(100)
.toArray();
```
</CodeGroup>
<Info>
**How to use booleans?**
- Use `and`/`&`(Python), `Occur.Must`(Typescript) for intersection (documents must match all queries).
- Use `or`/`|`(Python), `Occur.Should`(Typescript) for union (documents must match at least one query).
</Info>
## Substring Search Example {#substring-search-example}
LanceDB supports searching for substrings in text columns using n-gram tokenization. This is useful for finding partial matches within text content.
### Setting Up the Table {#setting-up-the-table}
First, create a table with sample text data and configure n-gram tokenization:
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
import lancedb
db = lancedb.connect(":memory:")
data = pa.table({"text": ["hello world", "lance database", "lance is cool"]})
table = db.create_table("test", data=data)
table.create_fts_index("text", base_tokenizer="ngram")
```
</CodeGroup>
### Basic Substring Search {#basic-substring-search}
With the default n-gram settings (minimum length of 3), you can search for substrings of length 3 or more:
<CodeGroup>
```python Python icon="python"
results = table.search("lan", query_type="fts").limit(10).to_list()
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
results = (
table.search("nce", query_type="fts").limit(10).to_list()
) # spellchecker:disable-line
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
```
</CodeGroup>
### Handling Short Substrings {#handling-short-substrings}
By default, the minimum n-gram length is 3, so shorter substrings like "la" won't match:
<CodeGroup>
```python Python icon="python"
results = table.search("la", query_type="fts").limit(10).to_list()
assert len(results) == 0
```
</CodeGroup>
### Customizing N-gram Parameters {#customizing-n-gram-parameters}
You can customize the n-gram behavior by adjusting the minimum length and using prefix-only matching:
<CodeGroup>
```python Python icon="python"
table.create_fts_index(
"text",
base_tokenizer="ngram",
replace=True,
ngram_min_length=2,
prefix_only=True,
)
```
</CodeGroup>
### Testing Custom N-gram Settings {#testing-custom-n-gram-settings}
With the new settings, you can now search for shorter substrings and use prefix-only matching:
<CodeGroup>
```python Python icon="python"
results = table.search("lan", query_type="fts").limit(10).to_list()
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
results = (
table.search("nce", query_type="fts").limit(10).to_list()
) # spellchecker:disable-line
assert len(results) == 0
results = table.search("la", query_type="fts").limit(10).to_list()
assert len(results) == 2
assert set(r["text"] for r in results) == {"lance database", "lance is cool"}
```
</CodeGroup>
## Full-Text Search on Array Fields {#full-text-search-on-array-fields}
+1 -1
View File
@@ -341,7 +341,7 @@ await table.query()
```
</CodeGroup>
The choice gets baked into both sub-queries, so the vector and FTS halves see the filter applied the same way. Use [`explain_plan`](/search/optimize-queries#analyzing-non-vector-queries) on a hybrid query to see whether the filter pushed into the scan or ran as a separate `FilterExec` step.
The choice gets baked into both sub-queries, so the vector and FTS halves see the filter applied the same way. Use [`explain_plan`](/search/explain#analyzing-non-vector-queries) on a hybrid query to see whether the filter pushed into the scan or ran as a separate `FilterExec` step.
## More on Reranking {#more-on-reranking}
+176
View File
@@ -0,0 +1,176 @@
---
title: "SELECT syntax"
sidebarTitle: "SELECT"
description: "The analytical query surface: projection, filtering, grouping, joins, and how each maps onto the SDKs."
icon: table-columns
keywords: ["select", "sql", "join", "group by", "window", "cte", "datafusion"]
---
<Badge color="red">Enterprise</Badge> SQL runs on a deployment. A local database
has no engine to plan a statement -- see
[Python, TypeScript, and SQL](/basics/apis).
The dialect is [DataFusion's](/sql/functions), so `SELECT` behaves as its
[reference](https://datafusion.apache.org/user-guide/sql/select.html) documents.
This page is the shape of that surface and where the SDKs meet it.
## The clauses {#clauses}
```sql
[WITH cte AS (...)]
SELECT [ALL | DISTINCT] select_expr [, ...]
FROM from_item [[JOIN ...] ON ...]
[WHERE predicate]
[GROUP BY expr [, ...]]
[HAVING predicate]
[WINDOW name AS (...)]
[ORDER BY expr [ASC | DESC] [NULLS FIRST | LAST] [, ...]]
[LIMIT n] [OFFSET n]
```
Set operations -- `UNION [ALL]`, `INTERSECT`, `EXCEPT` -- combine whole queries,
and subqueries are allowed anywhere an expression or relation is.
## Projection {#projection}
<CodeGroup>
```sql SQL
SELECT id, title, identifier FROM documents
```
```python Python icon="python"
tbl.search().select(["id", "title", "identifier"]).to_arrow()
```
```typescript TypeScript icon="square-js"
await tbl.query().select(["id", "title", "identifier"]).toArrow();
```
</CodeGroup>
Projection pushes into the scan in all three -- an unselected column is never
read off storage. On a table of images or video that is the difference between a
fast query and a slow one, so prefer naming columns over `SELECT *`.
Row metadata is available as [pseudo-columns](/sql/functions#pseudo-columns)
(`_rowid`, `_rowaddr`, `_rowoffset`) but only when named, so they stay out of
`SELECT *`.
## Filtering {#filtering}
<CodeGroup>
```sql SQL
SELECT * FROM documents
WHERE identifier > 0 AND identifier < 1000000
```
```python Python icon="python"
tbl.search().where("identifier > 0 AND identifier < 1000000").to_arrow()
```
```typescript TypeScript icon="square-js"
await tbl.query().where("identifier > 0 AND identifier < 1000000").toArrow();
```
</CodeGroup>
The SDK `where` takes the same expression language, so a predicate moves between
them unchanged. A predicate on an indexed column becomes a
`ScalarIndexQuery` rather than a scan -- confirm with
[`explain_plan`](/search/explain). See [Filtering](/search/filtering) for the
operators and their pushdown behaviour.
## Grouping and aggregation {#grouping}
```sql
SELECT category, count(*) AS n, avg(score) AS mean_score
FROM documents
GROUP BY category
HAVING count(*) > 10
ORDER BY n DESC
```
Aggregates, statistical and approximate functions come from DataFusion's
[aggregate function set](https://datafusion.apache.org/user-guide/sql/aggregate_functions.html).
<Note>
The SDK query builders do not aggregate. Grouping, joins, and window functions
are the reason to reach for SQL rather than a query builder -- see
[choosing between them](/basics/apis#choosing).
</Note>
## Joins {#joins}
```sql
SELECT d.title, c.name
FROM documents AS d
JOIN categories AS c ON c.id = d.category_id
```
`INNER`, `LEFT`, `RIGHT`, `FULL`, and `CROSS` joins are supported. Tables in
different [namespaces](/namespaces) are joined by their dotted paths:
```sql
SELECT * FROM prod.events.clicks AS c
JOIN prod.reference.users AS u ON u.id = c.user_id
```
## Window functions {#windows}
```sql
SELECT
title,
score,
row_number() OVER (PARTITION BY category ORDER BY score DESC) AS rank
FROM documents
```
The full set is DataFusion's
[window functions](https://datafusion.apache.org/user-guide/sql/window_functions.html).
## Common table expressions {#ctes}
```sql
WITH recent AS (
SELECT * FROM documents WHERE created_at > now() - INTERVAL '7 days'
)
SELECT category, count(*) FROM recent GROUP BY category
```
A CTE that you want to keep is a [view](/management/view); one whose rows you
want stored is a
[materialized view](/feature-engineering/materialized-views).
## Search inside SELECT {#search}
Full-text search is a **table function**, not a predicate, because it produces
relevance alongside the rows and `WHERE` has nowhere to put that:
```sql
SELECT id, text, _score
FROM fts('my_docs', 'puppy')
ORDER BY _score DESC
LIMIT 5
```
Because it is a relation it composes -- join it, aggregate over it, wrap it in a
CTE. For a keyword test that needs no score, the
[`contains_tokens`](/sql/functions#contains-tokens) predicate is
index-accelerated and usable directly in `WHERE`.
<Note>
Vector search has no SQL form. It is a query-builder operation -- see
[Vector search](/search/vector-search). A query that needs both vector
similarity and SQL analytics runs the search in the SDK and the analytics over
its result.
</Note>
## Reading the plan {#plans}
`EXPLAIN` and `EXPLAIN ANALYZE` work as DataFusion defines them, and produce the
same operators the SDK methods do:
```sql
EXPLAIN ANALYZE SELECT * FROM documents WHERE identifier < 1000
```
See [Explain](/search/explain) and [Explain Analyze](/search/explain-analyze)
for how to read the output.
@@ -1,12 +0,0 @@
{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */}
export const PyCondaClusterInline = "from geneva.cluster.builder import KubeRayClusterBuilder\n\ncluster = (\n KubeRayClusterBuilder.create(\"my-cluster\")\n .ray_init_kwargs({\n \"runtime_env\": {\n \"conda\": {\n \"channels\": [\"conda-forge\"],\n \"dependencies\": [\n \"python=3.10\",\n \"ffmpeg<8\",\n \"torchvision=0.22.1\",\n ],\n },\n \"config\": {\"eager_install\": True},\n }\n })\n .build()\n)\n";
export const PyCondaClusterPath = "from geneva.cluster.builder import KubeRayClusterBuilder\n\ncluster = (\n KubeRayClusterBuilder.create(\"my-cluster\")\n .ray_init_kwargs({\n \"runtime_env\": {\"conda\": \"environment.yml\"}\n })\n .build()\n)\n";
export const PyEnvVarsViaCluster = "from geneva.cluster.builder import KubeRayClusterBuilder\nimport os\n\ncluster = (\n KubeRayClusterBuilder.create(\"my-cluster\")\n .ray_init_kwargs({\n \"runtime_env\": {\n \"env_vars\": {\n \"AWS_ACCESS_KEY_ID\": os.environ[\"AWS_ACCESS_KEY_ID\"],\n \"AWS_SECRET_ACCESS_KEY\": os.environ[\"AWS_SECRET_ACCESS_KEY\"],\n }\n }\n })\n .build()\n)\n";
export const PyPipManifest = "import geneva\nfrom geneva.manifest.builder import PipManifestBuilder\n\nmanifest = (\n PipManifestBuilder.create(\"my-manifest\")\n .pip([\n \"numpy==1.26.4\",\n \"torch==2.0.1\",\n \"attrs==23.2.0\",\n ])\n .build()\n)\n\nconn = geneva.connect(\"s3://my-bucket/my-db\")\nconn.define_manifest(\"my-manifest\", manifest)\nwith conn.context(cluster=\"my-cluster\", manifest=\"my-manifest\"):\n conn.open_table(\"my-table\").backfill(\"my-column\")\n";
export const PyQuickFixManifest = "from geneva.manifest.builder import PipManifestBuilder\n\nmanifest = PipManifestBuilder.create(\"fix\").pip([\"numpy==1.26.4\"]).build()\n";
@@ -1,22 +0,0 @@
{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */}
export const PyBoundedCache = "from functools import lru_cache\n\nclass GoodEmbedding:\n def __init__(self):\n self._embed = None\n\n def setup(self):\n model = load_model()\n self._embed = lru_cache(maxsize=1024)(model.embed)\n\n def __call__(self, text: str) -> list[float]:\n if self._embed is None:\n self.setup()\n return self._embed(text)\n";
export const PyConfidenceCheck = "def __call__(self, x):\n scratch = bytearray(8 * 1024 * 1024) # 8 MiB\n self._scratches.append(scratch) # <-- deliberate leak\n return ...\n";
export const PyLeakyAggregator = "class BadAggregator:\n def __init__(self):\n self.history = []\n\n def __call__(self, batch: pa.RecordBatch) -> pa.Array:\n self.history.append(batch) # holds every batch ever processed\n ...\n";
export const PyLeakyCache = "class BadEmbedding:\n def __init__(self):\n self.cache: dict[str, list[float]] = {}\n\n def __call__(self, text: str) -> list[float]:\n if text not in self.cache:\n self.cache[text] = self.model.embed(text)\n return self.cache[text]\n";
export const PyLeakyClosure = "class BadDeferred:\n def __init__(self):\n self.work_queue = []\n\n def __call__(self, x: pa.Array) -> pa.Array:\n # Lambda captures `x` by reference — the whole Array stays alive\n self.work_queue.append(lambda: expensive(x))\n ...\n";
export const PyLogMemory = "import resource, pyarrow as pa\n\ndef log_memory(seq: int) -> None:\n rss_bytes = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss\n # ru_maxrss is bytes on macOS, KiB on Linux:\n import sys\n if sys.platform != \"darwin\":\n rss_bytes *= 1024\n arrow_live = pa.total_allocated_bytes()\n print(\n f\"seq={seq} \"\n f\"rss_mb={rss_bytes // 1024**2} \"\n f\"arrow_live_mb={arrow_live // 1024**2} \"\n f\"gap_mb={(rss_bytes - arrow_live) // 1024**2}\",\n flush=True,\n )\n";
export const PyMemrayTrackerUdf = "import os, pathlib, uuid\nfrom typing import Any\nimport memray\nimport geneva\nimport pyarrow as pa\n\n_MEMRAY_OUT_DIR_ENV = \"MY_UDF_MEMRAY_OUT_DIR\"\n\n\n@geneva.udf(data_type=pa.list_(pa.float32(), 512))\nclass MyEmbedding:\n def __init__(self):\n self.model = None\n self._tracker: Any = None # memray.Tracker, when profiling is on\n\n def setup(self):\n # Open a memray tracker per worker process, if requested. Each\n # worker writes its own .bin file so traces don't collide.\n out_dir = os.environ.get(_MEMRAY_OUT_DIR_ENV)\n if out_dir:\n pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)\n bin_path = pathlib.Path(out_dir) / (\n f\"memray-{os.getpid()}-{uuid.uuid4().hex}.bin\"\n )\n self._tracker = memray.Tracker(\n str(bin_path), native_traces=False, follow_fork=False\n )\n self._tracker.__enter__()\n self.model = load_model()\n\n def __call__(self, text: str) -> list[float]:\n if self.model is None:\n self.setup()\n return self.model.embed(text)\n";
export const PyRayClusterProfile = "from geneva.runners.ray._mgr import ray_cluster\n\nwith ray_cluster(\n local=True,\n extra_env={\"MY_UDF_MEMRAY_OUT_DIR\": \"/tmp/my-udf-profile\"},\n):\n table.backfill(\"embedding\", concurrency=1)\n";
export const PyStatefulUdfClass = "@geneva.udf(data_type=pa.list_(pa.float32(), 512))\nclass MyEmbedding:\n def __init__(self):\n self.model = None\n\n def setup(self):\n self.model = load_model() # allocated once per actor\n\n def __call__(self, text: str) -> list[float]:\n if self.model is None:\n self.setup()\n return self.model.embed(text)\n";
export const PyTorchInferenceMode = "def __call__(self, text: str) -> list[float]:\n with torch.inference_mode(): # <-- prevents autograd graph retention\n return self.model.encode(text)\n";
-20
View File
@@ -1,20 +0,0 @@
{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */}
export const PyAddColumnsScalarUdtf = "@udf(data_type=pa.list_(pa.float32(), 512))\ndef clip_embedding(clip_bytes: bytes) -> list[float]:\n return embed_model.encode(clip_bytes)\n\n# Add an embedding column to the clips table\nclips.add_columns({\"embedding\": clip_embedding})\n\n# Backfill computes embeddings for all existing clips\nclips.backfill(\"embedding\")\n";
export const PyChainingUdtfViews = "# videos → clips (1:N)\nclips = db.create_udtf_view(\n \"clips\", source=videos.search(None), udtf=extract_clips\n)\n\n# clips → frames (1:N)\nframes = db.create_udtf_view(\n \"frames\", source=clips.search(None), udtf=extract_frames\n)\n";
export const PyCreateScalarUdtfView = "import geneva\n\ndb = geneva.connect(\"/data/mydb\")\nvideos = db.open_table(\"videos\")\n\n# Create the 1:N materialized view\nclips = db.create_udtf_view(\n \"clips\",\n source=videos.search(None).select([\"video_path\", \"metadata\"]),\n udtf=extract_clips,\n)\n\n# Populate — runs the UDTF on every source row\nclips.refresh()\n";
export const PyDocumentChunkingFull = "from geneva import connect, chunker, udf\nfrom typing import Iterator, NamedTuple\nimport pyarrow as pa\n\nclass Chunk(NamedTuple):\n chunk_index: int\n chunk_text: str\n\n@chunker\ndef chunk_document(text: str) -> Iterator[Chunk]:\n \"\"\"Split a document into overlapping chunks.\"\"\"\n words = text.split()\n chunk_size = 500\n overlap = 50\n for i, start in enumerate(range(0, len(words), chunk_size - overlap)):\n chunk_words = words[start:start + chunk_size]\n yield Chunk(chunk_index=i, chunk_text=\" \".join(chunk_words))\n\ndb = connect(\"/data/mydb\")\ndocs = db.open_table(\"documents\")\n\n# Create chunked view — inherits doc_id, title, etc. from source\nchunks = db.create_udtf_view(\n \"doc_chunks\",\n source=docs.search(None).select([\"doc_id\", \"title\", \"text\"]),\n udtf=chunk_document,\n)\nchunks.refresh()\n\n# Add embeddings to chunks for semantic search\n@udf(data_type=pa.list_(pa.float32(), 1536))\ndef embed_text(chunk_text: str) -> list[float]:\n return embedding_model.encode(chunk_text)\n\nchunks.add_columns({\"embedding\": embed_text})\nchunks.backfill(\"embedding\") # Backfills embeddings on all existing chunks\n\n# Query — parent columns available alongside chunk columns\nchunks.search(None).select([\"doc_id\", \"title\", \"chunk_text\", \"embedding\"]).to_pandas()\n";
export const PyDocumentChunkingUdtf = "from geneva import chunker\nfrom typing import Iterator, NamedTuple\n\nclass Chunk(NamedTuple):\n chunk_index: int\n chunk_text: str\n\n@chunker\ndef chunk_document(text: str) -> Iterator[Chunk]:\n \"\"\"Split a document into overlapping chunks.\"\"\"\n words = text.split()\n chunk_size = 500\n overlap = 50\n for i, start in enumerate(range(0, len(words), chunk_size - overlap)):\n chunk_words = words[start:start + chunk_size]\n yield Chunk(chunk_index=i, chunk_text=\" \".join(chunk_words))\n";
export const PyIncrementalRefresh = "# Add new videos to the source table\nvideos.add(new_video_data)\n\n# Incremental refresh — only processes the new videos\nclips.refresh()\n";
export const PyScalarUdtfBatch = "@chunker(batch=True, output_schema=clip_schema)\ndef extract_clips(batch: pa.RecordBatch) -> pa.RecordBatch:\n \"\"\"Process rows in batches. Same 1:N semantic per row.\"\"\"\n ...\n";
export const PyScalarUdtfIterator = "from geneva import chunker\nfrom typing import Iterator, NamedTuple\n\nclass Clip(NamedTuple):\n clip_start: float\n clip_end: float\n clip_bytes: bytes\n\n@chunker\ndef extract_clips(video_path: str, duration: float) -> Iterator[Clip]:\n \"\"\"Yields multiple clips per video.\"\"\"\n clip_length = 10.0\n for start in range(0, int(duration), int(clip_length)):\n end = min(start + clip_length, duration)\n clip_data = extract_video_segment(video_path, start, end)\n yield Clip(clip_start=start, clip_end=end, clip_bytes=clip_data)\n";
export const PyScalarUdtfList = "@chunker\ndef extract_clips(video_path: str, duration: float) -> list[Clip]:\n clips = []\n for start in range(0, int(duration), 10):\n end = min(start + 10, duration)\n clips.append(Clip(clip_start=start, clip_end=end, clip_bytes=b\"...\"))\n return clips\n";
-8
View File
@@ -1,8 +0,0 @@
{/* Auto-generated by scripts/mdx_snippets_gen.py. Do not edit manually. */}
export const PyRegistrationScalarUdtf = "db = geneva.connect(\"/data/mydb\")\ndb.create_udtf_view(\"my_view\", source=my_source, udtf=my_chunker)\n";
export const PyRegistrationUdf = "mock_table.add_columns({\"col\": my_udf})\n";
export const PyRegistrationUdtf = "db = geneva.connect(\"/data/mydb\")\ndb.create_udtf_view(\"my_view\", source=my_source, udtf=my_udtf)\n";
+53
View File
@@ -115,6 +115,59 @@ A pseudo-column produced by `fts()`, carrying relevance for the row. It behaves
like a normal column — order by it, filter on it, project it — and exists only
in a relation that came from `fts()`.
### `contains_tokens(column, tokens)` {#contains-tokens}
A predicate rather than a relation, for when you want keyword matching inside a
`WHERE` clause and do not need relevance:
```sql
SELECT * FROM my_docs WHERE contains_tokens(text, 'fox jumps dog')
```
It is true when the column contains **all** the tokens, which are split on
whitespace and punctuation. Equivalent to an `fts()` match query with fuzziness
off, `AND` semantics, and the simple tokenizer.
**It is index-accelerated.** When the column has a full-text index that suits
the query, the planner uses it instead of scanning. That is the reason to prefer
it over `LIKE` for keyword tests.
Use `fts()` when you need `_score` or the fuzzy, phrase, or boost forms; use
`contains_tokens` when you just need a boolean.
### JSON functions {#json}
Lance registers a set of JSON accessors over string columns holding JSON:
| Function | Returns |
| --- | --- |
| `json_get(col, key)` | the value at `key`, still JSON |
| `json_get_string(col, key)` | `Utf8` |
| `json_get_int(col, key)` | `Int64` |
| `json_get_float(col, key)` | `Float64` |
| `json_get_bool(col, key)` | `Boolean` |
| `json_extract(col, path)` | `Utf8`, by path |
| `json_extract_with_type(col, path, type)` | the requested type |
| `json_exists(col, path)` | `Boolean` |
| `json_array_contains(col, value)` | `Boolean` |
| `json_array_length(col)` | length |
`json_get` returns JSON, so accessors nest for a nested document:
```sql
SELECT * FROM events
WHERE json_get_string(json_get(json_get(data, 'user'), 'profile'), 'name') = 'Alice'
```
**`json_get`, its typed variants, and `json_extract` are index-accelerated** by a
JSON scalar index on the column. The others evaluate per row.
<Note>
Lance also defines geospatial `st_*` functions, but they are behind an optional
build feature that LanceDB does not ship. Calling one returns an error telling
you the feature is missing rather than an unknown-function error.
</Note>
## Reaching a function LanceDB does not name {#escape-hatch}
Nothing here wraps or restricts the DataFusion set — a query reaches any
-72
View File
@@ -1,72 +0,0 @@
---
title: "SQL manual"
sidebarTitle: "Overview"
description: "Analytical SQL over the lakehouse, served through Arrow Flight SQL."
icon: table-list
keywords: ["sql", "flight sql", "query", "analytics", "datafusion", "fts"]
---
LanceDB answers analytical queries in SQL, served over an
[Arrow Flight SQL](https://arrow.apache.org/docs/format/FlightSql.html)
endpoint. Any Flight SQL client can reach it, and results come back as Arrow.
The dialect is [DataFusion's](/sql/functions), so the syntax and functions are
the ones that reference documents. What this manual covers is the parts that are
LanceDB's: how a query reaches the engine, how Lance columns appear to it, and
the extensions a lakehouse over multimodal data needs.
## Where a query runs {#execution}
Queries execute on the server. The client sends a statement and receives Arrow
record batches — nothing is converted to pandas or Python objects unless you ask
for it.
Because results are Arrow the whole way, a query that feeds training or
downstream processing does not pay a serialization cost to get there.
## The dialect {#dialect}
Ordinary analytical SQL — `SELECT`, `WHERE`, `GROUP BY`, `ORDER BY`, `LIMIT`,
joins, CTEs and window functions — over Arrow-typed columns.
Two things differ from a warehouse you may be used to, and both come from the
storage being Lance rather than something SQL-shaped:
- **Columns are Arrow types**, not a SQL type system mapped onto storage. A
vector column is a `FixedSizeList<Float32>` and behaves like one.
- **Row metadata is available as columns** — `_rowid`, `_rowaddr` — but only
when asked for, so they stay out of `SELECT *`.
Both are covered in [Functions and types](/sql/functions).
## Full-text search {#fts}
Full-text search is a **table function** rather than a predicate, because it
produces relevance alongside the matching rows and a `WHERE` clause has nowhere
to put that:
```sql
SELECT id, text, _score
FROM fts('my_docs', 'puppy')
ORDER BY _score DESC
LIMIT 5
```
`fts()` takes a table name and a query — a bare string, or a JSON query object
for fuzzy, phrase, boolean, boost and multi-match forms. `_score` carries
relevance and behaves like any other column.
Being a relation is what makes it compose: join its result, filter it, aggregate
over it.
## Reference {#reference}
<Card title="Functions and types" icon="calculator" href="/sql/functions">
The DataFusion dialect and version, how Lance columns map onto it, and what
LanceDB adds.
</Card>
## What this manual does not cover {#scope}
Connecting a Flight SQL client — drivers, authentication, the endpoint address —
is deployment-specific and documented with the deployment.
-43
View File
@@ -1,43 +0,0 @@
---
title: "Tables and Namespaces"
sidebarTitle: "Tables and Namespaces"
description: "Learn more about the table abstraction and namespaces in LanceDB."
icon: "table"
---
Despite its name, LanceDB is not a "database" in the traditional sense. It is a **Multimodal Lakehouse** built on Lance tables plus a catalog abstraction.
As you dive deeper into LanceDB, it helps to separate two ideas:
- A **table** is where your data lives and is queried.
- A **namespace** is how groups of tables are organized and resolved at the catalog level.
## Understanding tables {#understanding-tables}
A table is the core data abstraction in LanceDB: a structured dataset with schema, indexes, and versioned updates.
What changes between deployments is how that table is addressed and accessed.
The mental model below clarifies table types by connection mode:
- **`LanceTable`**: direct table access (local path, `file://`, `s3://`, and similar object-store paths). This is the common mode in LanceDB OSS.
- **`RemoteTable`**: catalog-backed table access through a server/cluster (`db://...`). This is the mode you will use in LanceDB Enterprise.
![](/static/assets/images/overview/understanding-tables.png)
From an application perspective, both expose a familiar table API: create/open tables, mutate rows, and query data.
The main difference is where resolution and execution happen (directly against storage vs through a remote catalog service).
## Semantic difference between tables and namespaces {#semantic-difference-between-tables-and-namespaces}
The easiest way to think about this is:
- A **table** answers: "What data do I store and query?"
- A **namespace** answers: "Where does this table name live in my catalog hierarchy?"
In other words, tables are data objects; namespaces are catalog objects.
| Concept | Scope | Owns | Typical operations |
| --- | --- | --- | --- |
| Table | Data layer | Schema, rows, indexes, versions | `create_table`, `open_table`, inserts/updates/deletes, search/query |
| Namespace | Catalog layer | Hierarchy of names, table grouping, table name resolution | `create_namespace`, `list_namespaces`, `drop_namespace`, table ops with `namespace` |
For simple use cases where you have a relatively flat set of tables, you can ignore namespaces and just use table paths directly.
As your application needs evolve and your tables grow in number and complexity, you may move from table-centric thinking
to catalog-centric thinking. Check out the [Namespaces and the Catalog Model](/namespaces) guide to learn more.
+2 -2
View File
@@ -510,11 +510,11 @@ The complete demo implementation with helper scripts and usage instructions is i
<Card title="Notebook" icon="book-open" href="https://github.com/lancedb/training/blob/vlm-textvqa/examples/vlm-textvqa/notebooks/colab_textvqa_lance.ipynb">
The runnable Colab workflow: download, explore, benchmark, train, and evaluate.
</Card>
<Card title="Geneva UDFs" icon="code" href="https://github.com/lancedb/tmls-2026-demo/blob/main/vlm/geneva_udfs.py">
<Card title="UDF registry" icon="code" href="https://github.com/lancedb/tmls-2026-demo/blob/main/vlm/geneva_udfs.py">
Tier 1, Tier 2, and Tier 3 feature definitions.
</Card>
<Card title="Backfill driver" icon="server" href="https://github.com/lancedb/tmls-2026-demo/blob/main/vlm/backfill_geneva.py">
Geneva-powered feature materialization.
Feature materialization for the tiers above.
</Card>
<Card title="Training loop" icon="microchip" href="https://github.com/lancedb/tmls-2026-demo/blob/main/vlm/train_qwen25vl_lora.py">
QLoRA training from cached Lance columns.
+1 -1
View File
@@ -28,7 +28,7 @@ print the resolved query plan.
LanceDB provides two powerful tools for query analysis and optimization: `explain_plan` and `analyze_plan`.
Read the full guide on [Query Optimization](/search/optimize-queries/).
Read the full guides on [Explain](/search/explain) and [Explain Analyze](/search/explain-analyze).
### The Python multiprocessing module {#the-python-multiprocessing-module}
Multiprocessing with `fork` is not supported. You should use `spawn` instead.