06 Case study Live
Plutarch
Ρίζα is an open dictionary of Modern Greek where every definition carries a citation you can click through to. Type any form of a word — accented or not, monotonic or polytonic — and land on its entry, with declension and conjugation tables, cross-references and real usage examples.
01 Problem
Online Greek dictionaries are either thin word lists or closed products. Greek also makes lookup hard: the same word arrives as dozens of inflected forms, with or without accents, and in monotonic or polytonic spelling. A search box that only matches the headword exactly fails most real queries.
I wanted the opposite: an open dictionary where any form of a word finds its entry, and every definition cites where it came from.
02 What I built
Ρίζα ("root") imports the Greek Wiktionary extraction from kaikki.org into Postgres and serves it through a Next.js app:
- Search that ignores accents and case, accepts polytonic input, and resolves inflected forms back to their lemma.
- Server-rendered entry pages with senses, usage examples, cross-references, and declension and conjugation tables.
- Greek permalinks (
/λέξη/…), a sitemap, word of the day, and an anonymous report-an-error flow.
03 Architecture
Two halves. At build time, a streaming pipeline turns 1.76 million raw records into five CSVs and bulk-loads them. At request time, two SQL functions do all of the search and ranking, so the app stays thin.
04 A week with Ρίζα
One reader's week with the dictionary. The phone shows the app's own Greek interface, and the right side shows the data behind each page: the search key, a lemma with its inflected forms, the cited senses, the cross-references and the reports queue. Every beat is a feature that ships; the glosses are short paraphrases, not quoted entries.
- MonAny form
A reader types εκανες, no accents. The search key strips them, finds the inflected form, and lands on its verb: κάνω.
- TueCited senses
Every sense carries a link to where it came from: Greek Wiktionary, CC BY-SA.
- WedThe table
The conjugation table shows the form they typed, in context.
- ThuWord of the day
Ρίζα, “root”, the dictionary’s own name. Cross-references lead to the words around it.
- FriReport a mistake
A reader flags an example. It’s sent anonymously (only a salted hash is kept, for rate limiting) to the reports queue.
05 Try it
Every query and every stored word goes through the same few steps before they're compared. Type any form of a Greek word, accented or not, monotonic or polytonic, and watch it reduce to the key the database actually searches. Combining accent marks are highlighted as they're stripped.
Interactive · search-key pipeline
Type a Greek word to see it reduced, step by step, to the key the database searches. (This demo needs JavaScript.)
06 Key decisions
Decision 1
Two normalisers, and a script that proves they agree
- Context
- The search key is generated in SQL when rows are written, but queries are normalised in TypeScript. If the two drift apart, search silently returns nothing.
- Decision
- Keep
normalise_el()andnormalise()as deliberate twins: decompose, strip combining marks, recompose, lowercase, fold final sigma. A parity checker runs both over the same inputs and also runs the app's real ranking query. - Trade-off
- Two implementations to maintain, in exchange for a failure mode that is caught instead of invisible.
Decision 2
Cap forms per entry instead of dropping words
- Context
- The corpus didn't fit the free database tier. The obvious filter would have deleted about 74,000 headwords.
- Decision
- Keep every entry and cap the inflected forms stored per entry, preferring tagged forms. That cut the corpus from 371 MB to 255 MB with all 95,774 entries intact.
- Trade-off
- The long tail of rare verb forms isn't searchable. Losing words would have been worse.
Decision 3
Rank by prominence, and say so
- Context
- Good ranking wants word frequency, but frequency lists mean an extra download and licensing questions.
- Decision
- Rank in six tiers (exact lemma first, substring last) and break ties by prominence: how many other entries point at a word.
- Trade-off
- Prominence is a proxy, not usage frequency, and it's documented as such. Real frequency data is the next ranking upgrade.
Decision 4
Put the database next to the functions
- Context
- The same query took 24 ms locally and 103 ms deployed, and TCP connection setup dominated cold requests.
- Decision
- Use Neon's HTTP driver in production, with node-postgres over TCP locally, and pin the Vercel functions to Frankfurt, the same region as the database.
- Trade-off
- Two code paths for one database client, switched by host, rather than one slower one.
07 Hard problems
One letter, three encodings
The accented letter ά reaches the database as three different codepoint sequences, and decomposition collapses them. Then a second bug turned up: Postgres's libc collation on macOS lowercases Ύ to ώ instead of ύ. Both normalisers now strip accents before lowercasing, so the order of operations can't reintroduce it.
A sequential scan on every keystroke
A LIKE pattern built inside a CTE stopped Postgres from using the index, so every keystroke scanned 880,000 inflected forms. Rewriting the prefix match as a range query on a btree index took it from 61 ms to 0.028 ms.
Ranking undone by the app
The SQL function ranked correctly and its tests passed, but the app wrapped it in an outer ORDER BY that threw the ranking away in production. The fix was WITH ORDINALITY. The lesson was to test the query the app actually sends, so the parity checker now does exactly that.
08 Outcome & what I'd do differently
Ρίζα is live and stable: 95,774 entries, declension tables for 63,971 of them, conjugation tables for 5,455 verbs, and a 98-test suite, built in four days. The source is private for now. The dictionary content is CC BY-SA, inherited from Wiktionary.
- Scope. The original plan (a diachronic dictionary with five "lenses" and Cypriot dialect research) was far bigger than what a first version needed. Shipping the core first was the right call, and I'd plan that way from day one.
- Test the integration, not just the parts. Unit tests on the SQL function missed the bug in the query that wraps it.
- Audience before features. The next version starts with who it's for, plus an admin view to triage error reports.