This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 0011652b1dc [test](lance) Build the missing IVF_PQ regression fixture 
(#66779)
0011652b1dc is described below

commit 0011652b1dcdc247b0c0bf51ea6d566a71633079
Author: FANNG <[email protected]>
AuthorDate: Sun Aug 16 07:51:05 2026 +0800

    [test](lance) Build the missing IVF_PQ regression fixture (#66779)
    
    ### What problem does this PR solve?
    
    Issue Number: Part of #66495
    
    Problem Summary: test_lance_vector_search documents its
    doris.vector_search
    fixture as carrying an IVF_PQ index, but the fixture SQL
    (run07_create_vector_types.sql) delegated index creation to a companion
    create_vector_search_index.py that was never committed, and
    lance-spark-bundle
    0.4.0 cannot create vector indexes through SQL. The table therefore had
    no
    index at all, so every use_index / nprobes / refine_factor query in the
    suite
    silently executed a flat KNN scan while the goldens still looked
    correct.
    Nothing in the repository could observe the difference.
    
    Reproduction: build the table the old fixture built and probe it with
    nprobes=1. Lance ignores nprobes on an unindexed dataset and returns
    exactly
    the flat top-10 (rows 256,255,257,254,258,253,259,252,260,251 for the
    boundary
    query) - identical to the flat baseline, which is why the defect was
    invisible.
    
    Fix: replace the Spark-created table with an offline-generated Directory
    Namespace V2 catalog that carries a real IVF_PQ index, and add the
    evidence
    that the index is actually used.
    
    - lance_build_preinstalled_catalog.py rebuilds the committed fixture and
    self-checks it: exactly one IVF_PQ index named embedding_ivf_pq_f32
    covering
    every fragment, ANNSubIndex and ANNIvfPartition present in the indexed
    plan,
      KNNVectorDistance and no ANN node in the flat plan, and the exact
    16 * (n - r)^2 distance ladder that every golden and comment encodes, so
    a
    change to the data shape fails here instead of surfacing as an opaque
    golden
      diff. Index creation goes through the physical dataset because
    DirectoryNamespace.create_table_index raises UnsupportedOperationError.
    - doris.vs_ivf_pq_f32 replaces doris.vector_search: 1024 rows in two
    fragments, 16-dimensional Float32 embedding[j] = (row_id - 1) + j, so
    the
    exact squared L2 distance between rows r and n is 16 * (n - r)^2 and the
    head/tail queries have no distance ties. Columns are declared NOT NULL
    to
    match the fixture being replaced, keeping the only non-nullable Lance
    column
    mapping recorded by any Lance suite's DESC golden. The
    vs_<algorithm>_<element type>
    name encodes one cell of the algorithm x element type matrix, so a
    missing
      combination is visible from the table list alone.
    - The suite gains a silent-fallback discriminator. Row 256 sits on the
    first
    IVF partition boundary, so a genuine single-partition probe must miss
    true
    neighbours from the next partition. The suite asserts that the nprobes=1
      distance sequence differs from flat search; on the previous unindexed
      fixture the two are identical and the assertion fails. Distances are
    compared rather than row ids because the boundary query is symmetric and
      rows r-d and r+d tie. top_k is 9 there, the last cut that lands on a
    complete tie pair: at 10 the pair at distance 400 is split, so the
    golden
    would pin an arbitrary winner that any change to Lance's top-k selection
    could flip. Which partition edge row 256 lands next to changes on every
      retrain, so no measured range is hardcoded; --check prints it instead.
    - IVF_PQ is lossy, so every indexed query uses refine_factor and the
    suite
    documents indexed/flat agreement as an observed property of this frozen
      fixture and pinned Lance version, not an algorithm guarantee.
    
    The fixture is generated with the pins in
    lance_fixture_requirements.txt.
    Its readers do not all run the same Lance version - a BE built from
    source uses
    lance-c v0.1.2 (lance-rs 4.0.1) per thirdparty/vars.sh, the BE in CI
    comes from
    the prebuilt doris-thirdparty package and is already on lance-c v0.1.6
    (lance-rs 7.0.0-beta), and Spark writes into the same __manifest through
    lance-java 4.0.0. The writer is therefore pinned to the oldest Lance in
    that
    set, which every reader can read. Verified that this does not make the
    goldens
    version-dependent: pylance 7.0.0 reads the committed fixture with
    results
    identical to pylance 4.0.1 - same index, same refined top-5, same
    nprobes=1
    boundary rows, same IVF partition ranges.
    
    Index training is not bit-reproducible, so regenerating the fixture
    changes
    the binary output; the reproducible properties are asserted by the
    generator
    self-check instead. IVF_FLAT, IVF_SQ, IVF_HNSW_* and the other vector
    element
    types are follow-up work for #66495.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test: Regression test
        - Fixture generator self-check with the pinned dependencies
    - test_lance_vector_search regenerated with -forceGenOut, then passed
    the
          normal golden comparison
    - The whole external_table_p0/lance directory passed (6 suites, 0
    failed),
    covering the pre-existing suites that share the regenerated __manifest
    - Cross-checked that the nprobes=1 golden row order matches what pylance
          records probing the same physical index directly
    - Behavior changed: No, test fixture and regression coverage only
    - Does this need documentation: No
---
 .../lance/run07_create_vector_types.sql            |  46 ---
 .../scripts/lance_build_preinstalled_catalog.py    | 398 +++++++++++++++++++++
 .../iceberg/scripts/lance_fixture_requirements.txt |  37 ++
 .../bitmap_page_lookup.lance                       | Bin 0 -> 703 bytes
 .../page_data.lance                                | Bin 0 -> 576 bytes
 .../page_lookup.lance                              | Bin 0 -> 1151 bytes
 .../page_data.lance                                | Bin 635 -> 0 bytes
 .../page_lookup.lance                              | Bin 1269 -> 0 bytes
 .../bitmap_page_lookup.lance                       | Bin 393 -> 0 bytes
 .../bitmap_page_lookup.lance                       | Bin 0 -> 393 bytes
 .../bitmap_page_lookup.lance                       | Bin 683 -> 0 bytes
 .../0-aabf9667-c2e9-49d9-a16d-59e616c77195.txn     |   2 -
 .../14-2b2b0055-53cb-4e2e-a2d8-c17bb410ce79.txn    | Bin 0 -> 940 bytes
 .../_versions/18446744073709551600.manifest        | Bin 0 -> 1949 bytes
 .../_versions/18446744073709551613.manifest        | Bin 1257 -> 0 bytes
 .../_versions/18446744073709551614.manifest        | Bin 693 -> 0 bytes
 .../__manifest/_versions/latest_version_hint.json  |   2 +-
 ...1101100100100100a8e404e57ae0aa8a08e98d5ec.lance | Bin 0 -> 1667 bytes
 ...0101001100101009117684cbe99ac14edc8e6fcd2.lance | Bin 1456 -> 0 bytes
 .../auxiliary.idx                                  | Bin 0 -> 12017 bytes
 .../4231299b-169a-4694-b440-7fb7396b1188/index.idx | Bin 0 -> 592 bytes
 .../0-c618efe7-b762-45a3-9600-02e54c17cf87.txn     | Bin 0 -> 352 bytes
 .../1-050f2e5b-6487-4532-b15a-8200a82dc87b.txn     | Bin 0 -> 126 bytes
 .../2-8b309f5c-51bc-414c-98cf-dcc867178c25.txn     | Bin 0 -> 196 bytes
 .../_versions/18446744073709551612.manifest        | Bin 0 -> 873 bytes
 .../_versions/18446744073709551613.manifest        | Bin 0 -> 644 bytes
 .../_versions/18446744073709551614.manifest        | Bin 0 -> 785 bytes
 ...1101111011010118622b246be93248675bfdaadfa.lance | Bin 0 -> 46994 bytes
 ...011100011000111e29e464c90b09bf330ca3b02c2.lance | Bin 0 -> 46994 bytes
 .../lance/test_lance_vector_search.out             |  61 ++--
 .../lance/test_lance_vector_search.groovy          | 109 ++++--
 31 files changed, 561 insertions(+), 94 deletions(-)

diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/lance/run07_create_vector_types.sql
 
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/lance/run07_create_vector_types.sql
deleted file mode 100644
index 6a6a99c572a..00000000000
--- 
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/lance/run07_create_vector_types.sql
+++ /dev/null
@@ -1,46 +0,0 @@
--- Dedicated fixture for Doris Lance vector_search() regression tests.
--- Two INSERT statements intentionally create multiple Lance fragments. The
--- companion create_vector_search_index.py builds the vector index because
--- lance-spark-bundle 0.4.0 does not expose vector index creation through SQL.
-CREATE NAMESPACE IF NOT EXISTS lance.doris;
-
-DROP TABLE IF EXISTS lance.doris.vector_search;
-
-CREATE TABLE lance.doris.vector_search (
-    row_id BIGINT NOT NULL,
-    category STRING NOT NULL,
-    label STRING NOT NULL,
-    embedding ARRAY<FLOAT> NOT NULL
-) USING lance
-TBLPROPERTIES ('embedding.arrow.fixed-size-list.size' = '4');
-
--- row_id 1 is the origin. For query [0, 0, 0, 0], the exact squared L2
--- distance of row_id n is 30 * (n - 1)^2.
-INSERT INTO lance.doris.vector_search
-SELECT
-    id + 1 AS row_id,
-    CASE WHEN id % 2 = 0 THEN 'even' ELSE 'odd' END AS category,
-    concat('item-', lpad(CAST(id + 1 AS STRING), 4, '0')) AS label,
-    array(
-        CAST(id AS FLOAT),
-        CAST(id * 2 AS FLOAT),
-        CAST(id * 3 AS FLOAT),
-        CAST(id * 4 AS FLOAT)
-    ) AS embedding
-FROM range(0, 512);
-
-INSERT INTO lance.doris.vector_search
-SELECT
-    id + 1 AS row_id,
-    CASE WHEN id % 2 = 0 THEN 'even' ELSE 'odd' END AS category,
-    concat('item-', lpad(CAST(id + 1 AS STRING), 4, '0')) AS label,
-    array(
-        CAST(id AS FLOAT),
-        CAST(id * 2 AS FLOAT),
-        CAST(id * 3 AS FLOAT),
-        CAST(id * 4 AS FLOAT)
-    ) AS embedding
-FROM range(512, 1024);
-
-SELECT count(*) AS row_count, min(row_id) AS min_id, max(row_id) AS max_id
-FROM lance.doris.vector_search;
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
 
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
new file mode 100644
index 00000000000..0d5a408d1b9
--- /dev/null
+++ 
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py
@@ -0,0 +1,398 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+"""Rebuild the preinstalled Lance Directory (V2) catalog fixture.
+
+This offline generator produces 
docker-compose/iceberg/scripts/preinstalled_data/lance,
+the fixture that the iceberg docker environment copies into MinIO at 
s3://warehouse/lance.
+It exists because lance-spark-bundle does not expose vector index creation 
through SQL, so
+indexed vector-search fixtures cannot be built by the Spark preinstall 
scripts. That gap is
+why the previous fixture had no vector index at all: 
run07_create_vector_types.sql created
+doris.vector_search and deferred index creation to a companion script that 
never existed, so
+every "indexed" query in test_lance_vector_search silently ran a flat KNN scan.
+
+The generated catalog contains:
+  - __manifest            Directory Namespace V2 manifest table (with its 
scalar indexes).
+  - all_types.lance       The pre-existing compatibility-mode root table, 
re-registered as-is.
+  - The `doris` namespace with one indexed vector table per ANN algorithm 
(hash-prefixed
+    directories), listed in VECTOR_TABLES below.
+
+Every vector table holds identical deterministic data: 1024 rows in two 
512-row fragments,
+16-dim Float32 `embedding` where embedding[j] = (row_id - 1) + j. For a query 
equal to the
+vector of row r, the exact squared L2 distance of row n is 16 * (n - r)^2, so 
head/tail
+queries have no distance ties. All values are integers below 2^11 and 
therefore exact in
+Float32.
+
+Environment: python3 with the exact pins in lance_fixture_requirements.txt. 
Index training
+(IVF kmeans) is not bit-reproducible across runs, so regenerating this fixture 
changes the
+binary output and requires regenerating the dependent regression .out files. 
Reproducible
+properties are asserted by the self-check below instead: logical metadata, the 
exact L2
+distance ladder the regression goldens encode, plan shape, and the 
partition-boundary
+discriminator. Indexed-vs-flat agreement is lossy for IVF_PQ, so it is only 
recorded.
+
+The self-check is the entire contract for a fixture whose bytes cannot be 
reproduced, so
+this script refuses to run under python -O, where assert statements are 
stripped.
+
+Usage:
+  python3 lance_build_preinstalled_catalog.py            # rebuild in place
+  python3 lance_build_preinstalled_catalog.py --check    # self-check the 
existing fixture
+"""
+
+import argparse
+import io
+import json
+import shutil
+import sys
+import tempfile
+from datetime import timedelta
+from pathlib import Path
+
+import lance
+import lance_namespace
+import pyarrow as pa
+import pyarrow.ipc as ipc
+from lance_namespace_urllib3_client.models import (
+    CreateNamespaceRequest,
+    CreateTableRequest,
+    DescribeTableRequest,
+    ListTablesRequest,
+    RegisterTableRequest,
+)
+
+DIM = 16
+ROWS = 1024
+FRAGMENT_ROWS = 512
+NUM_PARTITIONS = 4
+NAMESPACE = "doris"
+ALL_TYPES_DIR = "all_types.lance"
+MANIFEST_DIR = "__manifest"
+
+# 4-bit PQ keeps codebook training comfortable on 1024 rows. This only serves 
fixture
+# stability and is not a Doris compatibility statement about PQ parameters.
+PQ_BUILD_PARAMS = {"num_sub_vectors": 4, "num_bits": 4}
+
+# One table per ANN algorithm and element type, identical data, exactly one 
index named
+# embedding_<the table name without its vs_ prefix>. Naming is 
vs_<algorithm>_<element
+# type>, so one table is exactly one cell of the algorithm x element type 
matrix and a
+# missing combination is visible from the table list alone. The build loop and 
the
+# self-check are driven entirely by these specs; follow-up work for #66495 
adds the
+# remaining algorithms (IVF_FLAT, IVF_SQ, IVF_HNSW_*) and element types here.
+VECTOR_TABLES = {
+    "vs_ivf_pq_f32": {"index_type": "IVF_PQ", "params": PQ_BUILD_PARAMS},
+}
+
+# The head query is exactly row 1's vector; the tail query is row 1024's. Only 
endpoint
+# vectors are used so that 16 * (n - r)^2 never ties between two different 
rows n.
+HEAD_QUERY = [float(j) for j in range(DIM)]
+TAIL_QUERY = [float(ROWS - 1 + j) for j in range(DIM)]
+# On this collinear data IVF kmeans yields four contiguous row ranges, and row 
256 lands near
+# one of the internal edges - which side, and at exactly which row, changes 
every time the
+# index is retrained, so nothing here hardcodes it (--check prints the 
measured range). What
+# matters is only that part of row 256's true neighbourhood falls in an 
adjacent partition.
+# The regression suites query this row with nprobes=1 as their silent-fallback 
discriminator:
+# a real single-partition probe must miss those neighbours (result != flat), 
while a silent
+# flat fallback returns exactly the flat result - verified directly: on an 
unindexed copy of
+# this data Lance ignores nprobes entirely and returns the flat rows. The 
self-check pins
+# this property for every vector table, so regenerating the fixture with 
partition edges that
+# no longer split row 256's neighbourhood fails here instead of in the suites. 
Each table
+# trains its own IVF clustering, so this is checked per table.
+BOUNDARY_ROW = 256
+BOUNDARY_QUERY = [float(BOUNDARY_ROW - 1 + j) for j in range(DIM)]
+
+
+def make_fragment_table(row_offset_start: int, row_offset_end: int) -> 
pa.Table:
+    offsets = list(range(row_offset_start, row_offset_end))
+    embedding = pa.FixedSizeListArray.from_arrays(
+        pa.array(
+            [float(offset + j) for offset in offsets for j in range(DIM)],
+            type=pa.float32(),
+        ),
+        DIM,
+    )
+    table = pa.table(
+        {
+            "row_id": pa.array([offset + 1 for offset in offsets], 
type=pa.int64()),
+            "category": pa.array(
+                ["even" if offset % 2 == 0 else "odd" for offset in offsets]
+            ),
+            "label": pa.array([f"item-{offset + 1:04d}" for offset in 
offsets]),
+            "embedding": embedding,
+        }
+    )
+    # The Spark fixture this replaces declared every column NOT NULL, and its 
DESC golden is
+    # the only place in the Lance suites that records a non-nullable Lance 
column mapping to
+    # Doris 'No'. pyarrow defaults to nullable, so restate it to keep that 
coverage.
+    return table.cast(
+        pa.schema([pa.field(f.name, f.type, nullable=False) for f in 
table.schema])
+    )
+
+
+def index_name_of(table_name: str) -> str:
+    # vs_ivf_pq_f32 -> embedding_ivf_pq_f32
+    return "embedding_" + table_name.removeprefix("vs_")
+
+
+def create_vector_table(namespace, table_name: str) -> str:
+    first = make_fragment_table(0, FRAGMENT_ROWS)
+    buffer = io.BytesIO()
+    with ipc.new_stream(buffer, first.schema) as writer:
+        writer.write_table(first)
+    response = namespace.create_table(
+        CreateTableRequest(id=[NAMESPACE, table_name]), buffer.getvalue()
+    )
+    # Never predict the hashed storage path; always use the location the 
namespace returns.
+    location = response.location
+    lance.write_dataset(make_fragment_table(FRAGMENT_ROWS, ROWS), location, 
mode="append")
+    return location
+
+
+def compact_manifest(root: Path) -> None:
+    # Every namespace mutation above leaves a manifest fragment, index delta, 
and version
+    # behind. Fold them together so the committed fixture stays small and 
reviewable. Only
+    # the manifest is compacted: the vector tables must keep exactly two 
fragments.
+    manifest = lance.dataset(str(root / MANIFEST_DIR))
+    manifest.optimize.compact_files()
+    
manifest.optimize.optimize_indices(num_indices_to_merge=len(manifest.list_indices()))
+    manifest.cleanup_old_versions(older_than=timedelta(0), 
delete_unverified=True)
+    # cleanup_old_versions does not reclaim superseded index deltas; drop 
every index
+    # directory the resulting manifest version no longer references.
+    manifest = lance.dataset(str(root / MANIFEST_DIR))
+    referenced = {index["uuid"] for index in manifest.list_indices()}
+    for index_dir in (root / MANIFEST_DIR / "_indices").iterdir():
+        if index_dir.name not in referenced:
+            shutil.rmtree(index_dir)
+    # pylance 4.0.1 does not write the optional latest-version hint. Write it 
to keep the
+    # fixture shape identical to the previous one for every consuming reader.
+    hint = root / MANIFEST_DIR / "_versions" / "latest_version_hint.json"
+    hint.write_text(f'{{"version":{manifest.version}}}')
+
+
+def build(root: Path, all_types_source: Path) -> None:
+    shutil.copytree(all_types_source, root / ALL_TYPES_DIR)
+    namespace = lance_namespace.connect("dir", {"root": str(root)})
+    namespace.register_table(
+        RegisterTableRequest(id=["all_types"], location=ALL_TYPES_DIR)
+    )
+    namespace.create_namespace(CreateNamespaceRequest(id=[NAMESPACE]))
+    for table_name, spec in VECTOR_TABLES.items():
+        location = create_vector_table(namespace, table_name)
+        # DirectoryNamespace.create_table_index exists but raises 
UnsupportedOperationError,
+        # so open the physical dataset at the location the namespace returned 
and index it
+        # there. index_file_version V3 is what the Doris BE reads through 
lance-c; the
+        # default would produce an index the backend cannot open.
+        lance.dataset(location).create_index(
+            "embedding",
+            spec["index_type"],
+            name=index_name_of(table_name),
+            metric="L2",
+            num_partitions=NUM_PARTITIONS,
+            sample_rate=256,
+            index_file_version="V3",
+            **spec["params"],
+        )
+    compact_manifest(root)
+
+
+def topk(dataset, query, k: int, use_index: bool, **nearest_kwargs):
+    nearest = {"column": "embedding", "q": query, "k": k, "use_index": 
use_index}
+    if use_index:
+        nearest.setdefault("nprobes", NUM_PARTITIONS)
+    nearest.update(nearest_kwargs)
+    table = dataset.scanner(nearest=nearest).to_table()
+    return list(zip(table["row_id"].to_pylist(), 
table["_distance"].to_pylist()))
+
+
+def check_vector_dataset(name: str, location: str, index_type: str):
+    dataset = lance.dataset(location)
+    assert dataset.count_rows() == ROWS, f"{name}: expected {ROWS} rows"
+    fragments = dataset.get_fragments()
+    assert len(fragments) == 2, f"{name}: expected 2 fragments"
+    embedding_type = dataset.schema.field("embedding").type
+    assert pa.types.is_fixed_size_list(embedding_type), f"{name}: embedding 
type"
+    assert embedding_type.list_size == DIM, f"{name}: embedding dimension"
+    assert embedding_type.value_type == pa.float32(), f"{name}: embedding 
element type"
+    for field in dataset.schema:
+        assert not field.nullable, f"{name}: column {field.name} must be NOT 
NULL"
+
+    # The regression goldens are hand-checkable only because embedding[j] = 
(row_id-1)+j,
+    # which makes the exact squared L2 distance between rows r and n equal 
16*(n-r)^2. Every
+    # distance in the .out files and every comment in the suites encodes that 
ladder, so
+    # assert it against a flat scan rather than trusting the row/dimension 
counts above: a
+    # change to the data shape would otherwise leave the self-check green and 
surface only
+    # as an opaque golden diff after a full docker regression run.
+    ladder = [(row, 16.0 * step * step) for step, row in enumerate(range(1, 
11))]
+    assert topk(dataset, HEAD_QUERY, 10, use_index=False) == ladder, (
+        f"{name}: flat top-10 from row 1 is not the 16*(n-r)^2 ladder; the 
fixture data "
+        "shape changed and every dependent golden and comment is now stale"
+    )
+
+    indices = dataset.list_indices()
+    assert len(indices) == 1, f"{name}: expected exactly one index"
+    index = indices[0]
+    assert index["name"] == index_name_of(name), f"{name}: index name 
{index['name']}"
+    assert index["type"] == index_type, f"{name}: index type {index['type']}"
+    indexed_fragments = set(index["fragment_ids"])
+    all_fragments = {fragment.fragment_id for fragment in fragments}
+    assert indexed_fragments == all_fragments, f"{name}: index does not cover 
all fragments"
+
+    for query in (HEAD_QUERY, TAIL_QUERY):
+        indexed_plan = dataset.scanner(
+            nearest={"column": "embedding", "q": query, "k": 5, "nprobes": 
NUM_PARTITIONS}
+        ).explain_plan(True)
+        assert "ANNSubIndex" in indexed_plan, f"{name}: indexed plan lacks 
ANNSubIndex"
+        assert "ANNIvfPartition" in indexed_plan, f"{name}: plan lacks 
ANNIvfPartition"
+        flat_plan = dataset.scanner(
+            nearest={"column": "embedding", "q": query, "k": 5, "use_index": 
False}
+        ).explain_plan(True)
+        assert "ANNSubIndex" not in flat_plan, f"{name}: flat plan uses ANN"
+        assert "KNNVectorDistance" in flat_plan, f"{name}: flat plan lacks KNN 
node"
+    return dataset
+
+
+def check_lossy_results(name: str, dataset) -> None:
+    # IVF_PQ stores quantized codes, so agreement with flat search is an 
observed property
+    # of this frozen fixture and the pinned Lance version, never a guarantee. 
The
+    # regression suite therefore queries it with refine_factor, which reranks 
candidates
+    # with exact distances; record what that suite will observe.
+    raw = topk(dataset, HEAD_QUERY, 5, use_index=True)
+    flat = topk(dataset, HEAD_QUERY, 5, use_index=False)
+    assert len(raw) == 5, f"{name}: indexed search must return k rows"
+    raw_agreement = "matches" if raw == flat else "differs from"
+    print(f"record: {name} full-probe top-5 {raw_agreement} flat search: 
{raw}")
+    refined = topk(dataset, HEAD_QUERY, 5, use_index=True, refine_factor=10)
+    refined_agreement = "matches" if refined == flat else "differs from"
+    print(f"record: {name} refined top-5 {refined_agreement} flat search: 
{refined}")
+
+
+def check_boundary_discriminator(name: str, dataset) -> None:
+    # See BOUNDARY_ROW above. The lossy index uses refine_factor so the 
comparison against
+    # flat runs on exact distances, exactly like the regression suite does.
+    single_rows = topk(
+        dataset, BOUNDARY_QUERY, 10, use_index=True, nprobes=1, 
refine_factor=10)
+    flat_rows = topk(dataset, BOUNDARY_QUERY, 10, use_index=False)
+    # Compare distances, not row ids: the boundary query is symmetric, so rows 
r-d and r+d
+    # tie and either may fill the last slot. Only a missed neighbour changes 
the distances.
+    single = [distance for _, distance in single_rows]
+    flat = [distance for _, distance in flat_rows]
+    assert len(single) == 10, f"{name}: boundary nprobes=1 must still return k 
rows"
+    assert single != flat, (
+        f"{name}: row {BOUNDARY_ROW} no longer discriminates nprobes=1 from 
flat search; "
+        "the IVF partition boundaries moved. Update BOUNDARY_ROW here and the 
boundary "
+        "queries in the regression suites together."
+    )
+    print(f"record: {name} boundary nprobes=1 top-10 rows: "
+          f"{[row for row, _ in single_rows]}")
+    # The partition edge moves on every retrain, so report where it actually 
landed. This is
+    # the first thing to look at when a boundary golden shifts or this check 
starts failing.
+    probed = sorted(row for row, _ in topk(
+        dataset, BOUNDARY_QUERY, ROWS, use_index=True, nprobes=1, 
refine_factor=1))
+    contiguous = probed == list(range(probed[0], probed[-1] + 1))
+    print(f"record: {name} partition holding row {BOUNDARY_ROW}: rows "
+          f"{probed[0]}-{probed[-1]} ({len(probed)} rows, 
contiguous={contiguous})")
+
+
+def check_catalog(root: Path) -> None:
+    namespace = lance_namespace.connect("dir", {"root": str(root)})
+    tables = namespace.list_tables(ListTablesRequest(id=[NAMESPACE]))
+    assert sorted(tables.tables) == sorted(VECTOR_TABLES), (
+        f"unexpected {NAMESPACE} tables: {tables.tables}"
+    )
+    root_tables = namespace.list_tables(ListTablesRequest(id=[]))
+    assert "all_types" in root_tables.tables, "all_types is not registered at 
the root"
+    all_types = 
namespace.describe_table(DescribeTableRequest(id=["all_types"]))
+    all_types_path = Path(all_types.location.removeprefix("file://"))
+    assert all_types_path.is_dir(), f"all_types location missing: 
{all_types.location}"
+    # all_types.lance is copied through verbatim and cannot be regenerated by 
this script, so
+    # open it rather than only stat it: the rebuild path below deletes the 
previous fixture,
+    # which is its only copy. 12 rows is what test_lance_catalog_all_types.out 
records.
+    assert lance.dataset(str(all_types_path)).count_rows() == 12, (
+        "all_types.lance did not survive the copy intact"
+    )
+
+    manifest = lance.dataset(str(root / MANIFEST_DIR))
+    manifest_indices = {index["name"] for index in manifest.list_indices()}
+    for required in ("object_id_btree", "object_type_bitmap", 
"base_objects_label_list"):
+        assert required in manifest_indices, f"__manifest lacks index 
{required}"
+    hint = json.loads(
+        (root / MANIFEST_DIR / "_versions" / 
"latest_version_hint.json").read_text()
+    )
+    assert hint["version"] == manifest.version, (
+        f"latest_version_hint {hint['version']} does not match manifest 
version "
+        f"{manifest.version}"
+    )
+
+    for table_name, spec in VECTOR_TABLES.items():
+        described = namespace.describe_table(
+            DescribeTableRequest(id=[NAMESPACE, table_name])
+        )
+        path = Path(described.location.removeprefix("file://"))
+        assert path.is_dir(), f"{table_name} location missing: 
{described.location}"
+        dataset = check_vector_dataset(table_name, described.location, 
spec["index_type"])
+        check_lossy_results(table_name, dataset)
+        check_boundary_discriminator(table_name, dataset)
+    print(f"self-check OK: {root}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--output",
+        type=Path,
+        default=Path(__file__).resolve().parent / "preinstalled_data" / 
"lance",
+        help="fixture directory to rebuild (default: 
scripts/preinstalled_data/lance)",
+    )
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="only run the self-check against the existing fixture",
+    )
+    args = parser.parse_args()
+    output: Path = args.output
+
+    # Every verification in this script is an assert, and the self-check is 
the whole
+    # contract for a fixture whose bytes are not reproducible. Under -O the 
rebuild
+    # below would replace the committed fixture having verified nothing at all.
+    if not __debug__:
+        print("refusing to run with assertions disabled (python -O)", 
file=sys.stderr)
+        return 1
+
+    if args.check:
+        check_catalog(output)
+        return 0
+
+    all_types_source = output / ALL_TYPES_DIR
+    if not all_types_source.is_dir():
+        print(f"missing all_types source: {all_types_source}", file=sys.stderr)
+        return 1
+
+    with tempfile.TemporaryDirectory(prefix="lance_fixture_") as staging_name:
+        staging = Path(staging_name) / "lance"
+        staging.mkdir()
+        build(staging, all_types_source)
+        check_catalog(staging)
+        backup = output.with_name(output.name + ".old")
+        if backup.exists():
+            shutil.rmtree(backup)
+        output.rename(backup)
+        shutil.move(str(staging), str(output))
+        shutil.rmtree(backup)
+    check_catalog(output)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/lance_fixture_requirements.txt
 
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_fixture_requirements.txt
new file mode 100644
index 00000000000..58c29376cc1
--- /dev/null
+++ 
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_fixture_requirements.txt
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+# Exact pins for lance_build_preinstalled_catalog.py.
+#
+# The fixture has several readers and they do not all run the same Lance 
version:
+#
+#   lance-c v0.1.2 (lance-rs 4.0.1)       BE built from source - 
thirdparty/vars.sh
+#   lance-c v0.1.6 (lance-rs 7.0.0-beta)  BE in CI - the prebuilt 
doris-thirdparty package
+#   lance-java 4.0.0                      Spark, via lance-spark-bundle 0.4.0; 
it registers
+#                                         runtime tables into the same 
__manifest
+#   lance-java (FE)                       Doris FE Directory Namespace client
+#
+# So pin the writer to the oldest Lance in that set rather than to whichever 
one the BE
+# happens to use: an older writer is readable by every reader above, while a 
newer one
+# would leave the source-build and Spark cells unverified. Verified for the 
committed
+# fixture: pylance 7.0.0 reads it with results identical to pylance 4.0.1 - 
same index,
+# same refined top-5, same nprobes=1 boundary rows, same IVF partition ranges 
- so the
+# regression goldens are stable across the lance-rs 4 to 7 gap and do not 
depend on which
+# lance-c the backend was built with.
+pylance==4.0.1
+lance-namespace==0.6.1
+pyarrow==25.0.0
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/1f436c06-a392-4c38-af67-d10bef96f82f/bitmap_page_lookup.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/1f436c06-a392-4c38-af67-d10bef96f82f/bitmap_page_lookup.lance
new file mode 100644
index 00000000000..950f84fb016
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/1f436c06-a392-4c38-af67-d10bef96f82f/bitmap_page_lookup.lance
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6ac57aba-0a7a-4303-ba43-c61610e205b9/page_data.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6ac57aba-0a7a-4303-ba43-c61610e205b9/page_data.lance
new file mode 100644
index 00000000000..a28abb8fd6b
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6ac57aba-0a7a-4303-ba43-c61610e205b9/page_data.lance
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6ac57aba-0a7a-4303-ba43-c61610e205b9/page_lookup.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6ac57aba-0a7a-4303-ba43-c61610e205b9/page_lookup.lance
new file mode 100644
index 00000000000..c9e326171ed
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6ac57aba-0a7a-4303-ba43-c61610e205b9/page_lookup.lance
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/859ed2a5-7560-4701-a5be-e93d512df107/page_data.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/859ed2a5-7560-4701-a5be-e93d512df107/page_data.lance
deleted file mode 100644
index c4501c74cc3..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/859ed2a5-7560-4701-a5be-e93d512df107/page_data.lance
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/859ed2a5-7560-4701-a5be-e93d512df107/page_lookup.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/859ed2a5-7560-4701-a5be-e93d512df107/page_lookup.lance
deleted file mode 100644
index 12e258c1a40..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/859ed2a5-7560-4701-a5be-e93d512df107/page_lookup.lance
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/a72dde56-017f-4679-a8c8-f2898fa14c02/bitmap_page_lookup.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/a72dde56-017f-4679-a8c8-f2898fa14c02/bitmap_page_lookup.lance
deleted file mode 100644
index 1f85d1ccab3..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/a72dde56-017f-4679-a8c8-f2898fa14c02/bitmap_page_lookup.lance
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/be88a0b2-a11c-4537-b8eb-120ad3c24380/bitmap_page_lookup.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/be88a0b2-a11c-4537-b8eb-120ad3c24380/bitmap_page_lookup.lance
new file mode 100644
index 00000000000..46d15e405e1
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/be88a0b2-a11c-4537-b8eb-120ad3c24380/bitmap_page_lookup.lance
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/cd4c2b88-a91b-4ad4-9957-c98473f1e258/bitmap_page_lookup.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/cd4c2b88-a91b-4ad4-9957-c98473f1e258/bitmap_page_lookup.lance
deleted file mode 100644
index 2cf940fadf8..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/cd4c2b88-a91b-4ad4-9957-c98473f1e258/bitmap_page_lookup.lance
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/0-aabf9667-c2e9-49d9-a16d-59e616c77195.txn
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/0-aabf9667-c2e9-49d9-a16d-59e616c77195.txn
deleted file mode 100644
index 514d2181575..00000000000
--- 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/0-aabf9667-c2e9-49d9-a16d-59e616c77195.txn
+++ /dev/null
@@ -1,2 +0,0 @@
-$aabf9667-c2e9-49d9-a16d-59e616c77195��U  object_id ���������*string8R1
-,lance-schema:unenforced-primary-key:position0`$object_type 
���������*string8#location ���������*string08#metadata 
���������*string08%base_objects ���������*list08   object_id 
*string08
\ No newline at end of file
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/14-2b2b0055-53cb-4e2e-a2d8-c17bb410ce79.txn
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/14-2b2b0055-53cb-4e2e-a2d8-c17bb410ce79.txn
new file mode 100644
index 00000000000..e6e6a29a6ef
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/14-2b2b0055-53cb-4e2e-a2d8-c17bb410ce79.txn
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551600.manifest
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551600.manifest
new file mode 100644
index 00000000000..bb11a8c6aa1
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551600.manifest
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551613.manifest
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551613.manifest
deleted file mode 100644
index 021e641ad20..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551613.manifest
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551614.manifest
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551614.manifest
deleted file mode 100644
index e5a5389182f..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551614.manifest
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json
index 218abba1699..e64e1a4a8d6 100644
--- 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json
+++ 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json
@@ -1 +1 @@
-{"version":2}
\ No newline at end of file
+{"version":15}
\ No newline at end of file
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/1011101111101100100100100a8e404e57ae0aa8a08e98d5ec.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/1011101111101100100100100a8e404e57ae0aa8a08e98d5ec.lance
new file mode 100644
index 00000000000..eb5fe14bc4d
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/1011101111101100100100100a8e404e57ae0aa8a08e98d5ec.lance
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/1111110000101001100101009117684cbe99ac14edc8e6fcd2.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/1111110000101001100101009117684cbe99ac14edc8e6fcd2.lance
deleted file mode 100644
index e223dd4c0a4..00000000000
Binary files 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/1111110000101001100101009117684cbe99ac14edc8e6fcd2.lance
 and /dev/null differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_indices/4231299b-169a-4694-b440-7fb7396b1188/auxiliary.idx
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_indices/4231299b-169a-4694-b440-7fb7396b1188/auxiliary.idx
new file mode 100644
index 00000000000..ceadc9bfb3f
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_indices/4231299b-169a-4694-b440-7fb7396b1188/auxiliary.idx
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_indices/4231299b-169a-4694-b440-7fb7396b1188/index.idx
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_indices/4231299b-169a-4694-b440-7fb7396b1188/index.idx
new file mode 100644
index 00000000000..034746fb2a5
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_indices/4231299b-169a-4694-b440-7fb7396b1188/index.idx
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/0-c618efe7-b762-45a3-9600-02e54c17cf87.txn
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/0-c618efe7-b762-45a3-9600-02e54c17cf87.txn
new file mode 100644
index 00000000000..e88a51dc6af
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/0-c618efe7-b762-45a3-9600-02e54c17cf87.txn
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/1-050f2e5b-6487-4532-b15a-8200a82dc87b.txn
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/1-050f2e5b-6487-4532-b15a-8200a82dc87b.txn
new file mode 100644
index 00000000000..2f70cfa4346
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/1-050f2e5b-6487-4532-b15a-8200a82dc87b.txn
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/2-8b309f5c-51bc-414c-98cf-dcc867178c25.txn
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/2-8b309f5c-51bc-414c-98cf-dcc867178c25.txn
new file mode 100644
index 00000000000..dc51913682e
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_transactions/2-8b309f5c-51bc-414c-98cf-dcc867178c25.txn
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551612.manifest
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551612.manifest
new file mode 100644
index 00000000000..06e94bcc45d
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551612.manifest
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551613.manifest
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551613.manifest
new file mode 100644
index 00000000000..1206a247848
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551613.manifest
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551614.manifest
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551614.manifest
new file mode 100644
index 00000000000..1adf04ca4f5
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/_versions/18446744073709551614.manifest
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/data/1110011011101111011010118622b246be93248675bfdaadfa.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/data/1110011011101111011010118622b246be93248675bfdaadfa.lance
new file mode 100644
index 00000000000..116f0b1c496
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/data/1110011011101111011010118622b246be93248675bfdaadfa.lance
 differ
diff --git 
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/data/111110100011100011000111e29e464c90b09bf330ca3b02c2.lance
 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/data/111110100011100011000111e29e464c90b09bf330ca3b02c2.lance
new file mode 100644
index 00000000000..41bf64a968c
Binary files /dev/null and 
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/c325fd55_doris$vs_ivf_pq_f32/data/111110100011100011000111e29e464c90b09bf330ca3b02c2.lance
 differ
diff --git 
a/regression-test/data/external_table_p0/lance/test_lance_vector_search.out 
b/regression-test/data/external_table_p0/lance/test_lance_vector_search.out
index 1f4c38cdff7..0a98ba5b3a7 100644
--- a/regression-test/data/external_table_p0/lance/test_lance_vector_search.out
+++ b/regression-test/data/external_table_p0/lance/test_lance_vector_search.out
@@ -10,39 +10,54 @@ row_id      bigint  No      false   \N
 
 -- !indexed_l2_topk --
 1      item-0001       0.0
-2      item-0002       30.0
-3      item-0003       120.0
-4      item-0004       270.0
-5      item-0005       480.0
+2      item-0002       16.0
+3      item-0003       64.0
+4      item-0004       144.0
+5      item-0005       256.0
 
 -- !flat_l2_topk --
 1      item-0001       0.0
-2      item-0002       30.0
-3      item-0003       120.0
-4      item-0004       270.0
-5      item-0005       480.0
+2      item-0002       16.0
+3      item-0003       64.0
+4      item-0004       144.0
+5      item-0005       256.0
 
 -- !indexed_tail --
 1024   item-1024       0.0
-1023   item-1023       30.0
-1022   item-1022       120.0
+1023   item-1023       16.0
+1022   item-1022       64.0
 
 -- !indexed_offset --
-2      item-0002       30.0
-3      item-0003       120.0
+2      item-0002       16.0
+3      item-0003       64.0
 
 -- !indexed_pre_search_filter --
-2      odd     30.0
-4      odd     270.0
-6      odd     750.0
+2      odd     16.0
+4      odd     144.0
+6      odd     400.0
 
 -- !post_search_filter --
-2      odd     30.0
-
--- !pruned_vector_column --
-1      item-0001       0.0
-2      item-0002       30.0
-3      item-0003       120.0
-4      item-0004       270.0
-5      item-0005       480.0
+2      odd     16.0
+
+-- !boundary_single_probe --
+256    item-0256       0.0
+255    item-0255       16.0
+257    item-0257       16.0
+254    item-0254       64.0
+258    item-0258       64.0
+253    item-0253       144.0
+259    item-0259       144.0
+252    item-0252       256.0
+251    item-0251       400.0
+
+-- !boundary_flat --
+256    item-0256       0.0
+255    item-0255       16.0
+257    item-0257       16.0
+254    item-0254       64.0
+258    item-0258       64.0
+253    item-0253       144.0
+259    item-0259       144.0
+252    item-0252       256.0
+260    item-0260       256.0
 
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 5858c0d4a04..bde006738be 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
@@ -59,6 +59,19 @@ suite("test_lance_vector_search", "p0,external") {
      * Doris after Lance returns Top-K and can therefore reduce the final 
result below top_k.
      * The current implementation pins one Lance dataset version and searches 
its entire snapshot
      * with one scanner. Multi-scanner search plus global Top-K merging 
remains future work.
+     *
+     * Fixture: doris.vs_ivf_pq_f32 is generated offline by
+     * 
docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py.
+     * It holds 1024 rows in two fragments; embedding[j] = (row_id - 1) + j 
with dimension 16,
+     * covered by a real IVF_PQ index (4 partitions, 4-bit PQ) whose creation 
is verified by the
+     * generator self-check. For a query equal to the vector of row r, the 
exact squared L2
+     * distance of row n is 16 * (n - r)^2.
+     *
+     * IVF_PQ is lossy: raw PQ distances are approximations, so every indexed 
query here uses
+     * refine_factor to rerank candidates with exact distances. The agreement 
between indexed and
+     * flat results below is an observed property of this frozen fixture and 
the pinned Lance
+     * version, not an IVF_PQ algorithm guarantee. The remaining algorithms 
(IVF_FLAT, IVF_SQ,
+     * IVF_HNSW_*) and the other vector element types are follow-up work for 
#66495.
      */
     String enabled = context.config.otherConfigs.get("enableIcebergTest")
     if (enabled == null || !enabled.equalsIgnoreCase("true")) {
@@ -69,13 +82,21 @@ suite("test_lance_vector_search", "p0,external") {
     String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
     String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
     String catalogName = "test_lance_vector_search"
-    String tableName = "${catalogName}.doris.vector_search"
-    String indexedTopFive = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0,0]", "top_k"="5", "metric"="l2", 
"nprobes"="4", "refine_factor"="10", "use_index"="true")"""
-    String flatTopFive = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0,0]", "top_k"="5", "metric"="l2", 
"use_index"="false")"""
-    String indexedTopTwo = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0,0]", "top_k"="2", "metric"="l2", 
"nprobes"="4", "refine_factor"="10", "use_index"="true")"""
-    String indexedOffset = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0,0]", "top_k"="2", "offset"="1", 
"metric"="l2", "nprobes"="4", "refine_factor"="10", "use_index"="true")"""
-    String indexedPrefilter = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0,0]", "top_k"="3", 
"filter"="category = 'odd'", "metric"="l2", "nprobes"="4", 
"refine_factor"="10", "use_index"="true")"""
-    String indexedTail = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[1023,2046,3069,4092]", "top_k"="3", 
"metric"="l2", "nprobes"="4", "refine_factor"="10", "use_index"="true")"""
+    String tableName = "${catalogName}.doris.vs_ivf_pq_f32"
+    // headQuery is exactly row 1's vector and tailQuery is row 1024's, so 
distances are the
+    // deterministic ladder 0, 16, 64, 144, ... with no ties.
+    String headQuery = "[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]"
+    String tailQuery = 
"[1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038]"
+    // boundaryQuery is row 256's vector. On this frozen index row 256 lies 
next to an IVF
+    // partition edge, so part of its true neighbourhood sits in an adjacent 
partition
+    // (pinned by the generator self-check); see the discriminator block below.
+    String boundaryQuery = 
"[255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270]"
+    String indexedTopFive = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${headQuery}", "top_k"="5", 
"metric"="l2", "nprobes"="4", "refine_factor"="10", "use_index"="true")"""
+    String flatTopFive = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${headQuery}", "top_k"="5", 
"metric"="l2", "use_index"="false")"""
+    String indexedTopTwo = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${headQuery}", "top_k"="2", 
"metric"="l2", "nprobes"="4", "refine_factor"="10", "use_index"="true")"""
+    String indexedOffset = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${headQuery}", "top_k"="2", "offset"="1", 
"metric"="l2", "nprobes"="4", "refine_factor"="10", "use_index"="true")"""
+    String indexedPrefilter = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${headQuery}", "top_k"="3", 
"filter"="category = 'odd'", "metric"="l2", "nprobes"="4", 
"refine_factor"="10", "use_index"="true")"""
+    String indexedTail = """vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${tailQuery}", "top_k"="3", 
"metric"="l2", "nprobes"="4", "refine_factor"="10", "use_index"="true")"""
 
     sql """DROP CATALOG IF EXISTS `${catalogName}`"""
     try {
@@ -93,12 +114,16 @@ suite("test_lance_vector_search", "p0,external") {
         """
         sql """SET enable_file_scanner_v2 = true"""
 
-        order_qt_vector_table_desc """DESC 
`${catalogName}`.`doris`.`vector_search`"""
+        order_qt_vector_table_desc """DESC 
`${catalogName}`.`doris`.`vs_ivf_pq_f32`"""
         qt_vector_table_rows """
             SELECT count(*), count(DISTINCT row_id), min(row_id), max(row_id)
-            FROM `${catalogName}`.`doris`.`vector_search`
+            FROM `${catalogName}`.`doris`.`vs_ivf_pq_f32`
         """
 
+        // EXPLAIN asserts the logical search parameters Doris sends to the 
backend. It does not
+        // prove which physical Lance index served the query; that proof lives 
in the fixture
+        // generator's plan self-check plus the nprobes=1 discriminator below, 
until lance-c
+        // exposes the selected index at runtime.
         explain {
             sql("""SELECT row_id, label, _distance FROM ${indexedTopFive} 
ORDER BY _distance, row_id""")
             contains "externalSearchType=VECTOR"
@@ -107,19 +132,19 @@ suite("test_lance_vector_search", "p0,external") {
             contains "lanceOffset=0"
             contains "lanceMetric=l2"
             contains "lanceSearchScanners=1"
-            notContains "[0,0,0,0]"
+            // The raw query vector must not be echoed into the plan output.
+            notContains "[0,1,2,3"
         }
 
-        // The fixture has an IVF_PQ index over embedding. nprobes covers all
-        // four IVF partitions, and refine_factor reranks physical candidates.
+        // nprobes=4 covers all four IVF partitions and refine_factor reranks 
candidates with
+        // exact distances. On this frozen fixture the result equals the flat 
search.
         qt_indexed_l2_topk """
             SELECT row_id, label, _distance
             FROM ${indexedTopFive}
             ORDER BY _distance, row_id
         """
 
-        // Disable the index explicitly. Indexed and flat search must agree for
-        // these deterministic nearest rows.
+        // Disable the index explicitly for the exact flat baseline.
         qt_flat_l2_topk """
             SELECT row_id, label, _distance
             FROM ${flatTopFive}
@@ -142,16 +167,16 @@ suite("test_lance_vector_search", "p0,external") {
             ORDER BY _distance, row_id
         """
 
-        // The TVF filter is evaluated before Lance chooses Top-K. The nearest
-        // eligible rows are row_id 2, 4 and 6.
+        // The TVF filter is evaluated before Lance chooses Top-K. 
Odd-category rows are the
+        // even row ids, so the nearest eligible rows are row_id 2, 4 and 6.
         qt_indexed_pre_search_filter """
             SELECT row_id, category, _distance
             FROM ${indexedPrefilter}
             ORDER BY _distance, row_id
         """
 
-        // An outer WHERE remains a Doris post-search predicate. Search first
-        // selects row_id 1 and 2; filtering for odd retains only row_id 2.
+        // An outer WHERE remains a Doris post-search predicate. Search first 
selects row_id 1
+        // and 2; filtering for odd retains only row_id 2.
         qt_post_search_filter """
             SELECT row_id, category, _distance
             FROM ${indexedTopTwo}
@@ -159,20 +184,60 @@ suite("test_lance_vector_search", "p0,external") {
             ORDER BY _distance, row_id
         """
 
-        // Lance can search embedding even when Doris does not project it.
-        qt_pruned_vector_column """
+        // Silent-fallback discriminator. Row 256's true nearest neighbours 
straddle an IVF
+        // partition edge, so a genuine single-partition probe must miss the 
ones on the far
+        // side and differ from flat search even after exact reranking. A 
pipeline that ignores use_index/nprobes and silently scans
+        // flat fails this assertion: on an unindexed table Lance ignores 
nprobes and
+        // returns exactly the flat rows.
+        // top_k is 9, not 10: distances here come in symmetric pairs (rows 
256-d and
+        // 256+d tie), and 9 is the last cut that lands on a complete pair. At 
10 the tie
+        // group at distance 400 (rows 251 and 261) is split and only one of 
them fits, so
+        // the golden would pin an arbitrary choice and could flip on any 
change to Lance's
+        // top-k selection.
+        def boundarySingleProbe = sql """
+            SELECT row_id, _distance
+            FROM vector_search("table"="${tableName}", "column"="embedding", 
"query_vector"="${boundaryQuery}", "top_k"="9", "metric"="l2", "nprobes"="1", 
"refine_factor"="10", "use_index"="true")
+            ORDER BY _distance, row_id
+        """
+        def boundaryFlat = sql """
+            SELECT row_id, _distance
+            FROM vector_search("table"="${tableName}", "column"="embedding", 
"query_vector"="${boundaryQuery}", "top_k"="9", "metric"="l2", 
"use_index"="false")
+            ORDER BY _distance, row_id
+        """
+        assertEquals(9, boundarySingleProbe.size())
+        assertEquals(9, boundaryFlat.size())
+        // Compare distance sequences rather than row ids: the boundary query 
is symmetric, so
+        // rows r-d and r+d tie at the same distance and either may fill the 
last slot. Only a
+        // genuinely missed neighbour changes the distances.
+        def singleProbeDistances = boundarySingleProbe.collect { it[1] }
+        def flatDistances = boundaryFlat.collect { it[1] }
+        assertFalse(singleProbeDistances.equals(flatDistances),
+                "nprobes=1 produced the same distance sequence as the flat 
search, so the "
+                + "single-partition restriction had no effect: the IVF_PQ 
index was not used "
+                + "(silent flat fallback or ignored nprobes). "
+                + "nprobes=1 distances=" + singleProbeDistances + " flat=" + 
flatDistances)
+
+        qt_boundary_single_probe """
             SELECT row_id, label, _distance
-            FROM ${indexedTopFive}
+            FROM vector_search("table"="${tableName}", "column"="embedding", 
"query_vector"="${boundaryQuery}", "top_k"="9", "metric"="l2", "nprobes"="1", 
"refine_factor"="10", "use_index"="true")
+            ORDER BY _distance, row_id
+        """
+        qt_boundary_flat """
+            SELECT row_id, label, _distance
+            FROM vector_search("table"="${tableName}", "column"="embedding", 
"query_vector"="${boundaryQuery}", "top_k"="9", "metric"="l2", 
"use_index"="false")
             ORDER BY _distance, row_id
         """
 
+        // Note on vector-column pruning: no query in this suite projects 
embedding, so Lance
+        // searching an unprojected vector column is exercised by every block 
above.
+
         test {
             sql("""SELECT row_id FROM vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0]", "top_k"="1")""")
             exception "dimension"
         }
 
         test {
-            sql("""SELECT row_id FROM vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="[0,0,0,0]", "top_k"="0")""")
+            sql("""SELECT row_id FROM vector_search("table"="${tableName}", 
"column"="embedding", "query_vector"="${headQuery}", "top_k"="0")""")
             exception "top_k"
         }
     } finally {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to