<?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: Ashish Mishra</title>
    <description>The latest articles on DEV Community by Ashish Mishra (@ashish_mishra_26).</description>
    <link>https://dev.to/ashish_mishra_26</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%2F4135337%2F69d3176b-f68e-4342-90ce-1004697b46f7.png</url>
      <title>DEV Community: Ashish Mishra</title>
      <link>https://dev.to/ashish_mishra_26</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashish_mishra_26"/>
    <language>en</language>
    <item>
      <title>Where keyword search and vector search each fail</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 08 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/where-keyword-search-and-vector-search-each-fail-1dga</link>
      <guid>https://dev.to/ashish_mishra_26/where-keyword-search-and-vector-search-each-fail-1dga</guid>
      <description>&lt;p&gt;&lt;time&gt;Sep 8, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;We indexed 10,003 real questions that customers asked an online bank and ran the same queries through BM25 and vector search. The failures are instructive in both directions, and they are the whole case for hybrid search. This post shows the failures. The &lt;a href="https://infino.ai/docs/guides/hybrid-search-on-parquet" rel="noopener noreferrer"&gt;full walkthrough in the docs&lt;/a&gt; is runnable end to end: dataset, embeddings, index build, every query shape, and a read-back of the produced files with a plain Parquet reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paraphrase that breaks keyword search
&lt;/h2&gt;

&lt;p&gt;BM25 ranks rows by the exact tokens they share with the query. A user asking about a card that never arrived phrases it their own way, and BM25 anchors on "not shown up" and drifts into refund territory by the second hit:&lt;/p&gt;

&lt;p&gt;keyword_miss.py&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;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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;my new card still has not shown up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="mf"&gt;17.39&lt;/span&gt; &lt;span class="n"&gt;Why&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="n"&gt;still&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;come&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;15.52&lt;/span&gt; &lt;span class="n"&gt;My&lt;/span&gt; &lt;span class="n"&gt;refund&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="mf"&gt;15.15&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;cash&lt;/span&gt; &lt;span class="n"&gt;deposit&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="mf"&gt;14.04&lt;/span&gt; &lt;span class="n"&gt;My&lt;/span&gt; &lt;span class="n"&gt;refund&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;Where&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;13.42&lt;/span&gt; &lt;span class="n"&gt;My&lt;/span&gt; &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;shown&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vector search matches meaning, so the same paraphrase works even where a hit shares almost no vocabulary with the query:&lt;/p&gt;

&lt;p&gt;semantic.py&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;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vector_search&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;qvec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projection&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;text&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;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="mf"&gt;0.117&lt;/span&gt; &lt;span class="n"&gt;Why&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="n"&gt;still&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;come&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.165&lt;/span&gt; &lt;span class="n"&gt;why&lt;/span&gt; &lt;span class="n"&gt;have&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.172&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;just&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="n"&gt;how&lt;/span&gt; &lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="n"&gt;working&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.180&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.181&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;weeks&lt;/span&gt; &lt;span class="n"&gt;ago&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;ordered&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;It&lt;/span&gt; &lt;span class="n"&gt;isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t here. What should I do?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The token that breaks vector search
&lt;/h2&gt;

&lt;p&gt;Invert the query and the failure inverts with it. On a term of art, BM25 is precise:&lt;/p&gt;

&lt;p&gt;keyword.py&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;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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;SWIFT transfer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projection&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;text&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;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="mf"&gt;9.46&lt;/span&gt; &lt;span class="n"&gt;Is&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;SWIFT&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="n"&gt;acceptable&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;9.46&lt;/span&gt; &lt;span class="n"&gt;Can&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt; &lt;span class="n"&gt;SWIFT&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;9.46&lt;/span&gt; &lt;span class="n"&gt;Can&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;Transfer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;SWIFT&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;9.08&lt;/span&gt; &lt;span class="n"&gt;Can&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;SWIFT&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;8.72&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;there&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;fee&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;swift&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vector search has no notion of an exact token. A rare identifier, a product name, or an error code can dissolve into its embedding neighborhood, outranked by rows that are merely on topic. A real query stream contains both shapes daily. Whichever single retriever you pick, some of your users lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run both, fuse the rankings
&lt;/h2&gt;

&lt;p&gt;Hybrid search runs both retrievers and merges the two ranked lists with reciprocal rank fusion, so a row both retrievers surface outranks a row only one of them found:&lt;/p&gt;

&lt;p&gt;hybrid.py&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top up with Apple Pay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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;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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;projection&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;text&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;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="mf"&gt;0.0323&lt;/span&gt; &lt;span class="n"&gt;Is&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="n"&gt;possible&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Apple&lt;/span&gt; &lt;span class="n"&gt;Pay&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0320&lt;/span&gt; &lt;span class="n"&gt;How&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Apple&lt;/span&gt; &lt;span class="n"&gt;Pay&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0308&lt;/span&gt; &lt;span class="n"&gt;Am&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;able&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Apple&lt;/span&gt; &lt;span class="n"&gt;Pay&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0161&lt;/span&gt; &lt;span class="n"&gt;My&lt;/span&gt; &lt;span class="n"&gt;Apple&lt;/span&gt; &lt;span class="n"&gt;Pay&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;American&lt;/span&gt; &lt;span class="n"&gt;Express&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;working&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0161&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;there&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;way&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;apple&lt;/span&gt; &lt;span class="n"&gt;pay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact-token half of the query (Apple Pay) and the intent half (topping up) each pull their weight. Neither retriever alone ranks this list this way. If you want the concept in depth, &lt;a href="https://dev.to/blog/what-is-hybrid-search/"&gt;what is hybrid search?&lt;/a&gt; covers the two retrievers and the fusion math.&lt;/p&gt;

&lt;p&gt;All of this ran directly on Apache Parquet files. The index lives inside the files, they stay readable by any plain Parquet reader with Infino nowhere in the read path, and the same code targets S3 by changing one string. The &lt;a href="https://infino.ai/docs/guides/hybrid-search-on-parquet" rel="noopener noreferrer"&gt;docs guide&lt;/a&gt; is the complete walkthrough: install, dataset, embeddings, the index build, filtered search, SQL composition over search results, and the read-back. &lt;a href="https://dev.to/blog/search-index-inside-a-parquet-file/"&gt;Inside a Parquet superfile&lt;/a&gt; covers how the format carries an index without breaking compatibility.&lt;/p&gt;

</description>
      <category>database</category>
      <category>searchengine</category>
      <category>hybridsearch</category>
    </item>
    <item>
      <title>Fixed grids on vector search - comparing 3 approaches to quantization: FAISS, Turbovec, and Infino</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/fixed-grids-on-vector-search-comparing-3-approaches-to-quantization-faiss-turbovec-and-infino-3844</link>
      <guid>https://dev.to/ashish_mishra_26/fixed-grids-on-vector-search-comparing-3-approaches-to-quantization-faiss-turbovec-and-infino-3844</guid>
      <description>&lt;p&gt;&lt;time&gt;Sep 1, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;We benchmarked three approaches to 4-bit quantized vector search over the same 100,000 OpenAI embeddings (1536 dimensions): FAISS's classic product quantization, turbovec — an open-source implementation of TurboQuant — and Infino's SQ4, which we build, each measured through its own public API against exact brute-force ground truth. At 4 bits per dimension every index stores 768 bytes of codes per vector, and all three sit between 0.94 and 0.97 recall — but the latencies run from 1.5 ms to 45 ms.&lt;/p&gt;

&lt;p&gt;The sections below work through where that difference comes from, build and write cost, and the same scan at four corpus sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quantization, briefly
&lt;/h2&gt;

&lt;p&gt;There are two ways to make approximate search cheaper, and they're independent of each other.&lt;/p&gt;

&lt;p&gt;a. You can look at fewer vectors. That's &lt;strong&gt;routing&lt;/strong&gt; : IVF (inverted file) groups the corpus into clusters and reads only the clusters nearest your query, HNSW (hierarchical navigable small world) walks a graph toward the answer, tree methods carve up the space.&lt;/p&gt;

&lt;p&gt;b. Or you can make each vector cheaper to look at. That's &lt;strong&gt;quantization&lt;/strong&gt; , and it's the only thing that varies here — every index in this comparison scans all 100,000 vectors on every query. The FAISS index string carries no coarse quantizer, the turbovec index is a flat scan by design, and the Infino mode we measured is an exhaustive scan over its in-memory codes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scalar quantization&lt;/strong&gt; treats each coordinate on its own: take the float, round it to one of 16 levels, store 4 bits. 1536 coordinates, 768 bytes. Usually there's a rotation first, and those 16 levels can be evenly spaced or fitted to the data — Lloyd-Max, the classic minimum-error placement, is one such fit. &lt;strong&gt;Product quantization&lt;/strong&gt; chops the vector into M pieces and runs k-means separately on each piece, replacing that chunk of the vector with the index of its nearest centroid. The list of centroids a code indexes into is called a codebook, and each piece's codebook is a subquantizer. Because the centroids live in the chunk's own space, they can capture correlation &lt;em&gt;between&lt;/em&gt; coordinates in a chunk, which per-coordinate rounding cannot. The price is a training pass over the corpus.&lt;/p&gt;

&lt;p&gt;One thing none of these indexes does: rerank. In many systems you take the top few hundred results then re-score those against the uncompressed vectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three approaches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/facebookresearch/faiss" rel="noopener noreferrer"&gt;FAISS&lt;/a&gt;&lt;/strong&gt; is Meta's vector library, and its indexes are specified by factory string. The one here is &lt;code&gt;IDMap,PQ768x8np&lt;/code&gt; — classic product quantization with a lookup table: 768 subquantizers of two dimensions each, so one 8-bit code covers two coordinates and the budget is the same 4 bits per dimension and 768 bytes per vector as everything else in the article. It is also the configuration turbovec's own README benchmarks its recall against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/RyanCodrai/turbovec" rel="noopener noreferrer"&gt;turbovec&lt;/a&gt;&lt;/strong&gt; implements &lt;strong&gt;&lt;a href="https://arxiv.org/abs/2504.19874" rel="noopener noreferrer"&gt;TurboQuant&lt;/a&gt;&lt;/strong&gt;, and we ran it at its 1.0.0 release. No routing structure at all, a flat scan over compressed vectors. Per its docs, each vector is rotated, quantized to 2 or 4 bits per coordinate against a Lloyd-Max codebook, and renormalized by length, and the search kernels use nibble-split lookup tables over an interleaved layout, chosen at runtime.&lt;/p&gt;

&lt;p&gt;The TurboQuant authors' own released code ships only a conceptual Python path for inner-product estimation — no optimized scan, which is why the &lt;a href="https://arxiv.org/abs/2604.19528" rel="noopener noreferrer"&gt;RaBitQ team's comparison report&lt;/a&gt; excluded query-time efficiency entirely. As far as we know turbovec is the only way to measure this method's scan at full speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/infino-ai/infino" rel="noopener noreferrer"&gt;Infino&lt;/a&gt;&lt;/strong&gt; is an embedded retrieval library whose tables are Parquet files with the vector index embedded in them. It doesn't implement one fixed vector algorithm. The index serves through one of several modes — a routed cluster scan by default, an in-memory graph, or a flat scan — picked by a config setting, and &lt;code&gt;optimize()&lt;/code&gt; sizes whichever is selected against a recall target measured on the table's own data. The mode tested here is the flat scan, &lt;code&gt;flat_ivf&lt;/code&gt;: a 4-bit plane scanned exhaustively. At small corpora it's the effective shape and it's the configuration comparable to a flat library scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Everything runs in one process over 100,000 vectors from the &lt;a href="https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M" rel="noopener noreferrer"&gt;dbpedia OpenAI corpus&lt;/a&gt; — 1536 dimensions, cosine — each engine through its own public API. The machine is an EPYC 9V74 slice: 4 cores, 8 hardware threads, 63 GiB of RAM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdarw6uub0amr6ch1p141.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdarw6uub0amr6ch1p141.png" alt="Resident memory, fp32 vectors versus the 4-bit index: 586 MiB against 75 MiB at 100,000 vectors — 7.8 times smaller" width="800" height="152"&gt;&lt;/a&gt; &lt;br&gt;
&lt;em&gt;Resident memory at 100,000 vectors: fp32 against the 4-bit index.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At fp32, this corpus is 586 MiB of vectors. Every index below serves it from about 75 MiB — 4 bits per dimension in place of 32. Quantization writeups often make this the headline — turbovec's README included, where the compression figures ("31 GB of RAM as float32… fits it in 4 GB", "16x compression") are quoted against full 32-bit floating point vectors. The reduction in memory is real, but it is also what any 2- or 4-bit quantization delivers.&lt;/p&gt;

&lt;p&gt;To measure recall, ground truth is exact brute-force nearest neighbors, computed once and reused. Recall is graded at k = 1, 10 and 100 over 1,000 held-out queries, and every engine answers the same query set.&lt;/p&gt;
&lt;h2&gt;
  
  
  Latency, memory and recall
&lt;/h2&gt;

&lt;p&gt;Queries run one at a time, so the latency measurements are per-query scan costs and say nothing about throughput. Nothing pins threads: each engine parallelizes a single query however it chooses.&lt;/p&gt;

&lt;p&gt;| | recall@10 | p50 | bytes/vector | resident |&lt;br&gt;
| --- | --- | --- | --- | --- |&lt;br&gt;
   | FAISS flat PQ | 0.950 | 45.5 ms | 791 | 75.5 MiB |&lt;br&gt;
 | turbovec 4-bit | 0.948 | 1.59 ms | 788 | 75.2 MiB |&lt;br&gt;
 | Infino 4-bit | 0.963 | 1.50 ms | 788 | 75.2 MiB |&lt;/p&gt;

&lt;p&gt;Infino is slightly faster than turbovec, and both are significantly faster than FAISS.&lt;/p&gt;

&lt;p&gt;As expected, memory differences are a wash. 1536 dimensions at 4 bits is 768 bytes of codes, and all three indexes land within 3% of that, at 788 to 791 bytes per vector.&lt;/p&gt;

&lt;p&gt;The rest is bookkeeping. turbovec and Infino each store 20 bytes: an id plus a per-vector scalar that corrects the quantized dot's bias. Flat PQ stores an 8-byte id plus its trained codebook, which is 1.5 MiB shared across 100,000 vectors, or 15.7 bytes each — 768 + 8 + 15.7 = 791.7, against 791 measured.&lt;/p&gt;

&lt;p&gt;Recall at k = 1, 10, 100:&lt;/p&gt;

&lt;p&gt;| | k=1 | k=10 | k=100 |&lt;br&gt;
| --- | --- | --- | --- |&lt;br&gt;
   | FAISS flat PQ | 0.949 | 0.950 | 0.963 |&lt;br&gt;
 | turbovec 4-bit | 0.946 | 0.948 | 0.957 |&lt;br&gt;
 | Infino 4-bit | 0.970 | 0.963 | 0.970 |&lt;/p&gt;

&lt;p&gt;Every number here is graded over 1,000 held-out queries, which puts the standard error on a mean recall near ±0.004. FAISS PQ and turbovec are within a couple of points of each other at every k. Infino leads the group by at least 1.3 to 1.8 points at every size we ran.&lt;/p&gt;
&lt;h2&gt;
  
  
  Searching the vector space
&lt;/h2&gt;

&lt;p&gt;A 4-bit code can take sixteen values, so every quantizer here has sixteen levels to place. The three approaches place them differently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0m00erxrljc6qkoxbp5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0m00erxrljc6qkoxbp5.png" alt="How each approach places its 16 levels — FAISS PQ trains centroids in 2-D subspaces, TurboQuant uses a fixed Lloyd-Max codebook per coordinate, Infino computes a uniform ruler over mean ± 2.7 sigma — with what each stores and how each scores" width="800" height="436"&gt;&lt;/a&gt; &lt;br&gt;
&lt;em&gt;What each approach trains, stores, and scores with.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FAISS trains centroids.&lt;/strong&gt; It runs k-means in 768 two-dimensional subspaces, 256 centroids each — that's the 312 CPU-seconds of build time, and it buys a codebook that can represent correlation between paired coordinates, which neither one-dimensional fit in this comparison can. The scan never compares the query against the compressed vectors directly: once per query it precomputes the distance to every centroid — a 768 KiB table, 768 × 256 floats — then scores a candidate by summing one lookup per subquantizer, 768 of them, each at an address not known until the code byte has been read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turbovec precomputes its levels.&lt;/strong&gt; The insight behind it is not TurboQuant's alone — rotate-then-quantize is a family: &lt;a href="https://arxiv.org/abs/2405.12497" rel="noopener noreferrer"&gt;RaBitQ&lt;/a&gt; (SIGMOD 2024) established the construction and its error bounds although there is an ongoing discussion on precedence. The property both rely on is the same: after a random rotation, every coordinate follows the same known distribution — a Beta, near-Gaussian in high dimension — so the optimal 16-level Lloyd-Max quantizer for that distribution can be computed once, ahead of time, and reused for every corpus forever. (Infino's ruler, next, is the same construction with uniform levels.) Nothing is trained and nothing is fitted; the 1.2 CPU-seconds is rotating and encoding, which is the point — the paper calls it data-oblivious: no pass over the data is ever needed, so a vector can be quantized the moment it arrives. turbovec also stores one scalar per vector that corrects the inner product's quantization bias. At scan time the fitted levels are read through lookup tables of 16 entries per coordinate — small enough to stay in SIMD registers — at 1.59 ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infino computes a ruler based on the data.&lt;/strong&gt; It rotates (a seeded structured rotation — only the 8-byte seed is persisted), then fits &lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;step&lt;/code&gt; per rotated coordinate over &lt;strong&gt;mean ± 2.7σ&lt;/strong&gt; , uniform steps between. No codebook at all. The 2.7 is the optimal 16-level loading for a Gaussian, and the rotation is what makes the Gaussian assumption hold: it spreads each coordinate toward the same near-Gaussian marginal, which is what lets one &lt;em&gt;global&lt;/em&gt; ruler — a fixed grid, the same sixteen levels for every row in the table — work at all; on the raw embedding axes, with their wildly uneven energy, most coordinates would collapse onto a couple of codes. A comment in the encoder records the measured stakes: loading over sigma instead of min/max moved recall@10 up 0.032 on this corpus, because a min/max ruler is set by each coordinate's single most extreme value and spends most of its 16 levels on range almost no row occupies. The scan carries no distance table of any size — because the levels are evenly spaced, scoring reduces to a direct integer dot on the packed nibbles — and runs at 1.50 ms.&lt;/p&gt;
&lt;h2&gt;
  
  
  Building the vectors
&lt;/h2&gt;

&lt;p&gt;| | wall (8 threads) | CPU-seconds |&lt;br&gt;
| --- | --- | --- |&lt;br&gt;
   | turbovec 4-bit | 374 ms | 1.2 |&lt;br&gt;
 | Infino¹ | 1.7 s | — |&lt;br&gt;
 | FAISS flat PQ | 39.7 s | 312 |&lt;/p&gt;

&lt;p&gt;¹ Infino's build is a table write: the vectors are appended and committed, so the 1.7 s ends with the data durable on disk as Parquet, where the library builds end in RAM. With a single writer it's 3.57 s. The harness doesn't record CPU-seconds for it.&lt;/p&gt;

&lt;p&gt;FAISS trains: 768 k-means problems, 256 centroids each over a two-dimensional subspace, iterated over a sample of the corpus — that's the 312 CPU-seconds. TurboQuant's codebook is precomputed for the rotated coordinate distribution and never reads the corpus; turbovec's 1.2 CPU-seconds is rotation and encoding. Infino's build trains too — the 1.7 s includes its own k-means — but we didn't profile it by stage.&lt;/p&gt;

&lt;p&gt;On this corpus the trained codebook and the precomputed one reach the same recall at the same bytes. That's corpus-dependent, and we ran only the one. We didn't test anything with strong subspace structure, which is exactly where trained codebooks would have more to fit than a per-coordinate scheme does.&lt;/p&gt;

&lt;p&gt;A new vector has two jobs: get into the search structure, and get onto disk. FAISS and turbovec split them. Their &lt;code&gt;add&lt;/code&gt; writes straight into the layout the scan kernels read, so after turbovec's 18 µs the vector is searchable — in RAM, where a crash takes it back, because nothing touches disk until &lt;code&gt;save&lt;/code&gt; rewrites the whole index. Infino's &lt;code&gt;append&lt;/code&gt; does both jobs in one call: when it returns, the rows are searchable and committed.&lt;/p&gt;

&lt;p&gt;| | add | remove | persist | full cycle |&lt;br&gt;
| --- | --- | --- | --- | --- |&lt;br&gt;
   | FAISS flat PQ | 3.14 ms | 401 µs | 15.2 ms | 68.5 ms |&lt;br&gt;
 | turbovec 4-bit | 18.0 µs | 3.4 µs | 33.4 ms | 21.4 ms |&lt;br&gt;
 | Infino 4-bit | 27 µs¹ | 2.7 µs¹ | — | 17.0 ms |&lt;/p&gt;

&lt;p&gt;¹ per row, 100,000 rows sharing one commit, for the &lt;code&gt;append&lt;/code&gt; and the &lt;code&gt;delete&lt;/code&gt; alike. Infino separates add and save operations internally but renders them combined in its public API: &lt;code&gt;append&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; each do the in-memory work and the save in one call, so the per-row cost is set by how many rows share it. A row alone in the commit costs the full 17 ms to add, 7.9 ms to delete.&lt;/p&gt;

&lt;p&gt;Deletes have the same latency shape — turbovec's 3 µs remove is the in-RAM half, durable on its next save, and Infino's is again a commit — with one twist: a tombstone — the committed delete marker — carries no vector payload, so a wide delete amortizes below the in-RAM number, 2.7 µs a row durable against 3.4 µs in RAM. The two write models come down to what gets batched: the libraries batch time, with mutations waiting in RAM until the next save and a crash losing the interval, while Infino batches rows, and every call is a durability boundary.&lt;/p&gt;

&lt;p&gt;Committing every write costs the scan nothing: 788 bytes per vector and 1.50 ms, inside the range of the two libraries.&lt;/p&gt;
&lt;h2&gt;
  
  
  Scaling the quantization
&lt;/h2&gt;

&lt;p&gt;Everything above shares one constraint: to answer a query, each of these indexes reads every row. A SIMD scan over contiguous bytes has the DRAM and L1/L2/L3 cache behavior that random pointers through data structures can't match, whether it is a graph, a tree, or other types of routing. At small corpora, flat scan is the natural shape.&lt;/p&gt;

&lt;p&gt;We ran the same benchmark at four corpus sizes:&lt;/p&gt;

&lt;p&gt;| warm p50 @ k=10 | 100K | 250K | 500K | 933K |&lt;br&gt;
| --- | --- | --- | --- | --- |&lt;br&gt;
   | Infino SQ4 | 1.50 ms | 4.64 ms | 9.90 ms | 18.88 ms |&lt;br&gt;
 | turbovec 4-bit | 1.59 ms | 4.84 ms | 10.19 ms | 19.05 ms |&lt;br&gt;
 | FAISS flat PQ | 45.5 ms | 111.8 ms | 226.3 ms | 414.7 ms |&lt;/p&gt;

&lt;p&gt;Recall over the same sweep:&lt;/p&gt;

&lt;p&gt;| recall@10 | 100K | 250K | 500K | 933K |&lt;br&gt;
| --- | --- | --- | --- | --- |&lt;br&gt;
   | Infino SQ4 | 0.963 | 0.962 | 0.960 | 0.960 |&lt;br&gt;
 | turbovec 4-bit | 0.948 | 0.945 | 0.941 | 0.937 |&lt;br&gt;
 | FAISS flat PQ | 0.950 | 0.945 | 0.942 | 0.942 |&lt;/p&gt;

&lt;p&gt;Infino recall holds within 0.3 points across the sweep. turbovec and flat PQ drift down about a point. However, given the linear nature of scans all 3 libraries incur latency penalties as the corpus scales. In the next post we'll show how we scale past that point, with different ANN (approximate nearest neighbor) structures built on the same fixed-grid philosophy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;FAISS also ships a SIMD variant of this index: &lt;code&gt;PQ1536x4fs&lt;/code&gt;, "FastScan," which trades the float lookup tables for 8-bit-quantized ones resolved by shuffle instructions. We measured it, and its speed is real — 4.4 ms on this corpus, 10× faster than the classic scanner. Its recall is not publishable: at this 1536-subquantizer geometry it swung between 0.59 and 0.91 across corpus sizes in our runs, and a control that scored the identical trained codebook through the classic float tables read a steady 0.934 — so the loss is inside the fast-scan kernel, not the quantization. We found no published comparison that reports FastScan recall at all; turbovec's own README uses classic PQ as its recall baseline and cites FastScan only as a speed reference, and FAISS normally deploys it with a reranking stage.&lt;/li&gt;
&lt;li&gt;FAISS PQ is normally deployed at 32 to 96 bytes per vector, where its lookup table would be 32 to 96 KiB; the 768 bytes per vector measured here is far outside its usual range.&lt;/li&gt;
&lt;li&gt;Queries run one at a time throughout, so nothing in the test measures concurrency.&lt;/li&gt;
&lt;li&gt;Every recall number is a raw quantized operating point; we didn't measure any of these indexes with a rerank stage.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;reproduce.sh&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/infino-ai/retrievalbench &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;retrievalbench
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'vector:\n search_mode: flat_ivf\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; infino.yaml
&lt;span class="nv"&gt;INFINO_BENCH_SUPERTABLE_DOCS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100000 &lt;span class="se"&gt;\&lt;/span&gt;
  cargo bench &lt;span class="nt"&gt;--&lt;/span&gt; vector-codec &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;corpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hf:KShivendu/dbpedia-entities-openai-1M corpus-dir&lt;span class="o"&gt;=&lt;/span&gt;./corpora
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scaling table is the same command at &lt;code&gt;INFINO_BENCH_SUPERTABLE_DOCS&lt;/code&gt; of 250000, 500000, and 1000000; the recall and latency tables all come from that one four-size sweep, and the build and write tables from the same harness at 100K. The corpus downloads once from HuggingFace. The FAISS row needs &lt;code&gt;--features faiss&lt;/code&gt; after &lt;code&gt;scripts/build_faiss.sh&lt;/code&gt;, which builds the bundled FAISS source with &lt;code&gt;-march=native&lt;/code&gt; — without it, FAISS's SIMD kernels silently fall back to scalar code. The engine dependencies are pinned to a commit, and every committed run records host, engine commit and command in a &lt;code&gt;run.json&lt;/code&gt; beside the numbers under &lt;code&gt;results/inprocess/&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>searchengine</category>
      <category>vectorsearch</category>
    </item>
    <item>
      <title>What is hybrid search?</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 25 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/what-is-hybrid-search-15k1</link>
      <guid>https://dev.to/ashish_mishra_26/what-is-hybrid-search-15k1</guid>
      <description>&lt;p&gt;&lt;time&gt;Aug 25, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;Hybrid search runs two retrievers over one query. A keyword retriever scores documents with BM25 against an inverted index. A vector retriever finds nearest neighbors to an embedding of the query. Each returns its own ranked list, and a fusion step merges the two into one ranking.&lt;/p&gt;

&lt;p&gt;The reason to run both is that they miss different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two retrievers, opposite failure modes
&lt;/h2&gt;

&lt;p&gt;BM25 is literal. It finds the error code, the part number, the surname, the exact phrase someone typed, and it misses the same idea written another way.&lt;/p&gt;

&lt;p&gt;Vector search is about meaning. It finds the paraphrase, the synonym, the other language, and it will also hand back things that are merely on topic.&lt;/p&gt;

&lt;p&gt;A query like &lt;em&gt;retry a failed request&lt;/em&gt; needs both: the token &lt;code&gt;retry&lt;/code&gt;, and the idea of backoff, try-again, transient failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flud3dy5db4w8r8h6fm4e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flud3dy5db4w8r8h6fm4e.png" alt="One query splits into two retrievers running concurrently over the same snapshot. The keyword lane runs BM25 over the inverted index and is literal, catching codes, names, and exact phrases. The vector lane runs approximate nearest neighbor search over embeddings and catches paraphrase and synonyms. Both rankings feed reciprocal rank fusion, which merges by rank position and produces one ranked relation with a score column." width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A question in clauses
&lt;/h2&gt;

&lt;p&gt;Take &lt;strong&gt;“Which services retried a failed request, by team?”&lt;/strong&gt;. Each clause asks for something different.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which services&lt;/strong&gt; is a count: &lt;code&gt;COUNT(DISTINCT service)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By team&lt;/strong&gt; is a &lt;code&gt;GROUP BY&lt;/code&gt;: one count per team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retried&lt;/strong&gt; is a word to match. BM25 finds the events that say it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed request&lt;/strong&gt; has no fixed wording. Vector search finds “try again”, “transient failure”, “exponential backoff”.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fep7rth2ihrspqorrw9hg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fep7rth2ihrspqorrw9hg.png" alt="The question broken into four clauses. Which services is COUNT. By team is GROUP BY. Retried is BM25. A failed request is vector search." width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fusing two incompatible scores
&lt;/h2&gt;

&lt;p&gt;BM25 similarity rises for a better match, while vector distance falls. The two numbers are not on the same scale and adding them means inventing a weight. Reciprocal rank fusion sidesteps that by using position in each list instead of the scores themselves.&lt;/p&gt;

&lt;p&gt;rrf.txt&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;contribution(rank) = 1 / (60 + rank)

# first in a list contributes 1/61
# a document first on both sides: 1/61 + 1/61 ≈ 0.0328
# first on one side only: 1/61 ≈ 0.0164
# tenth on both sides: 1/70 + 1/70 ≈ 0.0286
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each list is best-first. A hit at rank &lt;code&gt;r&lt;/code&gt; (first place is 1) contributes &lt;code&gt;1 / (60 + r)&lt;/code&gt;. The two contributions add, and the sum is the emitted score. A document both retrievers liked climbs above a document that only one of them ranked first.&lt;/p&gt;

&lt;p&gt;60 is the constant from the &lt;a href="https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf" rel="noopener noreferrer"&gt;2009 paper that introduced the method&lt;/a&gt;, where fusing runs this way beat every individual system and Condorcet Fuse. It keeps fusion deterministic across queries.&lt;/p&gt;

&lt;p&gt;Fusion needs a stable identity to merge on. A row's &lt;code&gt;_id&lt;/code&gt; is what makes the same document one document, even when the two indexes store it differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  k controls recall
&lt;/h2&gt;

&lt;p&gt;Each lane retrieves &lt;code&gt;k&lt;/code&gt; hits and fusion keeps the top &lt;code&gt;k&lt;/code&gt; by fused score. A result &lt;code&gt;LIMIT&lt;/code&gt; is a separate number: how many of those rows the query returns.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A result page uses a modest &lt;code&gt;k&lt;/code&gt; (50-200) and a small &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An aggregate over matching events uses a large &lt;code&gt;k&lt;/code&gt; (thousands) so &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;COUNT&lt;/code&gt; see the full candidate cohort.&lt;/li&gt;
&lt;li&gt;The disagreement set (rows only one retriever found) also needs a deep &lt;code&gt;k&lt;/code&gt;. Shallow &lt;code&gt;k&lt;/code&gt; hides the misses.&lt;/li&gt;
&lt;li&gt;Raising &lt;code&gt;k&lt;/code&gt; costs work on both lanes and increases retrieval depth without changing fusion weights.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Seeing where the two disagree
&lt;/h2&gt;

&lt;p&gt;Fusion hides the split by design. To measure it, run each retriever on its own and join the two relations on &lt;code&gt;_id&lt;/code&gt;. The rows on only one side are the ones a single retriever never returns.&lt;/p&gt;

&lt;p&gt;disagreement.sql&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- where do keyword and meaning disagree, and how much does each add&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;sides&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
           &lt;span class="k"&gt;WHEN&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;_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'keyword only'&lt;/span&gt; &lt;span class="c1"&gt;-- literal, no paraphrase&lt;/span&gt;
           &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'meaning only'&lt;/span&gt; &lt;span class="c1"&gt;-- the same thing, said differently&lt;/span&gt;
           &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'both agreed'&lt;/span&gt;
         &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;found_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bm25&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;score&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cosine&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tickets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'disk full on ingest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
  &lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="k"&gt;OUTER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt;
         &lt;span class="n"&gt;vector_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tickets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'embedding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
      &lt;span class="k"&gt;ON&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;_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="c1"&gt;-- one snapshot, both sides&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;found_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="k"&gt;count&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bm25&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_bm25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- nulls skip themselves&lt;/span&gt;
         &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cosine&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_cosine&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sides&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;found_by&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it per query class. When the “meaning only” bucket grows, the embeddings and the vocabulary are drifting apart. Note that &lt;code&gt;k.score&lt;/code&gt; is BM25 (higher is better) while &lt;code&gt;v.score&lt;/code&gt; is a distance (lower is better), which is the reason fusion compares rank positions rather than adding those columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sits around retrieval
&lt;/h2&gt;

&lt;p&gt;Hybrid search is the retrieval middle: one keyword query, one vector query, fusion, a ranked list. The quality stages on either side of it belong to the application.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query rewrite sits in front. Several rewritten queries can run as separate searches or a &lt;code&gt;UNION&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A cross-encoder reranker sits after, on the dozens of rows that came back. Rescoring candidates inside an ANN index is a different thing that shares the name.&lt;/li&gt;
&lt;li&gt;Chunking, contextual retrieval, and a labeled eval set are ingest and evaluation work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Infino, both indexes live inside the same Parquet files, both lanes read one pinned snapshot, and the fused result is a SQL relation: &lt;a href="https://dev.to/agents/"&gt;hybrid search on Infino&lt;/a&gt; · &lt;a href="https://dev.to/agents/queries/"&gt;the query surface&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hybridsearch</category>
      <category>parquet</category>
    </item>
    <item>
      <title>Why object-storage vectors use OPANN + Sq16, while RAM uses HNSW</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Thu, 20 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/why-object-storage-vectors-use-opann-sq16-while-ram-uses-hnsw-14jf</link>
      <guid>https://dev.to/ashish_mishra_26/why-object-storage-vectors-use-opann-sq16-while-ram-uses-hnsw-14jf</guid>
      <description>&lt;p&gt;&lt;time&gt;Aug 20, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;HNSW is an excellent in-memory vector index. It is also a poor object-storage access pattern. Each graph hop depends on the previous hop, so a cold walk turns pointer chasing into serialized range requests.&lt;/p&gt;

&lt;p&gt;Infino therefore uses two serving paths over the same vector data: HNSW when the working set is resident in RAM, and OPANN (Infino’s object-storage ANN path) with Sq16 when the vectors are served from Parquet on object storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same corpus has two hardware states
&lt;/h2&gt;

&lt;p&gt;A 1024-dimension fp32 vector occupies 4 KiB before index metadata. Ten million vectors are roughly 40 GiB of raw values. If that working set and its graph fit in the available RAM, spending memory to minimize query CPU and latency is reasonable.&lt;/p&gt;

&lt;p&gt;The same decision fails for a large or mostly cold corpus. Keeping every vector and graph edge resident makes storage cost scale with the full dataset, even when only a small fraction receives queries. Moving the graph itself to object storage avoids the RAM bill but replaces memory loads with dependent network reads.&lt;/p&gt;

&lt;p&gt;two-serving-states.txt&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hot working set
  query ─▶ HNSW graph walk ─▶ top-k
          random access in RAM

cold / object-storage working set
  query ─▶ OPANN routing ─▶ bounded cluster ranges ─▶ top-k
                           contiguous object reads

durable source for both
  parquet superfiles in object storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  HNSW: random access is a feature in RAM
&lt;/h2&gt;

&lt;p&gt;HNSW builds a navigable graph over the vectors. A query starts at an upper layer, follows promising neighbors toward the query, and expands a candidate set near the bottom. The graph avoids scoring most vectors.&lt;/p&gt;

&lt;p&gt;In RAM, those dependent hops are cheap. The next node address may be unpredictable, but it resolves as a memory access rather than a storage request. A graph over the resident Sq16 representation also reduces the bytes touched per score relative to fp32.&lt;/p&gt;

&lt;p&gt;The cost moves to build time and memory. The graph stores edges in addition to vector data, and loading or rebuilding it is work a cold worker must pay before it receives the low-latency path. HNSW is the right answer only when the working set is actually resident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why graph hops do not belong on object storage
&lt;/h2&gt;

&lt;p&gt;Object storage rewards planned reads. One request can fetch a useful contiguous range at high throughput, but every additional dependent request pays request latency again.&lt;/p&gt;

&lt;p&gt;An HNSW walk cannot issue all of its reads in advance: the node selected at step N determines which neighbors exist at step N+1. Even with caching, a cold graph walk creates the exact sequence object storage handles poorly: small, unpredictable, serialized reads.&lt;/p&gt;

&lt;p&gt;Downloading the whole graph before searching avoids that request pattern, but turns the first query into a bulk load and requires enough local capacity for every cold namespace. That is pinning under another name.&lt;/p&gt;

&lt;h2&gt;
  
  
  OPANN: route first, then read contiguous ranges
&lt;/h2&gt;

&lt;p&gt;The object-storage path groups vectors into regions that can be addressed independently. A small routing structure chooses the regions most likely to contain the nearest results. The query then fetches those cluster ranges, scores their compact vectors, and reranks a shortlist.&lt;/p&gt;

&lt;p&gt;The important property is that the expensive reads are known before the data arrives. Cluster ranges can be requested concurrently, coalesced, cached, and reused. A query touches a bounded subset of the vector region instead of traversing a graph one network-dependent node at a time.&lt;/p&gt;

&lt;p&gt;Because the vector index sits inside the same immutable Parquet superfile as the row data, the returned ids resolve against the same snapshot as scalar filters and BM25 results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Sq16 sits between fp32 input and search
&lt;/h2&gt;

&lt;p&gt;Infino accepts fp32 vectors. Internally, Sq16 represents each dimension with 16 bits. A 1024-dimension row drops from 4 KiB to about 2 KiB before surrounding metadata: roughly 40 GiB to 20 GiB across ten million vectors.&lt;/p&gt;

&lt;p&gt;That reduction matters in both states. A resident HNSW walk touches fewer bytes per node. An OPANN query transfers and scores more candidates per object-store range. Full-precision input remains the API contract; quantization is an engine choice rather than a format the application has to manage.&lt;/p&gt;

&lt;p&gt;Sq16 is still a quantized representation. Workloads requiring a different codec or direct control over every search parameter may prefer a dedicated vector database that exposes those choices. Infino chooses the representation and query parameters from the table’s data and serving state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving state can change in place
&lt;/h2&gt;

&lt;p&gt;The durable index remains in the Parquet superfiles. A hot working set can load a resident HNSW path over those vectors. A cold worker, a restart, or a table beyond the RAM budget continues through OPANN. Changing serving state does not create a second source of truth or require the application to reinsert vectors.&lt;/p&gt;

&lt;p&gt;The object-storage path is the fallback when the resident graph is absent or evicted. Vector search becomes colder rather than unavailable because object storage still holds the data and the range-searchable index.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measured object-storage path
&lt;/h2&gt;

&lt;p&gt;On the 9.4-million-vector Cohere run (768 dimensions, top-10), the post-drain OPANN + Sq16 path reached &lt;strong&gt;0.995 recall@10&lt;/strong&gt; with &lt;strong&gt;9.37 ms p50&lt;/strong&gt; and &lt;strong&gt;10.25 ms p99&lt;/strong&gt; warm latency. A cold open took a 661 ms median; the first cold query took 4.60 seconds while fetching 2.55 GiB of index ranges.&lt;/p&gt;

&lt;p&gt;These measurements cover OPANN. They show the trade directly: planned range reads are fast once resident, but expensive on first touch. The resident HNSW path trades additional RAM and graph load/build cost for a query path that avoids the cluster scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the index that matches the medium
&lt;/h2&gt;

&lt;p&gt;Different storage media favor different vector structures. HNSW turns resident memory into low-latency graph traversal. OPANN turns object storage into a small number of planned range reads. Sq16 lowers the bytes both paths have to move and score.&lt;/p&gt;

&lt;p&gt;The shared invariant is the Parquet source beneath them: one durable vector corpus, with the serving path selected by where the working set lives.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
  </channel>
</rss>
