Documents & retrieval
Retrieval without a vector index.
The obvious design is: chunk, embed, store vectors, search by cosine similarity. This app doesn't. Upload extracts the text and stores it whole; every query then chunks it on the spot, shortlists with a cheap lexical pass, and lets a reranker pick the winners.
Why: there is no index to keep warm or migrate, rerankers beat embeddings on relevance at this size (per-user sets are hundreds of chunks, not millions), and reranking runs through the AI Gateway key that is already required.
The pipeline
The browser uploads to Vercel Blob directly, then posts the resulting URL to
/api/documents, which writes metadata to the document table. The bytes never
pass through a route handler — see The upload path for why
that matters. Then, at upload:
- Extract —
lib/document-text.ts.attachmentKindclassifies the file,pdfTexthandles PDFs,extractDocumentTextis the entry point. Scanned PDFs raiseScannedPdfErrorrather than returning an empty string. The result is cached whole ondocument.content. The bytes are fetched back from the store for this, since the upload didn't bring them here.
And at query time, all three in agent/tools/search_documents.ts:
- Chunk —
chunkTextfromlib/rag.ts, run overdocument.contenton every search. It splits on blank lines, so chunk quality is really the extractor's quality: unpdf returned one undifferentiated paragraph, leavingchunkTextnothing to split on but a hard slice every 1400 characters, and 29 of 34 chunks began mid-word. That is why extraction moved to pdf-inspector's Markdown — 13 of 41 on the same paper. - Shortlist —
lexicalShortlistinlib/rag.ts, a BM25-shaped scorer (IDF weighting, saturating term frequency). When it cannot discriminate, it returns everything rather than an arbitrary slice. - Rerank — the shortlist goes to
cohere/rerank-v4-fast, which returns the top chunks.
Chunks are never stored
Only the extracted text is. That is why changing the chunker needs no
migration and no re-upload — the next query simply chunks differently. If you
change the extractor, bun run db:backfill -- --write --force refills
document.content for existing documents.
A documentChunk table still exists in lib/db/schema.ts from the vector-index
design. Nothing reads or writes it.
Extraction lives in its own file for a reason
It used to be inside lib/rag.ts. The PDF path pulls in a native .node
addon, and eve's Rolldown bundler failed with UNLOADABLE_DEPENDENCY when the
agent imported anything from that module. next.config.ts also lists
@firecrawl/pdf-inspector in serverExternalPackages for the same reason.
pdf.js transfers the ArrayBuffer
pdfText copies with buffer.slice(0) before handing the data over. Without
the copy the caller's buffer is detached and every later read sees zero bytes.
The upload path
The browser uploads straight to the blob store and only then tells the server
about it. Files used to be posted to /api/documents as multipart and forwarded
on, which put them in a Vercel Function's request body — capped at 4.5 MB. The
route claimed 12 MB, so anything between the two failed with a platform 413
before the handler ran, and because that response isn't JSON the composer showed
a bare Upload failed (413).
Three pieces, in order:
/api/documents/upload-tokenissues a short-lived upload token. Every check that used to sit next toput()happens here, before the token exists: session, file type, size, and whether the chat or project is yours. It is deliberately not wrapped inauthed— the store calls the same route back ononUploadCompletedwith no session cookie.- The browser uploads, using
upload()from@vercel/blob/client. Over 8 MB it goes multipart: parts in parallel, retried individually. /api/documentstakes the resulting URL and creates the row.
The pathname is the proof of ownership
Bytes arriving out of band means step 3 has to decide whether to believe a URL the client hands it. Posting someone else's blob URL would otherwise attach their file to your chat and read it straight back through the model.
So uploads land under documents/<userId>/, tokens are only issued for a path
inside the caller's own prefix, and /api/documents re-checks the pathname
against head() rather than trusting anything in the request body. Size and
content type come from head() too, so a caller can't describe a 2 KB text
file and attach a 400 MB one.
Both halves live in lib/upload-limits.ts so the two routes cannot drift into
answering the same question differently.
The ceiling is now 50 MB, and what bounds it is extraction rather than the request body: step 1 of the pipeline fetches the file back into a function and parses it there.
When extraction fails
A file with no recoverable text — a scanned PDF, an empty upload — is stored
with status = "error" and the reason on document.error. Search skips it, via
an isNotNull(document.content) filter, so it can never contribute an empty
chunk to a shortlist.
That exclusion is silent by nature, so the project panel labels the file Not searchable with the reason on hover. Without that the only symptom is an agent that never cites a document sitting in plain sight, which is indistinguishable from a retrieval bug. The fix is to remove the file and upload a version with a text layer; no script involved.
The two constants
const SHORTLIST_KEEP = 100
const MAX_CANDIDATES = 200Both are measured. Against a 216-passage corpus, comparing each setting's top-6
against the top-6 from reranking everything, SHORTLIST_KEEP = 60 returned only
4 of the 6; 100 is the smallest value that loses nothing. If you change the
chunker, re-run that sweep. The number is a property of the corpus, not a
universal constant.