[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f12y2lv2586jqt":3},{"_id":4,"slug":5,"title":6,"subtitle":7,"kind":8,"cards":9,"tags":58,"categories":60,"source":62,"lang":65,"author":66,"audioState":69,"stats":70,"publishedAt":73,"renderer":74},"6abac4cfca21c797c7e9a44c","i-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a","I couldn't touch the database, so I built search next to it","A production system I work on needed a search box that forgives people.","news",[10,13,18,23,28,33,38,43,48,53],{"headline":6,"body":11,"imageUrl":12,"sourceImageUrl":12},"A production system I work on needed a search box that forgives people. They type kavefozo and expect Kávéfőző. They type hedphones and expect headphones. The constraints were the interesting part: The schema could not change. No new columns, no generated columns, no migrations on tables that weren't ours. No new infrastructure. No Elasticsearch, no Meilisearch, no extra service to run, sync, back up and secure.","https:\u002F\u002Fmedia2.dev.to\u002Fdynamic\u002Fimage\u002Fwidth=1200,height=627,fit=cover,gravity=auto,format=auto\u002Fhttps%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6i31fhjwbio779b70ms3.png",{"headline":14,"body":15,"imageUrl":16,"images":17},"So search had to happen inside the database","So search had to happen inside the database that was already there. PostgreSQL turned out to have almost every piece needed. Wiring those pieces together properly was the hard part, and that wiring became an open-source library: Fuzzphony.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F1.webp",{"local":16},{"headline":19,"body":20,"imageUrl":21,"images":22},"Fuzzphony is at v0.4 and still in active","Fuzzphony is at v0.4 and still in active development. The core works and is heavily tested, but the API can change before 1.0, and there are rough edges I'll be upfront about at the end. This post covers the design, the bug that taught me the most, and what I still haven't solved. What LIKE '%…%' gets wrong Most search boxes start here:","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F2.webp",{"local":21},{"headline":24,"body":25,"imageUrl":26,"images":27},"It misses typos (hedphones), accents (creme vs crème)","It misses typos (hedphones), accents (creme vs crème) and word forms (drills vs drill). It can't exclude a word, it doesn't rank, and when it misses, it reads the whole table to return nothing. Postgres ships three tools for exactly these problems: Full-text search (tsvector, tsquery, ts_rank_cd): stemming for 28 languages, field weights A to D, relevance ranking. unaccent: folds Kávéfőző into Kavefozo. pg_trgm: trigram similarity, which is what lets wireles match wireless.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F3.webp",{"local":26},{"headline":29,"body":30,"imageUrl":31,"images":32},"None of them is new. The work is","None of them is new. The work is in making them behave together, stay in sync with your data, and be safe to point at user input. Since your tables are off-limits, the index lives in its own table next to them. For every index, Fuzzphony keeps a table such as fuzzphony_products with: a weighted tsvector for full-text search, on a GIN index, a normalised, unaccented text column for typo tolerance, on a GIN trigram index, typed filter columns on btree indexes, so where('price', '\u003C=', 20_000) never has to touch your table, the inputs for ranking: a boost column (popularity, say) and a recency column.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F4.webp",{"local":31},{"headline":34,"body":35,"imageUrl":36,"images":37},"The source can be a single table or","The source can be a single table or any SELECT, joins included. With schema: fuzzphony set, the sidecar tables, the queue and the helper functions all live in their own schema, and dropping an index is one command.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F5.webp",{"local":36},{"headline":39,"body":40,"imageUrl":41,"images":42},"To be precise about \"off-limits\": no column is","To be precise about \"off-limits\": no column is ever added, but in the default queue mode and in trigger mode Fuzzphony does attach triggers to the tables it watches. That's how it sees writes from raw SQL, imports and other applications. If even a trigger is too much, the orm and manual modes need none. The cost of the design is obvious: there is now a second copy of the searchable data, and it has to stay in sync. Keeping it in sync without hurting writes There are four sync modes: Three details made the trigger-based modes usable on real tables.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F6.webp",{"local":41},{"headline":44,"body":45,"imageUrl":46,"images":47},"Statement-level triggers with transition tables. A row-level trigger","Statement-level triggers with transition tables. A row-level trigger on an UPDATE brand SET … that touches 100 000 rows fires 100 000 times. A statement-level trigger fires once and sees every changed row through a transition table, so queuing the affected products becomes a single set-based statement. In simplified form (not the exact generated SQL):","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F7.webp",{"local":46},{"headline":49,"body":50,"imageUrl":51,"images":52},"Only refresh when something relevant changed. An UPDATE","Only refresh when something relevant changed. An UPDATE product SET last_viewed_at = now() should not reindex anything. For the index's own table, Fuzzphony checks whether a mapped column actually changed. For joined tables you opt in with columns: ['name'], because a library cannot guess which of a joined table's columns matter to you.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F8.webp",{"local":51},{"headline":54,"body":55,"imageUrl":56,"images":57},"TRUNCATE is not a delete. It fires no","TRUNCATE is not a delete. It fires no DELETE triggers, so early versions left stale documents in the index after a TRUNCATE, and not even a full reindex removed them. I found that one late. Now every watched table also gets an AFTER TRUNCATE trigger, and a full reindex prunes documents whose rows no longer exist.","\u002Fapi\u002Fmedia\u002Fposts\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-cf6d3c5a\u002F9.webp",{"local":56},[59],"dev",[61],"Technology",{"name":63,"url":64},"Dev.to","https:\u002F\u002Fdev.to\u002F_er2es_\u002Fi-couldnt-touch-the-database-so-i-built-search-next-to-it-1pjn","en",{"handle":67,"displayName":68},"spots","Spots","queued",{"views":71,"likes":72,"saves":72,"shares":72,"completions":72,"opens":72,"skips":72,"depthSum":72},3,0,"2026-09-28T19:49:35.599Z","local"]