In plain English. Hermes Agent uses two small "search" models: an embedder that turns text into vectors so it can find the 15 most likely skills for a request, and a reranker that reads each of those 15 next to the request and re-scores them. We tested the engines you can run them with on a Mac (llama.cpp, oMLX, plain MLX scripts) on two Macs, an M4 Max and an M5 Max, and then on an NVIDIA DGX Spark (llama.cpp CUDA and vLLM) for comparison.
- Embedding: use llama.cpp. Same answers as MLX within noise (nDCG@10 0.870 vs 0.869 on SciFact), as fast per query (89 vs 90 ms on the M4, 56 vs 58 ms on the M5), the best batch throughput, and the smallest memory footprint.
- Update llama.cpp on an M5. Current llama.cpp (b12032) turns on the M5's GPU neural accelerators; the build we had (b8980) did not. Same Mac, same model: 986 → 2,985 tokens/s prompt processing (3.0×). On the M4 Max the two builds are identical.
- Reranking: use a batched engine, not a per-document loop. On the M4 our prefix-batched MLX server and oMLX both answer a Hermes skill lookup in about 0.61–0.64 s; llama.cpp takes 1.25 s and the old one-document-at-a-time script 1.42 s. On the M5 the same call is 195 ms with ours, 467 ms with oMLX, 600 ms with the old script and 701 ms with llama.cpp.
- The surprise: the 4B reranker did not improve results here. After the 8B embedder, reranking the top 20 lowered SciFact nDCG@10 from 0.883 to 0.860, and left Hermes skill routing flat (0.953 → 0.949). Same on every engine, so it is the model, not the engine. Giving it Qwen's SciFact instruction instead of the default made it worse still (0.847).
- For Hermes skill routing, even the 0.6B embedder is enough on our 40-query set (0.951 vs 0.953 nDCG@10) at about 12 ms per query instead of 89 ms on the M4. It is clearly worse on the harder SciFact task (0.797 vs 0.870).
- The DGX Spark is the fastest box we tested for this, if you use vLLM: a Hermes skill lookup in 128 ms (M5 Max: 195 ms, M4 Max: 612 ms), and 4 parallel embedding clients at 132 ms each. llama.cpp on the Spark is faster for one embedding at a time (48 ms) but slower at reranking (562 ms). Quality is the same on every box.
- Most Hermes users don't run anything this big. Hermes itself uses keyword search (SQLite FTS5, BM25) for sessions and skills; people who add semantic memory mostly run a 0.3–4B embedder through Ollama or llama.cpp, and rerankers are optional. Details in What Hermes power users run.
Bottom line: llama.cpp for embeddings, a batched reranker (ours or oMLX) if you keep one, and a current llama.cpp build on any M5.
What Hermes uses these for
Our Hermes setup has a find_skill tool. It embeds the request with Qwen3-Embedding-8B, takes the 15 closest of
about 250 skills from a prebuilt index, then asks Qwen3-Reranker-4B to score each name — description pair against
the request. The skill index is rebuilt by embedding every skill description. Until today both models lived on the M5 Max; we moved
them to the M4 Max so the M5 can become a travel laptop, and used the move to test the options properly.
Setup
| Item | Value |
|---|---|
| Macs | Mac Studio M4 Max, 64 GB (our Hermes / voice box, so other work shares its GPU) · MacBook Pro M5 Max, 128 GB (otherwise idle) |
| Models | Qwen3-Embedding-8B (Q8_0, Q4_K_M GGUF; mxfp8 MLX), Qwen3-Embedding-0.6B (Q8_0), Qwen3-Reranker-4B (Q8_0 GGUF; mxfp8 MLX) |
| Engines | llama.cpp b12032 (release build) · oMLX 0.7.3 · MLX 0.32.4 / mlx-lm 0.32.2 / mlx-embeddings 0.1.0 scripts · our prefix-batched MLX reranker |
| Quality data | Hermes skill routing: 40 hand-written requests, one correct skill each, 253 skills · SciFact-lite: all 300 SciFact test claims, every relevant abstract (283) plus 1,700 random others (1,983 docs, cut to 512 tokens) |
| Reranking quality | Rerank the top 20 from the llama.cpp Q8_0 embedder; 100 SciFact claims (fixed sample) and all 40 Hermes requests; default Qwen3 reranker instruction |
| Speed | One pass per Mac, client on the same machine, warm server: 50 single queries; 100 requests from 4 parallel clients; 64 documents in batches of 32; 50 Hermes find_skill calls; 15 calls of query + 20 SciFact abstracts |
Embedding results
| Engine | Model | Hermes skills nDCG@10 | SciFact-lite nDCG@10 | Δ vs llama.cpp Q8_0 (95% CI) | Query p50, M4 | Query p50, M5 | 4 clients p50, M4 / M5 | Docs/s batch, M4 / M5 | Memory, M4 |
|---|---|---|---|---|---|---|---|---|---|
| llama.cpp | 8B Q8_0 | 0.953 | 0.870 | reference | 89 ms | 56 ms | 345 ms / 214 ms | 3.3 / 8.3 | 2636 MB |
| oMLX 0.7.3 | 8B mxfp8 | 0.926 | 0.869 | -0.002 [-0.009, +0.006] | 90 ms | 58 ms | 351 ms / 224 ms | 2.3 / 6.7 | 10 GB |
| mlx-embeddings script | 8B mxfp8 | = oMLX (identical vectors) | = oMLX (identical vectors) | — | 90 ms | 57 ms | 293 ms / 174 ms | 2.3 / 6.7 | 18 GB |
| llama.cpp | 8B Q4_K_M | 0.953 | 0.867 | -0.004 [-0.011, +0.004] | 93 ms | 57 ms | 362 ms / 222 ms | 3.1 / 8.2 | 2634 MB |
| llama.cpp | 0.6B Q8_0 | 0.951 | 0.797 | -0.073 [-0.097, -0.052] | 12 ms | 11 ms | 42 ms / 39 ms | 30.0 / 66.5 | 2061 MB |
The mlx-embeddings script and oMLX run the same mxfp8 weights; their vectors are identical (cosine 1.000), so only one
quality run was needed. "Memory" is macOS phys_footprint after the speed run. It leaves out llama.cpp's
memory-mapped weights (about 8 GB for 8B Q8_0), and the MLX numbers include MLX's buffer cache, which the old script never empties.
The 95% interval is a paired bootstrap over the 300 claims.
Reranking results
| Engine | Model | Hermes skills nDCG@10 after rerank | SciFact-lite nDCG@10 after rerank | Same top-1 as llama.cpp | find_skill p50 / p95, M4 | find_skill p50 / p95, M5 | Query + 20 abstracts p50, M4 / M5 | Memory, M4 |
|---|---|---|---|---|---|---|---|---|
| prefix-batched MLX (ours) | 4B mxfp8 | 0.949 | 0.860 | 0.97 | 612 ms / 861 ms | 195 ms / 266 ms | 4.46 s / 1.33 s | 5665 MB |
| per-doc MLX script (old M5 lane) | 4B mxfp8 | = ours (same scores) | = ours (same scores) | — | 1.42 s / 1.49 s | 600 ms / 618 ms | 8.44 s / 1.69 s | 50 GB |
| oMLX 0.7.3 | 4B mxfp8 | 0.949 | 0.858 | 0.97 | 644 ms / 764 ms | 466 ms / 527 ms | 4.11 s / 1.62 s | 5069 MB |
| llama.cpp --reranking | 4B Q8_0 | 0.949 | 0.859 | reference | 1.25 s / 1.39 s | 701 ms / 740 ms | 4.50 s / 1.95 s | 4932 MB |
Before reranking, the llama.cpp 8B Q8_0 embedder scored 0.953 (Hermes skills) and 0.883 (the same 100 SciFact claims). The per-document script was only timed: it computes the same scores as our batched server, one document at a time.
DGX Spark (GB10)
Same harness and data, client on the Spark, one idle node. llama.cpp is the official b12032 CUDA 13.4 arm64 release build;
vLLM is 0.29 in pooling mode with the original bf16 weights (the reranker loaded with Qwen's documented
Qwen3ForSequenceClassification override, and the client sends Qwen's reranker prompt).
| Engine | Model | Hermes skills nDCG@10 | SciFact-lite nDCG@10 | Speed (p50) |
|---|---|---|---|---|
| llama.cpp b12032 (CUDA) | Embedding-8B Q8_0 | 0.953 | 0.874 | 48 ms one query · 180 ms with 4 clients · 11.1 docs/s |
| vLLM 0.29 (pooling) | Embedding-8B bf16 | 0.953 | 0.870 | 73 ms one query · 132 ms with 4 clients · 28.8 docs/s |
| llama.cpp b12032 (CUDA) --reranking | Reranker-4B Q8_0 | 0.949 | 0.862 | 562 ms find_skill · 1.35 s for 20 abstracts |
| vLLM 0.29 (pooling) | Reranker-4B bf16 | 0.949 | 0.859 | 128 ms find_skill · 704 ms for 20 abstracts |
- vLLM wins reranking outright on the Spark: 128 ms for a Hermes lookup and 704 ms for 20 abstracts, versus 562 ms and 1.35 s for llama.cpp on the same Spark. It batches all 15 pairs into one forward pass on a GPU that is good at exactly that.
- For embeddings it depends on traffic: llama.cpp answers one query in 48 ms (vLLM 73 ms); with 4 clients at once vLLM holds 132 ms while llama.cpp goes to 180 ms, and vLLM embeds documents in bulk 2.6× faster (28.8 vs 11.1 docs/s).
- bf16 weights did not change the quality story: the bf16 embedder scored 0.870 on SciFact-lite (llama.cpp Q8_0: 0.870–0.874), and the bf16 reranker still lowered SciFact from 0.883 to 0.859. The reranker result is the model, not the quantization.
- vLLM takes about 100 s to start versus 2 s for llama.cpp, which matters only if the lane restarts often.
What Hermes power users run
We read the Hermes docs and plugin catalog, GitHub issues and community repos, and recent X threads. Short version:
- Hermes' own retrieval is keyword-based.
session_searchis SQLite FTS5 with no model calls (docs), and the catalog's skill-retrieval plugin is BM25 with an optional hosted reranker that is off by default. - Semantic search arrives with a memory provider, each with a small local embedder:
Hindsight uses
bge-small-en-v1.5plus a local cross-encoder (Vectorize); qdrant-memory uses Ollamaqwen3-embedding:4b(catalog); Nexus usesQwen3-Embedding-0.6B; lancedb-suite uses Ollamanomic-embed-text; memory-zvec uses Ollamabge-m3; openltm recommendsbge-m3onllama-server --embedding(catalog); the qmd skill downloadsembeddinggemma-300Mplusqwen3-reranker-0.6b(docs). - Engines: Ollama is the common default; people who tune their setup run a second
llama-serverjust for embeddings, often on the CPU (example in issue #129323: a 26 MBbge-small-zh-v1.5on CPU feeding Mem0). We found nobody else running an 8B embedder. - Rerankers split people. One heavily tuned setup calls a
bge-reranker-v2-m3on llama.cpp "the largest single quality lever" for memory recall (andrewalhaj/custom-hermesagent), and a Hindsight guide pairsQwen3-Embedding-0.6Bwith the same reranker (dev.to). Hermes' skill retrieval and hermes-lcm ship reranking off.
That fits our result: a reranker earns its keep behind a weak or noisy first stage (keywords, or hybrid search over messy memory facts). Behind a strong 8B embedder over a few hundred clean skill descriptions it had nothing left to fix.
Five things we learned
- On an M5, the llama.cpp version is the biggest speed lever. b8980 logs "the tensor API is not supported in
this environment - disabling" on the M5 Max; b12032 logs
has tensor = true. llama-bench, 8B Q8_0, 512-token prompts: 986 → 2,985 tokens/s on the M5, 924 on the M4 with either build. Homebrew had left us about 3,000 builds behind. - Reranking is prompt processing, so batching wins. A Hermes lookup is 15 short pairs of about 100 tokens. The old script ran 15 forward passes. Ours prefills the shared prefix (system + instruction + request) once, copies its cache to every document, and scores all 15 in one batched pass. oMLX also batches and lands in the same place. llama.cpp batches too, but scores each pair as its own sequence, so the shared prefix is computed 15 times.
- MLX servers need their buffer cache capped. Every rerank request has a new batch shape, so MLX's cache
never reuses a buffer and grows. Our first version reached 103 GB on the 128 GB M5; the old per-document script reached
50 GB on the M4 in this run. One
mx.clear_cache()per request (plusmx.set_cache_limit) holds ours at about 5 GB with no speed loss. - Quantization barely matters for embeddings. Q8_0, mxfp8 and Q4_K_M are within about 0.004 nDCG@10 of each other on SciFact-lite. Model size matters a lot (0.6B: 0.797).
- Measure the reranker before you pay for it. With a strong 8B embedder in front, the 4B reranker cost time and did not help on either task we ran. That may not hold for other data or a task-specific instruction, but it is cheap to check and we had never checked.
What we run now
| Lane (M4 Max) | Engine | Why |
|---|---|---|
Embeddings :8002 | llama.cpp, Qwen3-Embedding-8B Q8_0 | Ties MLX on quality, as fast per query, highest batch throughput, smallest footprint |
Rerank :8003 | Our prefix-batched MLX server, Qwen3-Reranker-4B mxfp8, cache capped | Fastest find_skill on both Macs (tied with oMLX on the M4). Kept running but no longer called by default |
Hermes find_skill | Embedder only (reranker off; SKILL_RERANK=1 turns it back on) | Same routing on our tests, 0.2–0.6 s faster per call |
Caveats
- This was a quick run (about an hour), not a full study: one speed pass per Mac, SciFact-lite instead of full SciFact, 100 claims for reranking, and only 40 Hermes requests.
- SciFact-lite scores are higher than full-SciFact leaderboard scores because the corpus is smaller. Use them to compare rows here, not against MTEB.
- The M4 Max was not idle: it runs our voice stack and its desktop, and its GPU was busy in the background during the speed pass, so its absolute numbers are pessimistic. Every M4 row saw the same conditions.
- What embedder-only gives up: after we switched, a greeting or off-topic request still returns skills with cosine scores around 0.6. The reranker scored those near zero, which made "no skill fits" easy to detect. Our test sets only contained requests with a correct skill, so they could not measure this.
- The DGX Spark ran each config once on an idle node; the vLLM image is a vLLM 0.29 build we already had on the Sparks, not a stock release.
- The embedder used Qwen's SciFact instruction for queries; the reranker used the default Qwen3 reranker instruction (the instruction llama.cpp's GGUF template hard-codes). As a check we re-ran our reranker with the SciFact instruction on the same 100 claims (on the M4): 0.847, lower than the default's 0.860.
Reproduce
Harness: rigor.py (HTTP, engine-agnostic: quality = nDCG@10 / Recall@100, speed = latency percentiles),
run_cfg.py (starts one engine config, waits for readiness, runs suites, records memory, stops it), and
rerank_server_fast.py (the prefix-batched Qwen3 reranker with the same /v1/rerank API).
Server flags
llama-server -m Qwen3-Embedding-8B-Q8_0.gguf --embedding --pooling last -ngl 99 -fa on -c 16384 -np 8 -b 4096 -ub 4096
llama-server -m Qwen3-Reranker-4B.Q8_0.gguf --reranking -ngl 99 -fa on -c 32768 -np 16 -b 4096 -ub 4096
omlx serve --model-dir <dir> --max-concurrent-requests 8 --memory-guard off
python rerank_server_fast.py --model mlx-community/Qwen3-Reranker-4B-mxfp8 --port 8003 # MLX_CACHE_LIMIT_GB=2 default