A hybrid search engine combining BM25 text search, sparse vectors (SPLADE), and dense vectors (global IVF-TQ with HNSW coarse routing) in a single embeddable Rust library. Runs natively, over gRPC, in browsers via WASM, and over IPFS.
| Feature | Hermes | Tantivy | Qdrant | Elasticsearch |
|---|---|---|---|---|
| BM25 Full-text search | Yes | Yes | No | Yes |
| Dense vectors (ANN) | Yes (IVF-TQ + HNSW) | No | Yes | Plugin |
| Sparse vectors (SPLADE) | Yes (native) | No | Partial | No |
| WASM / Browser | Yes | No | No | No |
| IPFS storage | Yes | No | No | No |
| Embeddable library | Yes | Yes | No | No |
| Package | Description | Distribution |
|---|---|---|
hermes-core |
Core search engine library | crates.io |
hermes-server |
gRPC server for remote search and indexing | crates.io |
hermes-tool |
CLI for index management and data processing | crates.io |
hermes-wasm |
WASM bindings for browser search and indexing | npm |
hermes-web |
Vue/WASM search UI | Workspace application |
hermes-model-lab |
Standalone local LLM trace and observability UI | Workspace application |
hermes-client-python |
Async Python gRPC client | PyPI |
hermes-client-typescript |
TypeScript gRPC client | npm |
hermes-proto |
Shared gRPC protocol definition | Source package |
hermes-mal |
Model Architecture Language parser and bundled model configs | crates.io |
hermes-mal-python |
Python bindings for the shared MAL parser | Python extension |
hermes-tokenizer |
Stable-Rust byte-level BPE tokenizer | crates.io |
hermes-llm |
Shared model, inference, generation, and accelerator kernels | Workspace crate |
hermes-train |
Training CLI for the shared LLM implementation | Workspace crate |
cargo install hermes-tool
# Create an index from an SDL schema
hermes-tool create -i ./my_index -s schema.sdl
# Index documents from JSONL (with progress logging every 50k docs)
cat documents.jsonl | hermes-tool index -i ./my_index --stdin -p 50000
# Or from compressed files with optimization mode
zstdcat dump.zst | hermes-tool index -i ./my_index --stdin -O performance
# Commit, merge, and inspect
hermes-tool commit -i ./my_index
hermes-tool merge -i ./my_index
hermes-tool info -i ./my_indexuse hermes_core::{
Index, IndexConfig, MmapDirectory, Document,
index_json_document, parse_single_index,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create index from SDL
let dir = MmapDirectory::new("./my_index");
let schema = parse_single_index(r#"
index articles {
field title: text<en_stem> [indexed, stored]
field body: text [indexed]
field views: u64 [indexed, stored]
}
"#)?;
let index = Index::create(dir, schema, IndexConfig::default()).await?;
// Add documents
let mut writer = index.writer();
for json in [
serde_json::json!({"title": "Hybrid Search", "body": "BM25 meets vectors", "views": 42}),
serde_json::json!({"title": "WASM Search", "body": "Search in the browser", "views": 100}),
] {
index_json_document(&writer, &json).await?;
}
writer.commit().await?;
// Search
let results = index.query("hybrid search", 10).await?;
for hit in &results.hits {
let doc = index.get_document(&hit.address).await?;
println!("{:.4} {:?}", hit.score, doc);
}
Ok(())
}# Run with Docker
docker build -t hermes-server -f hermes-server/Dockerfile .
docker run -p 50051:50051 -v ./data:/data hermes-server --data-dir /data
# Or install directly
cargo install hermes-server
hermes-server --addr 0.0.0.0:50051 --data-dir ./dataFor production indexes, BP reorder resources are controlled independently:
hermes-server --data-dir /data \
--search-threads 12 \
--max-concurrent-searches 6 \
--optimizer-threads 16 \
--optimizer-concurrent-passes 1 \
--optimizer-max-unconverged-passes 3 \
--bp-memory-budget-mb 24576--search-threadsbounds the process-wide CPU pool shared by nested search work across every index; it defaults to one quarter of detected CPUs.--max-concurrent-searchesbounds simultaneous search pipelines and rejects overload promptly instead of queueing an unbounded number of decoded RPCs. Result windows, fusion/reranker work, query-tree expansion, vector payloads, stored-field hydration, and response bytes also have explicit server-side budgets; see Search resource controls.--optimizer-threadsis the width of one process-wide Rayon pool shared by every index and BP path. An active pass intentionally keeps that pool busy; lower this value when search or indexing needs more CPU.0disables the periodic optimizer, while merge-time and manual BP use the bounded fallback pool.--optimizer-concurrent-passeslimits whole-segment passes across the optimizer, merge-time reorder, and manual reorder. It is hard-capped at two, because each pass can use the complete shared pool and its own memory budget. Explicit force merge pauses new background BP admission and reserves foreground capacity once existing merges have drained.--optimizer-max-unconverged-passesis a hard eligibility bound for optimizer follow-up on one budget-truncated replacement lineage (the default3includes the initial partial pass). This prevents a segment that cannot converge within its budget from keeping the optimizer BP pool busy forever.--bp-memory-budget-mbbounds the main per-pass algorithmic working set: document maps, the BP forward graph and degree arrays, and record-rewrite grid/encode windows. Over-budget record passes fall back to block order and graph dimensions are trimmed. It is not a total-process RSS cap: readers, mmap/page-cache residency, output buffering, merge state, and indexing are additional.
Segment publication, replacement, reader retirement, orphan cleanup, failure
backoff, and index deletion follow one ownership protocol. Missing files that
are still referenced by metadata are quarantined instead of being retried in a
tight merge loop; start once with --doctor only when you intentionally want
to remove those corrupt metadata entries. See
Segment lifecycle and recovery and the full
server options.
Commit publication is cancellation-safe: after workers flush, an owned finalizer carries metadata publication, primary-key refresh, and worker resume to completion even if the client disconnects. A pre-publication storage error keeps that generation paused and retryable instead of mixing it with new input.
Vector indexes can be switched atomically between IVF and ScaNN with the
AlterVectorIndex gRPC method. The request identifies the field and supplies
its replacement SDL type/options; the response reports BUILT,
DEFERRED_FLAT (the hardcoded geometry-derived training floor has not been
reached), or PARAMETERS_ONLY. See the schema reference.
Python client:
from hermes_client_python import HermesClient
async with HermesClient("localhost:50051") as client:
await client.create_index(
"articles",
"""
index articles {
field title: text<en_stem> [indexed, stored]
field body: text [indexed, stored]
}
""",
)
await client.index_documents(
"articles",
[
{"title": "Hybrid Search", "body": "Combining BM25 with vectors"},
],
)
await client.commit("articles")
results = await client.search(
"articles", query={"match": {"field": "title", "text": "hybrid"}}
)
for hit in results.hits:
print(hit.score, hit.address)Hermes compiles to WebAssembly and can search indexes hosted over HTTP or IPFS directly in the browser, with IndexedDB-backed slice caching for near-zero cold-start latency on repeat visits.
import init, { RemoteIndex, IpfsIndex } from "hermes-wasm";
await init();
// HTTP: load from any static file server
const index = new RemoteIndex("https://example.com/my_index");
await index.load();
// IPFS: load from content-addressed storage via verified-fetch
const ipfsIndex = new IpfsIndex("/ipfs/QmYourCID");
await ipfsIndex.load(fetchFn, sizeFn);
// Search (same API for both)
const results = await index.search("hybrid search", 10);
console.log(results);
// Persist cache to IndexedDB for instant reload
await index.save_cache_to_idb();Unified hybrid search -- BM25 text ranking, SPLADE sparse vectors, and global IVF-TQ dense vectors share the same index, segments, and query pipeline. No sidecar services required.
6 posting list formats -- Adaptive format selection per list: HorizontalBP128, VerticalBP128, Elias-Fano, Partitioned Elias-Fano, Roaring bitmaps, and OptP4D. The engine picks the best format based on list density and length.
Block-Max MaxScore -- Top-k retrieval uses MaxScore partitioning (Turtle & Flood 1995) combined with block-max pruning (Ding & Suel 2011) and conjunction optimization. A single unified MaxScoreExecutor handles both BM25 text and sparse vector queries.
Multi-value combiners -- Documents with multiple vectors per field (e.g., chunked passages) are scored with configurable strategies: Sum, Max, Avg, LogSumExp (smooth approximation), or WeightedTopK with exponential decay.
Matryoshka reranking -- L2 reranker supports Matryoshka dimensionality reduction: scores candidates on leading dimensions first, then full-dimension exact scoring on survivors only. Skips 50-70% of cosine computations.
SOAR multi-probe -- IVF-TQ indexes default to Google's SOAR (Spilling with Orthogonality-Amplified Residuals) in selective mode, calibrating one secondary assignment for at most 30% of vectors; soar: off disables it explicitly.
SimHash dedup pipeline -- Stream-oriented CLI tools for near-duplicate detection: pipe through simhash, sort, then index to deduplicate million-document corpora before indexing.
18 language stemmers -- Snowball stemmers for Arabic, Danish, Dutch, English, Finnish, French, German, Greek, Hungarian, Italian, Norwegian, Portuguese, Romanian, Russian, Spanish, Swedish, Tamil, and Turkish. Plus HuggingFace tokenizer integration.
Storage abstraction -- Filesystem (mmap), HTTP (range requests), RAM, IPFS (JS fetch callbacks), and slice-caching directories. The same index binary works across all backends.
Hermes uses a Schema Definition Language (SDL) to define index structure:
index articles {
field url: text [indexed, stored, primary]
field title: text<en_stem> [indexed, stored]
field body: text [indexed]
field author: text<raw_ci> [indexed, stored]
field published_at: u64 [indexed, stored]
field embedding: dense_vector<768> [stored]
field sparse_embedding: sparse_vector [indexed]
}
Field types: text, u64, i64, f64, bytes, json, dense_vector<dim>, sparse_vector
Attributes: indexed, stored, primary, fast
Tokenizers: default, simple, raw, raw_ci, en_stem, de_stem, fr_stem, es_stem, it_stem, pt_stem, ru_stem, ar_stem, and more.
Full SDL reference: docs/schema.md
- Rust 1.98+ (see
rust-toolchain.toml) - Python 3.12+ (for Python client and bindings)
- Node.js 20+ (for WASM and web UI)
- pnpm 10+ (for TypeScript and web projects)
- uv and maturin (for Python projects)
- wasm-pack (for WASM builds)
- protoc (for gRPC)
# Build all Rust packages
cargo build --release
# Build WASM (requires Homebrew LLVM on macOS for zstd cross-compilation)
cd hermes-wasm && bash build.sh
# Build the Python gRPC client
cd hermes-client-python && uv build
# Build the MAL Python binding
cd hermes-mal-python && maturin build --releaseAlternatively you may build everything in docker via docker compose.
Examples:
docker compose run --rm cargo-builddocker compose run --rm build-hermes-wasm
cargo test --workspaceLLM contributors should start with the inference and training code map. Temporary official-repository GPU revisions and their release exit criteria are listed in the upstream dependency register. Backend-specific Metal and CUDA checks are documented in those guides; enabling every backend at once is not the portable test configuration.
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
# Or run the commit and push hooks
pip install pre-commit
pre-commit install
pre-commit run --all-files
pre-commit run --all-files --hook-stage pre-pushMIT