Project 02 · Retrieval · 2026

Retrieval finds the right passage half the time. The system knows when it hasn't.

Conviction answers questions about SEC 10-K filings with a citation on every claim, and refuses when the filing doesn't cover it. It's deployed, and I spent longer measuring it than building it. The measurements are not flattering, which is most of why they're worth publishing.

RoleSole author
Corpus4 10-K filings, 416 chunks
StackPython, GPT-4o, ChromaDB, FastAPI, Docker, EC2
StatusLive, v1.0
12 of 23answer-text recall@8
47 of 58citation precision, hand-labeled
6 of 6correctly refused
01

The problem

A 10-K runs to a few hundred pages, most of it boilerplate that barely moves year to year. The signal is in what changed. A risk factor that gained a paragraph, a hedge that got firmer, a customer concentration disclosure that grew a sentence. Finding that by reading is possible and nobody does it.

The incumbent tools, AlphaSense and Hebbia and Rogo, solve this for institutions at institutional prices. Conviction is scoped to one person's watchlist, and to transparency. Every claim points at the sentence it came from, and the system declines rather than guessing when the filing doesn't support an answer.

I aimed it at credit questions specifically: liquidity, debt, risk factors. Those are the questions where the answer lives in language rather than in a number you could pull from a table, and where being pointed at the wrong paragraph costs you something.

02

What I built

Filings come from EDGAR, get parsed into Items 1, 1A, 7 and 8, and are chunked section-aware at roughly 800 tokens with 100 tokens of overlap. Chunks never span Item boundaries, because a passage straddling Item 1A and Item 7 is answerable to neither question. The store is ChromaDB with ticker and item metadata, so retrieval scopes to one company and one part of the filing.

Generation runs against GPT-4o under a prompt that refuses to answer outside the provided excerpts and requires a [Source N] marker on every claim. LangChain is in the project for document loaders and nothing else. Everything downstream talks to the SDKs directly, which keeps the failure modes legible.

LayerChoiceWhy not the alternative
ChunkingSection-aware, ~800 tokensFixed-size splits cut across Items and produce ungroundable passages
Vector storeChroma, local, persistentPinecone adds a network hop and a bill for a single-user tool
FrameworkLangChain loaders onlyFull-chain abstraction hides where retrieval actually failed
HostingDocker Compose on one EC2 instanceApp Runner and Fargate fight a persistent disk, and Chroma needs one
EvalHand-labeled, no model judgeA judge shares the failure mode of the thing it grades

It runs as two containers on a t3.small, with Caddy terminating HTTPS and the Chroma store on a mounted volume. A FastAPI layer sits in front: API keys stored as SHA-256 hashes, per-key hourly and daily limits, a global daily cap, and a server-held demo key so the public page works without anyone signing up. Each request writes one structured JSON line to CloudWatch, carrying no keys and no question text.

I chose EC2 over a managed runtime deliberately. The app needs a persistent disk, and I wanted to be able to explain every layer of the thing rather than point at a platform that handles it.

03

What I got wrong

I built year-over-year risk-factor diffing on top of retrieval, because retrieval was the system I had. Ask for both years' risk factors, get the top-k chunks for each, compare them. It produced plausible output and it was wrong in a way that took an eval set to see.

Retrieval answers “what is relevant to this question.” Diffing asks “what is different between these two documents.” Top-k actively destroys the second.

The finding

Any retrieval step returns the passages most similar to a query. For two versions of the same document, those are the passages that didn't change. The parts that moved are exactly the parts least likely to survive a similarity ranking. I was sampling boilerplate and diffing it.

The fix was to stop retrieving. The diff path bypasses the vector store entirely and does whole-document comparison, with a model producing structured output on what changed. Character-level diffing was the other wrong answer I tried: filings get lightly reworded every year, so a text diff flags hundreds of edits and buries the three that matter. The judgment about which changes are material is the product, and that judgment needs a model reading both passages in full.

04

What the eval says

29 questions across the four filings, 23 answerable and 6 that should be refused, with 10 of the answerable ones tagged as credit questions. Every question was checked against the filing by hand before it entered the set. Every citation the system produced was labeled by hand afterwards. No model judged any part of this.

The first metric was useless

I started by measuring item-level hit@8: did any retrieved chunk come from the Item the answer lives in. It came back near-perfect, and that number meant nothing. Each ticker has only four Items indexed, so retrieving eight chunks from the right one is close to guaranteed whether the system works or not.

So I replaced it. Answer-text recall@8 asks whether the evidence sentence I recorded when writing the question actually appears in the eight chunks that came back. That is a much harder test and it produced a much more useful number.

MetricResultCredit questions only
Answer-text recall@812 of 23 (52.2%)4 of 10 (40.0%)
Chunk-level hit@89 of 23 (39.1%)4 of 10 (40.0%)
Citation precision47 of 58 (81.0%)—
Abstention accuracy6 of 6 (100%)—
False-refusal rate2 of 23 (8.7%)—

Retrieval is the weak link, and the system knows it

Just over half the time, the passage containing the answer doesn't make it into the context window. On credit questions, the ones I built this for, it's worse at 40%. That is the headline problem and no amount of prompt work fixes it.

What held up is the behavior around that failure. It refused all 6 questions it should have refused. It falsely refused only twice, and both false refusals were cases where retrieval had genuinely missed the evidence. The system was not being timid. It was correctly reporting that it couldn't see the answer.

When retrieval failed, the system said so rather than answering from whatever came back instead. The failures are honest ones, which is the only kind you can debug.

The thing worth knowing

Citation precision at 81% is the number I like least. Roughly one citation in five doesn't support the claim attached to it, and the two worst cases are instructive: one cited the wrong passage while still reaching a correct conclusion, and one attached a citation to a statement about something the filing doesn't mention. Both look completely normal in the output. That is exactly the failure a fluent answer hides, and it is why the labeling is manual.

Known limitations

The corpus is four filings and 416 chunks, so these are measurements of a controlled system rather than estimates of how it would behave at scale. Separately, the test suite found a real chunker bug after the eval was labeled: 16 of 416 chunks exceed the 1,000-token target, the largest at 1,554, mostly in AMD sections where the overlap logic misbehaves. I left it unfixed on purpose. Changing the chunker reshuffles every chunk and invalidates 58 hand-labeled citations, and I would rather ship a known bug with a failing test pointing at it than quietly reset the only honest numbers I have.

Next, in order: fix the chunker and re-label, since retrieval recall is the ceiling on everything else. Try hybrid retrieval, because filings repeat enough standard phrasing that lexical matching should help where embeddings are drifting. Extend diffing beyond Item 1A to MD&A, where the language moves more and means more. And benchmark grounded retrieval against frontier models answering from parameters alone, which is the question dart-rag takes up in Korean.

The transferable lesson from the whole build: the architecture should follow the question, not the other way round. I had a retrieval system, so I reached for retrieval. The diff only worked once I was willing to route around the thing I had just built, and I only found out it was broken because something was measuring it.