<?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>Choosing a database for AI agents: memory, retrieval and state compared</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/choosing-a-database-for-ai-agents-memory-retrieval-and-state-compared-2pgn</link>
      <guid>https://dev.to/ashish_mishra_26/choosing-a-database-for-ai-agents-memory-retrieval-and-state-compared-2pgn</guid>
      <description>&lt;p&gt;&lt;time&gt;Sep 24, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;The right database for an AI agent depends on the jobs its memory has to do. An agent recalls past context by meaning, looks up exact names and identifiers, filters and counts structured state such as users, sessions and time windows, and keeps a history that only grows. Most agents need all of these at once, which is why keyword and vector search over the same records, with SQL over the results, has become the common requirement.&lt;/p&gt;

&lt;p&gt;This page compares seven common options on those capabilities. It compares capabilities only, not prices, and it says where each option is the right choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an AI agent need from its database?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recall by meaning.&lt;/strong&gt; Vector search finds a past message or document that says the same thing in different words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recall by exact term.&lt;/strong&gt; Names, ticket numbers, error codes and product IDs are where embeddings blur, because a rare token dissolves into its neighborhood. Keyword search with BM25 finds them. &lt;a href="https://dev.to/blog/hybrid-search-on-parquet/"&gt;Where keyword search and vector search each fail&lt;/a&gt; shows both failures on real data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured state.&lt;/strong&gt; Memory is scoped to a user, a session, a tool or a time range, and an agent often needs a number rather than a list: how many times did this step fail this week, and for which customers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Growth.&lt;/strong&gt; An agent's history is appended to and rarely deleted, so the stored corpus grows every day while each query stays small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where it runs.&lt;/strong&gt; A library inside the agent's process has no server to operate and no network hop. A server or hosted service can be shared by many agents and scaled on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two are the case for &lt;a href="https://dev.to/blog/what-is-hybrid-search/"&gt;hybrid search&lt;/a&gt;: an agent's queries mix exact identifiers with loosely worded recall, and each retriever misses what the other finds. The third is the case for SQL over the search results rather than a second system to join them in application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the common options compare
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;system&lt;/th&gt;
&lt;th&gt;runs as&lt;/th&gt;
&lt;th&gt;keyword + vector in one query&lt;/th&gt;
&lt;th&gt;SQL over results&lt;/th&gt;
&lt;th&gt;storage format&lt;/th&gt;
&lt;th&gt;open source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infino&lt;/td&gt;
&lt;td&gt;embedded library or hosted&lt;/td&gt;
&lt;td&gt;yes: BM25 plus vector, fused with reciprocal rank fusion in one query&lt;/td&gt;
&lt;td&gt;yes: DataFusion SQL, with search as table functions&lt;/td&gt;
&lt;td&gt;Parquet, with the indexes inside the files&lt;/td&gt;
&lt;td&gt;yes (Apache-2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres + pgvector&lt;/td&gt;
&lt;td&gt;database server&lt;/td&gt;
&lt;td&gt;partial: native full-text plus pgvector, BM25 needs another extension, ranks combined by you&lt;/td&gt;
&lt;td&gt;yes, full Postgres SQL&lt;/td&gt;
&lt;td&gt;Postgres tables&lt;/td&gt;
&lt;td&gt;yes (PostgreSQL license)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch / OpenSearch&lt;/td&gt;
&lt;td&gt;cluster or service&lt;/td&gt;
&lt;td&gt;yes: BM25 plus kNN, fused&lt;/td&gt;
&lt;td&gt;partial: ES|QL, a SQL plugin, PPL&lt;/td&gt;
&lt;td&gt;Lucene segments, engine-private&lt;/td&gt;
&lt;td&gt;OpenSearch Apache-2, Elasticsearch AGPL / ELv2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qdrant&lt;/td&gt;
&lt;td&gt;server or cluster&lt;/td&gt;
&lt;td&gt;partial: dense plus sparse vectors&lt;/td&gt;
&lt;td&gt;no, retrieval API&lt;/td&gt;
&lt;td&gt;engine-private indexes&lt;/td&gt;
&lt;td&gt;yes (Apache-2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinecone&lt;/td&gt;
&lt;td&gt;hosted service&lt;/td&gt;
&lt;td&gt;partial: dense plus sparse vectors&lt;/td&gt;
&lt;td&gt;no, retrieval API&lt;/td&gt;
&lt;td&gt;engine-private indexes&lt;/td&gt;
&lt;td&gt;no, hosted only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LanceDB&lt;/td&gt;
&lt;td&gt;embedded library or hosted&lt;/td&gt;
&lt;td&gt;yes: full-text index plus vector, fused, optional reranker&lt;/td&gt;
&lt;td&gt;partial: SQL filter expressions on queries&lt;/td&gt;
&lt;td&gt;Lance, an open columnar format&lt;/td&gt;
&lt;td&gt;yes (Apache-2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chroma&lt;/td&gt;
&lt;td&gt;embedded, server, or hosted&lt;/td&gt;
&lt;td&gt;partial: dense, sparse and full-text search, local and distributed features still converging&lt;/td&gt;
&lt;td&gt;no, retrieval API with metadata filters&lt;/td&gt;
&lt;td&gt;engine-private&lt;/td&gt;
&lt;td&gt;yes (Apache-2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Checked against each project's documentation in September 2026. Each project moves quickly, so check the current docs for the feature you depend on. The &lt;a href="https://dev.to/vs/"&gt;comparison pages&lt;/a&gt; go system by system.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is each one the right choice?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infino&lt;/strong&gt; when an agent needs keyword, vector and SQL over the same memory in one query, with the data kept as Parquet in your own bucket and no server to run. It is not a system of record: transactional, write-heavy state belongs in an OLTP database next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres with pgvector&lt;/strong&gt; when the agent's application state already lives in Postgres and the memory corpus is modest. One database, transactional writes and full SQL. The cost is that relevance fusion is yours to write, and text, vectors and the application share the same instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elasticsearch or OpenSearch&lt;/strong&gt; when full-text relevance tuning matters most, with analyzers, synonyms and mature ranking controls, and a team already runs a search cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant or Pinecone&lt;/strong&gt; when the workload is dedicated vector serving at a high sustained query rate, filtered on payload metadata. Pinecone when nobody should run servers at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LanceDB&lt;/strong&gt; when the agent works with vectors and multimodal data on files, embedded in the process, and the same tables also feed training or data pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chroma&lt;/strong&gt; for the quickest local start on a prototype, with a hosted path when it grows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this looks like in Infino
&lt;/h2&gt;

&lt;p&gt;Infino is an open source retrieval library that runs inside the agent's process. A superfile is a search index that is a valid Parquet file: the data and the BM25 and vector indexes in one file. An agent's memory is one table of those files, on local disk or in object storage, and a single query can run a hybrid search, filter it by user and time, and group the results, because each search function is a table in SQL.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://infino.ai/docs/use-cases/agent-memory" rel="noopener noreferrer"&gt;agent memory guide&lt;/a&gt; walks through the pattern with code, and agents that speak MCP can use the &lt;a href="https://infino.ai/docs/integrations/mcp" rel="noopener noreferrer"&gt;Infino MCP server&lt;/a&gt; without writing any. &lt;a href="https://infino.ai/docs/guides/hybrid-search-on-parquet" rel="noopener noreferrer"&gt;Implementing hybrid search on Apache Parquet files&lt;/a&gt; is the runnable end-to-end version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do AI agents need a vector database?
&lt;/h3&gt;

&lt;p&gt;They need vector search, which is not the same thing. Vector search recalls past context by meaning, but it misses exact names, identifiers and error codes, and a standalone vector database adds a second system to keep in sync with the rest of the agent’s state. Engines that run keyword and vector search over the same records cover both needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is hybrid search necessary for agent memory?
&lt;/h3&gt;

&lt;p&gt;For most agents, yes. Their queries mix exact identifiers, such as a customer name or a ticket number, with loosely worded recall, such as what went wrong last time. Keyword search finds the first kind and vector search finds the second, and fusing the two rankings returns the rows that either one alone would miss. &lt;a href="https://dev.to/blog/what-is-hybrid-search/"&gt;How hybrid search fuses the two&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Postgres for AI agent memory?
&lt;/h3&gt;

&lt;p&gt;Yes. Postgres with pgvector and its native full-text search covers agent memory well when the application already runs on Postgres and the corpus is modest. BM25 ranking needs another extension, the two rankings are combined in your own SQL, and memory shares the instances that serve the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should an agent’s database be embedded or a server?
&lt;/h3&gt;

&lt;p&gt;Embedded when one agent process owns its memory and simplicity matters, since there is nothing to deploy and no network hop on a lookup. A server or hosted service when many agents share one store or it needs to scale apart from them. Some engines, Infino and LanceDB among them, offer both.&lt;/p&gt;

&lt;h3&gt;
  
  
  What storage format should agent memory use?
&lt;/h3&gt;

&lt;p&gt;An open one, if other tools need to read the history. Engine-private formats are readable only through the engine that wrote them. Infino stores memory as standard Parquet, so any Parquet reader can open it for analytics or export without Infino in the path. &lt;a href="https://infino.ai/docs/guides/parquet-interop" rel="noopener noreferrer"&gt;Parquet interop&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
    <item>
      <title>Open VDBBench: a fair and open benchmark for vector databases</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/open-vdbbench-a-fair-and-open-benchmark-for-vector-databases-502h</link>
      <guid>https://dev.to/ashish_mishra_26/open-vdbbench-a-fair-and-open-benchmark-for-vector-databases-502h</guid>
      <description>&lt;p&gt;&lt;time&gt;Sep 24, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;If you want to know which vector database is fastest, you probably end up at VectorDBBench. Most engineers check it, and it’s the benchmark AI assistants cite when you ask them. Zilliz, the company behind Milvus and Zilliz Cloud, maintains the harness and runs the leaderboard at &lt;a href="https://zilliz.com/vdbbench-leaderboard" rel="noopener noreferrer"&gt;zilliz.com/vdbbench-leaderboard&lt;/a&gt;. Zilliz products hold the top three places on it.&lt;/p&gt;

&lt;p&gt;When we submitted &lt;a href="https://github.com/zilliztech/VectorDBBench/pull/863" rel="noopener noreferrer"&gt;a client for Infino&lt;/a&gt; last month, we went back through the repo history to see how results make it onto that board, and found serious integrity problems. Most of the merged clients have never had a result published. Zilliz’s own products get re-run on each new release, while competitors sit on versions more than a year old, some with their compression switched off. And the dates and versions that would show you this were removed from the leaderboard data. &lt;a href="https://medium.com/@glitcher00/the-most-cited-vector-database-benchmark-is-not-publishing-results-from-competitors-b45788e20aa7?sharedUserId=glitcher00" rel="noopener noreferrer"&gt;Read the full critique&lt;/a&gt; for the PRs and configs behind each point.&lt;/p&gt;

&lt;p&gt;We wanted a board where every engine runs its current version with compression on, and every number says when it was measured. So we forked the harness and ran the engines ourselves. The results are live at &lt;a href="https://dev.to/open-vdbbench/"&gt;infino.ai/open-vdbbench&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Open VDBBench runs
&lt;/h2&gt;

&lt;p&gt;Open VDBBench uses the same VectorDBBench cases, Cohere 1M and Cohere 10M at 768 dimensions, with &lt;a href="https://github.com/infino-ai/VectorDBBench" rel="noopener noreferrer"&gt;our fork of the harness&lt;/a&gt;. Each engine gets its own Azure &lt;code&gt;Standard_D16ads_v7&lt;/code&gt; (16 vCPU, 64 GB, local NVMe), with the client on the same VM. We sweep each engine’s search parameter and rank it by its highest QPS at recall ≥ 0.90. If you care about higher recall, the board can re-pick every engine at 0.95 or 0.99.&lt;/p&gt;

&lt;p&gt;Every row carries the date it was measured and the version the runner read from the engine itself. If an engine’s client supports a quantized index, we use it. Anything that runs gets published, whatever it scores, and every row links to the result file it came from. The full rules are in &lt;a href="https://github.com/infino-ai/open-vdbbench/blob/main/METHODOLOGY.md" rel="noopener noreferrer"&gt;METHODOLOGY.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here’s how three engines ran on each board:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;zilliz board&lt;/th&gt;
&lt;th&gt;open board&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch&lt;/td&gt;
&lt;td&gt;8.17, HNSW float32, rescore off&lt;/td&gt;
&lt;td&gt;9.5.1, int8_hnsw, rescore, oversample 2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenSearch&lt;/td&gt;
&lt;td&gt;2.17, &lt;code&gt;use_quant: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3.8.0, HNSW with fp16 scalar quantization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Milvus&lt;/td&gt;
&lt;td&gt;2.6.14, SQ4U+FP16 and SQ8&lt;/td&gt;
&lt;td&gt;2.6.14, HNSW_SQ, SQ4U with FP16 refinement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first pass covers 19 engines at 1M and 13 at 10M. Infino runs under the same rules as everyone else, and it isn’t first at 1M. OceanBase is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add your database
&lt;/h2&gt;

&lt;p&gt;If your database is missing or on an old version, send a pull request to &lt;a href="https://github.com/infino-ai/open-vdbbench" rel="noopener noreferrer"&gt;infino-ai/open-vdbbench&lt;/a&gt;. It needs an entry in &lt;code&gt;harness/matrix.json&lt;/code&gt; pinned to an exact image tag, plus bring-up scripts if the database is new. A version bump only changes the tag.&lt;/p&gt;

&lt;p&gt;We’ll merge every new database within a month of its pull request. Every month, we run each new and updated database at 1M and 10M on the reference machine and put the results on the board.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
    <item>
      <title>You don’t need two databases for hybrid search</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Wed, 23 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/you-dont-need-two-databases-for-hybrid-search-26f1</link>
      <guid>https://dev.to/ashish_mishra_26/you-dont-need-two-databases-for-hybrid-search-26f1</guid>
      <description>&lt;p&gt;&lt;time&gt;Sep 23, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;Keyword search and vector search can run in one engine over one copy of your data. Infino is an open source retrieval library that keeps the text, the embeddings, and both the BM25 and vector indexes in one table of Parquet files on object storage, and a single hybrid query returns one ranked result set. There is no second database to feed and no sync job between them.&lt;/p&gt;

&lt;p&gt;Most teams arrive at the same setup by stages.&lt;/p&gt;

&lt;p&gt;They start with embeddings and a vector database because semantic search feels like the new part. Then the misses show up. Error codes disappear. Part numbers blur together. Proper names drift. So they add keyword search beside it, and now retrieval works well enough.&lt;/p&gt;

&lt;p&gt;It also means they are running two search systems for one job.&lt;/p&gt;

&lt;p&gt;That part gets waved away too easily. People spend their time on fusion weights and ranking tricks because that work is visible. The expensive part sits underneath it. Two services to run. Two copies of the corpus. Two indexing paths. One sync loop that nobody wants to own and everybody depends on.&lt;/p&gt;

&lt;p&gt;Hybrid search is not a luxury feature. It is what you end up building once you learn where each method breaks.&lt;/p&gt;

&lt;p&gt;Embeddings are good at meaning. They are weak at exact strings. BM25 is good at exact terms and bad at semantic drift. If your corpus contains manuals, tickets, logs, or internal docs, you usually need both. We ran both retrievers over 10,003 real banking questions in &lt;a href="https://dev.to/blog/hybrid-search-on-parquet/"&gt;where keyword search and vector search each fail&lt;/a&gt;, and &lt;a href="https://dev.to/blog/what-is-hybrid-search/"&gt;what is hybrid search&lt;/a&gt; covers the fusion math.&lt;/p&gt;

&lt;p&gt;The older assumption was that this meant two different systems. That assumption came from the period when vector search had to be bolted onto the stack from the outside. It no longer needs to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets expensive in the usual stack
&lt;/h2&gt;

&lt;p&gt;Look at the operational shape of the common design.&lt;/p&gt;

&lt;p&gt;You keep one system for vectors and another for full-text. You write every document into both. You keep two schemas in step. You watch two dashboards. You debug two failure modes. When one side lags, retrieval quality drops in ways that are hard to spot and annoying to explain.&lt;/p&gt;

&lt;p&gt;The cost model is just as awkward. Both systems charge you for stored corpus size. The sync path costs you on every change. Query traffic matters less than people expect. Double the corpus and you often double the retrieval bill, even if traffic stays flat.&lt;/p&gt;

&lt;p&gt;The ranking code is not the real burden here. Most teams can fuse two result lists in an afternoon. The part that lingers is everything around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source hybrid search on object storage
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/infino-ai/infino" rel="noopener noreferrer"&gt;Infino&lt;/a&gt; takes the obvious route and keeps one copy. It is an open source retrieval library under Apache-2.0, embedded in your process rather than run as a cluster.&lt;/p&gt;

&lt;p&gt;The table holds the text, the embeddings, and the indexes for both BM25 and vector search. A hybrid query runs over the same rows and returns one ranked result set. There is no second database to feed and no merge step in the application. That matters less because it saves a few milliseconds and more because it removes an entire layer of operational drag.&lt;/p&gt;

&lt;p&gt;There is another problem teams feel later. Once the index format is proprietary, the data becomes sticky. Leaving means re-indexing everything, moving the corpus, and often rebuilding the serving path around it. The migration cost grows with time.&lt;/p&gt;

&lt;p&gt;Infino cuts against that from both directions. The engine is open source, so nothing in the read path is a black box, and the storage format is one you already use. A superfile is a search index that is a valid Parquet file: the data and the BM25 and vector indexes in one file (&lt;a href="https://infino.ai/docs/core-concepts" rel="noopener noreferrer"&gt;core concepts&lt;/a&gt;). Those files sit in your own bucket, and &lt;a href="https://infino.ai/docs/guides/parquet-interop" rel="noopener noreferrer"&gt;any Parquet reader&lt;/a&gt; opens one as an ordinary table.&lt;/p&gt;

&lt;p&gt;Storage is priced like object storage, not like hot memory reserved for the life of the corpus.&lt;/p&gt;

&lt;p&gt;Readers do not need to hold the full dataset. They scale with query load rather than raw corpus size. A top-k lookup turns into a small set of offsets and range reads, with local caching where it helps.&lt;/p&gt;

&lt;p&gt;This is the right shape for a lot of RAG systems. The corpus grows steadily. Queries are small. Most documents sit cold most of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in code
&lt;/h2&gt;

&lt;p&gt;retrieval.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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;infino&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;infino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://my-bucket/docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;large_utf8&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;nullable&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="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&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;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&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="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;infino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IndexSpec&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;vector&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="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cosine&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;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docs&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;body&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;E-4021 after rotating a key&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;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_vec&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;10&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;_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;body&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The text and the embeddings live on the same rows. BM25 and vector search run in one place. The caller gets back one ranked set of Arrow rows, with &lt;code&gt;projection&lt;/code&gt; choosing the columns (it returns &lt;code&gt;_id&lt;/code&gt; and &lt;code&gt;score&lt;/code&gt; alone if you leave it off). The &lt;a href="https://infino.ai/docs/guides/search" rel="noopener noreferrer"&gt;search guide&lt;/a&gt; has every retrieval mode with runnable code, including how RRF fuses the two rankings.&lt;/p&gt;

&lt;p&gt;The environment switch is small:&lt;/p&gt;

&lt;p&gt;connect.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;infino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# tests
&lt;/span&gt;&lt;span class="n"&gt;infino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# laptop, no server
&lt;/span&gt;&lt;span class="n"&gt;infino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://my-bucket/docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# your bucket, queried in place
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Infino does not generate embeddings for you. Bring your own model, write the vectors you want, and query with the vectors you already trust.&lt;/p&gt;

&lt;p&gt;If you use a reranker, keep using it. Retrieval and reranking are different jobs. Pull back a wider candidate set, rerank it with a cross-encoder, and pass the short list to the model. Nothing about a single-engine hybrid design changes that.&lt;/p&gt;

&lt;p&gt;This is not a claim that every search workload belongs on object storage.&lt;/p&gt;

&lt;p&gt;If you need an in-memory serving tier with every knob exposed and corpus size is not the problem, there are good tools for that. If your pain comes from running two systems for one retrieval path, the trade-off looks different. Then the question is less about peak benchmark numbers and more about how much machinery you are carrying just to issue a search.&lt;/p&gt;

&lt;p&gt;That is the case Infino is built for.&lt;/p&gt;

&lt;p&gt;One table. One copy of the corpus. Hybrid retrieval over your own files. SQL in the same process when you need it.&lt;/p&gt;

&lt;p&gt;You can try it locally with a folder and move the same data to object storage later.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;infino
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine is on GitHub at &lt;a href="https://github.com/infino-ai/infino" rel="noopener noreferrer"&gt;infino-ai/infino&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need a separate vector database for hybrid search?
&lt;/h3&gt;

&lt;p&gt;No. The vector index and the BM25 index can live in the same table as the text. Infino writes both into the same Parquet files and hybrid_search queries them together, so there is no second system to feed or keep in sync. &lt;a href="https://infino.ai/docs/guides/search" rel="noopener noreferrer"&gt;The search guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does running a vector database next to a search cluster cost?
&lt;/h3&gt;

&lt;p&gt;Two copies of the corpus, two indexing paths, two schemas kept in step, and a sync job between them. Both systems usually charge by stored corpus size, so doubling the corpus tends to double the retrieval bill even when query traffic stays flat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where does the data live?
&lt;/h3&gt;

&lt;p&gt;In your own bucket as Parquet files, or on local disk. The same code runs against memory:// for tests, a local folder, or an s3:// prefix, and any Parquet reader can open the files as an ordinary table. &lt;a href="https://infino.ai/docs/guides/parquet-interop" rel="noopener noreferrer"&gt;Parquet interop&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Infino generate embeddings?
&lt;/h3&gt;

&lt;p&gt;No. Bring your own model, write the vectors with the rows, and query with a vector from the same model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Infino open source?
&lt;/h3&gt;

&lt;p&gt;Yes. The engine is Apache-2.0 licensed and runs as a library inside your process rather than as a cluster. &lt;a href="https://github.com/infino-ai/infino" rel="noopener noreferrer"&gt;infino-ai/infino on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
    <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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;recall@10&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;bytes/vector&lt;/th&gt;
&lt;th&gt;resident&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FAISS flat PQ&lt;/td&gt;
&lt;td&gt;0.950&lt;/td&gt;
&lt;td&gt;45.5 ms&lt;/td&gt;
&lt;td&gt;791&lt;/td&gt;
&lt;td&gt;75.5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;turbovec 4-bit&lt;/td&gt;
&lt;td&gt;0.948&lt;/td&gt;
&lt;td&gt;1.59 ms&lt;/td&gt;
&lt;td&gt;788&lt;/td&gt;
&lt;td&gt;75.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infino 4-bit&lt;/td&gt;
&lt;td&gt;0.963&lt;/td&gt;
&lt;td&gt;1.50 ms&lt;/td&gt;
&lt;td&gt;788&lt;/td&gt;
&lt;td&gt;75.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;k=1&lt;/th&gt;
&lt;th&gt;k=10&lt;/th&gt;
&lt;th&gt;k=100&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FAISS flat PQ&lt;/td&gt;
&lt;td&gt;0.949&lt;/td&gt;
&lt;td&gt;0.950&lt;/td&gt;
&lt;td&gt;0.963&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;turbovec 4-bit&lt;/td&gt;
&lt;td&gt;0.946&lt;/td&gt;
&lt;td&gt;0.948&lt;/td&gt;
&lt;td&gt;0.957&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infino 4-bit&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;td&gt;0.963&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;wall (8 threads)&lt;/th&gt;
&lt;th&gt;CPU-seconds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;turbovec 4-bit&lt;/td&gt;
&lt;td&gt;374 ms&lt;/td&gt;
&lt;td&gt;1.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infino¹&lt;/td&gt;
&lt;td&gt;1.7 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAISS flat PQ&lt;/td&gt;
&lt;td&gt;39.7 s&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;add&lt;/th&gt;
&lt;th&gt;remove&lt;/th&gt;
&lt;th&gt;persist&lt;/th&gt;
&lt;th&gt;full cycle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FAISS flat PQ&lt;/td&gt;
&lt;td&gt;3.14 ms&lt;/td&gt;
&lt;td&gt;401 µs&lt;/td&gt;
&lt;td&gt;15.2 ms&lt;/td&gt;
&lt;td&gt;68.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;turbovec 4-bit&lt;/td&gt;
&lt;td&gt;18.0 µs&lt;/td&gt;
&lt;td&gt;3.4 µs&lt;/td&gt;
&lt;td&gt;33.4 ms&lt;/td&gt;
&lt;td&gt;21.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infino 4-bit&lt;/td&gt;
&lt;td&gt;27 µs¹&lt;/td&gt;
&lt;td&gt;2.7 µs¹&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;17.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;warm p50 @ k=10&lt;/th&gt;
&lt;th&gt;100K&lt;/th&gt;
&lt;th&gt;250K&lt;/th&gt;
&lt;th&gt;500K&lt;/th&gt;
&lt;th&gt;933K&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infino SQ4&lt;/td&gt;
&lt;td&gt;1.50 ms&lt;/td&gt;
&lt;td&gt;4.64 ms&lt;/td&gt;
&lt;td&gt;9.90 ms&lt;/td&gt;
&lt;td&gt;18.88 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;turbovec 4-bit&lt;/td&gt;
&lt;td&gt;1.59 ms&lt;/td&gt;
&lt;td&gt;4.84 ms&lt;/td&gt;
&lt;td&gt;10.19 ms&lt;/td&gt;
&lt;td&gt;19.05 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAISS flat PQ&lt;/td&gt;
&lt;td&gt;45.5 ms&lt;/td&gt;
&lt;td&gt;111.8 ms&lt;/td&gt;
&lt;td&gt;226.3 ms&lt;/td&gt;
&lt;td&gt;414.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;recall@10&lt;/th&gt;
&lt;th&gt;100K&lt;/th&gt;
&lt;th&gt;250K&lt;/th&gt;
&lt;th&gt;500K&lt;/th&gt;
&lt;th&gt;933K&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infino SQ4&lt;/td&gt;
&lt;td&gt;0.963&lt;/td&gt;
&lt;td&gt;0.962&lt;/td&gt;
&lt;td&gt;0.960&lt;/td&gt;
&lt;td&gt;0.960&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;turbovec 4-bit&lt;/td&gt;
&lt;td&gt;0.948&lt;/td&gt;
&lt;td&gt;0.945&lt;/td&gt;
&lt;td&gt;0.941&lt;/td&gt;
&lt;td&gt;0.937&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAISS flat PQ&lt;/td&gt;
&lt;td&gt;0.950&lt;/td&gt;
&lt;td&gt;0.945&lt;/td&gt;
&lt;td&gt;0.942&lt;/td&gt;
&lt;td&gt;0.942&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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>
    <item>
      <title>Infino's self-driving vector engine</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Fri, 07 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/infinos-self-driving-vector-engine-327d</link>
      <guid>https://dev.to/ashish_mishra_26/infinos-self-driving-vector-engine-327d</guid>
      <description>&lt;p&gt;&lt;time&gt;Aug 7, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;You write a table. Infino builds the vector index — and tunes it to your data. No &lt;code&gt;n_cent&lt;/code&gt;, no &lt;code&gt;nprobe&lt;/code&gt;, no &lt;code&gt;rerank_mult&lt;/code&gt;, no sweeps. On Cohere-10M, that's &lt;strong&gt;0.99 recall@10&lt;/strong&gt; — with zero knobs touched.&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%2F2nbtpj5oh5y316vf4pmq.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%2F2nbtpj5oh5y316vf4pmq.png" alt="Self-driving vector engine: write a table, ingest append-only; Infino inverts it in the background into a distance-aligned global index clustered by similarity; then serves frontier recall (0.99 recall@10 on Cohere-10M) with n_cent, nprobe and rerank calibrated automatically — no knobs." width="800" height="451"&gt;&lt;/a&gt; &lt;br&gt;
&lt;em&gt;Write a table; Infino inverts it in the background into a distance-aligned index, then stamps the ANN dials — n_cent, nprobe, rerank — from your own data. No sweeps.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most vector databases make you hand-tune dials such as &lt;code&gt;n_cent&lt;/code&gt;, &lt;code&gt;nprobe&lt;/code&gt;, &lt;code&gt;ef_search&lt;/code&gt;, or rerank depth — and wish you luck. Get them wrong and you burn compute or silently ship poor recall, and the "right" values shift with scale and data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dials that run your recall bill
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;n_cent&lt;/strong&gt; — how finely to partition the index&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nprobe&lt;/strong&gt; — how many partitions to actually search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rerank multiplier&lt;/strong&gt; — how many candidates to re-score exactly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set them too conservative and you burn compute chasing recall you didn't need. Too aggressive and you silently ship 0.6 recall to users who expected 0.99. And there's no universal right answer: the sweet spot moves with corpus size, embedding distribution, even your query mix. Perfect at 1M is wrong at 100M. Perfect for clean product vectors, broken for messy multilingual text.&lt;/p&gt;

&lt;p&gt;So teams run sweeps, hard-code a compromise, and re-tune every time the data grows. Tuning your ANN index becomes a standing job — a tax on every vector workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infino's answer: write a table, invert it in the background
&lt;/h2&gt;

&lt;p&gt;Infino treats those dials as the engine's job, not yours — and the architecture is built for it.&lt;/p&gt;

&lt;p&gt;You write to Infino like a normal table: rows, columns, your vectors sitting next to your metadata. Ingest is cheap and append-only — no index to pre-build, no reindex window, no write stalls.&lt;/p&gt;

&lt;p&gt;Then, in the background, Infino inverts that row-ordered data into a globally distance-aligned layout — vectors reorganized by similarity across the entire corpus, so a query reads only the neighborhood that matters.&lt;/p&gt;

&lt;p&gt;That background pass is where the tuning happens. As it builds the global structure, Infino measures your actual data — how vectors cluster, how well the coarse code predicts the true nearest neighbor — and stamps a serving plan: partition count, probe width, and rerank budget calibrated to hit your recall target at the lowest cost. At query time it adapts again: probing narrow when the answer concentrates in one neighborhood, wider when it doesn't, and keeping the rerank budget honest as it widens.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we measured it
&lt;/h2&gt;

&lt;p&gt;On Cohere-10M (10M × 768-d embeddings, a standard vector benchmark), Infino's default adaptive serving — no manual &lt;code&gt;n_cent&lt;/code&gt;, &lt;code&gt;nprobe&lt;/code&gt;, or &lt;code&gt;rerank_mult&lt;/code&gt;, nothing tuned by hand — returns &lt;strong&gt;recall@10 ≈ 0.99&lt;/strong&gt;. The engine calibrated the probe width to the data and found that a narrow search already clears the bar — so it hits that recall without over-probing, spending compute only where it actually helps.&lt;/p&gt;

&lt;p&gt;That's the point: the same engine would probe wider on a harder corpus where a narrow search wouldn't clear the bar — because the width is stamped from the data, not from a config you guessed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-driving" actually means
&lt;/h2&gt;

&lt;p&gt;That's the difference between a database that's easier to tune and one that tunes itself. Infino owns the write path, the index, and the cost/recall trade-off end to end — so you get row-database-simple writes and expert-tuned ANN reads, and spend compute only where recall actually needs it.&lt;/p&gt;

&lt;p&gt;And this is just the engine. Infino's serverless offering takes it further — capacity, tiering, and scaling automated end to end.&lt;/p&gt;

</description>
      <category>vectordb</category>
      <category>database</category>
      <category>searchengine</category>
    </item>
    <item>
      <title>Inside a Parquet superfile</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/inside-a-parquet-superfile-4pkp</link>
      <guid>https://dev.to/ashish_mishra_26/inside-a-parquet-superfile-4pkp</guid>
      <description>&lt;p&gt;&lt;time&gt;Jul 28, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;An Infino superfile is both a Parquet table and a search artifact. A standard Parquet reader sees columns and rows. Infino reads those same bytes plus an inverted index for BM25 and a vector index for nearest-neighbor search.&lt;/p&gt;

&lt;p&gt;There is no sidecar to coordinate. The data and both indexes become visible together when the file is committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why use Parquet at all?
&lt;/h2&gt;

&lt;p&gt;Parquet is built for column scans, not posting-list jumps or vector probes. We considered three places to put the search index:&lt;/p&gt;

&lt;p&gt;three-designs.txt&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sidecar
  parquet data ──sync──▶ search cluster / vector store
  two copies · two consistency models

new format
  columns + search indexes in a search-first file
  one copy · dedicated readers

index inside parquet
  row groups + search regions + parquet footer
  one copy · standard parquet columns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sidecar gives each engine its preferred layout, but every write creates a synchronization boundary. A new format keeps one copy, but makes that engine responsible for every reader and connector the data needs elsewhere.&lt;/p&gt;

&lt;p&gt;Embedding the index accepts Parquet’s access-pattern constraints in exchange for keeping one immutable file and its existing columnar contract. The rest of the article is how that trade works at the byte level, and where it still hurts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The byte layout
&lt;/h2&gt;

&lt;p&gt;Parquet row groups come first and the Parquet footer remains last. The search regions occupy the space between them:&lt;/p&gt;

&lt;p&gt;superfile.sf.parquet&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;byte 0
┌────────┬────────────────────┬────────────────┬──────────────┬────────┬────────┐
│ PAR1 │ parquet row groups │ full-text index│ vector index │ footer │ PAR1 │
└────────┴────────────────────┴────────────────┴──────────────┴────────┴────────┘
                                                          end of file

footer metadata:
  full-text offset + length + column config
  vector offset + length + column config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row groups are written by the normal Arrow Parquet writer. Their byte offsets never change. The footer is an ordinary Parquet footer with additional namespaced metadata that tells Infino where each optional search region begins and ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the body, then rewrite the footer
&lt;/h2&gt;

&lt;p&gt;The index offsets are unknown until the row groups have been encoded, so the builder cannot provide them as ordinary writer properties up front.&lt;/p&gt;

&lt;p&gt;The build runs in three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write a complete Parquet file with the user columns and row groups.&lt;/li&gt;
&lt;li&gt;Remove the original footer and append the full-text and vector regions.&lt;/li&gt;
&lt;li&gt;Emit a new standard footer carrying the original row-group metadata plus the index offsets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;build-order.txt&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;standard parquet
      │
      ├── keep row-group bytes and metadata
      ├── append full-text region
      ├── append vector region
      └── write footer with final offsets

result: one immutable file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Appending the regions does not move the row groups, because they were already before the old footer. The replacement footer continues to point at the same column bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a normal reader still works
&lt;/h2&gt;

&lt;p&gt;A Parquet reader starts from the trailing &lt;code&gt;PAR1&lt;/code&gt;, reads the footer, and follows the row-group offsets named there. The search regions sit outside those ranges. Metadata keys the reader does not recognize are ignored.&lt;/p&gt;

&lt;p&gt;That is enough for a normal reader to project columns, apply predicates, and run SQL without knowing that BM25 or vector structures are present. Infino opens the same footer, recognizes its metadata namespace, and also addresses the embedded regions.&lt;/p&gt;

&lt;p&gt;The compatibility guarantee covers reads. A generic writer emits the columns it understood and a new footer, so the search regions disappear. The result remains valid Parquet and requires reindexing before Infino can search it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parquet’s point-lookup tax
&lt;/h2&gt;

&lt;p&gt;A Parquet reader decodes a page to retrieve one value. That is efficient for adjacent rows and wasteful when search needs to resolve one result by id.&lt;/p&gt;

&lt;p&gt;Most columns use &lt;strong&gt;1 MiB&lt;/strong&gt; pages. On the corpus we measured, one page covered roughly &lt;strong&gt;65,000&lt;/strong&gt; rows. The id column instead uses &lt;strong&gt;8 KiB&lt;/strong&gt; pages, roughly &lt;strong&gt;512&lt;/strong&gt; rows, so resolving a shortlist does not decode a megabyte per hit.&lt;/p&gt;

&lt;p&gt;Making every column use small pages was worse. On 320,000-row segments at k=10, full-row reads stayed flat while the id-plus-score path became about &lt;strong&gt;8× slower&lt;/strong&gt;. The reader had many more pages to plan over. Small pages belong on the point-lookup column, not everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside the full-text region
&lt;/h2&gt;

&lt;p&gt;The full-text index contains a term dictionary, posting lists, document-length data, and per-block score bounds.&lt;/p&gt;

&lt;p&gt;The dictionary is a finite-state transducer that maps each term to its posting list. Document ids are delta- and bit-packed in fixed-size blocks together with term frequencies. Each block records the maximum BM25 contribution any document in that block can make, allowing the query engine to skip blocks that cannot enter the current top-k.&lt;/p&gt;

&lt;p&gt;Ranked BM25 and boolean token matching share those posting lists. Prefix lookup starts from the ordered term dictionary. Exact phrase matching first intersects the member tokens, then verifies the candidate against the stored string value; it does not require a second text copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside the vector region
&lt;/h2&gt;

&lt;p&gt;The durable object-storage path uses OPANN with Sq16 data. Vectors are organized into contiguous regions so a query can select relevant clusters, fetch bounded ranges, score their quantized vectors, and rerank a shortlist without downloading the full column.&lt;/p&gt;

&lt;p&gt;When a working set is pinned in RAM, Infino can use HNSW over the resident vectors instead. That is a serving choice above the durable file: the Parquet superfile remains the source of truth, and a cold worker can fall back to the object-storage search path.&lt;/p&gt;

&lt;p&gt;This split follows the hardware. HNSW is effective when graph hops resolve in memory. Object storage favors a small number of planned, contiguous reads, which is what the clustered OPANN layout provides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checksums and range reads
&lt;/h2&gt;

&lt;p&gt;Each index region is checksummed, so corruption is detected before a query trusts its contents. The Parquet footer locates the top-level full-text and vector regions; the headers and directories inside those regions locate the term dictionary, posting blocks, vector clusters, and rerank data.&lt;/p&gt;

&lt;p&gt;An eager reader can materialize the complete file. An object-store reader instead opens a byte source and fetches the footer and region directories first. A BM25 query then reads the posting lists for its terms. A vector query reads the selected cluster ranges. The rest of the object stays in the bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  From one file to one table
&lt;/h2&gt;

&lt;p&gt;A table is a manifest over many immutable superfiles. Before opening any file, the manifest uses scalar ranges, term-presence summaries, and vector routing data to reject files that cannot contribute to the query. The surviving files execute their local search, and the table layer merges the results.&lt;/p&gt;

&lt;p&gt;The file and table layers therefore solve different problems: the superfile keeps data and indexes atomic; the manifest makes many superfiles behave as one snapshot.&lt;/p&gt;

&lt;p&gt;The format is intentionally simple at its boundary: Parquet row groups, opaque search regions, and a standard footer describing both. Infino adds the search access paths Parquet lacks while keeping data and indexes in one immutable file.&lt;/p&gt;

</description>
      <category>parquet</category>
      <category>database</category>
    </item>
    <item>
      <title>Cutting a ten-term BM25 OR from 74.6 ms to 20.0 ms</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/cutting-a-ten-term-bm25-or-from-746-ms-to-200-ms-530c</link>
      <guid>https://dev.to/ashish_mishra_26/cutting-a-ten-term-bm25-or-from-746-ms-to-200-ms-530c</guid>
      <description>&lt;p&gt;&lt;time&gt;Jun 23, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;On a warm 10-million-document real-cloud benchmark, a ten-term BM25 OR took &lt;strong&gt;74.6 ms&lt;/strong&gt;. Five terms took 24.4 ms; three common terms took 14.3 ms. The postings were not expensive to decode. The time was going into coordinating the posting lists one candidate document at a time.&lt;/p&gt;

&lt;p&gt;10m-common-term-or.txt&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query shape maxscore windowed reduction
3 similar terms 14.3 ms 8.2 ms 43%
5 similar terms 24.4 ms 12.0 ms 51%
10 similar terms 74.6 ms 20.0 ms 73%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure Standard_D8s_v7 · 8 vCPU / 31 GiB · warm p50 · baseline &lt;code&gt;a7528d2&lt;/code&gt; · windowed scorer shipped in PR #280&lt;/p&gt;

&lt;p&gt;For faster profiling iterations, we repeated the same query family on a one-million-document in-memory tier and timed decode separately from merge and score. Decode was &lt;strong&gt;3.3%&lt;/strong&gt; of the ten-term query. Merge and score were &lt;strong&gt;96.7%&lt;/strong&gt;. Optimizing compression or I/O would not fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MaxScore stopped skipping
&lt;/h2&gt;

&lt;p&gt;Block-Max MaxScore is fast when one term has a meaningfully different upper bound. The low-scoring lists become non-essential, and whole blocks can be skipped once they cannot beat the current top-k threshold.&lt;/p&gt;

&lt;p&gt;Common-term ORs have nearly uniform upper bounds. No term dominates, the essential set stays large, and block maxima are close to term maxima. The skip tests barely fire. MaxScore degrades into scoring almost the entire union while repeatedly finding the smallest document id across all active cursors, chasing their pointers, and touching the top-k heap.&lt;/p&gt;

&lt;p&gt;Decode time stayed roughly flat at 0.4 to 1.0 ms as the query grew. The per-document merge grew from 5.7 ms to 10 ms to 30.5 ms. That was the part to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Score one flat window at a time
&lt;/h2&gt;

&lt;p&gt;The replacement processes a 4096-document range at a time. Each term walks its postings in that range and adds its BM25 contribution to a flat score array. A presence bitset records which document ids appeared. After every term has contributed, the scorer drains the set bits in document order into the existing top-k heap, then reuses the buffers for the next range.&lt;/p&gt;

&lt;p&gt;The amount of scoring is unchanged when nothing can be skipped. What disappears is the per-document N-way cursor merge. Term-at-a-time writes are sequential and cache-friendly; the ordered drain happens once per window.&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%2Fx0hu0i4y1swuetz17bpo.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%2Fx0hu0i4y1swuetz17bpo.png" alt="Each posting list writes hits into a 4096-document window; the window drains into the top-k heap, then the next window starts" width="800" height="211"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The lists advance together. Hits land in a 4096-document window, drain into the heap, then the next window starts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At 10 million documents, the ten-term query fell from &lt;strong&gt;74.6 ms to 20.0 ms&lt;/strong&gt; , a 73% reduction. Five terms fell from 24.4 ms to 12.0 ms; three common terms fell from 14.3 ms to 8.2 ms. A brute-force oracle confirmed that the top-k results stayed identical across term counts, negation, and k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dispatch by query shape
&lt;/h2&gt;

&lt;p&gt;Windowing complements MaxScore. The dispatcher chooses between them from the shape of the upper bounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single-term queries keep the block-skipping path.&lt;/li&gt;
&lt;li&gt;Two-term ORs with a rare anchor and bounded top-k use the WAND/BMW path, where the short posting list provides a useful pivot.&lt;/li&gt;
&lt;li&gt;AND queries keep leapfrog intersection.&lt;/li&gt;
&lt;li&gt;Multi-term ORs with a dominant upper bound stay on Block-Max MaxScore, where pruning is effective.&lt;/li&gt;
&lt;li&gt;Multi-term ORs that pass the union-bound gates use the windowed scorer, where per-document coordination is the larger cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each scorer wins on a different disjunction shape. MaxScore wins when bounds create skips; a flat window wins when the union must be scored anyway.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
    <item>
      <title>The 3 to 10 regression from running decode on tokio’s I/O pool</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/the-3x-to-10x-regression-from-running-decode-on-tokios-io-pool-3kgn</link>
      <guid>https://dev.to/ashish_mishra_26/the-3x-to-10x-regression-from-running-decode-on-tokios-io-pool-3kgn</guid>
      <description>&lt;p&gt;&lt;time&gt;May 19, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;Infino’s public API is synchronous, while its storage I/O is asynchronous. A query may look like one blocking function call to the caller, but underneath it drives object-store reads through tokio and CPU-heavy decode through a sync-to-async bridge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong pool
&lt;/h2&gt;

&lt;p&gt;Cold vector search had been about &lt;strong&gt;1.1 s&lt;/strong&gt;. After decode moved onto the tokio runtime that issued the storage reads, the same work took &lt;strong&gt;3.7 s to 11 s&lt;/strong&gt; : a &lt;strong&gt;3× to 10×&lt;/strong&gt; regression.&lt;/p&gt;

&lt;p&gt;The cause was scheduler starvation. Tokio tasks yield at await points; decode has none. Once a worker began decoding a fetched block, it held that thread until the CPU work finished. Other tasks assigned to the same worker could not issue their next object-store reads, so CPU work serialized the I/O it was supposed to consume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokio for I/O, rayon for CPU
&lt;/h2&gt;

&lt;p&gt;The bridge now hands decode to rayon and awaits the result through a oneshot channel. Tokio’s workers remain available to schedule reads, while rayon’s bounded pool handles the data-parallel work. No thread acts as both an async-runtime driver and a rayon worker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spawn_blocking&lt;/code&gt; would move work off the async workers, but onto tokio’s separate blocking pool. Decode is short, bounded, and data-parallel, so rayon gives it the pool and work-stealing policy we want without creating a second async runtime.&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%2Fjm5agjcymjerj25em607.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%2Fjm5agjcymjerj25em607.png" alt="An async task on the tokio I/O pool hands CPU work to the rayon pool and awaits a oneshot channel for the result" width="799" height="185"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An async task hands CPU work to rayon and awaits a oneshot channel, so the tokio I/O threads never block on decode.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The range-fetch bridge has three caller contexts
&lt;/h2&gt;

&lt;p&gt;Lazy object-store range reads can be requested from a multi-thread tokio runtime, a current-thread test runtime, or a plain rayon/CLI thread with no runtime. That &lt;code&gt;Send + 'static&lt;/code&gt; bridge takes a different path in each case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With an ambient multi-thread runtime, &lt;code&gt;block_in_place&lt;/code&gt; tells the scheduler that this thread will block, allowing it to move other async work elsewhere.&lt;/li&gt;
&lt;li&gt;With no ambient runtime, a current-thread runtime polls the storage futures inline. There is no worker-thread handoff to coordinate.&lt;/li&gt;
&lt;li&gt;With an ambient current-thread runtime, &lt;code&gt;block_in_place&lt;/code&gt; is illegal. The bridge runs the operation on a dedicated worker thread instead of blocking the only runtime thread.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other sync-to-async entry points use a narrower contract. The main query path receives Infino’s shared multi-thread I/O runtime and always drives the future there; an ambient runtime only determines whether &lt;code&gt;block_in_place&lt;/code&gt; is needed. The three-way bridge above is reserved for range-fetch futures that can safely move to a dedicated thread.&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%2Fw36zsuxaj71ajkv7z0zo.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%2Fw36zsuxaj71ajkv7z0zo.png" alt="Three branches of the sync-to-async bridge depending on which tokio runtime is ambient" width="800" height="241"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The range-fetch bridge picks one of three paths depending on which tokio runtime, if any, is already running.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The second regression
&lt;/h2&gt;

&lt;p&gt;An earlier version of the generic no-runtime path had another avoidable handoff. It reused one multi-thread runtime pinned to a single worker. Reuse sounded cheaper than building a runtime per call, but every &lt;code&gt;block_on&lt;/code&gt; poll had to coordinate with that worker. A current-thread runtime polls the same future inline.&lt;/p&gt;

&lt;p&gt;The shared runtime added &lt;strong&gt;6 to 17 percent&lt;/strong&gt; to multi-term full-text queries at 10 million documents, with the penalty increasing as the query fanned out. Single-term queries were flat. Building a current-thread runtime for the no-runtime case removed that coordination cost.&lt;/p&gt;

&lt;p&gt;The rule is now explicit: tokio schedules object-store I/O, rayon performs decode, and the bridge adapts to the caller without mixing those responsibilities.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
    <item>
      <title>The three costs of a cold query on S3</title>
      <dc:creator>Ashish Mishra</dc:creator>
      <pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ashish_mishra_26/the-three-costs-of-a-cold-query-on-s3-2jc9</link>
      <guid>https://dev.to/ashish_mishra_26/the-three-costs-of-a-cold-query-on-s3-2jc9</guid>
      <description>&lt;p&gt;&lt;time&gt;Apr 28, 2026&lt;/time&gt; · engineering&lt;/p&gt;

&lt;p&gt;Object storage holds Infino’s durable copy; compute and local cache can disappear. The first query on a new worker therefore has to discover each file’s layout, open enough connections to read many files concurrently, and populate cache without downloading the whole dataset.&lt;/p&gt;

&lt;p&gt;On a 10-million-document table in us-east-1, opening one cold superfile took about &lt;strong&gt;164 ms&lt;/strong&gt; ; a fresh table with a couple hundred files took about &lt;strong&gt;556 ms&lt;/strong&gt;. Those numbers reflect three categories of cold-path work.&lt;/p&gt;

&lt;p&gt;These delays overlap: file opens fan out, cache state differs by file, and connection setup runs concurrently. Together they are the three places a first query can be forced to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Discover the file in one GET
&lt;/h2&gt;

&lt;p&gt;Parquet puts its footer at the end of the object. On local disk that is one seek. A textbook S3 reader sends a HEAD request to learn the object size, then a range GET for the tail: two round trips before it knows where the columns and indexes live.&lt;/p&gt;

&lt;p&gt;S3 suffix ranges remove the HEAD. A request for &lt;code&gt;bytes=-N&lt;/code&gt; returns the tail and reports the full object size in &lt;code&gt;Content-Range&lt;/code&gt;. Infino starts with a &lt;strong&gt;64 KiB&lt;/strong&gt; suffix, enough for the common footer. A targeted second GET runs only when the footer begins before that window.&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%2F1txowzaeajo92nff7vjy.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%2F1txowzaeajo92nff7vjy.png" alt="A single suffix-range GET returns both the file footer and the total object size; a second GET happens only when the footer is larger than the speculated window" width="800" height="171"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One suffix-range GET returns the tail and the object size. A second GET fires only when the footer started before the 64 KiB window.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In-region, the suffix request removed about &lt;strong&gt;25 to 50 ms&lt;/strong&gt; from a cold open. When the manifest already records exact ranges, the footer and index metadata can be fetched in one parallel batch. Small superfiles can carry their entire open window in that read.&lt;/p&gt;

&lt;p&gt;Azure Blob does not accept the same suffix-range request, so a standalone Azure open still uses HEAD plus a bounded read. A table manifest already records the object size, which removes that extra request from the normal query path.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Keep connections ready for fan-out
&lt;/h2&gt;

&lt;p&gt;A query may open hundreds of superfiles concurrently. Saving one metadata request does little if every range read first establishes TCP and TLS. Connection setup adds another serialized round trip and becomes visible in the tail.&lt;/p&gt;

&lt;p&gt;The pool keeps up to &lt;strong&gt;1024&lt;/strong&gt; idle connections per host so fan-out can reuse sockets. S3 connections expire from the pool after &lt;strong&gt;10 seconds&lt;/strong&gt; , below S3’s roughly 20-second server-side idle close; a &lt;strong&gt;5-second&lt;/strong&gt; connect timeout prevents one stalled handshake from holding the query. Azure uses a 90-second pool timeout to stay below its longer server-side window.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Drop RAM without dropping the file
&lt;/h2&gt;

&lt;p&gt;After a superfile is cached locally, Infino memory-maps it. That consumes two different budgets: resident pages in RAM and the file on local disk. An idle-threshold sweep (and a budget sweep when a memory cap is configured) uses &lt;code&gt;MADV_DONTNEED&lt;/code&gt; to drop resident pages while retaining the disk file, so the next access faults from local storage rather than S3.&lt;/p&gt;

&lt;p&gt;Pinning every file in RAM would remove that fault, but it would also recreate the always-on storage cost this architecture is meant to avoid. The disk copy is the middle state: slower than resident pages, faster than another object-store read, and disposable.&lt;/p&gt;

&lt;p&gt;The cache files are immutable and mapped read-only. POSIX also keeps an existing mapping valid after its file is unlinked, until the last reference disappears. A running query can therefore finish while the cache evicts the file from its directory.&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%2F4dpquszr9484m2uzmyt8.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%2F4dpquszr9484m2uzmyt8.png" alt="A memory-mapped file occupies both a RAM budget and a disk budget; MADV_DONTNEED drops its pages from RAM but keeps the file on local disk so it re-faults from disk rather than S3" width="800" height="151"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two budgets. Dropping pages from RAM leaves the local file, so the next touch skips the download.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the design stops working
&lt;/h2&gt;

&lt;p&gt;The cold-open cost is paid when a worker encounters files it has not seen before. Repeated queries reuse the open metadata, connections, disk files, and resident pages. The architecture works when that cache has time to become useful.&lt;/p&gt;

&lt;p&gt;On the same 10-million-document table, a cold analytic that touched every segment ran as high as &lt;strong&gt;eight seconds&lt;/strong&gt;. A workload made entirely of first-touch scans is the wrong fit. For tables that should become warm, prefetch runs at a bounded fan-out of &lt;strong&gt;8&lt;/strong&gt; , filling the local cache without sending every range request at once.&lt;/p&gt;

&lt;p&gt;Object storage becomes practical as the durable database only when the query path bounds its requests, reuses its sockets, and treats local disk as a disposable cache.&lt;/p&gt;

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