This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new 7872ff4f23b [feature](lance) Support approx_mode in vector_search
(#69852)
7872ff4f23b is described below
commit 7872ff4f23bdaba75578fbb0e63e4d67db903ef9
Author: Gabriel <[email protected]>
AuthorDate: Mon Nov 2 09:55:30 2026 +0800
[feature](lance) Support approx_mode in vector_search (#69852)
### What problem does this PR solve?
`vector_search` cannot currently select Lance's approximation policy.
This PR adds `"approx_mode"="fast|normal|accurate"`, validates the value
before metadata access, transports it as an optional typed Thrift field,
and forwards it to `lance_scanner_set_approx_mode` on each BE scanner.
`EXPLAIN` shows an explicitly requested mode.
Omitting the property preserves the existing native default (`normal` in
the pinned Lance revision). The setting applies through the shared
single-vector and multi-vector scanner setup. Existing refinement, probe
counts, search width, concurrency, split coverage, and distributed
merging remain unchanged. The existing lance-c dependency already
provides this API, so no third-party update or local patch is needed.
Website documentation is provided in
[apache/doris-website](https://github.com/apache/doris-website/pull/4554),
updating the English and Chinese 4.x Lance Catalog pages with mode
semantics, selection guidance, ACORN activation and fallback rules, SQL
usage, and compatibility notes. The temporary guide in the Doris
repository has been removed. The local experiment remains recorded
below. In particular, `accurate` still selects approximate candidates
and does not guarantee exact Top-K or original-vector distances on a
quantized index. During a smooth upgrade, FE rejects explicit `fast` or
`accurate` at scan initialization if any backend in the query's
candidate pool is marked as an old smooth-upgrade source. Older BEs
would ignore the optional field and silently use NORMAL, so rejecting
prevents a global TopN from merging split candidates generated under
different policies. The error names the backend and requested mode and
advises completing the upgrade or using `normal`. Omission and explicit
`normal` remain compatible. This checks only the selected backend
policy, not unrelated backends, and does not detect arbitrary older
builds without the smooth-upgrade-source marker.
### Mode semantics and recommended selection
Use `normal` as the baseline; choose another mode only after measuring
representative queries against an exact reference. These are
recommendations based on the pinned implementation and the local
experiment, not fixed speed/recall guarantees.
| Search path | `fast` | `normal` | `accurate` |
|---|---|---|---|
| RabitQ raw-query distance estimation | Uses one-bit scores even for a
multi-bit index, skipping extension-bit computation and its lower-bound
gate. | Uses the standard 8-bit quantized distance lookup table and
extension-bit computation when available. | Uses a higher-precision
16-bit distance lookup table and extension-bit computation when
available. |
| HNSW with prefiltering | Enables ACORN traversal when the runtime
conditions below permit it. | Uses regular filtered graph traversal. |
Uses the same traversal policy as `normal`; quantized distance
computation can still differ. |
| IVF_FLAT or exhaustive Flat Search | No mode-specific quantization
change. | Same scoring policy. | Same scoring policy. |
The 8-bit and 16-bit values describe the **query-time distance lookup
table**, not the number of bits stored per dimension in the index. For
example, on a 5-bit RabitQ index, `fast` uses one-bit scores on the
raw-query path; the other modes can use the additional four bits. Other
RabitQ paths, including residual-query scoring, can behave differently.
A mode description does not expand the supported vector-type or metric
combinations.
`accurate` is still approximate search. It does not guarantee exact
Top-K, exhaustive candidate evaluation, or original-vector `_distance`
on a quantized index. `refine_factor` controls reranking with original
vectors; increasing it can improve scores and recall but cannot recover
every candidate missed earlier. Multi-vector queries retain their
mandatory original-vector refinement.
| Workload | Starting choice | What to verify |
|---|---|---|
| General workloads or an unknown bottleneck | `normal` | Establish
recall and latency before changing the policy. |
| HNSW_FLAT with an effective prefilter, where latency matters | Compare
`fast` with `normal` | ACORN can reduce wasted distance calculations.
Check recall, result count, median latency, and p95; a speedup is not
guaranteed. |
| Multi-bit RabitQ where recall matters | `normal` | `fast` can discard
useful encoding information and substantially reduce recall. Use it only
if representative queries still meet the quality target. |
| RabitQ without original-vector reranking, where approximate score
error matters | Compare `accurate` with `normal` | Measure score error
and ranking quality against original-vector distances, as well as extra
latency. Better estimation does not guarantee higher recall. |
| RabitQ already using original-vector reranking | `normal` | The final
score benefit of `accurate` may be smaller; candidate selection can
still differ, so validate recall separately. |
| IVF_FLAT or HNSW_FLAT without an effective prefilter | Keep `normal` |
The current implementation offers no corresponding quantization or ACORN
benefit from changing the mode. IVF_FLAT recall still depends on the
probed partitions. |
For a compatible **HNSW index backed by RabitQ**, `fast` can both enable
ACORN traversal and simplify RabitQ distance estimation on the
applicable raw-query path. These effects are coupled by one option. Do
not infer its recall from an HNSW_FLAT test; validate the combined index
independently. `normal` and `accurate` have the same HNSW traversal
policy, but their RabitQ scoring can differ.
Recommended tuning order:
1. Pin the same dataset snapshot, query set, metric, filters, and index
coverage. Use `use_index=false` to obtain exact Top-K ground truth over
the same eligible rows; put eligibility conditions in the TVF `filter`
with `prefilter=true`.
2. Establish a `normal` baseline. Measure Recall@K, result count, score
error where relevant, and median/p95 latency.
3. If recall is insufficient, tune IVF `nprobes` and HNSW `ef`. If
original-vector scores or more reranked candidates are required,
configure `refine_factor`, accounting for the extra vector reads and
computation.
4. With the other settings fixed, compare `fast` for filtered HNSW or
`accurate` for RabitQ estimation precision. Select a mode only if it
meets the quality target and provides a worthwhile measured benefit.
Measure cold and warm cache behavior separately.
### ACORN activation and fallback
The selected compatible index must contain an HNSW graph. Request
`approx_mode="fast"` and a TVF `filter` with `prefilter="true"`; an
outer Doris `WHERE` does not supply this prefilter. `use_index=true`
alone does not select or force HNSW. The website documentation includes
a complete SQL example.
The current native implementation dispatches separately inside each HNSW
subindex (an IVF partition), using the number of nodes passing the
prefilter:
| Runtime condition | Path |
|---|---|
| No effective prefilter, or all nodes pass | Ordinary HNSW traversal. |
| Fewer than roughly 10% of nodes pass | Flat scoring over the remaining
nodes. |
| A partial filter remains and the sparse-filter rule does not apply |
`fast` uses ACORN; `normal` and `accurate` use regular filtered graph
traversal. |
| ACORN returns too few required candidates and the query has no
distance bounds | Retry with regular filtered graph traversal. |
The threshold applies within each subindex, not to the whole table.
There is no separate force-ACORN property in this interface. In Doris,
distance-range safety rules can also select Flat Search before native
graph dispatch.
`EXPLAIN` showing `lanceApproxMode=FAST` confirms the requested policy,
not that every subindex actually ran ACORN. HNSW_RabitQ behavior is
inferred from the coupled traversal/scoring implementation; the local
HNSW measurements below used HNSW_FLAT and do not validate the combined
index.
### Local validation of behavior and effect
These are **native lance-c scanner measurements, not end-to-end Doris
SQL timings**. The library was freshly built from pinned lance-c
`a51ab47a9c814a746cb1714c99fad93dd3a188b8`, using Lance
`68c12dfd7efe02f90ad2d7f3a239a7eb64884e57`. The unoptimized development
build and Python/Arrow result conversion make the timings illustrative,
not production performance claims.
Procedure:
1. Generate 16,384 Gaussian Float32 vectors of dimension 128, then 128
independent query vectors, using xorshift64/Box-Muller with seed
`20261029`. Assign `row_id=0..16383` and `category=row_id%10`.
2. Build fully covering IVF_RQ (32 partitions, 5 bits), IVF_FLAT (32
partitions), and IVF_HNSW_FLAT (32 partitions, 16 edges, construction
ef=100) indexes. Select each index explicitly by its segment UUIDs.
3. Search squared L2 with k=10, nprobes=32, and query_parallelism=1.
Repeat IVF_RQ with refine_factor=4. For HNSW, set ef=64 and prefilter
`category < 5` (about 50% of rows).
4. Compute exact Top-10 independently with NumPy. Warm up each mode with
eight queries; run three rounds of 128 queries per mode, rotating mode
order per query and round. The table below reports mean Recall@10,
median latency, and p95 latency, from 384 searches per mode.
5. Compare omitted mode with explicit normal over all 128 queries per
case, checking ordered IDs and Float32 distances. Independently
recompute original-vector distances for returned IVF_RQ rows to measure
score error.
| Index / refinement | Mode | Recall@10 | Median (ms) | p95 (ms) |
| --- | --- | ---: | ---: | ---: |
| IVF_RQ / none | fast | 26.80% | 14.57 | 16.03 |
| IVF_RQ / none | normal | 91.72% | 15.90 | 18.12 |
| IVF_RQ / none | accurate | 91.72% | 20.52 | 22.91 |
| IVF_RQ / 4 | fast | 54.30% | 20.50 | 22.07 |
| IVF_RQ / 4 | normal | 99.92% | 23.44 | 25.64 |
| IVF_RQ / 4 | accurate | 99.92% | 28.19 | 30.58 |
| IVF_FLAT / none | fast | 100% | 25.71 | 29.62 |
| IVF_FLAT / none | normal | 100% | 25.78 | 29.82 |
| IVF_FLAT / none | accurate | 100% | 25.71 | 30.74 |
| IVF_HNSW_FLAT / 50% prefilter | fast | 100% | 40.45 | 44.04 |
| IVF_HNSW_FLAT / 50% prefilter | normal | 100% | 44.51 | 49.86 |
| IVF_HNSW_FLAT / 50% prefilter | accurate | 100% | 44.48 | 50.05 |
All searches returned ten rows. Omitted mode matched explicit normal
exactly in every case. IVF_FLAT and filtered HNSW returned identical
ordered IDs and distances across modes. The data seed fixes vectors; ANN
index training/layout can vary across rebuilds.
Without refinement, normal and accurate had equal mean recall but
different ordered IDs on 89/128 queries. Mean absolute squared-L2 score
error was 30.7325 for fast, 0.7986 for normal, and 0.7783 for accurate.
Thus accurate slightly improved distance estimation on this fixture
without improving mean recall. With refinement factor 4, normal and
accurate returned identical results, while fast still missed many
neighbors. The mode is not interchangeable with refinement, and its
effect depends on the index/path and data.
### Release note
Add the optional `approx_mode` property to Lance `vector_search`,
accepting `fast`, `normal`, and `accurate`, with the existing default
preserved when omitted.
### Check List (For Author)
- Test
- [x] Unit tests: focused source compilation and JUnit execution of
`VectorSearchTableValuedFunctionTest` and `LanceScanNodeTest`: **59
tests passed**. Coverage includes accepted/invalid modes, typed Thrift
round trips, default omission, EXPLAIN, and smooth-upgrade scan
initialization.
- [x] Review-fix regression: the new real scan-initialization test
failed on the previous code (the expected upgrade rejection was
missing), then passed with the gate. Coverage includes FAST/ACCURATE
with old-only and mixed candidate pools, single/multi-vector requests,
all modes on current BEs, omitted/explicit NORMAL on mixed BEs, and
unaffected FTS initialization.
- [x] Manual test: native mode comparison described above, plus
independent distance-error checks.
- [x] FE `mvn -B clean checkstyle:check` passed; clang-format 16 checks
passed for both affected C++ files; generated Thrift sources rebuilt
successfully.
- [x] Fresh BE reader and test source objects compiled successfully
against the new headers and pinned C API.
- [ ] Full BE unit-test execution: not run locally because the available
prebuilt BE libraries have an incompatible ABI. New request-validation
coverage is included for CI.
- [ ] SQL regression execution: added mode pass-through, EXPLAIN, and
invalid-value coverage to `test_lance_vector_search`; the full suite
parsed successfully with Groovy 4.0.19, but execution is pending CI.
- Behavior changed:
- [x] Yes. Explicit `approx_mode` now selects the native policy;
omission preserves existing behavior.
- Does this need documentation?
- [x] Yes. English and Chinese 4.x documentation: [website
PR](https://github.com/apache/doris-website/pull/4554).
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
be/src/format_v2/table/lance_reader.cpp | 31 ++++++
be/test/format_v2/table/lance_reader_test.cpp | 20 ++++
.../datasource/lance/source/LanceScanNode.java | 28 ++++++
.../VectorSearchTableValuedFunction.java | 26 ++++-
.../datasource/lance/source/LanceScanNodeTest.java | 106 ++++++++++++++++++++-
.../VectorSearchTableValuedFunctionTest.java | 43 +++++++++
gensrc/thrift/PlanNodes.thrift | 8 ++
.../lance/test_lance_vector_search.groovy | 21 ++++
8 files changed, 280 insertions(+), 3 deletions(-)
diff --git a/be/src/format_v2/table/lance_reader.cpp
b/be/src/format_v2/table/lance_reader.cpp
index 752ce7460c2..2eb9bbc1ced 100644
--- a/be/src/format_v2/table/lance_reader.cpp
+++ b/be/src/format_v2/table/lance_reader.cpp
@@ -76,6 +76,25 @@ Status LanceTableReader::fetch_schema(const TFileRangeDesc&
range,
}
namespace {
+Status to_lance_approx_mode(TVectorApproxMode::type mode, LanceApproxMode*
native_mode) {
+ switch (mode) {
+ case TVectorApproxMode::FAST:
+ *native_mode = LANCE_APPROX_MODE_FAST;
+ break;
+ case TVectorApproxMode::NORMAL:
+ *native_mode = LANCE_APPROX_MODE_NORMAL;
+ break;
+ case TVectorApproxMode::ACCURATE:
+ *native_mode = LANCE_APPROX_MODE_ACCURATE;
+ break;
+ default:
+ return Status::InvalidArgument(
+ "Lance approx_mode must be FAST, NORMAL, or ACCURATE, got {}",
+ static_cast<int>(mode));
+ }
+ return Status::OK();
+}
+
// A dataset URI without its query string or a password in its user
information, either of which
// may carry credentials, for errors and profiles. A user part without a
password stays: Azure
// URIs name the container there (abfss://container@account...).
@@ -619,6 +638,10 @@ Status
LanceTableReader::_validate_external_search_request() const {
if (_search_kind == SearchKind::VECTOR &&
request.__isset.vector_search_options) {
const auto& options = request.vector_search_options;
+ if (options.__isset.approx_mode) {
+ LanceApproxMode native_mode;
+ RETURN_IF_ERROR(to_lance_approx_mode(options.approx_mode,
&native_mode));
+ }
if (options.__isset.query_parallelism && options.query_parallelism <
-1) {
return Status::InvalidArgument("Lance query_parallelism must be
-1, 0, or positive");
}
@@ -1131,6 +1154,14 @@ Status
LanceTableReader::_configure_vector_search(LanceScanner* scanner,
}
if (request.__isset.vector_search_options) {
const auto& options = request.vector_search_options;
+ // Leave an omitted mode unset so Lance retains its default search
policy.
+ if (options.__isset.approx_mode) {
+ LanceApproxMode native_mode;
+ RETURN_IF_ERROR(to_lance_approx_mode(options.approx_mode,
&native_mode));
+ if (lance_scanner_set_approx_mode(scanner, native_mode) != 0) {
+ return lance_error("set Lance vector approx_mode");
+ }
+ }
if (options.__isset.query_parallelism &&
lance_scanner_set_query_parallelism(scanner,
options.query_parallelism) != 0) {
return lance_error("set Lance vector query parallelism");
diff --git a/be/test/format_v2/table/lance_reader_test.cpp
b/be/test/format_v2/table/lance_reader_test.cpp
index c27190b8fe7..93ee0d26ea6 100644
--- a/be/test/format_v2/table/lance_reader_test.cpp
+++ b/be/test/format_v2/table/lance_reader_test.cpp
@@ -1654,6 +1654,26 @@ TEST(LanceTableReaderVectorSearchTest,
RejectsInvalidQueryParallelismBeforeOpeni
EXPECT_NE(std::string::npos, status.to_string().find("query_parallelism"));
}
+TEST(LanceTableReaderVectorSearchTest,
ValidatesApproxModeBeforeOpeningDataset) {
+ TQueryGlobals globals;
+ RuntimeState state(globals);
+ const Columns columns {projected_column("_distance", TYPE_FLOAT, true)};
+ for (const int value : {0, 1, 2, 3}) {
+ SCOPED_TRACE(value);
+ RuntimeProfile profile("lance_approx_mode_validation");
+ auto params = make_float32_vector_search_params({0.0F, 0.0F, 0.0F}, 2,
0);
+
params.lance_scan_params.external_search_request.vector_search_options.__set_approx_mode(
+ static_cast<TVectorApproxMode::type>(value));
+ LanceTableReader reader;
+ const auto status = init_reader(&reader, columns, &state, &profile,
¶ms);
+ const bool valid = value >= 0 && value <= 2;
+ EXPECT_EQ(valid, status.ok()) << status.to_string();
+ if (!valid) {
+ EXPECT_NE(std::string::npos,
status.to_string().find("approx_mode"));
+ }
+ }
+}
+
TEST(LanceTableReaderVectorSearchTest,
ValidatesDistanceRangeBeforeDatasetAccess) {
TQueryGlobals globals;
RuntimeState state(globals);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
index ebd18437d20..ce2c49a2d96 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
@@ -49,7 +49,9 @@ import org.apache.doris.thrift.TFullTextSearchParams;
import org.apache.doris.thrift.TLanceFileDesc;
import org.apache.doris.thrift.TLanceScanParams;
import org.apache.doris.thrift.TTableFormatFileDesc;
+import org.apache.doris.thrift.TVectorApproxMode;
import org.apache.doris.thrift.TVectorMetric;
+import org.apache.doris.thrift.TVectorSearchOptions;
import org.apache.doris.thrift.TVectorSearchParams;
import com.google.common.annotations.VisibleForTesting;
@@ -143,6 +145,7 @@ public class LanceScanNode extends FileQueryScanNode {
super.doInitialize();
checkAdditionalTypeBackendCompatibility(
projectsCurrentReaderType(), backendPolicy.getBackends());
+ checkApproxModeBackendCompatibility();
ExternalUtil.initSchemaInfo(params, -1L, sourceColumns);
if (searchKind.isExternalSearch()) {
@@ -157,6 +160,26 @@ public class LanceScanNode extends FileQueryScanNode {
lazyMaterializedColumns.add(columnName.toLowerCase(Locale.ROOT));
}
+ private void checkApproxModeBackendCompatibility() throws UserException {
+ if (searchKind != SearchKind.VECTOR ||
!externalSearchRequest.isSetVectorSearchOptions()) {
+ return;
+ }
+ TVectorSearchOptions options =
externalSearchRequest.getVectorSearchOptions();
+ if (!options.isSetApproxMode() || options.getApproxMode() ==
TVectorApproxMode.NORMAL) {
+ return;
+ }
+ // Old BEs skip the optional field, which would mix NORMAL with
FAST/ACCURATE split candidates.
+ for (Backend backend : backendPolicy.getBackends()) {
+ if (backend.isSmoothUpgradeSrc()) {
+ throw new UserException("Lance approx_mode="
+ +
options.getApproxMode().name().toLowerCase(Locale.ROOT)
+ + " is unavailable while backend " + backend.getId()
+ + " is a smooth upgrade source; finish upgrading the
query backends"
+ + " or use approx_mode=normal");
+ }
+ }
+ }
+
/** Checks columns read in either phase of a Lance scan. */
private boolean projectsCurrentReaderType() {
// Global row IDs route the second-phase take back to the first-phase
BE, so lazy pruning
@@ -354,6 +377,11 @@ public class LanceScanNode extends FileQueryScanNode {
.append(scanPlan.vectorIndexStatus).append("\n");
result.append(prefix).append("lanceVectorColumn=")
.append(vector.getColumn()).append("\n");
+ if (externalSearchRequest.isSetVectorSearchOptions()
+ &&
externalSearchRequest.getVectorSearchOptions().isSetApproxMode()) {
+ result.append(prefix).append("lanceApproxMode=")
+
.append(externalSearchRequest.getVectorSearchOptions().getApproxMode()).append("\n");
+ }
if (externalSearchRequest.isSetVectorSearchOptions()
&&
externalSearchRequest.getVectorSearchOptions().isSetQueryParallelism()) {
result.append(prefix).append("lanceQueryParallelism=")
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java
index fbb42ed7c0c..20a0ed19d8f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java
@@ -23,6 +23,7 @@ import
org.apache.doris.datasource.lance.metadata.LanceTableMetadata;
import org.apache.doris.thrift.TExternalSearchQuery;
import org.apache.doris.thrift.TExternalSearchRequest;
import org.apache.doris.thrift.TSearchVector;
+import org.apache.doris.thrift.TVectorApproxMode;
import org.apache.doris.thrift.TVectorElementType;
import org.apache.doris.thrift.TVectorMetric;
import org.apache.doris.thrift.TVectorSearchOptions;
@@ -61,6 +62,7 @@ public class VectorSearchTableValuedFunction extends
LanceExternalSearchTableVal
private static final String QUERY_VECTOR = "query_vector";
private static final String METRIC = "metric";
private static final String QUERY_PARALLELISM = "query_parallelism";
+ private static final String APPROX_MODE = "approx_mode";
private static final String NPROBES = "nprobes";
private static final String REFINE_FACTOR = "refine_factor";
private static final String EF = "ef";
@@ -69,7 +71,7 @@ public class VectorSearchTableValuedFunction extends
LanceExternalSearchTableVal
private static final String USE_INDEX = "use_index";
private static final Set<String> PROPERTIES = ImmutableSet.of(
TABLE, COLUMN, QUERY_VECTOR, TOP_K, OFFSET, METRIC, FILTER,
PREFILTER,
- NPROBES, REFINE_FACTOR, EF, USE_INDEX, QUERY_PARALLELISM,
+ NPROBES, REFINE_FACTOR, EF, USE_INDEX, QUERY_PARALLELISM,
APPROX_MODE,
VERSION, TIMESTAMP, TAG, BRANCH, DISTANCE_LOWER_BOUND,
DISTANCE_UPPER_BOUND);
public VectorSearchTableValuedFunction(Map<String, String> properties)
@@ -89,6 +91,7 @@ public class VectorSearchTableValuedFunction extends
LanceExternalSearchTableVal
boolean hasDistanceRange = vectorParams.isSetDistanceLowerBound() ||
vectorParams.isSetDistanceUpperBound();
boolean useIndex = !params.containsKey(USE_INDEX)
|| parseBoolean(params.get(USE_INDEX), USE_INDEX);
+ TVectorSearchOptions vectorSearchOptions =
buildVectorSearchOptions(params, useIndex);
CommonSearch common = prepareCommon(params, NAME,
"VectorSearchTableValuedFunction", "vector search", useIndex);
@@ -126,7 +129,6 @@ public class VectorSearchTableValuedFunction extends
LanceExternalSearchTableVal
TExternalSearchRequest searchRequest = new TExternalSearchRequest()
.setSchemaVersion(1)
.setSearchQuery(TExternalSearchQuery.vector_search(vectorParams));
- TVectorSearchOptions vectorSearchOptions =
buildVectorSearchOptions(params, useIndex);
if (vectorSearchOptions != null) {
searchRequest.setVectorSearchOptions(vectorSearchOptions);
}
@@ -169,6 +171,10 @@ public class VectorSearchTableValuedFunction extends
LanceExternalSearchTableVal
Map<String, String> params, boolean useIndex) throws
AnalysisException {
TVectorSearchOptions options = new TVectorSearchOptions();
boolean configured = false;
+ if (params.containsKey(APPROX_MODE)) {
+ options.setApproxMode(parseApproxMode(params.get(APPROX_MODE)));
+ configured = true;
+ }
if (params.containsKey(QUERY_PARALLELISM)) {
options.setQueryParallelism((int) parseLong(
params.get(QUERY_PARALLELISM), QUERY_PARALLELISM, -1,
Integer.MAX_VALUE));
@@ -194,6 +200,22 @@ public class VectorSearchTableValuedFunction extends
LanceExternalSearchTableVal
return configured ? options : null;
}
+ private static TVectorApproxMode parseApproxMode(String value) throws
AnalysisException {
+ if (value != null) {
+ switch (value.trim().toLowerCase(Locale.ROOT)) {
+ case "fast":
+ return TVectorApproxMode.FAST;
+ case "normal":
+ return TVectorApproxMode.NORMAL;
+ case "accurate":
+ return TVectorApproxMode.ACCURATE;
+ default:
+ break;
+ }
+ }
+ throw new AnalysisException("Unsupported 'approx_mode': expected fast,
normal, or accurate");
+ }
+
@VisibleForTesting
static List<Column> buildOutputColumns(LanceTableMetadata metadata)
throws AnalysisException {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
index 32173603c8f..d7cd2881cf8 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java
@@ -29,6 +29,7 @@ import org.apache.doris.catalog.TableIf;
import org.apache.doris.common.UserException;
import org.apache.doris.common.profile.RuntimeProfile;
import org.apache.doris.common.profile.SummaryProfile;
+import org.apache.doris.datasource.ExternalScanNode;
import org.apache.doris.datasource.FederationBackendPolicy;
import org.apache.doris.datasource.lance.LanceExternalCatalog;
import org.apache.doris.datasource.lance.LanceExternalTable;
@@ -54,6 +55,7 @@ import org.apache.doris.thrift.TFtsQueryType;
import org.apache.doris.thrift.TFullTextSearchParams;
import org.apache.doris.thrift.TLanceFileDesc;
import org.apache.doris.thrift.TPushAggOp;
+import org.apache.doris.thrift.TVectorApproxMode;
import org.apache.doris.thrift.TVectorMetric;
import org.apache.doris.thrift.TVectorSearchOptions;
import org.apache.doris.thrift.TVectorSearchParams;
@@ -80,6 +82,90 @@ import java.util.UUID;
public class LanceScanNodeTest {
+ @Test
+ public void
testNonDefaultApproxModeRejectsSmoothUpgradeSourcesDuringInitialization()
throws Exception {
+ Backend current = new Backend(1, "127.0.0.1", 9050);
+ Backend old = new Backend(2, "127.0.0.2", 9050);
+ old.setSmoothUpgradeSrc(true);
+ for (TVectorApproxMode mode : Arrays.asList(TVectorApproxMode.FAST,
TVectorApproxMode.ACCURATE)) {
+ for (boolean multiVector : Arrays.asList(false, true)) {
+ for (List<Backend> backends :
Arrays.asList(Collections.singletonList(old),
+ Arrays.asList(current, old))) {
+ TExternalSearchRequest request = vectorSearchRequest(5, 0);
+ request.setVectorSearchOptions(new
TVectorSearchOptions().setApproxMode(mode));
+ if (multiVector) {
+
request.getSearchQuery().getVectorSearch().setQueryVector(
+ new org.apache.doris.thrift.TSearchVector()
+
.setElementType(org.apache.doris.thrift.TVectorElementType.FLOAT32)
+
.setDimension(2).setNumVectors(2).setValues(new byte[16]));
+ }
+ LanceScanNode node =
newSearchNode(basicSearchMetadata(vectorSchema()), request);
+ setBackendCandidates(node, backends);
+
+ UserException error =
Assert.assertThrows(UserException.class, node::doInitialize);
+
+ Assert.assertTrue(error.getMessage(),
error.getMessage().contains("approx_mode="
+ + mode.name().toLowerCase(java.util.Locale.ROOT)));
+ Assert.assertTrue(error.getMessage(),
error.getMessage().contains("backend 2"));
+ Assert.assertTrue(error.getMessage(),
error.getMessage().contains("smooth upgrade source"));
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testAllApproxModesInitializeWithCurrentBackends() throws
Exception {
+ List<Backend> backends = Arrays.asList(new Backend(1, "127.0.0.1",
9050),
+ new Backend(2, "127.0.0.2", 9050));
+ for (TVectorApproxMode mode : TVectorApproxMode.values()) {
+ TExternalSearchRequest request = vectorSearchRequest(5, 0);
+ request.setVectorSearchOptions(new
TVectorSearchOptions().setApproxMode(mode));
+ LanceScanNode node =
newSearchNode(basicSearchMetadata(vectorSchema()), request);
+ setBackendCandidates(node, backends);
+
+ node.doInitialize();
+
+ Assert.assertEquals(mode,
node.createSplitSearchRequest().getVectorSearchOptions().getApproxMode());
+ }
+ }
+
+ @Test
+ public void
testDefaultApproxModeRemainsCompatibleWithSmoothUpgradeSources() throws
Exception {
+ Backend old = new Backend(2, "127.0.0.2", 9050);
+ old.setSmoothUpgradeSrc(true);
+ List<Backend> backends = Arrays.asList(new Backend(1, "127.0.0.1",
9050), old);
+ for (TVectorSearchOptions options : Arrays.asList(null, new
TVectorSearchOptions().setNprobes(4),
+ new
TVectorSearchOptions().setApproxMode(TVectorApproxMode.NORMAL))) {
+ TExternalSearchRequest request = vectorSearchRequest(5, 0);
+ if (options != null) {
+ request.setVectorSearchOptions(options);
+ }
+ LanceScanNode node =
newSearchNode(basicSearchMetadata(vectorSchema()), request);
+ setBackendCandidates(node, backends);
+
+ node.doInitialize();
+
+ TExternalSearchRequest split = node.createSplitSearchRequest();
+ Assert.assertEquals(request.isSetVectorSearchOptions(),
split.isSetVectorSearchOptions());
+ if (options != null) {
+ Assert.assertEquals(options, split.getVectorSearchOptions());
+ }
+ }
+ }
+
+ @Test
+ public void testApproxModeCompatibilityDoesNotRejectFullTextSearch()
throws Exception {
+ Backend old = new Backend(2, "127.0.0.2", 9050);
+ old.setSmoothUpgradeSrc(true);
+ TExternalSearchRequest request = fullTextSearchRequest(5, 0,
TFtsCoverageMode.STRICT);
+ LanceScanNode node =
newSearchNode(basicSearchMetadata(fullTextSchema()), request);
+ setBackendCandidates(node, Collections.singletonList(old));
+
+ node.doInitialize();
+
+ Assert.assertEquals(request, node.createSplitSearchRequest());
+ }
+
@Test
public void testSplitPlanningReportsSuccessAndFailureToQueryProfile()
throws Exception {
ConnectContext previous = ConnectContext.get();
@@ -669,13 +755,16 @@ public class LanceScanNodeTest {
Collections.singletonList(9),
Arrays.asList(3L, 4L),
IndexType.VECTOR, "L2")));
TExternalSearchRequest request = vectorSearchRequest(5, 0);
- request.setVectorSearchOptions(new
TVectorSearchOptions().setQueryParallelism(4));
+ request.setVectorSearchOptions(new TVectorSearchOptions()
+
.setQueryParallelism(4).setApproxMode(TVectorApproxMode.ACCURATE));
LanceScanNode node = newSearchNode(metadata, request);
List<Split> splits = node.getSplits(3);
Assert.assertTrue(node.getNodeExplainString("", TExplainLevel.NORMAL)
.contains("lanceQueryParallelism=4"));
+ Assert.assertTrue(node.getNodeExplainString("", TExplainLevel.NORMAL)
+ .contains("lanceApproxMode=ACCURATE"));
Assert.assertTrue(node.getNodeExplainString("", TExplainLevel.NORMAL)
.contains("lanceVectorIndexStatus=USED"));
@@ -1110,6 +1199,21 @@ public class LanceScanNodeTest {
return newNode(new SessionVariable());
}
+ private static LanceTableMetadata basicSearchMetadata(Schema schema) {
+ return LanceTableMetadata.createBasicSnapshot(
+ new LanceTableAccess("s3://bucket/table.lance",
Collections.emptyMap()), 42, schema,
+ Collections.singletonList(new LanceFragmentInfo(1, 8, 8)));
+ }
+
+ private static void setBackendCandidates(LanceScanNode node, List<Backend>
backends) throws Exception {
+ FederationBackendPolicy policy =
Mockito.mock(FederationBackendPolicy.class);
+ Mockito.when(policy.getBackends()).thenReturn(backends);
+ Mockito.when(policy.numBackends()).thenReturn(backends.size());
+ java.lang.reflect.Field field =
ExternalScanNode.class.getDeclaredField("backendPolicy");
+ field.setAccessible(true);
+ field.set(node, policy);
+ }
+
private static LanceScanNode newNode(SessionVariable sessionVariable) {
return new LanceScanNode(
new PlanNodeId(0),
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunctionTest.java
b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunctionTest.java
index 181abca8dc9..cfdbd558693 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunctionTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunctionTest.java
@@ -22,6 +22,7 @@ import org.apache.doris.catalog.Column;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.datasource.lance.metadata.LanceTableAccess;
import org.apache.doris.datasource.lance.metadata.LanceTableMetadata;
+import org.apache.doris.thrift.TVectorApproxMode;
import org.apache.doris.thrift.TVectorSearchOptions;
import org.apache.doris.thrift.TVectorSearchParams;
@@ -44,6 +45,48 @@ public class VectorSearchTableValuedFunctionTest {
return
VectorSearchTableValuedFunction.buildVectorSearchOptions(params, true);
}
+ @Test
+ public void testApproxModeIsIncludedInSearchOptions() throws Exception {
+ for (String value : new String[] {"fast", "normal", "accurate", " FAST
", "Normal", "ACCURATE"}) {
+ TVectorSearchOptions options =
parseOptions(Collections.singletonMap("approx_mode", value));
+ Assert.assertNotNull("approx_mode must be serialized: " + value,
options);
+ TVectorSearchOptions decoded = new TVectorSearchOptions();
+ new TDeserializer().deserialize(decoded, new
TSerializer().serialize(options));
+ Assert.assertTrue(decoded.isSetApproxMode());
+
Assert.assertEquals(TVectorApproxMode.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)),
+ decoded.getApproxMode());
+ Assert.assertFalse(decoded.isSetNprobes());
+ }
+ }
+
+ @Test
+ public void testOmittedApproxModePreservesNativeDefault() throws Exception
{
+ Assert.assertNull(parseOptions(Collections.emptyMap()));
+ TVectorSearchOptions options = parseOptions(ImmutableMap.of(
+ "nprobes", "4", "refine_factor", "2", "query_parallelism",
"1"));
+ Assert.assertFalse(options.isSetApproxMode());
+ Assert.assertEquals(4, options.getNprobes());
+ Assert.assertEquals(2, options.getRefineFactor());
+ Assert.assertEquals(1, options.getQueryParallelism());
+ }
+
+ @Test
+ public void testRejectInvalidApproxMode() {
+ for (String value : new String[] {null, "", " ", "invalid", "0", "1",
"2", "fastest"}) {
+ AnalysisException error =
Assert.assertThrows(AnalysisException.class,
+ () -> parseOptions(Collections.singletonMap("approx_mode",
value)));
+ Assert.assertTrue(error.getMessage(),
error.getMessage().contains("approx_mode"));
+ }
+ }
+
+ @Test
+ public void testRejectInvalidApproxModeBeforeMetadataAccess() {
+ AnalysisException error = Assert.assertThrows(AnalysisException.class,
+ () -> new VectorSearchTableValuedFunction(
+ Collections.singletonMap("approx_mode", "invalid")));
+ Assert.assertTrue(error.getMessage(),
error.getMessage().contains("approx_mode"));
+ }
+
@Test
public void testRejectInvalidDistanceBoundsBeforeMetadataAccess() {
for (String value : new String[] {"NaN", "Infinity", "-Infinity",
"1e100", "invalid", ""}) {
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index 9d9a042c469..fb336e14dc1 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -471,6 +471,12 @@ enum TVectorMetric {
HAMMING
}
+enum TVectorApproxMode {
+ FAST = 0,
+ NORMAL = 1,
+ ACCURATE = 2
+}
+
struct TSearchVector {
1: optional TVectorElementType element_type
2: optional i32 dimension
@@ -558,6 +564,8 @@ struct TVectorSearchOptions {
4: optional bool use_index
// Lance: -1 uses available CPU parallelism, 0 selects automatically,
positive values cap it.
5: optional i32 query_parallelism
+ // Unset preserves the vector engine's default speed/recall tradeoff.
+ 6: optional TVectorApproxMode approx_mode
}
// The active union field identifies the logical search kind. A future hybrid
field can contain both
diff --git
a/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
b/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
index efb2ce302d1..98f437e9bfd 100644
---
a/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
+++
b/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
@@ -57,6 +57,7 @@ suite("test_lance_vector_search", "p0,external") {
* refine_factor Positive candidate refinement/reranking factor.
* ef Positive graph-search candidate width.
* use_index true allows index search; false explicitly requests a
flat search.
+ * approx_mode fast, normal, or accurate; defaults to Lance's normal
policy.
*
* An outer WHERE is deliberately different from the TVF filter property:
it is evaluated by
* Doris after Lance returns Top-K and can therefore reduce the final
result below top_k.
@@ -152,6 +153,26 @@ suite("test_lance_vector_search", "p0,external") {
"""
def baselineRows = sql "SELECT row_id, label, _distance FROM
${indexedTopFive} ORDER BY _distance, row_id"
+ // This refined IVF_PQ fixture is a pass-through control, not a
guarantee of equal
+ // recall for algorithms whose candidate selection depends on
approx_mode.
+ ["fast", "normal", "accurate", " FAST "].each { mode ->
+ String modeSearch = indexedTopFive.substring(0,
indexedTopFive.length() - 1) +
+ ", \"approx_mode\"=\"${mode}\")"
+ explain {
+ sql("SELECT row_id, label, _distance FROM ${modeSearch}")
+ contains
"lanceApproxMode=${mode.trim().toUpperCase(java.util.Locale.ROOT)}"
+ }
+ assertEquals(baselineRows,
+ sql("SELECT row_id, label, _distance FROM ${modeSearch}
ORDER BY _distance, row_id"))
+ }
+ ["", "invalid", "0", "1", "2"].each { mode ->
+ String modeSearch = indexedTopFive.substring(0,
indexedTopFive.length() - 1) +
+ ", \"approx_mode\"=\"${mode}\")"
+ test {
+ sql "SELECT row_id FROM ${modeSearch}"
+ exception "approx_mode"
+ }
+ }
[-1, 0, 1, 2, 4].each { parallelism ->
String parallelSearch = indexedTopFive.substring(0,
indexedTopFive.length() - 1) +
", \"query_parallelism\"=\"${parallelism}\")"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]