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
33 changes: 30 additions & 3 deletions java/lance-jni/src/blocking_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ use lance::dataset::scanner::{
};
use lance_index::scalar::FullTextSearchQuery;
use lance_index::scalar::inverted::query::{
BooleanQuery as FtsBooleanQuery, BoostQuery as FtsBoostQuery, FtsQuery,
MatchQuery as FtsMatchQuery, MultiMatchQuery as FtsMultiMatchQuery, Occur as FtsOccur,
PhraseQuery as FtsPhraseQuery,
BooleanQuery as FtsBooleanQuery, BoostQuery as FtsBoostQuery,
CombinedFieldsQuery as FtsCombinedFieldsQuery, FtsQuery, MatchQuery as FtsMatchQuery,
MultiMatchQuery as FtsMultiMatchQuery, Occur as FtsOccur, PhraseQuery as FtsPhraseQuery,
};
use lance_io::ffi::to_ffi_arrow_array_stream;
use lance_linalg::distance::DistanceType;
Expand Down Expand Up @@ -162,6 +162,33 @@ pub(crate) fn build_full_text_search_query<'a>(

Ok(FtsQuery::MultiMatch(query))
}
"COMBINED_FIELDS" => {
let query_text = env.get_string_from_method(&java_obj, "getQueryText")?;
let columns: Vec<String> =
import_vec_from_method(env, &java_obj, "getColumns", |env, elem| {
let jstr = JString::from(elem);
let value: String = env.get_string(&jstr)?.into();
Ok(value)
})?;

let boosts: Option<Vec<f32>> =
env.get_optional_from_method(&java_obj, "getBoosts", |env, list_obj| {
import_vec_to_rust(env, &list_obj, |env, elem| {
env.get_f32_from_method(&elem, "floatValue")
})
})?;
let operator = env.get_fts_operator_from_method(&java_obj)?;

// Column uniqueness and boost (>= 1) validation live in the Rust core;
// `?` surfaces those errors across the JNI boundary.
let mut query = FtsCombinedFieldsQuery::try_new(query_text, columns)?;
if let Some(boosts) = boosts {
query = query.try_with_boosts(boosts)?;
}
query = query.with_operator(operator);

Ok(FtsQuery::CombinedFields(query))
}
"BOOST" => {
let positive_obj = env
.call_method(
Expand Down
98 changes: 98 additions & 0 deletions java/src/main/java/org/lance/ipc/FullTextQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public enum Type {
MATCH_PHRASE,
BOOST,
MULTI_MATCH,
COMBINED_FIELDS,
BOOLEAN
}

Expand Down Expand Up @@ -95,6 +96,15 @@ public static FullTextQuery multiMatch(
return new MultiMatchQuery(queryText, columns, boosts, operator);
}

public static FullTextQuery combinedFields(String queryText, List<String> columns) {
return combinedFields(queryText, columns, null, Operator.OR);
}

public static FullTextQuery combinedFields(
String queryText, List<String> columns, List<Float> boosts, Operator operator) {
return new CombinedFieldsQuery(queryText, columns, boosts, operator);
}

public static FullTextQuery boost(FullTextQuery positive, FullTextQuery negative) {
return boost(positive, negative, 0.5f);
}
Expand Down Expand Up @@ -340,6 +350,94 @@ public String toString() {
}
}

/**
* Combined-fields (BM25F) query across multiple columns.
*
* <p>Unlike {@link MultiMatchQuery}, which scores each column independently and keeps the best
* field, this query treats the target columns as a single virtual field so that term statistics
* are blended across fields (Lucene {@code CombinedFieldQuery}). A term that is rare in one field
* but common in another then scores consistently, and a single query term can match across fields
* (for example a first name in one column and a last name in another).
*
* <p>All target columns must share the same tokenizer/index configuration. When {@code boosts} is
* given, its length must equal the number of columns and each per-column weight must be finite
* and {@code >= 1} (fractional weights allowed); when {@code null}, every column defaults to
* {@code 1.0}. A {@code null} operator defaults to {@link Operator#OR}. The weight range, the
* boosts/columns length match, and column uniqueness are validated in the Rust core and surface
* as an exception when the query runs.
*/
public static final class CombinedFieldsQuery extends FullTextQuery {
private final String queryText;
private final List<String> columns;
private final Optional<List<Float>> boosts;
private final Operator operator;

CombinedFieldsQuery(
String queryText, List<String> columns, List<Float> boosts, Operator operator) {
Preconditions.checkArgument(
queryText != null && !queryText.isEmpty(), "queryText must not be null or empty");
Preconditions.checkArgument(
columns != null && !columns.isEmpty(), "columns must not be null or empty");

this.queryText = queryText;
this.columns =
Collections.unmodifiableList(new java.util.ArrayList<>(Objects.requireNonNull(columns)));
this.boosts =
boosts == null
? Optional.empty()
: Optional.of(Collections.unmodifiableList(new java.util.ArrayList<>(boosts)));
this.operator = operator == null ? Operator.OR : operator;
}

@Override
public Type getType() {
return Type.COMBINED_FIELDS;
}

public String getQueryText() {
return queryText;
}

public List<String> getColumns() {
return columns;
}

public Optional<List<Float>> getBoosts() {
return boosts;
}

public Operator getOperator() {
return operator;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CombinedFieldsQuery)) return false;
CombinedFieldsQuery other = (CombinedFieldsQuery) o;
return operator == other.operator
&& Objects.equals(queryText, other.queryText)
&& Objects.equals(columns, other.columns)
&& Objects.equals(boosts, other.boosts);
}

@Override
public int hashCode() {
return Objects.hash(queryText, columns, operator, boosts);
}

@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("type", getType())
.add("queryText", queryText)
.add("columns", columns)
.add("boosts", boosts)
.add("operator", operator)
.toString();
}
}

/** Boost query combining positive and negative queries. */
public static final class BoostQuery extends FullTextQuery {
private final FullTextQuery positive;
Expand Down
95 changes: 95 additions & 0 deletions java/src/test/java/org/lance/ipc/FullTextQueryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,101 @@ void testMultiMatchQueryInequalityDifferentBoosts() {
assertFalse(a.equals(b), "MultiMatchQuery instances with different boosts must not be equal");
}

@Test
void testCombinedFieldsWithoutBoosts() {
FullTextQuery.CombinedFieldsQuery q =
(FullTextQuery.CombinedFieldsQuery)
FullTextQuery.combinedFields("hello", Arrays.asList("title", "body"));

assertEquals(FullTextQuery.Type.COMBINED_FIELDS, q.getType());
assertEquals("hello", q.getQueryText());
assertEquals(Arrays.asList("title", "body"), q.getColumns());
assertFalse(q.getBoosts().isPresent());
assertEquals(FullTextQuery.Operator.OR, q.getOperator());
}

@Test
void testCombinedFieldsWithBoosts() {
FullTextQuery.CombinedFieldsQuery q =
(FullTextQuery.CombinedFieldsQuery)
FullTextQuery.combinedFields(
"hello",
Arrays.asList("title", "body"),
Arrays.asList(2.0f, 1.0f),
FullTextQuery.Operator.AND);

assertEquals(FullTextQuery.Type.COMBINED_FIELDS, q.getType());
assertTrue(q.getBoosts().isPresent());
assertEquals(2, q.getBoosts().get().size());
assertEquals(2.0f, q.getBoosts().get().get(0));
assertEquals(1.0f, q.getBoosts().get().get(1));
assertEquals(FullTextQuery.Operator.AND, q.getOperator());
assertNotNull(q.toString());
}

@Test
void testCombinedFieldsQueryEquality() {
FullTextQuery a = FullTextQuery.combinedFields("hello", Arrays.asList("title", "body"));
FullTextQuery b = FullTextQuery.combinedFields("hello", Arrays.asList("title", "body"));
assertEquals(a, b, "Two CombinedFieldsQuery instances with the same fields must be equal");
assertEquals(a.hashCode(), b.hashCode());
}

@Test
void testCombinedFieldsQueryInequalityDifferentOperator() {
FullTextQuery a =
FullTextQuery.combinedFields(
"hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.AND);
FullTextQuery b =
FullTextQuery.combinedFields(
"hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.OR);
assertFalse(
a.equals(b), "CombinedFieldsQuery instances with different operator must not be equal");
}

@Test
void testCombinedFieldsQueryInequalityDifferentBoosts() {
FullTextQuery a =
FullTextQuery.combinedFields(
"hello",
Arrays.asList("title", "body"),
Arrays.asList(2.0f, 1.0f),
FullTextQuery.Operator.OR);
FullTextQuery b =
FullTextQuery.combinedFields(
"hello",
Arrays.asList("title", "body"),
Arrays.asList(1.0f, 1.0f),
FullTextQuery.Operator.OR);
assertFalse(
a.equals(b), "CombinedFieldsQuery instances with different boosts must not be equal");
}

@Test
void testCombinedFieldsNotEqualMultiMatch() {
FullTextQuery combined = FullTextQuery.combinedFields("hello", Arrays.asList("title", "body"));
FullTextQuery multi = FullTextQuery.multiMatch("hello", Arrays.asList("title", "body"));
assertFalse(
combined.equals(multi),
"CombinedFieldsQuery and MultiMatchQuery must not be equal even with same fields");
}

@Test
void testCombinedFieldsBoostsDefensivelyCopied() {
java.util.List<Float> boosts = new java.util.ArrayList<>(Arrays.asList(2.0f, 1.0f));
FullTextQuery.CombinedFieldsQuery q =
(FullTextQuery.CombinedFieldsQuery)
FullTextQuery.combinedFields(
"hello", Arrays.asList("title", "body"), boosts, FullTextQuery.Operator.OR);

// Mutating the caller-provided list must not change the stored query.
boosts.set(0, 9.0f);
boosts.clear();

assertTrue(q.getBoosts().isPresent());
assertEquals(Arrays.asList(2.0f, 1.0f), q.getBoosts().get());
}

@Test
void testDifferentSubtypesNotEqual() {
FullTextQuery match = FullTextQuery.match("hello", "body");
Expand Down
Loading
Loading