# ruvector for Python 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](../../README.md). 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`](../adr/ADR-352-ruvector-python-sdk-cli-mcp.md) — read that for the "why", this guide for the "how". ![Animated tutorial: install, write, reopen, verify](../../assets/ruvector/tutorial-steps.svg) [Examples hub](../../examples/README.md) · [Main quick starts](../../README.md#quick-start-choose-your-track) · [Package manifest](../../crates/ruvector-py/pyproject.toml) ## Install 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. ```bash 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. ## Quick start ```python 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") ``` ## SDK reference ### `Collection` The main entry point — ids, metadata, filtering, soft-delete, and persistence over either backend. ```python 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) -> int ``` `filter` 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. ### Choosing a backend - **`hnsw` (default)** — approximate nearest-neighbor via `hnsw_rs`, metadata-aware, dict-filter evaluated in Rust. Good default for most workloads; see the benchmark table below for where it stands against `hnswlib`. - **`rabitq`** — RaBitQ+ symmetric 1-bit quantized scan with an exact f32 rerank pass (`rerank_factor` controls how many candidates get the exact rerank). Ids are stored as `u32` internally (0..2³²-1) — `Collection` raises `CollectionError` if you try to use an id outside that range with this backend. `coll.metric` always reports `"squared_l2"` for this backend (it doesn't support a configurable metric, unlike `hnsw`). ### Lower-level classes `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. ### Graph, GNN/attention rerank, clustering, SONA 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: ```python 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 anything ``` ### Error handling Every 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). ```python from ruvector import RuVectorError try: coll.search(wrong_dim_vector, k=5) except RuVectorError as e: ... ``` ## CLI ```bash 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. ## MCP server + ChatGPT `ui://` widget ```bash 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 ``` ### Tools | 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`). ### Auth (streamable-HTTP only; stdio has no network surface to protect) 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: ```bash export RUVECTOR_MCP_TOKEN="a long random secret, not committed anywhere" ruvector serve --http ``` ```bash curl -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). ### ChatGPT Apps SDK widget `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. ## Integrations ### LangChain ```bash maturin develop --release --extras langchain ``` ```python from 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`. ### LlamaIndex ```bash maturin develop --release --extras llamaindex ``` ```python from 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.VectorStoreQuery ``` Verified 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 `ruvector` bug**: `llama-index-core` depends directly on > `nltk`, which has an open, unpatched advisory (`PYSEC-2026-3740`, a file-sandbox bypass in > `nltk`'s own model-persistence helpers) as of this writing. `ruvector.integrations.llamaindex` > never calls the affected APIs, but if you separately use `llama-index-core`'s NLP features in > the same process, that exposure is real and not something `ruvector` can pin around. ### Salesforce Agentforce ```bash maturin develop --release --extras salesforce ``` Exposes 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). ```bash export RUVECTOR_SALESFORCE_ACTION_TOKEN="a long random secret" export RUVECTOR_ENABLE_SALESFORCE_ACTIONS=1 ruvector serve --http --port 8420 ``` This 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 ` (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: ```python 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/`](../../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. ## Perf tips - **Prefer `insert_batch`/`from_vectors` over a Python loop of `insert`** calls — one `py.detach()` GIL release and one Rust-side batch op instead of N round trips. - **Pass C-contiguous `float32` NumPy arrays** — `ruvector` reads vectors via `PyReadonlyArray` (no copy) but requires contiguity; a non-contiguous array (e.g. certain slices) raises `TypeError` rather 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 `hnsw` backend are evaluated in Rust per candidate; a callable always round-trips to Python per candidate regardless of backend. - **`filter=None` takes a fast path** on the `hnsw` backend — no overfetch beyond exactly `k` candidates from Rust. This was a real perf fix (previously always overfetched 4x even unfiltered) — see the ADR's M2 benchmark section. - **`ruvector --help` stays 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 extending `cli.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; `stdio` was never affected (one request at a time regardless). ## Benchmarks 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` 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. ## Further reading - [`docs/adr/ADR-352-ruvector-python-sdk-cli-mcp.md`](../adr/ADR-352-ruvector-python-sdk-cli-mcp.md) — full design rationale, capability-coverage table, security findings, both benchmark writeups - [`docs/sdk/LOOP-STATE.md`](../sdk/LOOP-STATE.md) — development resume-point notes (gotchas, what's deferred and why) - [`examples/python-salesforce-agentforce/`](../../examples/python-salesforce-agentforce/) — Salesforce setup walkthrough