Skip to main content
Blog/Tech Sharing

Apache Doris and Lance: How Lazy Materialization Speeds Up Vector Search

Wenqiang Li

Autonomous driving, knowledge-base retrieval, and multimodal search queries often perform several tasks at once: find similar records, apply business filters, and return text and attributes. As datasets grow, latency depends on both retrieval speed and how much data the execution pipeline reads, processes, and transfers.

The previous article described a billion-record test in which vector search with four filters took 2.32 seconds with Doris + Lance and 25.228 seconds with Ray + pylance, a latency ratio of about 10.87×. At the same scale, Doris + Lance took 0.43 seconds for vector search alone and 0.73 seconds with a time filter.

Query planning, parallel search, prefiltering, on-demand column reads, and caching work together in that execution path. This article focuses on lazy materialization, also called two-phase reading. It defers columns needed only for final output until after TopN, so candidates that are later discarded do not incur the full cost of reading and transferring their contents.

Why read some columns later?​

Suppose a Lance knowledge-base table contains id, title, body, and embedding. This query returns the 20 most relevant documents:

SELECT id, title, body, _distance
FROM vector_search(
"table" = "lance_catalog.demo.documents",
"column" = "embedding",
"query_vector" = "[0.1, 0.2, 0.3, 0.4]",
"metric" = "l2",
"top_k" = "20"
)
ORDER BY _distance ASC;

The four-dimensional vector is illustrative; use a vector and distance metric compatible with your dataset and index.

The query needs all four output values, but at different times. _distance must exist before ranking. In this example, title and body are needed only for display, so they can wait until the result is selected. Whether the business id can wait depends on its use in filtering, sorting, or other computation.

Reading the complete body during candidate generation makes losing documents incur I/O, decoding, and transfer costs. Distributed execution increases that cost: multiple search splits produce local candidates that Doris merges into a global TopN. Some candidates never reach the final result, yet their eagerly loaded bodies have already passed through Arrow conversion, Block transfers, and sorting.

Lazy materialization changes the order: select results using the required information, then fetch the remaining contents for those rows. Wide columns, such as long document bodies and rich attributes, offer the largest opportunity to save work.

Early materialization ranks complete candidate rows, while two-phase reading ranks IDs and scores before fetching selected payloads

How the two phases work together​

Doris's optimizer, distributed execution framework, and Lance's row-level reads cooperate on this optimization. FE identifies output columns that can be deferred and adds a materialization node to the physical plan. BEs perform search, filtering, and TopN, then use the retained row locators to fetch the remaining columns in batches.

PhaseMain workData carried
Phase 1: select resultsGenerate candidates and perform required filtering and rankingRequired early columns, distance or score, and hidden row locators
Phase 2: complete the outputFetch columns by row ID and restore result orderDeferred output columns for selected rows

FE plans a pinned snapshot; BEs run parallel search, global TopN, and materialization against that Lance dataset version

For an eligible query, the flow is:

Lance search splits
→ required early columns + score/distance + hidden row locator
→ Doris filtering / local TopN / exchange / global TopN
→ Materialize: batch row-ID fetch
→ restore result order and assemble output

The exact positions of TopN, Exchange, Project, and Filter depend on the plan. The key is that deferred columns stay out of the candidate stream until the optimized TopN has selected rows.

That design raises two implementation questions: which columns can wait, and how can Doris retrieve them accurately afterward?

Phase 1: read only the required columns​

Trace column dependencies​

Eligibility depends on how a column is used. Even a large body column must be read early if an early filter or sort needs its value.

During Nereids physical-plan postprocessing, LazyMaterializeTopN starts from TopN's output slots and traces each column's origin. MaterializeProbeVisitor walks down the plan to check whether sorting, filtering, or another operator needs the column early, and whether it traces back to an original column that supports row-ID fetching.

Sort keys need values before TopN. Columns used by residual Doris-side filters need values before those filters. Expressions evaluated early by Project also constrain their input columns. The optimizer tracks aliases and original-column dependencies so pruning one output does not remove an input still needed by another.

Distance and residual-filter columns remain in phase one, while an output-only body column can be fetched after global TopN

Column useTreatmentReason
Vector _distance or full-text _scoreKeep in retrieval and rankingThese values are generated by search and cannot be fetched as ordinary stored columns
Doris-side sort keys or residual-filter inputsRead earlyOperators need actual values to select candidates
Inputs to expressions evaluated earlyRetain according to expression dependenciesThe expression needs those inputs
Top-level columns used only for output, such as title and bodyDefer when eligibleThey can be fetched from the original dataset after selection

Prune projections and retain locators​

Once eligible columns are identified, LazySlotPruning adjusts scan and upstream outputs. It removes deferred columns from the early flow and adds a hidden global row-locator column. The optimizer then constructs PhysicalLazyMaterialize, recording the columns to fetch, their source relations, and each relation's locator column.

On the BE, LanceTableReader creates a scanner using the pruned projection. When a later fetch is needed, it calls lance_scanner_with_row_id(scanner, true) to request native Lance row IDs. A converter wraps them in an internal Doris locator that can travel across operators and BEs.

The candidate can therefore omit its body while retaining enough information to retrieve it later.

Phase 2: fetch the selected contents​

After TopN selects rows, the second phase retrieves their remaining output columns. It must preserve snapshot consistency and result order while organizing efficient reads.

Pin the snapshot and locate the row​

Lance returns native uint64 row IDs. Doris wraps each in a versioned internal locator containing the source BE, a query-local mapping ID, and the Lance row ID.

The mapping ID identifies a saved scan context; it is not a physical filename. A Lance row ID is also not simply a row number in a Parquet or ORC file.

The saved context contains the dataset URI, pinned version, and storage access options. Phase two restores those values through the mapping and opens the same dataset snapshot. The snapshot version belongs to the context and does not need to be repeated in every locator.

Both phases must interpret row IDs against the same snapshot. If phase one selects a row from version V, phase two must fetch from V rather than opening latest. LanceTableReader::read_by_row_ids therefore requires a fixed version and opens the dataset through the shared LanceSessionManager.

Route requests and fetch in batches​

The materialization operator decodes the hidden locators and groups multiget_data_v2 requests by source BE. The receiving BE resolves the saved scan context, then sorts and deduplicates row IDs within each mapping to form a batch request.

Selected rows carry BE IDs, mapping IDs, and Lance row IDs; grouped requests resolve the pinned snapshot, fetch rows, and restore output order

In the Lance-specific path, RowIdStorageReader uses LanceTableReader::read_by_row_ids to call the dataset-level API. It fetches columns by row ID without recreating a normal FileScanner to scan the candidate-producing fragment:

lance_dataset_take_rows(
dataset,
row_ids.data(),
row_ids.size(),
projected_columns,
&arrow_stream
);

projected_columns contains only the columns needed in phase two. The SDK returns an ArrowArrayStream. Doris imports it into a RecordBatchReader, consumes batches, and converts them into Doris Blocks.

Some Lance reads happen only while the Arrow stream is consumed. Measuring fetch time must therefore include both the API call and subsequent ReadNext calls. The shared session can reuse relevant metadata and data caches, but phase two still pays for dataset opening, row-ID requests, storage reads, decoding, and conversion.

Restore order and assemble output​

Sorting and deduplicating row IDs changes the storage-read order, and responses from different BEs can arrive in any order. Doris maintains mappings from output positions to fetch positions and source BEs, then assembles the returned values in TopN's original output order.

A duplicate row ID within a fetch group can be read once and mapped to multiple output positions. The implementation also checks requested versus returned row counts, column lengths, and row counts across BE responses. These checks keep fetched values aligned with row identity, the snapshot, and ranking.

Where the savings come from​

Lazy materialization reduces early reads of wide output columns for candidates that will be discarded.

A simple model illustrates the potential. Suppose search splits produce C candidates, the final result contains K rows, and each row has W bytes of deferrable output columns:

Early payload volume ≈ C × W
Deferred payload volume ≈ K × W
Avoided payload volume ≈ (C − K) × W

This describes logical output-column volume. It excludes index reads and vector reranking and does not equal bytes downloaded from object storage. Page or block read amplification, compression, caching, row locators, and fetch RPCs all affect actual cost.

For example, if 16 splits each produce 20 candidates, the final result keeps 20 rows, and each body is 4 KiB:

Read strategyLogical body volume
Read bodies early for all 320 candidates320 × 4 KiB = 1.25 MiB
Fetch bodies only for the final 20 rows20 × 4 KiB = 80 KiB

The logical body volume falls to 1/16. This is an explanatory model, not a measured speedup or object storage transfer reduction.

The savings can affect several stages:

  • Reading and decoding: losing candidates do not require early reads and decoding of deferred columns.
  • Transfers and memory movement: smaller intermediate Blocks reduce network traffic and memory copying.
  • Sorting: wide columns no longer travel through TopN with every candidate.
More likely to benefitGains may be limited
Wide output columns and a small final Top-KOnly distances, scores, or a few narrow columns are returned
Many candidates from multiple splitsCandidate count is already close to final result count
Columns are needed only for displayMost columns participate in early filters, sorting, or computation
Reads, decoding, or transfers are expensiveANN computation, reranking, prefiltering, or queueing dominates

With OFFSET, each search split may need to produce top_k + offset candidates even though only top_k rows are returned. Lazy materialization can avoid fetching output columns for skipped rows, but it does not remove candidate generation, sorting, or skipping.

The net gain depends on the work saved relative to the extra fetch, RPC, and result-reassembly costs.

Benchmark: two-phase reads on and off​

An internal benchmark compared the same queries with two-phase reading disabled and enabled. Vector search alone, vector search with a time filter, and vector search with time and sensor-array filters all fell from tens of seconds to single-digit seconds.

Test setup​

The test used a build based on doris-4.1.5-release-c7f95adaaa with 104,343,874 rows. That build identifier describes the test environment; it does not establish availability in a standard 4.1.5 release. The cluster had one FE and two BEs. Each BE had 32 CPU cores, 128 GB RAM, and 1 TB of storage. Both scalar and vector indexes had 15 index segments.

All three queries used vector_search, sorted by L2 distance, set nprobes=128, use_index=true, and top_k=20000, and returned every column, including the vector. The sensor query filtered an array for Pandar128.

The test toggled enable_lance_lazy_materialization. The cluster restarted before each group, and each SQL statement ran three consecutive times. Below, cold means the first execution after restart; min means the minimum of the three executions. Speedup is disabled time divided by enabled time.

First execution: 4.74× to 6.29× faster​

First-execution times with lazy materialization disabled and enabled across three vector search queries

QueryDisabled (s)Enabled (s)Speedup
Vector search only41.8778.8394.74×
Vector + time filter33.8055.5656.07×
Vector + time + sensor-array filter31.2084.9626.29×

Elapsed time fell by about 78.9% to 84.1%, including queries that combined filtering with two-phase reads.

Minimum of three executions: 6.49× to 6.64× faster​

Minimum times from three executions with lazy materialization disabled and enabled across the same three queries

QueryDisabled (s)Enabled (s)Speedup
Vector search only39.6306.1106.49×
Vector + time filter31.9504.9016.52×
Vector + time + sensor-array filter28.3774.2756.64×

The minimum times fell by about 84.6% to 84.9%. The improvement was present in both first and repeated executions.

Why these queries benefited​

These queries both ranked candidates and returned full rows, including vectors. Multiple search tasks produced candidates, but only some survived the global Top-K. Reading output-only columns early made losing candidates incur unnecessary reads, decoding, and transfers.

Two-phase reading defers that work. Phase one carries search, filter, and sort inputs plus row locators. Phase two completes the selected rows. The amount of full-output processing therefore follows the final result more closely than the intermediate candidate count.

The experiment shows that even a Top-K of 20,000 can benefit when the query returns all columns. Actual gains depend on the workload and the balance between saved work and second-phase overhead.

Availability​

At the time of the original article, this capability was planned for the upcoming Apache Doris 4.2 release. Consult the Lance Catalog documentation for release support and compatibility.

Lazy materialization connects three pieces: the optimizer identifies output columns that can wait, distributed execution carries stable locators, and Lance fetches selected contents by native row ID from a pinned snapshot. For retrieval applications that return rich text and attributes, this brings column reads closer to the final result size and avoids spending work on discarded candidates.

Recent posts

View all blogs