← All work
Case study · 2024

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.

Next.jsTypeScriptDrizzle ORMNeonDBPineconeOpenAIAWS S3ClerkTailwind CSSLive site ↗Source ↗
tldr-nice.vercel.app
TL;DR — screenshot of the live site

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-chat route, guarded by Clerk auth, that runs the ingestion pipeline and inserts a chats row with the S3 key, file name and public URL.
  • An ingestion pipeline (loadS3IntoPinecone) that downloads the file from S3, parses pages with LangChain's PDFLoader, splits them with RecursiveCharacterTextSplitter, embeds each chunk with text-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/chat route that injects that context into a system prompt, streams the gpt-3.5-turbo reply with the Vercel AI SDK, and writes both the user message and the completion to the messages table from the stream's onStart and onCompletion hooks.
  • A DELETE handler 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 enum user/system) and a user_subscriptions table 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 --> neon

System 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 messages

Decisions & 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-chat stays 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.