diff --git a/docs/openapi.yml b/docs/openapi.yml index b07e47e04..0b03c5a5e 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -424,16 +424,27 @@ paths: type: string index_uuid: type: string + index_type: + type: string + description: The index type, including FTS for full-text indexes. + index_details: + type: [string, "null"] + description: | + JSON-encoded index configuration. For an FTS index this + must contain the complete tokenizer configuration, + including remove_stop_words and custom_stop_words + (null, an empty array, or a non-empty array), so clients + can safely reconstruct the persisted tokenizer snapshot. "401": $ref: "#/components/responses/unauthorized" "404": $ref: "#/components/responses/not_found" /v1/table/{name}/create_index/: post: - description: Create vector index on a Table + description: Create an index on a table tags: - Tables - summary: Create vector index on a Table + summary: Create an index on a table operationId: createIndex parameters: - $ref: "#/components/parameters/table_name" @@ -453,6 +464,17 @@ paths: The metric type to use for the index. l2, Cosine, Dot are supported. index_type: type: string + custom_stop_words: + type: [array, "null"] + items: + type: string + description: | + The resolved custom stop-word snapshot for an FTS index. A non-null + array replaces the language's built-in stop-word list and is only + applied when remove_stop_words is enabled. Null uses the built-in + language list, while an empty array explicitly replaces it with no + stop words. Clients must resolve local file or table sources before + sending this request; paths and table references are not accepted. responses: "200": description: Index successfully created @@ -510,4 +532,4 @@ paths: "401": $ref: "#/components/responses/unauthorized" "404": - $ref: "#/components/responses/not_found" \ No newline at end of file + $ref: "#/components/responses/not_found" diff --git a/docs/src/java/java.md b/docs/src/java/java.md index 891cc1674..190f5a1fc 100644 --- a/docs/src/java/java.md +++ b/docs/src/java/java.md @@ -539,6 +539,91 @@ CreateTableScalarIndexResponse response = namespaceClient.createTableScalarIndex System.out.println("Index transaction: " + response.getTransactionId()); ``` +### Custom FTS Stop Words + +`LanceDbFtsIndexRequest` accepts either an inline list or a newline-delimited +UTF-8 file on the Java client. The client resolves the source to a concrete +snapshot before the request is sent; a client-local path is never sent to +LanceDB Cloud or Enterprise. + +Custom stop words replace the built-in list for `language` and are only applied +when `removeStopWords` is true. `null` keeps the built-in language list, while +an empty list explicitly replaces it with no stop words. Exact empty strings +are ignored and exact duplicates retain their first occurrence. Other content, +including case and surrounding whitespace, is preserved. + +```java +import com.lancedb.LanceDbFtsIndexClient; +import com.lancedb.LanceDbFtsIndexRequest; +import com.lancedb.LanceDbNamespaceClientBuilder; +import org.lance.namespace.model.CreateTableIndexResponse; + +import java.util.Arrays; + +public final class CustomStopWordsExample { + public static void main(String[] args) throws Exception { + LanceDbNamespaceClientBuilder clientBuilder = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("your_lancedb_api_key") + .database("your_database_name"); + + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setId(Arrays.asList("my_namespace", "my_table")); + request.setColumn("text_column"); + request.setName("text_idx"); + request.setRemoveStopWords(true); + request.setCustomStopWords(Arrays.asList("copyright", "reserved")); + + try (LanceDbFtsIndexClient indexClient = clientBuilder.buildFtsIndexClient()) { + CreateTableIndexResponse response = indexClient.createTableIndex(request); + System.out.println("Index transaction: " + response.getTransactionId()); + } + } +} +``` + +To read the snapshot from a file instead, configure the file source before +calling `createTableIndex`: + +```java +import java.nio.file.Paths; + +request.setCustomStopWordsFile(Paths.get("./stop-words.txt")); +``` + +The file is decoded as strict UTF-8 and read on the first +`createTableIndex` call. Lines use LF or CRLF terminators; a lone carriage +return is preserved as part of the stop word. The resolved list is then +retained on the request, so a retry uses the same snapshot even if the file +changes. + +!!! warning "Use the dedicated FTS index client for custom stop words" + Do not pass `LanceDbFtsIndexRequest` to + `namespaceClient.createTableIndex` or + `namespaceClient.createTableScalarIndex`. The current generated Lance + Namespace model has no `custom_stop_words` field, and its JNI boundary would + silently discard the extra JSON property. `LanceDbFtsIndexClient` uses the + official HTTP client directly and preserves the resolved snapshot. + + The helper accepts `endpoint`, API key, database, `delimiter`, and + `header.*`/`headers.*` builder configuration. It rejects transport settings + it cannot safely reproduce, such as `tls.*`; callers needing a custom TLS + stack can construct it with a preconfigured Namespace Apache `ApiClient`. + It also rejects the `uri`, `header.x-api-key`, and + `header.x-lancedb-database` configuration aliases instead of silently + overriding them; use `endpoint()`, `apiKey()`, and `database()`, + respectively. + +!!! note "Java table and tokenization APIs" + The current Java SDK is a remote Namespace client and has no embedded/local + LanceDB `Table` API. It therefore cannot prove a complete table-column + snapshot and intentionally does not accept remote table references as a + stop-word source. Materialize those words into an inline list or a local + UTF-8 file first. + + Java also has no public standalone tokenization API today, so there is no + Java tokenization entry point to extend with custom stop words. + ### Listing Indexes ```java @@ -556,9 +641,14 @@ for (IndexContent index : response.getIndexes()) { ``` !!! note - The current Java namespace API exposes index type, index name, distance type, and full text search tokenizer options. - IVF training parameters such as `num_partitions` are not exposed by `CreateTableIndexRequest` yet. - To make those configurable from Java, the namespace API must add those fields first. + The current Java namespace API exposes index type, index name, distance type, + and the generated FTS fields `withPosition`, `baseTokenizer`, `language`, + `maxTokenLength`, `lowerCase`, `stem`, `removeStopWords`, and + `asciiFolding`. `LanceDbFtsIndexRequest` adds the custom stop-word snapshot + while the upstream generated request catches up. IVF training parameters + such as `num_partitions` are not exposed by `CreateTableIndexRequest` yet. + To make those configurable from Java, the namespace API must add those + fields first. ## Reading Query Results diff --git a/docs/src/js/classes/Index.md b/docs/src/js/classes/Index.md index 7b5033160..4b29ff41d 100644 --- a/docs/src/js/classes/Index.md +++ b/docs/src/js/classes/Index.md @@ -98,6 +98,42 @@ You can combine filters with full text search. [`Index`](Index.md) +#### Examples + +Use an inline stop-word snapshot: +```ts +await table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: ["copyright", "reserved"], + }), +}); +``` + +Read a newline-delimited UTF-8 file on the client: +```ts +await table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { source: "file", path: "./stop-words.txt" }, + }), +}); +``` + +Snapshot a string column from another local/native LanceDB table: +```ts +await table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { + source: "table", + table: stopWordsTable, + column: "word", + }, + }), +}); +``` + *** ### hnswPq() diff --git a/docs/src/js/classes/MatchQuery.md b/docs/src/js/classes/MatchQuery.md index 61fd434a5..25258a18d 100644 --- a/docs/src/js/classes/MatchQuery.md +++ b/docs/src/js/classes/MatchQuery.md @@ -42,6 +42,11 @@ Creates an instance of MatchQuery. - `maxExpansions`: The maximum number of terms to consider for fuzzy matching (default is 50). - `operator`: The logical operator to use for combining terms in the query (default is "OR"). - `prefixLength`: The number of beginning characters being unchanged for fuzzy matching. + Remote tables currently reject every explicit `fuzziness > 0` query + because the server protocol does not declare tokenizer-snapshot-safe fuzzy + search. Embedded/local tables reject it only when the indexed tokenizer + has an active custom stop-word snapshot. Omit `fuzziness` or use + `fuzziness: 0` for exact matching in either case. * **options.boost?**: `number` diff --git a/docs/src/js/globals.md b/docs/src/js/globals.md index d381a12fe..f457bff1e 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -77,6 +77,8 @@ - [FragmentStatistics](interfaces/FragmentStatistics.md) - [FragmentSummaryStats](interfaces/FragmentSummaryStats.md) - [FtsOptions](interfaces/FtsOptions.md) +- [FtsStopWordsFileSource](interfaces/FtsStopWordsFileSource.md) +- [FtsStopWordsTableSource](interfaces/FtsStopWordsTableSource.md) - [FtsToken](interfaces/FtsToken.md) - [FullTextQuery](interfaces/FullTextQuery.md) - [FullTextSearchOptions](interfaces/FullTextSearchOptions.md) diff --git a/docs/src/js/interfaces/FtsOptions.md b/docs/src/js/interfaces/FtsOptions.md index 74a571fcb..2f602a965 100644 --- a/docs/src/js/interfaces/FtsOptions.md +++ b/docs/src/js/interfaces/FtsOptions.md @@ -59,27 +59,26 @@ the experimental FTS V3 format and may introduce breaking changes. ### customStopWords? ```ts -optional customStopWords: string[]; +optional customStopWords: CustomStopWordsSource; ``` -Custom stop words that replace the built-in language list. +Custom stop words that replace the built-in list for `language`. -`undefined` uses the built-in list while `[]` explicitly selects no stop -words. This only affects tokenization when `removeStopWords` is true. +This option only affects tokenization when `removeStopWords` is true. The +source can be an inline string array, a newline-delimited UTF-8 file on +this client, or a string column from a local/native LanceDB table. +Remote table sources are rejected because they cannot currently guarantee +a complete snapshot; the target table may still be remote. -*** +`undefined` keeps the built-in language list. An empty array explicitly +replaces it with no stop words. -### customStopWordsSource? - -```ts -optional customStopWordsSource: CustomStopWordsSource; -``` - -Request-only source resolved by a remote LanceDB service. - -This is mutually exclusive with `customStopWords`. Local native tables -reject source descriptors; load the source explicitly and pass -`customStopWords` instead. +Embedded/local fuzzy queries with `fuzziness > 0` are rejected while this +snapshot and `removeStopWords` are active. Use `fuzziness: 0`, or omit the +custom snapshot. Remote tables reject every explicit `fuzziness > 0` +query, regardless of stop-word configuration, because the server protocol +does not declare tokenizer-snapshot-safe fuzzy search; omit `fuzziness` or +use `fuzziness: 0`. *** diff --git a/docs/src/js/interfaces/FtsStopWordsFileSource.md b/docs/src/js/interfaces/FtsStopWordsFileSource.md new file mode 100644 index 000000000..cb8c72520 --- /dev/null +++ b/docs/src/js/interfaces/FtsStopWordsFileSource.md @@ -0,0 +1,29 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / FtsStopWordsFileSource + +# Interface: FtsStopWordsFileSource + +A newline-delimited UTF-8 custom stop-words file on this client. + +## Properties + +### path + +```ts +path: string; +``` + +Path to the stop-words file on the client. + +*** + +### source + +```ts +source: "file"; +``` + +Select a newline-delimited UTF-8 file. diff --git a/docs/src/js/interfaces/FtsStopWordsTableSource.md b/docs/src/js/interfaces/FtsStopWordsTableSource.md new file mode 100644 index 000000000..040199dde --- /dev/null +++ b/docs/src/js/interfaces/FtsStopWordsTableSource.md @@ -0,0 +1,42 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / FtsStopWordsTableSource + +# Interface: FtsStopWordsTableSource + +A custom stop-words snapshot read from a LanceDB table column. + +## Properties + +### column + +```ts +column: string; +``` + +Name of the string column containing the stop words. + +*** + +### source + +```ts +source: "table"; +``` + +Select a LanceDB table column. + +*** + +### table + +```ts +table: Table; +``` + +Local/native table containing the stop words. + +A remote table cannot be used as the source. Materialize its stop-word +column locally first, or use an inline list or UTF-8 file. diff --git a/docs/src/js/interfaces/TokenizeOptions.md b/docs/src/js/interfaces/TokenizeOptions.md index f061356d1..b366db3d1 100644 --- a/docs/src/js/interfaces/TokenizeOptions.md +++ b/docs/src/js/interfaces/TokenizeOptions.md @@ -30,6 +30,25 @@ The tokenizer to use. The default is "simple". *** +### customStopWords? + +```ts +optional customStopWords: CustomStopWordsSource; +``` + +Custom stop words that replace the built-in list for `language`. + +The source can be an inline string array, a newline-delimited UTF-8 file +on this client, or a string column from a local/native LanceDB table. +Remote table sources are rejected because they cannot currently guarantee +a complete snapshot. This option is only applied when `removeStopWords` is +true. + +`undefined` keeps the built-in language list. An empty array explicitly +replaces it with no stop words. + +*** + ### language? ```ts diff --git a/docs/src/js/type-aliases/CustomStopWordsSource.md b/docs/src/js/type-aliases/CustomStopWordsSource.md index b5562ff49..4ab5629a1 100644 --- a/docs/src/js/type-aliases/CustomStopWordsSource.md +++ b/docs/src/js/type-aliases/CustomStopWordsSource.md @@ -7,28 +7,40 @@ # Type Alias: CustomStopWordsSource ```ts -type CustomStopWordsSource: object | object | object; +type CustomStopWordsSource: string[] | FtsStopWordsFileSource | FtsStopWordsTableSource; ``` -A request-only source for custom FTS stop words. +A source for custom full-text-search stop words. -Remote services resolve file and table descriptors into a stable snapshot. -Local native tables reject source descriptors. - -## Example +An inline array supplies the entries directly. A file is read as UTF-8 with +one stop word per line. A table source reads stop words from the selected +string column of a local/native LanceDB table. Remote table sources are +rejected because the client cannot currently guarantee a complete snapshot. ```ts -const inline = { - type: "inline", - words: ["the", "a"], -} satisfies CustomStopWordsSource; -const file = { - type: "file", - uri: "s3://bucket/stop-words.txt", -} satisfies CustomStopWordsSource; -const table = { - type: "table", - table: "catalog.schema.stop_words", +const inline: CustomStopWordsSource = ["copyright", "reserved"]; +const file: CustomStopWordsSource = { + source: "file", + path: "./stop-words.txt", +}; +const tableColumn: CustomStopWordsSource = { + source: "table", + table: stopWordsTable, column: "word", -} satisfies CustomStopWordsSource; +}; ``` + +Empty strings are ignored and exact duplicates are removed while preserving +the first occurrence. Values are otherwise preserved exactly: LanceDB does +not trim them, lowercase them, or otherwise normalize their contents. +Embedded/local fuzzy queries fail closed when `fuzziness` is greater than +zero and a custom snapshot is active; `fuzziness: 0` and indexes without a +custom snapshot continue to work normally. Remote tables currently reject +every explicit `fuzziness > 0` query because the server protocol does not +declare tokenizer-snapshot-safe fuzzy search; omit `fuzziness` or use +`fuzziness: 0`. + +The source is resolved when the index is created, and the resulting list is +stored as a stable index snapshot. Standalone `tokenize` resolves the same +kind of one-call snapshot. File paths are always read by this client, never +by a remote LanceDB service. diff --git a/docs/src/python/python.md b/docs/src/python/python.md index 1e928be54..af784e430 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -94,12 +94,118 @@ of raw SQL strings with [where][lancedb.query.LanceQueryBuilder.where] and ## Full text search -Use [lancedb.table.Table.create_fts_index][] for the synchronous API or -[lancedb.table.AsyncTable.create_index][] with [lancedb.index.FTS][] for the -asynchronous API. +Use [lancedb.table.Table.create_index][] or +[lancedb.table.AsyncTable.create_index][] with [lancedb.index.FTS][]: + +```python +from lancedb.index import FTS + +table.create_index( + "text", + config=FTS( + remove_stop_words=True, + custom_stop_words=["acme", "internal"], + ), +) +``` + +The asynchronous form uses the same configuration: + +```python +await async_table.create_index("text", config=FTS(custom_stop_words=["acme"])) +``` + +### Custom stop-word sources + +`custom_stop_words` accepts exactly one source: an inline list, a UTF-8 +newline-delimited file, or a string column from an open local LanceDB table. + +```python +from lancedb.index import FTS, FtsStopWordsFile, FtsStopWordsTable + +# Inline snapshot +inline = FTS(custom_stop_words=["acme", "internal"]) + +# One stop word per line in a strict UTF-8 file +from_file = FTS(custom_stop_words=FtsStopWordsFile("stop_words.txt")) + +# Values from the "word" string column of an already opened local table +stop_words_table = db.open_table("stop_words") +from_table = FTS( + custom_stop_words=FtsStopWordsTable(stop_words_table, "word") +) + +table.create_index("text", config=from_table) +``` + +The source is resolved when `create_index` runs. LanceDB stores the resulting +word-list snapshot with the index, so reopening, rebuilding, querying, and +index-backed tokenization use the same words. A remote target index may use a +client-local file or local LanceDB table source: the client resolves it first +and sends only the concrete string list, so the service never attempts to open +a client-local path or interpret a table reference. A remote table cannot +itself be a source because the client cannot guarantee an untruncated snapshot; +materialize that column into a local table or pass an inline list instead. +Changing the original file or source table does not change an existing index; +recreate the index to take a new snapshot. + +The stop-word semantics are: + +- `custom_stop_words=None` uses the built-in list for `language`. +- `custom_stop_words=[]` explicitly replaces the built-in list with an empty + list, so no stop words are removed. +- A non-empty custom list replaces the built-in language list; it is not + appended to it. +- The configured snapshot is only applied when `remove_stop_words=True`. +- LanceDB does not trim, lowercase, or otherwise rewrite stop words. Exact + duplicates are removed while preserving the first occurrence. Exact empty + entries, including empty file lines, are ignored. +- File decoding is strict UTF-8. Table sources require the named column to + contain only non-null strings. Missing or unreadable files, invalid UTF-8, + empty paths or column names, unavailable tables, remote table sources, + missing columns, non-string columns, and null values raise an error instead + of silently dropping words. + +Local FTS queries with active custom stop words reject an explicit +`fuzziness > 0`: upstream fuzzy matching does not reuse the index's complete +tokenizer configuration, so LanceDB fails closed instead of silently applying +different stop-word semantics. An omitted fuzziness value and `fuzziness=0` +continue to use the configured snapshot. Remote tables temporarily reject every +explicitly positive `fuzziness` before sending a query request, even without +custom stop words, because the service does not yet expose a capability that +atomically binds the query to its tokenizer snapshot; this also avoids a +list-details/query time-of-check/time-of-use race. Native namespace pushdown +routes explicitly fuzzy queries to local execution, where the local rule +applies. + +The standalone tokenizer accepts the same source forms: + +```python +import lancedb +from lancedb.index import FtsStopWordsFile + +tokens = list( + lancedb.tokenize( + "acme makes searchable data", + custom_stop_words=FtsStopWordsFile("stop_words.txt"), + ) +) +``` + +`table.tokenize(...)` and `async_table.tokenize(...)` instead load the +persisted snapshot from the selected FTS index. The deprecated +[lancedb.table.Table.create_fts_index][] also accepts `custom_stop_words` for +compatibility, but new code should use `create_index(..., config=FTS(...))`. +When that deprecated helper also receives `tokenizer_name`, it preserves the +alias's historical `remove_stop_words=False`: the snapshot is persisted but +inactive. Use the modern `FTS(remove_stop_words=True, ...)` form to enable it. ::: lancedb.index.FTS +::: lancedb.index.FtsStopWordsFile + +::: lancedb.index.FtsStopWordsTable + ## Utilities ::: lancedb.schema.vector diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index eb47e25f3..95750a857 100644 --- a/java/lancedb-core/pom.xml +++ b/java/lancedb-core/pom.xml @@ -23,6 +23,17 @@ lance-core + + + org.lance + lance-namespace-apache-client + + org.apache.arrow arrow-vector diff --git a/java/lancedb-core/src/main/java/com/lancedb/LanceDbFtsIndexClient.java b/java/lancedb-core/src/main/java/com/lancedb/LanceDbFtsIndexClient.java new file mode 100644 index 000000000..d2b1c2b7c --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/LanceDbFtsIndexClient.java @@ -0,0 +1,118 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import org.lance.namespace.client.apache.ApiClient; +import org.lance.namespace.client.apache.ApiException; +import org.lance.namespace.client.apache.api.IndexApi; +import org.lance.namespace.model.CreateTableIndexResponse; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** + * HTTP client for creating LanceDB FTS indexes with custom stop words. + * + *

This deliberately bypasses the current JNI-backed namespace index method. That method + * deserializes requests into an upstream generated Rust model which does not yet have {@code + * custom_stop_words}, silently dropping the resolved snapshot before the HTTP request. + * + *

Prefer {@link LanceDbNamespaceClientBuilder#buildFtsIndexClient()}. The public constructor is + * available for callers that need to supply a custom Apache HTTP stack, such as mTLS. This client + * owns the supplied {@link ApiClient}'s HTTP client and closes it from {@link #close()}. + */ +public final class LanceDbFtsIndexClient implements Closeable { + private final ApiClient apiClient; + private final IndexApi indexApi; + private final String delimiter; + + /** + * Create a client using a fully configured official Lance Namespace Apache client. + * + * @param apiClient configured HTTP client; ownership transfers to this instance + * @param delimiter namespace identifier delimiter, normally {@code $} + */ + public LanceDbFtsIndexClient(ApiClient apiClient, String delimiter) { + this(apiClient, new IndexApi(Objects.requireNonNull(apiClient, "apiClient")), delimiter); + } + + LanceDbFtsIndexClient(ApiClient apiClient, IndexApi indexApi, String delimiter) { + this.apiClient = Objects.requireNonNull(apiClient, "apiClient"); + this.indexApi = Objects.requireNonNull(indexApi, "indexApi"); + if (delimiter == null || delimiter.isEmpty()) { + throw new IllegalArgumentException("delimiter cannot be null or empty"); + } + this.delimiter = delimiter; + } + + /** + * Resolve any client-local stop-word file and create the FTS index. + * + *

The outgoing JSON contains only the concrete {@code custom_stop_words} list (including a + * meaningful distinction between {@code null} and an empty list), never a client-local path. + * + * @param request FTS index request + * @return asynchronous index-creation transaction response + * @throws IOException if a local stop-word file cannot be read as strict UTF-8 + * @throws ApiException if the LanceDB HTTP request fails + */ + public CreateTableIndexResponse createTableIndex(LanceDbFtsIndexRequest request) + throws IOException, ApiException { + validateRequest(request); + request.resolveCustomStopWordsSnapshot(); + String id = joinIdentifier(request.getId()); + return indexApi.createTableIndex(id, request, delimiter); + } + + private void validateRequest(LanceDbFtsIndexRequest request) { + if (request == null) { + throw new IllegalArgumentException("FTS index request cannot be null"); + } + if (!"FTS".equals(request.getIndexType())) { + throw new IllegalArgumentException("FTS index request index type must be FTS"); + } + if (request.getColumn() == null || request.getColumn().trim().isEmpty()) { + throw new IllegalArgumentException("FTS index request requires a non-empty column"); + } + List id = request.getId(); + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("FTS index request requires a non-empty table id"); + } + for (int index = 0; index < id.size(); index++) { + String part = id.get(index); + if (part == null || part.isEmpty()) { + throw new IllegalArgumentException( + "FTS index request table id part at index " + index + " cannot be null or empty"); + } + } + } + + private String joinIdentifier(List id) { + StringBuilder result = new StringBuilder(); + for (String part : id) { + if (result.length() > 0) { + result.append(delimiter); + } + result.append(part); + } + return result.toString(); + } + + @Override + public void close() throws IOException { + apiClient.getHttpClient().close(); + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/LanceDbFtsIndexRequest.java b/java/lancedb-core/src/main/java/com/lancedb/LanceDbFtsIndexRequest.java new file mode 100644 index 000000000..9bdbc35de --- /dev/null +++ b/java/lancedb-core/src/main/java/com/lancedb/LanceDbFtsIndexRequest.java @@ -0,0 +1,315 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.lance.namespace.model.CreateTableIndexRequest; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * FTS index request with a resolved custom stop-word snapshot. + * + *

Custom stop words replace the built-in list for {@link #getLanguage()} and only affect + * tokenization when {@link #getRemoveStopWords()} is {@code true}. A {@code null} list keeps the + * built-in language list. An empty list explicitly replaces it with no stop words. + * + *

Use exactly one source: {@link #setCustomStopWords(List)} for an inline list or {@link + * #setCustomStopWordsFile(Path)} for a newline-delimited UTF-8 file on the client. Exact empty + * strings are removed and exact duplicates retain their first occurrence. Other content, including + * case and surrounding whitespace, is preserved verbatim. File lines use LF or CRLF terminators; a + * lone carriage return is part of the stop word. + * + *

The file is read when {@link LanceDbFtsIndexClient#createTableIndex(LanceDbFtsIndexRequest)} + * is first called. The resulting owned list is then reused, so retries cannot silently pick up a + * different file. Client-local paths are never serialized. + * + *

Do not pass this request to the JNI-backed {@code LanceNamespace.createTableIndex} method. + * Its upstream generated request model does not yet contain {@code custom_stop_words}, and the JNI + * layer would discard the extension field. Use {@link LanceDbFtsIndexClient}. + * + *

A LanceDB table source is not exposed here because the current Java SDK has no embedded/local + * Table API from which it could prove and materialize a complete snapshot. Remote table references + * are intentionally not sent to the server. + */ +public final class LanceDbFtsIndexRequest extends CreateTableIndexRequest { + public static final String JSON_PROPERTY_CUSTOM_STOP_WORDS = "custom_stop_words"; + + private enum StopWordsSource { + NONE, + INLINE, + FILE + } + + private List customStopWords; + + @JsonIgnore private transient StopWordsSource stopWordsSource = StopWordsSource.NONE; + @JsonIgnore private transient Path customStopWordsFile; + @JsonIgnore private transient boolean fileSnapshotResolved; + + /** Create an FTS request. The index type is always {@code FTS}. */ + public LanceDbFtsIndexRequest() { + super.setIndexType("FTS"); + } + + /** + * Return the resolved custom stop-word snapshot. + * + *

For a file source this remains {@code null} until the FTS index client resolves the file. + * + * @return {@code null} for the built-in language list, or the immutable custom snapshot + */ + @javax.annotation.Nullable + @JsonProperty(JSON_PROPERTY_CUSTOM_STOP_WORDS) + @JsonInclude(value = JsonInclude.Include.ALWAYS) + public List getCustomStopWords() { + return customStopWords; + } + + /** + * Set inline custom stop words. + * + * @param words custom stop words, {@code null} to use the built-in language list, or an empty + * list to explicitly use no stop words + * @throws IllegalArgumentException if an element is null or is not a string at runtime + * @throws IllegalStateException if a file source is already configured + */ + @JsonProperty(JSON_PROPERTY_CUSTOM_STOP_WORDS) + @JsonInclude(value = JsonInclude.Include.ALWAYS) + public void setCustomStopWords(@javax.annotation.Nullable List words) { + if (words == null) { + clearCustomStopWords(); + return; + } + requireCompatibleSource(StopWordsSource.INLINE); + customStopWords = snapshot(words); + customStopWordsFile = null; + fileSnapshotResolved = false; + stopWordsSource = StopWordsSource.INLINE; + } + + /** + * Fluent form of {@link #setCustomStopWords(List)}. + * + * @param words inline stop words + * @return this request + */ + public LanceDbFtsIndexRequest customStopWords(@javax.annotation.Nullable List words) { + setCustomStopWords(words); + return this; + } + + /** + * Configure a newline-delimited UTF-8 file source. + * + *

The path is client-local and is not serialized. It is read exactly once, on the first call + * to the FTS index client. Pass {@code null} to clear the source and use the built-in language + * list. + * + * @param path client-local stop-word file + * @throws IllegalArgumentException if the path is empty + * @throws IllegalStateException if an inline source is already configured + */ + @JsonIgnore + public void setCustomStopWordsFile(@javax.annotation.Nullable Path path) { + if (path == null) { + clearCustomStopWords(); + return; + } + if (path.toString().isEmpty()) { + throw new IllegalArgumentException( + "custom stop words file source requires a non-empty path"); + } + requireCompatibleSource(StopWordsSource.FILE); + customStopWords = null; + customStopWordsFile = path; + fileSnapshotResolved = false; + stopWordsSource = StopWordsSource.FILE; + } + + /** + * Fluent form of {@link #setCustomStopWordsFile(Path)}. + * + * @param path client-local stop-word file + * @return this request + */ + @JsonIgnore + public LanceDbFtsIndexRequest customStopWordsFile(@javax.annotation.Nullable Path path) { + setCustomStopWordsFile(path); + return this; + } + + /** + * Keep this specialized request constrained to FTS. + * + * @param indexType must be {@code FTS} + */ + @Override + public void setIndexType(String indexType) { + if (!"FTS".equals(indexType)) { + throw new IllegalArgumentException("LanceDbFtsIndexRequest index type must be FTS"); + } + super.setIndexType(indexType); + } + + /** + * Keep this specialized request constrained to FTS. + * + * @param indexType must be {@code FTS} + * @return this request + */ + @Override + public LanceDbFtsIndexRequest indexType(String indexType) { + setIndexType(indexType); + return this; + } + + void resolveCustomStopWordsSnapshot() throws IOException { + if (stopWordsSource != StopWordsSource.FILE || fileSnapshotResolved) { + return; + } + + byte[] bytes; + try { + bytes = Files.readAllBytes(customStopWordsFile); + } catch (IOException e) { + throw new IOException( + "failed to read custom stop words file `" + customStopWordsFile + "`: " + e.getMessage(), + e); + } + + String contents; + try { + contents = + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException e) { + throw new IOException( + "custom stop words file `" + customStopWordsFile + "` is not valid UTF-8", e); + } + + customStopWords = snapshot(splitLines(contents)); + fileSnapshotResolved = true; + } + + /** + * Match Rust {@code str::lines()}: split only on LF and remove a CR only when it belongs to a + * CRLF terminator. A lone CR is stop-word content and must be preserved. + */ + private static List splitLines(String contents) { + List lines = new ArrayList<>(); + int lineStart = 0; + for (int index = 0; index < contents.length(); index++) { + if (contents.charAt(index) != '\n') { + continue; + } + int lineEnd = index; + if (lineEnd > lineStart && contents.charAt(lineEnd - 1) == '\r') { + lineEnd--; + } + lines.add(contents.substring(lineStart, lineEnd)); + lineStart = index + 1; + } + if (lineStart < contents.length()) { + lines.add(contents.substring(lineStart)); + } + return lines; + } + + private void clearCustomStopWords() { + customStopWords = null; + customStopWordsFile = null; + fileSnapshotResolved = false; + stopWordsSource = StopWordsSource.NONE; + } + + private void requireCompatibleSource(StopWordsSource requested) { + if (stopWordsSource != StopWordsSource.NONE && stopWordsSource != requested) { + throw new IllegalStateException( + "custom stop words inline and file sources are mutually exclusive; clear the " + + stopWordsSource.name().toLowerCase(Locale.ROOT) + + " source before configuring a " + + requested.name().toLowerCase(Locale.ROOT) + + " source"); + } + } + + private static List snapshot(List words) { + Set seen = new LinkedHashSet<>(); + for (int index = 0; index < words.size(); index++) { + Object value = words.get(index); + if (value == null) { + throw new IllegalArgumentException( + "custom stop words inline value at index " + index + " cannot be null"); + } + if (!(value instanceof String)) { + throw new IllegalArgumentException( + "custom stop words inline value at index " + + index + + " must be a string, but was " + + value.getClass().getName()); + } + String word = (String) value; + if (!word.isEmpty()) { + seen.add(word); + } + } + return Collections.unmodifiableList(new ArrayList<>(seen)); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof LanceDbFtsIndexRequest)) { + return false; + } + LanceDbFtsIndexRequest that = (LanceDbFtsIndexRequest) other; + return super.equals(other) + && Objects.equals(customStopWords, that.customStopWords) + && stopWordsSource == that.stopWordsSource + && Objects.equals(customStopWordsFile, that.customStopWordsFile) + && fileSnapshotResolved == that.fileSnapshotResolved; + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), + customStopWords, + stopWordsSource, + customStopWordsFile, + fileSnapshotResolved); + } +} diff --git a/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java b/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java index 5e31aaaa1..497587464 100644 --- a/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java +++ b/java/lancedb-core/src/main/java/com/lancedb/LanceDbNamespaceClientBuilder.java @@ -14,6 +14,7 @@ package com.lancedb; import org.lance.namespace.LanceNamespace; +import org.lance.namespace.client.apache.ApiClient; import java.util.HashMap; import java.util.Map; @@ -136,29 +137,130 @@ public class LanceDbNamespaceClientBuilder { * @throws IllegalStateException if required parameters are missing */ public LanceNamespace build() { - // Validate required fields + validateRequiredFields(); + + // Build configuration map + Map config = new HashMap<>(additionalConfig); + config.put("header.x-lancedb-database", database); + config.put("header.x-api-key", apiKey); + config.put("uri", resolveUri()); + + return LanceNamespace.connect("rest", config, null); + } + + /** + * Build a client for creating FTS indexes with custom stop words. + * + *

The generated Lance Namespace request model currently has no {@code custom_stop_words} + * field. Passing a subclass through the JNI-backed {@link LanceNamespace} client would silently + * discard that field. This client uses the official HTTP transport directly so the resolved + * client-side snapshot reaches LanceDB Cloud or Enterprise. + * + *

The helper supports the same endpoint, API key, database, identifier delimiter, and {@code + * header.*}/{@code headers.*} configuration as {@link #build()}. TLS and other transport + * configuration cannot be copied safely to the Apache HTTP client and is rejected with an + * actionable error instead of being ignored. Callers needing a custom HTTP stack can construct + * {@link LanceDbFtsIndexClient} with a preconfigured {@link ApiClient}. + * + * @return A configured custom-stop-word FTS index client + * @throws IllegalStateException if required parameters are missing or an unsupported + * configuration key is present + */ + public LanceDbFtsIndexClient buildFtsIndexClient() { + validateRequiredFields(); + + String delimiter = additionalConfig.getOrDefault("delimiter", "$"); + if (delimiter == null || delimiter.isEmpty()) { + throw new IllegalStateException("FTS index client delimiter cannot be null or empty"); + } + String uri = resolveUri(); + if (uri.trim().isEmpty()) { + throw new IllegalStateException("FTS index client endpoint cannot be empty"); + } + + Map headers = new HashMap<>(); + for (Map.Entry entry : additionalConfig.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key == null) { + throw new IllegalStateException("FTS index client configuration key cannot be null"); + } + if ("delimiter".equals(key)) { + continue; + } + if ("uri".equals(key)) { + throw new IllegalStateException( + "FTS index client configuration key 'uri' is not accepted; use endpoint() instead"); + } + + String headerName = headerName(key); + if (headerName != null) { + if (headerName.isEmpty()) { + throw new IllegalStateException( + "FTS index client header configuration requires a non-empty header name"); + } + if (value == null) { + throw new IllegalStateException( + "FTS index client header '" + headerName + "' cannot have a null value"); + } + if (isReservedHeader(headerName)) { + String builderMethod = + "x-api-key".equalsIgnoreCase(headerName) ? "apiKey()" : "database()"; + throw new IllegalStateException( + "FTS index client header '" + + headerName + + "' is reserved; configure it with " + + builderMethod + + " instead"); + } + headers.put(headerName, value); + continue; + } + + throw new IllegalStateException( + "FTS index client cannot safely apply configuration key '" + + key + + "'. Configure an org.lance.namespace.client.apache.ApiClient explicitly and " + + "pass it to LanceDbFtsIndexClient instead."); + } + + ApiClient apiClient = new ApiClient().setBasePath(uri).setApiKey(apiKey); + for (Map.Entry header : headers.entrySet()) { + apiClient.addDefaultHeader(header.getKey(), header.getValue()); + } + apiClient.addDefaultHeader("x-lancedb-database", database); + return new LanceDbFtsIndexClient(apiClient, delimiter); + } + + private void validateRequiredFields() { if (apiKey == null) { throw new IllegalStateException("API key is required"); } if (database == null) { throw new IllegalStateException("Database is required"); } + } - // Build configuration map - Map config = new HashMap<>(additionalConfig); - config.put("header.x-lancedb-database", database); - config.put("header.x-api-key", apiKey); - - // Determine base URL - String uri; + private String resolveUri() { if (endpoint.isPresent()) { - uri = endpoint.get(); - } else { - String effectiveRegion = region.orElse(DEFAULT_REGION); - uri = String.format(CLOUD_URL_PATTERN, database, effectiveRegion); + return endpoint.get(); } - config.put("uri", uri); + String effectiveRegion = region.orElse(DEFAULT_REGION); + return String.format(CLOUD_URL_PATTERN, database, effectiveRegion); + } - return LanceNamespace.connect("rest", config, null); + private static String headerName(String key) { + if (key.startsWith("header.")) { + return key.substring("header.".length()); + } + if (key.startsWith("headers.")) { + return key.substring("headers.".length()); + } + return null; + } + + private static boolean isReservedHeader(String headerName) { + return "x-api-key".equalsIgnoreCase(headerName) + || "x-lancedb-database".equalsIgnoreCase(headerName); } } diff --git a/java/lancedb-core/src/test/java/com/lancedb/LanceDbFtsIndexClientTest.java b/java/lancedb-core/src/test/java/com/lancedb/LanceDbFtsIndexClientTest.java new file mode 100644 index 000000000..edb2d742a --- /dev/null +++ b/java/lancedb-core/src/test/java/com/lancedb/LanceDbFtsIndexClientTest.java @@ -0,0 +1,133 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.lance.namespace.client.apache.ApiClient; +import org.lance.namespace.client.apache.ApiException; +import org.lance.namespace.client.apache.api.IndexApi; +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableIndexResponse; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link LanceDbFtsIndexClient}. */ +public class LanceDbFtsIndexClientTest { + + @TempDir Path tempDir; + + @Test + public void testCreateIndexResolvesFileAndReusesSnapshotOnRetry() throws Exception { + ApiClient apiClient = new ApiClient(); + CreateTableIndexResponse response = new CreateTableIndexResponse(); + CapturingIndexApi indexApi = new CapturingIndexApi(apiClient, response); + LanceDbFtsIndexRequest request = request(); + Path path = tempDir.resolve("stop-words.txt"); + Files.write(path, "cat\ncat\n dog \n".getBytes(StandardCharsets.UTF_8)); + request.setCustomStopWordsFile(path); + + try (LanceDbFtsIndexClient client = new LanceDbFtsIndexClient(apiClient, indexApi, "$")) { + CreateTableIndexResponse actual = client.createTableIndex(request); + Files.write(path, "changed\n".getBytes(StandardCharsets.UTF_8)); + CreateTableIndexResponse retry = client.createTableIndex(request); + + assertSame(response, actual); + assertSame(response, retry); + assertEquals(Arrays.asList("cat", " dog "), request.getCustomStopWords()); + assertEquals("namespace$table", indexApi.id); + assertSame(request, indexApi.request); + assertEquals("$", indexApi.delimiter); + assertEquals(2, indexApi.calls); + } + } + + @Test + public void testCreateIndexValidatesRequiredFieldsBeforeNetworkCall() throws Exception { + ApiClient apiClient = new ApiClient(); + CapturingIndexApi indexApi = + new CapturingIndexApi(apiClient, new CreateTableIndexResponse()); + try (LanceDbFtsIndexClient client = new LanceDbFtsIndexClient(apiClient, indexApi, "$")) { + LanceDbFtsIndexRequest noColumn = new LanceDbFtsIndexRequest(); + noColumn.setId(Collections.singletonList("table")); + IllegalArgumentException columnError = + assertThrows(IllegalArgumentException.class, () -> client.createTableIndex(noColumn)); + assertTrue(columnError.getMessage().contains("non-empty column")); + + LanceDbFtsIndexRequest noId = new LanceDbFtsIndexRequest(); + noId.setColumn("text"); + IllegalArgumentException idError = + assertThrows(IllegalArgumentException.class, () -> client.createTableIndex(noId)); + assertTrue(idError.getMessage().contains("non-empty table id")); + assertEquals(0, indexApi.calls); + } + } + + @Test + public void testDelimiterMustBeNonEmpty() throws Exception { + ApiClient apiClient = new ApiClient(); + try { + CapturingIndexApi indexApi = + new CapturingIndexApi(apiClient, new CreateTableIndexResponse()); + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new LanceDbFtsIndexClient(apiClient, indexApi, "")); + + assertTrue(error.getMessage().contains("delimiter")); + } finally { + apiClient.getHttpClient().close(); + } + } + + private static LanceDbFtsIndexRequest request() { + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setId(Arrays.asList("namespace", "table")); + request.setColumn("text"); + request.setRemoveStopWords(true); + return request; + } + + private static final class CapturingIndexApi extends IndexApi { + private final CreateTableIndexResponse response; + private String id; + private CreateTableIndexRequest request; + private String delimiter; + private int calls; + + private CapturingIndexApi(ApiClient apiClient, CreateTableIndexResponse response) { + super(apiClient); + this.response = response; + } + + @Override + public CreateTableIndexResponse createTableIndex( + String id, CreateTableIndexRequest request, String delimiter) throws ApiException { + this.id = id; + this.request = request; + this.delimiter = delimiter; + calls++; + return response; + } + } +} diff --git a/java/lancedb-core/src/test/java/com/lancedb/LanceDbFtsIndexRequestTest.java b/java/lancedb-core/src/test/java/com/lancedb/LanceDbFtsIndexRequestTest.java new file mode 100644 index 000000000..753b954f7 --- /dev/null +++ b/java/lancedb-core/src/test/java/com/lancedb/LanceDbFtsIndexRequestTest.java @@ -0,0 +1,239 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lancedb; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.lance.namespace.client.apache.ApiClient; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link LanceDbFtsIndexRequest}. */ +public class LanceDbFtsIndexRequestTest { + + @TempDir Path tempDir; + + @Test + public void testInlineSnapshotPreservesContentAndCanonicalizesExactValues() { + List input = + new ArrayList<>(Arrays.asList("cat", "", " dog ", "cat", "CAT", " dog ")); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + + request.setCustomStopWords(input); + input.set(0, "mutated"); + + assertEquals(Arrays.asList("cat", " dog ", "CAT"), request.getCustomStopWords()); + assertThrows( + UnsupportedOperationException.class, + () -> request.getCustomStopWords().add("later mutation")); + } + + @Test + public void testNullAndEmptySnapshotsRemainDistinctInApacheClientJson() throws Exception { + ApiClient apiClient = new ApiClient(); + try { + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setColumn("text"); + + JsonNode nullJson = serialize(apiClient, request); + assertTrue(nullJson.has("custom_stop_words")); + assertTrue(nullJson.get("custom_stop_words").isNull()); + + request.setCustomStopWords(Collections.emptyList()); + JsonNode emptyJson = serialize(apiClient, request); + assertTrue(emptyJson.get("custom_stop_words").isArray()); + assertEquals(0, emptyJson.get("custom_stop_words").size()); + } finally { + apiClient.getHttpClient().close(); + } + } + + @Test + public void testInlineNullAndNonStringValuesAreRejected() { + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + + IllegalArgumentException nullError = + assertThrows( + IllegalArgumentException.class, + () -> request.setCustomStopWords(Arrays.asList("cat", null))); + assertTrue(nullError.getMessage().contains("index 1")); + assertTrue(nullError.getMessage().contains("cannot be null")); + + @SuppressWarnings({"rawtypes", "unchecked"}) + List invalid = (List) Arrays.asList("cat", 42); + IllegalArgumentException typeError = + assertThrows(IllegalArgumentException.class, () -> request.setCustomStopWords(invalid)); + assertTrue(typeError.getMessage().contains("index 1")); + assertTrue(typeError.getMessage().contains("must be a string")); + } + + @Test + public void testInlineAndFileSourcesAreMutuallyExclusive() { + Path path = tempDir.resolve("stop-words.txt"); + LanceDbFtsIndexRequest inline = new LanceDbFtsIndexRequest(); + inline.setCustomStopWords(Collections.emptyList()); + + IllegalStateException inlineError = + assertThrows(IllegalStateException.class, () -> inline.setCustomStopWordsFile(path)); + assertTrue(inlineError.getMessage().contains("mutually exclusive")); + + LanceDbFtsIndexRequest file = new LanceDbFtsIndexRequest(); + file.setCustomStopWordsFile(path); + IllegalStateException fileError = + assertThrows( + IllegalStateException.class, + () -> file.setCustomStopWords(Collections.singletonList("cat"))); + assertTrue(fileError.getMessage().contains("mutually exclusive")); + + inline.setCustomStopWords(null); + inline.setCustomStopWordsFile(path); + } + + @Test + public void testFileSnapshotUsesStrictUtf8AndIsStable() throws Exception { + Path path = tempDir.resolve("stop-words.txt"); + Files.write(path, "cat\n\n dog \r\ncat\nCAT\n".getBytes(StandardCharsets.UTF_8)); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setCustomStopWordsFile(path); + + assertNull(request.getCustomStopWords()); + request.resolveCustomStopWordsSnapshot(); + assertEquals(Arrays.asList("cat", " dog ", "CAT"), request.getCustomStopWords()); + + Files.write(path, "changed\n".getBytes(StandardCharsets.UTF_8)); + request.resolveCustomStopWordsSnapshot(); + assertEquals(Arrays.asList("cat", " dog ", "CAT"), request.getCustomStopWords()); + } + + @Test + public void testFilePathIsNeverSerialized() throws Exception { + Path path = tempDir.resolve("private-client-path.txt"); + Files.write(path, "cat\n".getBytes(StandardCharsets.UTF_8)); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setColumn("text"); + request.setCustomStopWordsFile(path); + request.resolveCustomStopWordsSnapshot(); + + ApiClient apiClient = new ApiClient(); + try { + JsonNode json = serialize(apiClient, request); + assertEquals(Collections.singletonList("cat"), request.getCustomStopWords()); + assertEquals("cat", json.get("custom_stop_words").get(0).asText()); + assertFalse(json.has("customStopWordsFile")); + assertFalse(json.has("custom_stop_words_file")); + assertFalse(json.toString().contains(path.toString())); + } finally { + apiClient.getHttpClient().close(); + } + } + + @Test + public void testEmptyFileProducesExplicitEmptySnapshot() throws Exception { + Path path = tempDir.resolve("empty.txt"); + Files.write(path, new byte[0]); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setCustomStopWordsFile(path); + + request.resolveCustomStopWordsSnapshot(); + + assertEquals(Collections.emptyList(), request.getCustomStopWords()); + } + + @Test + public void testLoneCarriageReturnIsPreservedAsContent() throws Exception { + Path path = tempDir.resolve("lone-cr.txt"); + Files.write(path, "cat\rdog\ncat\r\nbird\r".getBytes(StandardCharsets.UTF_8)); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setCustomStopWordsFile(path); + + request.resolveCustomStopWordsSnapshot(); + + assertEquals(Arrays.asList("cat\rdog", "cat", "bird\r"), request.getCustomStopWords()); + } + + @Test + public void testMissingFileIsRejectedWithPath() { + Path path = tempDir.resolve("missing.txt"); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setCustomStopWordsFile(path); + + IOException error = + assertThrows(IOException.class, request::resolveCustomStopWordsSnapshot); + + assertTrue(error.getMessage().contains("failed to read custom stop words file")); + assertTrue(error.getMessage().contains(path.toString())); + } + + @Test + public void testEmptyFilePathIsRejectedDescriptively() { + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> request.setCustomStopWordsFile(Paths.get(""))); + + assertTrue(error.getMessage().contains("file source requires a non-empty path")); + } + + @Test + public void testMalformedUtf8IsRejected() throws Exception { + Path path = tempDir.resolve("invalid.txt"); + Files.write(path, new byte[] {(byte) 0xc3, (byte) 0x28}); + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + request.setCustomStopWordsFile(path); + + IOException error = + assertThrows(IOException.class, request::resolveCustomStopWordsSnapshot); + + assertTrue(error.getMessage().contains("not valid UTF-8")); + } + + @Test + public void testRequestCannotBeChangedToAnotherIndexType() { + LanceDbFtsIndexRequest request = new LanceDbFtsIndexRequest(); + + assertEquals("FTS", request.getIndexType()); + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> request.setIndexType("BTREE")); + assertTrue(error.getMessage().contains("must be FTS")); + } + + private static JsonNode serialize(ApiClient apiClient, LanceDbFtsIndexRequest request) + throws Exception { + HttpEntity entity = + apiClient.serialize(request, Collections.emptyMap(), ContentType.APPLICATION_JSON); + String json = EntityUtils.toString(entity); + return new ObjectMapper().readTree(json); + } +} diff --git a/java/lancedb-core/src/test/java/com/lancedb/LanceDbNamespaceClientBuilderTest.java b/java/lancedb-core/src/test/java/com/lancedb/LanceDbNamespaceClientBuilderTest.java index ff69fdebd..71d7dfa89 100644 --- a/java/lancedb-core/src/test/java/com/lancedb/LanceDbNamespaceClientBuilderTest.java +++ b/java/lancedb-core/src/test/java/com/lancedb/LanceDbNamespaceClientBuilderTest.java @@ -93,4 +93,91 @@ public class LanceDbNamespaceClientBuilderTest { assertNotSame(builder1, builder2); } + + @Test + public void testFtsIndexClientRequiresApiKey() { + LanceDbNamespaceClientBuilder builder = + LanceDbNamespaceClientBuilder.newBuilder().database("test-db"); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, builder::buildFtsIndexClient); + assertEquals("API key is required", exception.getMessage()); + } + + @Test + public void testFtsIndexClientRequiresDatabase() { + LanceDbNamespaceClientBuilder builder = + LanceDbNamespaceClientBuilder.newBuilder().apiKey("test-api-key"); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, builder::buildFtsIndexClient); + assertEquals("Database is required", exception.getMessage()); + } + + @Test + public void testFtsIndexClientRejectsUnsupportedTransportConfiguration() { + LanceDbNamespaceClientBuilder builder = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("test-api-key") + .database("test-db") + .config("tls.cert_file", "/tmp/client.pem"); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, builder::buildFtsIndexClient); + + assertTrue(exception.getMessage().contains("tls.cert_file")); + assertTrue(exception.getMessage().contains("ApiClient")); + } + + @Test + public void testFtsIndexClientRejectsEmptyDelimiter() { + LanceDbNamespaceClientBuilder builder = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("test-api-key") + .database("test-db") + .config("delimiter", ""); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, builder::buildFtsIndexClient); + + assertTrue(exception.getMessage().contains("delimiter")); + } + + @Test + public void testFtsIndexClientRejectsUriConfigurationAlias() { + LanceDbNamespaceClientBuilder builder = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("test-api-key") + .database("test-db") + .config("uri", "https://example.invalid"); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, builder::buildFtsIndexClient); + + assertTrue(exception.getMessage().contains("uri")); + assertTrue(exception.getMessage().contains("endpoint()")); + } + + @Test + public void testFtsIndexClientRejectsReservedHeaders() { + LanceDbNamespaceClientBuilder apiKeyBuilder = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("test-api-key") + .database("test-db") + .config("header.x-api-key", "other-key"); + IllegalStateException apiKeyError = + assertThrows(IllegalStateException.class, apiKeyBuilder::buildFtsIndexClient); + assertTrue(apiKeyError.getMessage().contains("reserved")); + assertTrue(apiKeyError.getMessage().contains("apiKey()")); + + LanceDbNamespaceClientBuilder databaseBuilder = + LanceDbNamespaceClientBuilder.newBuilder() + .apiKey("test-api-key") + .database("test-db") + .config("headers.X-LanceDB-Database", "other-db"); + IllegalStateException databaseError = + assertThrows(IllegalStateException.class, databaseBuilder::buildFtsIndexClient); + assertTrue(databaseError.getMessage().contains("reserved")); + assertTrue(databaseError.getMessage().contains("database()")); + } } diff --git a/java/pom.xml b/java/pom.xml index ab8ec2cc8..94bc39cc0 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -29,6 +29,8 @@ UTF-8 15.0.0 10.0.0-beta.5 + + 0.7.7 false 2.30.0 1.7 @@ -67,6 +69,11 @@ lance-core ${lance-core.version} + + org.lance + lance-namespace-apache-client + ${lance-namespace.version} + org.apache.arrow arrow-vector diff --git a/nodejs/__test__/remote.test.ts b/nodejs/__test__/remote.test.ts index 7ed2a3eca..53c12858e 100644 --- a/nodejs/__test__/remote.test.ts +++ b/nodejs/__test__/remote.test.ts @@ -1,14 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors +import * as fs from "fs"; import * as http from "http"; import { RequestListener } from "http"; +import * as path from "path"; +import * as tmp from "tmp"; import { ClientConfig, Connection, ConnectionOptions, + MatchQuery, TlsConfig, connect, + tokenize, } from "../lancedb"; import { HeaderProvider, @@ -226,8 +231,8 @@ describe("remote connection", () => { ); }); - it("serializes FTS custom stop words and sources to remote tables", async () => { - const createIndexBodies: Record[] = []; + it("sends the FTS posting block size to remote tables", async () => { + let createIndexBody: Record | undefined; await withMockDatabase( (req, res) => { @@ -253,7 +258,7 @@ describe("remote connection", () => { raw += chunk; }); req.on("end", () => { - createIndexBodies.push(JSON.parse(raw)); + createIndexBody = JSON.parse(raw); res.writeHead(200).end(); }); return; @@ -266,59 +271,175 @@ describe("remote connection", () => { await table.createIndex("text", { config: Index.fts({ blockSize: 256 }), }); - await table.createIndex("text", { - config: Index.fts({ customStopWords: [] }), - }); - await table.createIndex("text", { - config: Index.fts({ customStopWords: ["lance", "database"] }), - }); - await table.createIndex("text", { - config: Index.fts({ - customStopWordsSource: { type: "inline", words: ["lance"] }, - }), - }); - await table.createIndex("text", { - config: Index.fts({ - customStopWordsSource: { - type: "file", - uri: "s3://bucket/stop-words.txt", - }, - }), - }); - await table.createIndex("text", { - config: Index.fts({ - customStopWordsSource: { - type: "table", - table: "stop_words", - column: "word", - }, - }), - }); }, ); - expect(createIndexBodies[0]["column"]).toBe("text"); - expect(createIndexBodies[0]["index_type"]).toBe("FTS"); - expect(createIndexBodies[0]["block_size"]).toBe(256); - expect(createIndexBodies[0]["custom_stop_words"]).toBeNull(); - expect(createIndexBodies[1]["custom_stop_words"]).toEqual([]); - expect(createIndexBodies[2]["custom_stop_words"]).toEqual([ - "lance", - "database", - ]); - expect(createIndexBodies[3]["custom_stop_words_source"]).toEqual({ - type: "inline", - words: ["lance"], - }); - expect(createIndexBodies[4]["custom_stop_words_source"]).toEqual({ - type: "file", - uri: "s3://bucket/stop-words.txt", - }); - expect(createIndexBodies[5]["custom_stop_words_source"]).toEqual({ - type: "table", - table: "stop_words", - column: "word", - }); + expect(createIndexBody?.["column"]).toBe("text"); + expect(createIndexBody?.["index_type"]).toBe("FTS"); + expect(createIndexBody?.["block_size"]).toBe(256); + }); + + it("resolves custom stop word sources before a remote request", async () => { + const tmpDir = tmp.dirSync({ unsafeCleanup: true }); + try { + const localDb = await connect(path.join(tmpDir.name, "local-db")); + const stopWordsTable = await localDb.createTable("dictionary_table", [ + { word: "the" }, + { word: "" }, + { word: "the" }, + ]); + const stopWordsPath = path.join(tmpDir.name, "stop-words.txt"); + fs.writeFileSync(stopWordsPath, "lance\n\nlance\n data \n", "utf8"); + + const requestBodies: Record[] = []; + await withMockDatabase( + (req, res) => { + if (req.url?.endsWith("/describe/")) { + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ + name: "t", + version: 1, + schema: { + fields: [ + { + name: "text", + type: { type: "string" }, + nullable: false, + }, + ], + }, + }), + ); + return; + } + + if (req.url?.endsWith("/create_index/")) { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + requestBodies.push(JSON.parse(raw)); + res.writeHead(200).end(); + }); + return; + } + + res.writeHead(404).end(); + }, + async (remoteDb) => { + const table = await remoteDb.openTable("t"); + await table.createIndex("text", { + config: Index.fts({ removeStopWords: true }), + }); + await table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: [], + }), + }); + await table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { source: "file", path: stopWordsPath }, + }), + }); + await table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { + source: "table", + table: stopWordsTable, + column: "word", + }, + }), + }); + await expect( + table.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { + source: "table", + table, + column: "text", + }, + }), + }), + ).rejects.toThrow( + "remote table sources cannot guarantee a complete snapshot", + ); + await expect( + tokenize("the lance", { + removeStopWords: true, + customStopWords: { + source: "table", + table, + column: "text", + }, + }), + ).rejects.toThrow( + "remote table sources cannot guarantee a complete snapshot", + ); + }, + ); + + expect(requestBodies).toHaveLength(4); + expect(requestBodies[0]["custom_stop_words"]).toBeNull(); + expect(requestBodies[1]["custom_stop_words"]).toEqual([]); + expect(requestBodies[2]["custom_stop_words"]).toEqual([ + "lance", + " data ", + ]); + expect(requestBodies[3]["custom_stop_words"]).toEqual(["the"]); + const serialized = JSON.stringify(requestBodies); + expect(serialized).not.toContain(stopWordsPath); + expect(serialized).not.toContain("dictionary_table"); + expect(serialized).not.toContain('"source"'); + expect(serialized).not.toContain('"path"'); + expect(serialized).not.toContain('"table"'); + expect(serialized).not.toContain('"column":"word"'); + } finally { + tmpDir.removeCallback(); + } + }); + + it("rejects explicit remote fuzzy FTS queries before the query request", async () => { + let requestCount = 0; + await withMockDatabase( + (req, res) => { + if (req.url?.endsWith("/describe/")) { + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ + name: "t", + version: 1, + schema: { + fields: [ + { + name: "text", + type: { type: "string" }, + nullable: false, + }, + ], + }, + }), + ); + return; + } + requestCount += 1; + res.writeHead(500).end(); + }, + async (remoteDb) => { + const table = await remoteDb.openTable("t"); + await expect( + table + .search(new MatchQuery("alph", "text", { fuzziness: 1 })) + .toArray(), + ).rejects.toThrow( + "explicit fuzzy FTS queries are not supported for remote tables", + ); + }, + ); + expect(requestCount).toBe(0); }); it("diffs and merges remote branches", async () => { diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 84776d574..19f7bbc36 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -42,7 +42,7 @@ import { getRegistry, register, } from "../lancedb/embedding"; -import { Index } from "../lancedb/indices"; +import { type CustomStopWordsSource, Index } from "../lancedb/indices"; import { BooleanQuery, Occur, @@ -2564,102 +2564,6 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( ); }); - test("preserves missing, empty, and non-empty custom stop words", async () => { - const db = await connect(tmpDir.name); - - const builtIn = await db.createTable("built_in_stop_words", [ - { text: "the lance database" }, - ]); - await builtIn.createIndex("text", { - config: Index.fts({ stem: false }), - }); - await expect( - builtIn.tokenize("the lance", { column: "text" }), - ).resolves.toEqual([{ text: "lance", position: 1 }]); - - const empty = await db.createTable("empty_custom_stop_words", [ - { text: "the lance database" }, - ]); - await empty.createIndex("text", { - config: Index.fts({ stem: false, customStopWords: [] }), - }); - await expect( - empty.tokenize("the lance", { column: "text" }), - ).resolves.toEqual([ - { text: "the", position: 0 }, - { text: "lance", position: 1 }, - ]); - - const custom = await db.createTable("non_empty_custom_stop_words", [ - { text: "the lance database" }, - ]); - await custom.createIndex("text", { - config: Index.fts({ - stem: false, - customStopWords: ["lance"], - }), - }); - await expect( - custom.tokenize("the lance", { column: "text" }), - ).resolves.toEqual([{ text: "the", position: 0 }]); - await expect(custom.search("the", "fts").toArray()).resolves.toHaveLength( - 1, - ); - await expect( - custom.search("lance", "fts").toArray(), - ).resolves.toHaveLength(0); - - const disabled = await db.createTable("disabled_custom_stop_words", [ - { text: "the lance database" }, - ]); - await disabled.createIndex("text", { - config: Index.fts({ - stem: false, - removeStopWords: false, - customStopWords: ["lance"], - }), - }); - await expect( - disabled.tokenize("the lance", { column: "text" }), - ).resolves.toEqual([ - { text: "the", position: 0 }, - { text: "lance", position: 1 }, - ]); - await expect( - disabled.search("lance", "fts").toArray(), - ).resolves.toHaveLength(1); - }); - - test("rejects custom stop-word sources on local tables", async () => { - const db = await connect(tmpDir.name); - const table = await db.createTable("source_stop_words", [ - { text: "lance database" }, - ]); - - await expect( - table.createIndex("text", { - config: Index.fts({ - customStopWordsSource: { - type: "file", - uri: "file:///tmp/stop-words.txt", - }, - }), - }), - ).rejects.toThrow("only supported for remote tables"); - }); - - test("rejects both custom stop-word forms", () => { - expect(() => - Index.fts({ - customStopWords: [], - customStopWordsSource: { - type: "file", - uri: "s3://bucket/stop-words.txt", - }, - }), - ).toThrow("mutually exclusive"); - }); - test("full text search without lowercase", async () => { const db = await connect(tmpDir.name); const data = [ @@ -2865,6 +2769,370 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( }, ); +describe("custom FTS stop words", () => { + let tmpDir: tmp.DirResult; + let db: Connection; + + beforeEach(async () => { + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + db = await connect(tmpDir.name); + }); + + afterEach(() => { + tmpDir.removeCallback(); + }); + + async function stopWordsSnapshot(table: Table): Promise { + const index = (await table.listIndices()).find( + (candidate) => candidate.indexType === "FTS", + ); + return (index?.indexDetails as Record | undefined)?.[ + "custom_stop_words" + ]; + } + + test("preserves undefined, empty, and custom snapshots", async () => { + const data = [{ text: "the alpha" }, { text: "lance beta" }]; + const builtin = await db.createTable("builtin", data); + await builtin.createIndex("text", { + config: Index.fts({ + stem: false, + removeStopWords: true, + }), + }); + expect(await stopWordsSnapshot(builtin)).toBeNull(); + expect( + (await builtin.tokenize("the lance", { column: "text" })).map( + (token) => token.text, + ), + ).toEqual(["lance"]); + + const empty = await db.createTable("empty", data); + await empty.createIndex("text", { + config: Index.fts({ + stem: false, + removeStopWords: true, + customStopWords: [], + }), + }); + expect(await stopWordsSnapshot(empty)).toEqual([]); + expect( + (await empty.tokenize("the lance", { column: "text" })).map( + (token) => token.text, + ), + ).toEqual(["the", "lance"]); + + const custom = await db.createTable("custom", data); + await custom.createIndex("text", { + config: Index.fts({ + stem: false, + removeStopWords: true, + customStopWords: ["", "lance", "lance", " data "], + }), + }); + expect(await stopWordsSnapshot(custom)).toEqual(["lance", " data "]); + expect( + (await custom.tokenize("the lance data", { column: "text" })).map( + (token) => token.text, + ), + ).toEqual(["the", "data"]); + await expect( + custom.search("the", "fts").limit(10).toArray(), + ).resolves.toHaveLength(1); + await expect( + custom + .search(new MatchQuery("alpha", "text", { fuzziness: 1 })) + .toArray(), + ).rejects.toThrow("uses a custom stop-word snapshot"); + await expect( + custom + .search(new MatchQuery("alpha", "text", { fuzziness: 0 })) + .toArray(), + ).resolves.toHaveLength(1); + await expect( + builtin + .search(new MatchQuery("alpha", "text", { fuzziness: 1 })) + .toArray(), + ).resolves.toBeDefined(); + + custom.close(); + const reopened = await db.openTable("custom"); + expect( + (await reopened.tokenize("the lance data", { column: "text" })).map( + (token) => token.text, + ), + ).toEqual(["the", "data"]); + await expect( + reopened.search("the", "fts").limit(10).toArray(), + ).resolves.toHaveLength(1); + + const disabled = await db.createTable("disabled", data); + await disabled.createIndex("text", { + config: Index.fts({ + stem: false, + removeStopWords: false, + customStopWords: ["lance"], + }), + }); + expect( + (await disabled.tokenize("the lance data", { column: "text" })).map( + (token) => token.text, + ), + ).toEqual(["the", "lance", "data"]); + await expect( + disabled + .search(new MatchQuery("alpha", "text", { fuzziness: 1 })) + .toArray(), + ).resolves.toHaveLength(1); + }); + + test("uses inline, file, and table sources in standalone tokenize", async () => { + const stopWordsPath = path.join(tmpDir.name, "stop-words.txt"); + fs.writeFileSync(stopWordsPath, "the\n\nlance\nthe\n", "utf8"); + const stopWordsTable = await db.createTable("stop_words", [ + { word: "the" }, + { word: "" }, + { word: "lance" }, + { word: "the" }, + ]); + const common = { + baseTokenizer: "simple" as const, + stem: false, + removeStopWords: true, + }; + const texts = async (customStopWords?: CustomStopWordsSource) => + ( + await tokenize("the lance data", { + ...common, + customStopWords, + }) + ).map((token) => token.text); + + expect(await texts()).toEqual(["lance", "data"]); + expect(await texts([])).toEqual(["the", "lance", "data"]); + expect(await texts(["lance"])).toEqual(["the", "data"]); + expect(await texts({ source: "file", path: stopWordsPath })).toEqual([ + "data", + ]); + expect( + await texts({ + source: "table", + table: stopWordsTable, + column: "word", + }), + ).toEqual(["data"]); + expect( + ( + await tokenize("the lance data", { + ...common, + removeStopWords: false, + customStopWords: ["lance"], + }) + ).map((token) => token.text), + ).toEqual(["the", "lance", "data"]); + }); + + test("snapshots file and table sources when creating an index", async () => { + const stopWordsPath = path.join(tmpDir.name, "index-stop-words.txt"); + fs.writeFileSync(stopWordsPath, "lance\nlance\n\n data \n", "utf8"); + const fileTarget = await db.createTable("file_target", [ + { text: "the lance data" }, + ]); + await fileTarget.createIndex("text", { + config: Index.fts({ + stem: false, + removeStopWords: true, + customStopWords: { source: "file", path: stopWordsPath }, + }), + }); + expect(await stopWordsSnapshot(fileTarget)).toEqual(["lance", " data "]); + fs.writeFileSync(stopWordsPath, "the\n", "utf8"); + fileTarget.close(); + const reopenedFileTarget = await db.openTable("file_target"); + expect( + ( + await reopenedFileTarget.tokenize("the lance data", { column: "text" }) + ).map((token) => token.text), + ).toEqual(["the", "data"]); + + const sourceTable = await db.createTable("table_stop_words", [ + { word: "lance" }, + { word: "" }, + { word: "lance" }, + ]); + const tableTarget = await db.createTable("table_target", [ + { text: "the lance data" }, + ]); + await tableTarget.createIndex("text", { + config: Index.fts({ + stem: false, + removeStopWords: true, + customStopWords: { + source: "table", + table: sourceTable, + column: "word", + }, + }), + }); + expect(await stopWordsSnapshot(tableTarget)).toEqual(["lance"]); + await sourceTable.add([{ word: "the" }]); + expect( + (await tableTarget.tokenize("the lance data", { column: "text" })).map( + (token) => token.text, + ), + ).toEqual(["the", "data"]); + }); + + test("rejects malformed sources and source read errors", async () => { + expect(() => + Index.fts({ + customStopWords: ["valid", 42] as unknown as string[], + }), + ).toThrow("customStopWords[1] must be a string"); + expect(() => + Index.fts({ customStopWords: null as unknown as string[] }), + ).toThrow("customStopWords must be"); + expect(() => + Index.fts({ + customStopWords: { + source: "file", + path: "words.txt", + column: "word", + } as never, + }), + ).toThrow("mutually exclusive"); + expect(() => + Index.fts({ + customStopWords: { source: "file", path: "" }, + }), + ).toThrow("non-empty string 'path'"); + expect(() => + Index.fts({ + customStopWords: { + source: "table", + table: {} as Table, + column: "", + }, + }), + ).toThrow("non-empty string 'column'"); + + await expect( + tokenize("hello", { + removeStopWords: true, + customStopWords: { + source: "file", + path: path.join(tmpDir.name, "missing.txt"), + }, + }), + ).rejects.toThrow("failed to read custom stop words file"); + + const reusableConfig = Index.fts({ + stem: false, + removeStopWords: true, + customStopWords: { + source: "file", + path: path.join(tmpDir.name, "retry-stop-words.txt"), + }, + }); + const retryTarget = await db.createTable("retry_target", [ + { text: "hello world" }, + ]); + await expect( + retryTarget.createIndex("text", { config: reusableConfig }), + ).rejects.toThrow("failed to read custom stop words file"); + fs.writeFileSync( + path.join(tmpDir.name, "retry-stop-words.txt"), + "hello\n", + "utf8", + ); + await expect( + retryTarget.createIndex("text", { config: reusableConfig }), + ).resolves.toBeUndefined(); + + const invalidUtf8Path = path.join(tmpDir.name, "invalid-utf8.txt"); + fs.writeFileSync(invalidUtf8Path, Buffer.from([0xff, 0xfe])); + await expect( + tokenize("hello", { + removeStopWords: true, + customStopWords: { source: "file", path: invalidUtf8Path }, + }), + ).rejects.toThrow("is not valid UTF-8"); + + const words = await db.createTable("error_words", [{ word: "lance" }]); + await expect( + tokenize("hello", { + removeStopWords: true, + customStopWords: { + source: "table", + table: words, + column: "missing", + }, + }), + ).rejects.toThrow("custom stop words column"); + + const numbers = await db.createTable("number_words", [{ word: 1 }]); + await expect( + tokenize("hello", { + removeStopWords: true, + customStopWords: { + source: "table", + table: numbers, + column: "word", + }, + }), + ).rejects.toThrow("must have type Utf8"); + + const nullableSchema = new arrow.Schema([ + new arrow.Field("word", new arrow.Utf8(), true), + ]); + const nullableWords = await db.createTable( + "nullable_words", + [{ word: "lance" }, { word: null }], + { schema: nullableSchema }, + ); + await expect( + tokenize("hello", { + removeStopWords: true, + customStopWords: { + source: "table", + table: nullableWords, + column: "word", + }, + }), + ).rejects.toThrow("contains NULL"); + + const target = await db.createTable("closed_source_target", [ + { text: "hello" }, + ]); + words.close(); + await expect( + target.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { + source: "table", + table: words, + column: "word", + }, + }), + }), + ).rejects.toThrow("is closed; open the table"); + + await expect( + target.createIndex("text", { + config: Index.fts({ + removeStopWords: true, + customStopWords: { + source: "table", + table: {} as Table, + column: "word", + }, + }), + }), + ).rejects.toThrow("created by this @lancedb/lancedb package instance"); + }); +}); + describe("when calling explainPlan", () => { let tmpDir: tmp.DirResult; let table: Table; diff --git a/nodejs/examples/full_text_search.test.ts b/nodejs/examples/full_text_search.test.ts index 9777bda7f..7f13ea3c2 100644 --- a/nodejs/examples/full_text_search.test.ts +++ b/nodejs/examples/full_text_search.test.ts @@ -29,8 +29,14 @@ test("full text search", async () => { const tbl = await db.createTable("myVectors", data, { mode: "overwrite" }); await tbl.createIndex("doc", { - config: lancedb.Index.fts(), + config: lancedb.Index.fts({ + stem: false, + removeStopWords: true, + customStopWords: ["banana"], + }), }); + const tokens = await tbl.tokenize("apple banana", { column: "doc" }); + expect(tokens.map((token) => token.text)).toEqual(["apple"]); // --8<-- [start:full_text_search] const result = await tbl @@ -42,4 +48,4 @@ test("full text search", async () => { expect(result.length).toBe(10); // --8<-- [end:full_text_search] }); -}, 10_000); +}, 30_000); diff --git a/nodejs/lancedb/index.ts b/nodejs/lancedb/index.ts index c29f10ea2..1fa3a20c5 100644 --- a/nodejs/lancedb/index.ts +++ b/nodejs/lancedb/index.ts @@ -17,7 +17,12 @@ import { } from "./native.js"; import { HeaderProvider } from "./header"; -import type { BaseTokenizer } from "./indices"; +import { + type BaseTokenizer, + type CustomStopWordsSource, + normalizeCustomStopWordsSource, +} from "./indices"; +import { customStopWordsSourceToNative } from "./table"; import type { FtsToken } from "./table"; // Re-export native header provider for use with connectWithHeaderProvider @@ -119,6 +124,8 @@ export { HnswSqOptions, FtsOptions, CustomStopWordsSource, + FtsStopWordsFileSource, + FtsStopWordsTableSource, BaseTokenizer, } from "./indices"; @@ -195,6 +202,20 @@ export interface TokenizeOptions { /** Whether to remove stop words. */ removeStopWords?: boolean; + /** + * Custom stop words that replace the built-in list for `language`. + * + * The source can be an inline string array, a newline-delimited UTF-8 file + * on this client, or a string column from a local/native LanceDB table. + * Remote table sources are rejected because they cannot currently guarantee + * a complete snapshot. This option is only applied when `removeStopWords` is + * true. + * + * `undefined` keeps the built-in language list. An empty array explicitly + * replaces it with no stop words. + */ + customStopWords?: CustomStopWordsSource; + /** Whether to fold ASCII characters. */ asciiFolding?: boolean; @@ -218,6 +239,10 @@ export async function tokenize( query: string, options?: Partial, ): Promise { + const customStopWords = normalizeCustomStopWordsSource( + options?.customStopWords, + ); + const source = customStopWordsSourceToNative(customStopWords); return await nativeTokenize( query, options?.baseTokenizer, @@ -230,6 +255,10 @@ export async function tokenize( options?.ngramMinLength, options?.ngramMaxLength, options?.prefixOnly, + source.inline, + source.file, + source.table, + source.column, ); } diff --git a/nodejs/lancedb/indices.ts b/nodejs/lancedb/indices.ts index 6f2efec04..1a216721e 100644 --- a/nodejs/lancedb/indices.ts +++ b/nodejs/lancedb/indices.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright The LanceDB Authors import { Index as LanceDbIndex } from "./native"; +import type { Table } from "./table"; /** * Options to create an `IVF_PQ` index @@ -496,6 +497,143 @@ export type BaseTokenizer = | `jieba/${string}` | `lindera/${string}`; +/** + * A source for custom full-text-search stop words. + * + * An inline array supplies the entries directly. A file is read as UTF-8 with + * one stop word per line. A table source reads stop words from the selected + * string column of a local/native LanceDB table. Remote table sources are + * rejected because the client cannot currently guarantee a complete snapshot. + * + * ```ts + * const inline: CustomStopWordsSource = ["copyright", "reserved"]; + * const file: CustomStopWordsSource = { + * source: "file", + * path: "./stop-words.txt", + * }; + * const tableColumn: CustomStopWordsSource = { + * source: "table", + * table: stopWordsTable, + * column: "word", + * }; + * ``` + * + * Empty strings are ignored and exact duplicates are removed while preserving + * the first occurrence. Values are otherwise preserved exactly: LanceDB does + * not trim them, lowercase them, or otherwise normalize their contents. + * Embedded/local fuzzy queries fail closed when `fuzziness` is greater than + * zero and a custom snapshot is active; `fuzziness: 0` and indexes without a + * custom snapshot continue to work normally. Remote tables currently reject + * every explicit `fuzziness > 0` query because the server protocol does not + * declare tokenizer-snapshot-safe fuzzy search; omit `fuzziness` or use + * `fuzziness: 0`. + * + * The source is resolved when the index is created, and the resulting list is + * stored as a stable index snapshot. Standalone `tokenize` resolves the same + * kind of one-call snapshot. File paths are always read by this client, never + * by a remote LanceDB service. + */ +export type CustomStopWordsSource = + | string[] + | FtsStopWordsFileSource + | FtsStopWordsTableSource; + +/** A newline-delimited UTF-8 custom stop-words file on this client. */ +export interface FtsStopWordsFileSource { + /** Select a newline-delimited UTF-8 file. */ + source: "file"; + /** Path to the stop-words file on the client. */ + path: string; +} + +/** A custom stop-words snapshot read from a LanceDB table column. */ +export interface FtsStopWordsTableSource { + /** Select a LanceDB table column. */ + source: "table"; + /** + * Local/native table containing the stop words. + * + * A remote table cannot be used as the source. Materialize its stop-word + * column locally first, or use an inline list or UTF-8 file. + */ + table: Table; + /** Name of the string column containing the stop words. */ + column: string; +} + +/** + * Validate and copy a custom-stop-words source without resolving it. + * + * @internal + */ +export function normalizeCustomStopWordsSource( + value: unknown, +): CustomStopWordsSource | undefined { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value)) { + for (const [index, stopWord] of value.entries()) { + if (typeof stopWord !== "string") { + throw new TypeError( + `customStopWords[${index}] must be a string, received ${typeof stopWord}`, + ); + } + } + // Keep [] distinct from undefined and prevent later caller mutation. + return [...value]; + } + + if (value === null || typeof value !== "object") { + throw new TypeError( + "customStopWords must be a string array, a file source, or a table source", + ); + } + + const candidate = value as Record; + if (candidate.source === "file") { + if (candidate.table !== undefined || candidate.column !== undefined) { + throw new TypeError( + "customStopWords file and table sources are mutually exclusive", + ); + } + if (typeof candidate.path !== "string" || candidate.path.length === 0) { + throw new TypeError( + "customStopWords file source requires a non-empty string 'path'", + ); + } + return { source: "file", path: candidate.path }; + } + + if (candidate.source === "table") { + if (candidate.path !== undefined) { + throw new TypeError( + "customStopWords file and table sources are mutually exclusive", + ); + } + if (candidate.table === null || typeof candidate.table !== "object") { + throw new TypeError( + "customStopWords table source requires a LanceDB 'table'", + ); + } + if (typeof candidate.column !== "string" || candidate.column.length === 0) { + throw new TypeError( + "customStopWords table source requires a non-empty string 'column'", + ); + } + return { + source: "table", + table: candidate.table as Table, + column: candidate.column, + }; + } + + throw new TypeError( + "customStopWords object source must have source: 'file' or source: 'table'", + ); +} + /** * Options to create a full text search index */ @@ -554,21 +692,25 @@ export interface FtsOptions { removeStopWords?: boolean; /** - * Custom stop words that replace the built-in language list. + * Custom stop words that replace the built-in list for `language`. * - * `undefined` uses the built-in list while `[]` explicitly selects no stop - * words. This only affects tokenization when `removeStopWords` is true. - */ - customStopWords?: string[]; - - /** - * Request-only source resolved by a remote LanceDB service. + * This option only affects tokenization when `removeStopWords` is true. The + * source can be an inline string array, a newline-delimited UTF-8 file on + * this client, or a string column from a local/native LanceDB table. + * Remote table sources are rejected because they cannot currently guarantee + * a complete snapshot; the target table may still be remote. * - * This is mutually exclusive with `customStopWords`. Local native tables - * reject source descriptors; load the source explicitly and pass - * `customStopWords` instead. + * `undefined` keeps the built-in language list. An empty array explicitly + * replaces it with no stop words. + * + * Embedded/local fuzzy queries with `fuzziness > 0` are rejected while this + * snapshot and `removeStopWords` are active. Use `fuzziness: 0`, or omit the + * custom snapshot. Remote tables reject every explicit `fuzziness > 0` + * query, regardless of stop-word configuration, because the server protocol + * does not declare tokenizer-snapshot-safe fuzzy search; omit `fuzziness` or + * use `fuzziness: 0`. */ - customStopWordsSource?: CustomStopWordsSource; + customStopWords?: CustomStopWordsSource; /** * whether to remove punctuation @@ -599,43 +741,15 @@ export interface FtsOptions { blockSize?: 128 | 256; } -/** - * A request-only source for custom FTS stop words. - * - * Remote services resolve file and table descriptors into a stable snapshot. - * Local native tables reject source descriptors. - * - * @example - * ```ts - * const inline = { - * type: "inline", - * words: ["the", "a"], - * } satisfies CustomStopWordsSource; - * const file = { - * type: "file", - * uri: "s3://bucket/stop-words.txt", - * } satisfies CustomStopWordsSource; - * const table = { - * type: "table", - * table: "catalog.schema.stop_words", - * column: "word", - * } satisfies CustomStopWordsSource; - * ``` - */ -export type CustomStopWordsSource = - | { type: "inline"; words: string[] } - | { type: "file"; uri: string } - | { type: "table"; table: string; column: string }; - export class Index { - private readonly inner: LanceDbIndex; - private readonly customStopWordsSource?: CustomStopWordsSource; + private readonly inner?: LanceDbIndex; + private readonly ftsOptions?: Readonly>; private constructor( - inner: LanceDbIndex, - customStopWordsSource?: CustomStopWordsSource, + inner?: LanceDbIndex, + ftsOptions?: Readonly>, ) { this.inner = inner; - this.customStopWordsSource = customStopWordsSource; + this.ftsOptions = ftsOptions; } /** @@ -794,34 +908,80 @@ export class Index { * The results of a full text search are ordered by relevance measured by BM25. * * You can combine filters with full text search. + * + * @example + * Use an inline stop-word snapshot: + * ```ts + * await table.createIndex("text", { + * config: Index.fts({ + * removeStopWords: true, + * customStopWords: ["copyright", "reserved"], + * }), + * }); + * ``` + * + * @example + * Read a newline-delimited UTF-8 file on the client: + * ```ts + * await table.createIndex("text", { + * config: Index.fts({ + * removeStopWords: true, + * customStopWords: { source: "file", path: "./stop-words.txt" }, + * }), + * }); + * ``` + * + * @example + * Snapshot a string column from another local/native LanceDB table: + * ```ts + * await table.createIndex("text", { + * config: Index.fts({ + * removeStopWords: true, + * customStopWords: { + * source: "table", + * table: stopWordsTable, + * column: "word", + * }, + * }), + * }); + * ``` */ static fts(options?: Partial) { - if ( - options?.customStopWords !== undefined && - options?.customStopWordsSource !== undefined - ) { - throw new Error( - "customStopWords and customStopWordsSource are mutually exclusive; choose one", - ); - } - return new Index( - LanceDbIndex.fts( - options?.withPosition, - options?.baseTokenizer, - options?.language, - options?.maxTokenLength, - options?.lowercase, - options?.stem, - options?.removeStopWords, - options?.asciiFolding, - options?.ngramMinLength, - options?.ngramMaxLength, - options?.prefixOnly, - options?.blockSize, - options?.customStopWords, - ), - options?.customStopWordsSource, + const customStopWords = normalizeCustomStopWordsSource( + options?.customStopWords, ); + if ( + options?.blockSize !== undefined && + options.blockSize !== 128 && + options.blockSize !== 256 + ) { + throw new RangeError("FTS blockSize must be 128 or 256"); + } + // Preserve the synchronous validation behavior of Index.fts while + // discarding the single-use native builder. Table.createIndex constructs a + // fresh builder from the saved recipe below. + LanceDbIndex.fts( + options?.withPosition, + options?.baseTokenizer, + options?.language, + options?.maxTokenLength, + options?.lowercase, + options?.stem, + options?.removeStopWords, + options?.asciiFolding, + options?.ngramMinLength, + options?.ngramMaxLength, + options?.prefixOnly, + options?.blockSize, + ); + // File and table sources can only be resolved asynchronously when + // Table.createIndex runs. Save an immutable recipe and create a fresh + // native builder for each invocation so failed resolution does not consume + // the public Index configuration. + return new Index(undefined, { + ...options, + customStopWords, + }); } /** diff --git a/nodejs/lancedb/query.ts b/nodejs/lancedb/query.ts index 843a1276f..2fcb60962 100644 --- a/nodejs/lancedb/query.ts +++ b/nodejs/lancedb/query.ts @@ -975,6 +975,12 @@ export class MatchQuery implements FullTextQuery { * - `maxExpansions`: The maximum number of terms to consider for fuzzy matching (default is 50). * - `operator`: The logical operator to use for combining terms in the query (default is "OR"). * - `prefixLength`: The number of beginning characters being unchanged for fuzzy matching. + * + * Remote tables currently reject every explicit `fuzziness > 0` query + * because the server protocol does not declare tokenizer-snapshot-safe fuzzy + * search. Embedded/local tables reject it only when the indexed tokenizer + * has an active custom stop-word snapshot. Omit `fuzziness` or use + * `fuzziness: 0` for exact matching in either case. */ constructor( query: string, diff --git a/nodejs/lancedb/table.ts b/nodejs/lancedb/table.ts index be93bd318..608553e80 100644 --- a/nodejs/lancedb/table.ts +++ b/nodejs/lancedb/table.ts @@ -18,7 +18,11 @@ import { } from "./arrow"; import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry"; -import { IndexOptions } from "./indices"; +import type { + CustomStopWordsSource, + FtsOptions, + IndexOptions, +} from "./indices"; import { MergeInsertBuilder } from "./merge"; import { AddColumnsResult, @@ -36,6 +40,7 @@ import { Tags, UpdateFieldMetadataResult, UpdateResult, + Index as _NativeIndex, Table as _NativeTable, } from "./native"; import { @@ -178,6 +183,67 @@ export type TokenizeTableOptions = column?: never; }; +interface NativeCustomStopWordsSource { + inline?: string[]; + file?: string; + table?: _NativeTable; + column?: string; +} + +function ftsRecipeToNative( + options: Readonly>, +): _NativeIndex { + return _NativeIndex.fts( + options.withPosition, + options.baseTokenizer, + options.language, + options.maxTokenLength, + options.lowercase, + options.stem, + options.removeStopWords, + options.asciiFolding, + options.ngramMinLength, + options.ngramMaxLength, + options.prefixOnly, + options.blockSize, + ); +} + +/** + * Convert a validated public source into the native binding arguments. + * + * @internal + */ +export function customStopWordsSourceToNative( + source?: CustomStopWordsSource, +): NativeCustomStopWordsSource { + if (source === undefined) { + return {}; + } + if (Array.isArray(source)) { + return { inline: source }; + } + if (source.source === "file") { + return { file: source.path }; + } + if (!(source.table instanceof LocalTable)) { + throw new TypeError( + "customStopWords table source must be a table created by this @lancedb/lancedb package instance", + ); + } + if (!source.table.isOpen()) { + throw new Error( + `customStopWords table source '${source.table.name}' is closed; open the table before resolving stop words`, + ); + } + const nativeTable = ( + source.table as unknown as { + inner: _NativeTable; + } + ).inner; + return { table: nativeTable, column: source.column }; +} + /** * Specification selecting Lance's MemWAL LSM-style write path for * `mergeInsert`. @@ -928,11 +994,19 @@ export class LocalTable extends Table { async createIndex(column: string, options?: Partial) { // Bit of a hack to get around the fact that TS has no package-scope. - // biome-ignore lint/suspicious/noExplicitAny: skip - const nativeIndex = (options?.config as any)?.inner; - // biome-ignore lint/suspicious/noExplicitAny: skip - const customStopWordsSource = (options?.config as any) - ?.customStopWordsSource; + const config = options?.config as unknown as + | { + inner?: _NativeIndex; + ftsOptions?: Readonly>; + } + | undefined; + const nativeIndex = + config?.ftsOptions !== undefined + ? ftsRecipeToNative(config.ftsOptions) + : config?.inner; + const source = customStopWordsSourceToNative( + config?.ftsOptions?.customStopWords, + ); await this.inner.createIndex( nativeIndex, column, @@ -940,9 +1014,10 @@ export class LocalTable extends Table { options?.waitTimeoutSeconds, options?.name, options?.train, - customStopWordsSource === undefined - ? undefined - : JSON.stringify(customStopWordsSource), + source.inline, + source.file, + source.table, + source.column, ); } diff --git a/nodejs/src/index.rs b/nodejs/src/index.rs index d115a37b5..6925457f6 100644 --- a/nodejs/src/index.rs +++ b/nodejs/src/index.rs @@ -4,7 +4,9 @@ use std::sync::Mutex; use lancedb::index::Index as LanceDbIndex; -use lancedb::index::scalar::{BTreeIndexBuilder, FmIndexBuilder, FtsIndexBuilder}; +use lancedb::index::scalar::{ + BTreeIndexBuilder, FmIndexBuilder, FtsIndexBuilder, FtsStopWordsSource, +}; use lancedb::index::vector::{ IvfFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, IvfPqIndexBuilder, IvfRqIndexBuilder, @@ -16,6 +18,54 @@ use crate::error::NapiErrorExt; use crate::table::FtsToken; use crate::util::parse_distance_type; +pub fn custom_stop_words_source( + inline: Option>, + file: Option, + table: Option<&crate::table::Table>, + column: Option, +) -> napi::Result> { + let has_table_source = table.is_some() || column.is_some(); + let source_count = + usize::from(inline.is_some()) + usize::from(file.is_some()) + usize::from(has_table_source); + if source_count > 1 { + return Err(napi::Error::from_reason( + "custom stop words inline, file, and table sources are mutually exclusive", + )); + } + + if let Some(words) = inline { + return Ok(Some(FtsStopWordsSource::inline(words))); + } + if let Some(path) = file { + if path.is_empty() { + return Err(napi::Error::from_reason( + "custom stop words file source requires a non-empty path", + )); + } + return Ok(Some(FtsStopWordsSource::file(path))); + } + match (table, column) { + (Some(table), Some(column)) => { + if column.is_empty() { + return Err(napi::Error::from_reason( + "custom stop words table source requires a non-empty column", + )); + } + let table = table.inner_ref().map_err(|err| { + napi::Error::from_reason(format!( + "failed to use custom stop words table: {}", + err.reason + )) + })?; + Ok(Some(FtsStopWordsSource::table(table.clone(), column))) + } + (None, None) => Ok(None), + _ => Err(napi::Error::from_reason( + "custom stop words table source requires both table and column", + )), + } +} + #[napi] pub struct Index { inner: Mutex>, @@ -35,7 +85,7 @@ impl Index { #[napi(catch_unwind)] #[allow(dead_code, clippy::too_many_arguments)] -pub fn tokenize( +pub async fn tokenize( query: String, base_tokenizer: Option, language: Option, @@ -47,6 +97,10 @@ pub fn tokenize( ngram_min_length: Option, ngram_max_length: Option, prefix_only: Option, + custom_stop_words: Option>, + custom_stop_words_file: Option, + custom_stop_words_table: Option<&crate::table::Table>, + custom_stop_words_column: Option, ) -> napi::Result> { let mut opts = FtsIndexBuilder::default(); if let Some(base_tokenizer) = base_tokenizer { @@ -84,6 +138,15 @@ pub fn tokenize( if let Some(prefix_only) = prefix_only { opts = opts.ngram_prefix_only(prefix_only); } + if let Some(source) = custom_stop_words_source( + custom_stop_words, + custom_stop_words_file, + custom_stop_words_table, + custom_stop_words_column, + )? { + let snapshot = source.resolve().await.default_error()?; + opts = opts.custom_stop_words(Some(snapshot)); + } Ok(lancedb_tokenize(&query, &opts) .default_error()? @@ -227,7 +290,6 @@ impl Index { ngram_max_length: Option, prefix_only: Option, block_size: Option, - custom_stop_words: Option>, ) -> napi::Result { let mut opts = FtsIndexBuilder::default(); if let Some(with_position) = with_position { @@ -268,9 +330,6 @@ impl Index { .block_size(block_size as usize) .map_err(|err| napi::Error::from_reason(err.to_string()))?; } - if let Some(custom_stop_words) = custom_stop_words { - opts = opts.custom_stop_words(Some(custom_stop_words)); - } Ok(Self { inner: Mutex::new(Some(LanceDbIndex::FTS(opts))), diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index 388b1ae61..94adceab8 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -16,7 +16,7 @@ use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi_derive::napi; use crate::error::NapiErrorExt; -use crate::index::Index; +use crate::index::{Index, custom_stop_words_source}; use crate::merge::NativeMergeInsertBuilder; use crate::query::{Query, TakeQuery, VectorQuery}; use crate::util::schema_to_buffer; @@ -137,7 +137,7 @@ impl Table { } #[napi(catch_unwind)] - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments, clippy::use_self)] pub async fn create_index( &self, index: Option<&Index>, @@ -146,14 +146,26 @@ impl Table { wait_timeout_s: Option, name: Option, train: Option, - custom_stop_words_source: Option, + custom_stop_words: Option>, + custom_stop_words_file: Option, + custom_stop_words_table: Option<&Table>, + custom_stop_words_column: Option, ) -> napi::Result<()> { + let custom_stop_words_source = custom_stop_words_source( + custom_stop_words, + custom_stop_words_file, + custom_stop_words_table, + custom_stop_words_column, + )?; let lancedb_index = if let Some(index) = index { index.consume()? } else { lancedb::index::Index::Auto }; let mut builder = self.inner_ref()?.create_index(&[column], lancedb_index); + if let Some(source) = custom_stop_words_source { + builder = builder.custom_stop_words(source).default_error()?; + } if let Some(replace) = replace { builder = builder.replace(replace); } @@ -167,12 +179,6 @@ impl Table { if let Some(train) = train { builder = builder.train(train); } - if let Some(source) = custom_stop_words_source { - let source = serde_json::from_str(&source).map_err(|err| { - napi::Error::from_reason(format!("invalid customStopWordsSource: {err}")) - })?; - builder = builder.custom_stop_words_source(source); - } builder.execute().await.default_error() } diff --git a/python/python/lancedb/__init__.py b/python/python/lancedb/__init__.py index 6f6468d9c..1a7fe5683 100644 --- a/python/python/lancedb/__init__.py +++ b/python/python/lancedb/__init__.py @@ -19,10 +19,16 @@ from .db import AsyncConnection, DBConnection, LanceDBConnection from .remote import ClientConfig from .remote.db import RemoteDBConnection from .expr import Expr, col, lit, func +from .index import ( + CustomStopWords, + FtsStopWordsFile as FtsStopWordsFile, + FtsStopWordsTable as FtsStopWordsTable, +) from .schema import blob, vector, BlobType from .table import AsyncTable, Table from .types import BaseTokenizerType from ._lancedb import Session +from .background_loop import LOOP from .namespace import ( connect_namespace, connect_namespace_async, @@ -258,6 +264,7 @@ def tokenize( lower_case: bool = True, stem: bool = True, remove_stop_words: bool = True, + custom_stop_words: CustomStopWords = None, ascii_folding: bool = True, ngram_min_length: int = 3, ngram_max_length: int = 3, @@ -265,22 +272,30 @@ def tokenize( ) -> Iterable[FtsToken]: """Tokenize a full-text search query using an explicit tokenizer. - This does not require a table or FTS index. The tokenizer options match - :class:`lancedb.index.FTS`. + This does not require an FTS index. The tokenizer options match + :class:`lancedb.index.FTS`. ``custom_stop_words`` accepts an inline + sequence, :class:`lancedb.index.FtsStopWordsFile`, or + :class:`lancedb.index.FtsStopWordsTable`. Table sources require an open + local/native table handle; remote table sources are rejected. """ - return _tokenize( - query, - base_tokenizer=base_tokenizer, - language=language, - max_token_length=max_token_length, - lower_case=lower_case, - stem=stem, - remove_stop_words=remove_stop_words, - ascii_folding=ascii_folding, - ngram_min_length=ngram_min_length, - ngram_max_length=ngram_max_length, - prefix_only=prefix_only, - ) + + async def tokenize_async(): + return await _tokenize( + query, + base_tokenizer=base_tokenizer, + language=language, + max_token_length=max_token_length, + lower_case=lower_case, + stem=stem, + remove_stop_words=remove_stop_words, + custom_stop_words=custom_stop_words, + ascii_folding=ascii_folding, + ngram_min_length=ngram_min_length, + ngram_max_length=ngram_max_length, + prefix_only=prefix_only, + ) + + return LOOP.run(tokenize_async()) WORKER_PROPERTY_PREFIX = "_lancedb_worker_" diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 448947a29..5c25b00b2 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -16,6 +16,7 @@ from .index import ( HnswSq, HnswFlat, FTS, + CustomStopWords, ) from lance_namespace import ( ListNamespacesResponse, @@ -50,7 +51,7 @@ class MetricDescription: def register_lancedb_metrics_recorder() -> bool: ... def lancedb_metrics_catalog() -> List[MetricDescription]: ... def snapshot_lancedb_metrics() -> List[MetricPoint]: ... -def tokenize( +async def tokenize( query: str, *, base_tokenizer: BaseTokenizerType = "simple", @@ -59,6 +60,7 @@ def tokenize( lower_case: bool = True, stem: bool = True, remove_stop_words: bool = True, + custom_stop_words: CustomStopWords = None, ascii_folding: bool = True, ngram_min_length: int = 3, ngram_max_length: int = 3, diff --git a/python/python/lancedb/index.py b/python/python/lancedb/index.py index 49d330878..8687088c4 100644 --- a/python/python/lancedb/index.py +++ b/python/python/lancedb/index.py @@ -1,14 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The LanceDB Authors -from dataclasses import dataclass, field -from typing import List, Literal, Optional, Union +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence, Union from ._lancedb import ( IndexConfig, ) from .types import BaseTokenizerType +if TYPE_CHECKING: + from .table import AsyncTable, Table + lang_mapping = { "ar": "Arabic", "da": "Danish", @@ -32,35 +38,86 @@ lang_mapping = { @dataclass(frozen=True) -class InlineStopWordsSource: - """Inline custom stop words for a remote FTS create-index request.""" +class FtsStopWordsFile: + """Use newline-delimited custom FTS stop words from a UTF-8 file. - words: List[str] - type: Literal["inline"] = field(default="inline", init=False) + The file is read when the index is created (or when + :func:`lancedb.tokenize` is called). Its contents are resolved to a stable + in-memory snapshot before any remote request is sent. + + Parameters + ---------- + path : str or os.PathLike + Non-empty path to a strictly UTF-8 encoded, newline-delimited + stop-word file. + """ + + path: Union[str, os.PathLike[str]] + + @property + def _path(self) -> str: + path = os.fspath(self.path) + if not isinstance(path, str): + raise TypeError("FtsStopWordsFile.path must be a string path") + if not path: + raise ValueError("FtsStopWordsFile.path must be a non-empty string path") + return path @dataclass(frozen=True) -class FileStopWordsSource: - """A UTF-8, newline-delimited custom stop-word object for a remote request.""" +class FtsStopWordsTable: + """Use custom FTS stop words from a local LanceDB table string column. - uri: str - type: Literal["file"] = field(default="file", init=False) + The column is read when the index is created (or when + :func:`lancedb.tokenize` is called). Its values are resolved to a stable + in-memory snapshot before any remote request is sent. + Parameters + ---------- + table : lancedb.Table or lancedb.AsyncTable + An open local/native LanceDB table handle. Remote table sources are + rejected because LanceDB cannot guarantee an untruncated snapshot; + materialize the column locally first. + column : str + Non-empty name of the string column containing stop words. + """ -@dataclass(frozen=True) -class TableStopWordsSource: - """A table string column used by a remote FTS create-index request.""" - - table: str + table: Union["Table", "AsyncTable"] column: str - type: Literal["table"] = field(default="table", init=False) + + @property + def _column(self) -> str: + if not isinstance(self.column, str): + raise TypeError("FtsStopWordsTable.column must be a string") + if not self.column: + raise ValueError("FtsStopWordsTable.column must be a non-empty string") + return self.column + + @property + def _native_table(self) -> Any: + # Synchronous RemoteTable lazily reopens through LOOP.run when its + # private `_table` property is accessed. This property is itself read + # from the LOOP thread by PyO3, so touching `_table` for a stale + # forked/unpickled handle would deadlock. Remote sources are unsupported + # anyway, so reject them without attempting a reopen. + if hasattr(self.table, "_table_handle"): + raise ValueError( + "remote table sources cannot guarantee a complete snapshot; " + "materialize the stop-word column into a local LanceDB table, " + "or use an inline list or UTF-8 file" + ) + native_table = getattr(self.table, "_inner", None) + if native_table is None: + async_table = getattr(self.table, "_table", None) + native_table = getattr(async_table, "_inner", None) + if native_table is None: + raise TypeError( + "FtsStopWordsTable.table must be a LanceDB Table or AsyncTable" + ) + return native_table -CustomStopWordsSource = Union[ - InlineStopWordsSource, - FileStopWordsSource, - TableStopWordsSource, -] +CustomStopWords = Optional[Union[Sequence[str], FtsStopWordsFile, FtsStopWordsTable]] @dataclass @@ -183,15 +240,17 @@ class FTS: remove_stop_words : bool, default True Whether to remove stop words. Stop words are common words that are often removed from text before indexing. For example, in English "the" and "and". - custom_stop_words : list of str, optional - A custom stop-word list that replaces the built-in language list. ``None`` - uses the built-in list, while ``[]`` explicitly selects no stop words. - This only affects tokenization when ``remove_stop_words`` is True. - custom_stop_words_source : CustomStopWordsSource, optional - A request-only inline, UTF-8 file, or table-column source resolved by a - remote LanceDB service. It is mutually exclusive with - ``custom_stop_words``. Local native tables reject source descriptors; - load the source explicitly and pass ``custom_stop_words`` instead. + custom_stop_words : sequence of str, file source, or table source, optional + A custom stop-word source. A sequence is used inline; + :class:`FtsStopWordsFile` reads a strictly UTF-8 newline-delimited file; + and :class:`FtsStopWordsTable` reads a string column from an open local + LanceDB table. Remote tables cannot be used as sources. Custom words + replace the built-in language stop words + and only take effect when ``remove_stop_words`` is True. ``None`` uses + the built-in language list, while an empty sequence explicitly uses no + stop words. Exact empty strings are ignored and exact duplicates are + removed while preserving first-seen order. Values are not trimmed, + lowercased, or otherwise normalized. ascii_folding : bool, default True Whether to fold ASCII characters. This converts accented characters to their ASCII equivalent. For example, "café" would be converted to "cafe". @@ -206,6 +265,18 @@ class FTS: require tokenizer models in Lance's language model home. Set ``LANCE_LANGUAGE_MODEL_HOME`` to override the default platform data directory under ``lance/language_models``. + + Local FTS queries with active custom stop words reject an explicit + ``fuzziness`` greater than zero because upstream fuzzy matching does not + reuse the index's complete tokenizer configuration. An omitted fuzziness + value and explicit zero continue to use the configured stop-word snapshot. + + Remote tables temporarily reject every explicitly positive ``fuzziness`` + before sending a query request, regardless of stop-word configuration, + because the service does not yet expose a capability that atomically binds + a query to its tokenizer snapshot. Native namespace pushdown routes + explicitly fuzzy queries to local execution, where the local rule above + applies. """ with_position: bool = False @@ -220,18 +291,7 @@ class FTS: ngram_max_length: int = 3 prefix_only: bool = False block_size: int = 128 - custom_stop_words: Optional[List[str]] = None - custom_stop_words_source: Optional[CustomStopWordsSource] = None - - def __post_init__(self): - if ( - self.custom_stop_words is not None - and self.custom_stop_words_source is not None - ): - raise ValueError( - "custom_stop_words and custom_stop_words_source are mutually " - "exclusive; choose one" - ) + custom_stop_words: CustomStopWords = None @dataclass @@ -906,10 +966,6 @@ __all__ = [ "HnswFlat", "IndexConfig", "FTS", - "CustomStopWordsSource", - "InlineStopWordsSource", - "FileStopWordsSource", - "TableStopWordsSource", "Bitmap", "LabelList", "Fm", diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index d0f6f1127..164cbc18d 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -39,7 +39,7 @@ from lancedb.index import ( FTS, BTree, Bitmap, - CustomStopWordsSource, + CustomStopWords, HnswFlat, HnswSq, IvfFlat, @@ -341,8 +341,7 @@ class RemoteTable(Table): lower_case: bool = True, stem: bool = True, remove_stop_words: bool = True, - custom_stop_words: Optional[List[str]] = None, - custom_stop_words_source: Optional[CustomStopWordsSource] = None, + custom_stop_words: CustomStopWords = None, ascii_folding: bool = True, ngram_min_length: int = 3, ngram_max_length: int = 3, @@ -365,7 +364,6 @@ class RemoteTable(Table): stem=stem, remove_stop_words=remove_stop_words, custom_stop_words=custom_stop_words, - custom_stop_words_source=custom_stop_words_source, ascii_folding=ascii_folding, ngram_min_length=ngram_min_length, ngram_max_length=ngram_max_length, diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index 845d431ac..a108cbd55 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -69,7 +69,7 @@ from .index import ( HnswSq, HnswFlat, FTS, - CustomStopWordsSource, + CustomStopWords, ) from .expr import Expr from .merge import LanceMergeInsertBuilder @@ -1104,8 +1104,7 @@ class Table(ABC): lower_case: bool = True, stem: bool = True, remove_stop_words: bool = True, - custom_stop_words: Optional[List[str]] = None, - custom_stop_words_source: Optional[CustomStopWordsSource] = None, + custom_stop_words: CustomStopWords = None, ascii_folding: bool = True, ngram_min_length: int = 3, ngram_max_length: int = 3, @@ -1140,7 +1139,11 @@ class Table(ABC): for english it would be "en_stem". For new native FTS indexes, use ``base_tokenizer`` directly; ``tokenizer_name`` is a legacy compatibility alias and does not expose model-backed tokenizer names - such as ``jieba/default`` or ``lindera/ipadic``. + such as ``jieba/default`` or ``lindera/ipadic``. Its historical + ``remove_stop_words=False`` setting is preserved. A custom + stop-word snapshot is persisted when both options are supplied, + but remains inactive; use the modern ``FTS`` config with + ``remove_stop_words=True`` to enable it. use_tantivy: bool, default False Deprecated legacy Tantivy parameter. Setting this to True raises an error. @@ -1173,12 +1176,12 @@ class Table(ABC): remove_stop_words : bool, default True Whether to remove stop words. Stop words are common words that are often removed from text before indexing. For example, in English "the" and "and". - custom_stop_words : list of str, optional - Custom words that replace the built-in language stop-word list. ``None`` - uses the built-in list; ``[]`` selects no stop words. - custom_stop_words_source : CustomStopWordsSource, optional - Request-only remote source for custom stop words. Mutually exclusive - with ``custom_stop_words`` and rejected by local native tables. + custom_stop_words : sequence of str, file source, or table source, optional + Custom words that replace the built-in language stop words. ``None`` + uses the built-in list; an empty sequence explicitly uses no stop + words. File and table sources are resolved to a stable snapshot + before the index is created. Table sources must be local/native; + remote table sources are rejected. ascii_folding : bool, default True Whether to fold ASCII characters. This converts accented characters to their ASCII equivalent. For example, "café" would be converted to "cafe". @@ -1867,6 +1870,8 @@ class Table(ABC): Model-backed tokenizers such as ``jieba/*`` and ``lindera/*`` are rebuilt in the client process from index metadata. For remote tables, this means the same tokenizer model files must also exist locally. + Custom stop-word sources are not re-read; this uses the persisted + snapshot stored in the index configuration. """ @abstractmethod @@ -3064,8 +3069,7 @@ class LanceTable(Table): lower_case: bool = True, stem: bool = True, remove_stop_words: bool = True, - custom_stop_words: Optional[List[str]] = None, - custom_stop_words_source: Optional[CustomStopWordsSource] = None, + custom_stop_words: CustomStopWords = None, ascii_folding: bool = True, ngram_min_length: int = 3, ngram_max_length: int = 3, @@ -3078,6 +3082,12 @@ class LanceTable(Table): .. deprecated:: 0.25.0 Use :meth:`create_index` with an FTS config instead. Example: ``table.create_index("text_column", config=FTS())`` + + When ``tokenizer_name`` is supplied, its historical + ``remove_stop_words=False`` setting is preserved. ``custom_stop_words`` + is still snapshotted and persisted, but remains inactive. Use the modern + :class:`lancedb.index.FTS` configuration with + ``remove_stop_words=True`` to enable custom stop-word removal. """ self._ensure_no_legacy_fts_index() @@ -3113,7 +3123,6 @@ class LanceTable(Table): "stem": stem, "remove_stop_words": remove_stop_words, "custom_stop_words": custom_stop_words, - "custom_stop_words_source": custom_stop_words_source, "ascii_folding": ascii_folding, "ngram_min_length": ngram_min_length, "ngram_max_length": ngram_max_length, @@ -3122,7 +3131,6 @@ class LanceTable(Table): else: tokenizer_configs = self.infer_tokenizer_configs(tokenizer_name) tokenizer_configs["custom_stop_words"] = custom_stop_words - tokenizer_configs["custom_stop_words_source"] = custom_stop_words_source config = FTS(block_size=block_size, **tokenizer_configs) @@ -3856,6 +3864,8 @@ class LanceTable(Table): Model-backed tokenizers such as ``jieba/*`` and ``lindera/*`` are rebuilt in the client process from index metadata. For remote tables, this means the same tokenizer model files must also exist locally. + Custom stop-word sources are not re-read; this uses the persisted + snapshot stored in the index configuration. """ return LOOP.run( self._table.tokenize(query, column=column, index_name=index_name) @@ -6003,6 +6013,8 @@ class AsyncTable: Model-backed tokenizers such as ``jieba/*`` and ``lindera/*`` are rebuilt in the client process from index metadata. For remote tables, this means the same tokenizer model files must also exist locally. + Custom stop-word sources are not re-read; this uses the persisted + snapshot stored in the index configuration. """ return await self._inner.tokenize(query, column=column, index_name=index_name) diff --git a/python/python/tests/docs/test_search.py b/python/python/tests/docs/test_search.py index f34dfd9b7..5160461f3 100644 --- a/python/python/tests/docs/test_search.py +++ b/python/python/tests/docs/test_search.py @@ -24,7 +24,7 @@ from pydantic import BaseModel # --8<-- [end:import-pydantic-base-model] # --8<-- [start:import-lancedb-fts] -from lancedb.index import FTS +from lancedb.index import FTS, FtsStopWordsFile, FtsStopWordsTable # --8<-- [end:import-lancedb-fts] # --8<-- [start:import-os] @@ -351,6 +351,51 @@ def test_fts_native(): # --8<-- [end:fts_incremental_index] +def test_fts_custom_stop_word_sources(tmp_path): + # --8<-- [start:fts_custom_stop_words] + db = lancedb.connect(tmp_path / "db") + documents = db.create_table( + "documents", + data=[{"text": "the lance database"}, {"text": "searchable data"}], + ) + + # Inline values + documents.create_index( + "text", + config=FTS(stem=False, custom_stop_words=["lance", "searchable"]), + ) + + # A strict UTF-8 file with one stop word per line + stop_words_path = tmp_path / "stop-words.txt" + stop_words_path.write_text("lance\nsearchable\n", encoding="utf-8") + documents.create_index( + "text", + config=FTS( + stem=False, + custom_stop_words=FtsStopWordsFile(stop_words_path), + ), + replace=True, + ) + + # A string column from another open LanceDB table + stop_words = db.create_table( + "stop_words", + data=[{"word": "lance"}, {"word": "searchable"}], + ) + documents.create_index( + "text", + config=FTS( + stem=False, + custom_stop_words=FtsStopWordsTable(stop_words, "word"), + ), + replace=True, + ) + # --8<-- [end:fts_custom_stop_words] + + tokens = documents.tokenize("the lance database", column="text") + assert [token.text for token in tokens] == ["the", "database"] + + @pytest.mark.skipif( os.name == "nt", reason="Need to fix https://github.com/lancedb/lance/issues/3905" ) diff --git a/python/python/tests/test_fts.py b/python/python/tests/test_fts.py index 918008369..b29505eaa 100644 --- a/python/python/tests/test_fts.py +++ b/python/python/tests/test_fts.py @@ -22,7 +22,7 @@ import zipfile import lancedb as ldb from lancedb.db import DBConnection -from lancedb.index import FTS, FileStopWordsSource +from lancedb.index import FTS, FtsStopWordsFile, FtsStopWordsTable from lancedb.query import ( BoostQuery, MatchQuery, @@ -243,47 +243,46 @@ def test_create_inverted_index_rejects_invalid_block_size(table): table.create_index("text", config=FTS(block_size=129)) -def test_custom_stop_words_preserve_none_and_empty(mem_db: DBConnection): - built_in = mem_db.create_table( - "built_in_stop_words", [{"text": "the lance database"}] +@pytest.mark.parametrize( + ("custom_stop_words", "expected_the", "expected_lance"), + [ + (None, 0, 2), + ([], 1, 2), + (["lance"], 1, 0), + ], + ids=["builtin", "explicit-empty", "custom"], +) +def test_custom_stop_words_inline_none_vs_empty( + tmp_path, custom_stop_words, expected_the, expected_lance +): + db = ldb.connect(tmp_path) + table = db.create_table( + "documents", + data=pa.table({"text": ["the lance database", "lance data"]}), ) - built_in.create_index( + table.create_index( "text", - config=FTS(stem=False, custom_stop_words=None), + config=FTS(stem=False, custom_stop_words=custom_stop_words), ) - assert [token.text for token in built_in.tokenize("the lance", column="text")] == [ - "lance" - ] - empty = mem_db.create_table( - "empty_custom_stop_words", [{"text": "the lance database"}] + assert ( + len(table.search("the", query_type="fts").limit(10).to_list()) == expected_the ) - empty.create_index( - "text", - config=FTS(stem=False, custom_stop_words=[]), + assert ( + len(table.search("lance", query_type="fts").limit(10).to_list()) + == expected_lance ) - assert [token.text for token in empty.tokenize("the lance", column="text")] == [ - "the", - "lance", - ] + details = table.list_indices()[0].index_details + assert details["custom_stop_words"] == custom_stop_words - custom = mem_db.create_table( - "non_empty_custom_stop_words", [{"text": "the lance database"}] - ) - custom.create_index( - "text", - config=FTS(stem=False, custom_stop_words=["lance"]), - ) - assert [token.text for token in custom.tokenize("the lance", column="text")] == [ - "the" - ] - assert len(custom.search("the", query_type="fts").to_list()) == 1 - assert custom.search("lance", query_type="fts").to_list() == [] - disabled = mem_db.create_table( - "disabled_custom_stop_words", [{"text": "the lance database"}] +def test_custom_stop_words_are_inactive_when_removal_is_disabled(tmp_path): + db = ldb.connect(tmp_path) + table = db.create_table( + "documents", + data=pa.table({"text": ["the lance database", "lance data"]}), ) - disabled.create_index( + table.create_index( "text", config=FTS( stem=False, @@ -291,31 +290,227 @@ def test_custom_stop_words_preserve_none_and_empty(mem_db: DBConnection): custom_stop_words=["lance"], ), ) - assert [token.text for token in disabled.tokenize("the lance", column="text")] == [ - "the", + + assert len(table.search("lance", query_type="fts").limit(10).to_list()) == 2 + tokens = table.tokenize("the lance data", column="text") + assert [token.text for token in tokens] == ["the", "lance", "data"] + + +def test_custom_stop_words_file_is_snapshotted_and_reopened(tmp_path): + stop_words_path = tmp_path / "stop-words.txt" + stop_words_path.write_bytes( + "lance\r\n\r\nlance\r\nKeep Case\r\n spaced \r\n".encode() + ) + db = ldb.connect(tmp_path / "db") + table = db.create_table( + "documents", + data=pa.table({"text": ["the lance database", "lance data"]}), + ) + table.create_index( + "text", + config=FTS( + stem=False, + custom_stop_words=FtsStopWordsFile(stop_words_path), + ), + ) + + stop_words_path.write_text("the\n", encoding="utf-8") + reopened = db.open_table("documents") + + details = reopened.list_indices()[0].index_details + assert details["custom_stop_words"] == ["lance", "Keep Case", " spaced "] + tokens = reopened.tokenize("the lance data", column="text") + assert [token.text for token in tokens] == ["the", "data"] + assert len(reopened.search("the", query_type="fts").limit(10).to_list()) == 1 + assert len(reopened.search("lance", query_type="fts").limit(10).to_list()) == 0 + + +def test_custom_stop_words_table_is_snapshotted(tmp_path): + db = ldb.connect(tmp_path) + stop_words = db.create_table( + "stop_words", + data=pa.table({"word": ["lance", "", "lance", "data"]}), + ) + documents = db.create_table( + "documents", + data=pa.table({"text": ["the lance database", "lance data"]}), + ) + documents.create_index( + "text", + config=FTS( + stem=False, + custom_stop_words=FtsStopWordsTable(stop_words, "word"), + ), + ) + + db.drop_table("stop_words") + reopened = db.open_table("documents") + + assert reopened.list_indices()[0].index_details["custom_stop_words"] == [ "lance", + "data", ] - assert len(disabled.search("lance", query_type="fts").to_list()) == 1 + tokens = reopened.tokenize("the lance data", column="text") + assert [token.text for token in tokens] == ["the"] -def test_local_fts_rejects_custom_stop_words_source(mem_db: DBConnection): - table = mem_db.create_table("source_stop_words", [{"text": "lance database"}]) - with pytest.raises(RuntimeError, match="only supported for remote tables"): - table.create_index( +@pytest.mark.asyncio +async def test_custom_stop_words_table_async(tmp_path): + db = await ldb.connect_async(tmp_path) + stop_words = await db.create_table( + "stop_words", + data=pa.table({"word": ["lance", "", "lance"]}), + ) + documents = await db.create_table( + "documents", + data=pa.table({"text": ["the lance database", "lance data"]}), + ) + + await documents.create_index( + "text", + config=FTS( + stem=False, + custom_stop_words=FtsStopWordsTable(stop_words, "word"), + ), + ) + + tokens = await documents.tokenize("the lance data", column="text") + assert [token.text for token in tokens] == ["the", "data"] + + +def test_custom_stop_words_deprecated_create_fts_index(table): + with pytest.warns(DeprecationWarning, match="create_fts_index"): + table.create_fts_index( + "text", + stem=False, + custom_stop_words=["puppy"], + ) + + assert len(table.search("puppy").limit(5).to_list()) == 0 + + +def test_custom_stop_words_tokenizer_name_compatibility_is_persisted(table): + with pytest.warns(DeprecationWarning, match="create_fts_index"): + table.create_fts_index( + "text", + tokenizer_name="default", + custom_stop_words=["puppy"], + ) + + details = table.list_indices()[0].index_details + assert details["custom_stop_words"] == ["puppy"] + assert details["remove_stop_words"] is False + assert len(table.search("puppy").limit(5).to_list()) == 5 + assert [token.text for token in table.tokenize("puppy runs", column="text")] == [ + "puppy", + "runs", + ] + + +@pytest.mark.parametrize( + ("source_factory", "match"), + [ + (lambda _tmp, _table: "not-a-sequence", "not a string"), + (lambda _tmp, _table: ["valid", 42], r"custom_stop_words\[1\]"), + ( + lambda tmp, _table: FtsStopWordsFile(tmp / "missing.txt"), + "failed to read custom stop words file", + ), + ( + lambda _tmp, _table: FtsStopWordsFile(""), + "path must be a non-empty string path", + ), + ( + lambda _tmp, table: FtsStopWordsTable(table, "missing"), + "failed to resolve custom stop words column", + ), + ( + lambda _tmp, table: FtsStopWordsTable(table, ""), + "column must be a non-empty string", + ), + ( + lambda _tmp, table: FtsStopWordsTable(table, "number"), + "must have type Utf8", + ), + ], +) +def test_custom_stop_words_validation_errors(tmp_path, source_factory, match): + db = ldb.connect(tmp_path / "db") + documents = db.create_table( + "documents", + data=pa.table( + { + "text": ["the lance database"], + "number": [1], + } + ), + ) + + with pytest.raises((TypeError, ValueError), match=match): + documents.create_index( + "text", + config=FTS(custom_stop_words=source_factory(tmp_path, documents)), + ) + + +def test_custom_stop_words_file_rejects_invalid_utf8(tmp_path): + stop_words_path = tmp_path / "stop-words.txt" + stop_words_path.write_bytes(b"valid\n\xff") + db = ldb.connect(tmp_path / "db") + documents = db.create_table( + "documents", + data=pa.table({"text": ["the lance database"]}), + ) + + with pytest.raises(ValueError, match="not valid UTF-8"): + documents.create_index( + "text", + config=FTS(custom_stop_words=FtsStopWordsFile(stop_words_path)), + ) + + +@pytest.mark.parametrize( + ("values", "match"), + [ + (pa.array(["valid", None]), "contains NULL"), + (pa.array([1, 2]), "must have type Utf8"), + ], +) +def test_custom_stop_words_table_rejects_invalid_values(tmp_path, values, match): + db = ldb.connect(tmp_path) + stop_words = db.create_table("stop_words", data=pa.table({"word": values})) + documents = db.create_table( + "documents", + data=pa.table({"text": ["the lance database"]}), + ) + + with pytest.raises(ValueError, match=match): + documents.create_index( "text", config=FTS( - custom_stop_words_source=FileStopWordsSource( - "file:///tmp/stop-words.txt" - ) + custom_stop_words=FtsStopWordsTable(stop_words, "word"), ), ) -def test_fts_rejects_both_custom_stop_words_forms(): - with pytest.raises(ValueError, match="mutually exclusive"): - FTS( - custom_stop_words=[], - custom_stop_words_source=FileStopWordsSource("s3://bucket/stop-words.txt"), +def test_custom_stop_words_table_rejects_closed_table(tmp_path): + db = ldb.connect(tmp_path) + stop_words = db.create_table( + "stop_words", + data=pa.table({"word": ["lance"]}), + ) + documents = db.create_table( + "documents", + data=pa.table({"text": ["the lance database"]}), + ) + stop_words._table.close() + + with pytest.raises(RuntimeError, match="Table stop_words is closed"): + documents.create_index( + "text", + config=FTS( + custom_stop_words=FtsStopWordsTable(stop_words, "word"), + ), ) @@ -970,6 +1165,60 @@ def test_tokenize_uses_explicit_icu_tokenizer(): ] +@pytest.mark.parametrize( + ("custom_stop_words", "expected"), + [ + (None, ["lance", "data"]), + ([], ["the", "lance", "data"]), + (["lance"], ["the", "data"]), + ], + ids=["builtin", "explicit-empty", "custom"], +) +def test_tokenize_custom_stop_words_none_vs_empty(custom_stop_words, expected): + tokens = ldb.tokenize( + "the lance data", + stem=False, + custom_stop_words=custom_stop_words, + ) + + assert [token.text for token in tokens] == expected + + +def test_tokenize_custom_stop_words_file_and_table(tmp_path): + stop_words_path = tmp_path / "stop-words.txt" + stop_words_path.write_text("lance\nlance\n\n", encoding="utf-8") + file_tokens = ldb.tokenize( + "the lance data", + stem=False, + custom_stop_words=FtsStopWordsFile(stop_words_path), + ) + + db = ldb.connect(tmp_path / "db") + stop_words = db.create_table( + "stop_words", + data=pa.table({"word": ["lance", "", "lance"]}), + ) + table_tokens = ldb.tokenize( + "the lance data", + stem=False, + custom_stop_words=FtsStopWordsTable(stop_words, "word"), + ) + + assert [token.text for token in file_tokens] == ["the", "data"] + assert [token.text for token in table_tokens] == ["the", "data"] + + +def test_tokenize_custom_stop_words_disabled(): + tokens = ldb.tokenize( + "the lance data", + stem=False, + remove_stop_words=False, + custom_stop_words=["lance"], + ) + + assert [token.text for token in tokens] == ["the", "lance", "data"] + + def test_fts_on_list(mem_db: DBConnection): data = pa.table( { diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index 1418ad670..9f8dfd96b 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -17,7 +17,7 @@ from packaging.version import Version import lancedb from lancedb.conftest import MockTextEmbeddingFunction -from lancedb.query import ColumnOrdering +from lancedb.query import ColumnOrdering, MatchQuery from lancedb.remote import ClientConfig from lancedb.remote.errors import HttpError, RetryError import pytest @@ -771,6 +771,7 @@ def test_table_create_indices(): "text", wait_timeout=timedelta(seconds=2), block_size=256, + custom_stop_words=["cloud", "", "cloud"], name="custom_fts_idx", ) @@ -795,6 +796,7 @@ def test_table_create_indices(): assert "name" in fts_req assert fts_req["name"] == "custom_fts_idx" assert fts_req["block_size"] == 256 + assert fts_req["custom_stop_words"] == ["cloud"] # Check vector index request has custom name vector_req = received_requests[2] @@ -810,7 +812,7 @@ def test_table_create_indices(): table.drop_index("custom_fts_idx") -def test_remote_create_index_new_api(): +def test_remote_create_index_new_api(tmp_path): received_requests = [] def handler(request): @@ -871,16 +873,19 @@ def test_remote_create_index_new_api(): from lancedb.index import ( BTree, FTS, - FileStopWordsSource, - InlineStopWordsSource, + FtsStopWordsFile, + FtsStopWordsTable, IvfPq, IvfRq, - TableStopWordsSource, ) with mock_lancedb_connection(handler) as db: table = db.create_table("test", [{"id": 1}]) - + local_db = lancedb.connect(tmp_path / "local-source") + local_stop_words = local_db.create_table( + "stop_words", + data=[{"word": "table-word"}, {"word": ""}, {"word": "table-word"}], + ) # New API: column-first, config= kwarg. Should NOT emit DeprecationWarning. import warnings as _warnings @@ -890,25 +895,24 @@ def test_remote_create_index_new_api(): table.create_index("category", config=BTree()) table.create_index("text", config=FTS(block_size=256)) table.create_index("text", config=FTS(custom_stop_words=[])) - table.create_index( - "text", config=FTS(custom_stop_words=["lance", "database"]) - ) table.create_index( "text", - config=FTS(custom_stop_words_source=InlineStopWordsSource(["lance"])), + config=FTS( + custom_stop_words=["cloud", "", "cloud", "Keep Case"], + ), ) + stop_words_path = tmp_path / "stop-words.txt" + stop_words_path.write_text("file-word\n\nfile-word\n", encoding="utf-8") table.create_index( "text", config=FTS( - custom_stop_words_source=FileStopWordsSource( - "s3://bucket/stop-words.txt" - ) + custom_stop_words=FtsStopWordsFile(stop_words_path), ), ) table.create_index( "text", config=FTS( - custom_stop_words_source=TableStopWordsSource("stop_words", "word") + custom_stop_words=FtsStopWordsTable(local_stop_words, "word"), ), ) # IvfRq via new API @@ -922,7 +926,26 @@ def test_remote_create_index_new_api(): num_partitions=8, ) - assert len(received_requests) == 10 + with pytest.raises( + ValueError, + match="remote table sources cannot guarantee a complete snapshot", + ): + table.create_index( + "text", + config=FTS( + custom_stop_words=FtsStopWordsTable(table, "text"), + ), + ) + with pytest.raises( + ValueError, + match="remote table sources cannot guarantee a complete snapshot", + ): + lancedb.tokenize( + "the cloud", + custom_stop_words=FtsStopWordsTable(table, "text"), + ) + + assert len(received_requests) == 9 assert [req["column"] for req in received_requests] == [ "vector", "category", @@ -931,27 +954,33 @@ def test_remote_create_index_new_api(): "text", "text", "text", - "text", "vector", "vector", ] assert received_requests[2]["block_size"] == 256 - assert received_requests[2]["custom_stop_words"] is None + assert received_requests[2].get("custom_stop_words") is None assert received_requests[3]["custom_stop_words"] == [] - assert received_requests[4]["custom_stop_words"] == ["lance", "database"] - assert received_requests[5]["custom_stop_words_source"] == { - "type": "inline", - "words": ["lance"], - } - assert received_requests[6]["custom_stop_words_source"] == { - "type": "file", - "uri": "s3://bucket/stop-words.txt", - } - assert received_requests[7]["custom_stop_words_source"] == { - "type": "table", - "table": "stop_words", - "column": "word", - } + assert received_requests[4]["custom_stop_words"] == ["cloud", "Keep Case"] + assert received_requests[5]["custom_stop_words"] == ["file-word"] + assert received_requests[6]["custom_stop_words"] == ["table-word"] + assert "path" not in json.dumps(received_requests[5]) + assert "table" not in json.dumps(received_requests[5]) + assert "table-word" in json.dumps(received_requests[6]) + assert not {"source", "table", "path"} & received_requests[6].keys() + + # A forked/unpickled RemoteTable may have no live handle. Stop-word + # validation must fail without trying to reopen through LOOP.run from + # the LOOP thread (which would deadlock). + table._table_handle = None + with pytest.raises( + ValueError, + match="remote table sources cannot guarantee a complete snapshot", + ): + lancedb.tokenize( + "the cloud", + custom_stop_words=FtsStopWordsTable(table, "text"), + ) + assert table._table_handle is None def test_table_wait_for_index_timeout(): @@ -1348,6 +1377,35 @@ def test_query_sync_fts(): ) +def test_query_sync_fts_fuzziness_boundary(): + query_requests = 0 + + def handler(body): + nonlocal query_requests + query_requests += 1 + assert body["full_text_query"]["query"]["match"]["fuzziness"] == 0 + return pa.table({"id": [1]}) + + with query_test_table(handler, server_version=Version("0.3.0")) as table: + table.search( + MatchQuery("puppy", "text", fuzziness=0), + query_type="fts", + ).to_list() + assert query_requests == 1 + + with pytest.raises( + ValueError, + match="explicit fuzzy FTS queries are not supported.*fuzziness=0", + ): + table.search( + MatchQuery("puppy", "text", fuzziness=1), + query_type="fts", + ).to_list() + + # Positive fuzziness fails before issuing another remote query. + assert query_requests == 1 + + def test_query_sync_hybrid(): def handler(body): if "full_text_query" in body: diff --git a/python/src/index.rs b/python/src/index.rs index dac24f463..3ce21a72e 100644 --- a/python/src/index.rs +++ b/python/src/index.rs @@ -8,15 +8,15 @@ use lancedb::index::vector::{ }; use lancedb::index::{ Index as LanceDbIndex, - scalar::{BTreeIndexBuilder, CustomStopWordsSource, FmIndexBuilder, FtsIndexBuilder}, + scalar::{BTreeIndexBuilder, FmIndexBuilder, FtsIndexBuilder, FtsStopWordsSource}, }; use pyo3::IntoPyObject; use pyo3::types::PyStringMethods; use pyo3::{ - Bound, FromPyObject, Py, PyAny, PyResult, Python, - exceptions::{PyKeyError, PyValueError}, + Bound, FromPyObject, Py, PyAny, PyRef, PyResult, Python, + exceptions::{PyKeyError, PyTypeError, PyValueError}, intern, pyclass, pymethods, - types::{PyAnyMethods, PyString}, + types::{PyAnyMethods, PySequence, PyString, PyTypeMethods}, }; use crate::util::parse_distance_type; @@ -33,42 +33,94 @@ pub fn class_name(ob: &'_ Bound<'_, PyAny>) -> PyResult { } } -fn extract_custom_stop_words_source( - source: &Bound<'_, PyAny>, -) -> PyResult> { - let source = source.getattr("custom_stop_words_source")?; - if source.is_none() { +pub(crate) fn extract_fts_stop_words_source( + source: &Option>, +) -> PyResult> { + let Some(config) = source else { + return Ok(None); + }; + if class_name(config)? != "FTS" { return Ok(None); } - let source_type: String = source.getattr("type")?.extract()?; - match source_type.as_str() { - "inline" => Ok(Some(CustomStopWordsSource::Inline { - words: source.getattr("words")?.extract()?, - })), - "file" => Ok(Some(CustomStopWordsSource::File { - uri: source.getattr("uri")?.extract()?, - })), - "table" => Ok(Some(CustomStopWordsSource::Table { - table: source.getattr("table")?.extract()?, - column: source.getattr("column")?.extract()?, - })), - other => Err(PyValueError::new_err(format!( - "invalid custom_stop_words_source type '{other}'; expected inline, file, or table" - ))), + + let value = config.getattr(intern!(config.py(), "custom_stop_words"))?; + extract_fts_stop_words_value(&value) +} + +pub(crate) fn extract_fts_stop_words_value( + value: &Bound<'_, PyAny>, +) -> PyResult> { + if value.is_none() { + return Ok(None); + } + + match class_name(value)?.as_str() { + "FtsStopWordsFile" => { + let path = value + .getattr(intern!(value.py(), "_path"))? + .extract::() + .map_err(|_| { + PyTypeError::new_err( + "FtsStopWordsFile.path must be a string or os.PathLike[str]", + ) + })?; + Ok(Some(FtsStopWordsSource::file(path))) + } + "FtsStopWordsTable" => { + let column = value + .getattr(intern!(value.py(), "_column"))? + .extract::() + .map_err(|_| PyTypeError::new_err("FtsStopWordsTable.column must be a string"))?; + let native_table = value.getattr(intern!(value.py(), "_native_table"))?; + let native_table = native_table + .extract::>() + .map_err(|_| { + PyTypeError::new_err( + "FtsStopWordsTable.table must be a LanceDB Table or AsyncTable", + ) + })?; + let table = native_table.inner_ref()?.clone(); + Ok(Some(FtsStopWordsSource::table(table, column))) + } + _ => { + if value.cast::().is_ok() { + return Err(PyTypeError::new_err( + "custom_stop_words must be a sequence of strings, not a string", + )); + } + value.cast::().map_err(|_| { + PyTypeError::new_err( + "custom_stop_words must be a sequence of strings, \ + FtsStopWordsFile, FtsStopWordsTable, or None", + ) + })?; + + let mut words = Vec::new(); + for (idx, word) in value.try_iter()?.enumerate() { + let word = word?; + words.push(word.extract::().map_err(|_| { + PyTypeError::new_err(format!( + "custom_stop_words[{idx}] must be a string, got {}", + word.get_type() + .name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "unknown".to_string()) + )) + })?); + } + Ok(Some(FtsStopWordsSource::inline(words))) + } } } -pub fn extract_index_params( - source: &Option>, -) -> PyResult<(LanceDbIndex, Option)> { +pub fn extract_index_params(source: &Option>) -> PyResult { if let Some(source) = source { match class_name(source)?.as_str() { - "BTree" => Ok((LanceDbIndex::BTree(BTreeIndexBuilder::default()), None)), - "Bitmap" => Ok((LanceDbIndex::Bitmap(Default::default()), None)), - "LabelList" => Ok((LanceDbIndex::LabelList(Default::default()), None)), - "Fm" => Ok((LanceDbIndex::Fm(FmIndexBuilder::default()), None)), + "BTree" => Ok(LanceDbIndex::BTree(BTreeIndexBuilder::default())), + "Bitmap" => Ok(LanceDbIndex::Bitmap(Default::default())), + "LabelList" => Ok(LanceDbIndex::LabelList(Default::default())), + "Fm" => Ok(LanceDbIndex::Fm(FmIndexBuilder::default())), "FTS" => { - let custom_stop_words_source = extract_custom_stop_words_source(source)?; let params = source.extract::()?; let inner_opts = FtsIndexBuilder::default() .base_tokenizer(params.base_tokenizer) @@ -83,7 +135,6 @@ pub fn extract_index_params( .lower_case(params.lower_case) .max_token_length(params.max_token_length) .remove_stop_words(params.remove_stop_words) - .custom_stop_words(params.custom_stop_words) .stem(params.stem) .ascii_folding(params.ascii_folding) .ngram_min_length(params.ngram_min_length) @@ -92,7 +143,7 @@ pub fn extract_index_params( let inner_opts = inner_opts .block_size(params.block_size) .map_err(|err| PyValueError::new_err(err.to_string()))?; - Ok((LanceDbIndex::FTS(inner_opts), custom_stop_words_source)) + Ok(LanceDbIndex::FTS(inner_opts)) } "IvfFlat" => { let params = source.extract::()?; @@ -108,7 +159,7 @@ pub fn extract_index_params( ivf_flat_builder = ivf_flat_builder.target_partition_size(target_partition_size); } - Ok((LanceDbIndex::IvfFlat(ivf_flat_builder), None)) + Ok(LanceDbIndex::IvfFlat(ivf_flat_builder)) } "IvfPq" => { let params = source.extract::()?; @@ -127,7 +178,7 @@ pub fn extract_index_params( if let Some(num_sub_vectors) = params.num_sub_vectors { ivf_pq_builder = ivf_pq_builder.num_sub_vectors(num_sub_vectors); } - Ok((LanceDbIndex::IvfPq(ivf_pq_builder), None)) + Ok(LanceDbIndex::IvfPq(ivf_pq_builder)) } "IvfSq" => { let params = source.extract::()?; @@ -142,7 +193,7 @@ pub fn extract_index_params( if let Some(target_partition_size) = params.target_partition_size { ivf_sq_builder = ivf_sq_builder.target_partition_size(target_partition_size); } - Ok((LanceDbIndex::IvfSq(ivf_sq_builder), None)) + Ok(LanceDbIndex::IvfSq(ivf_sq_builder)) } "IvfRq" => { let params = source.extract::()?; @@ -158,7 +209,7 @@ pub fn extract_index_params( if let Some(target_partition_size) = params.target_partition_size { ivf_rq_builder = ivf_rq_builder.target_partition_size(target_partition_size); } - Ok((LanceDbIndex::IvfRq(ivf_rq_builder), None)) + Ok(LanceDbIndex::IvfRq(ivf_rq_builder)) } "HnswPq" => { let params = source.extract::()?; @@ -179,7 +230,7 @@ pub fn extract_index_params( if let Some(num_sub_vectors) = params.num_sub_vectors { hnsw_pq_builder = hnsw_pq_builder.num_sub_vectors(num_sub_vectors); } - Ok((LanceDbIndex::IvfHnswPq(hnsw_pq_builder), None)) + Ok(LanceDbIndex::IvfHnswPq(hnsw_pq_builder)) } "HnswSq" => { let params = source.extract::()?; @@ -196,7 +247,7 @@ pub fn extract_index_params( if let Some(target_partition_size) = params.target_partition_size { hnsw_sq_builder = hnsw_sq_builder.target_partition_size(target_partition_size); } - Ok((LanceDbIndex::IvfHnswSq(hnsw_sq_builder), None)) + Ok(LanceDbIndex::IvfHnswSq(hnsw_sq_builder)) } "HnswFlat" => { let params = source.extract::()?; @@ -214,7 +265,7 @@ pub fn extract_index_params( hnsw_flat_builder = hnsw_flat_builder.target_partition_size(target_partition_size); } - Ok((LanceDbIndex::IvfHnswFlat(hnsw_flat_builder), None)) + Ok(LanceDbIndex::IvfHnswFlat(hnsw_flat_builder)) } not_supported => Err(PyValueError::new_err(format!( "Invalid index type '{}'. Must be one of BTree, Bitmap, LabelList, Fm, FTS, IvfPq, IvfSq, IvfHnswPq, IvfHnswSq, or IvfHnswFlat", @@ -222,7 +273,7 @@ pub fn extract_index_params( ))), } } else { - Ok((LanceDbIndex::Auto, None)) + Ok(LanceDbIndex::Auto) } } @@ -235,7 +286,6 @@ struct FtsParams { lower_case: bool, stem: bool, remove_stop_words: bool, - custom_stop_words: Option>, ascii_folding: bool, ngram_min_length: u32, ngram_max_length: u32, diff --git a/python/src/table.rs b/python/src/table.rs index aaeae662b..303c02d18 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -7,7 +7,10 @@ use crate::{ connection::Connection, error::PythonErrorExt, expr::PyExpr, - index::{IndexConfig, extract_index_params}, + index::{ + IndexConfig, extract_fts_stop_words_source, extract_fts_stop_words_value, + extract_index_params, + }, query::{Query, TakeQuery}, table::scannable::PyScannable, }; @@ -520,13 +523,15 @@ impl From for FtsToken { lower_case = true, stem = true, remove_stop_words = true, + custom_stop_words = None, ascii_folding = true, ngram_min_length = 3, ngram_max_length = 3, prefix_only = false ))] #[allow(clippy::too_many_arguments)] -pub fn tokenize( +pub fn tokenize<'py>( + py: Python<'py>, query: String, base_tokenizer: String, language: String, @@ -534,11 +539,17 @@ pub fn tokenize( lower_case: bool, stem: bool, remove_stop_words: bool, + custom_stop_words: Option>, ascii_folding: bool, ngram_min_length: u32, ngram_max_length: u32, prefix_only: bool, -) -> PyResult> { +) -> PyResult> { + let custom_stop_words = custom_stop_words + .as_ref() + .map(extract_fts_stop_words_value) + .transpose()? + .flatten(); let params = FtsIndexBuilder::default() .base_tokenizer(base_tokenizer) .language(&language) @@ -556,8 +567,15 @@ pub fn tokenize( .ngram_min_length(ngram_min_length) .ngram_max_length(ngram_max_length) .ngram_prefix_only(prefix_only); - let tokens = lancedb_tokenize(&query, ¶ms).infer_error()?; - Ok(tokens.into_iter().map(FtsToken::from).collect()) + future_into_py(py, async move { + let params = if let Some(source) = custom_stop_words { + params.custom_stop_words(Some(source.resolve().await.infer_error()?)) + } else { + params + }; + let tokens = lancedb_tokenize(&query, ¶ms).infer_error()?; + Ok(tokens.into_iter().map(FtsToken::from).collect::>()) + }) } #[pyclass] @@ -781,7 +799,8 @@ impl Table { name: Option, train: Option, ) -> PyResult> { - let (index, custom_stop_words_source) = extract_index_params(&index)?; + let custom_stop_words = extract_fts_stop_words_source(&index)?; + let index = extract_index_params(&index)?; let timeout = wait_timeout.map(|t| t.extract::().unwrap()); let mut op = self_ .inner_ref()? @@ -795,8 +814,8 @@ impl Table { if let Some(train) = train { op = op.train(train); } - if let Some(source) = custom_stop_words_source { - op = op.custom_stop_words_source(source); + if let Some(custom_stop_words) = custom_stop_words { + op = op.custom_stop_words(custom_stop_words).infer_error()?; } future_into_py(self_.py(), async move { diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index ad6a4e2d6..ca27af840 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -53,7 +53,7 @@ metrics-util = { workspace = true, optional = true } goosefs-sdk = { version = "=0.1.5", optional = true } moka = { workspace = true } pin-project = { workspace = true } -tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } +tokio = { version = "1.23", features = ["fs", "rt-multi-thread", "sync"] } log.workspace = true async-trait = "0" bytes = "1" diff --git a/rust/lancedb/examples/full_text_search.rs b/rust/lancedb/examples/full_text_search.rs index 54d4a38f5..c57361e49 100644 --- a/rust/lancedb/examples/full_text_search.rs +++ b/rust/lancedb/examples/full_text_search.rs @@ -11,7 +11,7 @@ use lance_index::scalar::FullTextSearchQuery; use lancedb::connection::Connection; use lancedb::index::Index; -use lancedb::index::scalar::FtsIndexBuilder; +use lancedb::index::scalar::{FtsIndexBuilder, FtsStopWordsSource}; use lancedb::query::{ExecutableQuery, QueryBase}; use lancedb::{Result, Table, connect}; use rand::random; @@ -77,6 +77,7 @@ async fn create_table(db: &Connection) -> Result { async fn create_index(table: &Table) -> Result<()> { table .create_index(&["doc"], Index::FTS(FtsIndexBuilder::default())) + .custom_stop_words(FtsStopWordsSource::inline(vec!["example".to_owned()]))? .execute() .await?; Ok(()) diff --git a/rust/lancedb/src/index.rs b/rust/lancedb/src/index.rs index 7e815f44d..e4639336d 100644 --- a/rust/lancedb/src/index.rs +++ b/rust/lancedb/src/index.rs @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright The LanceDB Authors use chrono::{DateTime, Utc}; -use scalar::FtsIndexBuilder; +use scalar::{FtsIndexBuilder, FtsStopWordsSource, normalize_stop_words}; use serde::Deserialize; use serde_with::skip_serializing_none; use std::sync::Arc; @@ -13,10 +13,7 @@ use crate::index::vector::IvfRqIndexBuilder; use crate::{DistanceType, Error, Result, table::BaseTable}; use self::{ - scalar::{ - BTreeIndexBuilder, BitmapIndexBuilder, CustomStopWordsSource, FmIndexBuilder, - LabelListIndexBuilder, - }, + scalar::{BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder, LabelListIndexBuilder}, vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, IvfPqIndexBuilder, IvfSqIndexBuilder, @@ -190,12 +187,12 @@ pub enum Index { pub struct IndexBuilder { parent: Arc, pub(crate) index: Index, + custom_stop_words_source: Option, pub(crate) columns: Vec, pub(crate) replace: bool, pub(crate) wait_timeout: Option, pub(crate) train: bool, pub(crate) name: Option, - pub(crate) custom_stop_words_source: Option, } impl IndexBuilder { @@ -203,12 +200,12 @@ impl IndexBuilder { Self { parent, index, + custom_stop_words_source: None, columns, replace: true, train: true, wait_timeout: None, name: None, - custom_stop_words_source: None, } } @@ -298,21 +295,6 @@ impl IndexBuilder { self } - /// Supply a request-only source for custom FTS stop words. - /// - /// This option is supported only for FTS indexes on remote tables. The - /// remote service resolves the source to a stable snapshot before the - /// distributed build starts. Local native tables return an error instead of - /// reading a path or table implicitly; load the source explicitly and use - /// [`FtsIndexBuilder::custom_stop_words`] for local indexes. - /// - /// This is mutually exclusive with inline custom stop words configured - /// directly on the [`FtsIndexBuilder`]. - pub fn custom_stop_words_source(mut self, source: CustomStopWordsSource) -> Self { - self.custom_stop_words_source = Some(source); - self - } - /// Duration of time to wait for asynchronous indexing to complete. If not set, /// `create_index()` will not wait. /// @@ -322,11 +304,136 @@ impl IndexBuilder { self } - pub async fn execute(self) -> Result<()> { + /// Configure a mutually exclusive source of custom FTS stop words. + /// + /// The source is resolved on the client when [`Self::execute`] runs. Files + /// are read as UTF-8 newline-delimited text, and table sources project and + /// stream only the selected string column. The resulting concrete list is + /// persisted as the index's tokenizer snapshot and is the only value sent + /// to a remote server. + /// + /// Custom stop words replace the built-in language stop words and are only + /// used when `remove_stop_words` is enabled in [`FtsIndexBuilder`]. + /// Native FTS queries using an active custom snapshot reject explicit + /// positive fuzziness because Lance's fuzzy path currently bypasses the + /// persisted tokenizer. Remote tables reject explicit positive fuzziness + /// until the server protocol declares snapshot-safe fuzzy support. + /// + /// ``` + /// use lancedb::index::{ + /// Index, + /// scalar::{FtsIndexBuilder, FtsStopWordsSource}, + /// }; + /// + /// # async fn create_index(table: &lancedb::Table) -> lancedb::Result<()> { + /// table + /// .create_index(&["text"], Index::FTS(FtsIndexBuilder::default())) + /// .custom_stop_words(FtsStopWordsSource::file("stop-words.txt"))? + /// .execute() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if this is not an FTS index, a source was already set, + /// or the supplied [`FtsIndexBuilder`] already contains a custom stop-word + /// list. + pub fn custom_stop_words(mut self, source: impl Into) -> Result { + if self.custom_stop_words_source.is_some() { + return Err(Error::InvalidInput { + message: "custom stop words source was set more than once".to_string(), + }); + } + let Index::FTS(params) = &self.index else { + return Err(Error::InvalidInput { + message: "custom stop words can only be configured for an FTS index".to_string(), + }); + }; + if fts_params_have_custom_stop_words(params)? { + return Err(Error::InvalidInput { + message: "custom stop words are already configured directly on FtsIndexBuilder; use either FtsIndexBuilder::custom_stop_words or IndexBuilder::custom_stop_words, not both".to_string(), + }); + } + self.custom_stop_words_source = Some(source.into()); + Ok(self) + } + + pub async fn execute(mut self) -> Result<()> { + let direct_custom_stop_words = match &self.index { + Index::FTS(params) => fts_params_custom_stop_words(params)?, + _ => None, + }; + if let Some(source) = self.custom_stop_words_source.take() { + let snapshot = source.resolve().await?; + let Index::FTS(params) = &mut self.index else { + return Err(Error::InvalidInput { + message: "custom stop words can only be configured for an FTS index" + .to_string(), + }); + }; + if fts_params_have_custom_stop_words(params)? { + return Err(Error::InvalidInput { + message: "custom stop words are already configured directly on FtsIndexBuilder; use either FtsIndexBuilder::custom_stop_words or IndexBuilder::custom_stop_words, not both".to_string(), + }); + } + *params = params.clone().custom_stop_words(Some(snapshot)); + } else if direct_custom_stop_words.is_some() + && let Index::FTS(params) = &mut self.index + { + *params = canonicalize_fts_params(params)?; + } self.parent.clone().create_index(self).await } } +fn fts_params_have_custom_stop_words(params: &FtsIndexBuilder) -> Result { + Ok(fts_params_custom_stop_words(params)?.is_some()) +} + +pub(crate) fn fts_params_custom_stop_words( + params: &FtsIndexBuilder, +) -> Result>> { + let value = serde_json::to_value(params).map_err(|e| Error::InvalidInput { + message: format!("failed to inspect FTS params for custom stop words: {}", e), + })?; + match value.get("custom_stop_words") { + None | Some(serde_json::Value::Null) => Ok(None), + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .map_err(|e| Error::InvalidInput { + message: format!("invalid custom stop words in FTS params: {}", e), + }), + } +} + +pub(crate) fn canonicalize_fts_params(params: &FtsIndexBuilder) -> Result { + match fts_params_custom_stop_words(params)? { + Some(stop_words) => Ok(params + .clone() + .custom_stop_words(Some(normalize_stop_words(stop_words)))), + None => Ok(params.clone()), + } +} + +pub(crate) fn fts_params_use_custom_stop_words(params: &FtsIndexBuilder) -> Result { + let value = serde_json::to_value(params).map_err(|e| Error::InvalidInput { + message: format!("failed to inspect FTS params for stop-word removal: {}", e), + })?; + let remove_stop_words = value + .get("remove_stop_words") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| Error::InvalidInput { + message: "invalid remove_stop_words value in FTS params".to_string(), + })?; + Ok(remove_stop_words + && !matches!( + value.get("custom_stop_words"), + None | Some(serde_json::Value::Null) + )) +} + #[derive(Debug, Clone, PartialEq, Deserialize)] pub enum IndexType { // Vector diff --git a/rust/lancedb/src/index/scalar.rs b/rust/lancedb/src/index/scalar.rs index 87437a396..86d9abca5 100644 --- a/rust/lancedb/src/index/scalar.rs +++ b/rust/lancedb/src/index/scalar.rs @@ -10,36 +10,290 @@ //! etc. Scalar indices can also speed up prefiltering for vector searches. A single //! vector search with prefiltering can use both a scalar index and a vector index. -use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::PathBuf; -/// A request-only source for custom FTS stop words. +use arrow_array::cast::AsArray; +use arrow_schema::DataType; +use futures::{TryStreamExt, future::BoxFuture}; + +use crate::query::{QueryExecutionOptions, QueryRequest, Select}; +use crate::table::query::{AnyQuery, execute_generic_query}; +use crate::utils::resolve_arrow_field_path; +use crate::{Error, Result, Table}; + +/// A client-side source for custom full-text-search stop words. /// -/// Remote LanceDB services resolve this source to a stable inline snapshot before -/// starting an index build. Local native tables do not resolve external sources; -/// callers must load them themselves and use -/// [`FtsIndexBuilder::custom_stop_words`] instead. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum CustomStopWordsSource { - /// Stop words supplied directly in the create-index request. - Inline { - /// Words that replace the built-in language stop-word list. - words: Vec, - }, - /// A strict UTF-8, newline-delimited object. - File { - /// URI of the object to read. - uri: String, - }, - /// A string column in a table visible to the remote service. +/// Every source is resolved by the LanceDB client into an owned, stable +/// `Vec` snapshot before index creation. Only that concrete snapshot is +/// passed to a local Lance index builder or serialized in a remote +/// `create_index` request; a remote server never attempts to read a client-local +/// file path. +/// +/// Exact empty strings are ignored and exact duplicates are removed while +/// retaining their first occurrence. Values are otherwise preserved verbatim: +/// LanceDB does not trim, lowercase, or otherwise normalize them. +/// +/// A native query using an active custom snapshot (`remove_stop_words=true`) +/// rejects explicit fuzzy matching (`fuzziness > 0`) because Lance's current +/// fuzzy path does not use the persisted tokenizer. Remote tables reject all +/// explicit fuzzy matching until the server protocol can atomically bind a +/// query to a tokenizer snapshot. Exact matching (`fuzziness=0`) and an unset +/// fuzziness remain supported. +#[non_exhaustive] +#[derive(Clone, Debug)] +pub enum FtsStopWordsSource { + /// Stop words supplied directly by the caller. + Inline(Vec), + /// A UTF-8, newline-delimited file read by the client. + File(PathBuf), + /// All values from a local/native table string column; `NULL` is rejected. + /// + /// Remote table sources are rejected because a remote query may impose a + /// result limit and cannot currently prove that the returned snapshot is + /// complete. Native sources are read directly from one local dataset + /// snapshot, including MemWAL rows, without Namespace query pushdown. A + /// remote target index may still use a local table source. Table { - /// Logical table identifier. - table: String, - /// Top-level string column containing one stop word per row. + /// The LanceDB table containing the stop words. + table: Table, + /// The `Utf8`, `LargeUtf8`, or `Utf8View` column to read. column: String, }, } +impl FtsStopWordsSource { + /// Create an inline stop-word source. + pub fn inline(words: Vec) -> Self { + Self::Inline(words) + } + + /// Create a UTF-8, newline-delimited file source. + pub fn file(path: impl Into) -> Self { + Self::File(path.into()) + } + + /// Create a LanceDB table-column source. + /// + /// The column is projected and streamed instead of materializing the rest + /// of the table. `NULL` values fail closed because silently dropping them + /// could produce an unintended tokenizer configuration. The source table + /// must be local/native; materialize remote stop words locally first. + pub fn table(table: Table, column: impl Into) -> Self { + Self::Table { + table, + column: column.into(), + } + } + + /// Resolve this source into the exact snapshot persisted with an FTS index. + /// + /// This method always runs on the client. It is also useful for standalone + /// tokenization APIs that need to use the same stop-word source semantics as + /// index creation. + pub fn resolve(self) -> BoxFuture<'static, Result>> { + Box::pin(async move { + match self { + Self::Inline(words) => Ok(normalize_stop_words(words)), + Self::File(path) => { + if path.as_os_str().is_empty() { + return Err(Error::InvalidInput { + message: "custom stop words file source requires a non-empty path" + .to_string(), + }); + } + let bytes = tokio::fs::read(&path) + .await + .map_err(|e| Error::InvalidInput { + message: format!( + "failed to read custom stop words file `{}`: {}", + path.display(), + e + ), + })?; + let contents = String::from_utf8(bytes).map_err(|e| Error::InvalidInput { + message: format!( + "custom stop words file `{}` is not valid UTF-8: {}", + path.display(), + e + ), + })?; + Ok(normalize_stop_words( + contents.lines().map(ToOwned::to_owned), + )) + } + Self::Table { table, column } => { + if column.is_empty() { + return Err(Error::InvalidInput { + message: "custom stop words table source requires a non-empty column" + .to_string(), + }); + } + resolve_table_stop_words(table, column).await + } + } + }) + } +} + +impl From> for FtsStopWordsSource { + fn from(words: Vec) -> Self { + Self::Inline(words) + } +} + +pub(crate) fn normalize_stop_words(words: impl IntoIterator) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + for word in words { + if !word.is_empty() && seen.insert(word.clone()) { + normalized.push(word); + } + } + normalized +} + +fn append_stop_words<'a>( + values: impl IntoIterator>, + table_name: &str, + column: &str, + row_offset: usize, + stop_words: &mut Vec, + seen: &mut HashSet, +) -> Result { + let mut rows = 0; + for (index, value) in values.into_iter().enumerate() { + rows += 1; + let value = value.ok_or_else(|| Error::InvalidInput { + message: format!( + "custom stop words column `{}` in table `{}` contains NULL at row {}", + column, + table_name, + row_offset + index + ), + })?; + if !value.is_empty() { + let value = value.to_owned(); + if seen.insert(value.clone()) { + stop_words.push(value); + } + } + } + Ok(rows) +} + +async fn resolve_table_stop_words(table: Table, column: String) -> Result> { + let table_name = table.name().to_owned(); + let native_table = table.as_native().ok_or_else(|| { + Error::InvalidInput { + message: format!( + "custom stop words table `{}` is remote; remote table sources cannot guarantee a complete snapshot. Materialize the stop-word column into a local LanceDB table, or use an inline list or UTF-8 file", + table_name + ), + } + })?; + let schema = table.schema().await.map_err(|e| Error::InvalidInput { + message: format!( + "failed to read schema for custom stop words table `{}`: {}", + table_name, e + ), + })?; + let (canonical_column, field) = + resolve_arrow_field_path(schema.as_ref(), &column).map_err(|e| Error::InvalidInput { + message: format!( + "failed to resolve custom stop words column `{}` in table `{}`: {}", + column, table_name, e + ), + })?; + if !matches!( + field.data_type(), + DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View + ) { + return Err(Error::InvalidInput { + message: format!( + "custom stop words column `{}` in table `{}` must have type Utf8, LargeUtf8, or Utf8View, but has {}", + canonical_column, + table_name, + field.data_type() + ), + }); + } + + let query = AnyQuery::Query(QueryRequest { + select: Select::columns(&[canonical_column.as_str()]), + ..Default::default() + }); + // Do not call `Table::query` here. A native table may have Namespace + // QueryTable pushdown enabled, whose protocol defaults an unbounded plain + // query to `k=10`. Reading through the generic local path both avoids that + // truncation and retains `create_plan`'s automatic MemWAL routing. + let mut stream = execute_generic_query(native_table, &query, QueryExecutionOptions::default()) + .await + .map_err(|e| Error::InvalidInput { + message: format!( + "failed to query custom stop words column `{}` in table `{}`: {}", + canonical_column, table_name, e + ), + })?; + let mut stop_words = Vec::new(); + let mut seen = HashSet::new(); + let mut row_offset = 0; + while let Some(batch) = stream.try_next().await.map_err(|e| Error::InvalidInput { + message: format!( + "failed while reading custom stop words column `{}` in table `{}`: {}", + canonical_column, table_name, e + ), + })? { + if batch.num_columns() != 1 { + return Err(Error::InvalidInput { + message: format!( + "querying custom stop words column `{}` in table `{}` returned {} columns instead of one", + canonical_column, + table_name, + batch.num_columns() + ), + }); + } + let values = batch.column(0); + let rows = match values.data_type() { + DataType::Utf8 => append_stop_words( + values.as_ref().as_string::().iter(), + &table_name, + &canonical_column, + row_offset, + &mut stop_words, + &mut seen, + )?, + DataType::LargeUtf8 => append_stop_words( + values.as_ref().as_string::().iter(), + &table_name, + &canonical_column, + row_offset, + &mut stop_words, + &mut seen, + )?, + DataType::Utf8View => append_stop_words( + values.as_ref().as_string_view().iter(), + &table_name, + &canonical_column, + row_offset, + &mut stop_words, + &mut seen, + )?, + data_type => { + return Err(Error::InvalidInput { + message: format!( + "querying custom stop words column `{}` in table `{}` returned unexpected type {}", + canonical_column, table_name, data_type + ), + }); + } + }; + row_offset += rows; + } + Ok(stop_words) +} + /// Builder for a btree index /// /// A btree index is an index on scalar columns. The index stores a copy of the column @@ -94,3 +348,284 @@ pub use lance_index::scalar::FullTextSearchQuery; pub use lance_index::scalar::InvertedIndexParams as FtsIndexBuilder; pub use lance_index::scalar::InvertedIndexParams; pub use lance_index::scalar::inverted::query::*; + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use arrow_array::{ + ArrayRef, Int32Array, LargeStringArray, RecordBatch, RecordBatchIterator, StringArray, + }; + use arrow_schema::{DataType, Field, Schema}; + use lance_namespace::LanceNamespace; + use lance_namespace::models::QueryTableRequest; + use tempfile::tempdir; + + use super::FtsStopWordsSource; + use crate::connection::NamespaceClientPushdownOperation; + use crate::table::BaseTable; + use crate::{Table, connect}; + + #[derive(Debug, Default)] + struct CountingNamespaceClient { + query_table_calls: AtomicUsize, + } + + #[async_trait::async_trait] + impl LanceNamespace for CountingNamespaceClient { + fn namespace_id(&self) -> String { + "counting-stop-words".to_string() + } + + async fn query_table(&self, _request: QueryTableRequest) -> lance::Result { + self.query_table_calls.fetch_add(1, Ordering::SeqCst); + panic!("custom stop words must be read from the local dataset snapshot"); + } + } + + #[tokio::test] + async fn resolve_inline_and_file_stop_words() { + let inline = FtsStopWordsSource::inline(vec![ + "cat".to_string(), + String::new(), + " cat ".to_string(), + "cat".to_string(), + "CAT".to_string(), + ]) + .resolve() + .await + .unwrap(); + assert_eq!(inline, vec!["cat", " cat ", "CAT"]); + + let dir = tempdir().unwrap(); + let path = dir.path().join("stop-words.txt"); + std::fs::write(&path, b"cat\r\n\r\n cat \ncat\nCAT\n").unwrap(); + let from_file = FtsStopWordsSource::file(&path).resolve().await.unwrap(); + assert_eq!(from_file, inline); + + let empty_path = dir.path().join("empty.txt"); + std::fs::write(&empty_path, b"\n\r\n").unwrap(); + assert!( + FtsStopWordsSource::file(&empty_path) + .resolve() + .await + .unwrap() + .is_empty() + ); + + let invalid_path = dir.path().join("invalid.txt"); + std::fs::write(&invalid_path, [0xff, 0xfe]).unwrap(); + let error = FtsStopWordsSource::file(&invalid_path) + .resolve() + .await + .unwrap_err(); + assert!(error.to_string().contains("is not valid UTF-8")); + + let error = FtsStopWordsSource::file(dir.path().join("missing.txt")) + .resolve() + .await + .unwrap_err(); + assert!(error.to_string().contains("failed to read")); + assert!(error.to_string().contains("missing.txt")); + + let error = FtsStopWordsSource::file("").resolve().await.unwrap_err(); + assert!( + error + .to_string() + .contains("file source requires a non-empty path") + ); + } + + #[tokio::test] + async fn resolve_table_stop_words_and_validate_column() { + let conn = connect("memory://").execute().await.unwrap(); + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("word", DataType::Utf8, false), + Field::new("large_word", DataType::LargeUtf8, false), + Field::new("number", DataType::Int32, false), + Field::new("nullable_word", DataType::Utf8, true), + ])), + vec![ + Arc::new(StringArray::from(vec!["cat", "", "cat", " CAT "])) as ArrayRef, + Arc::new(LargeStringArray::from(vec!["large", "large", "", "other"])), + Arc::new(Int32Array::from(vec![1, 2, 3, 4])), + Arc::new(StringArray::from(vec![ + Some("valid"), + None, + Some("later"), + Some("last"), + ])), + ], + ) + .unwrap(); + let table = conn + .create_table("stop_words", batch) + .execute() + .await + .unwrap(); + + assert_eq!( + FtsStopWordsSource::table(table.clone(), "word") + .resolve() + .await + .unwrap(), + vec!["cat", " CAT "] + ); + assert_eq!( + FtsStopWordsSource::table(table.clone(), "large_word") + .resolve() + .await + .unwrap(), + vec!["large", "other"] + ); + + let error = FtsStopWordsSource::table(table.clone(), "nullable_word") + .resolve() + .await + .unwrap_err(); + assert!(error.to_string().contains("contains NULL at row 1")); + + let error = FtsStopWordsSource::table(table.clone(), "number") + .resolve() + .await + .unwrap_err(); + assert!(error.to_string().contains("must have type Utf8")); + assert!(error.to_string().contains("Int32")); + + let error = FtsStopWordsSource::table(table, "missing") + .resolve() + .await + .unwrap_err(); + assert!(error.to_string().contains("failed to resolve")); + assert!(error.to_string().contains("missing")); + + let error = + FtsStopWordsSource::table(conn.open_table("stop_words").execute().await.unwrap(), "") + .resolve() + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("table source requires a non-empty column") + ); + } + + #[cfg(feature = "remote")] + #[tokio::test] + async fn reject_remote_table_stop_words_source() { + let table = + crate::Table::new_with_handler("remote_stop_words", |_| -> http::Response { + panic!("remote source validation should fail before issuing a request") + }); + let error = FtsStopWordsSource::table(table, "word") + .resolve() + .await + .unwrap_err(); + assert!(error.to_string().contains("is remote")); + assert!( + error + .to_string() + .contains("cannot guarantee a complete snapshot") + ); + assert!(error.to_string().contains("local LanceDB table")); + } + + #[tokio::test] + async fn table_stop_words_bypass_namespace_query_limits() { + let conn = connect("memory://").execute().await.unwrap(); + let words = (0..12) + .map(|index| format!("word-{index}")) + .collect::>(); + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("word", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from_iter_values( + words.iter().map(String::as_str), + ))], + ) + .unwrap(); + let table = conn + .create_table("stop_words_namespace_source", batch) + .execute() + .await + .unwrap(); + let namespace_client = Arc::new(CountingNamespaceClient::default()); + let mut native_table = table.as_native().unwrap().clone(); + native_table.namespace_client = Some(namespace_client.clone()); + native_table + .pushdown_operations + .insert(NamespaceClientPushdownOperation::QueryTable); + let source_table = Table::from(Arc::new(native_table) as Arc); + + assert_eq!( + FtsStopWordsSource::table(source_table, "word") + .resolve() + .await + .unwrap(), + words + ); + assert_eq!(namespace_client.query_table_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn table_stop_words_include_uncompacted_memwal_rows() { + let dir = tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("word", DataType::Utf8, false), + ])); + let initial_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2])), + Arc::new(StringArray::from(vec!["base-one", "base-two"])), + ], + ) + .unwrap(); + let table = conn + .create_table("stop_words_memwal_source", initial_batch) + .execute() + .await + .unwrap(); + table.set_unenforced_primary_key(["id"]).await.unwrap(); + table + .set_lsm_write_spec(crate::table::LsmWriteSpec::unsharded()) + .await + .unwrap(); + + let memwal_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![3])), + Arc::new(StringArray::from(vec!["memwal"])), + ], + ) + .unwrap(); + let mut merge = table.merge_insert(&["id"]); + merge + .when_matched_update_all(None) + .when_not_matched_insert_all(); + merge + .execute(Box::new(RecordBatchIterator::new( + vec![Ok(memwal_batch)], + schema, + ))) + .await + .unwrap(); + + let mut words = FtsStopWordsSource::table(table, "word") + .resolve() + .await + .unwrap(); + words.sort(); + assert_eq!(words, vec!["base-one", "base-two", "memwal"]); + } +} diff --git a/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 3cdf33615..acd1e3a14 100644 --- a/rust/lancedb/src/lib.rs +++ b/rust/lancedb/src/lib.rs @@ -222,6 +222,54 @@ pub fn tokenize(query: &str, params: &index::scalar::FtsIndexBuilder) -> Result< table::tokenize(query, params) } +/// Tokenize text after resolving an optional custom stop-word source. +/// +/// The source is resolved client-side into the same concrete snapshot used by +/// [`index::IndexBuilder::custom_stop_words`]. `None` retains the built-in +/// language stop words, while an empty source replaces them with an empty list. +/// Custom stop words are only applied when `remove_stop_words` is enabled in +/// `params`. +/// +/// # Errors +/// +/// Returns an error if both `params` and `custom_stop_words` configure a custom +/// list, or if the file or table source cannot be read and validated. +/// +/// ``` +/// use lancedb::index::scalar::{FtsIndexBuilder, FtsStopWordsSource}; +/// +/// # async fn tokenize() -> lancedb::Result<()> { +/// let params = FtsIndexBuilder::default(); +/// let tokens = lancedb::tokenize_with_custom_stop_words( +/// "search this text", +/// ¶ms, +/// Some(FtsStopWordsSource::file("stop-words.txt")), +/// ) +/// .await?; +/// # let _ = tokens; +/// # Ok(()) +/// # } +/// ``` +pub async fn tokenize_with_custom_stop_words( + query: &str, + params: &index::scalar::FtsIndexBuilder, + custom_stop_words: Option, +) -> Result> { + let params = if let Some(source) = custom_stop_words { + if index::fts_params_custom_stop_words(params)?.is_some() { + return Err(Error::InvalidInput { + message: "custom stop words are already configured directly on FtsIndexBuilder; use either FtsIndexBuilder::custom_stop_words or tokenize_with_custom_stop_words, not both".to_string(), + }); + } + params + .clone() + .custom_stop_words(Some(source.resolve().await?)) + } else { + params.clone() + }; + table::tokenize(query, ¶ms) +} + #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize, Default)] #[non_exhaustive] #[serde(rename_all = "lowercase")] @@ -374,3 +422,119 @@ pub use lance_io::object_store::ObjectStoreRegistry; /// declaring their own (potentially mismatched) direct `datafusion` dependency. /// See . pub use datafusion; + +#[cfg(test)] +mod tokenize_source_tests { + use std::sync::Arc; + + use arrow_array::{RecordBatch, StringArray}; + use arrow_schema::{DataType, Field, Schema}; + use tempfile::tempdir; + + use crate::index::scalar::{FtsIndexBuilder, FtsStopWordsSource}; + use crate::{connect, tokenize_with_custom_stop_words}; + + fn token_text(tokens: Vec) -> Vec { + tokens.into_iter().map(|token| token.text).collect() + } + + #[tokio::test] + async fn tokenize_distinguishes_none_empty_file_and_table_sources() { + let params = FtsIndexBuilder::default().stem(false); + + let builtin = tokenize_with_custom_stop_words("the lance", ¶ms, None) + .await + .unwrap(); + assert_eq!(token_text(builtin), vec!["lance"]); + + let empty = tokenize_with_custom_stop_words( + "the lance", + ¶ms, + Some(FtsStopWordsSource::inline(Vec::new())), + ) + .await + .unwrap(); + assert_eq!(token_text(empty), vec!["the", "lance"]); + + let dir = tempdir().unwrap(); + let path = dir.path().join("stop-words.txt"); + std::fs::write(&path, "lance\n").unwrap(); + let from_file = tokenize_with_custom_stop_words( + "the lance", + ¶ms, + Some(FtsStopWordsSource::file(path)), + ) + .await + .unwrap(); + assert_eq!(token_text(from_file), vec!["the"]); + + let conn = connect("memory://").execute().await.unwrap(); + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("word", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from(vec!["lance"]))], + ) + .unwrap(); + let table = conn + .create_table("stop_words", batch) + .execute() + .await + .unwrap(); + let from_table = tokenize_with_custom_stop_words( + "the lance", + ¶ms, + Some(FtsStopWordsSource::table(table, "word")), + ) + .await + .unwrap(); + assert_eq!(token_text(from_table), vec!["the"]); + } + + #[tokio::test] + async fn tokenize_rejects_two_custom_stop_word_sources() { + let params = + FtsIndexBuilder::default().custom_stop_words(Some(vec!["configured".to_string()])); + let error = tokenize_with_custom_stop_words( + "some text", + ¶ms, + Some(FtsStopWordsSource::inline(vec!["other".to_string()])), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("already configured directly")); + } + + #[test] + fn direct_tokenize_canonicalizes_custom_stop_words() { + let defaults = FtsIndexBuilder::default().stem(false); + assert_eq!( + token_text(crate::tokenize("the lance", &defaults).unwrap()), + vec!["lance"] + ); + let empty = defaults.clone().custom_stop_words(Some(Vec::new())); + assert_eq!( + token_text(crate::tokenize("the lance", &empty).unwrap()), + vec!["the", "lance"] + ); + + let params = FtsIndexBuilder::default() + .stem(false) + .custom_stop_words(Some(vec![ + "lance".to_string(), + String::new(), + "lance".to_string(), + " cat ".to_string(), + ])); + + assert_eq!( + token_text(crate::tokenize("the lance cat", ¶ms).unwrap()), + vec!["the", "cat"] + ); + assert_eq!( + crate::index::fts_params_custom_stop_words( + &crate::index::canonicalize_fts_params(¶ms).unwrap() + ) + .unwrap(), + Some(vec!["lance".to_string(), " cat ".to_string()]) + ); + } +} diff --git a/rust/lancedb/src/query.rs b/rust/lancedb/src/query.rs index b76865043..b170347af 100644 --- a/rust/lancedb/src/query.rs +++ b/rust/lancedb/src/query.rs @@ -437,6 +437,13 @@ pub trait QueryBase { /// /// This method is only valid on tables that have a full text search index. /// + /// Explicit fuzzy matching (`fuzziness > 0`) is rejected for native + /// indices with active custom stop words because the current fuzzy path + /// bypasses the persisted tokenizer. Remote tables reject all explicit + /// fuzzy matching until the server protocol can bind it to a tokenizer + /// snapshot atomically. `fuzziness=0` and an unset fuzziness continue to + /// use the normal query path. + /// /// ``` /// use lance_index::scalar::FullTextSearchQuery; /// use lancedb::query::{QueryBase, ExecutableQuery}; diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index f461ca70c..b4ef8c6d2 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -27,7 +27,7 @@ use crate::table::MergeResult; use crate::table::Tags; use crate::table::UpdateResult; use crate::table::merge::MergeFilter; -use crate::table::query::create_multi_vector_plan; +use crate::table::query::{create_multi_vector_plan, fuzzy_match_columns}; use crate::table::{AlterColumnsResult, FieldMetadataUpdate, UpdateFieldMetadataResult}; use crate::table::{AnyQuery, Filter, Predicate, PreprocessingOutput, TableStatistics}; use crate::utils::background_cache::BackgroundCache; @@ -1003,6 +1003,7 @@ impl RemoteTable { } async fn prepare_query_bodies(&self, query: &AnyQuery) -> Result> { + self.reject_remote_fuzzy_query(query)?; let version = self.current_version().await; let mut base_body = serde_json::json!({ "version": version }); self.apply_branch_body(&mut base_body); @@ -1019,6 +1020,21 @@ impl RemoteTable { } } + fn reject_remote_fuzzy_query(&self, query: &AnyQuery) -> Result<()> { + let Some(fts_query) = query.base().full_text_search.as_ref() else { + return Ok(()); + }; + let fuzzy_columns = fuzzy_match_columns(&fts_query.query); + if !fuzzy_columns.columns.is_empty() || fuzzy_columns.has_unbound_column { + return Err(Error::InvalidInput { + message: "explicit fuzzy FTS queries are not supported for remote tables because the server protocol does not declare a tokenizer-snapshot-safe fuzzy-search capability; use fuzziness=0" + .to_string(), + }); + } + + Ok(()) + } + fn invalidate_schema_cache(&self) { self.schema_cache.invalidate(); } @@ -2318,27 +2334,6 @@ impl BaseTable for RemoteTable { } }; - if let Some(source) = index.custom_stop_words_source.as_ref() { - if !matches!(&index.index, Index::FTS(_)) { - return Err(Error::InvalidInput { - message: "`custom_stop_words_source` can only be used with an FTS index" - .to_string(), - }); - } - if params - .as_ref() - .and_then(|params| params.get("custom_stop_words")) - .is_some_and(|value| !value.is_null()) - { - return Err(Error::InvalidInput { - message: "`custom_stop_words` and `custom_stop_words_source` are mutually \ - exclusive; choose one" - .to_string(), - }); - } - body["custom_stop_words_source"] = to_json(source)?; - } - body[INDEX_TYPE_KEY] = index_type_str.into(); if let Some(params) = params { for (key, value) in params.as_object().expect("params should be a JSON object") { @@ -2977,20 +2972,19 @@ mod tests { use super::*; use crate::remote::client::{ClientConfig, RetryConfig}; - use crate::table::{AddDataMode, FieldMetadataUpdate}; + use crate::table::{AddDataMode, FieldMetadataUpdate, FtsToken}; use arrow::{array::AsArray, compute::concat_batches, datatypes::Int32Type}; - use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, record_batch}; + use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, StringArray, record_batch}; use arrow_schema::{DataType, Field, Schema}; use chrono::{DateTime, Utc}; use futures::{StreamExt, TryFutureExt, future::BoxFuture}; - use lance_index::scalar::inverted::query::MatchQuery; + use lance_index::scalar::inverted::query::{BooleanQuery, FtsQuery, MatchQuery, Occur}; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; use reqwest::Body; use rstest::rstest; use serde_json::json; - use crate::index::scalar::CustomStopWordsSource; use crate::index::vector::{ IvfFlatIndexBuilder, IvfHnswFlatIndexBuilder, IvfHnswSqIndexBuilder, IvfRqIndexBuilder, IvfSqIndexBuilder, @@ -3000,7 +2994,10 @@ mod tests { use crate::utils::background_cache::clock; use crate::{ DistanceType, Error, Table, - index::{Index, IndexStatistics, IndexType, vector::IvfPqIndexBuilder}, + index::{ + Index, IndexStatistics, IndexType, scalar::FtsStopWordsSource, + vector::IvfPqIndexBuilder, + }, query::{ AnalyzePlanDistributedMetrics, ColumnOrdering, ExecutableQuery, QueryBase, QueryExecutionOptions, @@ -4310,6 +4307,100 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_remote_explicit_fuzzy_is_fail_closed_without_network_preflight() { + let requests = Arc::new(AtomicUsize::new(0)); + let requests_ref = requests.clone(); + let table = Table::new_with_handler_version( + "my_table", + semver::Version::new(0, 3, 0), + move |_request| -> http::Response { + requests_ref.fetch_add(1, Ordering::SeqCst); + panic!("remote fuzzy guard must reject before any network request") + }, + ); + + let queries = [ + FullTextSearchQuery::new_fuzzy("cot".to_string(), Some(1)) + .with_column("text".to_string()) + .unwrap(), + FullTextSearchQuery::new_query(FtsQuery::Boolean(BooleanQuery::new([ + ( + Occur::Must, + FtsQuery::Match( + MatchQuery::new("cot".to_string()).with_column(Some("other".to_string())), + ), + ), + ( + Occur::MustNot, + FtsQuery::Match( + MatchQuery::new("cot".to_string()) + .with_fuzziness(Some(1)) + .with_column(None), + ), + ), + ]))), + ]; + + for query in queries { + let error = table + .query() + .full_text_search(query) + .execute() + .await + .err() + .unwrap(); + assert!( + error + .to_string() + .contains("tokenizer-snapshot-safe fuzzy-search capability") + ); + assert!(error.to_string().contains("fuzziness=0")); + } + assert_eq!(requests.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn test_remote_none_and_zero_fuzziness_do_not_list_indices() { + for fuzziness in [None, Some(0)] { + let expected_data = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from(vec!["cat"]))], + ) + .unwrap(); + let expected_data_ref = expected_data.clone(); + let table = Table::new_with_handler_version( + "my_table", + semver::Version::new(0, 3, 0), + move |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/query/"); + let response_body = write_ipc_file(&expected_data_ref); + http::Response::builder() + .status(200) + .header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE) + .body(response_body) + .unwrap() + }, + ); + + let batches = table + .query() + .full_text_search( + FullTextSearchQuery::new_fuzzy("cot".to_string(), fuzziness) + .with_column("text".to_string()) + .unwrap(), + ) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches, vec![expected_data]); + } + } + #[rstest] #[case(DEFAULT_SERVER_VERSION.clone())] #[case(semver::Version::new(0, 2, 0))] @@ -4575,14 +4666,6 @@ mod tests { }, Index::FTS(InvertedIndexParams::default().block_size(256).unwrap()), ), - ( - "FTS", - serde_json::to_value( - InvertedIndexParams::default().custom_stop_words(Some(Vec::new())), - ) - .unwrap(), - Index::FTS(InvertedIndexParams::default().custom_stop_words(Some(Vec::new()))), - ), ]; for (index_type, expected_body, index) in cases { @@ -4623,111 +4706,16 @@ mod tests { } #[tokio::test] - async fn test_create_fts_index_with_custom_stop_words_source() { - let sources = [ - ( - CustomStopWordsSource::Inline { - words: vec!["lance".to_string()], - }, - json!({"type": "inline", "words": ["lance"]}), - ), - ( - CustomStopWordsSource::File { - uri: "s3://bucket/stop-words.txt".to_string(), - }, - json!({"type": "file", "uri": "s3://bucket/stop-words.txt"}), - ), - ( - CustomStopWordsSource::Table { - table: "stop_words".to_string(), - column: "word".to_string(), - }, - json!({"type": "table", "table": "stop_words", "column": "word"}), - ), - ]; + async fn test_create_remote_fts_index_sends_stop_word_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("stop-words.txt"); + std::fs::write(&path, b"cat\n\n dog \ncat\nCAT\n").unwrap(); - for (source, expected_source) in sources { - let table = - Table::new_with_handler("my_table", move |request| match request.url().path() { - "/v1/table/my_table/describe/" => { - let schema = Schema::new(vec![Field::new("text", DataType::Utf8, false)]); - http::Response::builder() - .status(200) - .body(describe_response(&schema)) - .unwrap() - } - "/v1/table/my_table/create_index/" => { - let body: serde_json::Value = - serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()) - .unwrap(); - assert_eq!(body["custom_stop_words_source"], expected_source); - assert!(body["custom_stop_words"].is_null()); - http::Response::builder() - .status(200) - .body("{}".to_string()) - .unwrap() - } - path => panic!("Unexpected path: {path}"), - }); - - table - .create_index(&["text"], Index::FTS(InvertedIndexParams::default())) - .custom_stop_words_source(source) - .execute() - .await - .unwrap(); - } - } - - #[tokio::test] - async fn test_create_fts_index_custom_stop_words_serialization() { - let cases = [ - (None, serde_json::Value::Null), - (Some(Vec::new()), json!([])), - ( - Some(vec!["lance".to_string(), "database".to_string()]), - json!(["lance", "database"]), - ), - ]; - - for (custom_stop_words, expected) in cases { - let table = - Table::new_with_handler("my_table", move |request| match request.url().path() { - "/v1/table/my_table/describe/" => { - let schema = Schema::new(vec![Field::new("text", DataType::Utf8, false)]); - http::Response::builder() - .status(200) - .body(describe_response(&schema)) - .unwrap() - } - "/v1/table/my_table/create_index/" => { - let body: serde_json::Value = - serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()) - .unwrap(); - assert_eq!(body["custom_stop_words"], expected); - http::Response::builder() - .status(200) - .body("{}".to_string()) - .unwrap() - } - path => panic!("Unexpected path: {path}"), - }); - - table - .create_index( - &["text"], - Index::FTS(InvertedIndexParams::default().custom_stop_words(custom_stop_words)), - ) - .execute() - .await - .unwrap(); - } - } - - #[tokio::test] - async fn test_create_fts_index_rejects_both_custom_stop_words_forms() { - let table = - Table::new_with_handler("my_table", move |request| match request.url().path() { + let create_requests = Arc::new(AtomicUsize::new(0)); + let create_requests_ref = create_requests.clone(); + let table = Table::new_with_handler("my_table", move |request| { + assert_eq!(request.method(), "POST"); + match request.url().path() { "/v1/table/my_table/describe/" => { let schema = Schema::new(vec![Field::new("text", DataType::Utf8, false)]); http::Response::builder() @@ -4735,22 +4723,52 @@ mod tests { .body(describe_response(&schema)) .unwrap() } - path => panic!("Unexpected path: {path}"), - }); + "/v1/table/my_table/create_index/" => { + let body = request.body().unwrap().as_bytes().unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + let mut expected = + serde_json::to_value(InvertedIndexParams::default()).unwrap(); + expected["column"] = "text".into(); + expected[INDEX_TYPE_KEY] = "FTS".into(); + expected["custom_stop_words"] = json!(["cat", " dog ", "CAT"]); + assert_eq!(body, expected); + create_requests_ref.fetch_add(1, Ordering::SeqCst); + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() + } + path => panic!("Unexpected path: {}", path), + } + }); - let error = table - .create_index( - &["text"], - Index::FTS( - InvertedIndexParams::default() - .custom_stop_words(Some(vec!["lance".to_string()])), - ), - ) - .custom_stop_words_source(CustomStopWordsSource::Inline { words: Vec::new() }) + table + .create_index(&["text"], Index::FTS(Default::default())) + .train(false) + .custom_stop_words(FtsStopWordsSource::file(&path)) + .unwrap() .execute() .await - .unwrap_err(); - assert!(error.to_string().contains("mutually exclusive")); + .unwrap(); + + // The legacy direct Rust builder entry point is canonicalized using + // the same exact-empty and exact-deduplication rules. + table + .create_index( + &["text"], + Index::FTS(InvertedIndexParams::default().custom_stop_words(Some(vec![ + "cat".to_string(), + String::new(), + " dog ".to_string(), + "cat".to_string(), + "CAT".to_string(), + ]))), + ) + .execute() + .await + .unwrap(); + + assert_eq!(create_requests.load(Ordering::SeqCst), 2); } #[tokio::test] @@ -5236,73 +5254,17 @@ mod tests { } #[tokio::test] - async fn test_tokenize_preserves_remote_custom_stop_words_tristate() { - let cases = [ - (serde_json::Value::Null, vec!["lance"]), - (serde_json::json!([]), vec!["the", "lance"]), - (serde_json::json!(["lance"]), vec!["the"]), - ]; - - for (custom_stop_words, expected) in cases { - let schema = Schema::new(vec![Field::new("text", DataType::Utf8, false)]); - let index_details = serde_json::json!({ - "lance_tokenizer": null, - "base_tokenizer": "simple", - "language": "English", - "with_position": false, - "max_token_length": 40, - "lower_case": true, - "stem": false, - "remove_stop_words": true, - "ascii_folding": true, - "custom_stop_words": custom_stop_words, - }) - .to_string(); - let table = Table::new_with_handler("my_table", move |request| { - assert_eq!(request.method(), "POST"); - match request.url().path() { - "/v1/table/my_table/describe/" => http::Response::builder() - .status(200) - .body(describe_response(&schema)) - .unwrap(), - "/v1/table/my_table/index/list/" => { - let body = serde_json::json!({ - "indexes": [ - { - "index_name": "text_idx", - "columns": ["text"], - "index_type": "FTS", - "index_details": index_details, - }, - ] - }); - http::Response::builder() - .status(200) - .body(serde_json::to_string(&body).unwrap()) - .unwrap() - } - path => panic!("Unexpected path: {}", path), - } - }); - - let tokens = table.tokenize("the lance", "text_idx").await.unwrap(); - assert_eq!( - tokens - .iter() - .map(|token| token.text.as_str()) - .collect::>(), - expected - ); - } - } - - #[tokio::test] - async fn test_tokenize_rejects_lossy_remote_index_details() { + async fn test_tokenize_uses_remote_index_details() { let schema = Schema::new(vec![Field::new("text", DataType::Utf8, false)]); let index_details = serde_json::json!({ - "base_tokenizer": "simple", + "base_tokenizer": "icu", "language": "English", - "remove_stop_words": true, + "with_position": false, + "max_token_length": 40, + "lower_case": true, + "stem": false, + "remove_stop_words": false, + "ascii_folding": true, }) .to_string(); let table = Table::new_with_handler("my_table", move |request| { @@ -5332,13 +5294,80 @@ mod tests { } }); - let err = table.tokenize("the lance", "text_idx").await.unwrap_err(); - assert!(matches!( - err, - Error::InvalidInput { message } - if message.contains("incomplete remote tokenizer details") - && message.contains("custom_stop_words") - )); + let tokens = table + .tokenize("Hello, こんにちは世界!", "text_idx") + .await + .unwrap(); + + assert_eq!( + tokens, + vec![ + FtsToken { + text: "hello".to_string(), + position: 0, + }, + FtsToken { + text: "こんにちは".to_string(), + position: 1, + }, + FtsToken { + text: "世界".to_string(), + position: 2, + }, + ] + ); + } + + #[tokio::test] + async fn test_tokenize_uses_remote_custom_stop_word_snapshot() { + let schema = Schema::new(vec![Field::new("text", DataType::Utf8, false)]); + let index_details = serde_json::json!({ + "base_tokenizer": "simple", + "language": "English", + "with_position": false, + "max_token_length": 40, + "lower_case": true, + "stem": false, + "remove_stop_words": true, + "ascii_folding": true, + "custom_stop_words": ["lance"], + }) + .to_string(); + let table = Table::new_with_handler("my_table", move |request| { + assert_eq!(request.method(), "POST"); + match request.url().path() { + "/v1/table/my_table/describe/" => http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap(), + "/v1/table/my_table/index/list/" => { + let body = serde_json::json!({ + "indexes": [ + { + "index_name": "text_idx", + "columns": ["text"], + "index_type": "FTS", + "index_details": index_details, + }, + ] + }); + http::Response::builder() + .status(200) + .body(serde_json::to_string(&body).unwrap()) + .unwrap() + } + path => panic!("Unexpected path: {}", path), + } + }); + + let tokens = table.tokenize("the lance data", "text_idx").await.unwrap(); + assert_eq!( + tokens + .into_iter() + .map(|token| token.text) + .collect::>(), + vec!["the", "data"] + ); } #[tokio::test] diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index 19734d9d7..26bb9fbd9 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -21,6 +21,7 @@ use lance::dataset::WriteMode; use lance::dataset::builder::DatasetBuilder; use lance::dataset::{InsertBuilder, WriteParams}; use lance::index::DatasetIndexExt; +use lance::index::scalar::load_segment_params; use lance::io::{ObjectStoreParams, WrappingObjectStore}; use lance_datafusion::utils::StreamingWriteSource; use lance_index::IndexCriteria; @@ -496,6 +497,7 @@ pub struct FtsToken { /// [`crate::index::scalar::FtsIndexBuilder`] to supply the same tokenizer /// options used when creating an FTS index. pub fn tokenize(query: &str, params: &InvertedIndexParams) -> Result> { + let params = crate::index::canonicalize_fts_params(params)?; let mut tokenizer = params.build().map_err(|err| Error::InvalidInput { message: format!("Failed to build tokenizer: {}", err), })?; @@ -1761,14 +1763,12 @@ impl Table { self.inner.list_indices().await } - /// Tokenize a full-text search query using the tokenizer configured on an FTS index. + /// Tokenize a full-text search query using the tokenizer snapshot persisted + /// in an FTS index's details, including any custom stop words. /// /// Model-backed tokenizers such as `jieba/*` and `lindera/*` are rebuilt in /// the client process from index metadata. For remote tables, this means the - /// same tokenizer model files must also exist locally. Remote index details - /// must contain the full persisted tokenizer parameters, including an - /// explicit `custom_stop_words` field. Tokenization fails closed when the - /// server only returns lossy index statistics. + /// same tokenizer model files must also exist locally. pub async fn tokenize(&self, query: &str, index_name: &str) -> Result> { let indices = self.inner.list_indices().await?; let matches = indices @@ -1793,18 +1793,16 @@ impl Table { message: format!("Index '{}' is not a full text search index", index_name), }); } - self.tokenize_with_index(query, index, index_name).await + self.tokenize_with_index(query, index, index_name) } - /// Tokenize a full-text search query using the tokenizer configured on the - /// FTS index for a column. + /// Tokenize a full-text search query using the tokenizer snapshot persisted + /// in the FTS index for a column, including any custom stop words. /// /// The column must have exactly one FTS index. Model-backed tokenizers such /// as `jieba/*` and `lindera/*` are rebuilt in the client process from /// index metadata. For remote tables, this means the same tokenizer model - /// files must also exist locally. Remote index details must contain the - /// full persisted tokenizer parameters, including an explicit - /// `custom_stop_words` field. + /// files must also exist locally. pub async fn tokenize_with_column(&self, query: &str, column: &str) -> Result> { let schema = self.inner.schema().await?; let (column, _) = resolve_arrow_field_path(schema.as_ref(), column)?; @@ -1836,80 +1834,30 @@ impl Table { self.tokenize(query, &index.name).await } - async fn tokenize_with_index( + fn tokenize_with_index( &self, query: &str, index: &IndexConfig, index_name: &str, ) -> Result> { let selector_description = format!("index name '{}'", index_name); - let params = if let Some(native) = self.inner.as_native() { - let dataset = native.dataset.get().await?; - let segments = dataset.load_indices_by_name(index_name).await?; - let mut params = None; - for segment in segments.iter() { - let segment_params = - lance::index::scalar::load_segment_params(&dataset, segment).await?; - match ¶ms { - Some(expected) if expected != &segment_params => { - return Err(Error::InvalidInput { - message: format!( - "Full text search index '{}' for {} has inconsistent tokenizer \ - parameters across segments", - index.name, selector_description - ), - }); - } - Some(_) => {} - None => params = Some(segment_params), - } - } - params.ok_or_else(|| Error::InvalidInput { + let details = index + .index_details + .as_deref() + .ok_or_else(|| Error::InvalidInput { message: format!( - "Full text search index '{}' for {} does not have any committed segments", + "Full text search index '{}' for {} does not include tokenizer details", index.name, selector_description ), - })? - } else { - let details = index - .index_details - .as_deref() - .ok_or_else(|| Error::InvalidInput { - message: format!( - "Full text search index '{}' for {} does not include tokenizer details", - index.name, selector_description - ), - })?; - let details = serde_json::from_str::(details).map_err(|err| { - Error::InvalidInput { - message: format!( - "Failed to parse tokenizer details for full text search index '{}' for {}: {}", - index.name, selector_description, err - ), - } })?; - if !details.as_object().is_some_and(|details| { - details.contains_key("lance_tokenizer") && details.contains_key("custom_stop_words") - }) { - return Err(Error::InvalidInput { - message: format!( - "Full text search index '{}' for {} has incomplete remote tokenizer \ - details: the server must return the full persisted parameters \ - (including `lance_tokenizer` and `custom_stop_words`); tokenization \ - cannot safely fall back to language defaults", - index.name, selector_description - ), - }); + let params = serde_json::from_str::(details).map_err(|err| { + Error::InvalidInput { + message: format!( + "Failed to parse tokenizer details for full text search index '{}' for {}: {}", + index.name, selector_description, err + ), } - serde_json::from_value::(details).map_err(|err| { - Error::InvalidInput { - message: format!( - "Failed to parse tokenizer details for full text search index '{}' for {}: {}", - index.name, selector_description, err - ), - } - })? - }; + })?; tokenize(query, ¶ms).map_err(|err| match err { Error::InvalidInput { message } => Error::InvalidInput { message: format!( @@ -3063,14 +3011,6 @@ impl BaseTable for NativeTable { } async fn create_index(&self, opts: IndexBuilder) -> Result<()> { - if opts.custom_stop_words_source.is_some() { - return Err(Error::NotSupported { - message: "`custom_stop_words_source` is only supported for remote tables; \ - resolve file or table sources to `custom_stop_words` before creating \ - a local native FTS index" - .to_string(), - }); - } if opts.columns.len() != 1 { return Err(Error::Schema { message: "Multi-column (composite) indices are not yet supported".to_string(), @@ -3256,67 +3196,116 @@ impl BaseTable for NativeTable { async fn list_indices(&self) -> Result> { let dataset = self.dataset.get().await?; let total_rows = dataset.count_rows(None).await? as u64; - let indices = dataset - .describe_indices(None) - .await? - .into_iter() - .filter_map(|idx_desc| { - let index_type: crate::index::IndexType = idx_desc - .index_type() - .parse() - .unwrap_or(crate::index::IndexType::Unknown); - if index_type == crate::index::IndexType::Unknown { - // Internal or future index types that this version doesn't recognize - // (e.g. Lance's internal FragReuseIndex) are silently excluded from - // the user-visible index listing. - log::debug!( - "Skipping unrecognized index '{}' (type '{}') in list_indices", - idx_desc.name(), - idx_desc.index_type(), - ); - return None; - } + let descriptions = dataset.describe_indices(None).await?; + let mut indices = Vec::with_capacity(descriptions.len()); + 'descriptions: for idx_desc in descriptions { + let index_type: crate::index::IndexType = idx_desc + .index_type() + .parse() + .unwrap_or(crate::index::IndexType::Unknown); + if index_type == crate::index::IndexType::Unknown { + // Internal or future index types that this version doesn't recognize + // (e.g. Lance's internal FragReuseIndex) are silently excluded from + // the user-visible index listing. + log::debug!( + "Skipping unrecognized index '{}' (type '{}') in list_indices", + idx_desc.name(), + idx_desc.index_type(), + ); + continue; + } - let field_ids = idx_desc.field_ids(); - let mut columns = Vec::with_capacity(field_ids.len()); - for field_id in field_ids { - let field_path = match dataset.schema().field_path(*field_id as i32) { - Ok(field_path) => field_path, - Err(e) => { - log::warn!( - "Failed to resolve field path for index {} field id {}: {}", + let field_ids = idx_desc.field_ids(); + let mut columns = Vec::with_capacity(field_ids.len()); + for field_id in field_ids { + let field_path = match dataset.schema().field_path(*field_id as i32) { + Ok(field_path) => field_path, + Err(e) => { + log::warn!( + "Failed to resolve field path for index {} field id {}: {}", + idx_desc.name(), + field_id, + e + ); + continue 'descriptions; + } + }; + columns.push(field_path); + } + + let segments = idx_desc.segments(); + let index_uuid = segments.first().map(|seg| seg.uuid.to_string()); + let created_at = segments.iter().filter_map(|seg| seg.created_at).min(); + let index_version = segments.first().map(|seg| seg.index_version); + let num_indexed_rows = idx_desc.rows_indexed(); + let index_details = if index_type == crate::index::IndexType::FTS + && !segments.is_empty() + { + let first_segment = &segments[0]; + let params = load_segment_params(&dataset, first_segment) + .await + .map_err(|source| Error::Other { + message: format!( + "Failed to load full text search configuration for index '{}' segment '{}': {}", idx_desc.name(), - field_id, - e - ); - return None; - } - }; - columns.push(field_path); + first_segment.uuid, + source + ), + source: Some(Box::new(source)), + })?; + for segment in &segments[1..] { + let segment_params = load_segment_params(&dataset, segment) + .await + .map_err(|source| Error::Other { + message: format!( + "Failed to load full text search configuration for index '{}' segment '{}': {}", + idx_desc.name(), + segment.uuid, + source + ), + source: Some(Box::new(source)), + })?; + if params != segment_params { + return Err(Error::InvalidInput { + message: format!( + "Full text search index '{}' has inconsistent configurations across segments '{}' and '{}'", + idx_desc.name(), + first_segment.uuid, + segment.uuid + ), + }); + } } + Some( + serde_json::to_string(¶ms).map_err(|source| Error::Other { + message: format!( + "Failed to serialize full text search configuration for index '{}'", + idx_desc.name() + ), + source: Some(Box::new(source)), + })?, + ) + } else { + // Preserve the manifest-level details behavior for non-FTS + // indices and for any legacy FTS metadata without segments. + idx_desc.details().ok() + }; - let segments = idx_desc.segments(); - let index_uuid = segments.first().map(|seg| seg.uuid.to_string()); - let created_at = segments.iter().filter_map(|seg| seg.created_at).min(); - let index_version = segments.first().map(|seg| seg.index_version); - let num_indexed_rows = idx_desc.rows_indexed(); - - Some(IndexConfig { - name: idx_desc.name().to_string(), - index_type, - columns, - index_uuid, - type_url: Some(idx_desc.type_url().to_string()), - created_at, - num_indexed_rows: Some(num_indexed_rows), - num_unindexed_rows: Some(total_rows.saturating_sub(num_indexed_rows)), - size_bytes: idx_desc.total_size_bytes(), - num_segments: Some(segments.len() as u32), - index_version, - index_details: idx_desc.details().ok(), - }) - }) - .collect(); + indices.push(IndexConfig { + name: idx_desc.name().to_string(), + index_type, + columns, + index_uuid, + type_url: Some(idx_desc.type_url().to_string()), + created_at, + num_indexed_rows: Some(num_indexed_rows), + num_unindexed_rows: Some(total_rows.saturating_sub(num_indexed_rows)), + size_bytes: idx_desc.total_size_bytes(), + num_segments: Some(segments.len() as u32), + index_version, + index_details, + }); + } Ok(indices) } @@ -3566,6 +3555,7 @@ mod tests { use lance::Dataset; use lance::io::{ObjectStoreParams, WrappingObjectStore}; use lance_core::datatypes::LANCE_UNENFORCED_PRIMARY_KEY_POSITION; + use lance_index::scalar::FullTextSearchQuery; use tempfile::tempdir; use super::*; @@ -3596,6 +3586,160 @@ mod tests { ); } + #[tokio::test] + async fn test_fts_stop_words_survive_table_reopen() { + async fn count_fts(table: &Table, query: &str) -> usize { + table + .query() + .full_text_search(FullTextSearchQuery::new(query.to_string())) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(RecordBatch::num_rows) + .sum() + } + + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + let cases = [ + ( + "builtin_stop_words", + None, + vec!["lance".to_string(), "data".to_string()], + 0, + 3, + true, + ), + ( + "empty_custom_stop_words", + Some(Vec::new()), + vec!["the".to_string(), "lance".to_string(), "data".to_string()], + 2, + 3, + true, + ), + ( + "custom_stop_words", + Some(vec!["lance".to_string()]), + vec!["the".to_string(), "data".to_string()], + 2, + 0, + true, + ), + ( + "untrained_custom_stop_words", + Some(vec!["lance".to_string()]), + vec!["the".to_string(), "data".to_string()], + 2, + 0, + false, + ), + ]; + + for ( + table_name, + custom_stop_words, + expected_tokens, + expected_the_matches, + expected_lance_matches, + train, + ) in cases + { + let schema = Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])); + let batch = RecordBatch::try_new( + schema, + vec![Arc::new(StringArray::from(vec![ + "the lance data", + "lance database", + ]))], + ) + .unwrap(); + let conn = connect(uri).execute().await.unwrap(); + let table = conn + .create_table(table_name, batch) + .execute() + .await + .unwrap(); + let params = crate::index::scalar::FtsIndexBuilder::default() + .stem(false) + .custom_stop_words(custom_stop_words.clone()); + table + .create_index(&["text"], Index::FTS(params)) + .train(train) + .execute() + .await + .unwrap(); + + let index = table.list_indices().await.unwrap().remove(0); + if !train { + assert!( + index.num_segments.is_some_and(|segments| segments > 0), + "untrained FTS indexes must create a physical segment to persist custom stop words" + ); + } + let details: serde_json::Value = + serde_json::from_str(index.index_details.as_deref().unwrap()).unwrap(); + assert_eq!( + details["custom_stop_words"], + serde_json::to_value(&custom_stop_words).unwrap() + ); + + drop(table); + drop(conn); + + let reopened_conn = connect(uri).execute().await.unwrap(); + let reopened = reopened_conn + .open_table(table_name) + .execute() + .await + .unwrap(); + let reopened_index = reopened.list_indices().await.unwrap().remove(0); + let reopened_details: serde_json::Value = + serde_json::from_str(reopened_index.index_details.as_deref().unwrap()).unwrap(); + assert_eq!( + reopened_details["custom_stop_words"], + serde_json::to_value(&custom_stop_words).unwrap() + ); + + let additional_batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from(vec!["the lance snapshot"]))], + ) + .unwrap(); + reopened.add(additional_batch).execute().await.unwrap(); + reopened + .optimize(OptimizeAction::Index(Default::default())) + .await + .unwrap(); + + let optimized_index = reopened.list_indices().await.unwrap().remove(0); + let optimized_details: serde_json::Value = + serde_json::from_str(optimized_index.index_details.as_deref().unwrap()).unwrap(); + assert_eq!( + optimized_details["custom_stop_words"], + serde_json::to_value(&custom_stop_words).unwrap() + ); + + let tokens = reopened + .tokenize("the lance data", "text_idx") + .await + .unwrap(); + assert_eq!( + tokens + .into_iter() + .map(|token| token.text) + .collect::>(), + expected_tokens + ); + assert_eq!(count_fts(&reopened, "the").await, expected_the_matches); + assert_eq!(count_fts(&reopened, "lance").await, expected_lance_matches); + } + } + #[tokio::test] async fn test_open() { let tmp_dir = tempdir().unwrap(); diff --git a/rust/lancedb/src/table/create_index.rs b/rust/lancedb/src/table/create_index.rs index 08e5eff63..879579ed3 100644 --- a/rust/lancedb/src/table/create_index.rs +++ b/rust/lancedb/src/table/create_index.rs @@ -383,8 +383,7 @@ mod tests { use crate::connection::ConnectBuilder; use crate::index::Index; use crate::index::scalar::{ - BTreeIndexBuilder, BitmapIndexBuilder, CustomStopWordsSource, FmIndexBuilder, - FtsIndexBuilder, + BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder, FtsIndexBuilder, FtsStopWordsSource, }; use crate::index::vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, @@ -1395,179 +1394,201 @@ mod tests { } #[tokio::test] - async fn test_native_fts_rejects_custom_stop_words_source() { - let conn = connect("memory://").execute().await.unwrap(); - let batch = RecordBatch::try_new( + async fn test_create_fts_index_from_table_stop_words_snapshot() { + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + let conn = connect(uri).execute().await.unwrap(); + + let stop_words = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("word", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from(vec!["cat", "", "cat"]))], + ) + .unwrap(); + let stop_words_table = conn + .create_table("stop_words", stop_words) + .execute() + .await + .unwrap(); + + let documents = RecordBatch::try_new( Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])), - vec![Arc::new(StringArray::from(vec!["lance database"])) as ArrayRef], + vec![Arc::new(StringArray::from(vec![ + "the cat dog", + "the fish", + "dog only", + ]))], ) .unwrap(); let table = conn - .create_table("test_custom_stop_words_source", batch) + .create_table("documents", documents) + .execute() + .await + .unwrap(); + table + .create_index( + &["text"], + Index::FTS(FtsIndexBuilder::default().stem(false)), + ) + .custom_stop_words(FtsStopWordsSource::table(stop_words_table, "word")) + .unwrap() + .execute() + .await + .unwrap(); + + // The source table is not needed after index creation. Both local and + // remote create paths receive a concrete snapshot. + conn.drop_table("stop_words", &[]).await.unwrap(); + drop(table); + drop(conn); + + let conn = connect(uri).execute().await.unwrap(); + let table = conn.open_table("documents").execute().await.unwrap(); + async fn count_fts(table: &crate::Table, query: &str) -> usize { + table + .query() + .full_text_search(FullTextSearchQuery::new(query.to_string())) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(RecordBatch::num_rows) + .sum() + } + + assert_eq!(count_fts(&table, "cat").await, 0); + assert_eq!(count_fts(&table, "the").await, 2); + assert_eq!(count_fts(&table, "dog").await, 2); + + let error = table + .query() + .full_text_search(FullTextSearchQuery::new_fuzzy("cot".to_string(), Some(1))) + .execute() + .await + .err() + .unwrap(); + assert!( + error + .to_string() + .contains("fuzzy FTS queries are not supported") + ); + assert!(error.to_string().contains("custom stop-word snapshot")); + } + + #[tokio::test] + async fn test_create_fts_index_from_file_stop_words_snapshot() { + let tmp_dir = tempdir().unwrap(); + let stop_words_path = tmp_dir.path().join("stop-words.txt"); + std::fs::write(&stop_words_path, "cat\n").unwrap(); + let conn = connect("memory://").execute().await.unwrap(); + let documents = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from(vec![ + "the cat dog", + "the fish", + "dog only", + ]))], + ) + .unwrap(); + let table = conn + .create_table("file_stop_words_documents", documents) + .execute() + .await + .unwrap(); + table + .create_index( + &["text"], + Index::FTS(FtsIndexBuilder::default().stem(false)), + ) + .custom_stop_words(FtsStopWordsSource::file(&stop_words_path)) + .unwrap() + .execute() + .await + .unwrap(); + + // The file is only a client-side source. Query tokenization continues + // to use the persisted snapshot after it changes or disappears. + std::fs::write(&stop_words_path, "dog\n").unwrap(); + std::fs::remove_file(&stop_words_path).unwrap(); + async fn count_fts(table: &crate::Table, query: &str) -> usize { + table + .query() + .full_text_search(FullTextSearchQuery::new(query.to_string())) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(RecordBatch::num_rows) + .sum() + } + + assert_eq!(count_fts(&table, "cat").await, 0); + assert_eq!(count_fts(&table, "the").await, 2); + assert_eq!(count_fts(&table, "dog").await, 2); + } + + #[tokio::test] + async fn test_custom_stop_words_builder_validation() { + let conn = connect("memory://").execute().await.unwrap(); + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])), + vec![Arc::new(StringArray::from(vec!["hello world"]))], + ) + .unwrap(); + let table = conn + .create_table("documents", batch) .execute() .await .unwrap(); let error = table - .create_index(&["text"], Index::FTS(FtsIndexBuilder::default())) - .custom_stop_words_source(CustomStopWordsSource::File { - uri: "file:///tmp/stop-words.txt".to_string(), - }) + .create_index(&["text"], Index::BTree(Default::default())) + .custom_stop_words(Vec::::new()) + .err() + .unwrap(); + assert!( + error + .to_string() + .contains("only be configured for an FTS index") + ); + + let error = table + .create_index( + &["text"], + Index::FTS(FtsIndexBuilder::default().custom_stop_words(Some(Vec::new()))), + ) + .custom_stop_words(Vec::::new()) + .err() + .unwrap(); + assert!(error.to_string().contains("already configured directly")); + + let builder = table + .create_index(&["text"], Index::FTS(Default::default())) + .custom_stop_words(Vec::::new()) + .unwrap(); + let error = builder + .custom_stop_words(vec!["other".to_string()]) + .err() + .unwrap(); + assert!(error.to_string().contains("set more than once")); + + let error = table + .create_index(&["text"], Index::FTS(Default::default())) + .train(false) + .custom_stop_words(FtsStopWordsSource::file("/does/not/exist")) + .unwrap() .execute() .await .unwrap_err(); assert!( error .to_string() - .contains("only supported for remote tables") - ); - assert!(error.to_string().contains("custom_stop_words")); - } - - #[tokio::test] - async fn test_fts_custom_stop_words_survive_table_reopen() { - let tempdir = tempdir().unwrap(); - let uri = tempdir.path().to_str().unwrap(); - let conn = connect(uri).execute().await.unwrap(); - - for (name, custom_stop_words) in [ - ("default_stop_words", None), - ("empty_custom_stop_words", Some(Vec::::new())), - ( - "non_empty_custom_stop_words", - Some(vec!["lance".to_string()]), - ), - ] { - let batch = RecordBatch::try_new( - Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])), - vec![Arc::new(StringArray::from(vec!["the lance database"])) as ArrayRef], - ) - .unwrap(); - let table = conn.create_table(name, batch).execute().await.unwrap(); - table - .create_index( - &["text"], - Index::FTS( - FtsIndexBuilder::default() - .stem(false) - .custom_stop_words(custom_stop_words), - ), - ) - .execute() - .await - .unwrap(); - } - drop(conn); - - let reopened = connect(uri).execute().await.unwrap(); - let default = reopened - .open_table("default_stop_words") - .execute() - .await - .unwrap(); - let default_tokens = default - .tokenize_with_column("the lance", "text") - .await - .unwrap(); - assert_eq!( - default_tokens - .iter() - .map(|token| token.text.as_str()) - .collect::>(), - vec!["lance"] - ); - let default_results = default - .query() - .full_text_search(FullTextSearchQuery::new("the".to_string())) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - assert_eq!( - default_results - .iter() - .map(RecordBatch::num_rows) - .sum::(), - 0 - ); - - let empty = reopened - .open_table("empty_custom_stop_words") - .execute() - .await - .unwrap(); - let empty_tokens = empty - .tokenize_with_column("the lance", "text") - .await - .unwrap(); - assert_eq!( - empty_tokens - .iter() - .map(|token| token.text.as_str()) - .collect::>(), - vec!["the", "lance"] - ); - let empty_results = empty - .query() - .full_text_search(FullTextSearchQuery::new("the".to_string())) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - assert_eq!( - empty_results - .iter() - .map(RecordBatch::num_rows) - .sum::(), - 1 - ); - - let custom = reopened - .open_table("non_empty_custom_stop_words") - .execute() - .await - .unwrap(); - let custom_tokens = custom - .tokenize_with_column("the lance", "text") - .await - .unwrap(); - assert_eq!( - custom_tokens - .iter() - .map(|token| token.text.as_str()) - .collect::>(), - vec!["the"] - ); - - let results = custom - .query() - .full_text_search(FullTextSearchQuery::new("the".to_string())) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - assert_eq!(results.iter().map(RecordBatch::num_rows).sum::(), 1); - let removed_results = custom - .query() - .full_text_search(FullTextSearchQuery::new("lance".to_string())) - .execute() - .await - .unwrap() - .try_collect::>() - .await - .unwrap(); - assert_eq!( - removed_results - .iter() - .map(RecordBatch::num_rows) - .sum::(), - 0 + .contains("failed to read custom stop words file") ); } diff --git a/rust/lancedb/src/table/merge/lsm.rs b/rust/lancedb/src/table/merge/lsm.rs index 0eb7c0231..81a65d670 100644 --- a/rust/lancedb/src/table/merge/lsm.rs +++ b/rust/lancedb/src/table/merge/lsm.rs @@ -81,6 +81,13 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec) } let mut dataset = (*table.dataset.get().await?).clone(); + reject_custom_stop_words_fts( + &dataset, + spec.maintained_indexes(), + "set_lsm_write_spec", + None, + ) + .await?; let mut builder = dataset.initialize_mem_wal(); let (maintained_indexes, writer_config_defaults) = match spec { LsmWriteSpec::Bucket { @@ -117,6 +124,69 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec) Ok(()) } +/// Reject maintained FTS indexes whose full segment params enable custom stop +/// words. +/// +/// Lance's MemWAL currently reconstructs an in-memory FTS index from +/// `InvertedIndexDetails`, which does not carry `custom_stop_words`. Allowing +/// such an index through would silently tokenize MemWAL rows differently from +/// the persisted base index. Read each physical segment's full params instead +/// of relying on the lossy manifest details. Installation, write dispatch, and +/// LSM FTS read planning all call this before changing or reading MemWAL state. +/// When `column` is supplied, only an FTS index covering that query column is +/// checked. A configured custom list is safe while `remove_stop_words` is +/// disabled because Lance does not apply it. +pub(in crate::table) async fn reject_custom_stop_words_fts( + dataset: &Dataset, + maintained_indexes: &[String], + operation: &str, + column: Option<&str>, +) -> Result<()> { + let field_id = column.and_then(|column| { + dataset + .schema() + .resolve_case_insensitive(column) + .and_then(|path| path.last().map(|field| field.id)) + }); + if column.is_some() && field_id.is_none() { + // Let the caller's ordinary missing-column validation produce its + // established diagnostic. + return Ok(()); + } + let indices = dataset.load_indices().await?; + for segment in indices.iter().filter(|segment| { + maintained_indexes.contains(&segment.name) + && field_id.is_none_or(|field_id| segment.fields.contains(&field_id)) + && segment + .index_details + .as_ref() + .is_some_and(|details| details.type_url.ends_with("InvertedIndexDetails")) + }) { + let params = lance::index::scalar::load_segment_params(dataset, segment) + .await + .map_err(|source| Error::InvalidInput { + message: format!( + "{operation}: failed to read full FTS parameters for maintained index '{}': {}", + segment.name, source, + ), + })?; + if crate::index::fts_params_use_custom_stop_words(¶ms)? { + return Err(Error::InvalidInput { + message: format!( + "{operation}: maintained FTS index '{}' uses an active custom_stop_words \ + snapshot (remove_stop_words=true; an explicitly empty custom list is \ + also unsupported); MemWAL cannot preserve this tokenizer setting. \ + Remove this index from \ + maintained_indexes and use ordinary append followed by optimize \ + instead of MemWAL-backed writes", + segment.name + ), + }); + } + } + Ok(()) +} + // ============================================================================= // unset_lsm_write_spec // ============================================================================= @@ -490,6 +560,19 @@ pub(crate) async fn lsm_dispatch_decision( return Ok(LsmDispatch::Standard); }; + // Revalidate on every dispatch, not only when the spec is installed. The + // maintained index can be replaced after installation, and an older + // database may already contain a MemWAL spec for an incompatible FTS + // index. Fail before opening a shard writer so neither case can silently + // index new rows with a different tokenizer. + reject_custom_stop_words_fts( + dataset.as_ref(), + &details.maintained_indexes, + "merge_insert", + None, + ) + .await?; + let pk_cols: Vec = dataset .schema() .unenforced_primary_key() @@ -924,9 +1007,19 @@ fn lsm_merge_result(num_rows: u64) -> MergeResult { #[cfg(test)] mod tests { use super::*; - use arrow_array::{ArrayRef, BooleanArray, Int32Array, Int64Array, StringArray, UInt64Array}; - use arrow_schema::Field; + use arrow_array::{ + ArrayRef, BooleanArray, Int32Array, Int64Array, RecordBatchIterator, StringArray, + StructArray, UInt64Array, + }; + use arrow_schema::{Field, Schema}; use lance_index::mem_wal::ShardingField; + use lance_index::scalar::InvertedIndexParams; + use tempfile::tempdir; + + use crate::connect; + use crate::index::Index; + use crate::query::{ExecutableQuery, QueryBase}; + use lance_index::scalar::FullTextSearchQuery; fn lance_schema(batch: &RecordBatch) -> LanceSchema { LanceSchema::try_from(batch.schema().as_ref()).unwrap() @@ -968,6 +1061,289 @@ mod tests { } } + #[tokio::test] + async fn set_lsm_write_spec_guards_custom_stop_words_fts() { + for (case, remove_stop_words, custom_stop_words, should_reject) in [ + ( + "enabled_non_empty", + true, + Some(vec!["lancedb".to_string()]), + true, + ), + ("enabled_empty", true, Some(Vec::new()), true), + ("enabled_none", true, None, false), + ( + "disabled_non_empty", + false, + Some(vec!["lancedb".to_string()]), + false, + ), + ("disabled_empty", false, Some(Vec::new()), false), + ("disabled_none", false, None, false), + ] { + let dir = tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(StringArray::from(vec!["lancedb database"]))], + ) + .unwrap(); + let table = conn + .create_table(format!("custom_stop_words_{case}"), batch) + .execute() + .await + .unwrap(); + let index_name = format!("fts_{case}"); + let params = InvertedIndexParams::default() + .remove_stop_words(remove_stop_words) + .custom_stop_words(custom_stop_words); + table + .create_index(&["text"], Index::FTS(params)) + .name(index_name.clone()) + .execute() + .await + .unwrap(); + + let result = table + .set_lsm_write_spec( + LsmWriteSpec::unsharded().with_maintained_indexes([index_name.as_str()]), + ) + .await; + let dataset = table.as_native().unwrap().dataset.get().await.unwrap(); + if should_reject { + let message = result + .expect_err("custom stop words must fail closed") + .to_string(); + assert!(message.contains("custom_stop_words"), "{message}"); + assert!(message.contains("maintained_indexes"), "{message}"); + assert!(message.contains("append"), "{message}"); + assert!(message.contains("optimize"), "{message}"); + assert!( + dataset.mem_wal_index_details().await.unwrap().is_none(), + "a rejected spec must not install the MemWAL system index" + ); + } else { + result + .expect("an FTS index without active custom stop words must remain supported"); + assert!( + dataset.mem_wal_index_details().await.unwrap().is_some(), + "the supported spec should install the MemWAL system index" + ); + } + } + } + + #[tokio::test] + async fn custom_stop_words_guard_resolves_nested_fts_column() { + let dir = tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let payload = Arc::new(StructArray::from(vec![( + Arc::new(Field::new("text", DataType::Utf8, false)), + Arc::new(StringArray::from(vec!["nested document"])) as ArrayRef, + )])); + let schema = Arc::new(Schema::new(vec![Field::new( + "payload", + payload.data_type().clone(), + false, + )])); + let batch = + RecordBatch::try_new(schema, vec![payload as ArrayRef]).expect("valid nested batch"); + let table = conn + .create_table("nested_custom_stop_words", batch) + .execute() + .await + .unwrap(); + let index_name = "payload_text_fts"; + table + .create_index( + &["payload.text"], + Index::FTS( + InvertedIndexParams::default() + .remove_stop_words(true) + .custom_stop_words(Some(vec!["nested".to_string()])), + ), + ) + .name(index_name.to_string()) + .execute() + .await + .unwrap(); + + let dataset = table.as_native().unwrap().dataset.get().await.unwrap(); + let message = reject_custom_stop_words_fts( + dataset.as_ref(), + &[index_name.to_string()], + "full_text_search", + Some("PAYLOAD.TEXT"), + ) + .await + .expect_err("nested field path must select and reject the custom FTS index") + .to_string(); + assert!(message.contains("custom_stop_words"), "{message}"); + } + + #[tokio::test] + async fn merge_insert_revalidates_replaced_custom_stop_words_fts_after_reopen() { + let dir = tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("text", DataType::Utf8, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1])), + Arc::new(StringArray::from(vec!["lancedb database"])), + ], + ) + .unwrap(); + let table = conn + .create_table("replace_custom_stop_words", batch) + .execute() + .await + .unwrap(); + table.set_unenforced_primary_key(["id"]).await.unwrap(); + + let index_name = "text_fts"; + table + .create_index( + &["text"], + Index::FTS( + InvertedIndexParams::default() + .remove_stop_words(true) + .custom_stop_words(None), + ), + ) + .name(index_name.to_string()) + .execute() + .await + .unwrap(); + table + .set_lsm_write_spec(LsmWriteSpec::unsharded().with_maintained_indexes([index_name])) + .await + .unwrap(); + + // Persist an un-compacted row using the original (compatible) FTS + // tokenizer, then close the writer so the table can be reopened. + let mem_wal_row = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![2])), + Arc::new(StringArray::from(vec!["memwal document"])), + ], + ) + .unwrap(); + let mem_wal_row: Box = Box::new(RecordBatchIterator::new( + vec![Ok(mem_wal_row)], + schema.clone(), + )); + let mut merge = table.merge_insert(&[]); + merge + .when_matched_update_all(None) + .when_not_matched_insert_all(); + merge.execute(mem_wal_row).await.unwrap(); + table.close_lsm_writers().await.unwrap(); + + // Replacing a maintained index after the spec is installed must not + // bypass the installation-time guard. + table + .create_index( + &["text"], + Index::FTS( + InvertedIndexParams::default() + .remove_stop_words(true) + .custom_stop_words(Some(vec!["lancedb".to_string()])), + ), + ) + .name(index_name.to_string()) + .replace(true) + .execute() + .await + .unwrap(); + drop(table); + + let table = conn + .open_table("replace_custom_stop_words") + .execute() + .await + .unwrap(); + assert!( + table + .as_native() + .unwrap() + .dataset + .get() + .await + .unwrap() + .mem_wal_index_details() + .await + .unwrap() + .is_some(), + "the persisted MemWAL spec should survive reopen" + ); + + let query = FullTextSearchQuery::new("database".to_string()) + .with_column("text".to_string()) + .unwrap(); + let result = table.query().full_text_search(query).execute().await; + let message = match result { + Ok(_) => panic!("read planning must revalidate a replaced maintained FTS index"), + Err(error) => error.to_string(), + }; + assert!(message.contains("full_text_search"), "{message}"); + assert!(message.contains("custom_stop_words"), "{message}"); + assert!(message.contains("maintained_indexes"), "{message}"); + assert!(message.contains("append"), "{message}"); + assert!(message.contains("optimize"), "{message}"); + + let upsert = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![3])), + Arc::new(StringArray::from(vec!["new document"])), + ], + ) + .unwrap(); + let upsert: Box = + Box::new(RecordBatchIterator::new(vec![Ok(upsert)], schema)); + let mut merge = table.merge_insert(&[]); + merge + .when_matched_update_all(None) + .when_not_matched_insert_all(); + let message = merge + .execute(upsert) + .await + .expect_err("dispatch must revalidate a replaced maintained FTS index") + .to_string(); + assert!(message.contains("merge_insert"), "{message}"); + assert!(message.contains("custom_stop_words"), "{message}"); + assert!(message.contains("maintained_indexes"), "{message}"); + assert!(message.contains("append"), "{message}"); + assert!(message.contains("optimize"), "{message}"); + assert!( + table + .as_native() + .unwrap() + .dataset + .shard_writer() + .read_snapshot() + .await + .unwrap() + .is_none(), + "revalidation must fail before opening a MemWAL shard writer" + ); + } + fn bucket_values(batch: &RecordBatch, num_buckets: u32) -> Vec { let LsmMode::Bucket { spec } = bucket_mode(0, num_buckets) else { unreachable!(); diff --git a/rust/lancedb/src/table/query.rs b/rust/lancedb/src/table/query.rs index 9feb9d5ab..09f79a8d1 100644 --- a/rust/lancedb/src/table/query.rs +++ b/rust/lancedb/src/table/query.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors +use std::collections::HashSet; use std::sync::Arc; mod lsm; @@ -9,6 +10,7 @@ use super::NativeTable; use crate::connection::NamespaceClientPushdownOperation; use crate::error::{Error, Result}; use crate::expr::expr_to_sql_string; +use crate::index::fts_params_use_custom_stop_words; use crate::query::{ DEFAULT_TOP_K, QueryExecutionOptions, QueryFilter, QueryRequest, Select, VectorQueryRequest, }; @@ -25,7 +27,10 @@ use futures::future::try_join_all; use lance::dataset::mem_wal::DatasetMemWalExt; use lance::dataset::scanner::DatasetRecordBatchStream; use lance::dataset::scanner::Scanner; +use lance::index::DatasetIndexExt; +use lance::index::scalar::load_segment_params; use lance_datafusion::exec::{analyze_plan as lance_analyze_plan, execute_plan}; +use lance_index::scalar::inverted::query::FtsQuery; use lance_namespace::LanceNamespace; use lance_namespace::models::{ QueryTableRequest as NsQueryTableRequest, QueryTableRequestColumns, @@ -47,6 +52,134 @@ impl AnyQuery { } } +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct FuzzyMatchColumns { + pub(crate) columns: HashSet, + pub(crate) has_unbound_column: bool, +} + +pub(crate) fn fuzzy_match_columns(query: &FtsQuery) -> FuzzyMatchColumns { + fn collect(query: &FtsQuery, columns: &mut FuzzyMatchColumns) { + let mut collect_match = |column: &Option, fuzziness: Option| { + if matches!(fuzziness, Some(value) if value > 0) { + match column { + Some(column) => { + columns.columns.insert(column.clone()); + } + None => columns.has_unbound_column = true, + } + } + }; + + match query { + FtsQuery::Match(query) => collect_match(&query.column, query.fuzziness), + FtsQuery::Phrase(_) => {} + FtsQuery::Boost(query) => { + collect(&query.positive, columns); + collect(&query.negative, columns); + } + FtsQuery::MultiMatch(query) => { + for query in &query.match_queries { + collect_match(&query.column, query.fuzziness); + } + } + FtsQuery::Boolean(query) => { + for query in query + .should + .iter() + .chain(&query.must) + .chain(&query.must_not) + { + collect(query, columns); + } + } + } + } + + let mut columns = FuzzyMatchColumns::default(); + collect(query, &mut columns); + columns +} + +fn fts_description_columns( + dataset: &lance::Dataset, + description: &dyn lance_index::IndexDescription, +) -> Result> { + description + .field_ids() + .iter() + .map(|field_id| { + dataset + .schema() + .field_path(*field_id as i32) + .map_err(Error::from) + }) + .collect() +} + +fn is_fts_description(description: &dyn lance_index::IndexDescription) -> bool { + description + .index_type() + .parse() + .unwrap_or(crate::index::IndexType::Unknown) + == crate::index::IndexType::FTS +} + +fn has_explicit_fuzzy_match(query: &FtsQuery) -> bool { + let columns = fuzzy_match_columns(query); + !columns.columns.is_empty() || columns.has_unbound_column +} + +async fn reject_custom_stop_words_fuzzy_query( + dataset: &lance::Dataset, + query: &AnyQuery, +) -> Result<()> { + let Some(fts_query) = query.base().full_text_search.as_ref() else { + return Ok(()); + }; + if !has_explicit_fuzzy_match(&fts_query.query) { + return Ok(()); + } + + let descriptions = dataset.describe_indices(None).await?; + let fts_descriptions = descriptions + .iter() + .filter(|description| is_fts_description(description.as_ref())) + .collect::>(); + let fuzzy_columns = fuzzy_match_columns(&fts_query.query); + + for description in fts_descriptions { + if !fuzzy_columns.has_unbound_column + && !fts_description_columns(dataset, description.as_ref())? + .iter() + .any(|column| fuzzy_columns.columns.contains(column)) + { + continue; + } + for segment in description.segments() { + let params = load_segment_params(dataset, segment) + .await + .map_err(|source| Error::InvalidInput { + message: format!( + "failed to validate fuzzy FTS query against index `{}` segment `{}`: {}", + description.name(), + segment.uuid, + source + ), + })?; + if fts_params_use_custom_stop_words(¶ms)? { + return Err(Error::InvalidInput { + message: format!( + "fuzzy FTS queries are not supported for index `{}` because it uses a custom stop-word snapshot; use fuzziness=0 or rebuild the index without custom stop words", + description.name() + ), + }); + } + } + } + Ok(()) +} + //Decide between namespace or local pub async fn execute_query( table: &NativeTable, @@ -86,10 +219,23 @@ async fn can_execute_namespace_query(table: &NativeTable, query: &AnyQuery) -> R } fn requires_local_namespace_execution(query: &AnyQuery) -> bool { - // The namespace QueryTable request has no approx_mode or use_lsm field yet, so - // pushing these down would silently ignore the user's setting. For use_lsm that - // is worse than a tuning miss: MemWAL read routing lives only in `create_plan`, - // so a pushed-down query would return stale base-only data with no error. + // Namespace QueryTable cannot atomically bind an explicit fuzzy query to the + // tokenizer snapshot validated by this client. Keep it local so + // `create_plan` validates and executes against the same Dataset handle. + if query + .base() + .full_text_search + .as_ref() + .is_some_and(|query| has_explicit_fuzzy_match(&query.query)) + { + return true; + } + + // The namespace QueryTable request has no approx_mode or use_lsm field yet, + // so pushing these down would silently ignore the user's setting. For + // use_lsm that is worse than a tuning miss: MemWAL read routing lives only + // in `create_plan`, so a pushed-down query would return stale base-only data + // with no error. if query.base().use_lsm.is_some() { return true; } @@ -112,7 +258,7 @@ pub async fn analyze_query_plan( } /// Local Execution Path (DataFusion) -async fn execute_generic_query( +pub(crate) async fn execute_generic_query( table: &NativeTable, query: &AnyQuery, options: QueryExecutionOptions, @@ -133,13 +279,15 @@ pub async fn create_plan( query: &AnyQuery, options: QueryExecutionOptions, ) -> Result> { + query.base().check_filter()?; + + let ds_ref = table.dataset.get().await?; + reject_custom_stop_words_fuzzy_query(&ds_ref, query).await?; + let query = match query { AnyQuery::VectorQuery(query) => query.clone(), AnyQuery::Query(query) => VectorQueryRequest::from_plain_query(query.clone()), }; - query.base.check_filter()?; - - let ds_ref = table.dataset.get().await?; // MemWAL read routing driven by `use_lsm`: // * unset — route through the LSM scanner iff the table carries a write spec @@ -699,6 +847,219 @@ mod tests { FixedSizeListArray::try_new_from_values(Float32Array::from(values), dimension).unwrap() } + #[test] + fn test_fuzzy_match_columns_only_collects_explicit_positive_fuzziness() { + use lance_index::scalar::inverted::query::{ + BooleanQuery, MatchQuery, MultiMatchQuery, Occur, + }; + + for fuzziness in [None, Some(0)] { + let query = FtsQuery::Match( + MatchQuery::new("term".to_string()) + .with_column(Some("ignored".to_string())) + .with_fuzziness(fuzziness), + ); + assert_eq!(fuzzy_match_columns(&query), FuzzyMatchColumns::default()); + } + + let query = FtsQuery::Match( + MatchQuery::new("term".to_string()) + .with_column(Some("text".to_string())) + .with_fuzziness(Some(1)), + ); + assert_eq!( + fuzzy_match_columns(&query).columns, + HashSet::from(["text".to_string()]) + ); + + let mut multi_match = + MultiMatchQuery::try_new("term".to_string(), vec!["title".into(), "body".into()]) + .unwrap(); + multi_match.match_queries[1].fuzziness = Some(1); + let boolean = FtsQuery::Boolean(BooleanQuery::new([ + (Occur::Must, FtsQuery::MultiMatch(multi_match)), + ( + Occur::MustNot, + FtsQuery::Match( + MatchQuery::new("term".to_string()) + .with_column(None) + .with_fuzziness(Some(1)), + ), + ), + ])); + let columns = fuzzy_match_columns(&boolean); + assert_eq!(columns.columns, HashSet::from(["body".to_string()])); + assert!(columns.has_unbound_column); + } + + #[test] + fn test_namespace_pushdown_keeps_explicit_fuzzy_queries_local() { + use lance_index::scalar::FullTextSearchQuery; + + let query = |fuzziness| { + AnyQuery::Query(QueryRequest { + full_text_search: Some( + FullTextSearchQuery::new_fuzzy("term".to_string(), fuzziness) + .with_column("text".to_string()) + .unwrap(), + ), + ..Default::default() + }) + }; + + assert!(!requires_local_namespace_execution(&query(None))); + assert!(!requires_local_namespace_execution(&query(Some(0)))); + assert!(requires_local_namespace_execution(&query(Some(1)))); + } + + #[tokio::test] + async fn test_custom_stop_words_fuzzy_guard_is_column_and_activation_scoped() { + use arrow_array::{RecordBatch, StringArray}; + use arrow_schema::{DataType, Field, Schema}; + use lance_index::scalar::inverted::query::{BooleanQuery, MatchQuery, Occur}; + use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; + + use crate::connect; + use crate::index::{Index, scalar::FtsStopWordsSource}; + use crate::query::{ExecutableQuery, QueryBase}; + + let conn = connect("memory://").execute().await.unwrap(); + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("custom_text", DataType::Utf8, false), + Field::new("plain_text", DataType::Utf8, false), + Field::new("inactive_text", DataType::Utf8, false), + ])), + vec![ + Arc::new(StringArray::from(vec!["cat cot"])), + Arc::new(StringArray::from(vec!["cat cot"])), + Arc::new(StringArray::from(vec!["cat cot"])), + ], + ) + .unwrap(); + let table = conn + .create_table("fuzzy_stop_words", batch) + .execute() + .await + .unwrap(); + table + .create_index( + &["custom_text"], + Index::FTS(InvertedIndexParams::default().stem(false)), + ) + .name("custom_text_fts".to_string()) + .custom_stop_words(FtsStopWordsSource::inline(vec!["cat".to_string()])) + .unwrap() + .execute() + .await + .unwrap(); + table + .create_index( + &["plain_text"], + Index::FTS(InvertedIndexParams::default().stem(false)), + ) + .name("plain_text_fts".to_string()) + .execute() + .await + .unwrap(); + table + .create_index( + &["inactive_text"], + Index::FTS( + InvertedIndexParams::default() + .stem(false) + .remove_stop_words(false), + ), + ) + .name("inactive_text_fts".to_string()) + .custom_stop_words(FtsStopWordsSource::inline(vec!["cat".to_string()])) + .unwrap() + .execute() + .await + .unwrap(); + + let execute = |column: &'static str, fuzziness| { + let table = table.clone(); + async move { + table + .query() + .full_text_search( + FullTextSearchQuery::new_fuzzy("cot".to_string(), fuzziness) + .with_column(column.to_string()) + .unwrap(), + ) + .execute() + .await + } + }; + + execute("custom_text", None).await.unwrap(); + execute("custom_text", Some(0)).await.unwrap(); + execute("plain_text", Some(1)).await.unwrap(); + execute("inactive_text", Some(1)).await.unwrap(); + + let error = execute("custom_text", Some(1)).await.err().unwrap(); + assert!( + error + .to_string() + .contains("fuzzy FTS queries are not supported") + ); + assert!(error.to_string().contains("custom_text_fts")); + + let namespace_client = Arc::new(CountingNamespaceClient::default()); + let mut native_table = table.as_native().unwrap().clone(); + native_table.namespace_client = Some(namespace_client.clone()); + native_table + .pushdown_operations + .insert(NamespaceClientPushdownOperation::QueryTable); + let namespace_query = AnyQuery::Query(QueryRequest { + full_text_search: Some( + FullTextSearchQuery::new_fuzzy("cot".to_string(), Some(1)) + .with_column("custom_text".to_string()) + .unwrap(), + ), + ..Default::default() + }); + let error = execute_query( + &native_table, + &namespace_query, + QueryExecutionOptions::default(), + ) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("custom_text_fts")); + assert_eq!(namespace_client.query_table_calls.load(Ordering::SeqCst), 0); + + // An unbound fuzzy leaf remains fail-closed even when another leaf is + // bound (and therefore the compound query reports a non-empty column + // set). Its candidate set is every FTS-indexed column. + let mixed_query = FtsQuery::Boolean(BooleanQuery::new([ + ( + Occur::Must, + FtsQuery::Match( + MatchQuery::new("cot".to_string()).with_column(Some("plain_text".to_string())), + ), + ), + ( + Occur::MustNot, + FtsQuery::Match( + MatchQuery::new("cot".to_string()) + .with_fuzziness(Some(1)) + .with_column(None), + ), + ), + ])); + let error = table + .query() + .full_text_search(FullTextSearchQuery::new_query(mixed_query)) + .execute() + .await + .err() + .unwrap(); + assert!(error.to_string().contains("custom_text_fts")); + } + #[tokio::test] async fn test_parse_arrow_ipc_response_handles_file_and_stream() { use arrow_array::{Int32Array, RecordBatch}; diff --git a/rust/lancedb/src/table/query/lsm.rs b/rust/lancedb/src/table/query/lsm.rs index 074d13476..871440444 100644 --- a/rust/lancedb/src/table/query/lsm.rs +++ b/rust/lancedb/src/table/query/lsm.rs @@ -435,6 +435,19 @@ async fn fts_plan( .to_string(), })?; + // The MemWAL in-memory FTS config is reconstructed from lossy manifest + // details that omit custom stop words. Revalidate the maintained index's + // full physical segment params on every LSM FTS read: the index may have + // been replaced after the write spec was installed, or an older database + // may already contain an incompatible spec. + crate::table::merge::lsm::reject_custom_stop_words_fts( + dataset, + &details.maintained_indexes, + "full_text_search", + Some(column), + ) + .await?; + // Without a maintained in-memory FTS index for this column, the active memtable // arm produces an empty plan (`active_source_can_execute_fts` returns false), so // the search silently omits un-compacted documents. Reject rather than mislead.