Vector and Full-text Search
Build indexes and retrieve candidates using vector, full-text, or hybrid queries. Start with the docs table from Multimodal Tables, including its content and embedding columns. Examples with additional vector or partition columns require a table with those fields.
Prerequisites
The index implementations used below require optional packages:
python -m pip install 'pypaimon[vindex,full-text]'
Use Python 3.9 or newer for this combination. Index creation requires data in the
target columns; replace the small illustrative vectors with data appropriate for
the chosen index's training requirements. In the examples, docs is the table,
pm is pypaimon.multimodal, and query vectors must match their column dimensions.
Create Index
Use create_index to create the global indexes used by search APIs. The
index_type argument is required; the multimodal API does not choose a default
index implementation. Use full-text for full-text indexes.
docs.create_index("embedding", index_type="ivf-pq")
docs.create_index("content", index_type="full-text")
Search
Use search for one vector query or one full-text query.
If the table has exactly one vector column, column can be omitted for vector
search. A string query is shorthand for a full-text match query when the table
has exactly one text column. To target a specific text column, pass column.
Use pre_filter to prune search candidates before ranking. Use where() to
filter the rows read from the search result. Both pre_filter and where()
accept SQL-like predicate strings. On data-evolution tables, full-text
pre_filter supports ordinary data columns as well as partition columns.
For primary-key full-text search, only partition predicates are supported.
Full-text data predicates are evaluated before Top-K selection. PyPaimon reads
the predicate's columns and row IDs at the search snapshot, then passes the
matching row IDs to the full-text index. Scalar indexes can prune this read,
but partial scalar-index coverage does not exclude matching rows covered by
the full-text search plan. This filter read can scan all candidate rows;
partition-only predicates keep the existing partition-pruning path. For
unindexed data in full-text-index.search-mode=full, filtering preserves the
unfiltered corpus used to calculate BM25 statistics.
For data-evolution vector search, a scalar index may return candidates rather than exact
matches, for example for BTree string-prefix or substring predicates, or when
part of a conjunction is unsupported. Such index candidates are excluded with
a warning by default, so the result can contain fewer than the requested rows.
Set the table option global-index.filter.refine-from-data=true to verify those
candidates before vector top-k selection. This reads the filter columns at the
search snapshot and may scan every candidate row; exact index matches need no
extra read. This applies to single and batch vector queries, locally and on Ray.
Each execution of search, search_vectors, or search_hybrid reads one
snapshot across candidate search, filtering, reranking, and result lookup.
Concurrent commits become visible on the next execution, including when reusing
the same query object. Explicit snapshot, tag, and timestamp selectors are
honored. All routes in a hybrid search and all vectors in a batch share that
execution's snapshot. Keep the snapshot's data files available for the duration
of the query; capturing a read view does not prevent snapshot expiration.
neighbors = (
docs.search(
[0.1, 0.2, 0.3],
column="embedding",
pre_filter="category = 'lake'",
)
.limit(10)
.to_arrow()
)
matches = (
docs.search("paimon vector", column="content", pre_filter="category = 'lake'")
.limit(10)
.to_pandas()
)
matches = (
docs.search(
'{"match":{"query":"paimon vector","operator":"And"}}',
column="content",
)
.limit(10)
.to_list()
)
Scores and Result Ordering
On data-evolution tables, use with_score() to append a float64 relevance
column and order_by_score() to sort by descending score, with ascending
_ROW_ID for ties. Both methods are optional: with_score() alone preserves
the existing result order, and order_by_score() does not require projecting
scores. Without either method, result behavior is unchanged.
neighbors = (
docs.search([0.1, 0.2, 0.3], column="embedding")
.select(["id", "content"])
.with_score("relevance")
.order_by_score()
.limit(10)
.to_arrow()
)
The default score column is _score. A custom name must not conflict with a
table column or a system field. Scores use the search engine's existing
higher-is-better convention: L2 uses 1 / (1 + squared_distance), cosine uses
cosine similarity, and inner product uses the dot product. Full-text results
expose BM25 scores; hybrid results expose the selected ranker's fusion scores.
Scores from different metrics or rankers are not directly comparable.
These methods also work with full-text, hybrid, and batch vector queries, and
with local or Ray vector execution. Batch output retains input-query order and
each row receives its score for that query. where() still filters selected
rows during lookup, so it can return fewer than the requested number of hits.
When only row IDs and scores are needed, explicitly project _ROW_ID:
hits = (
docs.search([0.1, 0.2, 0.3], column="embedding")
.select(["_ROW_ID"])
.with_score()
.order_by_score()
.limit(10)
.to_arrow()
)
Use select([]).with_score() for scores alone. When either score method is
enabled and the explicit projection contains only _ROW_ID or is empty, the
query skips final row lookup if there is no where() and query authorization
is disabled. Raw search, prefiltering, and vector refinement can still read
data. Historical snapshot and deletion semantics remain the same. Plain
select([]) without either method retains its existing behavior.
Distributed Vector Search
Use execution="ray" to execute vector queries across Ray workers and return
Arrow results to the driver. This supports single and batch vector queries on
data-evolution tables; primary-key tables and hybrid queries are not supported
by this execution mode.
Install the same PyPaimon and index dependencies on the driver and workers:
python -m pip install 'pypaimon[ray,vindex]'
import ray
ray.init() # Use address="auto" to connect to an existing cluster.
neighbors = (
docs.search(
[0.1, 0.2, 0.3],
column="embedding",
pre_filter="category = 'lake'",
)
.select(["id", "content"])
.limit(10)
.to_arrow(
execution="ray",
concurrency=4,
ray_remote_args={"num_cpus": 1},
)
)
execution defaults to "local". concurrency is a positive integer limiting
the number of in-flight Ray tasks, with a default of 4. ray_remote_args supplies
Ray task options, including resources and retry settings; num_returns is
managed by PyPaimon. These two arguments require execution="ray".
For single and batch vector queries, to_pandas() and to_list() accept the
same execution, concurrency, and ray_remote_args arguments as to_arrow().
For example, use query.to_pandas(execution="ray", concurrency=4) to return a
DataFrame, or one DataFrame per query for a batch. Result conversion runs on the
driver after the search and lookup complete.
Single-vector Ray queries require finite query values and fail if stored
vectors produce NaN scores.
Batch queries use the same execution options and return one Arrow table per input vector, in input order:
batch_neighbors = (
docs.search_vectors([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], column="embedding")
.select(["id", "content"])
.limit(10)
.to_arrow(execution="ray", concurrency=4)
)
Each batch task handles all query vectors for one split: index workers reuse an open shard across bounded query blocks, and raw workers stream each data split once with a separate top-k for each query. For refinement, the driver selects each query's global candidates, then workers stream their union once per read split, scoring each row only for the queries that selected it. Shared final row lookup runs on the driver. The entire batch uses one read snapshot. Candidate traffic and result memory grow with the number of query vectors; split large query collections into smaller batches when necessary.
The driver fixes one read snapshot and plans the query. Workers search individual
index shards and, when required by the table's search mode, scan unindexed data.
They return candidate row IDs and scores. For vector queries with
refinement enabled, the driver first selects the global candidate set, then
workers read and score those candidate vectors using the index's persisted
metric. The driver merges their results and retains the local rules for
filtering and final row lookup.
In particular, pre_filter filters candidates before ranking; where() filters
the selected rows and can return fewer than the requested number of results.
Index search, raw scans, refinement, lookup, and task retries use the same read
snapshot. The existing snapshot_id and tag_name arguments to search() also
work with Ray execution. A failed task fails the query rather than returning
partial results. Snapshot pinning does not prevent file expiration; the files
needed by the query must remain available for its duration.
All workers must be able to access the table's storage. Local filesystem paths
are suitable for a local Ray cluster; multiple nodes require shared storage.
Each index task searches one shard, while its native index I/O settings still
apply. Raw-scan parallelism is limited by the number of planned read splits,
controlled by the table's source.split.target-size option. Refinement also
uses planned read splits; workers return scores for merging.
Final row lookup runs on the driver. Candidate traffic grows
with the number of index shards and the configured refinement budget, so Ray
execution is most useful when shard search or raw scanning outweighs scheduling
and transfer costs. Small queries can be faster locally.
Local and Ray index searches merge shard results incrementally in plan order. The concurrency limit bounds running tasks plus completed results waiting for earlier shards, so a slow shard can delay further submissions. Global top-k selection still uses all merged candidate scores, whose storage grows with the number of unique candidate rows per query.
Full-text and Hybrid queries on Ray
Full-text and Hybrid queries accept execution="ray", concurrency, and
ray_remote_args on to_arrow, to_pandas, and to_list, matching vector queries:
rows = (docs.search("robot grasp", column="content")
.with_score().order_by_score().limit(20)
.to_arrow(execution="ray", concurrency=4))
This path supports data-evolution tables and requires pypaimon[ray,full-text]
on every worker (plus vindex for native vector routes). Index shards run as
bounded Ray tasks. Full-text raw fallback runs on one worker with the complete
uncovered corpus, preserving existing BM25 statistics; that corpus must fit the
worker's memory. Pre-filters and deletion vectors keep the local ranking semantics.
Hybrid routes share one resolved snapshot, use Ray for their shard work, and
reuse the existing RRF, MRR, or weighted-score fusion on the driver. Routes run
successively so concurrency bounds the whole query. Final Arrow/Pandas row lookup
remains on the driver. Native resources close on workers; task errors propagate
and pending read tasks are cancelled.
Search Hybrid
Use search_hybrid to combine vector and full-text routes, then rerank the
merged candidates. Create the indexes required by the routes you use.
Pass route specs to search_hybrid. Each route can set its own candidate
limit, weight, and vector index options. String text routes infer the
text column when the table has exactly one text column. To target a specific
text column, pass column to pm.text_route.
pre_filter is applied before ranking. It accepts a SQL-like predicate string.
On data-evolution tables, ordinary data predicates are applied to both vector
and full-text routes before each route selects its candidates. The vector
route retains the global-index.filter.refine-from-data behavior described
above, while the full-text route verifies data predicates through a filter-column
read. Partition-only predicates prune both routes without that extra read.
# This example assumes the table is partitioned by dt.
hybrid = (
docs.search_hybrid(
[
pm.vector_route("embedding", query_vector, limit=50),
pm.text_route("paimon vector", column="content", weight=0.2),
],
pre_filter="dt = '2026-07-01'",
)
.rerank("rrf")
.limit(10)
.to_list()
)
For multiple vector fields, add multiple vector routes:
hybrid = (
docs.search_hybrid(
[
pm.vector_route(
"image_embedding",
image_vector,
weight=0.7,
limit=50,
options={"nprobe": "8"},
),
pm.vector_route(
"text_embedding",
text_vector,
weight=0.3,
limit=50,
),
pm.text_route("paimon vector", column="content", weight=0.2),
],
ranker="weighted_score",
)
.limit(10)
.to_list()
)
Dictionary route specs are also accepted:
hybrid = docs.search_hybrid(
[
{
"column": "image_embedding",
"vector": image_vector,
"weight": 0.7,
"limit": 50,
"options": {"nprobe": "8"},
},
{
"column": "text_embedding",
"vector": text_vector,
"weight": 0.3,
"limit": 50,
},
],
)
Dictionary vector routes also accept anns_field, data, and param aliases.
Search Vectors
Use search_vectors for multiple query vectors against one vector column. It
returns one result set for each input vector, preserving input order.
Batch results share a lookup of their combined row IDs, so overlapping results
do not require reading the same row separately for each query. The limit and
post-filter still apply to each query's result set.
batch_neighbors = (
docs.search_vectors(
[
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
],
column="embedding",
pre_filter="category = 'lake'",
)
.limit(10)
.to_list()
)