<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Szj</title>
    <description>The latest articles on DEV Community by Szj (@_er2es_).</description>
    <link>https://dev.to/_er2es_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4147841%2F3ae5b387-7d4e-40ee-9465-acf759bca506.jpg</url>
      <title>DEV Community: Szj</title>
      <link>https://dev.to/_er2es_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_er2es_"/>
    <language>en</language>
    <item>
      <title>I couldn't touch the database, so I built search next to it</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:40:07 +0000</pubDate>
      <link>https://dev.to/_er2es_/i-couldnt-touch-the-database-so-i-built-search-next-to-it-1pjn</link>
      <guid>https://dev.to/_er2es_/i-couldnt-touch-the-database-so-i-built-search-next-to-it-1pjn</guid>
      <description>&lt;p&gt;A production system I work on needed a search box that forgives people. They type &lt;code&gt;kavefozo&lt;/code&gt; and expect &lt;code&gt;Kávéfőző&lt;/code&gt;. They type &lt;code&gt;hedphones&lt;/code&gt; and expect headphones.&lt;/p&gt;

&lt;p&gt;The constraints were the interesting part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The schema could not change.&lt;/strong&gt; No new columns, no generated columns, no migrations on tables that weren't ours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No new infrastructure.&lt;/strong&gt; No Elasticsearch, no Meilisearch, no extra service to run, sync, back up and secure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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: &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;Fuzzphony&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fuzzphony is at &lt;strong&gt;v0.4 and still in active development&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;This post covers the design, the bug that taught me the most, and what I still haven't solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;LIKE '%…%'&lt;/code&gt; gets wrong
&lt;/h2&gt;

&lt;p&gt;Most search boxes start here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It misses typos (&lt;code&gt;hedphones&lt;/code&gt;), accents (&lt;code&gt;creme&lt;/code&gt; vs &lt;code&gt;crème&lt;/code&gt;) and word forms (&lt;code&gt;drills&lt;/code&gt; vs &lt;code&gt;drill&lt;/code&gt;). It can't exclude a word, it doesn't rank, and when it misses, it reads the whole table to return nothing.&lt;/p&gt;

&lt;p&gt;Postgres ships three tools for exactly these problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full-text search&lt;/strong&gt; (&lt;code&gt;tsvector&lt;/code&gt;, &lt;code&gt;tsquery&lt;/code&gt;, &lt;code&gt;ts_rank_cd&lt;/code&gt;): stemming for 28 languages, field weights A to D, relevance ranking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unaccent&lt;/code&gt;&lt;/strong&gt;: folds &lt;code&gt;Kávéfőző&lt;/code&gt; into &lt;code&gt;Kavefozo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_trgm&lt;/code&gt;&lt;/strong&gt;: trigram similarity, which is what lets &lt;code&gt;wireles&lt;/code&gt; match &lt;code&gt;wireless&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sidecar table
&lt;/h2&gt;

&lt;p&gt;Since your tables are off-limits, the index lives in its own table next to them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyqdk348f6nz9tvmvygs.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyqdk348f6nz9tvmvygs.PNG" alt="Where the index lives: no column is added to your tables, triggers feed a queue, a worker refreshes the sidecar table, and searches read only the sidecar" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For every index, Fuzzphony keeps a table such as &lt;code&gt;fuzzphony_products&lt;/code&gt; with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a weighted &lt;code&gt;tsvector&lt;/code&gt; for full-text search, on a GIN index,&lt;/li&gt;
&lt;li&gt;a normalised, unaccented text column for typo tolerance, on a GIN trigram index,&lt;/li&gt;
&lt;li&gt;typed filter columns on btree indexes, so &lt;code&gt;where('price', '&amp;lt;=', 20_000)&lt;/code&gt; never has to touch your table,&lt;/li&gt;
&lt;li&gt;the inputs for ranking: a boost column (popularity, say) and a recency column.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source can be a single table or any &lt;code&gt;SELECT&lt;/code&gt;, joins included. With &lt;code&gt;schema: fuzzphony&lt;/code&gt; set, the sidecar tables, the queue and the helper functions all live in their own schema, and dropping an index is one command.&lt;/p&gt;

&lt;p&gt;To be precise about "off-limits": no column is ever added, but in the default &lt;code&gt;queue&lt;/code&gt; mode and in &lt;code&gt;trigger&lt;/code&gt; 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 &lt;code&gt;orm&lt;/code&gt; and &lt;code&gt;manual&lt;/code&gt; modes need none.&lt;/p&gt;

&lt;p&gt;The cost of the design is obvious: there is now a second copy of the searchable data, and it has to stay in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it in sync without hurting writes
&lt;/h2&gt;

&lt;p&gt;There are four sync modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;queue&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;triggers enqueue ids, &lt;code&gt;fuzzphony:worker&lt;/code&gt; refreshes in batches&lt;/td&gt;
&lt;td&gt;most apps; writes stay fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;triggers refresh inside the writing transaction&lt;/td&gt;
&lt;td&gt;you need read-your-writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a Doctrine listener refreshes after &lt;code&gt;flush()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;triggers are not allowed at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;manual&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing automatic&lt;/td&gt;
&lt;td&gt;batch imports, read-only data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three details made the trigger-based modes usable on real tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statement-level triggers with transition tables.&lt;/strong&gt; A row-level trigger on an &lt;code&gt;UPDATE brand SET …&lt;/code&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;brand_sync&lt;/span&gt;
&lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;
&lt;span class="k"&gt;REFERENCING&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;STATEMENT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;queue_products_of_changed_brands&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;-- inside the function, roughly:&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;sync_queue&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brand_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Only refresh when something relevant changed.&lt;/strong&gt; An &lt;code&gt;UPDATE product SET last_viewed_at = now()&lt;/code&gt; 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 &lt;code&gt;columns: ['name']&lt;/code&gt;, because a library cannot guess which of a joined table's columns matter to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;TRUNCATE&lt;/code&gt; is not a delete.&lt;/strong&gt; It fires no &lt;code&gt;DELETE&lt;/code&gt; triggers, so early versions left stale documents in the index after a &lt;code&gt;TRUNCATE&lt;/code&gt;, and not even a full reindex removed them. I found that one late. Now every watched table also gets an &lt;code&gt;AFTER TRUNCATE&lt;/code&gt; trigger, and a full reindex prunes documents whose rows no longer exist.&lt;/p&gt;

&lt;p&gt;The worker takes a batch with &lt;code&gt;DELETE … FOR UPDATE SKIP LOCKED&lt;/code&gt; and refreshes it in the same statement, so a failed refresh rolls the dequeue back and no id is lost, and several workers can run side by side. If you'd rather not run a long-lived process, &lt;code&gt;fuzzphony:worker --once&lt;/code&gt; from cron works too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to a query
&lt;/h2&gt;

&lt;p&gt;From the application side, a search looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fuzzphony&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wireles mouse -cable'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// a typo and an exclusion&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'in_stock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;highlight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// "&amp;lt;mark&amp;gt;Wireless&amp;lt;/mark&amp;gt; mouse"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that call, the query goes through a few stages:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv2e1fcgwms67ug91e3ng.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv2e1fcgwms67ug91e3ng.png" alt="Query pipeline: parse, exact full-text match, typo tolerance per word only if there are too few hits, relaxation if still nothing, then ranking" width="800" height="623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few decisions in there are worth explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User input never throws.&lt;/strong&gt; Unbalanced quotes, stray operators and absurdly long input are repaired and reported in &lt;code&gt;$result-&amp;gt;warnings&lt;/code&gt;, and &lt;code&gt;$result-&amp;gt;interpretedAs&lt;/code&gt; shows how the query was understood. Developer mistakes are the opposite: they fail loudly, with a hint. &lt;code&gt;Index "products" has no filter "prise". Did you mean "price"?&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact first, typo tolerance as a fallback.&lt;/strong&gt; Trigram matching is slower and noisier than full-text matching, so by default it only runs when exact matching finds fewer than &lt;code&gt;fallback_below&lt;/code&gt; hits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typo tolerance is per word.&lt;/strong&gt; The first version compared the whole query string against the fuzzy fields, so one long common word could satisfy it on its own. On the demo catalogue, &lt;code&gt;wireles mice&lt;/code&gt; returned 20 000 products (chairs, drills, kettles), of which 1 666 were mice. Now every word has to match on its own, exactly or by trigram similarity, inside the query's real AND / OR / NOT structure. The same query returns exactly the 1 666 wireless mice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty result gets one more try.&lt;/strong&gt; If a query of two or more words finds nothing, each word is checked on its own, the words that match nothing are dropped, and the search runs again with a warning saying which words were ignored. &lt;code&gt;wireless mouse aluminum&lt;/code&gt; returns the wireless mice instead of an empty page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking is explainable.&lt;/strong&gt; The score is a plain formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;relevance = text  × ts_rank_cd(weights A..D)
          + fuzzy × word_similarity(query, fuzzy fields)

score     = relevance
          + exact_bonus + prefix_bonus
          + boost   × boost column
          + recency × 2^(−age / half_life)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;min_score&lt;/code&gt; applies to relevance only, so a popularity boost can reorder relevant hits but can never pull an irrelevant one into the results. Every hit carries a score breakdown, so "why is this first?" always has an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that taught me the most
&lt;/h2&gt;

&lt;p&gt;A German test query, &lt;code&gt;Tasche für Laptop&lt;/code&gt;, did not find a product called &lt;code&gt;Laptop Tasche&lt;/code&gt;. In the French catalogue, searching for &lt;code&gt;à&lt;/code&gt; alone matched 13 products. Hungarian &lt;code&gt;és&lt;/code&gt; misbehaved the same way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpe1sehoyo9mbcw3dnw37.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpe1sehoyo9mbcw3dnw37.png" alt="The accented stop word bug: unaccent turned für into fur before the stemmer, whose stop-word list only knows für" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A text search configuration sends every token through a list of dictionaries, in order. Mine ran &lt;code&gt;unaccent&lt;/code&gt; before the Snowball stemmer. The stemmer's German stop-word list contains &lt;code&gt;für&lt;/code&gt;, but by the time the token reached it, it had become &lt;code&gt;fur&lt;/code&gt;, which is not a stop word. So &lt;code&gt;für&lt;/code&gt; was indexed as an ordinary word, and every query containing it had to match it.&lt;/p&gt;

&lt;p&gt;The fix was to drop stop words before anything else touches the token, with a stop-word dictionary built from the language's own list. In simplified form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;SEARCH&lt;/span&gt; &lt;span class="k"&gt;DICTIONARY&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german_stop&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;TEMPLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;simple&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STOPWORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;german&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;SEARCH&lt;/span&gt; &lt;span class="n"&gt;CONFIGURATION&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;german&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;SEARCH&lt;/span&gt; &lt;span class="n"&gt;CONFIGURATION&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german&lt;/span&gt;
    &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;MAPPING&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;asciiword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asciihword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hword_asciipart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hword_part&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german_stop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;german_stem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;ACCEPT = false&lt;/code&gt;, the stop dictionary discards stop words and passes everything else on to the next dictionary.&lt;/p&gt;

&lt;p&gt;Two things came out of this besides the fix. &lt;code&gt;fuzzphony:doctor&lt;/code&gt; now reports a configuration that still keeps accented stop words. And the demo got a Languages page that searches small catalogues in English, German, French, Spanish and Hungarian, and shows the lexeme Postgres produced for every query word. I would have caught this bug on day one if that page had existed earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, with the caveats
&lt;/h2&gt;

&lt;p&gt;A sample run on 200 000 products, PostgreSQL 16, a small cloud VM, 20 results per query:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;ILIKE (warm)&lt;/th&gt;
&lt;th&gt;ILIKE hits&lt;/th&gt;
&lt;th&gt;Fuzzphony (warm)&lt;/th&gt;
&lt;th&gt;Fuzzphony hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wireless&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6 ms&lt;/td&gt;
&lt;td&gt;20, unranked&lt;/td&gt;
&lt;td&gt;11.1 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+, ranked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;creme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;251.6 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.4 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hedphones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;252.6 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20.7 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drills&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;257.1 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.6 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"noise cancelling" -headphones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.5 ms&lt;/td&gt;
&lt;td&gt;20, wrong&lt;/td&gt;
&lt;td&gt;23.2 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read this carefully, because a benchmark against &lt;code&gt;ILIKE&lt;/code&gt; is easy to make look good:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On plain words, &lt;code&gt;ILIKE&lt;/code&gt; is much faster. &lt;code&gt;LIMIT 20&lt;/code&gt; without an &lt;code&gt;ORDER BY&lt;/code&gt; just returns the first 20 rows the scan happens to hit. Fast, but not the best 20.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ILIKE&lt;/code&gt; baseline has no trigram index, which is why its misses are full table scans. A &lt;code&gt;gin_trgm_ops&lt;/code&gt; index would make those rows fast. It would not make them correct: &lt;code&gt;ILIKE '%hedphones%'&lt;/code&gt; finds nothing no matter how it is indexed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ILIKE&lt;/code&gt; has no concept of exclusion, so on the last row it silently ignores &lt;code&gt;-headphones&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the numbers on your own data before believing anyone's benchmark, including this one. The benchmark script is in the repository, and CI runs it on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;p&gt;Fuzzphony is not the right tool if you need hundreds of millions of documents or thousands of searches per second on one index, analytics-style aggregations, semantic or vector search (look at pgvector or a dedicated engine), or a database other than PostgreSQL.&lt;/p&gt;

&lt;p&gt;It fits best where the database is not yours to change (a legacy system, an ERP, tables another team owns), where you are replacing &lt;code&gt;LIKE&lt;/code&gt; in admin panels and back offices, or where the data has to stay in the database for compliance reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't solved yet
&lt;/h2&gt;

&lt;p&gt;Two things are genuinely open, and I'd like opinions on both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typo tolerance is too lenient on short words.&lt;/strong&gt; At the default similarity threshold, &lt;code&gt;mouse&lt;/code&gt; also matches &lt;code&gt;monitor&lt;/code&gt;: short words have few trigrams, so a single shared one weighs a lot. The plan is length-aware thresholds, probably with trigrams only generating candidates from a vocabulary table and an edit-distance check deciding. How do you tune &lt;code&gt;pg_trgm&lt;/code&gt; for short words?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking of very frequent words.&lt;/strong&gt; A GIN index can find matches but can't return them in relevance order, so for a word that matches tens of thousands of documents, Fuzzphony ranks the first &lt;code&gt;candidate_limit&lt;/code&gt; (2000 by default) that the index returns. That's not necessarily the best 2000. For most back-office searches this doesn't show, but it's the real ceiling of doing search inside Postgres, and I'd rather say so than hide it behind "approximate ordering".&lt;/p&gt;

&lt;h2&gt;
  
  
  Rough edges I'm working on before 1.0
&lt;/h2&gt;

&lt;p&gt;Writing the docs for v0.4 surfaced a few things I'd design differently, so they're on the list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger privileges.&lt;/strong&gt; The sync functions run with the privileges of whoever writes the row. In a shared database, every role that writes to a watched table therefore needs grants on Fuzzphony's queue, or its writes fail. Moving the enqueue step into a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function removes that coupling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A batch that always fails.&lt;/strong&gt; A refresh that fails deterministically (a document over Postgres' ~1 MB &lt;code&gt;tsvector&lt;/code&gt; limit, for example) is rolled back and retried forever, which blocks the queue behind it. It needs a dead-letter path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pruning as a default.&lt;/strong&gt; A full reindex removes documents whose rows it can't see. If the reindexing role sees fewer rows than the application (row-level security, a different &lt;code&gt;search_path&lt;/code&gt;), that removes real documents. It should stop and ask above a threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field scoping leaks into typo tolerance.&lt;/strong&gt; &lt;code&gt;name:sony&lt;/code&gt; can return Sony-brand products, because the fuzzy fields share one trigram column. Per-field columns are planned for 0.5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these show up in a single-app setup with one database role, which is how most people will try it. But they're exactly what bites in the environments Fuzzphony is aimed at, so they come before new features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require fuzzphony/fuzzphony
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run the demo, a full Symfony app on a seeded catalogue, with &lt;code&gt;ILIKE&lt;/code&gt; and Fuzzphony side by side, a ranking playground with sliders, and a configuration wizard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/er2es/fuzzphony
&lt;span class="nb"&gt;cd &lt;/span&gt;fuzzphony/demo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;    &lt;span class="c"&gt;# http://localhost:8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It requires PHP 8.4+ and PostgreSQL 15+, and is tested on Symfony 7.4 and 8.0 against PostgreSQL 15 to 18. The current release is v0.4 and development is ongoing: breaking changes can still happen before 1.0, and each one is listed in the CHANGELOG with upgrade steps in UPGRADE.md.&lt;/p&gt;

&lt;p&gt;I built this with a lot of help from Claude Code. The design decisions are written down as ADRs in the repository, and the heavy testing (PHPStan at max level, 100% line coverage, mutation testing) is partly because of that: when you didn't type every line yourself, tests are how you know what the code actually does.&lt;/p&gt;

&lt;p&gt;Feedback is very welcome, in the comments or as a GitHub issue, especially on the two open problems above. If the library looks useful, a star on &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps other people find it.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>symfony</category>
      <category>fuzzy</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
