VerseAI: a trilingual Bible search engine, built and run by one engineer
VerseAI (verseai.org) is a free, ad-free Bible search and study platform in Korean, English, and Spanish. It combines semantic (embedding-based) verse search, an AI Bible-study assistant, reading plans, and a PowerPoint automation tool for churches — designed, built, and operated end-to-end by a single engineer, and running on one 4 GB VPS.
At a glance
| Frontend | Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS, next-intl |
|---|---|
| Backend | FastAPI (Python), LlamaIndex, FAISS, PostgreSQL, Redis, SQLite |
| Search | Multilingual sentence-transformer embeddings, per-translation FAISS indexes |
| AI search | Prompt-engineered LLM reference resolution — Claude Haiku 4.5 (default), Cohere Command R+ behind the same factory interface; Gemini Flash also implemented |
| AI study | LLM-backed study generation on retrieved verses, per-user daily quota (20/day) |
| Infrastructure | Docker on a single Hetzner VPS (4 GB RAM) |
| Locales | ko / en / es — UI, content, search, and defaults are all per-locale |
| Content | Full Bible text in 3 translations + Hebrew/Greek originals; 1,113 daily-verse entries (371 × 3 locales) |
| Testing | Jest (frontend), pytest (backend), a fixed 90-query search evaluation set |
Architecture
System architecture
flowchart LR
subgraph Client["Browser — Next.js 16 / React 19"]
UI["Trilingual UI<br/>per-locale routes /ko /en /es"]
PPT["PPT tool<br/>client-side .pptx build<br/>pptxgenjs + JSZip — zero upload"]
end
subgraph VPS["Hetzner VPS — Docker, single 4 GB host"]
API["FastAPI + uvicorn<br/>search · llm · auth · groups<br/>reading plans · admin"]
ENG["Search & LLM engines<br/>(engine diagram below)"]
PG[("PostgreSQL<br/>users, groups, plans,<br/>bookmarks, notes")]
RD[("Redis<br/>resolution + result caches")]
FS[("FAISS indexes<br/>one per translation")]
SQ[("SQLite verse stores<br/>one per translation<br/>+ Hebrew/Greek originals")]
end
HF["Hugging Face Hub"]
LLM["LLM APIs<br/>Claude · Cohere · Gemini"]
UI -->|REST / JSON| API
PPT -.->|no server round-trip| UI
API --> ENG
API --> PG
API --> RD
ENG --> FS
ENG --> SQ
ENG --> RD
ENG -->|factory-selected engine| LLM
HF -.->|embedding models<br/>downloaded once, cached on host| ENGData flow — semantic & AI search
flowchart TD
A[User query in KO / EN / ES] --> B{Search type}
B -->|natural language| C[Language detect + version select<br/>per-locale default or search-box choice]
C --> D{Encoder routing by translation}
D -->|NKRV · Korean| E[Korean-specific encoder<br/>jhgan/ko-sbert-nli]
D -->|NIV / RVR| F[Multilingual encoder<br/>paraphrase-multilingual-MiniLM-L12-v2]
E --> G[FAISS index for that translation]
F --> G
G --> H[Top-k verse chunks<br/>+ filters: testament / book / speaker / Strong's]
H --> I[Results with exact citations<br/>book · chapter · verse]
B -->|reference / keyword| J[SQLite verse stores<br/>exact + range + cross-reference lookups]
J --> I
B -->|AI search| N["LLM reference resolver<br/>prompt-engineered: references only,<br/>strict book|ch:v~ch:v format"]
N --> O[Deterministic validation<br/>book / chapter / verse / range checks]
O -->|valid references| J
O -->|unparseable| P[Format-retry loop<br/>reformat only — never invent citations]
I --> K{Optional}
K -->|AI Bible study| L[Retrieved verses as grounded context<br/>→ LLM generates the study, never the verse text]
K -->|PPT export| M[Verses written into the user's deck<br/>client-side]Data flow — keyword search
flowchart TD
A[Keyword query] --> B[Query cleaning<br/>normalization, noise removal]
B --> C{Redis result cache?<br/>normalized-query hash · 7-day TTL}
C -->|hit| Z[Grouped verse results]
C -->|miss| D[Full-text search<br/>SQLite verse store for the translation]
D --> E[Coverage scoring<br/>share of query tokens matched per verse]
E --> F[Group hits into ranges<br/>book · chapter · verse]
F --> G[Store result in Redis]
G --> ZData flow — verse reference & chapter reading
flowchart TD
A["Reference input or chapter navigation<br/>요한복음 3:16 · John 3:16 · Juan 3:16"] --> B[Book-name resolution<br/>full names + KO/EN abbreviation maps<br/>→ canonical book]
B --> C[Parse chapter : verse range]
C --> D[Range lookup<br/>SQLite verse store for the translation]
D --> E[Attach cross-references<br/>speaker + original-language data]
E --> F[Render passage<br/>per-locale default version applies]Inside the engines
flowchart TD
subgraph SE["llama_engine — semantic search core"]
RT[Model routing<br/>version → language → model<br/>environment-configured]
MC[Model cache<br/>SentenceTransformer instances<br/>loaded once, CPU inference]
IDX[Per-translation FAISS index<br/>+ chunk lookup + docstore<br/>loaded / built at startup]
RT --> MC --> IDX
end
subgraph LE["llm_engines — reference resolution"]
FAC[LLMFactory<br/>configuration selects the engine]
CLD[Claude engine<br/>Haiku 4.5 — production default]
COH[Cohere engine<br/>Command R+ · format-retry loop]
GEM[Gemini engine<br/>Flash]
FAC --> CLD & COH & GEM
VAL[VerseValidator<br/>book / chapter / verse / range checks]
CLD --> VAL
COH --> VAL
KC[KeywordSearchCache — Redis<br/>success-only · fail-open]
CLD --> KC
COH --> KC
end
SE -.->|shared verse stores| LEDesign decisions
Two design decisions shape everything else:
- Per-locale, not per-user-language. Bible version defaults, content, and settings are keyed by locale, so a Korean reader on the English UI still gets coherent behavior — and switching UI language never silently changes a reader's translation.
- Heavy work moves to the client or to precomputation. The PowerPoint tool runs entirely in the browser; verse data ships as local stores. The 4 GB server never touches a user's file.
Why RAG is the right shape for Scripture
Retrieval-augmented generation fits the Bible unusually well — better than almost any other corpus:
A fixed, verifiable corpus
- The corpus is closed and fixed. The canon does not get new documents. You embed it once, version the index, and it stays correct — no crawl freshness problem, no re-indexing treadmill.
- The text must be quoted, never paraphrased. A general LLM answering from memory will eventually misquote a verse, invent one, or blend two translations. In VerseAI the verse text always comes from retrieval out of a local data store; the model is only allowed to write about the verses it was handed. Fidelity is an architecture property, not a prompt hope.
- Every claim has a native citation format. Book / chapter / verse is a built-in grounding scheme: each retrieved chunk carries its citation, so answers can be checked against the source in one click — the user is always one tap from verifying.
Portable models, focused generation
- No fine-tuning tax. A new translation is a new index, not a new model: add the data store, embed, ship. The same applies to original-language (Hebrew/Greek) material alongside the translations.
- Separation of concerns keeps costs sane. Retrieval is cheap CPU work (FAISS on a 4 GB box); expensive LLM tokens are spent only on the study layer, on top of already-retrieved text.
Deep dive — Prompt engineering as a safety boundary (AI search)
Resolve references, not verse text
VerseAI's AI search never asks an LLM "what does the Bible say about X?" That question invites fluent, confident misquotation. Instead, the LLM is used as a reference resolver:
- A trilingual prompt template (KO/EN/ES) instructs the model to return only coordinates — up to 20 references in a strict machine-readable grammar:
book|chapter:verse~chapter:verse. No explanations, no verse text. - Every returned reference passes deterministic validation — the book must exist in the target translation, the chapter and verse numbers must be in range. Anything the model invented is silently dropped; it can never reach the user as a "verse."
- Only then does the application fetch the actual text from its own data store and render it.
Production safeguards
The LLM's creativity is confined to finding; the text is beyond its reach. Supporting machinery, all in production:
- Pluggable engines, one contract. Claude (Haiku 4.5, the production default) and Cohere Command R+ implement the same engine interface behind a factory, selected by configuration; a Gemini Flash engine implements it too. Swapping providers is a config change, not a rewrite.
- Format-retry loop. If an engine's output fails to parse, it is sent back with a reformat-only instruction — "fix the format, do not change the references, do not invent citations" — with the previous output fenced as data, not instructions (up to 3 attempts).
- Redis caching, so the same question is never paid for twice. Two layers: resolved LLM references (keyed by prompt hash + language) and finished keyword-search responses (normalized-query hash, 7-day TTL, explicitly clearable when source content changes). Only successful resolutions are served from cache — failures are evicted, never replayed — and the cache is fail-open: any Redis problem degrades to a cache miss, never to a user-facing error. On a fixed-canon corpus this is nearly free money: yesterday's popular question is today's cache hit, at zero API cost.
This is the part of the system where "prompt engineering" is load-bearing: the prompts are versioned code with parsers, validators, retries, and caches around them — not a text box someone typed into once.
Spend tokens where they matter
The cost asymmetry is deliberate. The reference resolver's output is capped at ~128 tokens — a miss on its spelling-exact cache costs almost nothing, so that layer never needed fuzzy matching. The genuinely expensive generation is AI Bible study — and a study is keyed to an exact passage (book / chapter / verse range / translation / language), a canonical, finite key space where plain exact-match caching is not a limitation but the correct tool, backed by a per-user daily quota (20/day). The layer where fuzzy matching would be dangerous is precisely the layer that doesn't need it.
Deep dive 1 — Semantic search, gated by an evaluation set
Natural-language questions ("a verse about anxiety at work") don't match keyword search. VerseAI embeds every verse chunk with a multilingual sentence-transformer (paraphrase-multilingual-MiniLM-L12-v2, with per-translation model selection, including a Korean-specific encoder) and serves nearest-neighbor lookups from per-translation FAISS indexes via LlamaIndex.
An evaluation gate for retrieval changes
The most valuable engineering artifact in the project is not the index — it is the evaluation gate around it:
- Early on, I shipped a retrieval change that felt better and measurably made search worse; it had to be rolled back.
- I then built a fixed evaluation set: 90 queries across the three locales, scored on recall@10, hit rate, and MRR. Current baseline: recall@10 0.459 (EN 0.533 / KO 0.394 / ES 0.450), hit rate 0.622, MRR 0.393.
- House rule since: no retrieval change ships unless it beats the baseline on the eval set, with a rollback path. A four-step backend refactor (index-loading cleanup, graph-store removal, startup profiling) was landed under exactly this gate — it had to reproduce the baseline metrics and the identical ordered top-10 results on all 90 queries before deploy.
Two encoders, because the measurements said so
One multilingual encoder for everything is the tidy design — and the eval set is exactly what showed it wasn't good enough. Split by locale, the baseline told the story:
| Locale | recall@10 (multilingual encoder) |
|---|---|
| English | 0.533 |
| Spanish | 0.450 |
| Korean | 0.394 |
Korean is where a multilingual MiniLM runs out of road: agglutinative morphology, flexible spacing, and honorific verb endings fragment badly under a shared multilingual tokenizer, so Korean queries land near — but not on — the right verses. The fix was not a bigger multilingual model; it was routing:
| Translation | Encoder |
|---|---|
| NKRV (Korean) | jhgan/ko-sbert-nli (KoBERT-based SBERT, NLI-trained) |
| NIV (English) | paraphrase-multilingual-MiniLM-L12-v2 |
| RVR (Spanish) | paraphrase-multilingual-MiniLM-L12-v2 |
Model choice is per-translation configuration in the search engine (version → language → model), so an encoder can be swapped for one locale without touching the others — and any swap has to re-clear the eval gate for that locale before it ships. All encoders are sourced from the Hugging Face hub and cached on the server at startup; the Korean encoder in particular was the product of sweeping HF's Korean SBERT models and letting the Korean eval split — not download counts — pick the winner. The general lesson the résumé version of this story carries: measure per segment, because the average hid the problem. Overall recall looked acceptable; the Korean third of the product was quietly the worst-served.
Deep dive 2 — PowerPoint automation that meets churches where they are
Korean churches run services on plain PowerPoint files. VerseAI's PPT tool takes the deck a church already uses and fills in the verses for this week's service — in about a minute, in the browser:
Template-to-slide workflow
- Upload a .pptx/.potx template → the tool discovers text boxes, identifies the verse / reference / footer boxes, and writes the selected verses into the matching slides with JSZip + XML DOM manipulation and PptxGenJS — 100% client-side; files never leave the user's machine.
Constraints and validation
- Constraints enforced in-product: ≤ 50 MB per file, ≤ 3 templates per account, ≤ 50 slides; a template needs just one verse text box.
- Test fixtures are generated with python-pptx to produce real OOXML decks, including malformed ones (the upload gate blocks only what executes code, pulls external content, or is structurally explosive).
The wedge is deliberate: free, Korean-first, and it upgrades the file churches already have instead of asking them to migrate to presentation software.
Deep dive 3 — Trilingual by design, including the awkward parts
Locale-aware content pipeline
A year of daily devotionals existed only as chat messages. I backfilled 1,113 verse/devotional pairs (371 days × 3 locales), with per-locale attribution rules and a display-only "previous-year fallback" so a missing day degrades gracefully instead of showing an empty page.
Guest-to-account continuity
Guest highlights live in IndexedDB and pins in localStorage; on sign-up, local state merges into the account. (Guest bookmarks/notes are intentionally sign-in-only — the library UI says so instead of pretending.)
Curated reading plans
A curated plan type (ordered unit lists, snapshotted at enrollment) powers 9 hand-built plans / 308 days, alongside generated plans.
Deep dive 4 — Running lean on 4 GB
Production runs on one small VPS, so memory is a feature:
Reduce startup and memory footprint
- Removing a stale auto-loaded graph store from the search engine's startup path cut startup from 4.85 s → 3.89 s and resident memory from 1,006 MiB → 917 MiB (uvicorn total RSS ≈ 2.1 GB on a 3.8 GB box).
Keep inference predictable
- Torch threading is pinned to a single thread to avoid oversubscription on the small instance; FAISS stays on CPU by design.
- The upgrade trigger (traffic, not vibes) that would justify a bigger box or a FAISS → pgvector migration is deliberately parked until the trigger fires.
Process: how this gets built
Spec-driven delivery
Every feature starts as a written spec (role × permission matrix, lifecycle edge cases, acceptance checks), executed with AI pair programming, then verified against the live site.
Continuous quality checks
A daily automated QA pass walks the production site in all three locales as a guest; findings land on a tracked bug board (60+ issues found and fixed to date).
Automated SEO operations
Sitemap/IndexNow submission and search-index monitoring run as scheduled jobs.