- 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
Section titled “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
Section titled “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.
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.
make sdk-js-build # once, from the repo root: the example links sdk/js/node, and its dist/ is not committedcd examples/rag-corecp .env.example .envcat ../../.ironflow/.ironflow_bootstrap_key.json | jq -r .key # paste into IRONFLOW_API_KEYpnpm install && pnpm run setup.env keys are all optional except the Ironflow one:
| Key | Without it |
|---|---|
IRONFLOW_API_KEY | required — everything 401s |
IRONFLOW_SERVER_URL | the SDK uses 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
Section titled “Walk the code”The chunker — src/chunk.ts
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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.replace(/\.md$/, "")}`, async () => {The Python map callback takes (item, step, index); use that scoped step the same way:
await ctx.step.map( "embed-documents", files, lambda file, doc_step, i: doc_step.run(f"embed-and-emit:{file.removesuffix('.md')}", lambda: embed_and_emit(file)),)The example strips .md from the step id because the id goes into a NATS
subject, and a dot adds a subject token.
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),});rpc.events.emit(TriggerRequest( event=EVENTS["ChunkEmbedded"], data=Struct.from_python(payload), idempotency_key=stable_id("chunk-embedded", chunk["chunkId"], content_hash),))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
Section titled “The projection — projections/vector-index.ts”export const vectorIndex = createProjection<unknown, unknown>({ name: "vector-index", events: [EVENTS.ChunkEmbedded], handler: async (raw: unknown) => {In Python, a projection with no initial_state is external, and its handler takes
(event, ctx):
from ironflow.projection import create_projection
async def index_chunk(event, ctx): upsert_vector(event.data) # writes rag.db, outside Ironflow
vector_index = create_projection(name="vector-index", events=[EVENTS["ChunkEmbedded"]], handler=index_chunk)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
Section titled “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],});
// Deliberately not awaited: worker.start() never resolves.worker.start();console.log("rag-core worker running");from ironflow.worker import Worker
print("rag-core worker running")Worker(functions=[ingest_corpus], projections=[vector_index]).run() # blocks until SIGINT/SIGTERMThe Python example code for this tutorial is not written yet; these blocks show the Ironflow calls. The chunker, embeddings and SQLite store are ordinary application code.
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() or worker.drain() — which nothing here does
while the worker runs. Await it and every line after it waits for shutdown that
never comes.
The CLI — cli.ts
Section titled “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
Section titled “Run it”pnpm start # terminal 2VOYAGE_API_KEY is not set — using the offline stand-in embedding.The pipeline runs end to end, but retrieval quality is meaningless.
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
Section titled “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 run 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 does
not read .env. It takes IRONFLOW_API_KEY from the process environment. When
none of IRONFLOW_SERVER_URL and IRONFLOW_API_KEY is set, it
can also take the key from the .ironflow/engine.json file that serve writes,
found by searching up from the current directory. Export the key yourself so the
dependency is explicit. Without any key, 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 startedJob ID: …Projection: vector-indexStatus: …Total 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
Section titled “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. Ctrl-C is not a crash:
worker.start() drains on SIGINT and SIGTERM, so active jobs finish first (up to 30 s
by default) and then the worker stops. To test a crash, use kill -9 $(pgrep -f "tsx.*worker.ts").
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
Section titled “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.