- Start Here
- Build a zero-Docker RAG pipeline
Build a zero-Docker RAG pipeline
Ask questions of your own markdown docs. One Ironflow binary, one SQLite file, no Docker.
This is part 1 of the RAG series. It builds the smallest complete retrieval pipeline, and it builds it the event-sourced way, so the index is derived data you can throw away and rebuild.
The finished code is examples/rag-core/. Every command and every output on
this page came from running it.
What you build
corpus/*.md │ ▼ingest workflow ── read ── chunk ── embed (durable step) │ ▼rag.chunk.embedded events (immutable, replayable, vector inside) │ ▼external projection │ ▼rag.db (sqlite-vec) ──▶ pnpm ask "…"Three source files, eleven chunks, one question answered with citations.
Why events, not a script
A 40-line script can do ingest → embed → store. Here is what it cannot do.
The index becomes rebuildable. Each chunk is an event, and the event carries its own embedding vector. Delete the database, replay the stream, and the index comes back — with zero embedding-API calls. Change your chunking, your schema, or your ranking, and you replay instead of re-crawling. Verified in this example: 11 chunks restored from 20 events, no API calls.
Every answer is traceable. A chunk in a citation traces back to the event that created it, with a timestamp.
Ingest survives a crash. Embedding is a durable step. A crashed worker’s run is reclaimed, resumes from the last checkpointed step, and completes correctly instead of being lost. Read Kill it mid-ingest for what that costs you in wall-clock time — the wait is longer than you would guess.
The trade: events get heavy. A chunk event carrying 1024 float32s is about 14.6KB of JSON. Nine thousand of them in one run went through with no overflow and no warning, so the trade holds at this scale.
Setup
Install Ironflow first — see Installation. Then, from a checkout of the repo:
make embed build && ./build/ironflow serveembed is not optional. A plain make build produces a binary whose serve
refuses to start with “embedded dashboard missing”.
The first start writes a bootstrap admin key. You need it: the server refuses unauthenticated function registration with a bare 401, and the worker will retry that 401 forever without telling you why.
cd examples/rag-corecp .env.example .envcat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key # paste into IRONFLOW_API_KEYpnpm install && pnpm setup.env keys are all optional except the Ironflow one:
| Key | Without it |
|---|---|
IRONFLOW_API_KEY | required — everything 401s |
IRONFLOW_URL | defaults to http://localhost:9123 |
VOYAGE_API_KEY | a deterministic offline stand-in embedding is used instead |
ANTHROPIC_API_KEY | ask prints the retrieved context instead of an answer |
The offline embedding is hashed token buckets, L2-normalised. It is not semantic. It exists so the whole tutorial runs without an account, and it makes retrieval quality meaningless. Say that out loud before judging the results.
Walk the code
The chunker — src/chunk.ts
Split on headings, then cap length at paragraph boundaries. The only property that really matters is determinism: same input, same chunks, same ids.
export function chunkMarkdown(docId: string, markdown: string): Chunk[] {Ids come from src/id.ts:
export function stableId(...parts: string[]): string { return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);}Deterministic ids are what make the emits idempotent later. This is load-bearing, not tidiness.
Embeddings — src/embed.ts
voyage-4 with output_dimension pinned to 1024, and an offline fallback:
export async function embed( texts: string[], inputType: "document" | "query",): Promise<number[][]> { if (texts.length === 0) return []; if (isOfflineEmbedding()) return texts.map(localEmbedding);inputType matters. Voyage embeds documents and queries with different prompts
into the same space, and mixing them up measurably hurts retrieval.
The store — src/db.ts
sqlite-vec gives you a KNN index in a single file, which is what keeps this
tutorial Docker-free:
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0( chunk_id TEXT PRIMARY KEY, embedding float[1024]);better-sqlite3 is synchronous. That is deliberate: the projection handler stays
simple, with no awaited I/O at all.
The event contract — events.ts
export const EVENTS = { IngestRequested: "rag.ingest.requested", ChunkEmbedded: "rag.chunk.embedded", DocumentIndexed: "rag.document.indexed",} as const;The index is a pure function of rag.chunk.embedded. Nothing else.
The workflow — workflows/ingest-corpus.ts
Two things here are easy to get wrong.
First: use the scoped step client inside step.map.
async (file, docStep) => docStep.run(`embed-and-emit:${file}`, async () => {step.map does not memoize a branch by itself — it just runs your callback. A
callback written as async (file) => {...} is the obvious shape and it
typechecks fine, but the entire map then persists as one step. With the
scoped docStep, a 3000-document run persists 3001 steps and each document
becomes independently memoized and visible in ironflow inspect.
Second: derive every idempotency key from content.
await client.emit(EVENTS.ChunkEmbedded, payload, { idempotencyKey: stableId("chunk-embedded", chunk.chunkId, contentHash),});A retry re-derives the same key, so the server drops the duplicate. This is what makes a re-run safe, and it is what saves you when a crashed run re-processes work (see below).
createWorker and createClient both fall back to IRONFLOW_API_KEY from the
environment, so an emit only 401s when the variable is genuinely absent — the
worker is no help in spotting that, because it 401s the same way while looking
perfectly healthy. The example still passes apiKey explicitly at the
createClient call, which is worth copying: it puts the dependency in the file
instead of in the shell.
The projection — projections/vector-index.ts
export const vectorIndex = createProjection<unknown, unknown>({ name: "vector-index", events: [EVENTS.ChunkEmbedded], handler: async (raw: unknown) => {This is an external projection — the one component that writes outside
Ironflow. It has to be here, on SQLite. A managed SQL projection can hold a
vector index on PostgreSQL: Ironflow allowlists the vector extension and lets
a projection declare its own indexes. But pgvector is PostgreSQL-only, and this
tutorial is deliberately Postgres-free, so the index lives in rag.db. Your app
owns rag.db; Ironflow owns runs, steps and events; neither reaches into the
other’s database. Part 6 of the series swaps this for a managed pgvector
projection.
The worker — worker.ts
One process hosts both halves: the function that ingests, and the projection
runner that writes rag.db.
const worker = createWorker({ functions: [ingestCorpus], projections: [vectorIndex], serverUrl: process.env.IRONFLOW_URL ?? "http://localhost:9123",});
// Deliberately not awaited: worker.start() never resolves.worker.start();console.log("rag-core worker running");Two things to copy.
createWorker, not serve(). This is pull mode, and it is not a
preference. serve() accepts a projections array in its config type, then
logs Projections in push mode are not supported. Use createWorker() for projections. and does nothing with it. The vector index is the app, so push
mode is not an option. The corpus ingest also has no time budget in pull mode,
where push tops out around ten seconds.
Do not await worker.start(). It is the poll loop, and it returns only when
something calls worker.stop() — which nothing here does. Await it and every
line after it waits for shutdown that never comes.
The CLI — cli.ts
pnpm ingest emits the trigger event. pnpm ask does retrieval plus one model
call directly, without going through a workflow — retrieval-and-answer is
request/response, and there is no ergonomic “emit an event, get the run’s result
back” client path today.
Run it
pnpm start # terminal 2VOYAGE_API_KEY is not set — using the offline stand-in embedding.rag-core worker running[ironflow-worker] Registered function: ingest-corpus[ironflow-worker] Projection runner started (streaming): vector-indexpnpm ingest # terminal 3In terminal 2:
[ironflow-worker] Processing job 17e04838-… for ingest-corpus[ironflow:8136ed36] ingest complete { docs: 3, chunks: 11 }Then ask:
pnpm ask "What port does the Forge dev server use?"[troubleshooting#Port already in use] If `forge dev` reports the port is busy,another process holds port 4311. Stop it, or start Forge with`forge dev --port 4400`.
[getting-started#The dev server] Run `forge dev` to start the local server. Bydefault it listens on port 4311. …Both hits name port 4311, from two different documents. That is retrieval working even on the offline stand-in embedding.
Rebuild the index from events
The payoff for putting vectors in events. This needs two terminals: pnpm start is the worker, so it blocks and never returns.
# terminal 2 — stop the worker with Ctrl-C, then:rm rag.dbpnpm setuppnpm startWait for this line before rebuilding. projection rebuild looks the projection
up in the server’s registry, and it only lands there once the worker has
started it:
[ironflow-worker] Projection runner started (streaming): vector-indexNow, in terminal 3. The app scripts in package.json (setup, start, dev,
ingest, ask) pass --env-file-if-exists=.env to tsx, which is why
pnpm ingest never needed a key in the environment. The ironflow binary has
no equivalent: it reads IRONFLOW_API_KEY from the process environment and
nowhere else. Export it yourself or the command fails with a bare 401:
set -x IRONFLOW_API_KEY (cat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key)../../build/ironflow projection rebuild vector-indexIn zsh or bash:
export IRONFLOW_API_KEY=$(cat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key)../../build/ironflow projection rebuild vector-indexRebuild startedProjection: vector-indexTotal Events: 20The index comes back. No embedding-API calls, because the vectors were in the events the whole time. This is the thing a plain ingest script cannot do.
Kill it mid-ingest
Now the honest part. Start an ingest, kill the worker mid-run, restart it.
What happens: the run stops. It sits in running, then waiting. Your
restarted worker — connected, healthy, idle — does not touch it for a while.
The killed worker was mid-step, so it still holds a concurrency lease, and
recovery is gated on that lease expiring: 90 s of lease, a 30 s recovery grace,
plus up to a scanner tick either side. Budget 90 seconds to 3 minutes from
the kill to the resume. The running → waiting flip is the halfway mark, not
the end.
What --dev does not do: shorten it. Those are compile-time constants with
no flag, YAML field or environment variable, and
IRONFLOW_STALE_CLAIM_THRESHOLD tunes a different loop that skips any run
holding a lease. See Crash recovery.
What you keep: the documents already embedded. The pull worker checkpoints
completed steps to the server on a ~1 s debounce (checkpointInterval on
createWorker, 0 to disable), so the reclaimed run replays them from cache
and re-executes only the step that was in flight. That is why the scoped
docStep in step.map matters: with one step per document, a crash costs you
one document, not the whole map. Nothing wires the SDK’s graceful drain() to
a signal handler, so Ctrl-C is still as abrupt as kill -9 — it just costs at
most the last checkpoint window now.
Why the result is correct either way: every emit carries a content-derived
idempotencyKey. Anything re-emitted by a replayed step is dropped as a
duplicate. The index has no double rows and the final chunk count is exactly
right.
Watch it yourself with ironflow run list --function ingest-corpus while the
clock runs down.
Deliberately not here
Each of these gets its own part of the series:
- change detection, deletes, re-indexing, upcasters — part 2
- golden sets, shadow indexes, promote-or-rollback — part 3
- per-tenant corpora and retrieval boundaries — part 4
- multi-step retrieval, reranking, streaming — part 5
- recovery, observability, cost, the pgvector swap — part 6
For a production-shaped RAG application today, read
examples/financial-rag:
recurring ingest, hybrid search over pgvector, an agentic query loop, and an
eval gate that blocks a bad index from going live.