Full-Text Search¶
Lance full-text search uses an inverted index and BM25 relevance scoring. It supports token matching, boolean queries, exact phrases, fuzzy matching, and searches across multiple text columns.
For ordinary substring filters, use an N-gram or FM scalar index. Those indexes accelerate contains(...) filters but do not provide BM25 ranking.
This guide covers the Python (pylance), Rust (lance), and Java (org.lance:lance-core) SDKs.
Create a Full-Text Index¶
Create an INVERTED index on a text column:
import lance
import pyarrow as pa
uri = "full_text_search.lance"
table = pa.table(
{
"id": [1, 2, 3],
"title": ["Lance guide", "Cooking ramen", "Evening train"],
"body": [
"A columnar format for multimodal data",
"Simmer the ramen broth for three hours",
"The train leaves for Boston in the evening",
],
"category": ["docs", "food", "travel"],
}
)
dataset = lance.write_dataset(table, uri)
dataset.create_scalar_index("body", "INVERTED")
use lance::index::DatasetIndexExt;
use lance::Dataset;
use lance_index::scalar::InvertedIndexParams;
use lance_index::IndexType;
let mut dataset = Dataset::open("full_text_search.lance").await?;
dataset
.create_index(
&["body"],
IndexType::Inverted,
None,
&InvertedIndexParams::default(),
true,
)
.await?;
import org.lance.Dataset;
import org.lance.index.IndexOptions;
import org.lance.index.IndexParams;
import org.lance.index.IndexType;
import org.lance.index.scalar.InvertedIndexParams;
import java.util.Collections;
dataset.createIndex(
IndexOptions.builder(
Collections.singletonList("body"),
IndexType.INVERTED,
IndexParams.builder()
.setScalarIndexParams(InvertedIndexParams.builder().build())
.build())
.replace(true)
.build());
The default index name is <column>_idx. Use a custom name=... when a column needs more than one index, such as both an inverted index and an N-gram index.
Use with_position=True when phrase queries are required. Positions increase index size but do not affect non-phrase query performance:
Common analyzer options include:
base_tokenizer: controls tokenization, such assimple,whitespace,raw,icu, or language-specific tokenizers.lower_case,stem,remove_stop_words,ascii_folding: control case normalization, stemming, stop-word removal, and accent folding.language: selects the language for stemming and default stop words.max_token_length: an exclusive UTF-8 byte limit; tokens whose encoded length is at least the limit are removed.analyzer="code": enables the code-analysis profile and its identifier/operator options.
See Tokenizers for language models, code analysis, and token inspection with lance.tokenize.
Run Keyword Searches¶
Results are ranked by BM25, with higher _score values indicating stronger matches. In Python and Rust, passing a query string searches all indexed text columns:
A string query uses the dataset's searchable text configuration. To select a column and control matching explicitly, use MatchQuery:
from lance.query import FullTextOperator, MatchQuery
# Both terms must be present
query = MatchQuery("train Boston", "body", operator=FullTextOperator.AND)
result = dataset.to_table(full_text_query=query)
# Either term may be present
query = MatchQuery("ramen broth", "body", operator=FullTextOperator.OR)
result = dataset.to_table(full_text_query=query)
use lance_index::scalar::inverted::query::{MatchQuery, Operator};
// Both terms must be present
let query = MatchQuery::new("train Boston".to_string())
.with_column(Some("body".to_string()))
.with_operator(Operator::And);
let batches: Vec<RecordBatch> = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(query.into()))?
.try_into_stream()
.await?
.try_collect()
.await?;
import org.apache.arrow.vector.ipc.ArrowReader;
import org.lance.ipc.FullTextQuery;
import org.lance.ipc.LanceScanner;
import org.lance.ipc.ScanOptions;
import java.util.Optional;
// Both terms must be present
ScanOptions options =
new ScanOptions.Builder()
.fullTextQuery(
FullTextQuery.match(
"train Boston",
"body",
1.0f,
Optional.empty(),
50,
FullTextQuery.Operator.AND,
0))
.build();
try (LanceScanner scanner = dataset.newScan(options);
ArrowReader reader = scanner.scanBatches()) {
while (reader.loadNextBatch()) {
// process reader.getVectorSchemaRoot()
}
}
Combine metadata filtering in the same scan:
ScanOptions options =
new ScanOptions.Builder()
.fullTextQuery(FullTextQuery.match("train", "body"))
.filter("category = 'travel'")
.build();
try (LanceScanner scanner = dataset.newScan(options);
ArrowReader reader = scanner.scanBatches()) {
while (reader.loadNextBatch()) {
// process reader.getVectorSchemaRoot()
}
}
Phrase Queries¶
Phrase queries require an index built with with_position=True:
use lance_index::scalar::inverted::query::PhraseQuery;
let phrase = PhraseQuery::new("ramen broth".to_string())
.with_column(Some("body".to_string()));
let batches: Vec<RecordBatch> = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(phrase.into()))?
.try_into_stream()
.await?
.try_collect()
.await?;
Stop words affect phrase positions. If phrases must contain stop words, build the index with remove_stop_words=False; otherwise those words are absent from the indexed positions and the exact phrase may not match.
Boolean and Fuzzy Queries¶
Use BooleanQuery and Occur when some clauses are required and others are forbidden:
use lance_index::scalar::inverted::query::{BooleanQuery, FtsQuery, Occur};
let boolean_query = BooleanQuery::new([
(
Occur::Must,
FtsQuery::Match(
MatchQuery::new("train".to_string()).with_column(Some("body".to_string())),
),
),
(
Occur::MustNot,
FtsQuery::Match(
MatchQuery::new("Boston".to_string()).with_column(Some("body".to_string())),
),
),
]);
let batches: Vec<RecordBatch> = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(boolean_query.into()))?
.try_into_stream()
.await?
.try_collect()
.await?;
import java.util.Arrays;
FullTextQuery booleanQuery =
FullTextQuery.booleanQuery(
Arrays.asList(
new FullTextQuery.BooleanClause(
FullTextQuery.Occur.MUST, FullTextQuery.match("train", "body")),
new FullTextQuery.BooleanClause(
FullTextQuery.Occur.MUST_NOT, FullTextQuery.match("Boston", "body"))));
try (LanceScanner scanner =
dataset.newScan(new ScanOptions.Builder().fullTextQuery(booleanQuery).build());
ArrowReader reader = scanner.scanBatches()) {
while (reader.loadNextBatch()) {
// process reader.getVectorSchemaRoot()
}
}
MatchQuery also supports fuzzy matching with Levenshtein edit distance:
let fuzzy = MatchQuery::new("ramen".to_string())
.with_column(Some("body".to_string()))
.with_fuzziness(Some(1))
.with_max_expansions(50);
let batches: Vec<RecordBatch> = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(fuzzy.into()))?
.try_into_stream()
.await?
.try_collect()
.await?;
FullTextQuery fuzzy =
FullTextQuery.match(
"ramen", "body", 1.0f, Optional.of(1), 50, FullTextQuery.Operator.OR, 0);
try (LanceScanner scanner =
dataset.newScan(new ScanOptions.Builder().fullTextQuery(fuzzy).build());
ArrowReader reader = scanner.scanBatches()) {
while (reader.loadNextBatch()) {
// process reader.getVectorSchemaRoot()
}
}
Set fuzziness=None for automatic fuzziness based on term length. Use prefix_length to require an exact prefix before edits are allowed.
Search Multiple Columns¶
Use MultiMatchQuery when the best score from any column should win:
use lance_index::scalar::inverted::query::MultiMatchQuery;
let multi = MultiMatchQuery::try_new(
"columnar guide".to_string(),
vec!["title".to_string(), "body".to_string()],
)?
.try_with_boosts(vec![2.0, 1.0])?;
let batches: Vec<RecordBatch> = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(multi.into()))?
.try_into_stream()
.await?
.try_collect()
.await?;
FullTextQuery multi =
FullTextQuery.multiMatch(
"columnar guide",
Arrays.asList("title", "body"),
Arrays.asList(2.0f, 1.0f),
FullTextQuery.Operator.OR);
try (LanceScanner scanner =
dataset.newScan(new ScanOptions.Builder().fullTextQuery(multi).build());
ArrowReader reader = scanner.scanBatches()) {
while (reader.loadNextBatch()) {
// process reader.getVectorSchemaRoot()
}
}
Use CombinedFieldsQuery (BM25F) when terms may be spread across columns and their corpus statistics should be combined:
use lance_index::scalar::inverted::query::CombinedFieldsQuery;
let combined = CombinedFieldsQuery::try_new(
"Lance multimodal".to_string(),
vec!["title".to_string(), "body".to_string()],
)?
.with_operator(Operator::And);
let batches: Vec<RecordBatch> = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(combined.into()))?
.try_into_stream()
.await?
.try_collect()
.await?;
FullTextQuery combined =
FullTextQuery.combinedFields(
"Lance multimodal",
Arrays.asList("title", "body"),
null,
FullTextQuery.Operator.AND);
try (LanceScanner scanner =
dataset.newScan(new ScanOptions.Builder().fullTextQuery(combined).build());
ArrowReader reader = scanner.scanBatches()) {
while (reader.loadNextBatch()) {
// process reader.getVectorSchemaRoot()
}
}
Columns in a combined-fields query must use the same tokenizer and index configuration.
Maintain Full-Text Indexes¶
Appended rows remain searchable: Lance combines indexed search with unindexed scans. As unindexed data grows, periodically add new fragments to the index:
After changing tokenizer or analyzer settings, rebuild the index with create_scalar_index(..., replace=True) so the entire dataset uses the new configuration. optimize_indices() adds new data to existing indexes; it does not change their analyzer settings.
For large builds, num_workers and memory_limit control build parallelism and memory. See Performance Guide and the FTS format specification for resource and compatibility details.
Full-Text and Vector Search¶
A scanner can combine full-text relevance and vector similarity. See Combine Full-Text and Vector Search for the query form and filtering behavior.
For a runnable tutorial, see the Full-Text Search Quickstart.