In LanceDB, how do you run a vector search and read the results as a DataFrame?
answer
- search() returns a builder, not rows
- nothing runs until a terminal call
- limit, metric, select, where chain on
- results gain one extra column
- that column is _distance, lower is nearer
basics
~20 sCall 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 sLanceDB'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 linesimport 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
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.
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.
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.
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