Skip to content

Technology stack

archAIc is a Rust engine wrapped in a PHP UI, served from Docker, optionally backed by a self-hosted LLM. Nothing in the stack is exotic, but several choices look unusual at first glance. This page explains what we picked and why.

The core is a Cargo workspace of around a dozen crates. The crates that show up in the runtime hot path:

LibraryUsed for
axum 0.8HTTP server in gd-server and gd-mcp. Tower middleware stack, native async.
utoipaOpenAPI 3.1 schema generation from Rust types. The Swagger UI under /swagger-ui/ is generated; the spec is /api-docs/openapi.json.
heedSafe Rust bindings over LMDB. The lexical index uses thirteen named databases inside a single environment.
fst 0.4Burntsushi’s Finite State Transducer crate. Prefix and Levenshtein search over the vocabulary.
roaring 0.11RoaringBitmap. Compressed per-word page sets, allowing fast Boolean set operations over postings.
rkyv 0.8Zero-copy archive format. Per-page payloads are deserialised in place, no allocator pressure on the hot path.
rusqliteThe corpus DB (corpora.db). Bundled SQLite to keep the deploy a single artefact.
qdrant-clientgRPC client for the Qdrant vector store.
ort 2.0ONNX Runtime for embedding inference. Optional CUDA feature flag.
rmcpThe Model Context Protocol server library.
tokioAsync runtime everywhere outside the index builder.
rayonData-parallel index builder. The build step is CPU-bound and parallel-friendly.

Today the deployed UI is plain PHP 8 served by Apache (the dipucoru deployment). An in-progress UI branch is modernising it toward Twig templates, typed DTOs, Pest-driven tests, Lit web components for shared widgets, and FrankenPHP as the planned application server; that branch deploys side-by-side under nginx as newdipucoru during the migration.

Choosing PHP for the UI is pragmatic. The first archAIc deployments inherited an existing PHP frontend from the C++ engine era, and the customer ops people who run archAIc already know PHP + Apache. We invested in modernising it (DTOs, Twig, Pest, FrankenPHP) instead of rewriting in a different language. The Lit components are the migration path: every interactive surface that needs to be sharable (search results, page viewer, DLU editor, IIIF mini-Mirador) becomes a web component the UI mounts and any third party can embed.

archAIc’s natural-language Q&A surface and curator-assist autofill rely on a local LLM. The default is Ollama running qwen3:32b on the customer’s own hardware. vLLM is the graduation path when one tenant outgrows a single GPU host. Cloud LLM APIs are supported through cfg_llm_profile but explicitly not recommended for archives bound by GDPR or sovereignty constraints.

The LLM is optional. KWS, semantic search, every standards-shaped export, IIIF, and MCP search all work without it. Only the natural-language Q&A endpoints and the curator autofill require Ollama (or equivalent) to be reachable.

See LLM setup for the install and configuration steps.

Everything ships as Docker images and is composed with docker-compose.yml. The compose file exposes profiles (rust, cpp, both, observability) so an operator starts only what they need. See deployment overview for the compose surface.

A keyword search request lands on gd-server, walks through the parser, hits the engine’s query cache, runs resolution and hydration, and returns JSON. The diagram below maps each step to the crate and module that owns it.

End-to-end query flow through gd-server, gd-parser, and gd-engine's search module — row 0 is the request/response path through four crates, row 1 is the two-phase internal call into resolution and hydration, row 2 is the data sources (LMDB, FST, scoring).

The hydration step does as little work as the response depth allows. Depth 0–1 stays in bitmap operations and skips per-page reads entirely; depth 2 reads per-word summaries; depth 3 (Full) loads per-hit detail via rkyv and the L2 cache.

Data flow by response depth — three columns showing the LMDB databases read for each depth tier, from bitmap-only grouping at depth 0–1 through per-word summaries at depth 2 to full per-hit detail at depth 3.

Elasticsearch (and OpenSearch, and Solr) treats probability as a filter after retrieval: rank by BM25, optionally re-rank with a model. archAIc’s queries operate over probabilities. Every word hypothesis carries a confidence, and a useful KWS engine integrates over those probabilities at scoring time rather than discarding all but the top-ranked hypothesis. Bolting that on top of Lucene was tried in the C++ predecessor and never matched the precision of an integrated implementation. The Rust engine treats probability as first-class data, which means we can return word-level confidence with every hit and let the UI render score histograms per query.

The second reason is footprint. The Rust engine holds the RMCR corpus (around 347K pages) at roughly 50 MB resident. The C++ predecessor that used MARISA tries plus DAWG fuzzy automata sat at about 689 MB on the same data. Elasticsearch on the same corpus is in the multi-gigabyte range. For a customer running on a workstation-class box the engine footprint matters.

The lexical index is shaped like a sorted-key store with many small, random reads over compressed postings. LMDB is memory-mapped, single-writer / many-reader, and lets the operating system page-cache exactly what stays resident. The FST plus RoaringBitmap layout is more compact and more cache-friendly than the equivalent B-tree shapes a relational store would build. Postgres is excellent for the corpus ledger (collections, libros, pages, DLUs, audit, config), but we use SQLite for that part because it ships as a single embedded file and removes one moving part from the deployment. Qdrant runs as a separate process because vector storage is its own beast.

archAIc’s latency budget is sub-millisecond per hot query, not measured in cold-start or first-token-out time. Pure Python KWS implementations exist (lucy, whoosh, custom NumPy code) and serve fine at small scale, but the GIL and per-call allocation cost make sustained 20K queries-per-second cold-cache benchmarks impossible. Rust gives us the latency floor for the engine, and an embedding service in Python (via ort from Rust) loses too much to FFI overhead. We use Python only for ingestion-side tooling and offline scripts.