A low-latency vector search library with a Rust core (PyO3 + maturin), a CLI, and an MCP
server — the Python counterpart to the Node.js ruvector package. Algorithms
stay in Rust; Python is a thin, typed layer over them. NumPy arrays are read without copying
(PyReadonlyArray), and heavy calls (insert, insert_batch, search) release the GIL.
This guide covers the Python package end to end. Design rationale, verified benchmark numbers,
security findings, and the full capability-coverage table live in
docs/adr/ADR-352-ruvector-python-sdk-cli-mcp.md —
read that for the "why", this guide for the "how".
Examples hub · Main quick starts · Package manifest
The distribution and import name are both ruvector. This guide uses the repository source; registry wheel availability must be checked separately. Python 3.9+, Rust, and platform build tools are required.
git clone https://github.com/ruvnet/RuVector.git
cd RuVector
python3 -m venv .venv
source .venv/bin/activate
python -m pip install maturin
cd crates/ruvector-py
maturin develop --release
python -c "from ruvector import Collection; print('RuVector import OK')"On Windows PowerShell, activate with .venv\\Scripts\\Activate.ps1. Keep the environment active for the following commands.
The manifest declares NumPy, Click, and Rich as base dependencies. Optional integrations add their own dependencies:
| Extra | Purpose |
|---|---|
mcp |
Python MCP server |
langchain |
LangChain adapter |
llamaindex |
LlamaIndex adapter |
salesforce |
Salesforce HTTP integration |
all |
All optional integrations |
For example, from crates/ruvector-py, run maturin develop --release --extras mcp. The Python MCP server and the npm MCP server are separate implementations; their tools and policy settings differ.
import numpy as np
from ruvector import Collection
# backend="hnsw" is the default (fast approximate search, metadata-filtered
# in Rust). backend="rabitq" is also available (1-bit quantized, exact f32
# rerank) — see "Choosing a backend" below.
coll = Collection.create(dim=384)
vec = np.random.default_rng(0).standard_normal(384).astype(np.float32)
doc_id = coll.insert(vec, metadata={"text": "hello world", "source": "notes"})
hits = coll.search(vec, k=5, filter={"source": "notes"})
for h in hits:
print(h.id, h.score, h.metadata)
coll.save("my-collection") # writes my-collection(.npy|.rbpx) + my-collection.meta.json
coll2 = Collection.load("my-collection")The main entry point — ids, metadata, filtering, soft-delete, and persistence over either backend.
Collection.create(dim: int, backend: str = "hnsw", rerank_factor: int = 20, seed: int = 42) -> Collection
Collection.from_vectors(vectors: NDArray[np.float32], metadatas=None, backend="hnsw", ...) -> Collection
Collection.load(path: str | Path) -> Collection
coll.save(path: str | Path) -> None
coll.insert(vector: NDArray[np.float32], metadata: dict | None = None) -> int # returns new id
coll.insert_batch(vectors: NDArray[np.float32], metadatas: list[dict | None] | None = None) -> list[int]
coll.search(query: NDArray[np.float32], k: int = 10, filter: dict | Callable | None = None,
rerank_factor: int | None = None) -> list[SearchHit] # SearchHit: .id .score .metadata
coll.delete(id: int) -> None # soft delete (tombstone)
coll.vacuum() -> int # physically rebuild, reclaim tombstoned space; returns rows dropped
coll.get_metadata(id: int) -> dict | None
coll.export_live_items() -> list[tuple[int, NDArray[np.float32], dict | None]]
coll.stats() -> CollectionStats # count, dim, backend, rerank_factor, memory_bytes, tombstoned
coll.metric -> str # "cosine" (hnsw, configurable) or "squared_l2" (rabitq, fixed)
len(coll) -> intfilter accepts either an exact-match dict ({"cat": "news"}, evaluated in Rust for the hnsw
backend — no Python-side round trip per candidate) or an arbitrary Python callable
(lambda meta: meta.get("score", 0) > 0.5, always evaluated Python-side since it's arbitrary
code). rabitq always filters Python-side regardless of which form is used.
hnsw(default) — approximate nearest-neighbor viahnsw_rs, metadata-aware, dict-filter evaluated in Rust. Good default for most workloads; see the benchmark table below for where it stands againsthnswlib.rabitq— RaBitQ+ symmetric 1-bit quantized scan with an exact f32 rerank pass (rerank_factorcontrols how many candidates get the exact rerank). Ids are stored asu32internally (0..2³²-1) —CollectionraisesCollectionErrorif you try to use an id outside that range with this backend.coll.metricalways reports"squared_l2"for this backend (it doesn't support a configurable metric, unlikehnsw).
RabitqIndex and HnswIndex are the raw index types Collection wraps (ids-as-strings for
HnswIndex, ids-as-u32 for RabitqIndex) — use these directly only if you need an index without
Collection's id/metadata/soft-delete bookkeeping on top.
Bound as standalone classes/functions, not wired into Collection — see ADR-352's "Capability
landing" table for exactly what's in and out of scope for each:
from ruvector import GraphDB, GnnLayer, AttentionReranker, kmeans, SonaEngine
g = GraphDB()
n1 = g.create_node(labels=["Person"], properties={"name": "Alice"})
n2 = g.create_node(labels=["Person"], properties={"name": "Bob"})
g.create_edge(n1, n2, "KNOWS", properties={}) # 3rd positional arg is relation_type
rows = g.query_cypher("MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE a.name = 'Alice' RETURN a, b")
# MATCH/WHERE only — RETURN is parsed but not projected (matches the upstream
# ruvector-graph-node contract exactly); CREATE/MERGE/SET/DELETE are rejected or no-op.
reranker = AttentionReranker(dim=384)
blended, weights = reranker.rerank(query_vec, candidate_vecs) # weights: what to re-sort by
# GnnLayer.forward is per-node (not a full-graph batch op): `node` is a
# single (input_dim,) vector, `neighbors` is that node's (n, input_dim)
# neighbor set, `weights` is an optional (n,) per-edge weight.
layer = GnnLayer(input_dim=384, hidden_dim=128, heads=4, dropout=0.0) # untrained = Xavier/Glorot random
out = layer.forward(node, neighbors, weights=None) # projection, NOT a trained quality win
# kmeans returns a plain tuple, not an object with attributes:
assignments, centroids, cohesion, cluster_sizes = kmeans(vectors, k=8, iters=20)
sona = SonaEngine(hidden_dim=384)
out_vec = sona.apply_micro_lora(vec) # fresh engine: EXACT identity transform (zero-init +
# residual), not "close to one" — inference-only; the
# online-learning API (begin_trajectory/tick/force_learn)
# is bound but needs a real reward signal to do anythingEvery error ruvector raises is a ruvector.RuVectorError (or its ruvector.CollectionError
subclass for Collection-level misuse, e.g. an out-of-range rabitq id). Catch RuVectorError to
handle anything the library itself raises, distinct from KeyError/ValueError/TypeError from
ordinary Python-level misuse (missing dict keys, wrong argument types).
from ruvector import RuVectorError
try:
coll.search(wrong_dim_vector, k=5)
except RuVectorError as e:
...pip install 'ruvector[cli]'| Command | What it does |
|---|---|
ruvector create --path P --dim D [--rerank-factor N] [--seed N] |
create a new empty collection |
ruvector import --path P --vectors V.npy [--metadata M.json] [--rerank-factor N] [--seed N] |
bulk-build a collection from a .npy file |
ruvector insert-batch --path P --vectors V.npy [--metadata M.json] |
insert rows into an existing collection |
ruvector search --path P --query Q.npy [-k N] [--filter '{"k":"v"}'] [--json] [--no-color] |
search; prints a ranked, colored Rich table (or plain JSON with --json) |
ruvector delete --path P --id N [--id N ...] [--vacuum] |
soft-delete one or more ids |
ruvector export --path P --out PREFIX |
export vectors + metadata to PREFIX.npy / PREFIX.meta.json |
ruvector info --path P [--json] [--no-color] |
collection stats, in a Rich panel or JSON |
ruvector benchmark [-n N] [--dim D] [--k K] [--queries Q] [--rerank-factor N] [--json] |
in-process build+search latency benchmark (no comparator — see the ADR for a real side-by-side) |
ruvector serve [--http] [--host H] [--port P] |
launch the MCP server (stdio by default; --http for streamable-HTTP) |
--json output on search/info/benchmark is always plain, unstyled JSON — safe to pipe into
jq or another script regardless of --no-color. Vectors go in and out as .npy files
(np.save/np.load, shape (n, dim) or (dim,), dtype float32); metadata as a .json file
holding a list of dicts (or null entries) aligned with the vector rows.
Color honors both --no-color and the NO_COLOR env var, on top of the usual auto-detection for
non-TTY stdout. NO_COLOR/--no-color suppress color specifically — bold/dim can still render on
a real TTY; piping to a file strips all ANSI regardless.
maturin develop --release --extras mcp
ruvector serve # stdio (default) — for Claude Desktop, most MCP clients
ruvector serve --http --port 8420 # streamable-HTTP — for a ChatGPT connector or a remote client| Tool | Scope needed | What it does |
|---|---|---|
vector_create_collection(name, dim, rerank_factor=20, seed=42) |
write | create a new empty collection |
vector_insert(name, vector, metadata=None) |
write | insert one vector |
vector_insert_batch(name, vectors, metadatas=None) |
write | insert many vectors |
vector_search(name, query, k=10, filter=None, rerank_factor=None) |
read | k-NN search with optional metadata filter |
vector_delete(name, id, vacuum=False) |
write | soft-delete (+ optional vacuum) |
vector_stats(name) |
read | count/dim/backend/rerank_factor/memory_bytes/tombstoned |
vector_list_collections() |
read | list every collection under the server's data root |
vector_explore(name, query, k=10) |
read | same as vector_search, but also returns the ChatGPT ui:// widget resource |
Collection names passed as a tool argument go through ruvector.mcp_server._safe_path, which
restricts them to ^[A-Za-z0-9_-]{1,128}$ and resolves strictly under
RUVECTOR_MCP_DATA_DIR (default: a local data directory) — this is the actual remote trust
boundary for this server (unlike the CLI's --path, which runs with the caller's own filesystem
authority, same as sqlite3 file.db).
Unset RUVECTOR_MCP_TOKEN (the default) serves --http without auth — run_http prints a
loud startup warning in that case. Set it to require a bearer token on every MCP request:
export RUVECTOR_MCP_TOKEN="a long random secret, not committed anywhere"
ruvector serve --httpcurl -H "Authorization: Bearer a long random secret, not committed anywhere" \
-X POST http://127.0.0.1:8420/mcp -d '{"jsonrpc":"2.0", ...}'The configured token gets full read+write scope. The write-gated tools
(vector_create_collection/vector_insert/vector_insert_batch/vector_delete) call
_require_write_scope() internally — a token without write scope is rejected on those calls
specifically (relevant if you ever issue a separate read-only token through the same
TokenVerifier mechanism).
vector_explore returns a ui://ruvector/explore.html resource alongside its JSON result —
when called from a ChatGPT connector that supports the Apps SDK widget convention, this renders
an inline results view instead of (or alongside) the raw JSON. No extra configuration needed
beyond using vector_explore instead of vector_search from that client.
maturin develop --release --extras langchainfrom ruvector.integrations.langchain import RuVectorStore
store = RuVectorStore.from_texts(
texts=["hello world", "goodbye world"],
embedding=my_langchain_embeddings, # any langchain-core Embeddings instance
metadatas=[{"source": "a"}, {"source": "b"}],
)
docs = store.similarity_search("hello", k=1)Verified against langchain-core==1.6.6.
maturin develop --release --extras llamaindexfrom ruvector import Collection
from ruvector.integrations.llamaindex import RuVectorStore
store = RuVectorStore(Collection.create(dim=384))
store.add(nodes) # Sequence[llama_index.core.schema.BaseNode]
result = store.query(query_obj) # llama_index.core.vector_stores.types.VectorStoreQueryVerified against llama-index-core==0.14.25. Distance-to-similarity conversion is exact for
cosine (1 - distance, matching ruvector_core's own cosine formula) and an approximation
(1 / (1 + distance)) for other metrics — check Collection.metric if this distinction matters
for your similarity-cutoff thresholds.
Known transitive issue, not a
ruvectorbug:llama-index-coredepends directly onnltk, which has an open, unpatched advisory (PYSEC-2026-3740, a file-sandbox bypass innltk's own model-persistence helpers) as of this writing.ruvector.integrations.llamaindexnever calls the affected APIs, but if you separately usellama-index-core's NLP features in the same process, that exposure is real and not somethingruvectorcan pin around.
maturin develop --release --extras salesforceExposes vector search as Agentforce actions via External Services + OpenAPI — the self-service extension point (Data Cloud's retrieval surface doesn't accept an arbitrary external vector store as a "BYO retriever", and Agentforce's own MCP client is Beta and AE-tier-gated; see ADR-352's Integrations section for the full research behind this choice).
export RUVECTOR_SALESFORCE_ACTION_TOKEN="a long random secret"
export RUVECTOR_ENABLE_SALESFORCE_ACTIONS=1
ruvector serve --http --port 8420This mounts, on the same server/port as the MCP tools:
| Route | Method | What it does |
|---|---|---|
/salesforce/openapi.json |
GET | the OpenAPI 3.0 document to register as an External Service (no auth required on this one) |
/salesforce/search |
POST | {"collection", "query_vector", "k", "filter"} → {"hits": [...]} |
/salesforce/upsert |
POST | {"collection", "vector", "metadata"} → {"id", "count"} |
/salesforce/ground |
POST | {"collection", "query_vector", "k", "text_field"} → {"context", "sources"} — joins top-k text for grounding an agent prompt |
These three action routes require Authorization: Bearer <RUVECTOR_SALESFORCE_ACTION_TOKEN>
(falls back to RUVECTOR_MCP_TOKEN if that specific var is unset) — not the same auth
mechanism as the MCP tools above, because MCPServer.custom_route (what these are mounted with)
does not go through the SDK's token_verifier at all. Error responses are clean 4xx JSON bodies
({"error": "...", "error_description": "..."}), not raw 500s, for KeyError/ValueError/
TypeError/RuVectorError — anything else is a real bug in this module and surfaces as an
ordinary 500.
For syncing Salesforce records into a collection for grounding:
from ruvector.integrations.salesforce import SalesforceConfig, fetch_records, sync_records_to_collection
config = SalesforceConfig.from_env() # RUVECTOR_SALESFORCE_{INSTANCE_URL,CLIENT_ID,CLIENT_SECRET}
records = await fetch_records(config, "SELECT Id, Name, Description FROM Account")
ids = sync_records_to_collection(coll, records, embed_fn=my_embed_fn, text_field="Description", id_field="Id")Example Named Credential / External Service Registration metadata (explicitly marked
illustrative, not validated against a real org) and setup steps:
examples/python-salesforce-agentforce/.
What was and wasn't verified against a real org: every test for this integration runs
against httpx.MockTransport or an in-process ASGI TestClient — no real Salesforce org, OAuth
exchange, SOQL query, or External Service registration happened in this session. The OpenAPI
document is confirmed valid OpenAPI 3.0 (openapi-spec-validator); whether it clears Salesforce
External Services' own import constraints can only be confirmed by a real import. See ADR-352's
Integrations section for the complete list.
- Prefer
insert_batch/from_vectorsover a Python loop ofinsertcalls — onepy.detach()GIL release and one Rust-side batch op instead of N round trips. - Pass C-contiguous
float32NumPy arrays —ruvectorreads vectors viaPyReadonlyArray(no copy) but requires contiguity; a non-contiguous array (e.g. certain slices) raisesTypeErrorrather than silently copying.np.ascontiguousarray(arr, dtype=np.float32)if unsure. - Use a dict filter over a callable filter when the predicate is exact-match — dict filters
on the
hnswbackend are evaluated in Rust per candidate; a callable always round-trips to Python per candidate regardless of backend. filter=Nonetakes a fast path on thehnswbackend — no overfetch beyond exactlykcandidates from Rust. This was a real perf fix (previously always overfetched 4x even unfiltered) — see the ADR's M2 benchmark section.ruvector --helpstays fast because every CLI subcommand imports numpy/click/rich lazily inside its own function body, not at module load — don't undo this if you're extendingcli.py.- Under
ruvector serve --http, all tool calls share one process-wide lock (RLock) around state that touches the on-disk cache — this serializes concurrent requests for correctness (avoids an id-collision race under concurrent inserts, which was a real, reproduced bug this session). It's the right tradeoff for a single vector index, not a high-QPS service;stdiowas never affected (one request at a time regardless).
Real text embeddings (10,000 docs, sklearn's 20-newsgroups, all-MiniLM-L6-v2, 384-dim) vs.
hnswlib at matched m=16, ef_construction=200, k=10, 200 queries, cosine distance:
| ef_search | ruvector p50 | ruvector QPS | hnswlib p50 | hnswlib QPS | recall@10 | gap |
|---|---|---|---|---|---|---|
| 50 | 0.252 ms | 3,854 | 0.035 ms | 28,104 | 0.999 / 0.999 | ~7.2x |
| 100 | 0.391 ms | 2,388 | 0.059 ms | 16,758 | 1.0 / 1.0 | ~6.6x |
| 200 | 0.611 ms | 1,514 | 0.108 ms | 9,057 | 1.0 / 1.0 | ~5.7x |
Recall is essentially identical to hnswlib at matched parameters — the algorithm does the same
job either way. The latency gap is real and named, not hidden: HnswIndex.search copies the
query into an owned Vec<f32> and builds a Python dict per hit even when metadata is unused,
costs hnswlib's bare-array return doesn't pay. A search_many batched entry point (amortizing
marshaling cost over N queries) is the obvious next lever and is not implemented yet. See the
ADR's M1.5 benchmark section for an earlier, worse comparison on adversarial random-Gaussian data
and the two real bugs (a dead ef_search per-call kwarg, a 4x overfetch) found and fixed while
chasing this number down — those fixes are already reflected in the table above.
Reproduce: crates/ruvector-py/benchmarks/bench_hnsw_real_embeddings.py (this table;
needs pip install hnswlib sentence-transformers scikit-learn) and
bench_compare_rabitq.py (the earlier RabitqPlus-vs-hnswlib random-Gaussian comparison in the
ADR, superseded as the default backend but kept for the record; needs pip install hnswlib).
Both are standalone scripts, not part of the pytest suite (too slow for CI, and the point is a
real side-by-side against a comparator that isn't always installed) — run with
python benchmarks/bench_hnsw_real_embeddings.py from crates/ruvector-py/. ruvector benchmark
in the CLI gives you ruvector's own numbers in-process without a comparator installed.
docs/adr/ADR-352-ruvector-python-sdk-cli-mcp.md— full design rationale, capability-coverage table, security findings, both benchmark writeupsdocs/sdk/LOOP-STATE.md— development resume-point notes (gotchas, what's deferred and why)examples/python-salesforce-agentforce/— Salesforce setup walkthrough