blob: 07f2befd7e08565ecb50c1c801f4a47b99146490 [file]
# 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.
import sys
import types
from unittest.mock import patch
import pyarrow as pa
import pytest
import pypaimon.multimodal as pm
from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
from pypaimon.globalindex.vindex_options import (
VINDEX_IDENTIFIERS,
)
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.tests.global_index_build_test import _FakeVectorIndexTrainer, _FakeVectorIndexWriter
SCHEMA = pa.schema([('id', pa.int64()), ('embedding', pa.list_(pa.float32(), 2)), ('category', pa.string())])
def append(table, ids, null_vector=False, category='yes'):
# Older Parquet writers cannot encode NULL fixed-size lists.
schema = SCHEMA.set(1, pa.field('embedding', pa.list_(pa.float32()))) if null_vector else SCHEMA
table.add(pa.table({'id': ids, 'embedding': [None if null_vector and i == 3 else [float(i), 1.] for i in ids],
'category': [category] * len(ids)}, schema=schema))
def create_table(tmp_path, null_vector=False):
schema = SCHEMA.set(1, pa.field('embedding', pa.list_(pa.float32()))) if null_vector else SCHEMA
table = pm.connect(options={'warehouse': str(tmp_path)}).create_table(
'vectors', schema=schema, options={
'file.format': 'parquet', 'vector.file.format': 'parquet', 'read.batch-size': '2',
'deletion-vectors.enabled': 'true',
'global-index.row-count-per-shard': '4', 'global-index.build.parallelism': '1'})
append(table, list(range(7)), null_vector)
return table
@pytest.fixture
def table(tmp_path):
return create_table(tmp_path)
def entries(table):
return sorted((e.index_file for e in IndexFileHandler(table.raw_table).scan(
table.raw_table.snapshot_manager().get_latest_snapshot()) if e.index_file.index_type in VINDEX_IDENTIFIERS),
key=lambda f: f.global_index_meta.row_range_start)
def build(table, metric='l2'):
pytest.importorskip('paimon_vindex')
return table.create_index('embedding', 'ivf-flat', options={
'ivf-flat.dimension': '2', 'ivf-flat.nlist': '1', 'ivf-flat.distance.metric': metric})
def search(table, batch=False, refine=False, **kwargs):
options = {'ivf.nprobe': '1'}
if refine:
options['refine_factor'] = '2'
query = (table.search_vectors([[1., 1.], [1., 1.]], column='embedding', options=options, **kwargs) if batch
else table.search([1., 1.], column='embedding', options=options, **kwargs))
return query.select(['id']).limit(3).to_list()
@pytest.mark.parametrize('index_type', VINDEX_IDENTIFIERS)
@pytest.mark.parametrize('all_deleted', [False, True])
def test_build_preserves_physical_ids_after_deletion(tmp_path, index_type, all_deleted):
table = create_table(tmp_path, null_vector=True)
# Overlapping column files must not duplicate IDs or re-add deleted rows.
table.update('id >= 0', {'category': 'changed'})
table.delete('id >= 0' if all_deleted else 'id = 0 OR id = 1 OR id = 6')
fake = types.SimpleNamespace(VectorIndexTrainer=_FakeVectorIndexTrainer, VectorIndexWriter=_FakeVectorIndexWriter)
with patch.dict(sys.modules, {'paimon_vindex': fake}), patch.object(_FakeVectorIndexWriter, 'instances', []):
assert table.create_index('embedding', index_type, options={index_type + '.dimension': '2'}) == 2
writers = _FakeVectorIndexWriter.instances
# NULL row 3 is counted in coverage but omitted from the native vectors.
assert [w.added_ids for w in writers] == [[0, 1, 2], [0, 1, 2]]
assert [w.added_vectors for w in writers] == [
[[0., 1.], [1., 1.], [2., 1.]], [[4., 1.], [5., 1.], [6., 1.]]]
assert [(f.global_index_meta.row_range_start, f.global_index_meta.row_range_end, f.row_count)
for f in entries(table)] == [(0, 3, 4), (4, 6, 3)]
assert table.create_index('embedding', index_type) == 0
@pytest.mark.parametrize('metric', ['l2', 'cosine', 'inner_product'])
@pytest.mark.parametrize('refine', [False, True])
def test_native_search_excludes_deletions_before_and_after_build(table, metric, refine):
table.delete('id = 1 OR id = 6')
assert build(table, metric) == 2
table.delete('id = 0')
table.create_index('category', 'bitmap')
expected = [{'id': i} for i in ((3, 4, 5) if metric == 'inner_product' else (2, 3, 4))]
for batch in (False, True):
assert search(table, batch, refine, pre_filter="category = 'yes'") == (
[expected, expected] if batch else expected)
table.delete('id >= 0')
assert search(table) == []
assert search(table, batch=True) == [[], []]
assert build(table, metric) == 0
@pytest.mark.python_plan
@pytest.mark.python_write
@pytest.mark.python_commit
@pytest.mark.parametrize('batch', [False, True])
def test_python_ivfpq_search_keeps_live_null_vectors_out_of_results(tmp_path, batch):
pytest.importorskip('paimon_vindex')
schema = pa.schema([('id', pa.int64()), ('embedding', pa.list_(pa.float32()))])
table = pm.connect(options={'warehouse': str(tmp_path)}).create_table(
'vectors', schema=schema, options={
'file.format': 'parquet', 'vector.file.format': 'parquet', 'vector-index.search-mode': 'fast',
'deletion-vectors.enabled': 'true'})
table.add(pa.table({
'id': list(range(5)), 'embedding': [[1., 0.], [0., 1.], None, [1., 1.], [2., 2.]]}, schema=schema))
table.delete('id = 0 OR id = 3')
assert table.create_index('embedding', 'ivf-pq', options={
'ivf-pq.dimension': '2', 'ivf-pq.nlist': '1', 'ivf-pq.pq.m': '1',
'ivf-pq.distance.metric': 'l2'}) == 1
raw_table = table.raw_table
query = (raw_table.new_batch_vector_search_builder().with_query_vectors([[1., 1.], [1., 1.]]) if batch
else raw_table.new_vector_search_builder().with_query_vector([1., 1.]))
query.with_vector_column('embedding').with_limit(64).with_option('ivf.nprobe', '1')
results = query.execute_batch_local() if batch else [query.execute_local()]
assert len(results) == (2 if batch else 1)
for result in results:
assert sorted(result.results()) == [1, 4]
def test_native_build_and_query_keep_snapshot_semantics(table):
table.delete('id = 1 OR id = 6')
assert build(table) == 2
saved = table.raw_table.snapshot_manager().get_latest_snapshot().id
builder = (table.raw_table.new_vector_search_builder().with_vector_column('embedding')
.with_query_vector([0., 1.]).with_limit(1).with_option('ivf.nprobe', '1'))
plan = builder.new_vector_search_scan().scan()
table.delete('id = 0')
assert list(builder.new_vector_search_read().read_plan(plan).results()) == [0]
assert list(builder.execute_local().results()) == [2]
assert search(table, snapshot_id=saved) == [{'id': i} for i in (0, 2, 3)]
def test_native_incremental_build_with_concurrent_delete(table):
assert build(table) == 2
append(table, [7, 8])
expected = [{'id': i} for i in (6, 7, 8)]
assert table.search([7., 1.], column='embedding').select(['id']).limit(3).to_list() == expected
builder = GlobalIndexBuilder(table.raw_table, 'embedding', 'ivf-flat', options={
'ivf-flat.dimension': '2', 'ivf-flat.nlist': '1', 'ivf-flat.distance.metric': 'l2'})
messages = builder.build()
# Deleting a newly indexed row between build and commit must remain visible.
table.delete('id = 7')
commit = table.raw_table.new_batch_write_builder().new_commit()
try:
commit.commit(messages)
finally:
commit.close()
assert [(f.global_index_meta.row_range_start, f.global_index_meta.row_range_end)
for f in entries(table)] == [(0, 3), (4, 6), (7, 7), (8, 8)]
expected = [{'id': i} for i in (5, 6, 8)]
assert table.search([7., 1.], column='embedding').select(['id']).limit(3).to_list() == expected
assert build(table) == 0
def test_native_partition_scoped_build_after_deletion(tmp_path):
pytest.importorskip('paimon_vindex')
table = pm.connect(options={'warehouse': str(tmp_path)}).create_table(
'vectors', schema=SCHEMA, partitioned=['category'], options={
'file.format': 'parquet', 'vector.file.format': 'parquet',
'deletion-vectors.enabled': 'true'})
append(table, [0, 1, 2], category='other')
append(table, [3, 4, 5])
table.delete("category = 'yes' AND id = 4")
assert table.raw_table.create_global_index('embedding', 'ivf-flat', partitions={'category': 'yes'},
options={'ivf-flat.nlist': '1', 'ivf-flat.distance.metric': 'l2'}) == 1
assert [(f.global_index_meta.row_range_start, f.global_index_meta.row_range_end)
for f in entries(table)] == [(3, 5)]
table.raw_table = table.raw_table.copy({'vector-index.search-mode': 'fast'})
assert search(table, pre_filter="category = 'yes'") == [{'id': 3}, {'id': 5}]
assert search(table, pre_filter="category = 'other'") == []
def test_native_index_commit_rejects_replaced_row_ids(table):
pytest.importorskip('paimon_vindex')
messages = GlobalIndexBuilder(table.raw_table, 'embedding', 'ivf-flat', options={
'ivf-flat.nlist': '1'}).build()
write_builder = table.raw_table.new_batch_write_builder().overwrite({})
writer, commit = write_builder.new_write(), write_builder.new_commit()
try:
writer.write_arrow(pa.table({'id': [99], 'embedding': [[99., 1.]], 'category': ['yes']}, schema=SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
commit = table.raw_table.new_batch_write_builder().new_commit()
try:
with pytest.raises(RuntimeError, match='Global index row ID existence conflict'):
commit.commit(messages)
finally:
commit.close()
assert entries(table) == []