Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/src/quickstart/full-text-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,75 @@ query_result = ds.to_table(
)
```

### Searching Multiple Columns

When a query should span several text columns (for example `title` and `body`),
Lance offers two strategies that differ in how they combine evidence across
fields.

#### `best_fields` with `MultiMatchQuery`

`MultiMatchQuery` runs the query against each column independently and keeps the
**best** field's score for each document. Each
column is scored against its *own* corpus statistics, and an optional per-column
`boosts` multiplier is applied before the max.

```python
from lance.query import MultiMatchQuery

query_result = ds.to_table(
full_text_query=MultiMatchQuery(
"albino elephant",
["title", "body"],
boosts=[2.0, 1.0], # weight title matches twice as much
)
)
```

This works well when the fields are interchangeable and a document is relevant
if *any single field* matches strongly.

#### `combined_fields` (BM25F) with `CombinedFieldsQuery`

`CombinedFieldsQuery` instead treats the target columns as **one virtual field**
and blends their statistics into a single BM25F model (the same scoring as
Lucene's `CombinedFieldQuery`). This makes the scores comparable across
fields and lets a single query term match *across* fields.

```python
from lance.query import CombinedFieldsQuery, FullTextOperator

query_result = ds.to_table(
full_text_query=CombinedFieldsQuery(
"john smith",
["first_name", "last_name"],
boosts=[1.0, 1.0],
operator=FullTextOperator.AND,
)
)
```

Prefer `combined_fields` when:

- a term is **rare in one field but common in another**: blended document
frequencies keep a spurious hit in a short field from outranking a genuinely
better match, which `best_fields` cannot do; or
- a term should match **across** fields: with `operator=AND`, `"john smith"`
matches a row whose `first_name` is `"john"` and `last_name` is `"smith"`,
even though neither field alone contains both terms (`best_fields` would
require a single field to contain the whole query).

Per-column `boosts` weight how much each field contributes to the blended term
frequency. Each weight must be `>= 1` (fractional weights are allowed); a title
boost of `2` counts every title term twice.

!!! note "Shared tokenizer required"

All columns in a `CombinedFieldsQuery` must share the same tokenizer / index
configuration, because BM25F is only well-defined when the fields tokenize
the same way. Mixing configurations raises an error listing the offending
columns.

## Performance Tips

### Index Maintenance
Expand Down
7 changes: 7 additions & 0 deletions python/python/lance/lance/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,13 @@ class PyFullTextQuery:
boosts: Optional[List[float]] = None,
operator: str = "OR",
) -> PyFullTextQuery: ...
@staticmethod
def combined_fields_query(
query: str,
columns: List[str],
boosts: Optional[List[float]] = None,
operator: str = "OR",
) -> PyFullTextQuery: ...

class ScanStatistics:
"""Statistics about a scan operation."""
Expand Down
49 changes: 49 additions & 0 deletions python/python/lance/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class FullTextQueryType(Enum):
MATCH_PHRASE = "match_phrase"
BOOST = "boost"
MULTI_MATCH = "multi_match"
COMBINED_FIELDS = "combined_fields"
BOOLEAN = "boolean"


Expand Down Expand Up @@ -227,6 +228,54 @@ def query_type(self) -> FullTextQueryType:
return FullTextQueryType.MULTI_MATCH


class CombinedFieldsQuery(FullTextQuery):
def __init__(
self,
query: str,
columns: list[str],
*,
boosts: Optional[list[float]] = None,
operator: FullTextOperator = FullTextOperator.OR,
):
"""
Combined-fields (BM25F) query for full-text search.

Unlike :class:`MultiMatchQuery`, which scores each column independently
and keeps the best field (Elasticsearch ``best_fields``), this query
treats the target columns as a single virtual field so that term
statistics are blended across fields (Elasticsearch ``combined_fields``
/ Lucene ``CombinedFieldQuery``). This makes a term that is rare in one
field but common in another score consistently, and lets a single query
term match across fields (e.g. a first name in one column and a last
name in another).

Parameters
----------
query : str
The query string to match against.
columns : list[str]
The list of columns combined into the virtual field.
boosts : list[float], optional
Per-column weights aligned with ``columns``. Each weight must be
``>= 1`` (fractional weights allowed). If not provided, every column
defaults to ``1.0``.
operator : FullTextOperator, default OR
The operator applied across the virtual field. If ``AND``, every
term must appear in at least one column; if ``OR``, at least one
term must match.

Notes
-----
All target columns must share the same tokenizer/index configuration.
"""
self._inner = PyFullTextQuery.combined_fields_query(
query, columns, boosts=boosts, operator=operator.value
)

def query_type(self) -> FullTextQueryType:
return FullTextQueryType.COMBINED_FIELDS


class BooleanQuery(FullTextQuery):
def __init__(self, queries: list[tuple[Occur, FullTextQuery]]):
"""
Expand Down
60 changes: 60 additions & 0 deletions python/python/tests/test_scalar_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from lance.query import (
BooleanQuery,
BoostQuery,
CombinedFieldsQuery,
FullTextOperator,
MatchQuery,
MultiMatchQuery,
Expand Down Expand Up @@ -1873,6 +1874,65 @@ def test_fts_multi_match_query(tmp_path):
)


@pytest.mark.parametrize(
"operator,combined_ids,multi_ids",
[
# combined_fields treats the columns as one virtual field, so AND matches
# when each term appears in any field (rows 0 and 1); best_fields AND only
# matches row 1, where a single field holds both terms.
(FullTextOperator.AND, {0, 1}, {1}),
(FullTextOperator.OR, {0, 1, 2}, {0, 1, 2}),
],
)
def test_fts_combined_fields_query(tmp_path, operator, combined_ids, multi_ids):
data = pa.table(
{
"id": [0, 1, 2],
# row 0 splits the terms across fields, row 1 has both in one field,
# row 2 has only "john".
"title": ["john", "john smith", "john"],
"body": ["smith", "foo", "alice"],
}
)
ds = lance.write_dataset(data, tmp_path)
ds.create_scalar_index("title", "INVERTED")
ds.create_scalar_index("body", "INVERTED")

def ids(query):
return set(ds.to_table(full_text_query=query, columns=["id"])["id"].to_pylist())

assert (
ids(CombinedFieldsQuery("john smith", ["title", "body"], operator=operator))
== combined_ids
)
assert (
ids(MultiMatchQuery("john smith", ["title", "body"], operator=operator))
== multi_ids
)


def test_fts_combined_fields_boost_validation(tmp_path):
# Per-column boosts must be >= 1 (Lucene CombinedFieldQuery constraint), and
# the boost count must match the column count.
data = pa.table({"title": ["hello"], "body": ["world"]})
ds = lance.write_dataset(data, tmp_path)
ds.create_scalar_index("title", "INVERTED")
ds.create_scalar_index("body", "INVERTED")

with pytest.raises(ValueError, match="boost"):
CombinedFieldsQuery("hello", ["title", "body"], boosts=[0.5, 1.0])
with pytest.raises(ValueError):
CombinedFieldsQuery("hello", ["title", "body"], boosts=[1.0])

# Fractional weights >= 1 are accepted.
result = ds.to_table(
full_text_query=CombinedFieldsQuery(
"hello", ["title", "body"], boosts=[1.5, 1.0]
)
)
assert result.num_rows == 1


def test_fts_boolean_query(tmp_path):
data = pa.table(
{
Expand Down
28 changes: 27 additions & 1 deletion python/src/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ use lance_file::reader::FileReaderOptions;
use lance_index::scalar::inverted::InvertedListFormatVersion;
use lance_index::scalar::inverted::query::Occur;
use lance_index::scalar::inverted::query::{
BooleanQuery, BoostQuery, FtsQuery, MatchQuery, MultiMatchQuery, Operator, PhraseQuery,
BooleanQuery, BoostQuery, CombinedFieldsQuery, FtsQuery, MatchQuery, MultiMatchQuery, Operator,
PhraseQuery,
};
use lance_index::{
FtsPrewarmOptions, IndexParams, IndexType, PrewarmOptions,
Expand Down Expand Up @@ -5343,6 +5344,31 @@ impl PyFullTextQuery {
})
}

#[staticmethod]
#[pyo3(signature = (query, columns, boosts=None, operator="OR"))]
fn combined_fields_query(
query: String,
columns: Vec<String>,
boosts: Option<Vec<f32>>,
operator: &str,
) -> PyResult<Self> {
let q = CombinedFieldsQuery::try_new(query, columns)
.map_err(|e| PyValueError::new_err(format!("Invalid query: {}", e)))?;
let q = if let Some(boosts) = boosts {
q.try_with_boosts(boosts)
.map_err(|e| PyValueError::new_err(format!("Invalid boosts: {}", e)))?
} else {
q
};

let op = Operator::try_from(operator)
.map_err(|e| PyValueError::new_err(format!("Invalid operator: {}", e)))?;

Ok(Self {
inner: q.with_operator(op).into(),
})
}

#[staticmethod]
#[pyo3(signature = (queries))]
fn boolean_query(queries: Vec<(String, Self)>) -> PyResult<Self> {
Expand Down
7 changes: 6 additions & 1 deletion rust/lance-index/src/scalar/inverted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

pub mod builder;
mod cache_codec;
mod combined;
mod encoding;
mod impact;
mod index;
Expand All @@ -21,11 +22,15 @@ use std::sync::Arc;
use arrow_schema::{DataType, Field};
use async_trait::async_trait;
pub use builder::InvertedIndexBuilder;
pub use combined::{
CombinedFieldColumn, build_combined_bm25_scorer, combined_fields_search,
validate_combined_tokenizers,
};
use datafusion::execution::SendableRecordBatchStream;
pub use index::*;
use lance_core::{Result, cache::LanceCache};
pub use lance_tokenizer::Language;
pub use scorer::{MemBM25Scorer, Scorer};
pub use scorer::{CombinedFieldsBM25Scorer, MemBM25Scorer, Scorer};
pub use tokenizer::*;

use crate::scalar::inverted::query::{FtsSearchParams, Tokens};
Expand Down
Loading
Loading