A browser-first RAG explorer. Drop in scrolls (PDF / Markdown), embed them locally, and question them through the Oracle — answers stream with inline citations you can click back to the source. Here's how it works under the hood.
A two-pane app: the Tome (a document viewer) on one side and the Oracle (a streaming RAG chat) on the other. Everything up to the LLM call happens in your browser — parsing, chunking, embedding and vector search — so a document never leaves your machine unless you opt in to cloud embeddings. Only the final question + the retrieved passages are sent to the server to generate an answer.
Embeddings run in a Web Worker via Transformers.js. The vector store is IndexedDB. Documents survive a refresh and work offline once indexed.
Answers stream token-by-token over Server-Sent Events. Reasoning is separated from the reply; citations and cost arrive as metadata.
UI, API, and RAG/AI live in separate folders with a one-way import rule. The RAG core is testable with no browser and no network.
Code is organized by responsibility, and dependencies flow one way. The UI knows the API; the API knows the RAG/AI core; the core knows neither HTTP nor Svelte.
ChatPanel (an
@ai-sdk/svelte Chat), and
DocumentViewer with highlight overlays. Reads streamed
metadata for citations + cost.
parser → chunker → embeddings → vector-store. Pure,
browser-side, framework-agnostic — unit-tested with a mock worker and
fake-indexeddb.
chat.*.ts modules.
CHUNK_SIZE, TOP_K,
EMBEDDING_DIMS, MODEL_IDS) live in
as const objects so logic never depends on raw literals.
Drop a file and a per-document state machine carries it from
pending → indexing → embedding → ready (or
error) — all in the browser. Progress for chunking and embedding
is tracked separately so the UI stays granular.
source::pN::cN id.
A question is embedded and searched locally, then the candidates go to the server to be reranked and answered. Colors mark the segment: browser, server.
/api/warmup) reorders
candidates by relevance; on any error it falls back to vector order. Top
8 become the context.
<source n> blocks; the Oracle
answers with inline [n] citations. Reasoning is intercepted;
cost is computed on finish.
Reasoning tokens are buffered and forwarded as message-metadata
(shown in a separate panel, never in the answer). Citations are derived deterministically
from the reranked chunks — no second LLM call.
The server returns an AI SDK v6 UI-message stream (SSE,
data: <json>\n\n, header
x-vercel-ai-ui-message-stream: v1). A server-side interceptor sits
in the middle, reshaping a few chunk types before they reach the client.
| Chunk | Handled how | Payload |
|---|---|---|
| message-metadata | Citations sent up-front; reasoning + cost merged in later | citations, reasoning?, usage?, costUsd?, truncated? |
| text-delta | Forwarded as-is — the visible answer | delta, id |
| reasoning-delta | Buffered, flushed every 6 tokens as metadata — never shown in the bubble | delta |
| reasoning-end | Final reasoning flush | — |
| finish | Compute usage + USD cost server-side, log, emit metadata, then forward | finishReason (stop · length · error) |
| finish · error | Swapped for a safe, in-character message ("the Oracle has gone silent") | — |
chat.stream.ts) consumes the
raw stream, narrows the chunks it cares about with type guards, and re-emits clean
metadata — keeping chain-of-thought out of the answer and attaching cost on the way out.
Retrieved text is wrapped in <source n> tags and the
system prompt treats it as untrusted data. A scroll that says "ignore previous
instructions" is disregarded — and there's a unit test that proves the delimiting.
LLM keys travel as request headers, resolved server-side (user → demo env → 401) and never logged. Cloud-embedding keys are an explicit client-side opt-in (documented in an ADR).
Zod caps the question at 2000 chars and the payload at 200 chunks; null bytes and control characters are stripped before anything reaches the model.
resolveProvider expresses primary → fallback → error in code:
an explicit pick is honored if its key exists, otherwise Fireworks → Claude → a helpful
error.
The generation is bounded by AbortSignal.any of the client
signal + a 60s AbortSignal.timeout. A provider that
accepts the request but never streams can't hang the function to the platform limit —
on expiry the stream aborts and the safe "the Oracle has gone silent" message is shown.
vercel.json sets X-Frame-Options, nosniff, Referrer-Policy, a
deny-by-default Permissions-Policy, HSTS, and the COOP/COEP the WASM worker needs. The
CSP is owned by SvelteKit's kit.csp — it hashes the inline boot
script, so script-src stays
'self' 'wasm-unsafe-eval' with no
unsafe-inline.
Every request carries a requestId; structured logs capture the
rerank delta, retrieve + latency ms, tokens, and a coarse error category. OpenTelemetry
spans (via @vercel/otel) record token usage, latency, and finish
reason — with prompt/answer text deliberately off for privacy.
No magic numbers scattered through the code — every constant lives in one module or an
as const object, so a change is a one-line edit (and re-run the
evals).
Unit and E2E tests verify the code works; evals verify the retrieval works.
pnpm eval runs offline against 20 ground-truth Q&A
pairs and gates the build.
similaritySearchevals/scores.json — a flat history is a cheap regression detectortry/catch fallback silently returned the
un-reranked order. The lesson: a fallback hides a dead component unless an eval exercises the
real path. Fix in, the eval now proves the reranker earns its latency.