RAG · Next.js 16 · Gemini 2.5

Answers,
with receipts.

Marginalia is a Retrieval-Augmented Generation app that lets you chat with your documents. Every response cites the exact passages it was drawn from — click a 1 mark to read the source in the margin.

Ingestion pipeline
📄 File
Extract
Chunk
Embed
SQLite
PDF/TXT/MD → text → 1400-char passages → 768-dim vectors
Query pipeline
Question
Rank
Sources
Gemini
Stream
embed → cosine similarity → top-8 → cited answer
01 Concept

How RAG works

Retrieval-Augmented Generation grounds language model answers in real documents. Rather than relying on training-time knowledge, the model reads the passages most relevant to your question before writing its response.

I

Index

Documents are split into overlapping passages, converted to embedding vectors, and stored in a local SQLite database.

📄 Upload PDF/TXT/MD
✂️ Chunk at ~1400 chars
🔢 Embed → 768 dims
💾 Store in SQLite
II

Retrieve

Your question is embedded with the same model, then ranked against every stored passage by cosine similarity.

Embed the question
📐 Cosine over all chunks
🏆 Top-8, score ≥ 0.35
📤 Send sources first
III

Generate

The numbered source passages are injected into a system prompt, and Gemini Flash streams a cited answer token by token.

📋 Build numbered context
🤖 Gemini 2.5 Flash
🌊 Stream tokens via NDJSON
1 Inline [n] citations
02 Pipeline

Interactive pipeline

Click any node to inspect its inputs, outputs, configuration, and source file.

01
📄
File Input
pdf · txt · md
02
✂️
Extract Text
extract.ts
03
📏
Chunk Text
chunk.ts
04
🔢
Embed Chunks
gemini.ts
05
💾
Store
Prisma · SQLite

Source

src/lib/extract.ts src/components/UploadZone.tsx

Accepted types

  • PDF — parsed by unpdf (WebAssembly PDF.js)
  • TXT / MD / MARKDOWN — read via native File.text()

UX details

  • Drag-and-drop anywhere on the page triggers upload
  • Progress streams as NDJSON from the API route
  • Multiple files upload concurrently, each with its own progress bar

Output

Raw UTF-8 string of extracted text, passed to the chunker.

Source

src/lib/extract.ts

PDF extraction

  • getDocumentProxy(buffer) — loads PDF via PDF.js WASM
  • extractText(pdf, {mergePages: true}) — joins all pages
  • Returns a single string; no layout/position data retained

Error handling

  • Unsupported type → error event streamed back to client immediately
  • Empty extraction → "No readable text found" error

Output

Plain text string, whitespace-normalized.

Source

src/lib/chunk.ts

Config

TARGET = 1400 chars OVERLAP = 200 chars

Algorithm

  • Walk from start in TARGET-char windows
  • Prefer to break at \n\n (paragraph)
  • Fallback to . / ? (sentence)
  • Last resort: whitespace (word)

Why this approach

Separator-based chunking keeps coherent semantic units intact — a sentence about one topic won't be split across chunks. The 200-char overlap ensures context isn't lost at boundaries and that retrieval can match a phrase even when it straddles a chunk edge.

Output

Array of overlapping text strings, each ≤ 1400 chars.

Source

src/lib/gemini.ts

Config

Model: gemini-embedding-001 Dims: 768 Batch: 50 texts Task: RETRIEVAL_DOCUMENT

Normalization

Every vector is L2-normalized after embedding, so cosine similarity reduces to a simple dot product at query time — no division needed. Stored as Float32ArrayBLOB in SQLite.

Output

Float32Array per chunk, stored as raw binary BLOB.

Source

src/lib/prisma.tsprisma/schema.prisma

Schema

  • documents — id, name, mime, size, chunk_count
  • chunks — id, document_id, idx, content, embedding BLOB
  • conversations — id, title, timestamps
  • messages — id, conversation_id, role, content, sources JSON

Why Prisma + SQLite?

Zero external service — one file on disk. Prisma's type-safe client and migration system replace raw SQL with a schema that TypeScript understands end-to-end. The LibSQL adapter connects the Prisma 7 runtime to the local SQLite file.

Prisma Studio

Run npm run db:studio to open a GUI browser over your documents, chunks, conversations, and messages — great for debugging retrieval.

01
💬
Question
ChatPanel.tsx
02
🔢
Embed Query
gemini.ts
03
📐
Rank Chunks
retrieve.ts
04
📚
Sources → Client
chat/route.ts
05
🤖
Generate
Gemini Flash
06
🌊
Stream Answer
NDJSON events

Source

src/components/ChatPanel.tsx

Input methods

  • Enter key submits (Shift+Enter for newline)
  • Textarea auto-grows up to 160px, then scrolls
  • Optionally carries a conversationId for follow-ups

Conversation history

The API receives the message and an optional conversation ID. If provided, the last 10 messages are fetched and passed to Gemini as conversation history so follow-up questions have context.

Source

src/lib/gemini.ts

Config

Task: RETRIEVAL_QUERY Same model as ingestion

Using the matching task type is critical — the model produces embeddings optimized for the asymmetric query↔document retrieval use case.

Why task types matter

RETRIEVAL_DOCUMENT produces embeddings that represent "what this passage contains." RETRIEVAL_QUERY produces embeddings that represent "what information I'm looking for." The model is trained so these two spaces align — without matching task types, retrieval quality degrades significantly.

Source

src/lib/retrieve.ts

Config

TOP_K = 8 MIN_SCORE = 0.35

Algorithm

  • Load all chunk embeddings from SQLite as Float32Arrays
  • Dot product against query vector (= cosine, since L2-normalized)
  • Sort descending, slice top 8, filter below 0.35

Performance

Brute-force JS dot products over all chunks. At 768 floats per chunk: a 500-document library (~2,500 chunks) processes in under 5ms. The bottleneck is always the Gemini embedding API call, not the local ranking.

Score meaning

Scores range 0→1. Displayed as % relevance in the sources panel. 0.35 eliminates clearly unrelated passages.

Source

src/app/api/chat/route.ts

NDJSON event

{
  "type": "meta",
  "conversationId": "…",
  "title": "…",
  "sources": [{ "n":1, "documentName":…,
    "excerpt":…, "score":… }]
}

Why sources arrive first

The meta event carries all retrieved sources before a single token is generated. This means 1 citation marks in the streamed answer are immediately clickable — the sources panel populates in full the moment the response begins.

After streaming, only sources actually cited ([n] appears in answer) are saved to the database.

Source

src/app/api/chat/route.ts

Model

gemini-2.5-flash

System prompt structure

  • Role definition as "Marginalia, a careful research assistant"
  • Citation rules: cite inline as [n], never invent
  • Numbered source passages injected verbatim
  • Fallback: acknowledge if sources don't contain the answer

History window

The last 10 messages from the current conversation are fetched from SQLite and prepended as user/model turns so follow-up questions like "what else does it say about that?" resolve correctly.

Title generation

The first 64 chars of the first message become the conversation title — no extra LLM call needed.

Source

src/app/api/chat/route.ts src/lib/client/api.ts

Event sequence

  • {type:"meta", sources:[…]} — sources, conv ID, title
  • {type:"token", text:"…"} — one per streamed chunk
  • {type:"done", messageId:"…"} — signals completion
  • {type:"error", message:"…"} — on any failure

Client rendering

A minimal markdown renderer (Markdown.tsx) handles inline bold, italic, code, headings, and lists. Citation marks n are rendered as interactive buttons that open the relevant source passage in the margin panel.

03 Stack

Technology choices

Hover a card to see why each technology was chosen over alternatives.

v16.2
Next.js App Router
React 19, Server Components, Edge-ready API routes with streaming ReadableStream support.
App Router's runtime = "nodejs" routes allow the Prisma LibSQL adapter to connect to the local database, while the route handler's Response streaming maps perfectly to NDJSON event delivery.
🤖 2.5 Flash
Gemini 2.5 Flash
Google's fast multimodal model. Streams responses via generateContentStream with a custom system instruction.
Flash offers an excellent speed/quality balance for RAG — answers are typically complete in under 3 seconds. The 1M-token context window easily fits the retrieved passages plus conversation history.
🔢 768 dims
Gemini Embedding 001
Produces 768-dimensional semantic vectors. Separate task types for documents vs. queries improve asymmetric retrieval.
Using the same provider for both chat and embeddings simplifies the key setup. The 768-dim output is compact enough to store as SQLite BLOBs without meaningful quality loss versus larger dims.
🗃️ zero-setup
Prisma 7 + SQLite
Type-safe ORM with auto-generated client, schema migrations, and Prisma Studio. SQLite via the LibSQL adapter — no external service needed.
Prisma is the most widely adopted ORM in the Node.js/TypeScript ecosystem. The schema-first workflow (prisma/schema.prisma) provides type safety, migration history, and a GUI via Prisma Studio — none of which raw SQL gives you.
📄 WASM
unpdf
WebAssembly PDF.js wrapper. Extracts text from PDFs without native binaries or external services.
Pure JS/WASM — no system dependencies to install and works in any Node environment. mergePages: true gives a single string to pass directly to the chunker.
🌙 SSR-safe
next-themes
Dark/light theme with system preference detection, no flash of wrong theme on hydration.
suppressHydrationWarning on the root html element, combined with next-themes' class strategy, avoids the hydration mismatch that naive localStorage theme solutions produce in SSR.
🎨 v4
Tailwind CSS 4
Utility-first CSS with the new @custom-variant dark for class-based theming via .dark selector.
Tailwind 4's @theme inline block maps CSS custom properties to utility classes, letting the same token names work in both component styles and one-off utilities without duplication.
🔡 Google Fonts
Newsreader + IBM Plex
Newsreader (serif) for display and answers; IBM Plex Sans for UI; IBM Plex Mono for code, paths, and citation marks.
The serif/mono pairing gives the interface an editorial reading-room quality rather than a generic AI-chat look. Newsreader's italics are particularly elegant for the app name and empty-state headline.
04 Codebase

File structure

The codebase is organized around three concerns: data access, API routes, and React UI.

src/ — key files
lib/Server-side core logic
prisma.tsPrisma Client singleton (LibSQL adapter)
gemini.tsGemini client, embedTexts()
chunk.tsSeparator-based text chunker
extract.tsPDF/TXT/MD text extraction
retrieve.tsCosine similarity retrieval
types.tsShared TypeScript interfaces
└─client/api.tsBrowser fetch helpers, ndjson() reader
app/api/Next.js Route Handlers
chat/route.tsPOST — stream chat with retrieval
documents/route.tsGET list · POST upload+index
└─conversations/[id]/route.tsGET messages · DELETE conv
components/React UI
App.tsxRoot state, layout orchestration
ChatPanel.tsxMessage list, textarea, streaming cursor
Sidebar.tsxConversations + library + upload zone
SourcesPanel.tsxCitation details margin panel
Markdown.tsxMinimal MD renderer with citation buttons
└─UploadZone.tsxDrop zone + per-file progress bars
05 API

Route handlers

All routes run on the Node.js runtime to allow SQLite and native modules. Chat and upload responses stream as NDJSON.

Method Route Description
GET /api/documents List all indexed documents with id, name, size, chunk count, created date.
POST /api/documents Upload and index a document. Accepts multipart/form-data with a file field.
NDJSON stream — emits stage, done, or error events.
DELETE /api/documents/[id] Remove a document and all its chunks (cascades via SQLite foreign key).
GET /api/conversations List conversations ordered by most recently updated.
GET /api/conversations/[id] Fetch all messages for a conversation with parsed sources arrays.
DELETE /api/conversations/[id] Delete a conversation and all its messages.
POST /api/chat Send a message. Body: { message, conversationId? }.
NDJSON stream — emits meta (sources + conv ID) → token× N → done.
06 Config

Tuning parameters

Key constants that control retrieval quality, chunking behaviour, and model selection. All live as named constants near the top of their respective files.

1400
Chunk target
Target character length per chunk. Actual splits occur at the nearest paragraph or sentence boundary.
chunk.ts · TARGET
200
Chunk overlap
Characters re-included at the start of the next chunk. Prevents topic context loss at boundaries.
chunk.ts · OVERLAP
768
Embedding dims
Output dimensionality from gemini-embedding-001. Stored as raw Float32 BLOBs (3 KB/chunk).
gemini.ts · EMBEDDING_DIM
50
Embed batch size
Texts sent per API call during ingestion. Balances throughput against rate limits.
gemini.ts · embedTexts()
8
Top-K retrieved
Maximum source passages sent to the model. More passages = better coverage, longer prompts.
retrieve.ts · TOP_K
.35
Min relevance
Cosine similarity floor. Passages below this score are excluded even within Top-K.
retrieve.ts · MIN_SCORE
10
History messages
Prior conversation turns sent to Gemini. Enables follow-up questions to resolve correctly.
chat/route.ts · HISTORY_LIMIT
64
Title length
Max characters taken from the first message to auto-name a new conversation.
chat/route.ts · buildTitle()