Akin Shonibare

the rook · straight lines, as far as they go

memo.

Capture everything, ask it back.

On my phone since September 2026Build 20Python, Postgres, pgvector, Expoby coraki.dev

memo's Home on a Thursday: two events and four todos for the day, and a highlight resurfaced from a month ago
Today. What is on, and one old idea back.
A new voice note mid-take: the amber orb listening, sixteen seconds in
Record. Tap, talk, tap the orb again. Extraction shows at once.
Ask: the question What do I need to do before the weekend, a streamed answer with numbered citations, and Sources open on the first note
Ask. Every answer cites the note it came from.

what it is

One primitive, many views.

Everything goes into one table of captures: text, a voice note, later a photo. Nothing is categorised at capture time. A worker transcribes it, extracts todos, events, people and ideas, and embeds the text. Every feature since has been a new kind of extraction and a new query over the same store, never a new module.

It is a personal app built to the decision standard of a product with real users: a multi-tenant schema from the first migration, idempotent jobs with visible failure states, tests against a real Postgres, and evals that fail the build when extraction quality drops.

captures

Everything in, timestamped. One write path.

derivations

Typed extractions pointing back at a capture: todo, event, idea, person, habit, list item.

chunks

Embedded text for retrieval. Vector plus full text, fused by reciprocal rank.

how a note travels

The line.

The request returns an id at once. Everything after that is a background job with retries and a dead-letter state you can see.

  1. 1.captureText or audio lands. The request returns an id at once; everything after is a job.
  2. 2.transcribeHosted speech to text, with the people in your notes sent as keyterms.
  3. 3.extractOne Pydantic schema constrains the model and validates the result. One repair, then terminal.
  4. 4.embedShort notes are one chunk; long ones split on structure. Items are embedded on their own, with their dates.
  5. 5.readyAsk can see it. The morning view will bring it back.

how an answer is made

Search, then the model.

Retrieval-augmented generation is two systems. The database decides which notes are relevant and what is true about them. The model only writes.

  1. 1.rewriteOnly when the question leans on earlier turns, a small model rewrites it standalone. Otherwise skipped.
  2. 2.read firstA deterministic parser turns “this week” or “last Friday” into days; the todos and events in them are pinned. People named in the question are looked up and their last contact pinned.
  3. 3.searchVector and keyword branches, 40 candidates each, same filters, de-duplicated by note, fused by reciprocal rank with k = 60. Inside a date window, exact distances over the few dozen chunks rather than the approximate index.
  4. 4.attachEach retrieved note’s current, non-rejected items are read live and placed under it, with their resolved dates and review status, so the model sees what is true now, not what was embedded then.
  5. 5.cite, then answerThe eight sources are chosen by the system and sent before the first token. The model is never asked to produce a citation.
  6. 6.streamTokens stream over server-sent events; a dropped client reconnects to the same message. Model, tokens, latency and cost are logged per call against a daily budget.

four moves, annotated

The decisions.

Every non-trivial choice is written the same way: the problem, the options, what each costs, the move, what we accepted. Seventy of them. These four are the hard ones: how retrieval is built, how notes become chunks, where the database ends and the model begins, and what happens when the model is wrong.

5. !!ADR 0005, with 0002

Hybrid retrieval, fused by rank, not pure vector search.

The options
Pure cosine over embeddings; pure keyword search with Postgres tsvector; both, combined by a weighted sum of scores; both, combined by reciprocal rank fusion; any of these plus a cross-encoder reranker.
The move
Vector and keyword branches run against the same table with the same metadata filters, 40 candidates each, de-duplicated by note, then fused on rank with k = 60. Eight chunks go to the model. The keyword branch ORs the question's terms, because the default AND almost never matches a whole question.
What we accepted
Fusion on rank throws away score magnitude, so a runaway-best vector match counts the same as a merely good one at the same rank. Every chunk carries both an embedding and a tsvector, and both indexes are paid for on every write. No reranker, so the top three can be in the wrong order while the right chunk sits in the top ten, which eight chunks in the prompt absorbs.
15. !ADR 0015

The schema constrains the model. Semantic failure is terminal after one repair.

The options
A raw prompt plus a hand-rolled parser and repair; a wrapper library that validates with the Pydantic model and re-prompts on failure; forced tool use with a JSON schema, the pattern most tutorials show; native structured outputs, where the API constrains generation to the schema derived from the same Pydantic model that validates the response.
The move
Native structured outputs, one Pydantic model as the single schema for both constraint and validation. Syntax is now a guarantee, so the policy is about semantics. A validation error gets one repair, the same request with the error appended as a new turn. A second failure is terminal. The note moves to failed with the raw output stored, shows in the app as "couldn't process this, retry?", and a retry is a fresh attempt.
What we accepted
Owning the repair loop, which is twenty lines a library would have hidden. Provider lock-in at the schema boundary, since the constraint parameter is one vendor's. Two model calls before a semantic failure surfaces, which is why the repair count is one and not three. A column that exists only for the bad path, which is the difference between "it failed" and knowing why without re-running.
17. !ADR 0017

Chunking depends on length. A short note is one chunk.

The options
Fixed token windows with overlap, the default everywhere; paragraph and sentence boundaries with a length cap; one chunk per note, always; semantic chunking that splits where adjacent sentences stop being similar.
The move
Two regimes. Up to about 200 tokens, which is most notes, the note is exactly one chunk. Above that, split on paragraphs, then sentences, packed into chunks of about 400 tokens with 60 tokens of overlap carried from the previous chunk, and a tail under 50 tokens merged into its predecessor. Tokens are approximated as characters divided by four. Each chunk stores its note, an ordinal and its character offsets, so a citation can highlight the passage rather than name the note.
What we accepted
Overlap duplicates text, so retrieval de-duplicates by note before fusion. Chunk boundaries change if the strategy changes, so chunks are never deleted on re-chunk, only marked superseded, and an old citation keeps resolving to the text it was grounded in. The token approximation is crude for code, URLs and non-Latin scripts, and is named as such so nobody trusts it in a different corpus.
45. !!ADR 0045, with 0021

The database answers the structured part. Retrieval answers the rest.

The options
Self-query, where a model turns the question into a query plus a metadata filter, the industry default; a deterministic parser over a closed set of time phrases; tool use, where the answer model calls a lookup mid-generation; or better-worded chunks and hope.
The move
Before searching, Ask reads what it can know exactly and pins it. A deterministic parser turns "this week", "last Friday", "6 October", "3 days ago" into a date span; the todos and events in that span are read from the same table the planner reads and pinned to the top. People named in the question are matched against the people table and their latest recorded contact is pinned with a line the model can quote. Then retrieval runs inside the window first, as exact distances over a materialised set rather than through the approximate index, and outside it to fill up to eight.
What we accepted
Phrasings outside the parser’s set get no window and fall back to plain retrieval. The answer is only as good as the review, since a confirmed wrong date is trusted over the note’s words. And the index is still a copy, so the companion decision reads each retrieved note's current, non-rejected items live at answer time rather than trusting what was embedded.
All of memo's decisions

what it runs on

The stack.

API
FastAPI, SQLAlchemy async, Alembic
Jobs
arq on Redis, idempotent, dead-letter visible
Store
Postgres with pgvector, multi-tenant from migration one
Models
Claude for extraction, routed by task; Voyage embeddings
App
Expo, built with EAS, updates over the air
Gates
ruff, mypy strict, tests on a real Postgres, evals in CI
Next piece, the bishop:Ravine, the long diagonal