Skip to content

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:

Terminal window
make embed build && ./build/ironflow serve

embed 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.

Terminal window
cd examples/rag-core
cp .env.example .env
cat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key # paste into IRONFLOW_API_KEY
pnpm install && pnpm setup

.env keys are all optional except the Ironflow one:

KeyWithout it
IRONFLOW_API_KEYrequired — everything 401s
IRONFLOW_URLdefaults to http://localhost:9123
VOYAGE_API_KEYa deterministic offline stand-in embedding is used instead
ANTHROPIC_API_KEYask 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

Terminal window
pnpm start # terminal 2
VOYAGE_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-index
Terminal window
pnpm ingest # terminal 3

In terminal 2:

[ironflow-worker] Processing job 17e04838-… for ingest-corpus
[ironflow:8136ed36] ingest complete { docs: 3, chunks: 11 }

Then ask:

Terminal window
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. By
default 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 window
# terminal 2 — stop the worker with Ctrl-C, then:
rm rag.db
pnpm setup
pnpm start

Wait 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-index

Now, 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:

Terminal window
set -x IRONFLOW_API_KEY (cat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key)
../../build/ironflow projection rebuild vector-index

In zsh or bash:

Terminal window
export IRONFLOW_API_KEY=$(cat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key)
../../build/ironflow projection rebuild vector-index
Rebuild started
Projection: vector-index
Total Events: 20

The 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 runningwaiting 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.