Production-grade document Q&A REST API. Upload a PDF, retrieve relevant passages with hybrid search, and stream grounded answers from a local LLM.
Lumen keeps inference on your machine (Ollama) and stores vectors in Supabase with pgvector. No cloud LLM keys required.
A Next.js frontend (Upload / Query / Documents) sits in front of the API. It proxies every request through server-side route handlers so the Supabase-backed API key never reaches the browser.
| Layer | Choice |
|---|---|
| API | Hono on Node.js (TypeScript) |
| Frontend | Next.js 14 (App Router), React 18, Tailwind CSS |
| PDF parsing | unpdf |
| Embeddings | Ollama nomic-embed-text |
| Generation | Ollama llama3.1:8b (token streaming) |
| Vector store | Supabase + pgvector with an HNSW index |
| Auth | Bearer API key (Authorization header), held server-side only |
┌─────────────────────────────────────────┐
│ Browser │
│ Upload · Query · Documents │
└──────────────────┬──────────────────────┘
│ same-origin fetch
│ (no API key here)
▼
┌─────────────────────────────────────────┐
│ Next.js Frontend │
│ App Router :3001 │
│ │
│ /api/ingest /api/query │
│ /api/documents /api/documents/:id │
│ (route handlers attach the API key, │
│ proxy to the backend, stream responses) │
└──────────────────┬──────────────────────┘
│
Bearer API key │ HTTP
(except /health) ▼
┌─────────────────────────────────────────┐
│ Lumen API │
│ Hono :3000 │
│ │
│ POST /ingestDocuments GET /health │
│ POST /query │
│ GET /documents DELETE /documents/:id │
└───────┬───────────────────────┬─────────┘
│ │
ingest path │ │ query path
▼ ▼
┌─────────────────────┐ ┌─────────────────────────┐
│ unpdf extractText │ │ embed(question) │
│ 500 / 50 chunker │ │ nomic-embed-text │
│ replace existing │ └────────────┬────────────┘
│ doc + embed chunks │ │
└──────────┬──────────┘ ▼
│ ┌─────────────────────────┐
│ │ Supabase RPC │
│ │ match_chunks │
│ │ HNSW ANN + keyword │
│ │ 70% vector + 30% keyword│
│ │ top 5 chunks + sources │
▼ └────────────┬────────────┘
┌─────────────────────┐ │
│ Supabase Postgres │◄───────────────┘
│ documents + chunks │
│ pgvector + HNSW │
└─────────────────────┘ │
▼
┌─────────────────────────┐
│ Ollama llama3.1:8b │
│ context + question │
│ line-buffered NDJSON │
│ stream → sources+tokens│
└─────────────────────────┘
- Ingest — PDF uploaded → text extracted with unpdf → split into 500-character chunks with 50-character overlap. If a document with the same
namealready exists, it (and its chunks) is deleted first, so re-uploads replace rather than duplicate. - Index — Each chunk is embedded with
nomic-embed-textand stored in Supabase (documents+chunkswith a pgvector column, backed by an HNSW index). - Retrieve — The user question is embedded, then hybrid search runs via
match_chunks: 70% vector similarity (HNSW ANN) + 30% keyword. The top 5 chunks are returned along with their source document and similarity score. - Generate — Retrieved chunks and the question are sent to
llama3.1:8b. The model is instructed to answer only from that context. The response is streamed back as asourcesJSON line followed by answer tokens.
- Node.js 20+
- Ollama running locally (
http://localhost:11434) - A Supabase project with the
pgvectorextension enabled
Pull the local models:
ollama pull nomic-embed-text
ollama pull llama3.1:8bgit clone <repo-url>
cd lumen
npm installCreate a .env file in the project root (never commit real credentials):
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key
API_KEY=your-secret-api-keyRun the API:
npx tsx src/backend/index.tsThe server listens on http://localhost:3000.
cd frontend
npm installCreate a .env.local file in frontend/ — the same API_KEY as the backend, read only by the server-side route handlers, never exposed to the client bundle:
API_KEY=your-secret-api-keynpm run devThe frontend listens on http://localhost:3001.
Enable pgvector and create tables plus the hybrid-search RPC. Typical schema:
documents—id,name,file_size,page_count,created_atchunks—id,document_id,raw_text,embedding vectormatch_chunks(query_embedding, query_text, match_count)— hybrid score, returns the topmatch_countrows (default 5), includingdocument_id,document_name, andsimilarity
Add an HNSW index on the embedding column for approximate nearest-neighbor search:
create index on chunks
using hnsw (embedding vector_cosine_ops);All routes except GET /health require:
Authorization: Bearer <API_KEY>The key is compared to process.env.API_KEY. Missing or invalid tokens return 401 Unauthorized.
The browser never holds this key. The Next.js route handlers under frontend/app/api/* read API_KEY from server-side environment variables and attach it when proxying to the Hono backend — the client only ever calls same-origin /api/* routes.
Direct backend routes (http://localhost:3000). The frontend calls these through its own /api/* proxy routes instead of hitting them directly.
Liveness check. No auth.
curl http://localhost:3000/healthUpload a PDF. Multipart form fields:
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | yes | PDF document |
name |
string | yes | Display name stored on the document row |
Re-uploading a name that already exists deletes the previous document and its chunks before inserting the new ones.
curl -X POST http://localhost:3000/ingestDocuments \
-H "Authorization: Bearer $API_KEY" \
-F "name=quarterly-report.pdf" \
-F "file=@./quarterly-report.pdf"200
{ "message": "Documents ingested successfully" }400 if file or name is missing. 500 on parse, embed, or database errors.
Ask a question over ingested documents. Response body is a stream: a single JSON line with sources, followed by plain-text answer tokens — not a single JSON object.
curl -N -X POST http://localhost:3000/query \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"question":"What were the main findings in Q3?"}'Request
{ "question": "Your question here" }Stream
{"sources":[{"document_id":"...","document_name":"quarterly-report.pdf","similarity":0.87,"preview":"Revenue grew 12%..."}]}
Revenue in Q3 grew 12% year over year, driven primarily by...
The handler embeds the question, calls match_chunks, writes the sources line, then builds a grounded prompt and streams llama3.1:8b output token by token.
If the answer is not in retrieved context, the model is prompted to reply: I don't have enough information to answer that.
List all ingested documents with their chunk counts.
curl http://localhost:3000/documents \
-H "Authorization: Bearer $API_KEY"200
[
{
"id": "b1f2...",
"name": "quarterly-report.pdf",
"file_size": 48213,
"page_count": 1,
"created_at": "2026-08-01T10:00:00Z",
"chunks": [{ "count": 24 }]
}
]Delete a document and cascade-delete its associated chunks.
curl -X DELETE http://localhost:3000/documents/b1f2... \
-H "Authorization: Bearer $API_KEY"200
{ "message": "Document deleted successfully" }500 on database errors.
Next.js 14 App Router app with three pages:
| Page | Route | Purpose |
|---|---|---|
| Upload | /upload |
Drag-and-drop PDF upload; on success, automatically streams a 3-4 sentence summary and 5 suggested questions inline |
| Query | /query |
Ask a question, watch the answer render word by word, view deduplicated source cards (document name, similarity %, text preview) below the answer |
| Documents | /documents |
List uploaded documents with chunk counts, delete individual documents |
Query and upload responses show elapsed time and word count once streaming completes.
Route handlers (frontend/app/api/*) are the only part of the frontend that knows API_KEY; every page and component calls same-origin /api/* paths.
src/backend/
index.ts # Hono routes, streaming, server
ingest.ts # Duplicate-name replace + chunk embed loop
chunker.ts # 500 / 50 overlapping splits
embeddings.ts # Ollama nomic-embed-text
llm.ts # llama3.1:8b generate + stream
db.ts # Supabase client
middleware/auth.ts # Bearer API key
frontend/
app/
api/
ingest/route.ts # Proxies POST /ingestDocuments
query/route.ts # Proxies POST /query, forwards the stream
documents/route.ts # Proxies GET /documents
documents/[id]/route.ts # Proxies DELETE /documents/:id
upload/page.tsx # Upload UI + auto summary
query/page.tsx # Query UI + source cards
documents/page.tsx # Document list + delete
lib/api.ts # Fetch helpers, NDJSON stream parser
components/ # Nav, Toast
API keys are proxied server-side, not sent from the browser. The original version had the frontend call the Hono API directly with the Authorization header attached client-side, which shipped the API key in every browser request. Route handlers under frontend/app/api/* now hold the key as a server-only environment variable and forward requests to the backend, so the key never appears in client-side JavaScript or network requests visible to the browser.
HNSW over brute-force vector search. match_chunks originally did a sequential scan over all chunk embeddings for every query, which is O(n) per query and degrades as the corpus grows. An HNSW index (vector_cosine_ops) turns this into approximate nearest-neighbor search, keeping retrieval fast as the number of ingested documents scales, at the cost of exactness (HNSW is approximate, not exhaustive).
Streaming uses a line buffer, not naive per-chunk JSON.parse. Ollama emits newline-delimited JSON, but TCP/HTTP chunk boundaries don't align with message boundaries — a single response.body chunk can contain a partial JSON object, or several complete ones. Parsing each raw chunk directly throws on split objects. Both src/backend/index.ts (backend → frontend) and frontend/lib/api.ts (frontend → browser) instead accumulate chunks into a buffer, split on \n, hold back the last (possibly incomplete) line, and only parse complete lines.
| Setting | Value |
|---|---|
| Chunk size | 500 characters |
| Overlap | 50 characters |
| Retrieval | Hybrid 70% vector (HNSW ANN) / 30% keyword |
match_count |
5 |
| Embed model | nomic-embed-text |
| Chat model | llama3.1:8b |
| Ollama | http://localhost:11434 |
| Backend port | 3000 |
| Frontend port | 3001 |
ISC