Quickstart#
Installation#
pip install ecpfs
Building an index#
from ecpfs import Builder, Metric, EmbeddingDtype
builder = Builder(
index_path="my_index.zarr",
levels=3,
metric=Metric.L2,
is_normalized=False,
# None stores each file's own dtype. A narrower EmbeddingDtype
# (F16, UInt8, Int8) saves disk but loses precision, and logs a
# warning. Every read widens back to f32, so it saves disk, not memory.
embedding_dtype=None,
)
builder.select_representatives(
embeddings_file="embeddings.zarr",
target_cluster_items=100,
strategy="offset",
fallback_batch_rows=100_000,
)
builder.build(
embeddings_file="embeddings.zarr",
fallback_batch_rows=100_000,
)
A Builder has no context manager. The index is on disk when build()
returns.
Loading an index#
Open a newly built or existing index with Index. As a context manager it
calls close() when the block ends, including when the block raises.
from ecpfs import Index
with Index("my_index.zarr") as index:
... # search or insert
Without with, call close() in a finally block so it runs even if
an error occurs.
index = Index("my_index.zarr")
try:
... # search or insert
finally:
index.close()
close() saves every query still in flight, so a later Index can resume
it, and then closes the index. Every method raises ValueError afterward.
Calling close() more than once is safe. The examples below use with.
Searching an index#
import numpy as np
from ecpfs import Index
with Index("my_index.zarr") as index:
query = np.random.rand(128).astype(np.float32)
items, query_id = index.new_search(
query=query,
k=10,
search_exp=4,
max_increments=-1,
exclude_vec=[],
)
# items: list[(score, item_id)], lower score is a better match.
# See the search parameters page for k, search_exp and max_increments.
# Pull further results for the same query without re-searching from the root:
more_items = index.get_next_k_items(
query_id=query_id,
k=10,
search_exp=4,
max_increments=-1,
exclude_vec=[],
)
# Leaving the `with` block calls close().
Adding data to an existing index#
insert routes each new point to its nearest leaf node and appends it there,
the same descent search already does. Ids are assigned automatically in
row order, starting at the index’s next_item_id. A fresh build sets
that to the number of items it stored, so inserted ids continue straight
on from the build’s own. insert returns the
assigned (start_id, end_id) range (end_id excluded) so the caller
can map its own external ids to them.
import numpy as np
from ecpfs import Index
with Index("my_index.zarr") as index:
# 2 new rows, matching the index's own dimensionality
new_embeddings = np.random.rand(2, 128).astype(np.float32)
start_id, end_id = index.insert(embeddings=new_embeddings)
# start_id, end_id = 1000, 1002; the two rows got ids 1000 and 1001.
The new rows are in the index when insert returns. close() is not what
saves them.
There’s no rebalancing. A leaf node that keeps growing just keeps growing, so search quality degrades gradually as an index accumulates far more inserts than its original build accounted for. A rebuild is the only fix for that currently.
insert is not atomic. A crash partway through can leave next_item_id
ahead of what landed on disk, skipping the unwritten ids for good rather than
reusing them or colliding with one already written. total_items still
counts only what was stored, so do not assume an index survives a crash
mid-insert without a gap in its ids.
Concurrent inserts and searches on one loaded Index are safe and
fine-grained. Two operations only serialize when they land on the same
leaf node, and a search may briefly see pre-insert (stale) data for a
leaf node an insert is concurrently touching rather than wait for it.
This holds
across Python threads too, because ecpfs releases the GIL during search and
insert. It does not extend across separate processes. Two
independent processes (or two separate Index(...) handles anywhere)
writing to the same index path can race and corrupt data, so the caller
must ensure only one writer touches a given path at a time.
The caller is responsible for new embeddings already matching the
index’s metric/is_normalized/dtype convention.
The CLI equivalent bulk-loads a whole file in one process, auto-numbering new ids the same way:
ecp add-data my_index.zarr new_embeddings.h5
Persisting and resuming queries#
An Index keeps each query in flight, its position in the tree and its
buffered results, in memory. A query reaches disk in two cases. close()
saves it, and so does evicting it from memory to stay under the memory limit.
A query that was neither saved by close() nor evicted is lost when the
process crashes or close() is never called. get_next_k_items then
returns no items for its query_id.
A saved query can be resumed from a new Index on the same path, in the
same or another process, using the query_id the original new_search
returned:
from ecpfs import Index
with Index("my_index.zarr") as index:
items, query_id = index.new_search(
query=query, k=2, search_exp=1, max_increments=-1, exclude_vec=[]
)
# index.close() already ran; query_id's remaining progress is on disk.
# ... later, possibly a different process ...
with Index("my_index.zarr") as index:
more_items = index.get_next_k_items(
query_id=query_id, k=2, search_exp=1, max_increments=-1, exclude_vec=[]
)
A query with nothing left to explore or return is erased rather than saved,
so finished queries do not accumulate on disk. One with results left stays
on disk until a caller resumes it or cleanup_persisted_queries_older_than
erases it:
import datetime
from ecpfs import Index
with Index("my_index.zarr") as index:
# Erase anything persisted more than a day ago.
cutoff = (datetime.datetime.now() - datetime.timedelta(days=1)).timestamp()
erased = index.cleanup_persisted_queries_older_than(cutoff)
The equivalent from the CLI, for a scheduled cleanup job:
ecp cleanup-queries my_index.zarr --older-than-hours 24
Enabling logging#
from ecpfs import init_logging
log_path = init_logging(log_dir="ecp_logs") # level defaults to "info"
See Logging for the level policy, the record format, and where the file goes.