Skip to content

AI and agents

PyRust can't speed up LLM API calls or GPU inference. Those are waiting and GPU time. It does speed up the CPU work around them: retrieval, text processing, scoring and evaluation. Agents run that work over and over.

Ready-made functions: pyrust_engine.ai

from pyrust_engine.ai import BM25, chunk_text, cosine_top_k, levenshtein, minhash, rouge_l, token_f1, tokenize
Area Functions Use for
Retrieval cosine_top_k(query, matrix, k), top_k(scores, k), BM25(docs).search(q, k) embedding search (float32 or float64, all CPU cores), keyword search
Compact vectors hamming_top_k, int8_top_k, binarize, quantize_int8 binary (32x smaller) and int8 (4x smaller) embeddings
rank_bm25 users BM25Okapi(corpus) same API and the same scores as rank_bm25.BM25Okapi, much faster
Batches batch(fn, items), tokenize_many, chunk_many many documents on all CPU cores
Text tokenize, chunk_text(text, size, overlap), ngrams, levenshtein, similarity, jaccard RAG indexing, fuzzy matching, cleaning
Deduplication minhash(tokens), minhash_similarity(a, b) near-duplicate documents
Evaluation token_f1, exact_match, rouge_l QA and summarization metrics

Every function is plain Python compiled by PyRust. Its result is exactly what its Python source returns, and it runs as Python where Rust isn't installed. Text functions are fastest on ASCII text. Other characters are handled exactly, though some steps then run in Python.

Hybrid search for RAG

from pyrust_engine.ai import BM25, chunk_text, cosine_top_k

chunks = [c for doc in documents for c in chunk_text(doc, 800, 100)]
keyword = BM25(chunks)
vectors = embed(chunks)                          # your embedding model, float32 (n, d)

def search(query, k=5):
    ids_kw, _ = keyword.search(query, 20)
    ids_vec, _ = cosine_top_k(embed([query])[0], vectors, 20)
    fused = {}
    for ranking in (ids_kw, ids_vec):            # reciprocal rank fusion
        for rank, i in enumerate(ranking.tolist()):
            fused[i] = fused.get(i, 0.0) + 1 / (61 + rank)
    return [chunks[i] for i in sorted(fused, key=lambda i: -fused[i])[:k]]

All searches take mask=, a bool array over the rows, to filter by tenant, user, date or document type:

allowed = tenant_ids == current_tenant          # NumPy bool array, one value per chunk
ids, scores = cosine_top_k(query_vec, vectors, 5, mask=allowed)
ids, scores = keyword.search(query, 5, mask=allowed)

The full runnable version is examples/rag_retriever.py.

When NumPy is already the right tool

A dense matrix–vector product (matrix @ query) runs in optimized BLAS. cosine_top_k is competitive with it and avoids temporary arrays, but the benchmarks show both honestly. PyRust's bigger wins are where NumPy can't help: per-token loops, dicts, strings and branching logic.

Replacing rank_bm25

rank_bm25 is pure Python: building the index and every query loop over all documents in Python. BM25Okapi has the same constructor, methods and attributes, and returns the same scores bit for bit (CI compares them with the real package), so switching is one import:

from pyrust_engine.ai import BM25Okapi          # was: from rank_bm25 import BM25Okapi

bm25 = BM25Okapi(tokenized_corpus)
scores = bm25.get_scores(tokenized_query)
top = bm25.get_top_n(tokenized_query, corpus, n=5)

LangChain's BM25Retriever builds a rank_bm25 index by default; pass ours instead:

from langchain_community.retrievers import BM25Retriever

retriever = BM25Retriever(vectorizer=BM25Okapi([d.page_content.split() for d in docs]), docs=docs, k=4)

Keeping an index up to date

index = BM25(chunks)
new_ids = index.add(new_chunks)                  # ids of existing documents never change
index.remove([12, 40])                           # removed ids are never returned again
index.save("index.npz")                          # NumPy file, no pickle
index = BM25.load("index.npz")                   # server start: no re-tokenizing

After add() and remove(), scores are exactly those of a fresh index over the live documents. Add documents in batches: each call rebuilds the scoring tables once (NumPy, fast), and BM25Okapi keeps rank_bm25's idf rule (epsilon times the average idf) over the live documents.

Binary and int8 embeddings

Binary codes keep one bit per dimension; int8 codes keep one byte. Search a large first pass with them, then re-rank the top candidates with full float vectors if needed:

from pyrust_engine.ai import binarize, hamming_top_k, int8_top_k, quantize_int8

codes = binarize(vectors)                          # (n, d/8) uint8
ids, distances = hamming_top_k(binarize(query_vec), codes, 100)

q8, scale = quantize_int8(vectors)                 # (n, d) int8 and one scale
ids, scores = int8_top_k(quantize_int8(query_vec, scale)[0], q8, 10)

Both read the matrix in place, 8 bytes at a time, on all CPU cores. With NumPy, int8 vectors must be cast to int32 first (int8 @ int8 overflows), which copies the whole matrix on every query; Hamming distance needs np.bitwise_count over a temporary array. Results are exact: distances and integer dot products, ties broken by the lower row index.

Many documents at once

from pyrust_engine.ai import batch, chunk_many, tokenize_many

tokens = tokenize_many(documents)                  # same as [tokenize(d) for d in documents]
chunks = chunk_many(documents, 800, 100)
embedded = batch(my_cpu_function, items, workers=8)

Compiled functions release the GIL while they run, so their work runs in parallel. On free-threaded Python (3.14t) everything does. The speedup with the GIL depends on how much of each call is native work; the benchmarks show the measured number.

Your own code: @optimize and @vectorize

The usual AI-app hot spots now compile:

from pyrust_engine import optimize

@optimize
def keyword_hits(text: str, keywords: frozenset[str]) -> dict[str, int]:
    counts = {}
    for word in text.lower().split():
        word = word.strip('.,!?')
        if word in keywords:
            counts[word] = counts.get(word, 0) + 1
    return counts

@optimize
def trim_to_budget(tokens: list[str], budget: int) -> list[str]:
    out = []
    used = 0
    for t in tokens:
        cost = len(t) // 4 + 1
        if used + cost > budget:
            break
        out.append(t)
        used += cost
    return out

For coding agents

Command line, with JSON output

python -m pyrust_engine suggest app/scoring.py --json     # per function: compiles? worth it? what to change
python -m pyrust_engine apply app/scoring.py              # diff that adds @optimize where it compiles
python -m pyrust_engine apply app/scoring.py --write      # edit the file

suggest reports for each function and class method whether it compiles, whether it has a loop (if not, native code can't help), and the action to take. When something blocks compilation, it gives the line, the code and a rewrite hint. apply only decorates functions that compile and have a loop, adds the import, and never writes a file that doesn't parse.

MCP server

claude mcp add pyrust -- python -m pyrust_engine mcp

Other MCP clients use python -m pyrust_engine mcp (or pyrust-mcp) as a stdio server command.

Tool What it returns
check_code per function: compiles or not, parameter/return types, the blocking line, reason and hint
suggest_file the suggest report for a file
apply_file a diff adding @optimize; writes only with write: true
rust_source the generated Rust for a function
benchmark plain Python vs PyRust time for one call, and whether the results match
doctor whether Rust is installed and working

check_code and rust_source also check class methods (Particle.advance), with attribute types taken from the class. The server speaks MCP 2025-11-25: each tool has a title and hints (apply_file is marked as changing files; tools that run the given code are not marked read-only), and every result also comes as structuredContent.

check_code, rust_source and benchmark execute the code they are given, so use the server on trusted code.

Instructions for assistants

AGENTS.md gives coding assistants the workflow, the good and poor candidates, and a table of rewrites that make code compile. llms.txt is the short version for LLM context.

Serving

Compiled calls release the GIL. In a FastAPI app, run them in a worker thread so the event loop keeps serving requests:

distance = await asyncio.to_thread(levenshtein, a, b)

examples/fastapi_app.py has search, similarity and a compiled scoring rule. To deploy without Rust on the server, examples/docker/Dockerfile compiles the functions in a build stage and ships only the prebuilt libraries.