<?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: Sahib Alizada</title>
    <description>The latest articles on DEV Community by Sahib Alizada (@sahib_alizada).</description>
    <link>https://dev.to/sahib_alizada</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%2F3839804%2Fda6bf685-3e44-40f8-8c94-9368b928253e.jpeg</url>
      <title>DEV Community: Sahib Alizada</title>
      <link>https://dev.to/sahib_alizada</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sahib_alizada"/>
    <language>en</language>
    <item>
      <title>Visual Search on pgvector + Gemini: Detect, Crop, Embed, Rank (and the Averaging Bug That Broke Exact Match)</title>
      <dc:creator>Sahib Alizada</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:15:40 +0000</pubDate>
      <link>https://dev.to/sahib_alizada/visual-search-on-pgvector-gemini-detect-crop-embed-rank-and-the-averaging-bug-that-broke-4acd</link>
      <guid>https://dev.to/sahib_alizada/visual-search-on-pgvector-gemini-detect-crop-embed-rank-and-the-averaging-bug-that-broke-4acd</guid>
      <description>&lt;h1&gt;
  
  
  Visual Search on pgvector + Gemini: Detect, Crop, Embed, Rank (and the Averaging Bug That Broke Exact Match)
&lt;/h1&gt;

&lt;p&gt;I run a fashion marketplace in Azerbaijan (solo founder + AI pair programming). Users upload a photo — a screenshot from Instagram, a shop window, an outfit — and get matching products from our catalog.&lt;/p&gt;

&lt;p&gt;Last week I gave it the cruelest test I could design: I uploaded a screenshot of a product's &lt;strong&gt;own catalog photo&lt;/strong&gt;. The system had this exact image in its database.&lt;/p&gt;

&lt;p&gt;It missed. Score: &lt;strong&gt;0.9259&lt;/strong&gt;. Exact-match threshold: &lt;strong&gt;0.93&lt;/strong&gt;. And a &lt;em&gt;blue&lt;/em&gt; dress (0.8165) ranked above the identical &lt;em&gt;orange&lt;/em&gt; one (0.9259).&lt;/p&gt;

&lt;p&gt;Both failures turned out to be textbook embedding-system mistakes that I suspect half the "add vector search in a weekend" tutorials out there quietly share. Here's the full pipeline — Django, PostgreSQL + pgvector, Gemini (flash-lite gate + &lt;code&gt;gemini-embedding-2&lt;/code&gt;) — with the real fixes.&lt;/p&gt;

&lt;p&gt;Live demo first, if you prefer:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/I_GNCRcfxVA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The gate: one small-model call that structures everything
&lt;/h2&gt;

&lt;p&gt;Every upload hits a Gemini Flash-Lite class model once. Not "is this a product?" — that's a waste of a multimodal call. We ask for everything ranking will need, as JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name_az"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Narıncı don"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name_ru"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Оранжевое платье"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"color_az"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Narıncı"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category_public_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cat_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"subcategory_public_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sub_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"variant_item_public_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"vri_..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"partial"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions that mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The gate reads our catalog.&lt;/strong&gt; We inject a ~3K-token digest of the live taxonomy (categories, subcategories, variant vocabulary — Redis-cached 12h) into the prompt. The model returns &lt;em&gt;our IDs&lt;/em&gt;, not free text. No fuzzy string-matching layer between detection and ranking, and item labels come back in all 4 UI languages for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;primary&lt;/code&gt; beats "largest bbox".&lt;/strong&gt; The model marks the star of the photo. A model holding a white bag → the bag is primary, not the bigger blouse behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ≥90% visibility rule.&lt;/strong&gt; A trouser leg entering the frame corner is not a searchable item. Mostly-cut-off detections get &lt;code&gt;partial: true&lt;/code&gt; — shown as a label, never cropped, never searched. This single rule killed a whole class of garbage results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fail-open everywhere: if the gate call dies, search continues without hints. Cost with the catalog digest: &lt;strong&gt;~$0.002/search&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Crop + embed — twice
&lt;/h2&gt;

&lt;p&gt;The primary bbox is cropped (5% padding, skip if it covers &amp;gt;85% of the frame — already tight). Then the asymmetry problem: &lt;strong&gt;catalog vectors come from full product photos; a cropped query is a zoomed fragment.&lt;/strong&gt; Same dress, drifted fingerprints.&lt;/p&gt;

&lt;p&gt;Cheapest fix in this article — embed both, search both, best score wins per product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;crop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;crop_item_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# None if bbox &amp;gt; 85% of frame
&lt;/span&gt;&lt;span class="n"&gt;vec_crop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crop&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;image_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;vec_full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;crop&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;knn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vec_crop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vec_full&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_max_by_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;knn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vec_full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gemini-embedding-2&lt;/code&gt; is $0.00012/image. The second embedding is insurance that costs a hundredth of a cent and rescues every catalog-screenshot query.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The averaging bug (read this section if you skim everything else)
&lt;/h2&gt;

&lt;p&gt;Original design, the one every tutorial teaches: product has N photos → embed each → &lt;strong&gt;store the mean&lt;/strong&gt; as the product vector.&lt;/p&gt;

&lt;p&gt;What the mean actually is: a point &lt;em&gt;somewhat close to every view and identical to none&lt;/em&gt;. My test dress had front + back photos. Uploading the back photo produced a query sitting exactly on "back photo" — measuring &lt;strong&gt;0.9259&lt;/strong&gt; against the stored midpoint. Threshold 0.93. An identical image, diluted below my own exact bar by my own storage scheme.&lt;/p&gt;

&lt;p&gt;And it fails &lt;em&gt;intermittently&lt;/em&gt;: 1-photo products match perfectly, 4-photo products miss constantly. Users read that as "flaky," which is worse than "broken."&lt;/p&gt;

&lt;p&gt;The fix — stop discarding per-photo vectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VisualImageEmbedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;color_combination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ColorCombination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;      &lt;span class="c1"&gt;# main | gallery | cc
&lt;/span&gt;    &lt;span class="n"&gt;image_ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;   &lt;span class="c1"&gt;# storage path = natural dedupe key
&lt;/span&gt;    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VectorField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;constraints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uniq_vie_product_image_ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every photo is a row. Query-time kNN runs over views; a product's score = &lt;strong&gt;its best-matching view&lt;/strong&gt; (max-merge, same pattern as the colorway vectors). The mean still gets derived — free, since the per-view vectors are already in hand — because category centroids and the PDP "similar products" rail &lt;em&gt;want&lt;/em&gt; a prototype. Retrieval doesn't.&lt;/p&gt;

&lt;p&gt;Re-embedding the catalog: one background job, 906 products + 301 colorways, 0 failures, ≈ &lt;strong&gt;$0.25&lt;/strong&gt; total. The row upsert checks &lt;code&gt;(product, image_ref, model_id)&lt;/code&gt; first, so re-runs reuse stored vectors instead of calling the API again.&lt;/p&gt;

&lt;p&gt;Post-fix, same cruel test: &lt;strong&gt;0.9743&lt;/strong&gt;. Exact, rank 1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; mean embeddings are for &lt;em&gt;classification prototypes&lt;/em&gt;. If a user can query with any single view of an entity, store per-view vectors and max-merge. The tidy average is where your exact matches go to die.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. pgvector notes that saved us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Plain &lt;strong&gt;Cloud SQL Postgres + pgvector&lt;/strong&gt;, HNSW indexes, same instance as the shop. No second datastore to operate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hnsw.iterative_scan&lt;/code&gt;&lt;/strong&gt; matters: with a &lt;code&gt;status='Published'&lt;/code&gt; filter, vanilla HNSW under-fills (asks for 40, returns 7). Iterative scan re-probes until the limit fills. We wrap it in a context manager that does &lt;code&gt;SET LOCAL&lt;/code&gt; inside a transaction — PgBouncer-safe.&lt;/li&gt;
&lt;li&gt;Guarded migrations: the HNSW index is created inside &lt;code&gt;RunPython&lt;/code&gt; with a &lt;code&gt;connection.vendor == "postgresql"&lt;/code&gt; check, so SQLite dev/test environments don't explode.&lt;/li&gt;
&lt;li&gt;Per-view rows tripled the row count and changed nothing about latency worth graphing — fetch &lt;code&gt;pool*3&lt;/code&gt; view-rows, merge to distinct products in Python.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Ranking: centroids, a trust gate, and one golden rule
&lt;/h2&gt;

&lt;p&gt;Cosine similarity is category-blind — it will rank a manicure set next to a face cream because both are "small object on white background." So candidates pass a reasoning stage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image centroids for classification.&lt;/strong&gt; Every category/subcategory/gender keeps a centroid = mean of its product-image embeddings (averages are &lt;em&gt;right&lt;/em&gt; here — we want prototypes). The query classifies against ~70 centroids in one pass. Leave-one-out over 583 production products: &lt;strong&gt;84.7%&lt;/strong&gt; category, &lt;strong&gt;95.9%&lt;/strong&gt; gender — ~7 points over the neighbor-vote baseline. Fun fact: our first classifier compared the image against embedded category &lt;em&gt;names&lt;/em&gt;. Image-to-text cosine was so flat (~0.67 for right AND wrong answers) it couldn't separate hoodies from lace pajamas. Compare images with images.&lt;/p&gt;

&lt;p&gt;The centroid build is deliberately boring — a weekly job, pure averaging, no training loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_centroids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_samples&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ProductEmbedding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product__status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;keys_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# ("category", cat_id),
&lt;/span&gt;            &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;DIMS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ("subcategory", sub_id),
&lt;/span&gt;            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;      &lt;span class="c1"&gt;# ("gender", g) — unisex excluded
&lt;/span&gt;                &lt;span class="n"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# sparse label → no centroid → falls back to vote
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;VisualCentroid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_or_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ref_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sample_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New category added by the content team? Next weekly run picks it up. Zero maintenance, zero drift meetings. Note the irony: the classifier is &lt;em&gt;built from averages&lt;/em&gt; — the same operation that broke retrieval. Averages are prototypes; prototypes are exactly what classification wants. Context is everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust gate:&lt;/strong&gt; the centroid (or the gate's taxonomy) only hard-filters when confident (score + top1−top2 margin) &lt;strong&gt;and&lt;/strong&gt; the strongest visual match agrees. Otherwise: plain weighted majority vote of the top candidates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The golden rule, written in blood twice:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nothing above the exact threshold may EVER be filtered out
&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dominant&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;exact_threshold&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bikini-set photo taught us this: the gate saw "bikini top" as primary, the vote locked onto dresses, and the &lt;em&gt;identical set&lt;/em&gt; — top score in the pool — got voted off the main grid into a side section. Exact-tier candidates are now exempt from the category vote, the taxonomy scope, color reordering, minority demotion — everything. A 93%+ match outranks every committee.&lt;/p&gt;

&lt;p&gt;Also in this stage: color-match-first ordering (crediting the product's own color metadata, not just colorway-photo matches — the second bug from the intro), minority-subcategory demotion to a capped tail, and honest empty results. When there's no exact match, the header names the dominant subcategory of what's &lt;em&gt;actually shown&lt;/em&gt; ("closest &lt;strong&gt;Dresses&lt;/strong&gt;") — computed from the final ranked list, not from an upstream guess that ranking may have overruled.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Observability or it didn't happen
&lt;/h2&gt;

&lt;p&gt;One structured log line per search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[VS-RANK] hint=clothing src=gate rag_sub=48(Dresses) sub_score=0.845
          margin=0.019 gender=women(0.83) mode=focused demoted=0
          | n=12 | 19/48:0.8165, 19/48:0.9259, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every incident in this article was diagnosed from these lines in minutes, not hours. Two boring-but-vital details: log the &lt;strong&gt;max&lt;/strong&gt; score as &lt;code&gt;top_score&lt;/code&gt; (we briefly logged the &lt;em&gt;post-sort first element&lt;/em&gt; and silently poisoned two weeks of threshold-calibration data), and never log dynamic-keyed dicts to a BigQuery sink — &lt;code&gt;json.dumps&lt;/code&gt; them into a single STRING column, or the auto-schema will mint a new column per key and jam the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Failure modes: the boring table that makes it production
&lt;/h2&gt;

&lt;p&gt;Three model calls, a cache and a queue sit in this request path. Every row below is implemented, not aspirational — and two of them are postmortems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gate call dies / times out&lt;/td&gt;
&lt;td&gt;Search continues, no chips/hints&lt;/td&gt;
&lt;td&gt;Fail-open — assistants are never load-bearing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog digest unavailable&lt;/td&gt;
&lt;td&gt;Gate degrades to basic questions&lt;/td&gt;
&lt;td&gt;Digest appended in try/except; structured IDs are a bonus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding API fails&lt;/td&gt;
&lt;td&gt;Clean 503, &lt;strong&gt;credit NOT charged&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;consume_credit()&lt;/code&gt; runs only after a successful embed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Junk upload (no fashion item)&lt;/td&gt;
&lt;td&gt;Polite 422, credit &lt;strong&gt;IS&lt;/strong&gt; charged&lt;/td&gt;
&lt;td&gt;Deliberate anti-abuse: free rejections make quotas decorative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing ≥ similarity floor (0.78)&lt;/td&gt;
&lt;td&gt;Honest empty state&lt;/td&gt;
&lt;td&gt;Never pad the grid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New code, old schema (rolling deploy)&lt;/td&gt;
&lt;td&gt;Invisible&lt;/td&gt;
&lt;td&gt;Per-view kNN wrapped in fallback to legacy vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis outage&lt;/td&gt;
&lt;td&gt;Searches run; rate limits fail open&lt;/td&gt;
&lt;td&gt;Availability &amp;gt; strictness for a cache tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic dict keys in analytics&lt;/td&gt;
&lt;td&gt;BigQuery sink jammed (once)&lt;/td&gt;
&lt;td&gt;Since then: &lt;code&gt;json.dumps&lt;/code&gt; into ONE string column, always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;top_score&lt;/code&gt; logged from &lt;code&gt;scores[0]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Two weeks of calibration data poisoned (once)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;max(scores)&lt;/code&gt;, and metrics name their sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any committee vs an exact match&lt;/td&gt;
&lt;td&gt;Committee loses&lt;/td&gt;
&lt;td&gt;The golden rule from §5, enforced in every filter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why not CLIP / a vector DB / Google's managed visual search?
&lt;/h2&gt;

&lt;p&gt;Asked constantly, so, honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vertex AI Search for Commerce&lt;/strong&gt;: ~$2.50/1k queries ≈ &lt;strong&gt;4-6× our all-in cost&lt;/strong&gt;, plus catalog sync, plus a ranker that can't learn our exemption rule. No.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vision Product Search&lt;/strong&gt;: maintenance mode per Google's own docs. You don't build on sunset infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated vector DB&lt;/strong&gt;: at ~10³ products, pgvector latency is indistinguishable and marginal ops is zero. One database, operated honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted CLIP-family&lt;/strong&gt;: GPU serving + a second embedding space divorced from our text search and chatbot (which share this one). The single shared space is what makes "this bag, but in brown" a future merge instead of a migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Buy undifferentiated capability; build what encodes your catalog's truth (the gate prompt, the centroids, the exemption rules).&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The human loop
&lt;/h2&gt;

&lt;p&gt;Every visual search lands in a moderator queue: confirm/correct the AI's category, subcategory, gender and variants (against the real variant vocabulary), and tick which shown products were actually relevant. Reviewed rows are exempt from the 90-day purge — each one is a permanent human-verified training pair. Threshold calibration reads this pool today; centroid enrichment and fine-tuning read it next. The moderation UI is a labeling shop wearing a moderation costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;906 products + 301 colorways re-embedded per-view for &lt;strong&gt;≈$0.25&lt;/strong&gt;, zero failures&lt;/li&gt;
&lt;li&gt;~70 centroids (17 cat / 50 subcat / 3 gender), rebuilt weekly by a scheduled job&lt;/li&gt;
&lt;li&gt;Cruel test before → after: &lt;strong&gt;0.9259 → 0.9743&lt;/strong&gt; (threshold 0.93)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~$0.002/search&lt;/strong&gt; (gate + 2 embeddings); vector search marginal cost ≈ $0&lt;/li&gt;
&lt;li&gt;≤4 detected pieces per photo, ≥90% visibility to be searchable, 15-min session, piece-searches free&lt;/li&gt;
&lt;li&gt;~40 admin knobs, hot-reloaded, zero deploys — and every incident retired at least one knob&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Per-view vectors + max-merge for retrieval; averages only as prototypes.&lt;/li&gt;
&lt;li&gt;Embed noisy queries twice (full + detected object). $0.0001 insurance.&lt;/li&gt;
&lt;li&gt;Classify images against image centroids, never label text.&lt;/li&gt;
&lt;li&gt;Exact-threshold candidates are exempt from every filter. Write the rule before the incident.&lt;/li&gt;
&lt;li&gt;Fail open on assistant calls, fail honest on results.&lt;/li&gt;
&lt;li&gt;Log ranking &lt;em&gt;decisions&lt;/em&gt; (scope, scores, demotions), not just outcomes — and log &lt;code&gt;max(scores)&lt;/code&gt;, not &lt;code&gt;scores[0]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Ship the human-review queue with the feature, not after it. It's your training-data factory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Questions about any layer — the gate prompt shape, iterative_scan, the centroid build — happy to go deeper in comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Search-engine prequel (4 languages, 2 alphabets, CIEDE2000 color science): see the canonical Medium article. Try the live feature at &lt;a href="https://geyin.az" rel="noopener noreferrer"&gt;geyin.az&lt;/a&gt; — the camera button in the search bar.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>postgres</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Building a Search Engine for 4 Languages, 2 Alphabets, and 107 Messy Color Names (Elasticsearch + pgvector + CIEDE2000)</title>
      <dc:creator>Sahib Alizada</dc:creator>
      <pubDate>Mon, 03 Aug 2026 19:30:25 +0000</pubDate>
      <link>https://dev.to/sahib_alizada/building-a-search-engine-for-4-languages-2-alphabets-and-107-messy-color-names-elasticsearch--1igb</link>
      <guid>https://dev.to/sahib_alizada/building-a-search-engine-for-4-languages-2-alphabets-and-107-messy-color-names-elasticsearch--1igb</guid>
      <description>&lt;p&gt;Real production queries from my fashion marketplace in Azerbaijan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;гара йупка      # "black skirt" — Azerbaijani words, Cyrillic script
pembe yubka     # "pink skirt"  — half Turkish, half Russian
roziviy platya  # "pink dress"  — Russian translit, with a typo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My catalog is written in Azerbaijani (&lt;em&gt;qara ətək&lt;/em&gt;, &lt;em&gt;çəhrayı don&lt;/em&gt;). Vanilla Elasticsearch returns nothing — or confidently wrong results — for all three. Here's the full architecture that fixes it, with the actual techniques and the failures along the way. Stack: Django, Elasticsearch 8, PostgreSQL + pgvector, Gemini embeddings. Team: one founder + AI pair programming.&lt;/p&gt;

&lt;p&gt;Prefer watching it in action first? 90-second demo of the live engine:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/d4rk_ULkYOs"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Script folding + synonyms (the floor)
&lt;/h2&gt;

&lt;p&gt;Everything starts with character folding and transliteration at analysis time: &lt;code&gt;ə→e&lt;/code&gt;, &lt;code&gt;ş→s&lt;/code&gt;, Cyrillic→Latin (&lt;code&gt;гара→qara&lt;/code&gt;). On top: 103 curated synonym groups mapping &lt;strong&gt;street language&lt;/strong&gt;, not dictionary language — one slippers group reads &lt;code&gt;tərlik, terlik, тапочки, шлёпки, slippers, səndəl&lt;/code&gt; (five languages/registers → one catalog concept), and the suede group carries the slang spellings users actually type (&lt;code&gt;zamuj, zamıj, zamsha → zamşa&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Bonus mechanism: the synonym keys themselves are fuzzy-matched (threshold 0.78, tunable). "yupka" isn't in any group — "yubka" is; the bridge connects the misspelling to the key, the key expands to the catalog term: &lt;code&gt;yupka → yubka → ətək&lt;/code&gt;. Two hops, zero user friction. Raise the threshold to 0.82 and the bridge silently dies — measured knob, not a constant.&lt;/p&gt;

&lt;p&gt;One analyzer discipline that saves you months of "why is this matching": &lt;strong&gt;edge_ngram at index time only.&lt;/strong&gt; Index-time &lt;code&gt;custom_analyzer&lt;/code&gt; builds front-prefixes (autocomplete recall); query-time uses a separate &lt;code&gt;search_analyzer&lt;/code&gt; with NO ngram — otherwise every 2-letter fragment of the query becomes a prefix matcher and your results fill with garbage. We learned this from a prod bug where the fragment "el" matched gloves, dresses, and a shop description simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Query → structured intent
&lt;/h2&gt;

&lt;p&gt;Every query passes a token classifier that tests each token against the live taxonomy &lt;strong&gt;in all four languages at once&lt;/strong&gt;, priority-ordered: gender → category → subcategory → color/size → price.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="s2"&gt;"pembe yubka"&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;color_family:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cehrayi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;category:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Skirts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;text:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multi-word subcategories get a sliding-window phrase matcher with token-coverage scoring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name_tokens&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# stopwords stripped
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;nt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;name_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;coverage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;coverage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "ətək dəsti" (skirt set) matches the subcategory "Ətək və Üst Geyim Dəsti" as a &lt;em&gt;phrase&lt;/em&gt; — a single ambiguous token can never hijack the filter.&lt;/p&gt;

&lt;p&gt;Naive matching also needs guards: without them, "ayaqqabısı" (a long word for shoes) fuzzy-matches the size "S". Length-ratio rules and a size-keyword list protect real size queries while blocking accidental ones. Price hints parse too — "50 manatadək" becomes &lt;code&gt;{price_max: 50}&lt;/code&gt;, "ucuz" becomes a cheap-range filter.&lt;/p&gt;

&lt;p&gt;End-to-end, one mixed-language line — &lt;code&gt;qadın pembe yubka 50 manatadək&lt;/code&gt; (AZ + TR + RU-translit + AZ price) — parses to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;gender:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"women"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;AZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;column&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;match&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;color_families:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pink"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;TR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;alias&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;category:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Skirts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;RU&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;word&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;synonym&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pool&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ətək&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;price_max:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;parsed,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;matched&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero tokens left as free text. Swap &lt;code&gt;pembe&lt;/code&gt; for English &lt;code&gt;pink&lt;/code&gt; — identical parse; aliases carry all four languages, so one sentence can mix them freely.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Color science: 107 vendor strings → 15 families
&lt;/h2&gt;

&lt;p&gt;Vendors wrote 107 distinct color values, including pipes ("Qırmızı | Açıq çəhrayı") and literal CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;circle&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;#000000&lt;/span&gt; &lt;span class="err"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nf"&gt;#C69258&lt;/span&gt; &lt;span class="err"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%)&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="nt"&gt;leopard&lt;/span&gt; &lt;span class="nt"&gt;print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is not more string matching. It's color science:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;HEX_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# finds ALL hexes,
&lt;/span&gt;                                                             &lt;span class="c1"&gt;# even inside gradients
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hex_to_lab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hex_code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# sRGB → linear → XYZ(D65) → Lab
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delta_e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lab2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;         &lt;span class="c1"&gt;# full CIEDE2000 (Sharma formulation),
&lt;/span&gt;    &lt;span class="p"&gt;...&lt;/span&gt;                          &lt;span class="c1"&gt;# ~80 lines, zero dependencies
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every family has anchor hexes; every vendor color maps to its nearest anchor by CIEDE2000. We validated against the official Sharma test vectors — 9/9 to four decimals, including the notorious &lt;code&gt;1.5381&lt;/code&gt; pair. Upgrading from naive CIE76 (Euclidean in Lab) to CIEDE2000 re-classified nine boundary colors correctly: mustard→yellow (was: gold), ivory→beige (was: white), navy stopped bleeding into purple.&lt;/p&gt;

&lt;p&gt;And 15 is today's taxonomy, not a ceiling. Families live in an admin table — adding a 16th is a database row, not a deploy. More importantly, the classifier has &lt;strong&gt;no color limit at all&lt;/strong&gt;: whatever new shade a vendor invents tomorrow, its hex lands in Lab space and maps to the nearest family automatically. As sellers grow, the color vocabulary grows without permission — and the organization absorbs it.&lt;/p&gt;

&lt;p&gt;Rules on top of the math:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pipe/dual colors → &lt;strong&gt;both&lt;/strong&gt; families (a red|pink set must appear in pink searches)&lt;/li&gt;
&lt;li&gt;≥4 hex-derived families → collapse to "multicolor"&lt;/li&gt;
&lt;li&gt;Pattern aliases (leopard, zebra) → multicolor, hex families skipped&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Products with no color variant at all? One-shot backfill: product photo → Gemini Flash-Lite → "dominant product colors as hex, ignore background and skin" → CIEDE2000 → family. &lt;strong&gt;76 products classified for under $0.01 total.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One war story: the first version set &lt;code&gt;max_output_tokens=256&lt;/code&gt; on the Gemini call. An AI review agent flagged it before deploy: thinking tokens share that budget, so the model could burn the whole ceiling &lt;em&gt;thinking&lt;/em&gt; and return empty text — the entire backfill would have silently produced nothing. Ceiling raised to 2048, truncation logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Filters that degrade instead of failing
&lt;/h2&gt;

&lt;p&gt;Hard filters are how you serve empty pages. Every filtered search descends a ladder; the first rung with hits wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;full&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nf"&gt;search_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_color&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nf"&gt;search_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;partial&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nf"&gt;search_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_plus_category_only&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text_only&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;search_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A wrong constraint costs ranking quality, never a blank screen.&lt;/p&gt;

&lt;p&gt;Second forgiveness mechanism: &lt;strong&gt;the taxonomy bridge.&lt;/strong&gt; "yubka" detects the &lt;em&gt;Skirts&lt;/em&gt; category — but skirt &lt;em&gt;sets&lt;/em&gt; live in another category whose subcategory is literally named "Skirt &amp;amp; Top Set." A hard &lt;code&gt;category.id&lt;/code&gt; filter silently hid them. Now the filter is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bool&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;should&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;term&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;category.id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;detected_id&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nc"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;match&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;subcategory.title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;# concept bridge
&lt;/span&gt;        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;specific_tokens_only&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# generic tokens stripped
&lt;/span&gt;        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;analyzer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;search_analyzer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}}),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;minimum_should_match&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generic tokens (&lt;em&gt;clothing, set, top…&lt;/em&gt;) are excluded so the bridge can't over-widen — if only generic tokens remain, no bridge is built. (Credit: an AI code-review agent caught the over-widening risk before it shipped.)&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Semantic layer: one embedding space, two doors
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Text &lt;strong&gt;and&lt;/strong&gt; image embeddings live in a single shared 1536-dim space (Gemini Embedding 2, multimodal), stored in &lt;strong&gt;pgvector&lt;/strong&gt; — durable across index rebuilds.&lt;/li&gt;
&lt;li&gt;When lexical results are weak (below a threshold), we run text-kNN and image-kNN and fuse with &lt;strong&gt;Reciprocal Rank Fusion&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lst&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The same space powers visual search (photo → products). One space, two doors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The taxonomy is embedded too.&lt;/strong&gt; Every category/subcategory/gender has a centroid — the mean vector of its products — in pgvector. Visual search classifies the query photo against centroids before ranking:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VisualCentroid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding__isnull&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;annotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;CosineDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ref_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;distance&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# → "this photo is a women's earring" → type-constrain results
# (an earring photo can never return trousers on color similarity alone)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Crucially, semantic &lt;strong&gt;only fires on weak lexical results&lt;/strong&gt;. When exact match works, don't dilute it with vibes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two production footnotes for the vector crowd:&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;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pgvector's HNSW index &lt;strong&gt;silently under-returns&lt;/strong&gt; when you post-filter a kNN query (e.g. &lt;code&gt;status='Published'&lt;/code&gt;) — the index yields K candidates, the filter eats most of them, and you get 3 results where 40 exist. Every filtered kNN of ours runs inside that session setting.&lt;/p&gt;

&lt;p&gt;And freshness: every product save dispatches a Cloud Task that re-syncs its ES document within seconds — no nightly rebuild, no Celery workers, just HTTP-triggered tasks on Cloud Run. A vendor lists a dress; it's searchable before they close the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. One brain, three doors: the chatbot runs on the same engine
&lt;/h2&gt;

&lt;p&gt;Our AI assistant has &lt;strong&gt;no product knowledge of its own&lt;/strong&gt;. It calls the search engine through 7 authenticated tool endpoints (API-key via constant-time compare, plus Google-signed JWT for service accounts):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;search_products            # same intent parser, same filters
search_shops               # same product-evidence ranking
search_faq / search_blog   # RAG corpus: FAQ + docs + blog chunks,
                           # same embedding space, same RRF fusion
search_products_by_vibe    # semantic-only door
find_similar_products      # embedding neighbors
get_catalog_info           # taxonomy/variants/brands (cached)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visual search is the third door — same embeddings, same centroids, same ranking rules. Fix a synonym once; the search bar, the camera, and the chatbot all improve in the same second.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The data flywheel: why BigQuery is the whole point
&lt;/h2&gt;

&lt;p&gt;Every meaningful interaction emits a structured JSON event — &lt;strong&gt;18 event types&lt;/strong&gt;, 15 of them routed by the Cloud Logging sink into BigQuery (60K+ events in the last 30 days). The interesting ones aren't pageviews:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;log_smart_upload_save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ai_suggestions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;      &lt;span class="c1"&gt;# what the AI proposed
&lt;/span&gt;    &lt;span class="n"&gt;vendor_choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;      &lt;span class="c1"&gt;# what the human decided
&lt;/span&gt;    &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;             &lt;span class="c1"&gt;# field-level diff: accepted/modified/rejected
&lt;/span&gt;    &lt;span class="n"&gt;acceptance_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;     &lt;span class="c1"&gt;# one number per upload
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;strong&gt;labeled training pair manufactured by normal platform usage.&lt;/strong&gt; Same pattern everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SearchHistory&lt;/code&gt; stores query → clicked product (text AND visual; visual queries keep their 1536-dim embedding) → query-relevance pairs&lt;/li&gt;
&lt;li&gt;Moderator rejections carry categories and feed a &lt;strong&gt;negative-example index&lt;/strong&gt;; new uploads get a kNN pre-check against it &lt;em&gt;before&lt;/em&gt; any Gemini call — a lookalike of a rejected image is flagged in milliseconds and skips the API cost entirely&lt;/li&gt;
&lt;li&gt;A moderator review queue grades visual search results and corrects categories → hand-labeled ground truth&lt;/li&gt;
&lt;li&gt;Zero-result queries land in a dedicated BigQuery view → weekly synonym curation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The staircase: &lt;strong&gt;(1)&lt;/strong&gt; dashboards and mining today → &lt;strong&gt;(2)&lt;/strong&gt; behavior-driven ranking (CTR adjusts weights, semantic blend ratio set by measurement) → &lt;strong&gt;(3)&lt;/strong&gt; fine-tuning our own retrieval/classification models on suggestion-vs-decision pairs and click-relevance pairs. Nobody else has this dataset, because nobody else's users type "гара йупка".&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The control plane
&lt;/h2&gt;

&lt;p&gt;Every AI behavior is admin-tunable with zero deploys — &lt;strong&gt;47 knobs&lt;/strong&gt; across two singleton config models (semantic thresholds, RRF k, color-filter toggle, visual similarity tiers, gate model, rate limits, session TTLs). Config resolution: DB override → settings → defaults, cache-invalidated on admin save.&lt;/p&gt;

&lt;p&gt;Thresholds aren't vibes. A zero-API-cost eval command runs leave-one-out over every product embedding, compares three classification methods (neighbor-vote / centroid / text-RAG argmax), and prints the threshold grid that reaches 95% precision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score ∈ {0.60..0.85} × margin ∈ {0..0.05} → precision/coverage table
→ recommended gate = first cell with precision ≥ 0.95
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admin knob gets set to what the benchmark says.&lt;/p&gt;

&lt;p&gt;Cost is part of the control plane too: the Gemini catalog context lives in a server-side context cache (~94% of prompt tokens per AI-upload call served from cache), vision inputs are resized before sending, and the rejected-image kNN pre-check skips Gemini calls entirely — each skipped call is logged with its saved cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production debugging, not guessing
&lt;/h2&gt;

&lt;p&gt;When "pembe" (pink) started returning beige and denim skirts in production, we didn't tweak boosts blindly. We traced the parsed intent on the live system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FILTERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;variants&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Açıq Mavi | Krem | Çəhrayı&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;matched_field&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title_tr&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...}],&lt;/span&gt;
           &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;color_families&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cehrayi&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bej&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mavi&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is: "pembe" exact-matched a &lt;em&gt;pipe item's&lt;/em&gt; Turkish translation and inherited &lt;strong&gt;all three&lt;/strong&gt; of its families, diluting the filter. Fix: word-aliases take precedence over item inheritance — pink means pink. One trace, one line changed, covered by two new tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;4 languages, 2 alphabets, ~100 synonym groups&lt;/li&gt;
&lt;li&gt;15 color families; 116/116 colors auto-classified; 76 products colored from photos for &amp;lt;$0.01&lt;/li&gt;
&lt;li&gt;CIEDE2000: 9/9 official vectors&lt;/li&gt;
&lt;li&gt;18 analytics event types → BigQuery; 7 AI tool endpoints; 47 zero-deploy admin knobs&lt;/li&gt;
&lt;li&gt;366 automated tests on the search stack&lt;/li&gt;
&lt;li&gt;Built and reviewed by 1 founder + AI agents (code review, ES review, migration safety, perf)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this goes
&lt;/h2&gt;

&lt;p&gt;The layered architecture is deliberately a template: the Turkic-language world (Turkey, Central Asia) shares the same script-mixing and transliteration chaos as our home market — each new language is a new folding table + synonym pack on the same brain. Next signals on the roadmap: user behavior (already streaming), then body measurements and personal style (opt-in, privacy-first) as ranking inputs. Target state: the search bar behaves like a personal stylist that happens to accept text, photos, and chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Multilingual search dies at the alphabet layer first. Fold scripts before you tune ranking.&lt;/li&gt;
&lt;li&gt;A color word is a &lt;strong&gt;filter&lt;/strong&gt;, not a keyword. Parse intent.&lt;/li&gt;
&lt;li&gt;Lab + CIEDE2000 is 80 dependency-free lines that outperform any hand-maintained color mapping.&lt;/li&gt;
&lt;li&gt;Build a relaxation ladder. Wrong constraints should cost ranking, never results.&lt;/li&gt;
&lt;li&gt;Zero-result logs are free product management.&lt;/li&gt;
&lt;li&gt;One brain, many doors: search bar, camera, and chatbot should share the same intent parser, embeddings, and ranking.&lt;/li&gt;
&lt;li&gt;Capture every AI-suggestion-vs-human-decision pair from day one. That's your fine-tuning dataset compounding while you sleep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Questions about any layer — analyzers, the phrase matcher, the CIEDE2000 port, RRF tuning — ask below. I'll share details.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm building geyin.az (Azerbaijan's first AI-powered fashion marketplace) solo, in public, with AI. Previous write-up: how we built a zero-hallucination AI content pipeline.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>elasticsearch</category>
      <category>python</category>
      <category>ai</category>
      <category>django</category>
    </item>
    <item>
      <title>How We Built a Zero-Hallucination AI Content Army for Fashion E-Commerce</title>
      <dc:creator>Sahib Alizada</dc:creator>
      <pubDate>Mon, 23 Mar 2026 10:25:55 +0000</pubDate>
      <link>https://dev.to/sahib_alizada/how-we-built-a-zero-hallucination-ai-content-army-for-fashion-e-commerce-opo</link>
      <guid>https://dev.to/sahib_alizada/how-we-built-a-zero-hallucination-ai-content-army-for-fashion-e-commerce-opo</guid>
      <description>&lt;p&gt;&lt;strong&gt;Geyin.az AI Agent Command Center — 11 agents monitoring dashboard with zero hallucination pipeline&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;By Sahib Alizada — Founder, Geyin.az&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;In my first article, I shared how I built Azerbaijan’s first AI-powered fashion marketplace as a solo founder. Today, I want to go deeper into the hardest problem we solved: making AI-generated content trustworthy.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI industry has a $67.4 billion hallucination problem. According to the Suprmind Research Report 2026, 47% of enterprise AI users made at least one major business decision based on hallucinated content in 2024. Each enterprise employee costs $14,200 per year in hallucination-related mitigation alone.&lt;/p&gt;

&lt;p&gt;At Geyin.az, we decided that “good enough” wasn’t good enough. When you’re publishing fashion content in four languages to hundreds of thousands of potential readers, every fabricated trend, every made-up brand detail, every fictional price point erodes trust — and trust is the only currency that matters in e-commerce.&lt;/p&gt;

&lt;p&gt;This is the story of how we built an AI content army that doesn’t hallucinate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Scale of the Hallucination Problem&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Let’s be honest about where the industry stands. Even the best models still hallucinate:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Gemini 2.0 Flash holds the current record at 0.7% hallucination rate on Vectara’s benchmark — the lowest ever recorded.&lt;/p&gt;

&lt;p&gt;GPT-5 reduced hallucinations by 44% compared to GPT-4o, dropping from 12.9% to 9.6% on factual tasks (PMC peer-reviewed study).&lt;/p&gt;

&lt;p&gt;Claude Sonnet 4.6 achieved a 91% success rate in detecting false information, with only a 3% rate of confidently accepting falsehoods (AnyAPI LLM Hallucination Index 2026).&lt;/p&gt;

&lt;p&gt;OpenAI’s own research (September 2025) concluded that next-token training fundamentally rewards confident guessing over calibrated uncertainty (arXiv:2509.04664).&lt;/p&gt;

&lt;p&gt;That last point is critical. The very architecture that makes language models powerful — predicting the next most likely token — is also what makes them hallucinate. They’re designed to sound confident, even when they shouldn’t be.&lt;/p&gt;

&lt;p&gt;And here’s the uncomfortable truth: domain-specific hallucination rates are 10–20% or higher, even when standardized benchmarks show 1–3%. Fashion is a domain where product names, seasonal trends, brand histories, and pricing are hyper-specific. Generic AI benchmarks mean nothing here.&lt;/p&gt;

&lt;p&gt;Press enter or click to view image in full size&lt;br&gt;
AI hallucination rate comparison 2025–2026 — Gemini 0.7% to GPT-4o 12.9%&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our Approach: Architecture Over Prompting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most teams try to solve hallucination by tweaking prompts. We solved it by designing an architecture where hallucination literally cannot survive the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11 Agents, Each With One Job&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our AI army consists of 11 specialized agents running 24/7 on a dedicated server. Each agent has a narrowly defined role and data access pattern:&lt;/p&gt;

&lt;p&gt;Blog Writer — Writes articles in English only. Receives verified research data as input — never generates facts from memory.&lt;/p&gt;

&lt;p&gt;GEO/AEO Enricher — Adds FAQ sections, comparison tables, and citations. Only works with the Writer’s output plus verified external sources.&lt;/p&gt;

&lt;p&gt;Translator — Converts EN → AZ/TR/RU in one pass. Translates existing content — no creative generation allowed.&lt;/p&gt;

&lt;p&gt;Brand Tracker — Monitors 34 fashion brands. Scrapes official websites and Wikipedia — no speculation.&lt;/p&gt;

&lt;p&gt;Trend Tracker — Analyzes fashion trends from 50+ RSS feeds daily — reports only what’s published.&lt;/p&gt;

&lt;p&gt;Competitor Monitor — Tracks 10 competitors. Scrapes real pricing and catalog data.&lt;/p&gt;

&lt;p&gt;SEO Monitor — Runs full-site SEO audits every morning. Crawls actual pages — reports measured metrics.&lt;/p&gt;

&lt;p&gt;Chef Agent — Quality control commander. Scores all output on 5 criteria — rejects anything scoring below 7/10.&lt;/p&gt;

&lt;p&gt;Health Monitor — Checks platform uptime every 30 minutes. Pings real endpoints — binary pass/fail.&lt;/p&gt;

&lt;p&gt;Telegram Bot — Customer-facing AI chat using the Chef Agent’s personality with grounded context.&lt;/p&gt;

&lt;p&gt;Topics Researcher — Generates new content topics by cross-referencing trend and competitor data — only suggests verified gaps.&lt;/p&gt;

&lt;p&gt;The key insight: no single agent both generates and publishes content. Every piece goes through at least three agents before it reaches our platform.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.tourl"&gt;Geyin.az&lt;/a&gt; AI agent service running on dedicated server — 11 tasks, 45.9MB memory, active since March 23&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 14 Anti-Hallucination Rules&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every content-generating agent in our system follows 14 hard-coded rules. Here are the most important ones:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Never state a fact without a data source. If the agent can’t cite where information came from (RSS feed, scraped website, brand report), it must not include it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Web scraping is mandatory, not optional. Before writing about any brand or trend, the agent scrapes the official source. “I think Zara’s new collection features…” is forbidden. “According to zara.com, accessed March 23, 2026…” is required.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Numbers must be traceable. Every statistic in our content links back to a specific data source — a competitor’s catalog count, a trend report’s color frequency, a brand’s official pricing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When uncertain, omit. Our agents are explicitly instructed: if you’re not 100% sure, leave it out. We’d rather publish a shorter, accurate article than a longer one with fabricated details.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cross-agent verification. The Enricher cross-references the Writer’s claims against its own data sources. The Chef Agent independently validates factual claims before scoring.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why RAG Alone Isn’t Enough (And What We Do Instead)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) is the industry’s favorite answer to hallucination. And it works — RAG reduces hallucination rates by 71% when properly integrated (Suprmind, 2026). In clinical settings, self-reflective RAG lowered hallucinations to 5.8% (MDPI Electronics, peer-reviewed). LinkedIn integrated RAG with knowledge graphs and saw a 77.6% improvement in retrieval accuracy (arXiv survey).&lt;/p&gt;

&lt;p&gt;But RAG has a fundamental limitation: it retrieves and summarizes. Our agents don’t just retrieve — they verify, cross-reference, and reject.&lt;/p&gt;

&lt;p&gt;Our pipeline is closer to what I call the “Verified Intelligence Pipeline” (VIP):&lt;/p&gt;

&lt;p&gt;Step 1: RESEARCH — Agent scrapes primary sources (official sites, Wikipedia, RSS)&lt;/p&gt;

&lt;p&gt;Step 2: WRITE — Blog Writer uses ONLY the research data (never its own “knowledge”)&lt;/p&gt;

&lt;p&gt;Step 3: ENRICH — GEO/AEO Enricher adds FAQ, tables, Schema.org — from verified data&lt;/p&gt;

&lt;p&gt;Step 4: TRANSLATE — Translator converts EN → AZ/TR/RU (no creative liberty)&lt;/p&gt;

&lt;p&gt;Step 5: REVIEW — Chef Agent scores on 5 criteria (SEO, quality, fashion relevance, multilingual, GEO/AEO)&lt;/p&gt;

&lt;p&gt;Step 6: GATE — Score ≥ 7 = approved | Score &amp;lt; 7 = flagged | Score &amp;lt; 5 = rewritten&lt;/p&gt;

&lt;p&gt;Step 7: PUBLISH — Only approved content reaches the platform&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.amazonaws.com%2Fuploads%2Farticles%2F4vd9dtuhikeyb3dwj0ge.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.amazonaws.com%2Fuploads%2Farticles%2F4vd9dtuhikeyb3dwj0ge.png" alt=" " width="738" height="828"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The critical difference from RAG: our system has a rejection mechanism. RAG retrieves and generates. Our pipeline retrieves, generates, verifies, and can reject. That rejection layer is what makes zero-hallucination possible.&lt;/p&gt;

&lt;p&gt;Press enter or click to view image in full size&lt;br&gt;
Verified Intelligence Pipeline — 7-stage content verification with quality gate and rejection mechanism&lt;br&gt;
Standing on Giants: Google and Anthropic&lt;br&gt;
We didn’t build this alone. Our stack leverages two of the most advanced AI platforms in the world:&lt;/p&gt;

&lt;p&gt;Google Gemini 2.5 Flash — Powers our Smart Upload (product recognition) and content generation. Gemini 2.0 Flash currently holds the world’s lowest hallucination rate at 0.7%. The improvement trajectory is remarkable: from 21.8% in 2021 to 0.7% in 2025 — a 96% reduction in four years (SparkCo analysis). We use Gemini’s grounding features to connect our agents to real-time web content.&lt;/p&gt;

&lt;p&gt;Anthropic Claude — Powers our development workflow and strategic planning. Claude’s approach to “epistemic humility” — the tendency to say “I don’t know” rather than fabricate — is unique in the industry. Anthropic is the only vendor showing a consistent upward trajectory in this behavior (Balbix analysis). Their 2025 interpretability research identified the actual internal circuits responsible for declining answers when the model lacks information — a breakthrough in understanding why hallucinations happen at the architectural level.&lt;/p&gt;

&lt;p&gt;Google Vertex AI — Our multimodal embedding engine uses Vertex AI’s 1408-dimensional vector space for visual search, understanding both images and text in the same mathematical space. When a customer uploads a photo of a dress, our system finds visually similar products — no hallucination possible, just pure mathematical similarity.&lt;/p&gt;

&lt;p&gt;We chose these platforms deliberately. In an industry where 76% of enterprises now run human-in-the-loop processes specifically to catch AI hallucinations (Suprmind, 2026), we wanted to minimize the need for human intervention by starting with the most reliable foundations.&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.amazonaws.com%2Fuploads%2Farticles%2Fm3bhiyvqf9zb73w77pyg.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.amazonaws.com%2Fuploads%2Farticles%2Fm3bhiyvqf9zb73w77pyg.png" alt=" " width="800" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press enter or click to view image in full size&lt;br&gt;
Real-time agent logs showing Health Monitor, Topic Researcher, and Blog Writer activity&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What’s Next: Our Own ML Models&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We’re not stopping at using other companies’ models. Here’s what’s coming:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom Model Fine-Tuning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We’re preparing to fine-tune our own models specifically for the fashion domain. Why? Because general-purpose models have general-purpose hallucination patterns. A model fine-tuned on verified fashion data — our own curated dataset of brand information, trend histories, material properties, and pricing patterns — will have dramatically lower hallucination rates in our specific domain.&lt;/p&gt;

&lt;p&gt;Our data advantage: every piece of content our agents produce is verified before it enters our dataset. This means our fine-tuning data is clean by design, not cleaned after the fact. Most companies start with dirty web-scraped data and spend millions cleaning it. We start with verified, structured, multilingual fashion intelligence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini Embedding 2.0 Migration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We’re upgrading from 1,408-dimensional to 3,072-dimensional embeddings with Google’s latest Gemini Embedding 2 model. This means:&lt;/p&gt;

&lt;p&gt;5 modalities (text, image, video, audio, PDF) instead of 3. 100+ language support (critical for our 4-language platform). Task-specific optimization for fashion product similarity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BigQuery Intelligence Engine&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All our agent data flows into Google BigQuery. The next step: building a feedback loop where our ML models learn from what content performs best — which articles get the most engagement, which product descriptions convert, which trend predictions prove accurate. This is the Shopify model applied to fashion content intelligence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Numbers That Matter&lt;/strong&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F9s7wqjshcfmjcurmvvfw.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.amazonaws.com%2Fuploads%2Farticles%2F9s7wqjshcfmjcurmvvfw.png" alt=" " width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press enter or click to view image in full size&lt;br&gt;
Geyin.az AI Army metrics — 11 agents, $55 monthly cost, 4 languages, 20K+ daily API calls, zero fabricated facts&lt;br&gt;
Our entire AI agent army:&lt;/p&gt;

&lt;p&gt;11 agents running 24/7 on a single server. $55/month total infrastructure cost. Produces what would require 5–6 content specialists. 4 languages (Azerbaijani, Turkish, Russian, English). 20,000+ API requests/day capacity across 7 AI providers. Zero fabricated facts in published content (enforced by pipeline).&lt;/p&gt;

&lt;p&gt;For comparison, industry data shows:&lt;/p&gt;

&lt;p&gt;85.1% of AI users deploy it for blog content generation (AutoFaceless, 2026). But 92% of organizations will increase GenAI investment while only 1% say deployment reached maturity (Business of Fashion, State of Fashion 2026). The AI agent market is projected to grow from $7.84 billion (2025) to $52.62 billion by 2030 — a 46.3% CAGR (MEV analysis). Gartner predicts 40% of enterprise apps will feature task-specific AI agents by end of 2026, up from less than 5% in 2025.&lt;/p&gt;

&lt;p&gt;We’re already there. Not planning to adopt AI agents — operating them in production, at scale, with verified output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Future Belongs to AI-Native Platforms&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here’s my thesis: the fashion platforms that win in 2027 and beyond won’t be the ones with the biggest catalogs or the most VC funding. They’ll be the ones with the most trustworthy AI systems.&lt;/p&gt;

&lt;p&gt;Traditional search engine volume will drop 25% by 2026 due to AI chatbots and agents (Gartner via SearchEngineLand). Shopping-related generative AI searches grew 4,700% between July 2024 and July 2025 (Business of Fashion). AI-driven revenue per visit on US retail sites grew 84% in the same period.&lt;/p&gt;

&lt;p&gt;The implication is clear: your content needs to be citable by AI. Not just indexable by Google — citable by ChatGPT, Perplexity, Google AI Overviews, and Claude. That requires structured data, verified facts, authoritative sources, and expert authorship signals.&lt;/p&gt;

&lt;p&gt;This is exactly what our AI army produces — every article, every day, in four languages, with zero hallucination.&lt;/p&gt;

&lt;p&gt;The AI in fashion market is worth $2.92 billion in 2025 and projected to reach $3.99 billion in 2026 — a 40.8% growth rate (Business Research Insights). We’re building at the intersection of two explosive trends: AI agents and fashion e-commerce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For Builders: What I Learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you’re building AI systems that generate content, here’s what I’d tell you:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Architecture beats prompting. You can’t prompt your way out of hallucination. You need a pipeline with verification and rejection stages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Separate generation from publishing. No agent should both create and deploy content. The gap between those two actions is where quality control lives.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use real data, not AI “knowledge.” Your agents should never rely on their training data for facts. Feed them verified sources — scrape it, cite it, or don’t include it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Build on the best foundations. Google and Anthropic are spending billions on reducing hallucination at the model level. Leverage their work — then add your own verification layer on top.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clean data in, clean data out. If you’re planning to fine-tune models, start collecting verified data now. Your future competitive advantage is the quality of your training dataset, and that takes time to build.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The cost is lower than you think. Our entire 11-agent army runs on $55/month. The barrier to AI-native operations isn’t money — it’s architecture.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sahib Alizada is the founder of &lt;a href="https://dev.tourl"&gt;Geyin.az&lt;/a&gt;, Azerbaijan’s first AI-powered fashion marketplace. He builds AI agent systems for fashion e-commerce and writes about the intersection of artificial intelligence, entrepreneurship, and the future of retail.&lt;/p&gt;

&lt;p&gt;Follow me on &lt;a href="https://www.linkedin.com/in/sahib-alizada/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;  for more on AI-native business building.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>startup</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
