Skip to content

Commit 2d0d5da

Browse files
committed
perf guide
1 parent 6aba63f commit 2d0d5da

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"group": "Get started",
3636
"pages": [
3737
"quickstart",
38+
"performance",
3839
{
3940
"group": "What is LanceDB?",
4041
"pages": [

docs/performance.mdx

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
---
2+
title: "Performance Tuning Guide"
3+
sidebarTitle: "Performance"
4+
description: "The short list of things to get right (and the things to avoid) when running LanceDB."
5+
icon: "gauge-high"
6+
keywords: ["performance", "tuning", "best practices", "optimization", "ingestion", "indexing", "vector search", "filtering", "compaction", "oss", "enterprise"]
7+
---
8+
9+
10+
# LanceDB OSS
11+
12+
## Common pitfalls
13+
14+
Here are some common patters to avoid when dealing with large LanceDB datasets:
15+
16+
1. **Materializing the whole table in memory.** Calling `table.to_pandas()` / `table.to_arrow()` etc. loads entire table in memory. Filter, project, or stream instead. See [Querying](#querying).
17+
2. **Calling `add()` once per row or for small batches.** Each call commits a new version. Use bulk ingestion if you're ingesting form existing source or use iterators to for better ingestion perforance without creating too many versions or fragents. [Ingestion](#ingestion).
18+
3. **No index on a column you filter by.** Running `where(...)` predicate on a large table would perform better with a scalar index instead of brute forcing. Similarly, for vector columns it is recommended to create vector index once the table is larger than a few 100K rows. See [Indexing](#indexing).
19+
4. **Not running `optimize()` on fragmented tables.** Without it, queries on unindexed rows fall back to flat search, soft-deleted rows aren't physically removed, and old-version files accumulate on disk. See [Maintenance](#maintenance).
20+
5. **Wrong distance metric for the embedding model.** Once an index is built the metric is fixed; mismatching it degrades results without an obvious error. See [Indexing](#indexing).
21+
6. **Updating indexed columns without rebuilding.** Updates move rows out of the vector index. They are still searchable but no longer benefit from the index. See [Maintenance](#maintenance).
22+
7. **`fork()` with multiprocessing.** Use `spawn`. LanceDB is multi-threaded internally, and `fork` plus a multi-threaded process is unsafe.
23+
24+
The rest of this section expands on each.
25+
26+
## Ingestion
27+
28+
The first rule is to **batch your writes**. Every `add()` call commits a new version and a new fragment, so per-row `add()` in a loop is a common cause of slow ingestion. Past that, LanceDB supports two ingestion modes that solve different problems. Pick by what your data source looks like.
29+
30+
### Bulk ingestion: for data you already have
31+
32+
Pass an Arrow Table, a Pandas DataFrame, or `pyarrow.dataset(...)` (best for large file-backed loads). LanceDB sees the total size up front and parallelizes the write across workers automatically.
33+
34+
```python Python icon="python"
35+
import pyarrow.dataset as ds
36+
37+
table.add(arrow_table) # in-memory Arrow
38+
table.add(df) # pandas DataFrame
39+
table.add(ds.dataset("data/", format="parquet")) # streams from disk, still parallelized
40+
```
41+
42+
This is the right path for initial loads from existing files, ETL outputs, or anything already materialized. `pa.dataset(...)` is a good fit for big file-based loads: it streams Parquet/CSV from disk without loading them into memory, and still preserves auto-parallelism. Use it instead of reading files into a DataFrame first.
43+
44+
For very large initial loads, create the table empty (with a schema) and then call `add()`. Passing the dataset directly to `create_table(name, data)` skips the auto-parallel write path.
45+
46+
### Iterator ingestion: for streaming or computed-on-the-fly data
47+
48+
Pass an iterator (or generator) of `pyarrow.RecordBatch`. You control chunk size, memory stays bounded, and LanceDB can process unbounded sources.
49+
50+
```python Python icon="python"
51+
import pyarrow as pa
52+
53+
def stream():
54+
for raw in source: # queue, HTTP, Kafka, etc.
55+
vectors = model.encode(raw["text"]) # compute on the fly
56+
yield pa.RecordBatch.from_pydict({**raw, "vector": vectors})
57+
58+
table.add(stream())
59+
```
60+
61+
Use this when data arrives over time, when you compute rows on the fly (embedding pipelines), or when the dataset doesn't fit in memory and isn't a Parquet/CSV file LanceDB can open directly.
62+
63+
The trade-off: streaming inputs don't currently expose the parallelism knob, so per-batch throughput is lower than bulk mode. Pick chunk sizes large enough that per-batch overhead amortizes; a few thousand to tens of thousands of rows is a reasonable range. **Don't yield single-row batches**, since that loses the benefit of the iterator path and recreates the per-row `add()` problem.
64+
65+
### Concurrent writers on S3 need a commit lock
66+
67+
Plain S3 has no atomic put-if-absent. Use `s3+ddb://` with a DynamoDB table:
68+
69+
```python Python icon="python"
70+
db = lancedb.connect("s3+ddb://bucket/path?ddbTableName=lance_commits")
71+
```
72+
73+
Concurrent reads scale freely; concurrent writers retry commits a finite number of times, so don't fan out hundreds of writers against the same table.
74+
75+
For more, see [Create a Table](/tables/create) and [Storage configuration](/storage/configuration).
76+
77+
## Indexing
78+
79+
Two kinds of indexes are independent and complementary. Most workloads use both.
80+
81+
### Vector indexes
82+
83+
A vector index is not strictly required below ~100K vectors; disk-based brute-force scan is fast enough at that scale. Past that, build one. Pick by what your workload looks like:
84+
85+
| Index | When to use |
86+
|-------|-------------|
87+
| `IVF_PQ` | Common starting point, especially when most queries carry a `where(...)` filter. Higher accuracy than RQ at small dimensions (≤ 256). This is also the index Enterprise builds automatically. |
88+
| `IVF_RQ` | Maximum compression on high-dimensional vectors, faster builds than PQ, and good behavior under filters. |
89+
| `IVF_HNSW_SQ` | Best recall/latency trade-off for unfiltered search. Can show higher latency variance under selective `where(...)` filters, so prefer `IVF_PQ` or `IVF_RQ` if most queries are filtered. |
90+
| `IVF_FLAT` | Required for binary vectors with `hamming`. Highest recall, no compression. |
91+
92+
The distance metric is fixed once an index is built. Match it to how the embedding model was trained (`cosine` for most general-purpose embeddings; `dot` for already-normalized vectors; `l2` for Euclidean-trained models; `hamming` for binary).
93+
94+
For parameter tuning (`num_partitions`, `num_sub_vectors`, `ef_construction`), see [Vector Indexing](/indexing/vector-index).
95+
96+
### Scalar indexes
97+
98+
Every column you filter on should have one. Without a scalar index, `where(...)` and `merge_insert` join keys do a full column scan.
99+
100+
| Index | Best for |
101+
|-------|----------|
102+
| `BTREE` (default) | Numeric, string, temporal columns with mostly distinct values; range queries |
103+
| `BITMAP` | Boolean and low-cardinality columns (< ~1,000 distinct values) |
104+
| `LABEL_LIST` | `List<T>` columns queried with `array_has_any` / `array_has_all` |
105+
106+
For more, see [Scalar Indexing](/indexing/scalar-index).
107+
108+
### Full-text search
109+
110+
Defaults are fine. Phrase queries require `with_position=True` and `remove_stop_words=False`, which significantly increases the index size and indexing time. Leave them off unless you need phrase matching. See [FTS Indexing](/indexing/fts-index).
111+
112+
## Querying
113+
114+
Three things to get right on every query.
115+
116+
**Always use `select()` and `limit()`.** Returned columns drive I/O; `limit()` bounds work and prevents flooding the client.
117+
118+
```python Python icon="python"
119+
table.search(query_emb).select(["id", "title"]).limit(20)
120+
```
121+
122+
**Use pre-filter (the default) unless you have a reason not to.** Pre-filter applies `where` before vector search, so results always satisfy the predicate. Post-filter (`prefilter=False`) is cheaper but may return fewer than `limit` rows; only use it when correctness allows. See [Filtering](/search/filtering).
123+
124+
**Tune for recall with one knob, not many.** If results are wrong (low recall, missing the right answer):
125+
126+
- For quantized indexes (PQ, RQ, SQ): use `refine_factor` to pull extra candidates and re-score on full vectors. Distances on quantized indexes are computed on compressed vectors and are approximate without it.
127+
- For HNSW-backed indexes: use `ef` at search time. Start at `1.5 × k`, raise toward `10 × k` if recall is short.
128+
- For IVF candidate breadth: `nprobes` is auto-tuned. Only raise it manually when a selective pre-filter leaves too few neighbors.
129+
130+
```python Python icon="python"
131+
table.search(emb).refine_factor(20).limit(10) # quantized recall recovery
132+
```
133+
134+
For hybrid search (vector + FTS), reranking is required because the score scales aren't directly comparable. The default `RRFReranker` is a strong starting point. See [Hybrid Search](/search/hybrid-search).
135+
136+
### Iterating the whole dataset
137+
138+
When you genuinely need every row (training data export, batch processing, dataset migration), don't materialize with `to_pandas()` / `to_arrow()`. Drop to the underlying Lance dataset and stream:
139+
140+
```python Python icon="python"
141+
ds = table.to_lance()
142+
for batch in ds.to_batches(columns=["id", "text"], batch_size=10000):
143+
process(batch)
144+
```
145+
146+
For more control (filters, fragment-level parallelism), use a scanner explicitly:
147+
148+
```python Python icon="python"
149+
scanner = table.to_lance().scanner(
150+
columns=["id", "text"],
151+
filter="created_at > '2025-01-01'",
152+
batch_size=10000,
153+
)
154+
for batch in scanner.to_batches():
155+
process(batch)
156+
```
157+
158+
Memory stays bounded regardless of table size. On Enterprise, `to_lance()` is not available on `RemoteTable`; iterate via scoped query builders (`table.search(...)`, `table.query(...)`) instead.
159+
160+
## Maintenance
161+
162+
In OSS, you own the lifecycle. One call covers most of it:
163+
164+
```python Python icon="python"
165+
from datetime import timedelta
166+
table.optimize() # default 7-day retention
167+
table.optimize(cleanup_older_than=timedelta(days=1)) # reclaim space sooner
168+
```
169+
170+
`optimize()` performs three maintenance operations:
171+
172+
1. **Compaction**: merges small fragments into larger ones to improve read performance.
173+
2. **Pruning/cleanup**: removes files from versions older than `cleanup_older_than` (7 days by default).
174+
3. **Index update**: adds newly-ingested data to existing indexes.
175+
176+
Run it after large writes, on a schedule, or both. Without it, queries on unindexed rows fall back to flat search, deleted rows continue to occupy storage, and the more data you add between optimizations, the more noticeable the latency impact becomes.
177+
178+
A few things to remember:
179+
180+
- Updates **move rows out of** the vector index. After large updates, rebuild it.
181+
- Deletes are soft. `optimize()` is what physically reclaims space.
182+
- `merge_insert` joins on a key. That join column needs a scalar index.
183+
184+
See [Reindexing](/indexing/reindexing) and [Versioning](/tables/versioning) for more.
185+
186+
## Diagnostics
187+
188+
Two tools, in this order:
189+
190+
```python Python icon="python"
191+
print(table.search(emb).where("year > 2000").limit(10).analyze_plan())
192+
print(table.index_stats("vector_idx")) # num_unindexed_rows should be ~0
193+
```
194+
195+
In `analyze_plan`, look for:
196+
197+
| Symptom | Likely cause |
198+
|---------|--------------|
199+
| `LanceScan` with high `bytes_read` / `iops` | Missing index, no `select()`, or uncompacted dataset |
200+
| `KNNVectorDistance` over millions of rows | No vector index, or bypassed |
201+
| Many small `output_batches` | Fragmented data; run `optimize()` |
202+
203+
For a worked example, see [Optimize Query Performance](/search/optimize-queries).
204+
205+
# LanceDB Enterprise
206+
207+
<Note>
208+
Enterprise-specific performance guidance is coming soon. For benchmark methodology and reference latency numbers, see [Performance Characteristics](/enterprise/performance).
209+
</Note>
210+
211+
---
212+
213+
## Where to go next
214+
215+
<Columns cols={2}>
216+
<Card title="Optimize Query Performance" icon="gauge" href="/search/optimize-queries">
217+
Read execution plans, find the bottleneck.
218+
</Card>
219+
<Card title="Vector Indexing" icon="layer-group" href="/indexing/vector-index">
220+
Index types, parameters, and tuning in depth.
221+
</Card>
222+
<Card title="Filtering" icon="filter" href="/search/filtering">
223+
Pre- vs post-filter, scalar indexes, list columns.
224+
</Card>
225+
<Card title="Enterprise Performance" icon="server" href="/enterprise/performance">
226+
Benchmark methodology and reference latency numbers.
227+
</Card>
228+
</Columns>

0 commit comments

Comments
 (0)