TL;DR
Chat with your PDFs. Upload a file or paste a link, and answers come back grounded in the document through a Pinecone-backed RAG pipeline.

Overview
TL;DR is a document chat app. You drop in a PDF or paste a PDF URL, it gets indexed into a vector store, and you can ask questions that are answered from the document itself. Each upload becomes its own chat, with history saved to Postgres, so you can come back to a paper later and continue.
The problem
Long PDFs like research papers, manuals and lecture notes are slow to skim, and a general chatbot will happily make things up about a document it has never seen. I wanted answers tied to the actual text of the file, with the file kept per user and per conversation instead of being pasted into one long prompt.
What I built
- Client-side upload to S3 with drag-and-drop (react-dropzone), a 150 MB cap, PDF validation through
pdf-lib, and a second path that pulls a PDF from a URL through a CORS proxy before uploading it. - A
/api/create-chatroute, guarded by Clerk auth, that runs the ingestion pipeline and inserts achatsrow with the S3 key, file name and public URL. - An ingestion pipeline (
loadS3IntoPinecone) that downloads the file from S3, parses pages with LangChain'sPDFLoader, splits them withRecursiveCharacterTextSplitter, embeds each chunk withtext-embedding-ada-002, and upserts the vectors into a Pinecone index under a namespace derived from the file key. Chunk IDs are MD5 hashes of the content, so re-uploading the same text does not create duplicates. - A retrieval step (
getContext) that embeds the latest question, takes the top 5 matches from that file's namespace, drops anything scoring below 0.7, and caps the context at 3000 characters. - An edge-runtime
/api/chatroute that injects that context into a system prompt, streams thegpt-3.5-turboreply with the Vercel AI SDK, and writes both the user message and the completion to themessagestable from the stream'sonStartandonCompletionhooks. - A
DELETEhandler on the same route that removes messages, then the chat (to respect the foreign key), then the S3 object, and still reports success if only the S3 delete fails. - A Drizzle schema on NeonDB with
chats,messages(role enumuser/system) and auser_subscriptionstable prepared for Stripe.
Architecture
The app is a single Next.js 14 project. Storage, vectors and the model are all managed services.
flowchart LR
subgraph client["Client"]
ui["Next.js UI"]
up["FileUpload with dropzone"]
end
subgraph server["Next.js API routes"]
cc["/api/create-chat"]
chat["/api/chat on edge"]
msgs["/api/get-messages"]
end
subgraph data["Data"]
s3[("AWS S3 bucket")]
neon[("NeonDB Postgres via Drizzle")]
pc[("Pinecone index tldrpdf")]
end
subgraph ext["External"]
clerk["Clerk auth"]
oai["OpenAI embeddings and chat"]
end
up -->|"PutObject"| s3
ui -->|"POST file_key"| cc
ui -->|"POST messages"| chat
ui --> msgs
cc --> clerk
cc -->|"download PDF"| s3
cc -->|"embed chunks"| oai
cc -->|"upsert by namespace"| pc
cc -->|"insert chat"| neon
chat -->|"query top 5"| pc
chat -->|"stream completion"| oai
chat -->|"save messages"| neon
msgs --> neonSystem design
There are two lifecycles: ingesting a document once, then answering each question against it.
sequenceDiagram
participant U as User
participant B as Browser
participant S3 as S3
participant API as Next.js API
participant OAI as OpenAI
participant PC as Pinecone
participant DB as NeonDB
U->>B: Drop a PDF or paste a URL
B->>S3: PutObject uploads slash timestamp name
B->>API: POST create-chat with file_key
API->>S3: Download file to tmp
API->>API: PDFLoader then split into chunks
loop each chunk
API->>OAI: Create embedding ada-002
end
API->>PC: Upsert vectors in file namespace
API->>DB: Insert chats row
API-->>B: chat_id
U->>B: Ask a question
B->>API: POST chat with messages and chatId
API->>DB: Look up fileKey for chat
API->>OAI: Embed the question
API->>PC: Query topK 5 in namespace
API->>API: Keep score above 0.7 and cap at 3000 chars
API->>OAI: gpt-3.5-turbo with context, stream true
OAI-->>B: Streamed tokens
API->>DB: Save user and assistant messagesDecisions & trade-offs
- One Pinecone namespace per file. Retrieval can never leak chunks from someone else's document, and deleting a chat maps cleanly to one namespace. The cost is many small namespaces instead of one filtered index.
- Score threshold and character cap on context. Filtering at 0.7 and truncating to 3000 characters keeps the prompt small and cheap, but a question that needs facts spread across many pages can get an incomplete context.
- Uploading to S3 from the browser. This skips a server hop for large files. The catch is that the S3 keys sit in
NEXT_PUBLIC_env vars and the bucket needs public read, which I would not ship for anything sensitive. - Edge runtime for chat, Node for ingestion. Streaming starts quickly on the edge, while PDF parsing needs the filesystem, so
create-chatstays on Node. Ingestion is synchronous in the request, so a very large PDF makes the user wait.
What I'd do next
- Move ingestion to a background job and switch the upload to presigned URLs so no credentials are exposed to the client.
- Return page numbers with each answer. They are already stored in the vector metadata but never shown.
- Upgrade the chat and embedding models and add a re-ranking step before the context is built.