Ranked Search on SQLite
A job-search engine over 13,000 multilingual listings — structured SQL filters, FTS5 with a trigram index, title-boosted BM25, and an eight-signal scorer in PHP — built after deliberately turning down Meilisearch.
Search for a command to run...
A job-search engine over 13,000 multilingual listings — structured SQL filters, FTS5 with a trigram index, title-boosted BM25, and an eight-signal scorer in PHP — built after deliberately turning down Meilisearch.
No comments yet. Be the first to comment.
Restaurant staff search for clients and reservations with typos, missing diacritics, and half-remembered phone numbers — and the whole thing runs on PostgreSQL trigrams plus a scoring layer in TypeScript, with no search infrastructure at all.
You inject a secret into a container at startup, the app runs perfectly, and then docker exec fails with an authentication error. Here is why it happens, and the one line that fixes it.
A multilingual savings app I built to make myself save. The interesting part isn't the balance creeping up. It's what happens when the plan has to change — a missed deposit, a bigger ambition, a moved date. The schedule rewrites itself, and it never throws the old one away.
A table-reservation system for an Istanbul restaurant where the real engineering wasn't the booking form but a live floor plan that read the night's reservations off the database every half hour and regrouped, recoloured, and reset every table on its own — with every staff screen kept in agreement in real time.
Deutsche Post does not hire Postboten. Not in its job titles, anyway — the postal-delivery listings are titled "Zusteller" or "Fachkraft Postdienstleistungen", and the word every German speaker would actually type into a search box, Postbote, appears only somewhere in the body of the description. In the corpus behind my job-search engine, 511 listings match "Postbote" through their descriptions, every one of them a real postal job, and not a single one would surface if search only looked at titles. When I wrote about that project before, I stopped at the pipeline that reads the listings; the sentence "before a custom search engine ever ranks it" was a promissory note. This post is the search engine.
The sensible option was Meilisearch. It was on the table, seriously — an open-source faceted search engine that hands you multilingual stemming and typo tolerance for free. It lost the decision for a reason I stand behind: the parts of search I most wanted to build — the ranking, the scoring, the ability to explain a result's position — are exactly the parts Meilisearch would have absorbed into a black box, and it would have added a service to a stack that otherwise fits in a single SQLite file. Also considered and rejected: FTS5 alone (throws away the structured filters the relational schema gives me) and building an inverted index from scratch in PHP (two to three weeks of reinventing what FTS5 does well, with no extra learning in the parts that matter). What shipped is a hybrid: SQL owns the filters, FTS5 owns the free text, and a scorer in PHP owns the ranking.
Every search resolves through two layers that compose. Layer 1 is plain SQL over indexed columns — country, language, years of experience, role taxonomy, tech stack, canonical-only, not-polluted — and it always runs, cutting the 13,000-listing corpus down to a slice before any text matching happens. Layer 2 is SQLite's FTS5 virtual table, which indexes exactly three columns:
CREATE VIRTUAL TABLE listings_fts USING fts5(
title, -- short, high-signal
description_text, -- the full HTML-stripped body
indexed_org_name, -- the employer
tokenize = 'trigram'
);
A worked example from the calibration notes: a candidate filtered to Germany, English-speaking, at most five years of experience, software roles, types postgres backend. Layer 1 narrows 13,158 records to roughly 600. Layer 2 runs MATCH 'postgres backend' over those 600 and returns about 30 with BM25 scores. The scorer ranks the 30. No cluster, no sync job, no second store — the search index lives in the same database file as the rows it indexes.
The tokenizer choice is where the corpus pushed back on the defaults. About half the listings are in German, and German welds its words together — Postbote is Post + Bote, and a candidate who types bote is asking a question that word-boundary tokenizers cannot answer. In a head-to-head spike on 2,000 records, FTS5's default unicode61 tokenizer found 0 matches for bote and 0 for kube; the trigram tokenizer — which indexes overlapping three-character windows and does not care where words begin — found 477 and 29. That substring property covers compound nouns and partial tech terms in one move, and it is script-agnostic, so Greek and Czech and Polish queries work with zero per-language code.
The bet has a bill attached. The trigram index is 2.35× the size of the default. There is no typo tolerance — developr returns nothing, a limitation documented and accepted rather than papered over. And a three-letter query like "SAP" happily matches inside "ASAP", "WhatsApp", and "Hansaplast", because trigrams have no concept of a word boundary. That last one grew teeth after launch: word-bounded matching eventually came back as a second FTS index (unicode61 alongside trigram), blended in one query, with a rank bonus for word-bounded hits — because a user searching "Git" deserves better than every listing containing "digital".
Searching descriptions is what makes Postbote findable, and it is also where the noise lives: for a common word like "Manager", description-only matches run about 80% irrelevant. The answer is not to stop searching descriptions — it is to make the ranking opinionated about where a match landed. FTS5's bm25() accepts per-column weights:
ORDER BY bm25(listings_fts, 20.0, 1.0, 5.0)
-- title ×20, description ×1, org name ×5
A title match outweighs a description match twenty to one. Jobs actually titled "React Developer" rank far above jobs that mention React once in a nice-to-have list, while the description-only Postbote matches still surface below anything title-relevant. The held-out term analysis that justified this found that tech terms almost never appear in titles at all — a title-only search would lose 77–98% of real matches per term — so the weighting keeps both worlds: recall from descriptions, precision from titles.
BM25 is one voice in the final ranking, not the verdict. The composite scorer in PHP computes eight normalized components per result: the free-text score, recency (an exponential decay anchored to the corpus snapshot's newest record, not to "now" — the product is honest about being a snapshot), description quality, a canonical-cluster bonus, role match graded by classifier confidence, language match, tech-stack match, and a visa-sponsorship signal where unknown gets a neutral 0.5 rather than a punishment. The weights live in one config file, and PHP is the deliberate seam: scoring pushed into SQL would lock it to the substrate, pushed into FTS5 would lock it to BM25.
Alongside every score, the scorer emits a per-component breakdown — weighted contribution, raw value, and a human-readable note like "posted 12 days ago" or "en+de required, you speak en" — designed to power an explainer panel where any result can answer "why this rank?". Writing this post forced an honest audit: the breakdown is computed on every request, unit-tested, and consumed by a benchmarking command — and no UI component renders it yet. The panel is now a filed issue rather than a quiet assumption, which is its own small lesson about the distance between an architecture document and a screen.
The biggest post-launch fix was in how typed queries become FTS5 expressions. The first version joined words with an implicit AND, which turned multi-word queries into word-bag noise — every listing containing both words anywhere. The replacement is two modes selected by syntax: a plain multi-word query becomes a phrase-or-proximity search, "data engineer" OR NEAR("data" "engineer", 100), and a comma becomes an explicit opt-in to keyword mode where each comma-separated chunk must match as its own phrase. Depending on the query, that dropped 22–98% of the noise.
The companion decision matters as much: when a query finds nothing, the engine does not silently degrade to sloppier matching to avoid an empty screen. It shows the empty state — and makes it smart. laravel developer can match zero listings as text while the corpus is full of Laravel jobs, because the tech extractor understands concepts that exact tokens miss; so the empty state offers clickable recovery tips — "try the Tech: Laravel filter (n jobs)", "try comma-separated keywords" — instead of quietly showing worse results. Honest zero over engagement-driven noise.
Would embeddings make this better? Probably — "postgres" never matching "PostgreSQL DBA" is a real gap, and semantic search sits in the V2 notes next to German morphology and learned per-user weights. But the version that exists ranks thirteen thousand listings in two dozen languages on a database engine most people call a toy, and every interesting decision in it — the tokenizer, the column weights, the eight signals, the refusal to fake results — is mine to explain rather than a vendor's to hide.
Earlier posts from the same project: The Job Finder That Reads Every Listing covers the pipeline that turns raw postings into the structured corpus this engine searches, and Fuzzy Search Without Elasticsearch is this post's sibling: the same do-it-with-the-database philosophy applied to a restaurant's client book.