Full-text search in LanceDB starts one line deep. You build an index on a text column and call table.search("machine learning"), and it works — LanceDB tokenizes the string, matches the terms with BM25, and hands you ranked rows. For a lot of agent retrieval that's the whole job.

The trouble starts the moment the query gets specific. A bare string is an OR of its tokens: search("machine learning") will happily return a row that says "machine" and a different row that says "learning," in any order, anywhere in the field. Ask it for an exact multi-word product name, tolerate a user's typo, or weight a term that only matters in one column, and the string API has no way to say so.

The fix isn't a different index. It's the same FTS index, queried with structured query objects instead of a string. Build the index once with the right flags and three behaviors open up.

embedded, serverless multimodal retrieval library
★ 6kRustlancedb/lancedb

Build the index so it can do more than OR#

Everything below hangs off one index build. The two flags that matter are with_position (store token positions, which phrase matching needs) and remove_stop_words (keep the small words, so a phrase like "the old man and the sea" survives).

import lancedb

db = lancedb.connect("./data/lance")
table = db.open_table("docs")

# positions ON, stop-words KEPT — this one index serves plain, phrase, and fuzzy queries
table.create_fts_index(
    "text",
    with_position=True,
    remove_stop_words=False,
    replace=True,
)

If you build the index without with_position=True, phrase queries don't error — they just return nothing, which is the single most common "LanceDB FTS is broken" report that isn't a bug. Positions cost index size; pay it if you need phrases.

1. Phrase queries: pin word order and adjacency#

PhraseQuery requires the terms to appear adjacent and in order. This is what your UI's quoted-search box should map to, and what you want for exact titles, function signatures, or multi-word entity names.

from lancedb.query import PhraseQuery

rows = (
    table.search(PhraseQuery("machine learning", column="text"))
    .limit(10)
    .select(["text"])
    .to_list()
)

Newer LanceDB builds accept a slop on phrase matching — the number of extra words allowed to sit between your terms — so "agent memory" can still match "agent working memory" when you want the looser net. Reach for slop only when strict adjacency is costing you real hits; the default (adjacent) is the safer starting point.

2. Fuzzy queries: survive the typo#

Humans mistype, OCR mangles, and voice transcripts approximate. MatchQuery with a fuzziness does an edit-distance match around each term, so learnin finds learning.

from lancedb.query import MatchQuery

rows = (
    table.search(MatchQuery("learnin", column="text", fuzziness=1, max_expansions=50))
    .limit(10)
    .to_list()
)

fuzziness is the maximum edit distance (leave it default and LanceDB scales it by term length — 0 for very short tokens, up to 2 for long ones). max_expansions caps how many fuzzy variants of the term the query is allowed to chase; it's the dial between catching rare misspellings and keeping latency flat. Turn it up for a forgiving consumer search box, down for a hot path.

3. Scope the term to the column that means it#

The token react in a tags column is a strong signal; the same token in a long body is often noise. Pass column= so the match is scored only where it counts, and cut the candidate set first with a metadata filter:

rows = (
    table.search(MatchQuery("react", column="tags"))
    .where("lang = 'en'")   # prefilter on metadata before text scoring
    .limit(20)
    .to_list()
)

For partial tokens — type-ahead, prefix autocomplete, or matching inside identifiers like parse_mandate — you want a substring-capable tokenizer (base_tokenizer="ngram") rather than the word tokenizer. That's a bigger index and its own set of trade-offs, which we walk through in substring search with the FM-index.

None of the above touches vectors. When your queries mix natural-language meaning with exact tokens — the usual agent case — you layer this FTS index under hybrid search, where LanceDB runs the full-text query and a vector query together and fuses them with a reranker. The structured query classes here are what make the full-text half of that fusion precise instead of a bag of OR'd words.

The mental model worth keeping: the bare string is the recall-maximizing default, and each query class is a way to trade some of that recall for precision on purpose — phrase for order, fuzzy for tolerance, column for relevance. Build the index once with positions on, and you can make that trade per query without reindexing a thing.