skip to content

LanceDB

An embedded vector database on the Lance columnar format: no server, data on local disk or object storage, versioned tables, and vectors sitting beside the rest of your columns. It appears when the question is retrieval without running a cluster, or vectors living next to analytics data.

on this pageshow

questions

11

In LanceDB, how do you run a vector search and read the results as a DataFrame?

level: juniorimportance: must knowfreq 62%

answer

  1. search() returns a builder, not rows
  2. nothing runs until a terminal call
  3. limit, metric, select, where chain on
  4. results gain one extra column
  5. that column is _distance, lower is nearer

basics

~20 s

Call table.search(query_vector), chain .limit(n) to say how many neighbours you want, and finish with .to_pandas(). Every returned row carries the table's own columns plus a _distance column holding that row's distance from the query vector.

solid answer

~40 s

LanceDB's `search()` returns a lazy query builder, not results. You chain modifiers on it — `.metric("cosine")` to pick the distance function, `.limit(k)` for the number of neighbours, `.select([...])` to trim the columns read off disk, `.where("...")` for a SQL predicate — and nothing executes until you call a terminal method. `.to_pandas()` gives a DataFrame, and `.to_arrow()`, `.to_list()` and `.to_pydantic(Model)` give the same rows in other shapes. The result set is the matching table rows with an extra `_distance` column: smaller means closer for every supported metric, including cosine, where the value is a distance, not a similarity. Always set `.limit()` explicitly rather than relying on the builder's small default, and set `.metric()` to the same metric you built the index with.

code

python · 14 lines
python
import lancedb

db = lancedb.connect("./lance_data")
tbl = db.open_table("docs")

df = (
    tbl.search([0.12, 0.44, 0.07])
    .metric("cosine")
    .limit(5)
    .select(["id", "text"])
    .to_pandas()
)

print(df[["id", "_distance"]])

go deeper

for a junior

Be able to write the chain from memory: search(vector), limit, then to_pandas or to_list. Say plainly that results include a _distance column and that lower means closer.

for a middle

Explain that the builder is lazy and that select and where are pushed into the columnar scan, so projecting fewer columns really does read fewer bytes. Know that cosine yields a distance, not a similarity.

for a senior

Show judgment about the result path in a service: avoid DataFrame construction per request, calibrate any distance cutoff on real data, and keep a flat-scan baseline so you can tell exact results from approximate ones.

for a principal

Own the contract your service exposes. Raw _distance values are engine- and metric-specific and should not leak into client APIs or thresholds that other teams then depend on; publish a stable, calibrated score instead.

## The query builder A LanceDB query is built, then executed. `table.search(query_vector)` hands back a query-builder object that has recorded only the query vector so far. Every method you chain onto it — the metric, the limit, a filter, a column projection, index-search knobs — mutates that plan and returns the builder again. No I/O happens until you call a terminal method that materializes rows. This matters for two reasons. First, mistakes in chaining are silent: a builder that is never terminated simply does nothing. Second, because execution is deferred, LanceDB can push the projection and the filter down into the columnar scan instead of reading whole rows and discarding them afterwards. ## The pieces you chain - `.limit(k)` — how many neighbours to return. This is the k of the nearest-neighbour search, not a page size applied afterwards. Set it explicitly; the builder has a small default that is easy to trip over when you assume you got everything. - `.metric("l2" | "cosine" | "dot")` — which distance function to score with. It must agree with the metric the vector index was built under; scoring with one metric against an index trained for another gives you results that are quietly wrong rather than an error. - `.select(["id", "text"])` — restrict the columns read. Lance stores data column-by-column, so skipping a large text or blob column genuinely reduces the bytes pulled off disk or out of object storage. - `.where("price < 100")` — a SQL-style predicate string over the table's scalar columns. ## Terminal methods `.to_pandas()` is the usual one in notebooks and returns a pandas DataFrame. `.to_arrow()` returns an Arrow table and is the cheapest, because Lance is Arrow-native and no conversion is needed. `.to_list()` returns plain Python dictionaries, which is what most application code wants. `.to_pydantic(Model)` maps rows onto a Pydantic model when the table was created from one. ## The _distance column Whatever shape you ask for, the results carry an extra `_distance` field alongside the table's own columns. It is a **distance**: lower is nearer, and rows come back sorted ascending. Under `cosine` this is cosine distance, so a perfect match tends toward 0, not toward 1 — a frequent source of confusion when someone wires the number straight into a UI labelled "similarity" or applies a threshold with the comparison inverted. If you need a similarity for display, derive it yourself from the distance. The values are also not comparable across metrics or across queries with different vector norms. Use them to order results and, if you must, to apply a cutoff you have calibrated empirically on your own data — never as an absolute quality score. ## Exactness On a table with no vector index, the returned neighbours are exact, because the engine scans every row. Once an approximate index exists, the same call returns approximate neighbours: the API is identical and nothing in the result signals which mode ran. That is deliberate — it lets you develop against exact results and add the index later — but it means correctness testing has to be explicit, comparing indexed results against a flat-scan baseline for a sample of queries. ## Practical shape of a call A typical production query names the metric, sets a limit, projects only the columns the caller needs, and terminates with `.to_list()` so no DataFrame machinery is dragged into a request path. Notebooks use `.to_pandas()` because inspecting `_distance` next to the source text is the fastest way to sanity-check that embeddings and metric agree.

  • Under the cosine metric, does a _distance of 0.02 mean a good match or a bad one?
    A good one. `_distance` is always a distance, so smaller is closer and rows arrive sorted ascending. Under cosine, 0.02 means the vectors point in nearly the same direction; a value near 1 means they are close to orthogonal. Code that treats the column as a similarity score and keeps the largest values will systematically return the worst matches.
  • Why prefer to_arrow() or to_list() over to_pandas() in a service?
    Lance is Arrow-native, so `to_arrow()` hands back the data in the format it was already decoded into — no conversion pass and no pandas dependency in the request path. `to_list()` gives plain dicts, which is usually what a JSON response needs. `to_pandas()` builds a DataFrame you then immediately tear apart, which costs time and memory for nothing outside a notebook.

saying these in an interview costs you the question

  • Thinking search() returns rows without a terminal call
  • Reading _distance as a similarity where higher is better
  • Assuming results are always exact, index or not
  • Comparing _distance values across different metrics
  • Relying on the default limit instead of setting it

context

open as a page

In LanceDB, how do you create a table and where can its schema come from?

level: juniorimportance: must knowfreq 72%

basics

~20 s

db.create_table(name, data=...) infers an Arrow schema from a pandas DataFrame, PyArrow table or list of dicts. Passing schema= with a PyArrow schema or a lancedb.pydantic LanceModel defines it explicitly and allows creating an empty table.

open as a page

In LanceDB's create_index, what do num_partitions and num_sub_vectors control?

level: middleimportance: must knowfreq 58%

basics

~20 s

num_partitions sets how many clusters the vectors are split into, so it governs how much data one query touches per probe. num_sub_vectors sets how many pieces each vector is chopped into for compression, so it governs index size and how lossy stored distances are.

open as a page

What does LanceDB do when you search a table with no vector index?

level: middleimportance: must knowfreq 68%

basics

~20 s

It runs an exhaustive brute-force scan, comparing the query against every vector in the table. Results are exact, but latency grows linearly with row count. Calling create_index builds an approximate IVF_PQ index that trades that exactness for sub-linear search.

open as a page

How does LanceDB versioning work, and how do you read an older table version?

level: middleimportance: must knowfreq 65%

basics

~20 s

Every write to a LanceDB table — append, update, delete, overwrite, index build — commits a new immutable version instead of mutating data in place. table.list_versions() enumerates them, table.checkout(n) pins the table object to version n for reading, and table.checkout_latest() returns to the newest.

open as a page

In LanceDB, what does prefilter=True on a search's where clause change?

level: middleimportance: should knowfreq 50%

basics

~20 s

With prefilter=True the SQL predicate is applied before the nearest-neighbour search, so the search only considers matching rows and you get a full limit of results. Post-filtering instead applies the predicate to rows the vector search already returned, which can leave you with far fewer.

open as a page

What does lancedb.connect() do for a local path versus an s3:// URI?

level: middleimportance: should knowfreq 60%

basics

~20 s

lancedb.connect(uri) opens a database rooted at a directory — a local path it creates if missing, or an object-storage prefix such as s3://bucket/prefix reached with storage_options for region and credentials. No server or connection pool is involved; the calling process is the database.

open as a page

In LanceDB, what does table.merge_insert() do that table.add() cannot?

level: middleimportance: should knowfreq 48%

basics

~20 s

merge_insert() matches incoming rows against existing ones on a key column and applies update-or-insert semantics in a single commit. add() only appends, so re-running it with the same records silently creates duplicates — LanceDB tables have no primary-key constraint to stop it.

open as a page

How do you add full-text search to a LanceDB table and combine it with vector search?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Build an inverted index over a text column with create_fts_index, then query it with search(text, query_type="fts"). Passing query_type="hybrid" instead runs the keyword and vector legs together and fuses their two ranked lists through a reranker.

open as a page

How do nprobes and refine_factor change recall on a LanceDB vector search?

level: seniorimportance: should knowfreq 52%

basics

~20 s

nprobes sets how many index partitions a query searches, so raising it widens coverage at linear latency cost. refine_factor over-fetches candidates and rescores them against the full-precision vectors, fixing ordering errors caused by compression at the price of extra reads.

open as a page

Why does a LanceDB table keep growing on disk after deletes, and what fixes it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Deletes commit a new version recording removed rows rather than rewriting data, and every earlier version still references the original files, so nothing is reclaimed. table.optimize() compacts fragments and prunes versions older than an age you pass, which is what actually returns the space.

open as a page