Chat with your documents — with citations you can actually check.
Upload a PDF, TXT or Markdown file inside the conversation, ask questions about it, and get streamed answers grounded in the document. Every factual claim carries a citation showing the filename, the page or section, and the verbatim excerpt it came from.
Live URL: https://citeline-henna.vercel.app Repository: https://github.com/Miny320/citeline
- Setup
- Architecture
- Database schema
- Key trade-offs
- Time spent
- AI tools used
- Where I corrected or rejected AI output
- What I did not build, and why
Requires Node.js 20.9+ (developed on 22.23). Both services are free and need no credit card.
git clone https://github.com/Miny320/citeline.git
cd citeline
npm installCreate a project at neon.com (Free plan) and copy the pooled
connection string. The host should contain -pooler.
Create a key at aistudio.google.com/apikey. The free
tier covers both the Gemini Flash chat models and gemini-embedding-001.
On the free tier Google may use submitted content to improve their products, so use public or synthetic documents only. The API also refuses requests from some datacenter and VPN exit IPs with
FAILED_PRECONDITION: User location is not supported— if you hit that locally, it is your egress IP, not your key.
cp .env.example .env.localFill in DATABASE_URL and GOOGLE_GENERATIVE_AI_API_KEY.
npm run verify:providerConfirms the chat model id resolves, embeddings return at 1536 dimensions, vectors are unit-length after normalisation, and semantic similarity actually separates a paraphrase from unrelated text. If this fails, stop — nothing downstream will behave correctly.
npm run db:migrate
npm run verify:dbUse
db:migrate, notdb:push.pushgenerates DDL from the schema and never reads the migration files, so it would skip the hand-addedCREATE EXTENSION vector.
verify:db asserts what actually landed — in particular that the embedding index really is
HNSW rather than having silently fallen back to a sequential scan.
npm run devThen check http://localhost:3000/api/health; it should report ok: true and
pgvector: true. A sample document is committed at
fixtures/acme-handbook.pdf — upload it and ask
"What is the refund window for enterprise customers?" or "What does ERR_2043 mean?"
| Command | Purpose |
|---|---|
npm run dev |
Development server |
npm run build |
Production build |
npm run check |
Typecheck + lint + tests |
npm test |
Unit tests (87) |
npm run verify:provider |
Model, dimensions, normalisation, similarity |
npm run verify:db |
Schema, index types, cascades, JSONB round-trip |
npm run verify:rag |
End-to-end: ingest the fixture, retrieve, assert page attribution |
npm run db:generate |
Generate a migration from the schema |
npm run db:migrate |
Apply migrations to Neon |
npm run fixtures |
Regenerate the sample PDF |
Stack: Next.js 16 (App Router) · TypeScript strict · Vercel AI SDK 7 · Google Gemini (chat + embeddings) · Neon Postgres + pgvector · Drizzle ORM · Tailwind 4
composer file picker
→ POST /api/documents (multipart, max 4 MB)
→ validate: extension + MIME + size [reject early, clear message]
→ insert documents row as `processing` [so a failure has somewhere to live]
→ parse
.pdf → unpdf extractText(mergePages:false) → one string per page
.md → split on ATX headings, keep the heading path
.txt → paragraph runs with char offsets
→ chunk (~1000 chars, 150 overlap, never across a page boundary)
→ embedMany(gemini-embedding-001 @ 1536d) → L2 normalise
→ insert chunks
→ status = 'ready' | 'failed' + reason
useChat sendMessage
→ POST /api/chat { chatId, messages }
→ embed(question)
→ HYBRID RETRIEVAL scoped to this chat's documents
a) vector: embedding <=> query top 20
b) lexical: ts_rank_cd over tsvector top 20
c) fuse by Reciprocal Rank Fusion → top 8
→ stream `data-sources` part (the resolved retrieval set)
→ streamText(gemini-3.6-flash, grounded system prompt, showEvidence tool)
→ onEnd: persist the full UIMessage.parts array as JSONB
This is the part the whole design rests on.
- The model sees numbered excerpts —
[1] (handbook.pdf · p. 2)— with no chunk ids. - It cites
[1],[2]inline. Its only freedom is which real passage to point at. - The server maps those indexes back to rows from the set it retrieved for that request. Anything out of range is dropped.
- The excerpt displayed is read from Postgres, never from model output — so the quoted text is guaranteed to be what the document says.
A fabricated citation is structurally impossible rather than merely unlikely. This is covered by tests: an out-of-range, zero or negative index resolves to nothing, and chunk ids never appear in the context block.
components/ may import types from lib/, never functions. Anything needing data gets it
as props from a server component or from useChat. lib/rag/ never imports from app/.
Four tables. The first migration enables pgvector.
chats id · title · created_at · updated_at
documents id · chat_id→chats · filename · mime_type · byte_size
kind ('pdf'|'txt'|'md') · page_count
status ('processing'|'ready'|'failed') · error
idx (chat_id)
chunks id · document_id→documents · chunk_index · content
page_from · page_to · section · char_start · char_end
embedding vector(1536)
content_tsv tsvector GENERATED ALWAYS AS to_tsvector('english', content) STORED
idx hnsw (embedding vector_cosine_ops)
idx gin (content_tsv)
idx (document_id)
messages id (the AI SDK's message id) · chat_id→chats · role
parts jsonb (the full UIMessage.parts array)
idx (chat_id, created_at)
All foreign keys cascade on delete, so removing a chat removes its documents, chunks and messages. Verified by test, not assumed.
Why messages.parts is JSONB rather than flattened text. AI SDK v7 messages are typed
part arrays including tool calls, tool results and custom data parts. Flattening to text would
make evidence cards and citations vanish on refresh — the structured UI would appear to work
in a live demo and silently fail the moment anyone reloaded. Storing parts wholesale
round-trips everything, and validateUIMessages on load means a later schema change degrades
gracefully instead of crashing the conversation.
Why messages.id is the SDK's own id. Persistence becomes idempotent: re-saving a stream
upserts rather than duplicating.
Each one states what was rejected as well as what was chosen.
Every pgvector tutorial, including Drizzle's own, filters with .where(gt(similarity, 0.5)).
I measured what these embeddings actually score:
| cosine | |
|---|---|
| correct passage | 0.8932 |
| unrelated passage, same document | 0.7386 |
| unrelated passage, same document | 0.7220 |
| unrelated passage, same document | 0.7032 |
gt(similarity, 0.5) matches everything — it reads as a relevance filter, raises no error,
and does nothing. Tightening it would be worse: 0.8 starts discarding correct matches when a
document's phrasing differs from the question's. Absolute scores are not stable across models
or corpora; the ordering and the margin are. So retrieval ranks by Reciprocal Rank Fusion,
which consumes ranks rather than scores, and grounding is enforced by the system prompt
instead — a semantic judgement rather than an arithmetic one.
Pure vector search reliably misses exact tokens — error codes, product names, acronyms — which
is much of what people actually ask a document chat. The lexical arm costs one generated
tsvector column and a GIN index, no extra service. The sample document contains ERR_2043
specifically so this is testable.
unpdf returns one string per page, so the true page number is free while the structure is
still visible. Flatten first and page attribution can only be re-derived by guessing at
offsets — which is how demos end up citing page 7 for content on page 9. Chunking refuses to
build a chunk that spans two pages, because such a chunk cannot be cited unambiguously.
Vercel Hobby allows 300s and a ≤4 MB file parses and embeds well inside that. A queue would
add a service and a class of failure modes to solve a problem this app does not have. The
processing → ready | failed status column gives the honest UX benefits of async — progress,
a real failure state that survives a reload — at almost no cost.
Vercel caps function request bodies at 4.5 MB. That is a platform limit, not a preference —
exceeding it returns an opaque 413 FUNCTION_PAYLOAD_TOO_LARGE that reads as a broken app.
Rejecting at 4 MB with an explanation turns a confusing platform error into an understood
product boundary. Blob storage would lift the cap, but the app discards original bytes after
parsing, so there would be nothing for it to usefully hold.
The brief requires that no paid services are needed. Google AI Studio's free tier covers chat and embeddings from one key and one provider package — the only mainstream option where a reviewer can clone, paste one key, and have everything work. OpenAI has better-documented embeddings but no free tier.
Model ids were chosen by measurement, not assumption (lib/ai/models.ts):
| Candidate | Result |
|---|---|
gemini-2.5-flash |
Rejected by the API: "no longer available to new users" |
gemini-3.7-flash |
Failed after 3 retries: "currently experiencing" issues |
gemini-3.5-flash |
Worked, 23,053 ms |
gemini-3.6-flash |
Worked, 4,931 ms ✅ |
About 6 hours 55 minutes — over the five-hour box, the last hour and a quarter being a UI and accessibility pass after reviewing the deployed app, reported as measured rather than as budgeted, and tracked as the build happened rather than reconstructed afterwards.
| Phase | Budget | Actual |
|---|---|---|
| Planning + stack verification | — | 0:20 |
| Implementation spec | — | 0:15 |
| 0 — Skeleton, deploy path, provider verification | 0:30 | 0:50 |
| 1 — Schema and data layer | 0:45 | 0:35 |
| 2 — Ingestion pipeline | 1:00 | 1:05 |
| 3 — Chat, retrieval, persistence | 1:00 | 0:50 |
| 4 — Citations and evidence cards | 0:45 | 0:40 |
| 5 — States, hardening, deploy, QA | 0:30 | 0:40 |
| 6 — README and submission | 0:30 | 0:25 |
| 7 — UI redesign (after reviewing the deployed app) | — | 0:50 |
| 8 — Accessibility pass | — | 0:25 |
The plan front-loaded deployment and provider verification deliberately: the failure mode that ruins these submissions is a polished local app that will not deploy, discovered at hour five. Two of the three most damaging problems in this build were caught in the first thirty minutes because of that ordering.
| Tool | Used for |
|---|---|
| Claude Code (Opus 5) | Planning, live-documentation verification, scaffolding, implementation, test authoring, review |
Working rule for the whole build: every AI-generated snippet touching a fast-moving
dependency was checked against live documentation before being trusted. ai, next and
zod had all shipped majors recently. That rule caught four separate defects, below.
Eight instances were logged during the build. The most consequential:
What AI produced, reproducing the canonical Drizzle example:
const similarity = sql`1 - (${cosineDistance(chunks.embedding, embedding)})`;
.where(gt(similarity, 0.5)) // "only keep relevant matches"Why I rejected it: rather than reasoning about whether 0.5 was right, I measured. With a retrieval-shaped probe the correct passage scored 0.8932 while three unrelated passages from the same document scored 0.7386, 0.7220 and 0.7032. Two entirely unrelated sentences still scored 0.5355.
So the predicate matches everything. It reads as a relevance filter, returns plausible results, raises no error, and does nothing whatsoever.
Why it mattered: this is the most dangerous class of bug in the build — syntactically fine,
semantically inert, and invisible. It would have shipped looking completely correct, and the
symptom would have been "retrieval quality is a bit vague sometimes", which is nearly
impossible to trace back to a where clause that appears to be doing its job. Catching it
required running the numbers, not reading the code.
-
AI SDK v7 route handler. Generated code used
result.toUIMessageStreamResponse()— the v5 shape. Beyond being wrong, it has no server-side persistence hook, which would have pushed message saving to the client, where an interrupted stream loses the reply. The v7onEndcallback is what makes "survives a reload" actually true. -
unpdfdetaches your buffer. pdf.js transfers theArrayBufferto its worker, so a second read throws — and my error handler reported it to the user as "your file may be corrupted". Found only because the tests parse the same fixture twice.parsePdfnow passes a copy. -
drizzle-kit generateomitsCREATE EXTENSION vector. The migration fails on any cold database, but passes for anyone whose local database already has the extension — so it would have broken on the reviewer's machine, not mine. -
The message renderer handled citations but not markdown. Gemini emits markdown; nothing rendered it, so every answer showed literal backticks and asterisks. Typecheck, lint and 31 tests were all green on output that was visibly wrong to anyone who opened the page. Caught by grepping the deployed DOM rather than trusting that "the chat works" meant "the chat looks right".
-
The palette failed WCAG contrast.
--subtle— used for hints, timestamps and footers throughout — measured 2.73:1 in light mode against a 4.5:1 requirement. Colours are strings, so nothing in the toolchain could have objected. Found by computing relative luminance for every foreground/background pairing the UI renders; all 22 now clear 4.6:1 and are asserted in tests. The same pass found there was no focus-visible style at all.
The recurring shape is worth naming: every one of these was invisible to the typechecker, the linter and the test suite until a check was written specifically for it. Three of them also fail only on someone else's machine, cold database, or eyes — never on the author's.
Stated plainly rather than left as unexplained gaps.
Deliberately out of scope (the brief excludes them): authentication, billing, admin.
Deliberate cuts, pre-declared in the plan:
- Line-range locators for TXT citations. PDFs cite a page and Markdown cites a heading path, but plain text degrades to filename + excerpt. Real line numbers need capture at parse time plus two more columns; showing an invented locator would be worse than showing none.
- A
compareItemscomparison-table tool.showEvidencewas the structured component worth building first because it makes grounding inspectable, which compounds with the citation requirement instead of sitting beside it as decoration. - Re-ranking model. Better retrieval quality, but another provider, more latency and more free-tier exposure. The wrong trade inside five hours.
Known limitations:
- No authentication means no privacy boundary. Authentication was explicitly out of scope, but the consequence is worth stating plainly rather than leaving implied: every conversation and every uploaded document is visible to anyone with the URL, and all conversations are listed on the home page. That is acceptable for a demo and is surfaced in the UI, but it is the first thing that would need building before real use.
- Gemini's free tier is rate-limited to 20 requests for
gemini-3.6-flash. Quota is metered per model, so the app falls back through a chain of models when one is exhausted (lib/ai/stream-with-fallback.ts). The switch happens before any token is sent, so an answer never restarts mid-sentence, and only rate limits trigger it — a bad key would fail identically on every model. Sustained use will hit it, and the API asks for a short wait before retrying. The app detects this specifically and says "The AI service is busy right now. Wait a moment and try again." If every model in the chain is exhausted, the UI says "The AI service is busy right now. Wait a moment and try again." with a Try again button, rather than a generic failure. Retrieval still runs and its sources still stream, so only the generated answer is affected. Discovered by exhausting the quota during QA; the real cause was read out of the Vercel runtime logs. - Scanned/image-only PDFs are rejected with a clear message. No OCR.
- Files over 4 MB are rejected (Vercel's body limit).
- No stream resumption: closing the tab mid-answer loses that answer, though everything already persisted survives.
- Retrieval is scoped per chat, so documents cannot be compared across conversations.
- Mobile is usable but not specifically designed for.