← Back to work

FOLIO

Upload a PDF. Ask it anything. Get a structured summary in seconds.

AWS CDK (serverless)RAG / S3 VectorsReact + Cognito
Open app →
Folio app screenshot

What It Is

University students accumulate course material faster than they can process it — lecture slides, textbook chapters, and PDF handouts that need to be understood, not just stored. Folio ingests those documents and produces a structured study guide: key concepts extracted and organized, definitions preserved in their original phrasing, arguments presented with the clarity of a well-edited textbook. The same content becomes queryable — ask a question, get the relevant passage.

Architecture

Compute

7 Lambda functions — Python. S3 events, SNS, SQS, API Gateway.

AI

Amazon Bedrock. Cohere embed-multilingual-v3 (1024-dim vectors). Claude Haiku for summaries and conversation.

Vector store

AWS S3 Vectors. IAM-native, no API keys, topK up to 100.

Storage

S3, DynamoDB for Metadata, QueryLogs, Conversations and Usage.

Auth

Cognito User Pool + Identity Pool. Google OAuth. Branded HTML verification email.

Delivery

CloudFront CDN. API Gateway REST. Route 53 + ACM custom domain.

IaC

AWS CDK (TypeScript). 3 environments: dev/staging/prod.

CI/CD

GitHub Actions + OIDC. No long-lived credentials.

User browser
  │
  ├── S3 presigned URL ──► S3 (raw PDF)
  │                            │
  │                        S3 event
  │                            │
  │                     DocumentIngestion Lambda (Python 3.13, 300s)
  │                     (PyPDF → digest JSON, or async Textract for scanned PDFs)
  │                            │
  │                     Digest JSON → S3
  │                            │
  │                        S3 event
  │                            │
  │                     BedrockVectorization Lambda (Python 3.9, 300s)
  │                     (Cohere 1024-dim → S3 Vectors)
  │                            │
  │                     SNS ──► SQS ──► StudyBook Lambda (Python 3.13, 900s)
  │                                     (Map-reduce → Claude Haiku 4.5)
  │                                             │
  │                                       DynamoDB (status: summary_generated, summary)
  │                                             │
  ├── API Gateway ──► QueryProcessing Lambda (Python 3.13, 30s)
  │                   (S3 Vectors topK search → ranked passages)
  │
  └── API Gateway ──► ConversationFunction (Python 3.9, 60s)
                      (S3 Vectors context + Claude Haiku 4.5 → synthesized answer)

Technical Decisions

Decision 1

Chose: PyPDF text extraction (free), Textract as async fallback for scanned PDFs

Over: Textract for all documents

Because: Textract charges $0.0015/page — $0.30 for a 200-page PDF. PyPDF is free and processes in under 1 second. Text PDFs account for the majority of academic papers.

Decision 2

Chose: AWS S3 Vectors (GA) for vector storage

Over: Pinecone, Weaviate, or Bedrock Knowledge Base

Because: IAM-native — no API keys to manage, no external billing, no egress. Bedrock KB was rejected because it locks preprocessing into AWS's pipeline with no control over chunking or metadata schema.

Decision 3

Chose: Cohere embed-multilingual-v3 (1024-dim) via Bedrock

Over: OpenAI text-embedding-3-small, AWS Titan embeddings

Because: $0.00002/token with multilingual support baked in. Research documents come from global sources — French papers, Spanish textbooks, Japanese journals.

Decision 4

Chose: Claude Haiku 4.5 for summary generation and conversation

Over: GPT-4 Turbo, Claude 3 Sonnet, Bedrock Titan Text

Because: $0.00025/1K input tokens — GPT-4 Turbo is 40× more expensive. 200K context window handles the 50K character document digest without truncation.

Decision 5

Chose: Map-reduce summarization — chunk groups of 10 → sub-summaries → final reduction

Over: Single-pass summarization of all chunks

Because: A 200-page PDF produces 500 chunks. Feeding all 500 raw chunks to Haiku exceeds the 50K context cap and degrades quality. Sub-summarizing in groups of 10 costs less and produces better-structured output.

User Journey

A student arrives with a long research paper and not enough time to read it. They land on Folio, sign in with Google, and upload the PDF. The status badge says “Processing.” While the pipeline runs — text extracted, content embedded as vectors, stored in S3 — they open the document page and watch the status flip to “Ready to chat.” They type: “What does this paper conclude about carbon pricing?” Three relevant passages come back, each with page context. Below them, a synthesized answer draws from all three. They haven’t read a single page.

By the time they’ve worked through a handful of questions, the status flips again: “Summary ready.” A structured digest appears — a paragraph synopsis, a domain glossary, the main arguments laid out as takeaways. They use it to anchor everything they just learned through the conversation. The document stays in their library. Next session it’s immediately queryable — no re-upload, no re-processing.

Why It Matters

Why it exists: PDF is a terrible format for learning, and the existing tools for fixing that either charge a subscription, require vendor lock-in, or process your documents in a black box with no cost transparency. The gap was a focused, fully serverless implementation — your documents, your vectors, your AWS account, your IAM. No Pinecone invoice, no OpenAI dependency, every component pay-per-use and observable.

What makes the implementation non-obvious: Three decisions a less experienced engineer would not have reached.

First: S3 Vectors — a service that entered general availability in December 2025 — over Pinecone, because IAM-native auth eliminates API keys and removes an entire external billing surface; the tradeoff was building against a brand-new service that changed its filter syntax when it left preview, breaking metadata filters silently.

Second: PyPDF-first ingestion with Textract as an async fallback for scanned documents, because Textract charges per page and most academic PDFs are text-based — the cost discipline decision happened before the architecture decision.

Third: map-reduce summarization — summarizing chunks in groups of ten, then reducing the sub-summaries — because feeding hundreds of raw chunks to a model in one call both exceeds context limits and degrades output quality; it’s the same pattern used in production LLM systems at scale, applied to a single-user document pipeline.

Revelations

Haiku says it finished, but the JSON is broken

Claude Haiku returns stop_reason: end_turn — meaning it completed normally. The JSON was still malformed. Not a rare edge case: it happened on the first production document. The map-reduce summary pipeline had to be rebuilt with a _extract_partial_summary() fallback that uses regex to pull whatever fields it can. The lesson was not "Haiku is unreliable" — it was "never trust a language model's self-report of completion without structural validation."

PyPDF collapsed document processing cost from ~$11 to $0

The original design used Textract for everything. Textract's AnalyzeDocument API charges $0.0015 per page. A 200-page document costs $0.30. At scale, that's meaningful. The discovery that PyPDF can extract text from the vast majority of academic PDFs for free — and do it in 1 second instead of 15+ — changed the ingestion architecture from synchronous-Textract to PyPDF-first with Textract as an async fallback for scanned images. The $11/doc estimate dropped to under $0.20 total in Textract charges.

Cognito user pool attributes are immutable after creation

The name attribute was not included in the initial Cognito User Pool schema. Once users exist in a pool, you cannot add standard attributes — Cognito rejects the CloudFormation change. The only fix is to delete all three pools (dev, staging, prod) and recreate them. That meant a manual deletion of every Cognito resource, which left 14 orphaned CloudWatch log groups from the Lambda functions. Those log groups blocked the CloudFormation stack from recreating the same Lambda names. The fix required manually deleting each log group before the next deploy could succeed.

One Tailwind class silently killed the background gradient

The body background was a red radial gradient implemented via body::before at z-index: -1. The AuthContainer had a bg-blade-bg Tailwind class — a solid opaque background — which sat on top of the gradient and blocked it. The component looked correct in isolation. No console errors. No linter warning. The only way to catch it was a side-by-side screenshot comparison during a visual review pass. The fix was removing one class.

S3 Vectors changed its filter syntax when it left preview

The vector index was built against S3 Vectors while it was still in preview. When it went GA in December 2025, the filter syntax changed. Every query that used metadata filtering broke silently — they returned results but ignored the user_id filter, meaning any user's documents could appear in any other user's results. This wasn't caught in unit tests because unit tests mocked the S3 Vectors client. The fix required a codebase-wide filter syntax migration and re-indexing all existing vectors.

Contact

Building something that needs a pragmatic engineer — someone who thinks about cost before complexity and ships to prod, not just localhost? Get in touch.