Skip to content

Commit a77efc3

Browse files
committed
[vector][python] Read vindex vector indexes
1 parent 0428283 commit a77efc3

34 files changed

Lines changed: 922 additions & 1107 deletions

paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorIndexOptions.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public LuminaVectorIndexOptions(Options options) {
130130
/**
131131
* Resolves per-field Lumina options for {@code fieldName} into an effective {@link Options}.
132132
*
133-
* <p>Following the convention shared with {@code paimon-vector-index} (PR #8239), a field-level
133+
* <p>Following the convention shared with {@code paimon-vector} (PR #8239), a field-level
134134
* option is written {@code fields.<fieldName>.<option>} — <b>without</b> the {@code lumina.}
135135
* index-type prefix — and overrides the column-agnostic {@code lumina.<option>} for that field
136136
* only. For example {@code fields.embed.distance.metric} overrides {@code
@@ -139,7 +139,7 @@ public LuminaVectorIndexOptions(Options options) {
139139
*
140140
* <p>Only recognized Lumina options (the keys in {@code FIELD_OVERRIDABLE_KEYS}) are accepted;
141141
* any other {@code fields.<fieldName>.*} key (e.g. a merge/aggregation option) is left
142-
* untouched, mirroring how {@code paimon-vector-index} ignores keys it does not recognize.
142+
* untouched, mirroring how {@code paimon-vector} ignores keys it does not recognize.
143143
*
144144
* <p>Each recognized field option is flattened back to its plain {@code lumina.*} form, so the
145145
* rest of this class still sees only {@code lumina.*} keys and the metadata produced from these

paimon-python/dev/requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ vortex-data==0.70.0; python_version >= "3.11"
3232
datafusion>=52; python_version >= "3.10"
3333
# Lumina vector search (optional, for lumina index tests)
3434
lumina-data>=0.1.0
35+
# paimon-vindex vector search (optional, for vindex index tests)
36+
paimon-vindex==0.1.0; python_version >= "3.9"

paimon-python/dev/run_mixed_tests.sh

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,64 @@ run_lumina_vector_btree_test() {
436436
fi
437437
}
438438

439+
ensure_paimon_vindex() {
440+
if python -c "import paimon_vindex" >/dev/null 2>&1; then
441+
return 0
442+
fi
443+
444+
echo "Installing Python paimon-vindex dependency..."
445+
if python -m pip install 'paimon-vindex==0.1.0'; then
446+
return 0
447+
fi
448+
449+
echo -e "${YELLOW}Direct pip install failed; installing paimon-vindex into a temporary target directory...${NC}"
450+
local target_dir="${TMPDIR:-/tmp}/paimon-vindex-site"
451+
rm -rf "$target_dir"
452+
if python -m pip install --target "$target_dir" 'paimon-vindex==0.1.0'; then
453+
export PYTHONPATH="$target_dir:${PYTHONPATH:-}"
454+
return 0
455+
fi
456+
457+
if python -c "import numpy" >/dev/null 2>&1; then
458+
echo -e "${YELLOW}Dependency install failed but numpy is already available; retrying paimon-vindex without dependencies...${NC}"
459+
rm -rf "$target_dir"
460+
if python -m pip install --target "$target_dir" --no-deps 'paimon-vindex==0.1.0'; then
461+
export PYTHONPATH="$target_dir:${PYTHONPATH:-}"
462+
return 0
463+
fi
464+
fi
465+
466+
echo -e "${RED}✗ Failed to install paimon-vindex${NC}"
467+
return 1
468+
}
469+
470+
# Function to run paimon-vindex vector index test (Java write index, Python read and search)
471+
run_vindex_vector_test() {
472+
echo -e "${YELLOW}=== Running paimon-vindex Vector Index Test (Java Write, Python Read) ===${NC}"
473+
474+
cd "$PROJECT_ROOT"
475+
476+
echo "Running Maven test for JavaPyE2ETest.testVindexVectorIndexWrite..."
477+
if mvn test -Dtest=org.apache.paimon.JavaPyE2ETest#testVindexVectorIndexWrite -pl paimon-vector -am -q -DfailIfNoTests=false -Drun.e2e.tests=true; then
478+
echo -e "${GREEN}✓ Java test completed successfully${NC}"
479+
else
480+
echo -e "${RED}✗ Java test failed${NC}"
481+
return 1
482+
fi
483+
cd "$PAIMON_PYTHON_DIR"
484+
if ! ensure_paimon_vindex; then
485+
return 1
486+
fi
487+
echo "Running Python test for JavaPyReadWriteTest.test_read_vindex_vector_index..."
488+
if python -m pytest java_py_read_write_test.py::JavaPyReadWriteTest::test_read_vindex_vector_index -v; then
489+
echo -e "${GREEN}✓ Python test completed successfully${NC}"
490+
return 0
491+
else
492+
echo -e "${RED}✗ Python test failed${NC}"
493+
return 1
494+
fi
495+
}
496+
439497
run_compact_conflict_test() {
440498
echo -e "${YELLOW}=== Running Compact Conflict Test (Java Write Base, Python Shard Update + Java Compact) ===${NC}"
441499

@@ -696,6 +754,7 @@ main() {
696754
local tantivy_fulltext_result=0
697755
local lumina_vector_result=0
698756
local lumina_vector_btree_result=0
757+
local vindex_vector_result=0
699758
local compact_conflict_result=0
700759
local blob_compact_conflict_result=0
701760
local blob_alter_compact_result=0
@@ -841,6 +900,18 @@ main() {
841900

842901
echo ""
843902

903+
# Run paimon-vindex vector index test (requires Python >= 3.9)
904+
if [[ "$PYTHON_MINOR" -ge 9 ]]; then
905+
if ! run_vindex_vector_test; then
906+
vindex_vector_result=1
907+
fi
908+
else
909+
echo -e "${YELLOW}⏭ Skipping paimon-vindex Vector Index Test (requires Python >= 3.9, current: $PYTHON_VERSION)${NC}"
910+
vindex_vector_result=0
911+
fi
912+
913+
echo ""
914+
844915
# Run compact conflict test (Java write+compact, Python read)
845916
if ! run_compact_conflict_test; then
846917
compact_conflict_result=1
@@ -994,6 +1065,12 @@ main() {
9941065
echo -e "${RED}✗ Lumina Vector + BTree Pre-Filter Test (Java Write, Python Read): FAILED${NC}"
9951066
fi
9961067

1068+
if [[ $vindex_vector_result -eq 0 ]]; then
1069+
echo -e "${GREEN}✓ paimon-vindex Vector Index Test (Java Write, Python Read): PASSED${NC}"
1070+
else
1071+
echo -e "${RED}✗ paimon-vindex Vector Index Test (Java Write, Python Read): FAILED${NC}"
1072+
fi
1073+
9971074
if [[ $compact_conflict_result -eq 0 ]]; then
9981075
echo -e "${GREEN}✓ Compact Conflict Test (Java Write+Compact, Python Read): PASSED${NC}"
9991076
else
@@ -1047,7 +1124,7 @@ main() {
10471124
# Clean up warehouse directory after all tests
10481125
cleanup_warehouse
10491126

1050-
if [[ $java_write_result -eq 0 && $python_read_result -eq 0 && $python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 && $btree_index_result -eq 0 && $compressed_text_result -eq 0 && $tantivy_fulltext_result -eq 0 && $lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 && $compact_conflict_result -eq 0 && $blob_compact_conflict_result -eq 0 && $blob_alter_compact_result -eq 0 && $data_evolution_result -eq 0 && $data_evolution_py_write_result -eq 0 && $java_variant_write_py_read_result -eq 0 && $py_variant_write_java_read_result -eq 0 && $vector_append_table_result -eq 0 && $vector_dedicated_java_write_result -eq 0 && $vector_dedicated_py_write_result -eq 0 && $multi_vector_dedicated_java_write_result -eq 0 && $multi_vector_dedicated_py_write_result -eq 0 && $row_format_result -eq 0 ]]; then
1127+
if [[ $java_write_result -eq 0 && $python_read_result -eq 0 && $python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 && $btree_index_result -eq 0 && $compressed_text_result -eq 0 && $tantivy_fulltext_result -eq 0 && $lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 && $vindex_vector_result -eq 0 && $compact_conflict_result -eq 0 && $blob_compact_conflict_result -eq 0 && $blob_alter_compact_result -eq 0 && $data_evolution_result -eq 0 && $data_evolution_py_write_result -eq 0 && $java_variant_write_py_read_result -eq 0 && $py_variant_write_java_read_result -eq 0 && $vector_append_table_result -eq 0 && $vector_dedicated_java_write_result -eq 0 && $vector_dedicated_py_write_result -eq 0 && $multi_vector_dedicated_java_write_result -eq 0 && $multi_vector_dedicated_py_write_result -eq 0 && $row_format_result -eq 0 ]]; then
10511128
echo -e "${GREEN}🎉 All tests passed! Java-Python interoperability verified.${NC}"
10521129
return 0
10531130
else
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""paimon-vindex based global index readers."""
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""Vector global index reader using paimon-vindex."""
19+
20+
import os
21+
import threading
22+
23+
import numpy as np
24+
25+
from pypaimon.common.file_io import pread, supports_pread
26+
from pypaimon.globalindex.global_index_reader import GlobalIndexReader, _completed_future
27+
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
28+
29+
VINDEX_IDENTIFIERS = ("ivf-flat", "ivf-pq", "ivf-hnsw-flat", "ivf-hnsw-sq")
30+
31+
NPROBE_PARAMETER = "ivf.nprobe"
32+
EF_SEARCH_PARAMETER = "hnsw.ef_search"
33+
DEFAULT_NPROBE = 16
34+
DEFAULT_EF_SEARCH = 0
35+
36+
37+
class PaimonVindexInput:
38+
"""Input adapter required by paimon_vindex.VectorIndexReader."""
39+
40+
def __init__(self, stream):
41+
self._stream = stream
42+
self._supports_pread = supports_pread(stream)
43+
self._lock = threading.Lock()
44+
45+
def pread_many(self, ranges):
46+
if self._supports_pread:
47+
return [pread(self._stream, length, offset) for offset, length in ranges]
48+
49+
chunks = []
50+
with self._lock:
51+
for offset, length in ranges:
52+
self._stream.seek(offset)
53+
chunks.append(self._stream.read(length))
54+
return chunks
55+
56+
57+
class VindexVectorGlobalIndexReader(GlobalIndexReader):
58+
"""Vector global index reader using paimon-vindex."""
59+
60+
def __init__(self, file_io, index_path, io_metas, options=None):
61+
assert len(io_metas) == 1, "Expected exactly one index file per shard"
62+
self._file_io = file_io
63+
self._index_path = index_path
64+
self._io_meta = io_metas[0]
65+
self._options = dict(options or {})
66+
self._stream = None
67+
self._index_input = None
68+
self._reader = None
69+
self._metadata = None
70+
self._load_lock = threading.Lock()
71+
72+
def visit_vector_search(self, vector_search):
73+
self._ensure_loaded()
74+
75+
query = np.asarray(vector_search.vector, dtype=np.float32)
76+
if query.ndim != 1:
77+
raise ValueError("Query vector must be a one-dimensional float32 array")
78+
expected_dim = self._metadata.dimension
79+
if query.shape[0] != expected_dim:
80+
raise ValueError(
81+
"Query vector dimension mismatch: expected %d, got %d"
82+
% (expected_dim, query.shape[0]))
83+
84+
effective_k = self._effective_k(vector_search)
85+
if effective_k <= 0:
86+
return _completed_future(None)
87+
88+
options = vector_search.options or {}
89+
nprobe = _int_parameter(options, NPROBE_PARAMETER, DEFAULT_NPROBE)
90+
ef_search = _int_parameter(options, EF_SEARCH_PARAMETER, DEFAULT_EF_SEARCH)
91+
filter_bytes = _filter_bytes(vector_search.include_row_ids)
92+
93+
ids, distances = self._reader.search(
94+
query, effective_k, nprobe, ef_search, filter_bytes=filter_bytes)
95+
id_to_scores = _build_scores(ids, distances, self._metadata.metric)
96+
if not id_to_scores:
97+
return _completed_future(None)
98+
return _completed_future(DictBasedScoredIndexResult(id_to_scores))
99+
100+
def vector_metric(self):
101+
self._ensure_loaded()
102+
return self._metadata.metric
103+
104+
def _effective_k(self, vector_search):
105+
limit = vector_search.limit
106+
total_vectors = getattr(self._metadata, "total_vectors", limit)
107+
effective_k = min(limit, int(total_vectors))
108+
include_row_ids = vector_search.include_row_ids
109+
if include_row_ids is not None:
110+
cardinality = include_row_ids.cardinality()
111+
if cardinality == 0:
112+
return 0
113+
effective_k = min(effective_k, cardinality)
114+
return effective_k
115+
116+
def _ensure_loaded(self):
117+
if self._reader is not None:
118+
return
119+
120+
with self._load_lock:
121+
if self._reader is not None:
122+
return
123+
124+
try:
125+
from paimon_vindex import VectorIndexReader
126+
except ImportError as e:
127+
raise ImportError(
128+
"paimon-vindex is required to read vindex vector indexes. "
129+
"Install paimon-vindex==0.1.0 or pypaimon[vindex].") from e
130+
131+
file_path = (self._io_meta.external_path
132+
if self._io_meta.external_path
133+
else os.path.join(self._index_path, self._io_meta.file_name))
134+
stream = self._file_io.new_input_stream(file_path)
135+
try:
136+
index_input = PaimonVindexInput(stream)
137+
reader = VectorIndexReader(index_input)
138+
self._metadata = reader.metadata()
139+
self._index_input = index_input
140+
self._reader = reader
141+
self._stream = stream
142+
except Exception:
143+
stream.close()
144+
raise
145+
146+
def __enter__(self):
147+
return self
148+
149+
def __exit__(self, exc_type, exc_val, exc_tb):
150+
self.close()
151+
return False
152+
153+
def close(self):
154+
if self._reader is not None:
155+
self._reader.close()
156+
self._reader = None
157+
if self._stream is not None:
158+
self._stream.close()
159+
self._stream = None
160+
161+
162+
def _filter_bytes(include_row_ids):
163+
if include_row_ids is None:
164+
return None
165+
if include_row_ids.cardinality() == 0:
166+
return None
167+
return include_row_ids.serialize()
168+
169+
170+
def _build_scores(ids, distances, metric):
171+
id_to_scores = {}
172+
for row_id, distance in zip(ids, distances):
173+
row_id = int(row_id)
174+
if row_id < 0:
175+
continue
176+
id_to_scores[row_id] = _convert_distance_to_score(float(distance), metric)
177+
return id_to_scores
178+
179+
180+
def _convert_distance_to_score(distance, metric):
181+
if metric == "l2":
182+
return 1.0 / (1.0 + distance)
183+
if metric == "cosine":
184+
return 1.0 - distance
185+
if metric == "inner_product":
186+
return distance
187+
raise ValueError("Unknown vector search metric: %s" % metric)
188+
189+
190+
def _int_parameter(options, key, default_value):
191+
value = options.get(key)
192+
if value is None:
193+
return default_value
194+
try:
195+
return int(value)
196+
except ValueError as e:
197+
raise ValueError(
198+
"Invalid value for '%s': %s. Must be an integer." % (key, value)) from e

paimon-python/pypaimon/table/source/vector_search_read.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,16 @@ def _create_vector_reader(index_type, file_io, index_path, index_io_meta_list, o
225225
LUMINA_IDENTIFIERS,
226226
LuminaVectorGlobalIndexReader,
227227
)
228+
from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
229+
VINDEX_IDENTIFIERS,
230+
VindexVectorGlobalIndexReader,
231+
)
228232
if index_type in LUMINA_IDENTIFIERS:
229233
return LuminaVectorGlobalIndexReader(
230234
file_io, index_path, index_io_meta_list, options
231235
)
236+
if index_type in VINDEX_IDENTIFIERS:
237+
return VindexVectorGlobalIndexReader(
238+
file_io, index_path, index_io_meta_list, options
239+
)
232240
raise ValueError("Unsupported vector index type: '%s'" % index_type)

0 commit comments

Comments
 (0)