← All case studies

Case Study

DocSage

Making AI document analysis traceable enough to trust

Next.js 16FastAPI 0.115asyncpgpgvectorOpenAI GPT-4oAWS RDS PostgreSQLTerraformPydantic v2structlogDocker

Organizations often need to extract answers from large collections of unstructured documents. Manual review is slow, keyword search misses semantically related material, and a conventional chatbot can produce a convincing answer without showing whether the documents actually support it.

I built DocSage to explore a more useful model for document intelligence: every answer should show its evidence, how that evidence was retrieved, and what the operation cost.

DocSage is a production-deployed retrieval-augmented generation platform that ingests documents, retrieves relevant passages through semantic search, and generates structured answers with citations back to the original source chunks.

The Challenge

An LLM can summarize a document, but fluency alone does not make its answer trustworthy. A useful document-analysis system also needs to answer several questions:

  • Which passages influenced the response?
  • How relevant was each retrieved passage?
  • Was the search limited to the correct documents?
  • How many tokens did the operation consume?
  • What did the answer cost?
  • What happens when extraction, retrieval, or infrastructure behaves differently in production?

The challenge was therefore larger than connecting a document to an LLM. The system needed an end-to-end ingestion and retrieval pipeline with enough transparency for a user to inspect how an answer was produced.

What I Built

A full-stack document intelligence platform built with Next.js, FastAPI, PostgreSQL, pgvector, and OpenAI models. The platform supports:

  • PDF, Markdown, HTML, and text ingestion
  • Dual-parser PDF extraction for more resilient handling of real-world files
  • Automatic document chunking and embedding after upload
  • Idempotent chunk and embedding creation for safe reprocessing
  • Semantic search with metadata and document-level filters
  • Structured, schema-validated LLM analysis with confidence scoring and retry handling
  • Citations linking responses to individual source chunks
  • Retrieval details including similarity scores and applied filters
  • Token usage and per-request cost reporting
  • A browser interface for inspecting both the answer and its retrieval process

The application is deployed across Vercel, Render, and AWS RDS PostgreSQL.

How It Works

When a document is uploaded, the backend extracts and sanitizes its text, divides it into chunks, generates vector embeddings, and stores both the relational metadata and vectors in PostgreSQL.

Uploads are processed in memory; the original files are not retained as blobs. Only the extracted document representation needed by the retrieval system is persisted.

When a user asks a question, DocSage:

  1. 01Converts the question into an embedding.
  2. 02Searches pgvector for semantically similar chunks.
  3. 03Applies document, type, and metadata filters.
  4. 04Ranks the results and removes chunks below the configured threshold.
  5. 05Builds a grounded context containing explicit source references.
  6. 06Sends that context to the LLM for structured analysis.
  7. 07Returns the answer alongside citations, retrieved chunks, similarity scores, model metadata, token usage, and cost.

This makes retrieval visible instead of hiding it behind the final response.

Key Engineering Decisions

Keep relational and vector data together

I chose pgvector on AWS RDS instead of introducing a dedicated vector database. At the project's scale, keeping document metadata, chunks, and embeddings in PostgreSQL reduced operational complexity without creating a meaningful retrieval-performance constraint.

The design still uses an HNSW index for approximate nearest-neighbor search. If the corpus grew to millions of chunks with strict latency requirements, a specialized vector service could become the better tradeoff. For the current workload, one database meant fewer services, SDKs, costs, and failure modes.

Use retrieval instead of fine-tuning

The documents are dynamic, and users need citations. Fine-tuning would make updates slower and would not provide reliable source attribution. RAG keeps the knowledge outside the model, allows documents to change independently, and makes the supporting evidence available to the user.

Optimize for inspectability

The API does not return only an answer. It also returns the chunks used, similarity scores, filters, embedding and LLM metadata, token counts, and estimated cost.

This was a deliberate product decision: when an answer looks questionable, a user should be able to inspect the retrieval process rather than guess why the model responded that way.

The LLM response itself is also treated as untrusted input. DocSage requests JSON output, validates it against a Pydantic schema, and retries malformed responses with exponential backoff before returning a result.

Treat retrieval defaults as hypotheses

An initial similarity threshold of 0.5 worked poorly for short documents and sometimes returned no context at all. Manual testing suggested that 0.3 improved recall, but a later code audit revealed that the application did not have one authoritative default: the retrieval service and API route used 0.3 while the PostgreSQL adapter still defaulted to 0.5. The API documentation also described a different value from the parameter it exposed.

That changed the lesson. A plausible threshold — even one tested against representative documents — is still only a hypothesis until the system has one source of truth and a repeatable evaluation harness. Consolidating the retrieval parameters and measuring them against a curated dataset must come before claiming that any value is optimal.

The audit also found a subtle sweep-breaking bug: an explicit threshold of 0.0 was replaced by the service default because the fallback used Python's truthiness operator. Left unfixed, an evaluation could label a run as 0.0 while actually testing 0.3. This is exactly the kind of silent metric error that makes an apparently rigorous baseline less trustworthy than no baseline at all.

Choose the smaller embedding model intentionally

I selected text-embedding-3-small because its 1,536-dimensional vectors fit the existing pgvector design and its lower cost matched the project's scale. The system does not yet have a benchmark that justifies claiming the larger model would provide no meaningful quality improvement; that comparison belongs in the same repeatable evaluation pipeline as threshold and top_k tuning.

What Production Revealed

Several of the most valuable engineering lessons appeared only after deployment.

Extracted text is not clean text

Some PDFs contained null bytes that PostgreSQL text columns rejected. The ingestion pipeline now sanitizes extracted text before any database write.

Database drivers do not share identical type behavior

Moving to direct asyncpg connections exposed serialization differences hidden by the previous data-access layer. UUIDs, JSONB fields, and vectors required explicit conversion at system boundaries. I added centralized parsing and serialization so Pydantic models, JSON, and PostgreSQL received the types they expected.

Infrastructure readiness is different from process readiness

Creating a TLS connection pool to AWS RDS sometimes delayed application startup long enough for Render to mark the deployment unhealthy. I moved pool initialization into a background task so the server could bind its port immediately, while real requests wait for database readiness with a bounded timeout.

Database namespaces can affect extension operators

Schema isolation placed the application schema ahead of public in PostgreSQL's search path. That prevented pgvector's distance operator from resolving correctly. Qualifying the vector type and operator explicitly made the search function reliable under the least-privilege application role.

A technically valid workflow can still fail as a product

Originally, uploading a document and generating its embeddings were separate actions. Users naturally uploaded a document and queried it immediately, receiving no results without understanding why. I changed the workflow so embedding begins automatically after upload.

That was not an algorithmic failure. It was a mismatch between the system's internal states and the user's mental model.

Result

DocSage is a deployed document-analysis system that turns unstructured files into searchable, source-grounded answers while exposing the evidence and operational cost behind every response.

Its repository includes test coverage across document ingestion, chunking, embeddings, retrieval filters, citation behavior, structured LLM output, API error handling, vector operations, and cost metadata. Restoring reliable test collection is tracked separately before extending that suite with retrieval-evaluation tests.

What it demonstrates

Designing retrieval around traceability rather than noveltyMaking architecture choices appropriate to the actual scaleHandling inconsistent document and database representationsDebugging failures across multiple cloud platformsProtecting credentials behind a server-side API proxyTranslating internal pipeline states into a coherent user experience

What I'd Improve Next

The current similarity threshold was tuned manually against representative documents. That was useful for diagnosing an immediate production symptom, but it is not an evidence-based baseline, and the code does not yet expose one authoritative retrieval configuration.

The next step is deliberately ordered:

  1. 01Restore reliable test collection and consolidate threshold and top_k defaults in Settings.
  2. 02Add a failing regression test for an explicit 0.0 threshold, then replace the truthiness fallback with an is-None check.
  3. 03Curate a versioned golden dataset of queries and relevant chunk IDs.
  4. 04Implement and unit-test Precision@k, Recall@k, and Mean Reciprocal Rank independently of the live database.
  5. 05Build a harness that sweeps threshold and top_k, records reproducible run metadata, and produces a committed baseline report.

The pipeline's job is to produce trustworthy evidence, not silently retune production. Changing the deployed defaults remains a separate decision based on the resulting precision-recall tradeoff.

DocSage is representative of the kind of work I enjoy most: taking an ambiguous AI product idea and engineering the complete system around it — data ingestion, retrieval, backend services, cloud infrastructure, observability, cost controls, and a usable frontend.

Discuss a project like this