<?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: Sergey Nikolaev</title>
    <description>The latest articles on DEV Community by Sergey Nikolaev (@sanikolaev).</description>
    <link>https://dev.to/sanikolaev</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%2F363352%2F6f7a2da7-fa00-47f5-aaca-a007b1d43350.jpeg</url>
      <title>DEV Community: Sergey Nikolaev</title>
      <link>https://dev.to/sanikolaev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sanikolaev"/>
    <language>en</language>
    <item>
      <title>Manticore Search 28.4.4: Faster KNN, better conversational search, easier installs and more faceting controls</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Tue, 14 Jul 2026 11:24:55 +0000</pubDate>
      <link>https://dev.to/sanikolaev/manticore-search-2844-faster-knn-better-conversational-search-easier-installs-and-more-32pf</link>
      <guid>https://dev.to/sanikolaev/manticore-search-2844-faster-knn-better-conversational-search-easier-installs-and-more-32pf</guid>
      <description>&lt;p&gt;&lt;a href="https://manticoresearch.com/install/" rel="noopener noreferrer"&gt;Manticore Search 28.4.4&lt;/a&gt; has been released. This release brings faster KNN rescoring, more flexible conversational search, a simpler install and upgrade path, better faceting controls, per-table relevance defaults, and fixes across authentication, replication, SQL compatibility, distributed queries, and columnar/KNN internals.&lt;/p&gt;

&lt;p&gt;This post is a catch-up for everything shipped from &lt;strong&gt;27.2.0 through 28.4.4&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Upgrade Notes
&lt;/h2&gt;

&lt;p&gt;Please review these before upgrading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;28.0.0 bumps the plugin ABI version &lt;code&gt;SPH_UDF_VERSION&lt;/code&gt; to 12.&lt;/strong&gt; External UDF, ranker, and token-filter plugin binaries must be rebuilt before loading them into this version. The change lets token-filter plugins receive long &lt;a href="https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#dict" rel="noopener noreferrer"&gt;&lt;code&gt;dict=keywords_32k&lt;/code&gt;&lt;/a&gt; tokens instead of silently bypassing values above the old 126-byte plugin limit. Existing table data and configuration remain compatible, and no index migration is required. Downgrade is possible if you also restore plugin binaries built for the older ABI and do not rely on the new long-token plugin behavior. (&lt;a href="https://github.com/manticoresoftware/manticoresearch/issues/4667" rel="noopener noreferrer"&gt;Issue #4667&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/pull/4668" rel="noopener noreferrer"&gt;PR #4668&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If authentication is enabled, prioritize the 28.4.x update.&lt;/strong&gt; 28.4.2 fixes an authentication/authorization permission-check bypass in MySQL multi-statement execution. 28.3.4 also improves authenticated MySQL startup compatibility for Connector/J and PyMySQL, and 28.4.1 fixes authenticated replication-cluster restart recovery by persisting the stored replication user in cluster metadata. (&lt;a href="https://github.com/manticoresoftware/manticoresearch/pull/4713" rel="noopener noreferrer"&gt;PR #4713&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/issues/4691" rel="noopener noreferrer"&gt;Issue #4691&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/issues/4705" rel="noopener noreferrer"&gt;Issue #4705&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you manage MCL separately from the daemon, upgrade it together with Manticore.&lt;/strong&gt; This release includes &lt;a href="https://github.com/manticoresoftware/columnar" rel="noopener noreferrer"&gt;MCL&lt;/a&gt; 13.7.0, the new &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#embeddings_threads" rel="noopener noreferrer"&gt;&lt;code&gt;embeddings_threads&lt;/code&gt;&lt;/a&gt; setting, and several columnar/KNN build, packaging, and cleanup fixes. Mixing an older library with a newer daemon is not recommended. (&lt;a href="https://github.com/manticoresoftware/columnar/pull/169" rel="noopener noreferrer"&gt;PR #169&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/columnar/pull/186" rel="noopener noreferrer"&gt;PR #186&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/pull/4676" rel="noopener noreferrer"&gt;PR #4676&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Faster KNN rescoring
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Searching/KNN#KNN-vector-search" rel="noopener noreferrer"&gt;KNN search&lt;/a&gt; now batches distance calculations during the &lt;code&gt;rescore&lt;/code&gt; pass. After HNSW returns the candidate set, Manticore recomputes final full-precision distances and re-sorts the results. Batching that work reduces per-candidate overhead in the final stage of vector search.&lt;/p&gt;

&lt;p&gt;For vector-heavy workloads, this takes work out of the part of the query that runs after candidate selection. Results do not change; the final ranking pass just has less overhead when many candidates are rescored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conversational search through SQL and HTTP
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Searching/Conversational_search" rel="noopener noreferrer"&gt;Conversational search&lt;/a&gt; is now available through the &lt;code&gt;/search&lt;/code&gt; JSON API as well as SQL &lt;code&gt;CALL CHAT&lt;/code&gt;. That makes it easier to use Manticore's chat flow from applications that already talk to the HTTP API and do not want to add a separate SQL path just for chat requests.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CREATE CHAT MODEL&lt;/code&gt; also gained &lt;code&gt;custom_prompt&lt;/code&gt; support, so answers can follow application-specific instructions such as citation rules, tone, response length, or formatting. The feature is still built on the same Manticore Search flow: retrieve relevant documents from an existing vectorized table, build context, keep conversation history, and return an answer with supporting sources.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-line installation
&lt;/h3&gt;

&lt;p&gt;The quick-start install path is now simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://manticoresearch.com | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same installer can also upgrade an existing installation, list available versions, switch between stable and development repositories, and install a selected version. Package managers still remain the source of truth for installed files, repositories, services, and dependencies; the new path just removes the manual setup steps around them.&lt;/p&gt;

&lt;p&gt;For all options, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://manticoresearch.com | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Facets can keep zero-count buckets visible
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Searching/Faceted_search" rel="noopener noreferrer"&gt;Faceted search&lt;/a&gt; now supports zero-count facet buckets through SQL &lt;code&gt;ZEROES&lt;/code&gt; and JSON &lt;code&gt;"zeroes": true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a small but important UI feature. In e-commerce-style filtering, you often want to keep an option visible even when the current filter combination gives it a count of &lt;code&gt;0&lt;/code&gt;. Combined with &lt;code&gt;max&lt;/code&gt;-mode facet behavior, zero-count buckets make it easier to show selected, available, and currently unavailable choices without hiding part of the filter vocabulary from the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  Better defaults for search relevance
&lt;/h3&gt;

&lt;p&gt;Manticore now supports &lt;a href="https://manual.manticoresearch.com/Creating_a_table/Local_tables/Plain_and_real-time_table_settings#profile" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE TABLE ... profile='relevance'&lt;/code&gt;&lt;/a&gt;, plus stored per-table defaults for &lt;a href="https://manual.manticoresearch.com/Searching/Options#ranker" rel="noopener noreferrer"&gt;&lt;code&gt;ranker&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://manual.manticoresearch.com/Searching/Options#boolean_mode" rel="noopener noreferrer"&gt;&lt;code&gt;boolean_mode&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Based on our search quality tests, &lt;code&gt;profile='relevance'&lt;/code&gt; and the ranking settings it enables improve relevance in many cases. The application also no longer needs to repeat the same ranking parameters in every request.&lt;/p&gt;

&lt;h3&gt;
  
  
  More control over embedding CPU usage
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#embeddings_threads" rel="noopener noreferrer"&gt;&lt;code&gt;embeddings_threads&lt;/code&gt;&lt;/a&gt; caps the CPU threads used for auto-embedding inserts, &lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt;, and text-to-vector KNN queries.&lt;/p&gt;

&lt;p&gt;This matters on shared hosts and mixed workloads. Embedding generation and KNN rebuilds can be CPU-heavy; a server-level cap makes those jobs easier to schedule without letting them take over the whole machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug Fixes
&lt;/h2&gt;

&lt;p&gt;This release includes &lt;strong&gt;17 bug fixes&lt;/strong&gt;. The most important ones are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;28.4.3 fixed incompatible multi-statement handling around &lt;a href="https://manual.manticoresearch.com/Searching/Grouping#COUNT%28DISTINCT-field%29" rel="noopener noreferrer"&gt;&lt;code&gt;COUNT(DISTINCT ...)&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;28.4.2 fixed an authentication/authorization permission-check bypass in MySQL multi-statement execution, so each statement in a multi-statement request is validated correctly under auth.&lt;/li&gt;
&lt;li&gt;28.4.1 and 28.3.1 fixed replication-cluster recovery edge cases: authenticated cluster restart recovery and startup when a node was left alone in the cluster.&lt;/li&gt;
&lt;li&gt;Distributed queries with remote stored fields now fail on remote &lt;code&gt;GETFIELD&lt;/code&gt; fetch errors or malformed replies instead of returning apparently successful rows with empty or untrusted stored-field values.&lt;/li&gt;
&lt;li&gt;Interrupted columnar/KNN merge cleanup now removes temporary component files, preventing orphaned &lt;code&gt;.tmp.spc.*&lt;/code&gt; files from breaking later table rename, attach, or drop operations.&lt;/li&gt;
&lt;li&gt;DBeaver compatibility improved by accepting simple single-table aliases in &lt;code&gt;SELECT&lt;/code&gt; queries.&lt;/li&gt;
&lt;li&gt;Connector/J and PyMySQL clients can now complete authenticated native-password login flows, and harmless session &lt;code&gt;SET&lt;/code&gt; statements no longer fail under auth.&lt;/li&gt;
&lt;li&gt;The built-in Ukrainian lemmatizer now normalizes apostrophe words correctly, so forms such as &lt;code&gt;здоров'ям&lt;/code&gt; match &lt;code&gt;здоров'я&lt;/code&gt; under &lt;code&gt;lemmatize_uk_all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Blended-keyword handling now honors the configured &lt;code&gt;blend_mode&lt;/code&gt;, restoring separator-stripped variants consistently for indexing and keyword extraction.&lt;/li&gt;
&lt;li&gt;RT auto-optimization no longer compacts a table below two disk chunks unless that lower cutoff is explicitly requested.&lt;/li&gt;
&lt;li&gt;A crash involving &lt;code&gt;percentiles&lt;/code&gt; aggregations together with terms aggregations in the same &lt;code&gt;/search&lt;/code&gt; request on multi-chunk RT tables was fixed.&lt;/li&gt;
&lt;li&gt;Long SQL parse errors now preserve UTF-8 character boundaries, so invalid queries containing Cyrillic and other multibyte text no longer produce truncated or corrupted error messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the complete list, see the &lt;a href="https://manual.manticoresearch.com/Changelog#Version-28.4.4" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need help or want to connect?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Join our &lt;a href="https://slack.manticoresearch.com" rel="noopener noreferrer"&gt;Slack&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Visit the &lt;a href="https://forum.manticoresearch.com" rel="noopener noreferrer"&gt;Forum&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Report issues or suggest features on &lt;a href="https://github.com/manticoresoftware/manticoresearch/issues" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email us at &lt;code&gt;contact@manticoresearch.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>machinelearning</category>
      <category>news</category>
    </item>
    <item>
      <title>Sharding in Manticore Search: automatic distribution and replication</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Fri, 03 Jul 2026 03:32:41 +0000</pubDate>
      <link>https://dev.to/sanikolaev/sharding-in-manticore-search-automatic-distribution-and-replication-12ha</link>
      <guid>https://dev.to/sanikolaev/sharding-in-manticore-search-automatic-distribution-and-replication-12ha</guid>
      <description>&lt;p&gt;Search systems often start simple: one table on one server. That works until one of two things happens. Either a single query stops being able to use all the CPU you paid for, or a single server stops being enough — for capacity, for throughput, or for the simple fact that a server can fail and take your data with it.&lt;/p&gt;

&lt;p&gt;The automatic sharding built into Manticore Search, available since release &lt;a href="https://dev.to/blog/manticore-search-27-1-5/"&gt;27.1.5&lt;/a&gt;, addresses both issues by &lt;strong&gt;splitting a table into several smaller physical pieces (shards), that can be searched in parallel and placed on different nodes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On a single node&lt;/strong&gt;, sharding spreads concurrent writes across independent pieces and keeps each one small enough to stay fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Across a cluster&lt;/strong&gt;, sharding distributes data over multiple nodes and — this is the main point — &lt;strong&gt;automatically replicates each shard and keeps that replication factor intact&lt;/strong&gt; as nodes fail and recover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second part is the real reason most people reach for sharding: high availability. You declare how many shards you want and how many copies of each should exist, and Manticore handles placement, replication, and rebalancing. You don't script failover.&lt;/p&gt;

&lt;p&gt;Below: both use cases, the machinery without drowning in internals, the commands you'll run, and the current limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short glossary
&lt;/h2&gt;

&lt;p&gt;Key terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shard&lt;/td&gt;
&lt;td&gt;One physical piece of a table — a real table that Manticore creates and manages for you. A table with &lt;code&gt;shards='4'&lt;/code&gt; has four of them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replica&lt;/td&gt;
&lt;td&gt;A copy of a shard on another node. Replicas are how data survives a node failure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replication factor (RF)&lt;/td&gt;
&lt;td&gt;How many nodes hold a copy of each shard. &lt;code&gt;rf='2'&lt;/code&gt; means every shard exists on two nodes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distributed table&lt;/td&gt;
&lt;td&gt;The table you actually query. It has the name you gave it and transparently fans queries out to all shards.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster&lt;/td&gt;
&lt;td&gt;A Manticore replication cluster — the group of nodes between which data is replicated.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Master&lt;/td&gt;
&lt;td&gt;The node that currently coordinates sharding operations (placement, rebalancing). Elected automatically.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebalancing&lt;/td&gt;
&lt;td&gt;The automatic process that moves or copies shards when the set of nodes changes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to create a sharded table
&lt;/h2&gt;

&lt;p&gt;Sharding in Manticore is driven entirely by two simple options on &lt;code&gt;CREATE TABLE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;shards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'4'&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'2'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shards='N'&lt;/code&gt; — split the table into &lt;code&gt;N&lt;/code&gt; physical pieces.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rf='M'&lt;/code&gt; — keep &lt;code&gt;M&lt;/code&gt; copies of each piece across the cluster (the replication factor).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the common case, that one &lt;code&gt;CREATE TABLE&lt;/code&gt; is all you write. There is no separate "make this distributed" step, no manual &lt;code&gt;agent=&lt;/code&gt; lists as in older manual sharding setups, and no per-node table creation. Manticore creates the physical shards, places them, sets up replication, and creates a distributed table named &lt;code&gt;products&lt;/code&gt; on every node, so the application can use the same table name from any cluster node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case A: sharding on a single node
&lt;/h2&gt;

&lt;p&gt;Start with the case where you have a single server — perhaps a big, many-core one — and no cluster yet. In this setup, sharding is not about storage durability; it helps use that one machine more effectively. If all writes go into one real-time table, concurrent INSERTs contend on the same internal table locks. As the table grows, &lt;a href="https://manual.manticoresearch.com/Securing_and_compacting_a_table/Compacting_a_table#Compacting-a-Table" rel="noopener noreferrer"&gt;RAM-chunk merges&lt;/a&gt; get heavier and can slow down ingestion. Splitting that table into several independent shards helps on both fronts: writes spread across the shards, and each piece stays small. High availability isn't part of the picture yet — that needs more than one node — so this is purely a performance play.&lt;/p&gt;

&lt;p&gt;The simplest form has no cluster and &lt;code&gt;rf='1'&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;shards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'8'&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates eight physical shards on the one node and a distributed table &lt;code&gt;logs&lt;/code&gt; that points at all of them. How does that help on a single machine?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More concurrent ingestion.&lt;/strong&gt; Each shard is an independent real-time table, so concurrent writes spread across them instead of serializing on one table's locks — the win the benchmarks below measure directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller pieces stay fast.&lt;/strong&gt; Real-time tables periodically merge their internal RAM chunks. A table split into shards keeps each shard's chunks smaller, so those merges use fewer resources and are less likely to slow inserts down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query parallelism (usually a small gain on one node).&lt;/strong&gt; A distributed table searches its shards in parallel across the server's worker thread pool, so a single query can use several cores instead of one — bounded by &lt;code&gt;searchd.threads&lt;/code&gt; and the number of physical cores. On a single node, though, this overlaps with &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#pseudo_sharding" rel="noopener noreferrer"&gt;pseudo-sharding&lt;/a&gt;, and the gain is usually small (~5–12%) — see the read benchmarks below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've used Manticore's &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#pseudo_sharding" rel="noopener noreferrer"&gt;pseudo-sharding&lt;/a&gt; before, the goal may be familiar — use all the cores for one query — but the mechanism is different. Pseudo-sharding parallelizes a &lt;em&gt;single&lt;/em&gt; physical table automatically at query time. Explicit sharding creates &lt;em&gt;real&lt;/em&gt; shards you control: you decide how many, they're separate tables you can reason about, and — crucially — the &lt;em&gt;same&lt;/em&gt; sharded table can later be spread across nodes without changing how your application talks to it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The two are complementary, but they don't stack for free. Physical sharding — a distributed table over several local tables — already keeps the worker threads busy, so if you've explicitly sharded a table, enabling &lt;code&gt;pseudo_sharding&lt;/code&gt; on top usually adds little and can even cost a bit of throughput. Test it both ways with &lt;a href="https://github.com/manticoresoftware/manticore-load" rel="noopener noreferrer"&gt;&lt;code&gt;manticore-load&lt;/code&gt;&lt;/a&gt;: run your workload with and without &lt;code&gt;pseudo_sharding&lt;/code&gt;, and if it adds nothing on top of explicit shards, turn it off.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On a single node the replication factor must be &lt;code&gt;1&lt;/code&gt;: there's only one node, so there's nowhere to put a second copy. That's also the catch — single-node sharding gives you parallelism, not durability. For durability you need more than one node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case B: multi-node sharding and automatic replication
&lt;/h2&gt;

&lt;p&gt;This is what sharding is really for. Start from a replication cluster of several nodes (see &lt;a href="https://manual.manticoresearch.com/Creating_a_cluster/Setting_up_replication/Setting_up_replication" rel="noopener noreferrer"&gt;Setting up replication&lt;/a&gt; for how to create one), then create the table inside that cluster with the &lt;code&gt;cluster:&lt;/code&gt; prefix and an RF greater than 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;mycluster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;shards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'4'&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'2'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what Manticore does for you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates four shards.&lt;/li&gt;
&lt;li&gt;Places them across the cluster's nodes in a balanced way.&lt;/li&gt;
&lt;li&gt;Creates a &lt;strong&gt;second copy of every shard on a different node&lt;/strong&gt;, because &lt;code&gt;rf='2'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Wires up replication between each shard and its replica.&lt;/li&gt;
&lt;li&gt;Creates a distributed table &lt;code&gt;products&lt;/code&gt; on every node, so any node can serve reads and accept writes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From the application's point of view, nothing changed — you still &lt;code&gt;INSERT INTO products …&lt;/code&gt; and &lt;code&gt;SELECT … FROM products&lt;/code&gt;. Reads fan out across the shards and the results are merged; writes are routed to a shard. But now every shard lives on two nodes, and that's the property you care about: &lt;strong&gt;any single node can fail and the table stays fully available with no data loss.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The replication factor scales with your durability needs and your node count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RF&lt;/th&gt;
&lt;th&gt;Copies per shard&lt;/th&gt;
&lt;th&gt;Survives&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;nothing — a lost node loses its shards&lt;/td&gt;
&lt;td&gt;single-node parallelism, dev/test, data you can rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;one node failure&lt;/td&gt;
&lt;td&gt;the common production choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3+&lt;/td&gt;
&lt;td&gt;3 or more&lt;/td&gt;
&lt;td&gt;multiple simultaneous failures&lt;/td&gt;
&lt;td&gt;mission-critical, frequent-failure environments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The constraint is simple: &lt;strong&gt;you can't ask for more copies than you have nodes.&lt;/strong&gt; &lt;code&gt;rf='3'&lt;/code&gt; needs at least three nodes in the cluster. Manticore checks this when you create the table and tells you if the cluster is too small.&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;-- 6 shards, 3 copies each, across a 3+ node cluster&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;mycluster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;shards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'6'&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'3'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Putting it together: a multi-node walkthrough
&lt;/h2&gt;

&lt;p&gt;Say you have a three-node replication cluster called &lt;code&gt;mycluster&lt;/code&gt; (if you don't yet, &lt;a href="https://manual.manticoresearch.com/Creating_a_cluster/Setting_up_replication/Setting_up_replication" rel="noopener noreferrer"&gt;Setting up replication&lt;/a&gt; walks through &lt;code&gt;CREATE CLUSTER&lt;/code&gt; and &lt;code&gt;JOIN CLUSTER&lt;/code&gt;). Create a sharded, replicated table from any node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;mycluster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;shards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'4'&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'2'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Manticore creates four shards, puts two copies of each across the three nodes, and a distributed table &lt;code&gt;products&lt;/code&gt; on every node. Check the placement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;SHARDING&lt;/span&gt; &lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- illustrative output (abbreviated columns)
+-------+-------+--------+----+-----------+
| shard | node  | status | rf | rf_status |
+-------+-------+--------+----+-----------+
|     0 | node1 | active |  2 | ok        |
|     0 | node2 | active |  2 | ok        |
|     1 | node2 | active |  2 | ok        |
|     1 | node3 | active |  2 | ok        |
|     2 | node1 | active |  2 | ok        |
|     2 | node3 | active |  2 | ok        |
|     3 | node1 | active |  2 | ok        |
|     3 | node2 | active |  2 | ok        |
+-------+-------+--------+----+-----------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every shard appears on two distinct nodes — that's &lt;code&gt;rf=2&lt;/code&gt; — and every &lt;code&gt;rf_status&lt;/code&gt; is &lt;code&gt;ok&lt;/code&gt;. (The full result also includes &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;cluster&lt;/code&gt;, and &lt;code&gt;replication_cluster&lt;/code&gt; columns.) Now use it like any other table, from any node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Wireless mouse'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mouse'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write is routed to a shard and replicated to that shard's other copy; the read fans out across all four shards and merges the results. Your application never names a shard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintaining the replication factor
&lt;/h2&gt;

&lt;p&gt;Setting &lt;code&gt;rf='2'&lt;/code&gt; is easy. The hard part in any distributed system is honoring that condition &lt;em&gt;over time&lt;/em&gt;, as machines fail and come back and as you add capacity. But you no longer have to worry about that. Manticore Search automates this work.&lt;/p&gt;

&lt;p&gt;How it works in Manticore is that the cluster elects a &lt;strong&gt;master&lt;/strong&gt; node that runs a coordination loop. It monitors the cluster's topology — which nodes are alive — and reacts to changes:&lt;/p&gt;

&lt;h4&gt;
  
  
  A node fails
&lt;/h4&gt;

&lt;p&gt;Its shards now have fewer copies than RF requires. The master detects the missing node and tries to rebuild the missing replicas. If the cluster still has at least &lt;code&gt;rf&lt;/code&gt; active nodes after the failure, it places new copies on active nodes that don't already hold them, restoring the replication factor. Queries keep working as long as at least one copy of each shard is still available.&lt;/p&gt;

&lt;p&gt;Continuing the walkthrough above — if &lt;code&gt;node3&lt;/code&gt; goes down, &lt;code&gt;SHOW SHARDING STATUS products&lt;/code&gt; shows the affected shards as &lt;code&gt;degraded&lt;/code&gt; (one copy down, one still up):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- illustrative: node3 is down
+-------+-------+----------+----+-----------+
| shard | node  | status   | rf | rf_status |
+-------+-------+----------+----+-----------+
|     1 | node2 | active   |  2 | degraded  |
|     1 | node3 | inactive |  2 | degraded  |
|     2 | node1 | active   |  2 | degraded  |
|     2 | node3 | inactive |  2 | degraded  |
|   ... | ...   | ...      |    | ...       |
+-------+-------+----------+----+-----------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are still two active nodes (&lt;code&gt;node1&lt;/code&gt;, &lt;code&gt;node2&lt;/code&gt;) and &lt;code&gt;rf=2&lt;/code&gt;, so the master creates the missing copies of shards 1 and 2 on the active node that lacks them. While a new copy is being built it shows up as &lt;code&gt;pending&lt;/code&gt;; once replication catches up it becomes &lt;code&gt;active&lt;/code&gt; and &lt;code&gt;rf_status&lt;/code&gt; returns to &lt;code&gt;ok&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important caveat: &lt;strong&gt;Manticore can only restore RF if there is somewhere to put the new copy.&lt;/strong&gt; If the active node count drops below &lt;code&gt;rf&lt;/code&gt;, the requested RF cannot be met yet: affected shards stay &lt;code&gt;degraded&lt;/code&gt; with their surviving copy until a node returns or you add one. Manticore won't create another copy of the same shard on the same node, and it won't silently pretend RF is met. If no live copy of a shard remains, its status becomes &lt;code&gt;broken&lt;/code&gt;; that case is covered below. For &lt;code&gt;rf='1'&lt;/code&gt;, a failed node's shards are simply gone — there was never a second copy.&lt;/p&gt;

&lt;h4&gt;
  
  
  A node joins
&lt;/h4&gt;

&lt;p&gt;New capacity should be used. The master rebalances so the new node takes its share of the load. How it does this depends on the RF:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RF = 1:&lt;/strong&gt; shards must be &lt;em&gt;moved&lt;/em&gt; (there's only one copy, so it can't just be duplicated). Manticore moves them safely using a temporary internal cluster: it copies the data to the new node first and removes it from the old one only after that, so the shard always has an available copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RF ≥ 2:&lt;/strong&gt; shards are &lt;em&gt;replicated&lt;/em&gt; to the new node using the cluster's existing replication, then the distribution is rebalanced. No risky data movement, because another copy always exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Every copy of a shard is down
&lt;/h4&gt;

&lt;p&gt;If all nodes holding a given shard are lost at once, that shard's &lt;code&gt;rf_status&lt;/code&gt; becomes &lt;code&gt;broken&lt;/code&gt; — there's no surviving copy to serve or to replicate from. The rest of the table keeps working; the broken shard recovers when one of its nodes returns. RF reduces the chance of this case: with &lt;code&gt;rf='2'&lt;/code&gt; it takes two simultaneous failures of the &lt;em&gt;right&lt;/em&gt; nodes, with &lt;code&gt;rf='3'&lt;/code&gt; three.&lt;/p&gt;

&lt;p&gt;All of this happens through an internal, ordered, rollback-aware operation queue, so a rebalancing operation either completes or is cleanly rolled back — even if the master node itself dies mid-operation, the next master cleans up the half-finished work. The point for you as an operator: &lt;strong&gt;you set RF once, and the cluster works to keep it true.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood (the short version)
&lt;/h2&gt;

&lt;p&gt;You don't need this to use sharding, but it helps to know what's happening.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Physical shards are real tables.&lt;/strong&gt; A table with four shards is backed by four real tables that Manticore creates and manages for you. You normally never touch them directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your application talks to a &lt;a href="https://manual.manticoresearch.com/Creating_a_table/Creating_a_distributed_table/Creating_a_distributed_table" rel="noopener noreferrer"&gt;distributed table&lt;/a&gt;.&lt;/strong&gt; Manticore creates one named &lt;code&gt;products&lt;/code&gt; on every node. In its internal definition, local shards are listed directly, and shards on other nodes are connected through &lt;code&gt;agent&lt;/code&gt;. That's what makes &lt;code&gt;SELECT … FROM products&lt;/code&gt; transparently hit everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordination state lives in the cluster.&lt;/strong&gt; Manticore tracks its own internal metadata — shard placement, coordination state, and the pending-operation queue — so it always knows who holds what and what work is still outstanding. In a multi-node setup this state is replicated across the cluster, so every node shares the same view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The master drives changes.&lt;/strong&gt; Placement, replication setup, and rebalancing are computed by the master and pushed onto the queue as ordered commands with rollback instructions, then executed across nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication reuses Manticore's clustering.&lt;/strong&gt; The same proven replication mechanism Manticore already uses for clusters keeps shard replicas in sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Architecturally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            CREATE TABLE ... shards='4' rf='2'
                          │
                          ▼
                  ┌────────────────┐
                  │ Manticore      │  computes placement,
                  │ elected master │  enqueues ordered ops
                  └────────┬───────┘
                          │
        ┌─────────────────┼─────────────────┐
        ▼                 ▼                 ▼
    ┌──────────┐     ┌──────────┐     ┌──────────┐
    │  node1   │     │  node2   │     │  node3   │
    │ s0 s2 s3 │◄───►│ s0 s1 s3 │◄───►│  s1 s2   │   (each shard on 2 nodes = rf 2)
    └──────────┘     └──────────┘     └──────────┘
    distributed table "products" exists on every node
    (same placement as the SHOW SHARDING STATUS output above)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Operating a sharded table
&lt;/h2&gt;

&lt;p&gt;Everything you'd expect to work, works — and there are a couple of sharding-specific commands for visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspect the schema.&lt;/strong&gt; &lt;code&gt;DESC&lt;/code&gt; and &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; work on the logical table; Manticore resolves them through the underlying shards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;See where every shard lives and whether the RF is healthy.&lt;/strong&gt; This is the command you'll watch during failures and rebalancing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;SHARDING&lt;/span&gt; &lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-------+-------+--------+----+-----------+
| shard | node  | status | rf | rf_status |
+-------+-------+--------+----+-----------+
|     0 | node1 | active |  2 | ok        |
|     0 | node2 | active |  2 | ok        |
|   ... | ...   | ...    |    | ...       |
+-------+-------+--------+----+-----------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reports one row per shard copy, with these columns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the logical table name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shard number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the node holding this copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;active&lt;/code&gt;, &lt;code&gt;inactive&lt;/code&gt; (node down), or &lt;code&gt;pending&lt;/code&gt; (being created)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cluster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the replication cluster the table belongs to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;replication_cluster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the internal cluster that keeps this shard's copies in sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;how many copies this shard currently has&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rf_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ok&lt;/code&gt; (RF satisfied), &lt;code&gt;degraded&lt;/code&gt; (some copies down but at least one up), or &lt;code&gt;broken&lt;/code&gt; (no copies up)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;rf_status&lt;/code&gt; is the at-a-glance health signal: all &lt;code&gt;ok&lt;/code&gt; means the cluster is meeting the replication factor you asked for; &lt;code&gt;degraded&lt;/code&gt; means it's working but exposed; &lt;code&gt;broken&lt;/code&gt; means a shard is down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find the coordinator:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;SHARDING&lt;/span&gt; &lt;span class="n"&gt;MASTER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Drop it cleanly.&lt;/strong&gt; Dropping a sharded table works exactly like dropping a regular table — &lt;code&gt;DROP TABLE&lt;/code&gt; removes the table and all its shards across the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scale by changing the cluster.&lt;/strong&gt; Because rebalancing is automatic, the way you scale a sharded table out is by adding nodes to the cluster (&lt;a href="https://manual.manticoresearch.com/Creating_a_cluster/Adding_a_new_node" rel="noopener noreferrer"&gt;Adding a new node&lt;/a&gt;). The master notices the new node and rebalances onto it without any action on the table itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the shard count and replication factor
&lt;/h2&gt;

&lt;p&gt;A few rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shards for faster writes:&lt;/strong&gt; a handful of shards is usually enough, well below your core count — in the benchmarks below a 16-core / 32-thread box peaked at &lt;strong&gt;4–8 shards&lt;/strong&gt; and by 32 shards was &lt;em&gt;slower than no sharding at all&lt;/em&gt;. Start small (4–8), measure, and only add more if your own numbers say so. More shards than that rarely helps and adds per-shard overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shards for distribution:&lt;/strong&gt; with multiple nodes, you want enough shards that they divide evenly across nodes and leave room to grow — a multiple of your node count is a good default. Don't go wild: each shard is a real table with its own overhead. (Manticore caps the shard count at &lt;strong&gt;3000&lt;/strong&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RF for durability:&lt;/strong&gt; &lt;code&gt;rf='2'&lt;/code&gt; is the standard production choice — it survives any single node failure at 2× storage. Use &lt;code&gt;rf='3'&lt;/code&gt; only when you genuinely need to survive simultaneous failures or have strict availability requirements, and remember it costs 3× the storage and more replication traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RF=1 is for performance or throwaway data only.&lt;/strong&gt; It has no fault tolerance. Use it on a single node for parallelism, or in a cluster only when you have an external way to rebuild lost data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Benchmarks: does sharding actually speed up inserts?
&lt;/h2&gt;

&lt;p&gt;A feature is only worth using if it earns its keep, so we measured. The question we wanted answered honestly: &lt;strong&gt;for the same workload, does sharding make ingestion faster — and if so, when?&lt;/strong&gt; The methodology was simple: compare every sharded run against the same baseline — a regular table without sharding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; on a 16-core box with 32 concurrent writers, sharding raised insert throughput by about &lt;strong&gt;1.5×&lt;/strong&gt; at its best — from ~163k to ~253k docs/s — but only when the shard count stayed small. The best results came at &lt;strong&gt;4–8 shards&lt;/strong&gt;; by 32 shards, throughput had fallen &lt;em&gt;below&lt;/em&gt; the unsharded baseline. The binary log cost roughly &lt;strong&gt;25%&lt;/strong&gt; of write performance, and &lt;code&gt;rf=2&lt;/code&gt; replication across two real machines cost about &lt;strong&gt;30%&lt;/strong&gt; more — fair prices for durability, but not free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup.&lt;/strong&gt; A dedicated server with no other significant CPU load — AMD Ryzen 9 5950X (16 cores / 32 threads), 128 GB RAM. Everything ran inside a single Docker container running a recent dev build with the sharding feature. Manticore ran with &lt;strong&gt;stock settings — no performance tuning&lt;/strong&gt;: only listener ports, the data directory, and the binary log path were set; the thread pool, RT memory limits, and binary log behaviour were left at their defaults. Load came from &lt;a href="https://github.com/manticoresoftware/manticore-load" rel="noopener noreferrer"&gt;&lt;code&gt;manticore-load&lt;/code&gt;&lt;/a&gt;. Each run inserts the same documents — &lt;code&gt;(id bigint, name text, type int)&lt;/code&gt; where &lt;code&gt;name&lt;/code&gt; is 10–100 random words — in batches of 1000 into a real-time table. Only the &lt;strong&gt;shard count&lt;/strong&gt;, the &lt;strong&gt;replication factor&lt;/strong&gt;, and the &lt;strong&gt;binary log&lt;/strong&gt; change between runs; the "no sharding" baseline is a plain RT table. We run the full single-node shard sweep &lt;strong&gt;twice — once with the binary log on (the default), once with it off&lt;/strong&gt; — inserting &lt;strong&gt;20,000,000&lt;/strong&gt; docs per run with &lt;strong&gt;32 concurrent writers&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the exact shape of every insert run (shards/rf vary)&lt;/span&gt;
manticore-load &lt;span class="nt"&gt;--batch-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32 &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20000000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"create table test(id bigint, name text, type int) shards='8' rf='1'"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"insert into test(id,name,type) values(&amp;lt;increment&amp;gt;,'&amp;lt;text/10/100&amp;gt;',&amp;lt;int/1/100&amp;gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Single node: throughput vs shard count
&lt;/h3&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%2Fn9o2x51594mvacc1z4y3.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%2Fn9o2x51594mvacc1z4y3.png" alt=" " width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Docs inserted per second, 20M docs, 32 writers — both binary log modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shards&lt;/th&gt;
&lt;th&gt;binary log on (default)&lt;/th&gt;
&lt;th&gt;binary log off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none (baseline)&lt;/td&gt;
&lt;td&gt;162,920&lt;/td&gt;
&lt;td&gt;218,079&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;191,976&lt;/td&gt;
&lt;td&gt;246,288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;252,807&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;290,665&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;251,008&lt;/td&gt;
&lt;td&gt;265,015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;175,848&lt;/td&gt;
&lt;td&gt;182,288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;108,006&lt;/td&gt;
&lt;td&gt;111,381&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The chart plots the full sweep twice — binary log &lt;strong&gt;on&lt;/strong&gt; (blue, the default) and &lt;strong&gt;off&lt;/strong&gt; (orange). Three things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent inserts get a real boost.&lt;/strong&gt; With 32 writers, splitting the table lifts throughput by &lt;strong&gt;~1.5× at the peak&lt;/strong&gt; — from &lt;strong&gt;163k to 253k docs/s&lt;/strong&gt; on the default (binary-log-on) line. Each shard is an independent real-time table, so spreading writes across several sharply cuts the lock contention a single RT table hits under concurrency, and lets the inserts use more cores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The best range is small — 4–8 shards.&lt;/strong&gt; The gain peaks there and then falls off fast. By 16 shards it's barely above baseline, and &lt;strong&gt;at 32 shards throughput is &lt;em&gt;below&lt;/em&gt; the unsharded table (0.66×)&lt;/strong&gt; — past that range, per-shard and coordination overhead outweighs the extra parallelism. More shards is emphatically &lt;em&gt;not&lt;/em&gt; better. Both lines have the &lt;strong&gt;same shape and the same best range&lt;/strong&gt; — the binary log doesn't move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durability costs most where you're fastest.&lt;/strong&gt; Turning the binary log off (orange) lifts the whole curve, but the gap is widest in the high-throughput region — &lt;strong&gt;253k → 291k at 4 shards&lt;/strong&gt; — and nearly vanishes once the shard count is too high (&lt;strong&gt;108k vs 111k at 32 shards&lt;/strong&gt;), where coordination overhead, not durability, is the bottleneck. We measure the binary-log impact separately below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat worth stating plainly: this speedup comes from &lt;strong&gt;concurrent writes&lt;/strong&gt;. A single, strictly-sequential writer can't exploit parallel shards and will see no speedup (and a touch of distributed-layer overhead). Sharding pays off when many clients or consumers write at once — which is what real ingestion pipelines do.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cost of durability: the binary log
&lt;/h3&gt;

&lt;p&gt;Manticore's binary log makes inserts crash-safe (it can replay un-flushed transactions after an unclean shutdown). It's on by default. You can already see its cost in the chart above — the orange (binary-log-off) line runs above the blue one. Isolating it on the plain unsharded baseline, with only &lt;code&gt;binlog_path&lt;/code&gt; changing:&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%2Fob30uyrezdrhz383aoy5.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%2Fob30uyrezdrhz383aoy5.png" alt=" " width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Disabling the binary log raised baseline throughput from &lt;strong&gt;162k to 218k docs/s&lt;/strong&gt; — so &lt;strong&gt;crash-safe inserts cost roughly 25%&lt;/strong&gt;. That's the price of not losing un-flushed writes on a hard crash; leave it on in production unless you can rebuild the data from source and want the extra speed during bulk loads.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cost of replication on writes
&lt;/h3&gt;

&lt;p&gt;Durability across machines isn't free either — and this is the one test that's only honest on &lt;strong&gt;real, separate hardware&lt;/strong&gt;. So unlike the single-node benchmarks above, we ran the two replication tests on &lt;strong&gt;two distinct physical machines&lt;/strong&gt;: two 4-core / 7 GB cloud VMs joined as a Manticore cluster. That way &lt;code&gt;rf=1&lt;/code&gt; and &lt;code&gt;rf=2&lt;/code&gt; never fight over the same cores or memory — the fair comparison a single box simply can't give.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rf=1&lt;/code&gt; keeps a single copy on one machine; &lt;code&gt;rf=2&lt;/code&gt; keeps a full copy on &lt;strong&gt;both&lt;/strong&gt;, so every insert is synchronously replicated across the network before it's acknowledged. Same 1M-doc load, 4 writer threads:&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%2F0qi4w2jx16cpyk2e5tv3.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%2F0qi4w2jx16cpyk2e5tv3.png" alt=" " width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Replication cost about &lt;strong&gt;30% of insert throughput&lt;/strong&gt; (112k → 78k docs/s) — the price of copying every write to the second machine before acking. That's the &lt;code&gt;rf&lt;/code&gt; trade-off in one number: write a bit slower, survive losing an entire machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do sharding and replication speed up reads?
&lt;/h3&gt;

&lt;p&gt;Reads are easy to measure wrong, so first the method. A &lt;strong&gt;trivial&lt;/strong&gt; query — fetch 20 rows, no ranking — runs in well under a millisecond, so its cost is &lt;em&gt;all fixed overhead&lt;/em&gt;; a distributed table that hops to an agent then looks &lt;strong&gt;~3× slower&lt;/strong&gt; (≈7,700 vs 2,400 q/s here) purely because the network round-trip dwarfs the near-zero work. That's not a real query. A &lt;strong&gt;realistic&lt;/strong&gt; full-text query (a few terms to match and rank, ~10–30 ms) tells the truth: the distribution overhead shrinks to a &lt;strong&gt;few percent&lt;/strong&gt;, because now real work dominates the fixed cost. The numbers below all use realistic queries on the 2-node cluster, read from a single entry node.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# realistic read — what the numbers below use: full-text match-and-rank, ~10–30 ms each&lt;/span&gt;
manticore-load &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"select id from &amp;lt;table&amp;gt; where match('&amp;lt;text/1/5&amp;gt; &amp;lt;text/1/5&amp;gt;')"&lt;/span&gt;

&lt;span class="c"&gt;# trivial read — sub-ms, all fixed overhead, exaggerates distributed cost ~3x&lt;/span&gt;
manticore-load &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"select id from &amp;lt;table&amp;gt; limit 20 option ranker=none"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; is one of: a plain RT table (&lt;strong&gt;no sharding&lt;/strong&gt;), a &lt;code&gt;type='distributed'&lt;/code&gt; table over local shards (&lt;strong&gt;single-node sharding&lt;/strong&gt; — &lt;code&gt;local='s0' local='s1' …&lt;/code&gt;), or a sharded table with &lt;code&gt;shards='4' rf='1'&lt;/code&gt;/&lt;code&gt;rf='2'&lt;/code&gt;. Every read is issued to &lt;strong&gt;one&lt;/strong&gt; node — we never query both nodes as separate clients.&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%2Fzdbwfp0uov8njojm2wcp.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%2Fzdbwfp0uov8njojm2wcp.png" alt=" " width="799" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sharding across nodes speeds reads up.&lt;/strong&gt; A 4-shard &lt;code&gt;rf=1&lt;/code&gt; table spread over both machines does &lt;strong&gt;~516 q/s vs ~315&lt;/strong&gt; for a single unsharded node — about &lt;strong&gt;1.6×&lt;/strong&gt; — because each query runs across both nodes' cores at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication keeps reads fast.&lt;/strong&gt; &lt;code&gt;rf=2&lt;/code&gt; (every shard on both nodes) does &lt;strong&gt;~410 q/s&lt;/strong&gt; — a touch below &lt;code&gt;rf=1&lt;/code&gt;. The reason is &lt;em&gt;not&lt;/em&gt; that the load sticks to one node (it spreads across replicas); it's that with &lt;code&gt;rf=2&lt;/code&gt; every shard is reached through the agent/mirror path — even the copies that happen to be local — and that path costs the few percent noted above, whereas &lt;code&gt;rf=1&lt;/code&gt; reads its on-node shards in-process. Either way reads stay well above a single node, and the data now survives a machine loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On one box, sharding's read win is small.&lt;/strong&gt; Splitting a table into 2–4 shards on a &lt;em&gt;single&lt;/em&gt; node adds only ~5–12% (query parallelism), and only when there are spare cores — heavier queries help (≈12% vs ≈3% for light ones), but a fully-loaded box is a wash. The real read scaling comes from adding &lt;strong&gt;machines&lt;/strong&gt;, not shards on one box.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Method note: the read and replication tests run on a &lt;strong&gt;separate pair of cloud VMs — 4 vCPU / 7 GB RAM each, two distinct physical machines&lt;/strong&gt; — joined as a 2-node Manticore cluster, same recent dev build, stock settings, &lt;strong&gt;1.5M docs&lt;/strong&gt;. They are a different, much less powerful setup than the 16-core / 32-thread server used for the insert sweeps above, so &lt;strong&gt;draw conclusions within each test, not across them&lt;/strong&gt;. The small core count is also why single-node sharding's read gain is modest here — there are few spare cores for a query to parallelize across.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Bottom line
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;On one box, sharding gives a &lt;strong&gt;~1.5× concurrent insert speedup&lt;/strong&gt; (163k → 253k docs/s here at 20M).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;best range here is 4–8 shards&lt;/strong&gt; on this 16-core / 32-thread CPU. Too many shards &lt;em&gt;hurts&lt;/em&gt;: at 32 shards throughput fell &lt;em&gt;below&lt;/em&gt; the unsharded baseline. Match shard count to cores and write concurrency, not to a big round number.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;binary log costs ~25%&lt;/strong&gt; of write throughput (and the gap shrinks to near-zero once the shard count is too high), and &lt;strong&gt;&lt;code&gt;rf=2&lt;/code&gt; replication ~30%&lt;/strong&gt; on writes (measured on two separate machines) — both fair prices for crash safety and node-failure survival, respectively.&lt;/li&gt;
&lt;li&gt;With &lt;strong&gt;realistic&lt;/strong&gt; full-text queries, sharding across a 2-node cluster reads &lt;strong&gt;~1.6×&lt;/strong&gt; a single node, and &lt;code&gt;rf=2&lt;/code&gt; keeps reads fast while surviving a node loss. Trivial id-lookup queries exaggerate distributed overhead ~3× — always benchmark reads with realistic queries.&lt;/li&gt;
&lt;li&gt;Absolute numbers depend on hardware and document shape; what travels between setups is the &lt;em&gt;shape&lt;/em&gt; of these curves — so benchmark your own workload before committing to a shard count.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations and things to know
&lt;/h2&gt;

&lt;p&gt;There are sharp edges worth knowing up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rf&lt;/code&gt; is required.&lt;/strong&gt; A sharded &lt;code&gt;CREATE TABLE&lt;/code&gt; must specify &lt;code&gt;rf=&lt;/code&gt;. If you omit it, &lt;code&gt;CREATE TABLE&lt;/code&gt; fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rf&lt;/code&gt; greater than 1 requires a cluster.&lt;/strong&gt; You can't create a multi-copy sharded table on a standalone node — there's nowhere to put the copies. Multi-copy tables must use the &lt;code&gt;cluster:name&lt;/code&gt; form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local sharded tables can't be created on a node that's already in a cluster.&lt;/strong&gt; If a node belongs to a replication cluster, create the table &lt;em&gt;in&lt;/em&gt; that cluster (&lt;code&gt;CREATE TABLE cluster:name …&lt;/code&gt;) rather than as a local table, or the sharding metadata won't be tracked correctly. Manticore detects this and tells you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maximum 3000 shards&lt;/strong&gt; per table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RF=1 means no fault tolerance.&lt;/strong&gt; A lost node's shards are gone. This is inherent, not a bug — it's the trade-off you accept for &lt;code&gt;rf='1'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need at least RF nodes.&lt;/strong&gt; &lt;code&gt;rf='M'&lt;/code&gt; requires a cluster of at least &lt;code&gt;M&lt;/code&gt; nodes; creation fails otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creation is synchronous up to a timeout.&lt;/strong&gt; &lt;code&gt;CREATE TABLE&lt;/code&gt; waits for the distribution to complete (default 30s). For very large shard counts, raise it with &lt;code&gt;timeout='N'&lt;/code&gt; (seconds), e.g. &lt;code&gt;shards='3000' rf='3' timeout='60'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;Sharding in Manticore covers a wide span with a deliberately small interface. With &lt;code&gt;shards='N' rf='1'&lt;/code&gt; on a single box, sharding spreads concurrent writes across independent pieces and keeps each one small. With &lt;code&gt;shards='N' rf='M'&lt;/code&gt; inside a cluster, it gives you a distributed, replicated table that survives node failures and rebalances itself when the cluster changes — without you writing a line of failover logic. The same table definition grows from one node to many, and your application keeps talking to it the same way throughout. In practice, this means you can start by improving write throughput on one node and later move to a fault-tolerant cluster without changing the application.&lt;/p&gt;

&lt;p&gt;To go deeper into the building blocks sharding stands on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://manual.manticoresearch.com/Creating_a_table/Creating_a_sharded_table/Creating_a_sharded_table" rel="noopener noreferrer"&gt;Creating a sharded table&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://manual.manticoresearch.com/Creating_a_cluster/Setting_up_replication/Setting_up_replication" rel="noopener noreferrer"&gt;Setting up replication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#pseudo_sharding" rel="noopener noreferrer"&gt;Pseudo-sharding&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have a sharding question or a workload you'd like us to benchmark? &lt;a href="https://manticoresearch.com/contact-us/" rel="noopener noreferrer"&gt;Let us know&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Faster KNN index builds in Manticore</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Thu, 02 Jul 2026 08:48:19 +0000</pubDate>
      <link>https://dev.to/sanikolaev/faster-knn-index-builds-in-manticore-390d</link>
      <guid>https://dev.to/sanikolaev/faster-knn-index-builds-in-manticore-390d</guid>
      <description>&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;Building a KNN index used to be the slow part of saving and merging tables with vector attributes. As of release &lt;a href="https://manticoresearch.com/blog/manticore-search-27-1-5/" rel="noopener noreferrer"&gt;v27.1.5&lt;/a&gt;, Manticore can use several CPU cores for this work during chunk saves, &lt;code&gt;OPTIMIZE&lt;/code&gt; merges, auto-optimize, and &lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt;. On a 16-core Ryzen 9 5950X, building a KNN index for 1 million 1536-dimensional vectors dropped from 8 minutes to 39 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HNSW build speed matters
&lt;/h2&gt;

&lt;p&gt;Manticore uses HNSW graphs to power KNN search over &lt;code&gt;float_vector&lt;/code&gt; attributes. You can think of an HNSW graph as a map that helps Manticore quickly find vectors that are close to the query vector.&lt;/p&gt;

&lt;p&gt;Building that map can take a long time. For tables without KNN, saving or merging data is mostly about writing ordinary table data. For tables with KNN, Manticore also has to insert every vector into an HNSW graph, and that extra work can dominate the total time.&lt;/p&gt;

&lt;p&gt;This matters most after large inserts and during maintenance. Fresh data is saved from memory to disk chunks. Existing chunks are later merged by &lt;code&gt;OPTIMIZE&lt;/code&gt; or auto-optimize. Each saved or merged chunk needs its own HNSW graph, so faster graph building means shorter waits after bulk loading, faster background optimization, and less time spent on maintenance operations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Creating_a_table/Local_tables/Plain_and_real-time_table_settings#Important-notes-about-RAM-chunks" rel="noopener noreferrer"&gt;Disk chunk&lt;/a&gt; count matters for search too. Manticore stores RT tables as disk chunks, and each chunk has its own HNSW graph. A KNN query searches every chunk and merges the results, so fewer chunks usually mean faster KNN queries. The fastest layout is often one chunk with one graph.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#auto_optimize" rel="noopener noreferrer"&gt;Auto-optimize&lt;/a&gt; does not go all the way to one chunk by default. It merges chunks in the background, but stops when the table reaches a target chunk count. For ordinary tables, the target is &lt;code&gt;2 * num_logical_cpus&lt;/code&gt;; for tables with a KNN attribute, it is lower: &lt;code&gt;num_physical_cpus / 2&lt;/code&gt;. On a 32-thread / 16-core host, that means 8 chunks for a KNN table instead of 64 for an ordinary table. The KNN target is lower because extra chunks hurt KNN search latency more, but the default still leaves more than one graph. To make auto-optimize converge to a single chunk, set &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#optimize_cutoff" rel="noopener noreferrer"&gt;optimize_cutoff&lt;/a&gt; to &lt;code&gt;1&lt;/code&gt; server-wide, per table, or at runtime with &lt;code&gt;SET GLOBAL optimize_cutoff = 1&lt;/code&gt;. You can also do it manually with &lt;a href="https://manual.manticoresearch.com/Securing_and_compacting_a_table/Compacting_a_table" rel="noopener noreferrer"&gt;OPTIMIZE TABLE ... OPTION cutoff = 1&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What used to happen
&lt;/h2&gt;

&lt;p&gt;Freshly inserted documents first accumulate in a RAM chunk. When that RAM chunk reaches &lt;a href="https://manual.manticoresearch.com/Creating_a_table/Local_tables/Plain_and_real-time_table_settings#rt_mem_limit" rel="noopener noreferrer"&gt;rt_mem_limit&lt;/a&gt;, which defaults to 128MB, Manticore saves it as a new disk chunk. For a table with a KNN attribute, that save includes building a fresh HNSW graph from the vectors in the RAM chunk.&lt;/p&gt;

&lt;p&gt;The same kind of HNSW build happens when disk chunks are merged. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; and auto-optimize read live rows from existing chunks, write a new merged chunk, and build a new HNSW graph for that merged result. &lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt;, and ALTER operations that add or drop a &lt;code&gt;float_vector&lt;/code&gt; column, also rebuild the graph.&lt;/p&gt;

&lt;p&gt;Before this change, the HNSW part of each individual save, merge, or rebuild used &lt;strong&gt;one worker&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A RAM-to-disk chunk save walked all live rows from the RAM chunk's segments one by one and inserted their vectors into one HNSW graph.&lt;/li&gt;
&lt;li&gt;A chunk merge walked all live rows from the input disk chunks one by one and inserted their vectors into the new graph.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt; rebuilt each graph in one worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Manticore already had some parallelism around these operations. Up to 2 RAM-chunk saves can run at the same time. The optimizer can also run several chunk merges at once, controlled by &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#parallel_chunk_merges" rel="noopener noreferrer"&gt;parallel_chunk_merges&lt;/a&gt;. The default is 2 when the host has enough CPU cores. But inside each individual save or merge, the KNN graph build was still single-worker. On KNN-heavy tables, that single worker often determined how long the whole operation took.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Manticore now splits one KNN graph build across several workers. Each worker gets part of the rows, inserts its vectors into the same destination graph, and finishes independently. The graph-building library coordinates those concurrent inserts so the graph remains valid.&lt;/p&gt;

&lt;p&gt;The exact split depends on the operation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;During RAM-to-disk saves, workers take RAM segments from a shared queue until all segments are processed.&lt;/li&gt;
&lt;li&gt;During chunk merges and &lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt;, Manticore divides the live rows into similarly sized ranges so the work is spread evenly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Single-thread improvements
&lt;/h3&gt;

&lt;p&gt;The same release also improves the single-worker path. Even when &lt;code&gt;knn_parallel_build&lt;/code&gt; is set to &lt;code&gt;1&lt;/code&gt;, the benchmark below shows a 10% improvement before adding parallelism. That comes from three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two-pass neighbor processing.&lt;/strong&gt; When inserting a vector, the algorithm walks through candidate neighbors and computes distances to them. The new code splits that into two passes: the first pass walks the neighbor list and prefetches the vector data, and the second pass computes the distances. This gives the CPU time to bring the vectors into cache before they are used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two comparisons at a time.&lt;/strong&gt; Some distance calculations now process two candidate vectors together. This reduces repeated work in the inner loop where most of the build time is spent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile-time distance dispatch in build mode.&lt;/strong&gt; The builder now picks the right distance function once for the build, such as inner product vs. L2 and raw float vs. binary-quantized vectors. That avoids a function-pointer lookup on every distance call and lets the compiler optimize the inner loop more aggressively.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The default and the config
&lt;/h3&gt;

&lt;p&gt;A new searchd setting, &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#knn_parallel_build" rel="noopener noreferrer"&gt;knn_parallel_build&lt;/a&gt;, controls how many workers one KNN build may use. The default is &lt;code&gt;min(4, threads / 4)&lt;/code&gt;, where &lt;code&gt;threads&lt;/code&gt; is Manticore's &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#threads" rel="noopener noreferrer"&gt;threads&lt;/a&gt; setting - the size of the worker pool that runs queries and background tasks, which defaults to the number of logical CPU cores on the host.&lt;/p&gt;

&lt;p&gt;In practical terms, that means one worker on small hosts and up to four workers by default on larger hosts: a 4-thread host gets one worker, an 8-thread host gets two, a 16-thread host gets four, and anything above that is also capped at four. The default is conservative because production machines often need to handle searches, inserts, and background work at the same time.&lt;/p&gt;

&lt;p&gt;You usually do not need to change it. Consider raising it when you are rebuilding or optimizing a KNN-heavy table on a host that is not serving live traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;knn_parallel_build&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it to &lt;code&gt;1&lt;/code&gt; if you need the old single-worker behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;knn_parallel_build&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value can also be set in the searchd config and checked with &lt;code&gt;SHOW VARIABLES&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU usage
&lt;/h3&gt;

&lt;p&gt;Multiple saves and merges can be active at the same time, and each one can use up to &lt;code&gt;knn_parallel_build&lt;/code&gt; workers. These workers use Manticore's existing &lt;code&gt;threads&lt;/code&gt; pool. They do not create an unlimited number of extra operating-system threads; if all pool threads are busy, extra work waits in the queue.&lt;/p&gt;

&lt;p&gt;This is why the default leaves headroom. On a 32-thread host, the default is 4 workers per KNN build. If two chunk saves overlap, the KNN build work can use up to 8 workers, leaving the rest of the thread pool available for other work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark
&lt;/h2&gt;

&lt;p&gt;Setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AMD Ryzen 9 5950X (16 physical / 32 logical cores)&lt;/li&gt;
&lt;li&gt;Dataset: &lt;a href="https://storage.googleapis.com/ann-filtered-benchmark/datasets/dbpedia_openai_1M.tgz" rel="noopener noreferrer"&gt;dbpedia-openai-1M&lt;/a&gt; - 1M vectors, 1536 dimensions, cosine distance&lt;/li&gt;
&lt;li&gt;Quantization: 1-bit (binary) quantization&lt;/li&gt;
&lt;li&gt;Data inserted into an RT table, then &lt;code&gt;OPTIMIZE&lt;/code&gt; to a single disk chunk&lt;/li&gt;
&lt;li&gt;Measurement: &lt;code&gt;ALTER TABLE knn_data REBUILD KNN&lt;/code&gt;, three runs per setting, single-chunk so timings are stable&lt;/li&gt;
&lt;li&gt;HNSW settings: defaults&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt; was used because it exercises the same parallel build path as chunk saves and chunk merges, while giving stable timings that are easy to reproduce.&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%2Fy0c3w95q1k69aqbjgd3b.jpg" 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%2Fy0c3w95q1k69aqbjgd3b.jpg" alt=" " width="800" height="457"&gt;&lt;/a&gt;&lt;br&gt;
Results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With one worker, the new code is already 10% faster than the old code: 492 seconds dropped to 442 seconds.&lt;/li&gt;
&lt;li&gt;With 16 workers, rebuild time dropped to 39 seconds, about 11x faster than the new one-worker result.&lt;/li&gt;
&lt;li&gt;Going from 16 to 32 workers helped only a little: 39 seconds became 36 seconds. On this machine, the useful limit is close to the 16 physical CPU cores.&lt;/li&gt;
&lt;li&gt;The default is meant for shared production hosts. For maintenance work on a dedicated host, raising &lt;code&gt;knn_parallel_build&lt;/code&gt; can be worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Migration
&lt;/h2&gt;

&lt;p&gt;No action is required. Existing tables keep working, and KNN graphs built by the parallel path are functionally equivalent to graphs built by the old single-worker path.&lt;/p&gt;

&lt;p&gt;One detail can matter for strict reproducibility: parallel workers may insert vectors in a different order, so Manticore's on-disk KNN graph file, stored with the &lt;code&gt;.spknn&lt;/code&gt; extension, is not guaranteed to be byte-for-byte identical to a single-worker build. Search quality and query speed are expected to be the same. If byte-for-byte reproducibility matters, set &lt;code&gt;knn_parallel_build = 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This change speeds up one of the slowest maintenance steps for KNN tables. Parallel graph building reduces the time needed to save chunks, merge chunks, and rebuild KNN data, while the improved single-worker path also speeds up builds on smaller systems. Existing tables continue to work without changes. When CPU resources are available during maintenance, &lt;code&gt;knn_parallel_build&lt;/code&gt; can be raised to build KNN graphs faster.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Manticore Search under systemd: beyond fork, PID files, and guesswork</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Fri, 26 Jun 2026 12:12:48 +0000</pubDate>
      <link>https://dev.to/sanikolaev/manticore-search-under-systemd-beyond-fork-pid-files-and-guesswork-259d</link>
      <guid>https://dev.to/sanikolaev/manticore-search-under-systemd-beyond-fork-pid-files-and-guesswork-259d</guid>
      <description>&lt;p&gt;If you run Manticore Search on Linux, systemd should be the default way to manage it.&lt;/p&gt;

&lt;p&gt;That sentence sounds obvious now, but for a long time it was only partly true. Manticore could run under systemd, yes, though the relationship was a little awkward. The daemon model came from an earlier Unix world; systemd came later and wanted different things from a service. So the setup worked, but never in a very satisfying way.&lt;/p&gt;

&lt;p&gt;What changed is simple enough: Manticore now supports native systemd notifications.&lt;/p&gt;

&lt;p&gt;Why care? Because several mildly annoying operational problems get better at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;systemctl status&lt;/code&gt; tells a more truthful story&lt;/li&gt;
&lt;li&gt;startup and reload are easier to follow&lt;/li&gt;
&lt;li&gt;logs fit naturally into &lt;code&gt;journalctl&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;shutdown is safer when real-time tables are flushing data&lt;/li&gt;
&lt;li&gt;PID files stop doing so much heavy lifting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters less than people think, until the day it matters a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with shutdown, because that's where things usually get real
&lt;/h2&gt;

&lt;p&gt;The nicest part of this change is not the nicest-looking part. It is shutdown behavior.&lt;/p&gt;

&lt;p&gt;If Manticore is flushing data from real-time tables, shutdown may take a while. Older setups often fell back to &lt;code&gt;searchd --stopwait&lt;/code&gt; and a certain amount of hope. Sometimes that was enough. Sometimes it really was not.&lt;/p&gt;

&lt;p&gt;The failure mode is boring and nasty: systemd decides the service is stuck, waits long enough, then sends &lt;code&gt;SIGKILL&lt;/code&gt;. If Manticore is in the middle of a flush, that is about the worst possible moment to force the process down.&lt;/p&gt;

&lt;p&gt;The newer behavior is cooperative. While Manticore is still flushing data, it tells systemd that progress is ongoing and that more time is needed. Concretely, it sends timeout-extension notifications every 15 seconds, each asking for another 30 seconds.&lt;/p&gt;

&lt;p&gt;I would put this above almost every other benefit in the article. Better status output is nice. Cleaner reload is nice. Not getting your shutdown path mangled during an RT flush is nicer.&lt;/p&gt;

&lt;p&gt;A slow stop is not automatically suspicious anymore. Sometimes it just means the server is finishing the part that actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the old setup looked like
&lt;/h2&gt;

&lt;p&gt;Historically, Manticore used the normal Unix daemon pattern: detach from the terminal, double-fork into the background, write a PID file, continue on. That was not some strange design choice. If portability matters, you end up there pretty quickly.&lt;/p&gt;

&lt;p&gt;The friction came from mixing that model with systemd.&lt;/p&gt;

&lt;p&gt;Support existed, but it was limited. The unit had to trust that the daemon forked correctly and that the PID file existed and still pointed to the right process. When everything behaved, fine. When it drifted even a little, supervision got fuzzy.&lt;/p&gt;

&lt;p&gt;The usual weak spots were predictable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;process tracking depended on an external file&lt;/li&gt;
&lt;li&gt;status reporting was indirect&lt;/li&gt;
&lt;li&gt;startup progress was mostly invisible to systemd&lt;/li&gt;
&lt;li&gt;reloads and shutdowns were harder to monitor cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this sounds dramatic when written out calmly. In practice it leads to exactly the kind of question operators hate: is this service actually healthy, or just currently alive.&lt;/p&gt;

&lt;p&gt;One specific example is worth keeping in mind. If Manticore's internal watchdog restarted &lt;code&gt;searchd&lt;/code&gt; after a crash, systemd could keep supervising the original process relationship instead of the newly resurrected daemon. That is where warnings like &lt;code&gt;Supervising process ... which is not our child&lt;/code&gt; came from.&lt;/p&gt;

&lt;p&gt;A smaller but common failure mode was config drift: change the &lt;code&gt;pid_file&lt;/code&gt; path in Manticore, forget to update the unit, and &lt;code&gt;systemd&lt;/code&gt; ends up tracking the wrong place. The daemon might be up while &lt;code&gt;systemctl status&lt;/code&gt; tells a much less helpful story.&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%2F971pjs9fim6lovl8t4m1.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%2F971pjs9fim6lovl8t4m1.png" alt=" " width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That screenshot captures the old setup fairly well. systemd could see a process tree, but not always the service lifecycle you cared about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The notify-based unit
&lt;/h2&gt;

&lt;p&gt;The newer setup uses &lt;code&gt;Type=notify&lt;/code&gt;, which lets Manticore report its own state directly instead of forcing systemd to infer too much from a PID file and guesswork.&lt;/p&gt;

&lt;p&gt;The systemd unit now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Manticore Search Engine
&lt;span class="err"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;notify
&lt;span class="err"&gt;...&lt;/span&gt;

&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/usr/bin/searchd --config /etc/manticoresearch/manticore.conf --nodetach $_ADDITIONAL_SEARCHD_PARAMS
&lt;span class="err"&gt;...&lt;/span&gt;

&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;on-failure
&lt;span class="err"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Type=notify&lt;/code&gt; means systemd expects status updates from Manticore itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--nodetach&lt;/code&gt; keeps &lt;code&gt;searchd&lt;/code&gt; in the foreground. Under systemd, that is the right default. Anything else is extra ceremony. The flag itself is not new; what changed is that it is now part of the packaged systemd setup rather than a debugging-only kind of option.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PIDFile&lt;/code&gt; is no longer the main supervision mechanism, but it can still be useful and added manually if you have older tooling that expects it.&lt;/p&gt;

&lt;p&gt;With this setup, Manticore can report states such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;starting&lt;/li&gt;
&lt;li&gt;loading tables&lt;/li&gt;
&lt;li&gt;reloading&lt;/li&gt;
&lt;li&gt;ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not sound glamorous. Still, compared to the old "there is a process, probably fine" model, it is a real improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small thing I like: reload becomes less awkward
&lt;/h2&gt;

&lt;p&gt;Reloading Manticore has traditionally meant sending &lt;code&gt;SIGHUP&lt;/code&gt; to &lt;code&gt;searchd&lt;/code&gt;. That is still what happens under systemd too. The packaged unit uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="nt"&gt;ExecReload&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/bin/kill -HUP $MAINPID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;systemctl reload manticore&lt;/code&gt; does not restart the daemon. It sends &lt;code&gt;SIGHUP&lt;/code&gt; to the running &lt;code&gt;searchd&lt;/code&gt;, which initiates table rotation: Manticore reopens &lt;a href="https://manual.manticoresearch.com/Read_this_first#Real-time-table-vs-plain-table" rel="noopener noreferrer"&gt;plain tables&lt;/a&gt; and switches to freshly built table files without doing a full daemon restart.&lt;/p&gt;

&lt;p&gt;That distinction matters. &lt;code&gt;reload&lt;/code&gt; is the low-risk way to trigger rotation without taking the service down first. Depending on the &lt;code&gt;seamless_rotate&lt;/code&gt; setting, new queries may be briefly stalled and clients may see temporary errors during the rotation window. &lt;code&gt;restart&lt;/code&gt; is a different operation entirely: it stops the service and then does a fresh start.&lt;/p&gt;

&lt;p&gt;The nice part now is visibility. When rotation starts, Manticore reports &lt;code&gt;RELOADING=1&lt;/code&gt; to systemd; when it finishes, it reports &lt;code&gt;READY=1&lt;/code&gt; again. So &lt;code&gt;systemctl status manticore&lt;/code&gt; can show that the daemon is actively rotating instead of just sitting there in a generic running state.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;--nodetach&lt;/code&gt; also fixes the logging story
&lt;/h2&gt;

&lt;p&gt;This option is not new. The environment around it changed.&lt;/p&gt;

&lt;p&gt;When Manticore stays attached to systemd instead of disappearing into the background, logs go to the journal and the main process is the one systemd actually knows about. There is less indirection. That usually pays off later, not immediately. Before &lt;code&gt;--nodetach&lt;/code&gt;, startup messages could begin in &lt;code&gt;journalctl&lt;/code&gt; and then the rest of the story would move into Manticore's own log files after detach.&lt;/p&gt;

&lt;p&gt;So for many deployments, the normal tools are enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; manticore
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; manticore &lt;span class="nt"&gt;-f&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; manticore &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I like boring logging setups. One place to look first is underrated.&lt;/p&gt;

&lt;p&gt;If you are happy using the systemd journal as the main log destination, you may not need the older logging setup in &lt;code&gt;manticore.conf&lt;/code&gt;. Often that is one less thing to think about.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the internal watchdog
&lt;/h2&gt;

&lt;p&gt;If Manticore runs under systemd, the simple setup is the one I would recommend: let systemd supervise the service, and do not enable Manticore's internal watchdog unless you actually need it.&lt;/p&gt;

&lt;p&gt;Could you run both? Yes.&lt;br&gt;
Would I choose that for a normal deployment? No.&lt;/p&gt;

&lt;p&gt;If you explicitly enable the internal watchdog, the supervision model becomes less direct and you may see warnings like:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Supervising process ... which is not our child&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That configuration is supported. It is also, for most people, just extra moving parts.&lt;/p&gt;
&lt;h2&gt;
  
  
  A few loose notes
&lt;/h2&gt;

&lt;p&gt;PID files matter much less to systemd itself with notification-based supervision. You may be able to remove &lt;code&gt;pid_file&lt;/code&gt; from the configuration, but only if nothing else in your environment still depends on it. Old scripts tend to survive longer than anyone expects.&lt;/p&gt;

&lt;p&gt;If you use executable configuration files, including shebang-based configs such as &lt;code&gt;#!/usr/bin/env python&lt;/code&gt;, this setup tends to behave more naturally than some older service-management approaches did.&lt;/p&gt;
&lt;h2&gt;
  
  
  Commands you will actually use
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start manticore
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop manticore
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart manticore
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload manticore
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status manticore
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;manticore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And for logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; manticore
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; manticore &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; manticore &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is basically it.&lt;/p&gt;

&lt;p&gt;The practical result is not flashy: Manticore behaves more like a modern Linux service and less like a daemon from an older era that everyone learned to work around. But those are often the best infrastructure changes. They remove friction you had almost stopped noticing.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>14 faster embeddings: how we rebuilt the ONNX path in Manticore</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Thu, 25 Jun 2026 11:50:04 +0000</pubDate>
      <link>https://dev.to/sanikolaev/14-faster-embeddings-how-we-rebuilt-the-onnx-path-in-manticore-4eom</link>
      <guid>https://dev.to/sanikolaev/14-faster-embeddings-how-we-rebuilt-the-onnx-path-in-manticore-4eom</guid>
      <description>&lt;p&gt;When we shipped &lt;a href="https://manticoresearch.com/blog/auto-embeddings/" rel="noopener noreferrer"&gt;Auto Embeddings&lt;/a&gt; — the feature that turns any text column into a vector automatically, with no separate model service to run — the most common piece of feedback was about speed. The previous path went through SentenceTransformers on top of &lt;a href="https://github.com/huggingface/candle" rel="noopener noreferrer"&gt;Candle&lt;/a&gt;, Hugging Face's pure-Rust ML inference runtime, and it left a lot of CPU on the floor: most workloads sat in the low-double-digits of docs/sec no matter how we fed them, and concurrent calls serialised on a single model session.&lt;/p&gt;

&lt;p&gt;So we spent a few weeks rebuilding how Manticore runs ONNX models. The new ONNX Runtime backend shipped in &lt;a href="https://manticoresearch.com/blog/manticore-search-27-1-5/" rel="noopener noreferrer"&gt;Manticore Search 27.1.5&lt;/a&gt;. ONNX (Open Neural Network Exchange) is the portable model format that most of the popular open-source embedding models — MiniLM, BGE, E5, and friends — already publish. The result is a backend that's &lt;strong&gt;~14× faster on average than the previous SentenceTransformers/Candle path&lt;/strong&gt; on the same hardware (average cheap 16 cores / 32 threads server), same model, same weights, averaged over the full &lt;code&gt;threads × batch&lt;/code&gt; workload grid — and that advantage holds whether you run 1 client thread or 32. The old path stayed in the 5–11 docs/sec range across the entire grid; the new one lives in the 70–230 docs/sec band.&lt;/p&gt;

&lt;p&gt;This post is the engineering log: what we tried, what surprised us, what we threw away, and what the final design looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~14× faster on average than the previous SentenceTransformers/Candle path&lt;/strong&gt;, averaged across the full &lt;code&gt;threads × batch&lt;/code&gt; workload grid (1 / 2 / 4 / 8 / 16 / 32 threads × batch sizes 1…128) on the same box (16 cores / 32 threads), same model, same weights.&lt;/li&gt;
&lt;li&gt;Released in &lt;a href="https://manticoresearch.com/blog/manticore-search-27-1-5/" rel="noopener noreferrer"&gt;Manticore Search 27.1.5&lt;/a&gt;, the new ONNX path is now the default fast path for any HuggingFace model that ships an &lt;code&gt;.onnx&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;all-MiniLM-L12-v2&lt;/code&gt;, the old Candle path sat at &lt;strong&gt;5–11 docs/sec&lt;/strong&gt; across every configuration we tried. The new ONNX path lands in the &lt;strong&gt;70–230 docs/sec&lt;/strong&gt; range — the &lt;strong&gt;same ~14× margin holds whether you run 1 client thread or 32&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Single-insert latency on our test box: &lt;strong&gt;~14 ms&lt;/strong&gt; with a single client, &lt;strong&gt;~56 ms&lt;/strong&gt; under 8-way concurrent load — both well below the 200+ ms Candle was hitting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want maximum bulk ingest throughput?&lt;/strong&gt; Use a &lt;strong&gt;high batch size&lt;/strong&gt; (32–128) on a &lt;strong&gt;single client thread&lt;/strong&gt;. The new backend parallelises inside the call, so client-side fan-out just piles coordination overhead on top — peak on our box was &lt;strong&gt;233 docs/sec at 1 thread + batch=64&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The two changes that mattered most: turning &lt;strong&gt;&lt;code&gt;intra_op_spinning&lt;/code&gt; off&lt;/strong&gt;, and giving up on batching documents inside the worker.&lt;/li&gt;
&lt;li&gt;No user-facing API changes. A table that already points at an ONNX-capable &lt;code&gt;MODEL_NAME&lt;/code&gt; picks up the new path automatically. Switching an existing table to a different model isn't a one-liner — Manticore doesn't allow altering &lt;code&gt;MODEL_NAME&lt;/code&gt; on a &lt;code&gt;FLOAT_VECTOR&lt;/code&gt; field in place — but you don't have to recreate the whole table either: you can add a new column with the new model alongside, rebuild its embeddings, and drop the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;With auto-embeddings, the database itself runs the model on every &lt;code&gt;INSERT&lt;/code&gt;. That means embedding speed &lt;em&gt;is&lt;/em&gt; INSERT speed — your ingest throughput is whatever the embedding step can sustain.&lt;/p&gt;

&lt;p&gt;The old SentenceTransformers/Candle path left performance on the table. Concurrency hit lock contention, batched calls plateaued because of padding overhead, and between calls the runtime parked threads in ways that prevented the next call from picking up where the previous one left off. The headline symptom was simple: &lt;code&gt;top&lt;/code&gt; would show the box well under full load no matter what you threw at it. The whole sweep — single-row INSERTs, 128-row bulk INSERTs, one client thread, thirty-two client threads — sat at &lt;strong&gt;5–11 docs/sec&lt;/strong&gt;, because nothing about how you fed it could buy you more CPU.&lt;/p&gt;

&lt;p&gt;The new ONNX path raises the floor by an order of magnitude &lt;em&gt;and&lt;/em&gt; gives users meaningful performance tuning options. A single-thread, single-row INSERT now lands &lt;strong&gt;72 docs/sec&lt;/strong&gt; — already ~7× the old Candle ceiling. Add concurrency or batch size and it climbs into the &lt;strong&gt;130–230 docs/sec&lt;/strong&gt; range, with the top of the grid at &lt;strong&gt;233 docs/sec on a single client thread at &lt;code&gt;--batch-size=64&lt;/code&gt;&lt;/strong&gt;. Averaged across the whole &lt;code&gt;threads × batch&lt;/code&gt; matrix, the new path is &lt;strong&gt;~14× the old one&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ONNX, and not Candle
&lt;/h2&gt;

&lt;p&gt;Manticore's embeddings library has supported a few backends for a while. The Candle path is great for correctness and easy to ship. But for production inference of small encoder models like the MiniLM and BGE family, ONNX Runtime is hard to beat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ONNX Runtime (or &lt;strong&gt;ORT&lt;/strong&gt; — Microsoft's official, hand-tuned C++ inference engine for ONNX models) does graph fusion, constant folding, kernel autotuning.&lt;/li&gt;
&lt;li&gt;Most of the popular embedding models on HuggingFace already publish a pre-fused &lt;code&gt;model.onnx&lt;/code&gt; in their &lt;code&gt;onnx/&lt;/code&gt; directory. The on-disk file is already in the shape ORT wants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the same &lt;code&gt;all-MiniLM-L12-v2&lt;/code&gt; weights, on CPU, the ONNX path is a noticeable step up over the Candle path. Same quality, much less per-document work.&lt;/p&gt;

&lt;p&gt;The ORT session is created with a small set of opinions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;session&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="nf"&gt;.with_optimization_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;GraphOptimizationLevel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Level3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="nf"&gt;.with_intra_threads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;            &lt;span class="c1"&gt;// let ORT pick (= all cores)&lt;/span&gt;
    &lt;span class="nf"&gt;.with_intra_op_spinning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;    &lt;span class="c1"&gt;// do NOT busy-wait between calls&lt;/span&gt;
    &lt;span class="nf"&gt;.with_flush_to_zero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;             &lt;span class="c1"&gt;// kill denormals on attention softmax&lt;/span&gt;
    &lt;span class="nf"&gt;.with_approximate_gelu&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;          &lt;span class="c1"&gt;// ~10% faster activation, no quality loss&lt;/span&gt;
    &lt;span class="nf"&gt;.commit_from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;onnx_path&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of these are uncontroversial, "of course you turn that on" knobs. One is not: &lt;code&gt;intra_op_spinning(false)&lt;/code&gt;. We'll come back to it — it's the single biggest win in the whole branch, and it's not really an ORT setting so much as a load-shape decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concurrency model — the part most readers will find new
&lt;/h2&gt;

&lt;p&gt;If you give a Rust developer "make ONNX go fast" with no other constraints, they reach for one of two patterns. We tried both. They are both wrong for this workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern 1: a single shared &lt;code&gt;Session&lt;/code&gt; behind a &lt;code&gt;Mutex&lt;/code&gt;&lt;/strong&gt; (a &lt;code&gt;Mutex&lt;/code&gt; is a lock that lets only one thread touch the session at a time). Easy to reason about, easy to get right. Throughput collapses under concurrency because every caller serialises on the lock. Fine for a CLI tool, awful for a database serving many concurrent INSERTs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern 2: a session pool, one &lt;code&gt;Session&lt;/code&gt; per CPU.&lt;/strong&gt; No more lock contention, but cold-start time multiplies, RAM use multiplies, and small inputs pay a dispatch cost just to land on a session. We had a working version of this in a development branch and it never quite delivered.&lt;/p&gt;

&lt;p&gt;The thing that unlocked the design is something most Rust ONNX wrappers get wrong: &lt;strong&gt;on Linux and macOS, ORT's C &lt;code&gt;Run()&lt;/code&gt; API is thread-safe.&lt;/strong&gt; You can share one &lt;code&gt;Session&lt;/code&gt; across many concurrent callers without any locking. The C++ side already serialises what needs serialising; the Rust API just hides it behind borrow-checker rules that do not match what the underlying library actually allows.&lt;/p&gt;

&lt;p&gt;So we wrap the session in a small platform-aware type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[cfg(not(target_os&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"windows"&lt;/span&gt;&lt;span class="nd"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;SessionWrapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnsafeCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;session&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[cfg(not(target_os&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"windows"&lt;/span&gt;&lt;span class="nd"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;SessionWrapper&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="nd"&gt;#[cfg(not(target_os&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"windows"&lt;/span&gt;&lt;span class="nd"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;SessionWrapper&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;SessionWrapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;with_session&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nf"&gt;FnOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;R&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.inner&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, this is &lt;code&gt;unsafe&lt;/code&gt;. We're taking the borrow checker out of the loop because the underlying library is documented to be safe under the access pattern we're using. It's a deliberate &lt;code&gt;unsafe&lt;/code&gt; with a one-line justification, not a foot-gun.&lt;/p&gt;

&lt;p&gt;On Windows, ORT's threading model has known issues, so we serialise &lt;code&gt;Run()&lt;/code&gt; with a &lt;code&gt;Mutex&lt;/code&gt;. Importantly, the lock is held &lt;em&gt;for the entire closure&lt;/em&gt;, not just the call to &lt;code&gt;run()&lt;/code&gt; — that's what fixed the race we saw on Windows where one thread's &lt;code&gt;SessionOutputs&lt;/code&gt; were still being read while another thread had already started a new &lt;code&gt;run()&lt;/code&gt;. Closure-scoped locking, not call-scoped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adaptive parallelism — the wrong turns we took
&lt;/h2&gt;

&lt;p&gt;This is the part of the work that took the longest, because every textbook says "to make ONNX fast, batch your inputs". So our first attempts followed the textbook.&lt;/p&gt;

&lt;p&gt;We tokenized chunks of 8, 16, 32 documents at a time, padded them to &lt;code&gt;max_len&lt;/code&gt;, and ran a single forward pass per worker thread. The throughput numbers came back lower than processing the same texts one-by-one through the same session. We ran it again. Same result. We spent a while trying to disprove it before accepting it. The reverted commit &lt;code&gt;980b24b "Revert: perf(model): batch inference in worker threads"&lt;/code&gt; is the moment we stopped fighting and rebuilt around what the profiler kept telling us.&lt;/p&gt;

&lt;p&gt;Two things were behind the surprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The padding tax.&lt;/strong&gt; A batch of mixed-length texts pads every row up to the longest row. The model then does work proportional to &lt;code&gt;batch_size * max_len * hidden_dim&lt;/code&gt;, regardless of how much real content is in the batch. Real text inputs are highly variable in length: a typical batch of 8 random sentences might have one 60-token outlier and seven 8-token rows. The model spends most of its cycles multiplying padding tokens against attention weights. With one-doc batches, the model only does work proportional to that doc's actual token count. Per-document, "no batching" is cheaper than "batching" once the variance in input length is realistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spinning.&lt;/strong&gt; ORT's intra-op thread pool defaults to &lt;em&gt;spinning&lt;/em&gt; between dispatches — threads burn CPU in a tight loop waiting for the next chunk of work. With one big batch per session call this is invisible: the thread is always busy with real work. With many concurrent small calls, it becomes a disaster: every worker's intra-op pool is pinned at 100% CPU between calls, and there's no CPU left for anything else. We saw exactly this pattern in &lt;code&gt;top&lt;/code&gt;: every core at 100%, throughput &lt;em&gt;lower&lt;/em&gt; than spinning-off. This sounds wrong until you remember the rest of the system needs CPU time too — the tokenizer, the HNSW build, the rest of &lt;code&gt;searchd&lt;/code&gt;. Flipping &lt;code&gt;with_intra_op_spinning(false)&lt;/code&gt; on was a one-line change that immediately raised throughput and dropped CPU usage at the same time.&lt;/p&gt;

&lt;p&gt;So the final shape is the opposite of the textbook recipe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One shared session&lt;/strong&gt;, no pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One document per inference call&lt;/strong&gt;, no batching inside the worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many concurrent callers&lt;/strong&gt;, scaled to CPU count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No spinning&lt;/strong&gt; between calls — yield the CPU like a polite citizen.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;predict_pipelined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;batch_size&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Small input — single tokenize + infer, no thread overhead.&lt;/span&gt;
    &lt;span class="c1"&gt;// This is the path a 1-doc INSERT takes.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tokenize_and_infer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texts&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="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Large input — split across workers, each running 1-doc-at-a-time&lt;/span&gt;
    &lt;span class="c1"&gt;// through the SHARED session. This deliberately mimics the&lt;/span&gt;
    &lt;span class="c1"&gt;// many-concurrent-callers pattern that ORT is happiest with.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;num_workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;bs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;available_cpus&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;docs_per_worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.div_ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_workers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;worker_texts&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="nf"&gt;.chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs_per_worker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="nf"&gt;.spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;worker_texts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tokenize_and_infer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                             &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&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 two-branch design is on purpose. A 1-row INSERT comes in with &lt;code&gt;texts.len() == 1&lt;/code&gt;, which is &lt;code&gt;&amp;lt;= bs&lt;/code&gt;, so it takes the fast path with &lt;strong&gt;zero thread spawning, zero channel sends, zero coordination overhead&lt;/strong&gt;. A bulk REPLACE INTO with thousands of rows takes the parallel branch and gets the throughput benefit. The cheap case stays cheap, the expensive case stays parallel.&lt;/p&gt;

&lt;p&gt;We also enable parallel tokenization once at startup (&lt;code&gt;TOKENIZERS_PARALLELISM=true&lt;/code&gt;) and pre-truncate inputs by character count before BPE, so a 100KB blob of text doesn't pin a CPU on the tokenizer for a second before the model even sees it.&lt;/p&gt;

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

&lt;p&gt;All runs on our standard benchmark box, using &lt;code&gt;all-MiniLM-L12-v2-onnx&lt;/code&gt;, 1000 documents per run. Generated with &lt;a href="https://manticoresearch.com/blog/manticore-load/" rel="noopener noreferrer"&gt;manticore-load&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;manticore-load &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="nt"&gt;--drop&lt;/span&gt; &lt;span class="nt"&gt;--batch-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8 &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CREATE TABLE t (
    f text,
    v FLOAT_VECTOR KNN_TYPE='hnsw' HNSW_SIMILARITY='l2'
      MODEL_NAME='onnx-models/all-MiniLM-L12-v2-onnx' FROM=''
  )"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"INSERT INTO t(f) VALUES('&amp;lt;text/10/100&amp;gt;')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same command with &lt;code&gt;--batch-size=2&lt;/code&gt;, &lt;code&gt;8&lt;/code&gt;, &lt;code&gt;32&lt;/code&gt;, &lt;code&gt;128&lt;/code&gt;, all at 8 threads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;--batch-size&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;docs/sec&lt;/th&gt;
&lt;th&gt;avg call latency (ms)&lt;/th&gt;
&lt;th&gt;per-doc latency (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;td&gt;55.9&lt;/td&gt;
&lt;td&gt;55.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;113&lt;/td&gt;
&lt;td&gt;141.6&lt;/td&gt;
&lt;td&gt;70.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;703.3&lt;/td&gt;
&lt;td&gt;87.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;1753.4&lt;/td&gt;
&lt;td&gt;54.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;6966.0&lt;/td&gt;
&lt;td&gt;54.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compared against Candle at the same 8 threads — which sat flat at &lt;strong&gt;10 docs/sec across every batch size&lt;/strong&gt; — that's between &lt;strong&gt;9× and 15× more documents per second&lt;/strong&gt; depending on the batch you pick. The "avg call latency" column is the time for one full &lt;code&gt;INSERT&lt;/code&gt; statement to return, not per document; divide by the batch size and the per-doc cost lands in the 55–90 ms band.&lt;/p&gt;

&lt;p&gt;If you swap the table to 1 client thread — the configuration that turns out to be optimal for bulk loading — the numbers climb further: &lt;strong&gt;72 / 76 / 93 / 175 / 233 / 222 docs/sec&lt;/strong&gt; at batches 1 / 2 / 8 / 32 / 64 / 128. The peak in the entire grid is &lt;strong&gt;233 docs/sec at 1 thread × batch=64&lt;/strong&gt;, with per-document latency of &lt;strong&gt;~4.3 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to feed it for maximum throughput
&lt;/h3&gt;

&lt;p&gt;If you're loading a lot of data in bulk and want maximum docs/sec, the recipe is straightforward: send large &lt;code&gt;INSERT ... VALUES (..), (..), ...&lt;/code&gt; statements (batch 32–128) from a &lt;strong&gt;single client thread&lt;/strong&gt;, not many small inserts from many threads. The new backend already parallelises &lt;em&gt;inside&lt;/em&gt; the call (see the &lt;code&gt;predict_pipelined&lt;/code&gt; code above), so client-side fan-out just piles coordination overhead on top of what ORT is already doing — that's why 1 thread × batch=64 (233 docs/sec) beats 8 threads × batch=128 (147 docs/sec) by a clear margin.&lt;/p&gt;

&lt;p&gt;If your workload is naturally one-row-at-a-time — web requests, queue consumers, MCP servers — just use &lt;code&gt;INSERT INTO&lt;/code&gt;. The single-thread / single-row floor of 72 docs/sec is already ~7× the old Candle path, low enough latency that this isn't a tier you need to optimise around any more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before vs after, across the whole grid
&lt;/h3&gt;

&lt;p&gt;To make the before/after concrete, we also swept the full &lt;code&gt;threads × batch&lt;/code&gt; grid against the old Candle/&lt;code&gt;trans&lt;/code&gt; path on the same box, same weights:&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%2Fq25ro59w1ilutzdxb24z.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%2Fq25ro59w1ilutzdxb24z.png" alt=" " width="800" height="193"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each X-axis tick is &lt;code&gt;backend threads/batch-size&lt;/code&gt;. The left half (&lt;code&gt;trans …&lt;/code&gt;) is the old Candle path — docs/sec sits at 5–11 across the entire grid no matter how many threads or how large the batch, while CPU is already pinned. The right half (&lt;code&gt;onnx …&lt;/code&gt;) is the new path — docs/sec is an order of magnitude higher across the whole sweep. Within the new path: at small batches, adding client threads helps (1T/batch=1 = 72 → 8T/batch=1 = 143); at large batches, a single client thread wins (1T/batch=64 = 233 is the global peak).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frhikrnu4ck1fpi9fadup.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%2Frhikrnu4ck1fpi9fadup.png" alt=" " width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same sweep, but plotting efficiency (docs/sec per % CPU) alongside docs/sec. On the Candle (&lt;code&gt;trans&lt;/code&gt;) side, both lines hug the floor — the box is spending CPU without producing documents. On the ONNX (&lt;code&gt;onnx&lt;/code&gt;) side, efficiency is highest at 1–2 threads with mid-sized batches, where each percent of CPU buys the most embeddings, and it stays well above the old path even as we crank threads up to 32.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A few things are queued behind this work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPU path.&lt;/strong&gt; The current ONNX setup is CPU-only. The &lt;code&gt;_use_gpu&lt;/code&gt; parameter is plumbed through but not yet wired to the ORT CUDA execution provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows perf parity.&lt;/strong&gt; We currently serialise on Windows because of an ORT threading bug. Once that bug is resolved upstream, Windows should get the same shared-session behaviour Linux/macOS already have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More architectures down the ONNX path.&lt;/strong&gt; Right now ONNX is the path for BERT-family encoders. T5, causal-LM and quantized GGUF models still go through Candle for now.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If your existing table is already pointed at an ONNX-capable model, the new path takes over once you upgrade to Manticore Search 27.1.5 or newer — no schema changes, no re-ingest. You should just see your INSERTs go faster.&lt;/p&gt;

&lt;p&gt;If you're not on an ONNX model yet — or you want to move to a smaller / faster one to take maximum advantage of the new backend — note that &lt;strong&gt;you can't swap the model on an existing field&lt;/strong&gt;. Manticore doesn't support altering &lt;code&gt;MODEL_NAME&lt;/code&gt; on an existing &lt;code&gt;FLOAT_VECTOR&lt;/code&gt; field, so migrating in place isn't an option. You have two practical paths to choose between, depending on what's easier in your setup:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — dump, edit, reload.&lt;/strong&gt; Even if you no longer have the original source data, you can &lt;code&gt;mysqldump&lt;/code&gt; the existing table to a SQL file, edit the &lt;code&gt;CREATE TABLE&lt;/code&gt; in that dump to point &lt;code&gt;MODEL_NAME&lt;/code&gt; at the ONNX-optimised model you want, and replay the dump into a fresh table. Manticore will re-embed every row through the new path on the way in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — add a new column alongside, rebuild, drop the old one.&lt;/strong&gt; If you'd rather stay in SQL and avoid the dump round-trip, add a new &lt;code&gt;FLOAT_VECTOR&lt;/code&gt; column on the same table that points at the ONNX model, then trigger a one-shot re-embed of that column from the source text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;v_new&lt;/span&gt; &lt;span class="n"&gt;FLOAT_VECTOR&lt;/span&gt; &lt;span class="n"&gt;KNN_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'hnsw'&lt;/span&gt;
  &lt;span class="n"&gt;HNSW_SIMILARITY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'l2'&lt;/span&gt;
  &lt;span class="n"&gt;MODEL_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Xenova/all-MiniLM-L6-v2'&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'text_field'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;REBUILD&lt;/span&gt; &lt;span class="n"&gt;EMBEDDINGS&lt;/span&gt; &lt;span class="n"&gt;v_new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- once you've cut over reads to v_new, drop the old column&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;v_old&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the &lt;a href="https://manual.manticoresearch.com/dev/Updating_table_schema_and_settings#Rebuilding-embeddings" rel="noopener noreferrer"&gt;Rebuilding embeddings&lt;/a&gt; section of the docs for the exact syntax and constraints.&lt;/p&gt;

&lt;p&gt;On brand-new tables, none of this matters — just pick an ONNX-optimised &lt;code&gt;MODEL_NAME&lt;/code&gt; from the start.&lt;/p&gt;

&lt;p&gt;A good place to shop for ONNX-ready embedding models is the &lt;a href="https://huggingface.co/Xenova/models" rel="noopener noreferrer"&gt;Xenova collection on Hugging Face&lt;/a&gt; — these are pre-converted to ONNX and ready to drop into &lt;code&gt;MODEL_NAME='...'&lt;/code&gt;. Filter the list by the &lt;strong&gt;feature-extraction&lt;/strong&gt; task to narrow it down to embedding-style models. Some sensible starting points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Xenova/all-MiniLM-L6-v2&lt;/code&gt; — small and fast, 384-dim, great default.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Xenova/all-MiniLM-L12-v2&lt;/code&gt; — the model we benchmarked in this post, 384-dim, a step up in quality.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Xenova/bge-small-en-v1.5&lt;/code&gt; — strong English retrieval, 384-dim.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Xenova/multilingual-e5-small&lt;/code&gt; — multilingual coverage, 384-dim.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you aren't using auto-embeddings yet at all, the &lt;a href="https://manticoresearch.com/blog/auto-embeddings/" rel="noopener noreferrer"&gt;original announcement&lt;/a&gt; walks through the SQL from scratch.&lt;/p&gt;

&lt;p&gt;📚 &lt;a href="https://manual.manticoresearch.com/Searching/KNN" rel="noopener noreferrer"&gt;KNN search documentation&lt;/a&gt;&lt;br&gt;
💬 &lt;a href="https://slack.manticoresearch.com/" rel="noopener noreferrer"&gt;Slack community&lt;/a&gt; — we'd love to see how the new path holds up on your data.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Український лематизатор тепер вбудовано в Manticore Search</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Tue, 23 Jun 2026 11:14:40 +0000</pubDate>
      <link>https://dev.to/sanikolaev/ukrayinskii-liematizator-tiepier-vbudovano-v-manticore-search-5aoa</link>
      <guid>https://dev.to/sanikolaev/ukrayinskii-liematizator-tiepier-vbudovano-v-manticore-search-5aoa</guid>
      <description>&lt;h2&gt;
  
  
  Коротко
&lt;/h2&gt;

&lt;p&gt;починаючи з релізу &lt;code&gt;27.1.5&lt;/code&gt; український лематизатор більше не потребує окремого Python-стека.&lt;br&gt;
Раніше потрібно було встановлювати окремий пакет, Python 3.9, &lt;code&gt;pymorphy2&lt;/code&gt; і українські словники.&lt;br&gt;
Гарна новина - тепер словник уже входить до Manticore.&lt;/p&gt;

&lt;p&gt;Достатньо лише ввімкнути явно морфологію:&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="n"&gt;morphology&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'lemmatize_uk_all'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Окремо додавати українські символи до &lt;code&gt;charset_table&lt;/code&gt; також вже не потрібно: стандартний &lt;code&gt;non_cont&lt;/code&gt; містить мапінги для &lt;code&gt;є&lt;/code&gt;, &lt;code&gt;і&lt;/code&gt;, &lt;code&gt;ї&lt;/code&gt;, &lt;code&gt;ґ&lt;/code&gt;.&lt;br&gt;
А от апостроф для української мови важливий, але тут є один важливий нюанс. Якщо просто додати його в &lt;code&gt;charset_table&lt;/code&gt; це може зачепити данні на англійській мові, де апостроф також використовується.&lt;/p&gt;

&lt;p&gt;Саме тому для українських текстів ми рекомендуємо використовувати окрему таблицю з власним &lt;code&gt;charset_table&lt;/code&gt; та апострофом, а не змішувати українську з англійською чи іншими мовами в одній таблиці.&lt;/p&gt;

&lt;p&gt;Це все що потрібно урахувати для повноцінної підтримки Української в ManticoreSearch. Ніяких словників, пакетів чи скриптів. Тепер все працює прямо "з коробки"&lt;/p&gt;
&lt;h2&gt;
  
  
  Що таке лематизатор
&lt;/h2&gt;

&lt;p&gt;У повнотекстовому пошуку часто потрібно знайти слово не лише в тій формі, яку ввів користувач. У документі може бути &lt;code&gt;мрії&lt;/code&gt;, а користувач шукає &lt;code&gt;мрія&lt;/code&gt;. Або в тексті є &lt;code&gt;інтернет-магазину&lt;/code&gt;, а в запиті приходить &lt;code&gt;інтернет-магазин&lt;/code&gt;. Людина легко бачить, що це форми того самого слова. Для пошукового рушія без морфології це різні токени.&lt;/p&gt;

&lt;p&gt;Для цього в пошукових рушіях використовують стемінг і лематизацію.&lt;/p&gt;

&lt;p&gt;Стемер зазвичай працює за правилами: відкидає або замінює закінчення. Це швидко, але результат буває грубим і не завжди схожим на справжнє слово.&lt;/p&gt;

&lt;p&gt;Лематизатор спирається на словник і морфологію, щоб отримати нормальну форму слова. Для української мови це особливо помітно через відмінки, рід і число.&lt;/p&gt;
&lt;h2&gt;
  
  
  Що змінилося
&lt;/h2&gt;

&lt;p&gt;Якщо ви вже пробували українську лематизацію в Manticore, то проблема могла бути не в самому пошуку, а у встановленні:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;окремий &lt;code&gt;manticore-lemmatizer-uk&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;Python 3.9 з &lt;code&gt;--enable-shared&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pymorphy2&lt;/code&gt; і &lt;code&gt;pymorphy2-dicts-uk&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;додаткові системні залежності.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Тепер український словник постачається як звичайний мовний файл &lt;code&gt;uk.pak&lt;/code&gt;, а Manticore завантажує його напряму. Вам залишається налаштувати таблицю: вказати потрібну &lt;code&gt;morphology&lt;/code&gt; і працювати далі.&lt;/p&gt;
&lt;h2&gt;
  
  
  Мінімальна конфігурація
&lt;/h2&gt;

&lt;p&gt;Створимо таблицю для українських текстів:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;uk_docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;morphology&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'lemmatize_uk_all'&lt;/span&gt;
  &lt;span class="n"&gt;charset_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'non_cont,U+0027'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Тут важливо ввімкнути морфологію:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;morphology='lemmatize_uk_all'&lt;/code&gt; вмикає український лематизатор та індексує усі знайдені нормальні форми.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Для української мови додаємо лише апостроф (&lt;code&gt;U+0027&lt;/code&gt;), щоб слова на кшталт &lt;code&gt;обов'язковим&lt;/code&gt; індексувалися як один токен.&lt;/p&gt;

&lt;p&gt;Для однієї нормальної форми підійде &lt;code&gt;lemmatize_uk&lt;/code&gt;. Щоб індексувати усі можливі форми, виберіть &lt;code&gt;lemmatize_uk_all&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Перевіримо на прикладі
&lt;/h2&gt;

&lt;p&gt;Додамо кілька документів:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;uk_docs&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'мрії про червону сукню'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'каталог інтернет-магазину'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'команд-учасниць запросили на зустріч'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Запит &lt;code&gt;мрія&lt;/code&gt; знаходить документ, де слово записано як &lt;code&gt;мрії&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;uk_docs&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'мрія'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------+---------------------------+
| id   | title                     |
+------+---------------------------+
|    1 | мрії про червону сукню    |
+------+---------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Запит &lt;code&gt;червоний&lt;/code&gt; знаходить &lt;code&gt;червону&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;uk_docs&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'червоний'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------+---------------------------+
| id   | title                     |
+------+---------------------------+
|    1 | мрії про червону сукню    |
+------+---------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;А &lt;code&gt;інтернет-магазин&lt;/code&gt; знаходить &lt;code&gt;інтернет-магазину&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;uk_docs&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'інтернет-магазин'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------+---------------------------+
| id   | title                     |
+------+---------------------------+
|    2 | каталог інтернет-магазину |
+------+---------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Що відбувається з токенами
&lt;/h2&gt;

&lt;p&gt;Якщо хочете побачити не лише результат пошуку, а й саму нормалізацію, використовуйте &lt;code&gt;CALL KEYWORDS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;KEYWORDS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'мрії червона інтернет-магазину команд-учасниць'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'uk_docs'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------+--------------------+--------------+
| qpos | tokenized          | normalized   |
+------+--------------------+--------------+
| 1    | мрії               | мрія         |
| 2    | червона            | червоний     |
| 3    | інтернет           | інтернет     |
| 4    | магазину           | магазин      |
| 5    | команд             | команда      |
| 6    | учасниць           | учасниця     |
+------+--------------------+--------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Тут добре видно різницю з простим обрізанням закінчень: на виході маємо нормальні форми слів, за якими вже можна шукати. &lt;code&gt;мрії&lt;/code&gt; перетворюється на &lt;code&gt;мрія&lt;/code&gt;, &lt;code&gt;червона&lt;/code&gt; на &lt;code&gt;червоний&lt;/code&gt;, &lt;code&gt;магазину&lt;/code&gt; на &lt;code&gt;магазин&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Що варто пам'ятати
&lt;/h2&gt;

&lt;p&gt;Користуватися українським лематизатором стало простіше, але для кожної таблиці його все одно треба ввімкнути явно через &lt;code&gt;morphology&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Стандартний &lt;code&gt;charset_table=non_cont&lt;/code&gt; уже покриває українські символи &lt;code&gt;є&lt;/code&gt;, &lt;code&gt;і&lt;/code&gt;, &lt;code&gt;ї&lt;/code&gt;, &lt;code&gt;ґ&lt;/code&gt;. Якщо ви задаєте таблицю саме для українських текстів, достатньо додати до нього апостроф: &lt;code&gt;charset_table='non_cont,U+0027'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Якщо ви використовуєте офіційні пакети або образи Manticore Search актуальних версій, український &lt;code&gt;uk.pak&lt;/code&gt; уже має бути на місці. Якщо у вас власна збірка або нестандартне розташування файлів, перевірте, що &lt;code&gt;lemmatizer_base&lt;/code&gt; вказує на каталог, де лежить &lt;code&gt;uk.pak&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Докладніше про налаштування морфології можна прочитати в документації: &lt;a href="https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Morphology#morphology" rel="noopener noreferrer"&gt;morphology&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>news</category>
      <category>nlp</category>
      <category>sql</category>
    </item>
    <item>
      <title>Faster KNN search in Manticore: 2-pass HNSW, batched distances, and AVX-512</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Tue, 23 Jun 2026 10:43:59 +0000</pubDate>
      <link>https://dev.to/sanikolaev/faster-knn-search-in-manticore-2-pass-hnsw-batched-distances-and-avx-512-4gd2</link>
      <guid>https://dev.to/sanikolaev/faster-knn-search-in-manticore-2-pass-hnsw-batched-distances-and-avx-512-4gd2</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Three changes to the HNSW search engine improve KNN throughput by up to 29% at high k, with over 20% gains under concurrent load. No API changes, no index rebuild, no configuration. Just faster searches.&lt;/p&gt;

&lt;h1&gt;
  
  
  Faster KNN search in Manticore
&lt;/h1&gt;

&lt;p&gt;Manticore's KNN search is built on top of &lt;a href="https://github.com/nmslib/hnswlib" rel="noopener noreferrer"&gt;hnswlib&lt;/a&gt;, an open-source HNSW implementation. Historically, most of our KNN work focused on custom distance functions, such as those used for binary quantization, rather than on hnswlib's core search loop. We also added features like &lt;a href="https://manual.manticoresearch.com/Searching/KNN#Filtering-strategies:-prefilter-vs.-postfilter" rel="noopener noreferrer"&gt;prefiltering with ACORN-1&lt;/a&gt; and &lt;a href="https://manual.manticoresearch.com/Searching/KNN#Filtering-strategies:-prefilter-vs.-postfilter" rel="noopener noreferrer"&gt;early termination&lt;/a&gt;, but the main search loop stayed the same: hnswlib still visited neighbors, computed distances, and maintained its set of candidates the same way.&lt;/p&gt;

&lt;p&gt;These changes go further, modifying hnswlib's core search loop itself - restructuring how it traverses neighbors, how it calls distance functions, and how it interacts with the CPU's memory hierarchy. Combined with new AVX-512 distance implementations in the columnar library, these changes target three sources of overhead: inefficient memory access patterns, redundant data loads, and indirect function call overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compile-time distance function specialization
&lt;/h2&gt;

&lt;p&gt;Previously, the distance function was a runtime function pointer stored in the HNSW index and called for every candidate. For large search budgets, that can mean a large number of indirect calls per query. Indirect calls prevent the compiler from inlining the distance function into the search loop, and they create branch prediction overhead.&lt;/p&gt;

&lt;p&gt;The new code resolves the distance function at compile time using C++ templates. When the search begins, a single switch statement selects the right template specialization based on the distance metric and quantization settings. From that point on, the entire inner loop - neighbor traversal, distance computation, candidate set updates - runs as one monolithic function with the distance calculation fully inlined. The compiler can now optimize register allocation, instruction scheduling, and loop unrolling across the distance computation boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  2-pass neighbor processing
&lt;/h2&gt;

&lt;p&gt;The HNSW algorithm explores the graph by visiting nodes and computing distances to their neighbors. In the original implementation, each neighbor was processed in a single pass: check if visited, fetch its vector data, compute distance, update the set of candidates. This meant that memory prefetch hints had little time to take effect before the data was needed.&lt;/p&gt;

&lt;p&gt;The new implementation splits this into two passes. Pass 1 iterates all neighbors of the current node, skips already-visited ones, and collects the unvisited neighbors into a small batch array. As each neighbor is added to the batch, a prefetch hint is issued for its vector data. Pass 2 iterates the batch and computes distances. By the time Pass 2 reaches each vector, the prefetch from Pass 1 has had time to bring the data into cache.&lt;/p&gt;

&lt;p&gt;Pass 2 walks a compact sequential array of candidate IDs, not the graph structure itself. The underlying vector loads are still scattered, but the data has been prefetched ahead of time.&lt;/p&gt;

&lt;p&gt;For unfiltered queries (no WHERE clause on the KNN search), the new code also takes a fast path that eliminates the per-candidate filter check entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batched distance computation
&lt;/h2&gt;

&lt;p&gt;The 2-pass structure helps in two ways: it gives prefetching more time to work, and it makes batching easy. Once Pass 2 has a compact list of candidates, it can score them two at a time instead of one by one.&lt;/p&gt;

&lt;p&gt;When scoring two candidates, the query vector is loaded once per SIMD iteration and reused for both distance computations, eliminating redundant loads.&lt;/p&gt;

&lt;p&gt;This reduces repeated query-side loads and lets the scoring loop process candidates in pairs, with a fallback for an odd remainder. Batch-2 functions are provided for inner product, L2, and their binary-quantized variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  AVX-512 support
&lt;/h2&gt;

&lt;p&gt;The new AVX-512 distance code processes 16 floats per iteration instead of 8 with AVX2. For inner product and L2 distance, the core loop uses fused multiply-add (&lt;code&gt;_mm512_fmadd_ps&lt;/code&gt;), which combines multiplication and accumulation in a single instruction. For binary-quantized vectors, the AVX-512 VPOPCNTDQ extension speeds up bit-counting operations used in distance calculation.&lt;/p&gt;

&lt;p&gt;Manticore now ships three library variants: a baseline build, an AVX2 build, and an AVX-512 build. At startup, Manticore detects the CPU's capabilities and loads the appropriate library automatically. No configuration is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark results
&lt;/h2&gt;

&lt;p&gt;The following benchmarks were run on the &lt;a href="https://storage.googleapis.com/ann-filtered-benchmark/datasets/dbpedia_openai_1M.tgz" rel="noopener noreferrer"&gt;dbpedia-openai-1M-1536-angular&lt;/a&gt; dataset (1M vectors, 1536 dimensions, cosine distance) on an AMD Ryzen 7 9700X (Zen 5, 8 physical cores / 16 logical cores). All data uses 1-bit binary quantization with oversampling and rescoring disabled. For multithreaded runs, throughput is reported as average per-thread queries per second: each worker runs its own batch of queries, its QPS is measured independently, and the final number is the average across workers. Each result is the average of 6 independent runs. Early termination was also disabled to isolate the effect of these optimizations on raw HNSW traversal.&lt;/p&gt;

&lt;p&gt;Zen 5 was chosen because it supports AVX-512 with native 512-bit datapaths, avoiding the split-512 execution behavior and heavy AVX-512 downclocking associated with some older Intel processors. This helps isolate the algorithmic effects of these changes from CPU-specific AVX-512 throttling behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Algorithmic improvements alone
&lt;/h3&gt;

&lt;p&gt;The first chart isolates the effect of the algorithmic changes (2-pass processing, batched distances, compile-time dispatch) by comparing the new AVX2 build against the previous AVX2 build. Both builds use the same SIMD instruction set, so the difference is purely from the new code structure.&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%2Fyyy05atim9h0xyis7xw6.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%2Fyyy05atim9h0xyis7xw6.png" alt=" " width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On a single thread, the gain grows steadily from +3% at k=10 to +24% at k=1000 as distance computation comes to dominate the search workload. With more threads competing for memory bandwidth, the per-thread gain shrinks: +9-10% at 4 or 8 threads, and only +2-5% at 16 threads.&lt;/p&gt;

&lt;p&gt;The 16-thread case is SMT (each physical core runs two threads). Distance computation is memory-bound, so when two threads share a core's L1/L2 caches, the prefetching and batching wins are partially absorbed by shared-resource contention. The algorithmic improvements still help, but the headroom shrinks.&lt;/p&gt;

&lt;h3&gt;
  
  
  SIMD width benefit (AVX-512 vs AVX2)
&lt;/h3&gt;

&lt;p&gt;The second chart isolates the effect of AVX-512 by comparing the AVX-512 build against the new AVX2 build (both share the same algorithmic improvements).&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%2F2s077qw27ggqh2gvo66h.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%2F2s077qw27ggqh2gvo66h.png" alt=" " width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AVX-512 is slightly slower than AVX2 at k=10 (around -2%) regardless of thread count. This is specific to AVX-512: the algorithmic improvements alone don't show this regression, so it's not a uniform per-query overhead. From k=30 upward, AVX-512 pulls ahead at every thread count.&lt;/p&gt;

&lt;p&gt;The interesting pattern is that AVX-512's benefit grows with thread count. Although this benchmark disables oversampling, the default Manticore KNN query uses &lt;code&gt;LIMIT 20&lt;/code&gt;, and with the default &lt;code&gt;oversampling=3.0&lt;/code&gt; (which multiplies the effective HNSW search budget for rescoring after quantized search) that becomes k=60 internally. At k=60, AVX-512 vs AVX2 (new) is +1.2% on a single thread, +2.6% at 4 threads, +3.4% at 8 threads, and +6.5% at 16 threads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combined improvement (AVX-512 vs the old code)
&lt;/h3&gt;

&lt;p&gt;The third chart shows the cumulative effect: AVX-512 with all the new code, compared against the previous AVX2 build. This is what a user upgrading from the previous Manticore version to the new one would see if their CPU supports AVX-512.&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%2Fajkius5p93q71qtg1wcm.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%2Fajkius5p93q71qtg1wcm.png" alt=" " width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The single-thread curve climbs from +0.5% at k=10 to +29% at k=1000. The multi-thread curves all reach +22-24% at k=1000. The improvement is broadly distributed across thread counts - the algorithmic and SIMD gains compose differently at different concurrency levels, but the combined result is consistently large at moderate-to-high k.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the gain grows with k
&lt;/h3&gt;

&lt;p&gt;All three charts show the same shape: small improvement at low k, large at high k. The reason is that low-k queries spend a larger share of their time on graph traversal (visiting nodes, checking visited bits, popping the candidate set) - work that scales with the graph structure, not k. As k grows, the effective search budget grows proportionally, and the queries spend more time on distance computation. The optimizations target distance computation and the loops around it, so their benefit scales with the share of work that distance computation represents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for you
&lt;/h2&gt;

&lt;p&gt;These improvements require no action. They are available in the recent &lt;a href="https://dev.to/blog/manticore-search-27-1-5/"&gt;Manticore Search 27.1.5 release&lt;/a&gt;; there are no API changes, no new configuration options, and no need to rebuild indexes.&lt;/p&gt;

&lt;p&gt;The gains stack with the &lt;a href="https://manual.manticoresearch.com/Searching/KNN#Early-termination" rel="noopener noreferrer"&gt;KNN early termination&lt;/a&gt;: early termination reduces the number of distance computations per query, and these optimizations make each computation faster.&lt;/p&gt;

&lt;p&gt;The biggest improvements show up with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High-dimensional vectors&lt;/strong&gt; (more arithmetic per distance computation, more SIMD benefit)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large k values&lt;/strong&gt; (more total distance computations, more opportunity for batching and cache optimization)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries with oversampling&lt;/strong&gt; (oversampling multiplies the effective k, pushing queries into the range where gains are largest)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Searching/KNN#Early-termination" rel="noopener noreferrer"&gt;KNN early termination documentation&lt;/a&gt; - how Manticore detects HNSW convergence and stops early&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Searching/KNN#Filtering-strategies:-prefilter-vs.-postfilter" rel="noopener noreferrer"&gt;KNN filtering documentation&lt;/a&gt; - how prefiltering works with ACORN-1&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Searching/KNN" rel="noopener noreferrer"&gt;KNN vector search reference&lt;/a&gt; - full syntax, parameters, quantization, rescoring&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Manticore Search 27.1.5: Authentication, sharded tables, conversational search and faster vector search</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Mon, 22 Jun 2026 02:19:45 +0000</pubDate>
      <link>https://dev.to/sanikolaev/manticore-search-2715-authentication-sharded-tables-conversational-search-and-faster-vector-204g</link>
      <guid>https://dev.to/sanikolaev/manticore-search-2715-authentication-sharded-tables-conversational-search-and-faster-vector-204g</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/install/"&gt;Manticore Search 27.1.5&lt;/a&gt; has been released. This release brings built-in authentication and authorization, sharded tables, conversational search, faster HNSW builds, better faceting and aggregations, and a long list of fixes across KNN, replication, protocol compatibility and other areas.&lt;/p&gt;

&lt;p&gt;This post is a catch-up for everything shipped from &lt;strong&gt;25.0.1 through 27.1.5&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Upgrade Notes
&lt;/h2&gt;

&lt;p&gt;Please review these before upgrading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;27.0.0 adds built-in auth/authz, and enabling it changes access assumptions.&lt;/strong&gt; Auth is not enabled by default, but once you enable it, anonymous access no longer works. Roll it out in stages: upgrade remote agents and replication peers first, then upgrade the masters that query or manage them, and enable auth only after the whole topology is on the new version. Distributed remote-agent and replication-related operations also need matching stored auth data across the participating daemons. A successful &lt;code&gt;JOIN CLUSTER&lt;/code&gt; replaces the joining node's local auth data with the donor cluster's auth data. (&lt;a href="https://github.com/manticoresoftware/manticoresearch/issues/2833" rel="noopener noreferrer"&gt;Issue #2833&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/pull/3648" rel="noopener noreferrer"&gt;PR #3648&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;26.0.0 changed replication storage layout.&lt;/strong&gt; Incoming replicated tables now live under the normal &lt;a href="https://manual.manticoresearch.com/Server_settings/Searchd#data_dir" rel="noopener noreferrer"&gt;&lt;code&gt;data_dir/&amp;lt;table&amp;gt;&lt;/code&gt;&lt;/a&gt; layout instead of the cluster &lt;code&gt;path&lt;/code&gt;. If you run replication clusters with a custom &lt;code&gt;path&lt;/code&gt;, you may need to move or re-synchronize replicated tables after upgrade. Downgrade is only safe before the new layout is adopted. (&lt;a href="https://github.com/manticoresoftware/manticoresearch/issues/4431" rel="noopener noreferrer"&gt;Issue #4431&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/pull/4598" rel="noopener noreferrer"&gt;PR #4598&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you manage MCL separately from the daemon, upgrade it together with Manticore.&lt;/strong&gt; This release line moves through several &lt;a href="https://github.com/manticoresoftware/columnar" rel="noopener noreferrer"&gt;MCL&lt;/a&gt; updates, from vector-performance work to multithreaded HNSW builds and later stability fixes. Mixing an older library with a newer daemon is not recommended. (&lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.2.0" rel="noopener noreferrer"&gt;25.2.0&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.15.0" rel="noopener noreferrer"&gt;25.15.0&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/26.0.3" rel="noopener noreferrer"&gt;26.0.3&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/26.3.2" rel="noopener noreferrer"&gt;26.3.2&lt;/a&gt;, &lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/27.1.0" rel="noopener noreferrer"&gt;27.1.0&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Built-in authentication and authorization
&lt;/h3&gt;

&lt;p&gt;Manticore now supports &lt;a href="https://manual.manticoresearch.com/Security/Authentication_and_authorization" rel="noopener noreferrer"&gt;users, passwords, bearer tokens, and fine-grained permissions&lt;/a&gt; across MySQL, HTTP/HTTPS, distributed remote agents, and replication-related operations. This makes access control a first-class part of the product instead of something that always has to be handled outside the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sharded tables
&lt;/h3&gt;

&lt;p&gt;Manticore can now create and manage &lt;a href="https://manual.manticoresearch.com/Creating_a_table/Creating_a_sharded_table/Creating_a_sharded_table" rel="noopener noreferrer"&gt;sharded tables&lt;/a&gt;, distribute inserts across shards, and handle more of the surrounding lifecycle in one place. That makes larger write-heavy deployments easier to operate and reduces the amount of sharding-specific logic that has to live outside the engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conversational search
&lt;/h3&gt;

&lt;p&gt;This release adds &lt;a href="https://manual.manticoresearch.com/Searching/Conversational_search" rel="noopener noreferrer"&gt;conversational search&lt;/a&gt; to Manticore Search. It is exposed through &lt;a href="https://manual.manticoresearch.com/Searching/Conversational_search" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE CHAT MODEL&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://manual.manticoresearch.com/Searching/Conversational_search" rel="noopener noreferrer"&gt;&lt;code&gt;CALL CHAT&lt;/code&gt;&lt;/a&gt;, so you can ask questions over an existing vectorized table instead of building a separate retrieval layer around the same data.&lt;/p&gt;

&lt;p&gt;Under the hood, Manticore Search runs KNN on a &lt;code&gt;FLOAT_VECTOR&lt;/code&gt; field, builds LLM context from that field's &lt;code&gt;from='...'&lt;/code&gt; source columns, keeps conversation history by &lt;code&gt;conversation_uuid&lt;/code&gt;, and returns both the answer and the supporting &lt;code&gt;sources&lt;/code&gt;. If you already keep embeddings in Manticore, this makes document Q&amp;amp;A and support-style assistants much easier to wire up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Faster vector builds and KNN improvements
&lt;/h3&gt;

&lt;p&gt;Vector search kept improving throughout this cycle.&lt;/p&gt;

&lt;p&gt;Manticore improved KNN performance, added local ONNX embeddings support, sped up ONNX inference, and then made HNSW build and rebuild work much faster with multithreaded index construction.&lt;/p&gt;

&lt;p&gt;A few important steps in that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.1.0" rel="noopener noreferrer"&gt;25.1.0&lt;/a&gt; improved KNN distance calculation and AVX-512 loading.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.2.0" rel="noopener noreferrer"&gt;25.2.0&lt;/a&gt; added local ONNX embeddings support in MCL and improved vector-search performance further.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.14.0" rel="noopener noreferrer"&gt;25.14.0&lt;/a&gt; and &lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.15.0" rel="noopener noreferrer"&gt;25.15.0&lt;/a&gt; added multithreaded HNSW builds together with the required library support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest practical improvement here is a much faster auto-embedding and shorter build and rebuild time for large vector tables. Initial KNN builds, chunk merges, and &lt;code&gt;ALTER TABLE ... REBUILD KNN&lt;/code&gt; are all affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Better faceting and aggregations
&lt;/h3&gt;

&lt;p&gt;Faceting and aggregations also became more useful.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Searching/Faceted_search" rel="noopener noreferrer"&gt;&lt;code&gt;facet_filter_mode&lt;/code&gt;&lt;/a&gt; makes it easier to build e-commerce-style filters that preserve selected, available, and unavailable buckets under active filtering.&lt;/p&gt;

&lt;p&gt;On the analytics side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Functions/Date_and_time_functions#DATE_HISTOGRAM%28%29" rel="noopener noreferrer"&gt;&lt;code&gt;date_histogram()&lt;/code&gt;&lt;/a&gt; gained &lt;code&gt;time_zone&lt;/code&gt; and &lt;code&gt;offset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Opensearch dashboards support&lt;/li&gt;
&lt;li&gt;Manticore added statistical aggregations such as &lt;code&gt;percentiles&lt;/code&gt;, &lt;code&gt;percentile_ranks&lt;/code&gt;, and &lt;code&gt;mad&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Other Notable Improvements
&lt;/h2&gt;

&lt;p&gt;This release line also includes several smaller but useful additions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Starting_the_server/Manually#searchd-command-line-options" rel="noopener noreferrer"&gt;&lt;code&gt;searchd --check&lt;/code&gt;&lt;/a&gt; validates configuration before startup without side effects.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Creating_a_cluster/Setting_up_replication/Managing_replication_nodes#EXIT-CLUSTER" rel="noopener noreferrer"&gt;&lt;code&gt;EXIT CLUSTER&lt;/code&gt;&lt;/a&gt; lets a node leave a replication cluster online without restarting.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#dict" rel="noopener noreferrer"&gt;&lt;code&gt;dict=keywords_32k&lt;/code&gt;&lt;/a&gt; makes it possible to index very long machine-generated tokens such as hashes and message IDs without silent truncation.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Morphology#morphology" rel="noopener noreferrer"&gt;built-in Ukrainian lemmatizer&lt;/a&gt; expands native morphology support for Ukrainian text search.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/manticoresoftware/manticoresearch/releases/tag/25.4.0" rel="noopener noreferrer"&gt;Systemd &lt;code&gt;Type=notify&lt;/code&gt;&lt;/a&gt; improves startup and shutdown supervision.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;searchd&lt;/code&gt; process under systemd management now logs to &lt;code&gt;systemd&lt;/code&gt; journal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JOIN&lt;/code&gt; queries now support explicit left-table column prefixes.&lt;/li&gt;
&lt;li&gt;OpenSearch Dashboards support.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;manticore-load&lt;/code&gt; gained multi-query support.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Bug Fixes
&lt;/h2&gt;

&lt;p&gt;This release line also includes &lt;strong&gt;65 changelog-listed fixes&lt;/strong&gt;. The latest follow-up releases added a few more worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;27.1.5 fixed a crash when fetching columnar &lt;code&gt;float_vector&lt;/code&gt; attributes.&lt;/li&gt;
&lt;li&gt;27.1.4 fixed &lt;code&gt;ALTER TABLE ... RECONFIGURE&lt;/code&gt; and &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; for one-way upgrades from &lt;code&gt;dict='keywords'&lt;/code&gt; to &lt;code&gt;dict=keywords_32k&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;27.1.3 updated Buddy to 4.0.1 and tightened Queue-plugin mutation permission handling under auth.&lt;/li&gt;
&lt;li&gt;KNN-by-&lt;code&gt;doc_id&lt;/code&gt; queries now preserve &lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;max_matches&lt;/code&gt; correctly.&lt;/li&gt;
&lt;li&gt;KNN rescoring order was fixed, so explicit &lt;code&gt;ORDER BY&lt;/code&gt; tie-breakers work again.&lt;/li&gt;
&lt;li&gt;Hybrid fused queries with &lt;code&gt;GROUP BY&lt;/code&gt; on columnar tables stopped crashing.&lt;/li&gt;
&lt;li&gt;Replication and node-rejoin crash paths were cleaned up further.&lt;/li&gt;
&lt;li&gt;Binary MySQL protocol behavior was fixed in 25.12.1, which matters for integrations that expect real client compatibility.&lt;/li&gt;
&lt;li&gt;Fluent Bit bulk-ingest interoperability was fixed, preventing successful responses from being replayed as duplicate inserts.&lt;/li&gt;
&lt;li&gt;27.1.2 fixed &lt;code&gt;sql_attr_multi&lt;/code&gt; handling for plain indexes built from multiple &lt;code&gt;source&lt;/code&gt; blocks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the complete list, see the &lt;a href="https://manual.manticoresearch.com/Changelog" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need help or want to connect?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Join our &lt;a href="https://slack.manticoresearch.com" rel="noopener noreferrer"&gt;Slack&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Visit the &lt;a href="https://forum.manticoresearch.com" rel="noopener noreferrer"&gt;Forum&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Report issues or suggest features on &lt;a href="https://github.com/manticoresoftware/manticoresearch/issues" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email us at &lt;code&gt;contact@manticoresearch.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>news</category>
      <category>security</category>
    </item>
    <item>
      <title>The Evolution of 'More Like This</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Tue, 02 Jun 2026 04:01:20 +0000</pubDate>
      <link>https://dev.to/sanikolaev/the-evolution-of-more-like-this-5225</link>
      <guid>https://dev.to/sanikolaev/the-evolution-of-more-like-this-5225</guid>
      <description>&lt;p&gt;In many search scenarios, the user does not start from an empty query box, but from an existing result.&lt;/p&gt;

&lt;p&gt;A user opens an article and wants to find related material. A buyer views a product card and looks for close alternatives. A support engineer investigates an incident and wants to see earlier cases with the same symptoms. In all these situations, the user already has a relevant document to start from.&lt;/p&gt;

&lt;p&gt;This scenario is traditionally called &lt;strong&gt;More Like This (MLT)&lt;/strong&gt;: a function for finding documents similar to the selected one. In this article, MLT means search that starts from a known document, not from a newly typed query.&lt;/p&gt;

&lt;p&gt;The classic MLT approach, or similar-document search, was based on comparing textual matches. Modern implementations increasingly use embeddings: numerical representations of documents. A search index stores embeddings as vectors, and the search system can find documents with close vector representations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short glossary
&lt;/h2&gt;

&lt;p&gt;To avoid repeating definitions throughout the article, here are the main terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning in this article&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;More Like This (MLT)&lt;/td&gt;
&lt;td&gt;search for documents similar to an already selected document&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;embedding&lt;/td&gt;
&lt;td&gt;a numerical representation of text, a product, an image, or another object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;embedding vector&lt;/td&gt;
&lt;td&gt;a numerical representation of an object, such as text or a product, stored in the index to find similar objects by vector proximity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KNN, nearest-neighbor search&lt;/td&gt;
&lt;td&gt;search for nearest neighbors, meaning objects with close vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ANN, approximate nearest neighbors&lt;/td&gt;
&lt;td&gt;approximate nearest-neighbor search; it speeds up KNN on large datasets without scanning every vector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG, Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;an approach where the search system retrieves context for a generative model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hybrid search&lt;/td&gt;
&lt;td&gt;combining full-text search and vector search in one scenario&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reranking&lt;/td&gt;
&lt;td&gt;an additional sorting step for already retrieved candidates using a more precise model or rule&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What classic More Like This did
&lt;/h2&gt;

&lt;p&gt;Classic MLT was lexical. It answered a simple question: which documents use similar important words?&lt;/p&gt;

&lt;p&gt;The process usually looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The search system took the source document.&lt;/li&gt;
&lt;li&gt;It analyzed its text.&lt;/li&gt;
&lt;li&gt;It selected informative terms.&lt;/li&gt;
&lt;li&gt;It built a query from those terms.&lt;/li&gt;
&lt;li&gt;It searched for documents with a similar set of words.&lt;/li&gt;
&lt;li&gt;It returned a list of similar documents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Internally, this used familiar full-text search mechanisms: TF-IDF or BM25, term frequency, stopwords, field boosts, and document-frequency limits. That is why older MLT implementations exposed parameters such as &lt;code&gt;min_term_freq&lt;/code&gt;, &lt;code&gt;min_doc_freq&lt;/code&gt;, &lt;code&gt;max_doc_freq&lt;/code&gt;, and &lt;code&gt;max_query_terms&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This was not just an interface element, but a full search mechanism. MLT was used for related articles and products, duplicate detection, support-ticket matching, legal search, patent research, and internal knowledge bases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the lexical approach is still strong
&lt;/h2&gt;

&lt;p&gt;Lexical MLT works well when specific words, identifiers, and stable formulations matter.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;error codes;&lt;/li&gt;
&lt;li&gt;product SKUs;&lt;/li&gt;
&lt;li&gt;part numbers;&lt;/li&gt;
&lt;li&gt;function names;&lt;/li&gt;
&lt;li&gt;stack traces;&lt;/li&gt;
&lt;li&gt;legal wording;&lt;/li&gt;
&lt;li&gt;nearly identical product or ticket descriptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason is that exact matching is critical here. If two incident reports contain the same error code or the same stack trace, full-text search sees a direct match. For example, when searching tickets with the code &lt;code&gt;ERR_404&lt;/code&gt;, lexical MLT quickly finds every mention of that code, while vector search may return tickets that describe similar but not identical problems.&lt;/p&gt;

&lt;p&gt;Lexical MLT had another advantage: it was cheap to run. The inverted index is already in the search engine. The analyzers are already configured. Ranking already works. There is no need to deploy separate search infrastructure just to support a “find similar” feature.&lt;/p&gt;

&lt;p&gt;The limitation is also clear. If two documents describe the same thing in different words, lexical MLT may fail to connect them. Synonyms work unevenly. Paraphrases are harder. Cross-lingual similarity is usually unavailable. For example, &lt;code&gt;memory leak&lt;/code&gt; and &lt;code&gt;unbounded heap growth&lt;/code&gt; may describe the same problem, but a standard analyzer sees different tokens.&lt;/p&gt;

&lt;p&gt;Lexical MLT efficiently finds documents with matching or similar wording. Semantic search helps when the meaning matches, not the words.&lt;/p&gt;

&lt;h2&gt;
  
  
  What embeddings change
&lt;/h2&gt;

&lt;p&gt;Using &lt;a href="https://manticoresearch.com/blog/vector-search-deep-dive/" rel="noopener noreferrer"&gt;embeddings&lt;/a&gt; — numerical representations of documents — changes the comparison principle: instead of words, the system compares vector representations.&lt;/p&gt;

&lt;p&gt;A document no longer has to be represented only as a set of weighted terms. It can be stored as a dense vector. Nearby vectors usually correspond to documents that are similar in meaning, even if they are written in different words.&lt;/p&gt;

&lt;p&gt;The lexical approach looks for matches by words and terms, while embedding search looks at the proximity of document vector representations. The first approach is optimal for exact matches such as error codes and SKUs. The second finds semantically close documents, even when they are phrased differently.&lt;/p&gt;

&lt;p&gt;This expands the scope of this kind of search. You can compare not only articles, but also products, images, code fragments, user events, or context fragments in a RAG system. In RAG, the search system first retrieves relevant context, and then the generative model uses that context to produce an answer.&lt;/p&gt;

&lt;p&gt;Lexical search does not disappear. Exact error codes, SKUs, names, statute references, and near duplicates are still better handled lexically. That is why production systems often use &lt;a href="https://manticoresearch.com/blog/hybrid-search/" rel="noopener noreferrer"&gt;hybrid search&lt;/a&gt;: full-text search provides exact matches, vector search adds results by meaning, filters constrain the search space, and reranking refines the final order.&lt;/p&gt;

&lt;p&gt;As shown in our &lt;a href="https://manticoresearch.com/blog/lexical-search-vs-vector-search/" rel="noopener noreferrer"&gt;comparison of lexical and vector search&lt;/a&gt;, the former wins on precise strict matches, while the latter improves coverage of semantic relationships.&lt;/p&gt;

&lt;h2&gt;
  
  
  MLT as lookup by a vector from the index
&lt;/h2&gt;

&lt;p&gt;If a vector representation has already been computed for a document and stored in the index, modern MLT can be described without a separate API example:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the source document.&lt;/li&gt;
&lt;li&gt;Retrieve its precomputed vector representation from the index.&lt;/li&gt;
&lt;li&gt;Find the nearest vectors.&lt;/li&gt;
&lt;li&gt;Return the documents those vectors belong to.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is still More Like This: the user starts from one document and gets related results. Only the comparison method changes. Instead of extracting terms, the search system uses the vector representation of the source document.&lt;/p&gt;

&lt;p&gt;In Manticore Search, this operation can be performed directly at the search-engine level: the query specifies the ID of the source document, and Manticore takes its embedding vector from the index and runs KNN search. The application does not need to fetch the vector separately, serialize hundreds or thousands of numbers, and send them back in a second request.&lt;/p&gt;

&lt;p&gt;A minimal SQL example looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knn_dist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;knn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;embedding&lt;/code&gt; is the field with the precomputed embedding vector, &lt;code&gt;123&lt;/code&gt; is the ID of the source document, and &lt;code&gt;10&lt;/code&gt; is the number of nearest documents to return. The &lt;code&gt;knn_dist()&lt;/code&gt; function returns the distance between vectors: a smaller value means greater semantic proximity to the source document. The same operation can be performed through the HTTP JSON API; the search logic does not change. The application passes the document ID, and Manticore performs lookup using that document’s vector from the index.&lt;/p&gt;

&lt;p&gt;For large datasets, KNN is usually implemented through an ANN index. This speeds up search through approximate computation and avoids scanning every vector. For the user, the important part is not the internal structure of the index, but the result: quickly finding documents that are close to the source in meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why search is better handled in the engine
&lt;/h2&gt;

&lt;p&gt;You can implement this scenario in the application: first fetch the document, then extract its vector, then send a separate KNN query, and then combine the result with filters.&lt;/p&gt;

&lt;p&gt;That approach makes the system architecture more complex. The application has to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pass the vector between services;&lt;/li&gt;
&lt;li&gt;prevent accidental logging;&lt;/li&gt;
&lt;li&gt;check the embedding model version;&lt;/li&gt;
&lt;li&gt;keep data synchronized with the main index;&lt;/li&gt;
&lt;li&gt;apply the same filters used in normal search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the search system performs the lookup itself, the path is shorter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application passes the ID of the source document.&lt;/li&gt;
&lt;li&gt;The search system finds the precomputed vector representation in the index.&lt;/li&gt;
&lt;li&gt;The search system runs nearest-neighbor search (KNN) or its approximate variant (ANN).&lt;/li&gt;
&lt;li&gt;The search system returns the found documents with the same access filters and metadata.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Benefits of this approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fewer inter-service requests from the application;&lt;/li&gt;
&lt;li&gt;large vectors do not have to be sent through external APIs;&lt;/li&gt;
&lt;li&gt;filters stay close to search;&lt;/li&gt;
&lt;li&gt;the result is easier to reproduce and debug;&lt;/li&gt;
&lt;li&gt;the application does not need an additional layer for similarity calculation — everything runs inside the search engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This will not fix poor embeddings or remove the need to tune ranking. But it reduces the number of interacting components in the search chain, which makes the system easier to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical examples and the evolution of MLT
&lt;/h2&gt;

&lt;p&gt;Search from an existing object is especially useful when the user has already found a relevant starting point.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Source object&lt;/th&gt;
&lt;th&gt;What to find&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Support&lt;/td&gt;
&lt;td&gt;ticket with an error&lt;/td&gt;
&lt;td&gt;past tickets with similar symptoms and related fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog&lt;/td&gt;
&lt;td&gt;product card&lt;/td&gt;
&lt;td&gt;close alternatives, similar models, or products from the same category&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;relevant fragment already found by the first search&lt;/td&gt;
&lt;td&gt;context expansion: neighboring sections of the same document, related documentation fragments, or similar discussions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer tools&lt;/td&gt;
&lt;td&gt;stack trace, diff, or bug description&lt;/td&gt;
&lt;td&gt;related code changes, discussions, and past incidents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In these examples, there is no need to type a new query manually. The system uses the source object as a reference point and finds documents similar to it lexically, semantically, or by both criteria.&lt;/p&gt;

&lt;p&gt;In the context of RAG, this is not about the primary search by the user’s query, but about subsequent context selection: the system has already found a relevant fragment and uses it as the reference object to collect surrounding context. This is useful when one fragment is too narrow: nearby content may include a term definition, a configuration example, a related discussion, or a neighboring section of the same guide.&lt;/p&gt;

&lt;p&gt;In systems with personalization or AI agents, it is important to clearly define which data is used for search: the system may consider the user’s search-query history, the context of previous interactions, or saved working notes. This makes it clear which data participates in retrieval and why the result is considered similar.&lt;/p&gt;

&lt;p&gt;The evolution of MLT can be described like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2000s&lt;/td&gt;
&lt;td&gt;MLT mostly relied on lexical analysis, TF-IDF, BM25, and term overlap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2010s&lt;/td&gt;
&lt;td&gt;Word2Vec and GloVe appeared and became widely used, making it possible to build semantic embeddings of words and texts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Early 2020s&lt;/td&gt;
&lt;td&gt;FAISS and similar ANN libraries made it possible to run vector search efficiently even on very large datasets.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mid-2020s&lt;/td&gt;
&lt;td&gt;RAG, recommendations, and search from an existing object made lookup by stored vectors a common product scenario.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The evolution of MLT is a shift from lexical comparison to matching document vector representations. But the practical request stayed the same: find documents relevant to the source result.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep in mind
&lt;/h2&gt;

&lt;p&gt;Semantic MLT does not replace all search engineering.&lt;/p&gt;

&lt;p&gt;Production systems still need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exact search for identifiers, error codes, and other strict matches;&lt;/li&gt;
&lt;li&gt;embedding model metadata and versioning;&lt;/li&gt;
&lt;li&gt;ACL filters: rules for document access by roles or users;&lt;/li&gt;
&lt;li&gt;tenant filters: data isolation between customers or workspaces;&lt;/li&gt;
&lt;li&gt;hybrid search when both meaning and exact matches matter;&lt;/li&gt;
&lt;li&gt;reranking when result order is critical;&lt;/li&gt;
&lt;li&gt;search-quality monitoring: precision and recall metrics, false-positive frequency, and missed relevant documents caused by ANN-index approximation errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lexical MLT can miss documents that use different words. Vector search sometimes returns overly broad results, or false positives, and can miss relevant documents because of the approximate nature of ANN indexes. That is why the quality of this kind of search should be evaluated on real queries and real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;More Like This has moved from purely lexical search to hybrid solutions that combine lexical, vector, and filtering mechanisms.&lt;/p&gt;

&lt;p&gt;The core concept remains the same: the user selects a source document, and the system finds materials relevant to it, taking both lexical and semantic similarity into account.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>algorithms</category>
      <category>machinelearning</category>
      <category>nlp</category>
    </item>
    <item>
      <title>KNN early termination in Manticore Search</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Mon, 01 Jun 2026 09:51:55 +0000</pubDate>
      <link>https://dev.to/sanikolaev/knn-early-termination-in-manticore-search-4fa5</link>
      <guid>https://dev.to/sanikolaev/knn-early-termination-in-manticore-search-4fa5</guid>
      <description>&lt;p&gt;Modern search engines do more than match keywords. When you search for "cozy mystery set in Paris" and get results for "atmospheric detective novel in France" that's vector search at work: documents and queries are converted into lists of numbers, called embeddings, and the search engine finds the documents whose numbers are closest to the query's.&lt;/p&gt;

&lt;p&gt;Manticore Search supports this natively. Under the hood, it uses a data structure called HNSW: a graph that connects nearby vectors, so it can find nearest neighbors quickly without scanning every document. That makes vector search fast enough to run on millions of documents in milliseconds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;But HNSW has an inefficiency. Early in the traversal, almost every distance computation finds a better candidate than the ones already in the result set.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As the search goes on, those improvements become rarer, but the algorithm keeps traversing the graph until it exhausts its exploration budget. By that point, the result set has often already converged, and the remaining work does little or nothing to improve it. Early termination fixes this by detecting that point and stopping early.&lt;/p&gt;

&lt;p&gt;The effect becomes more noticeable as &lt;code&gt;k&lt;/code&gt; grows, where &lt;code&gt;k&lt;/code&gt; is the number of nearest neighbors the query asks Manticore to return. Returning more neighbors requires more graph exploration, and much of that extra work happens after the result set has already stabilized. That also makes early termination more valuable, because it has more unnecessary work to cut.&lt;/p&gt;

&lt;p&gt;This gets more pronounced with &lt;a href="https://manual.manticoresearch.com/Searching/KNN#Vector-quantization" rel="noopener noreferrer"&gt;vector quantization&lt;/a&gt;. Quantization compresses stored vectors to save memory, which slightly lowers search precision. To recover it, Manticore uses &lt;a href="https://manual.manticoresearch.com/Searching/KNN#KNN-vector-search" rel="noopener noreferrer"&gt;oversampling&lt;/a&gt;: it fetches 3x more candidates than requested, then rescores them using the original full-precision vectors. With the default 3x oversampling, HNSW explores many more candidates per query. Large &lt;code&gt;k&lt;/code&gt; values often come from this kind of candidate expansion: an application may ask the vector index for hundreds or thousands of candidates, then rescore, rerank, or filter them down to a much smaller final result set to improve recall and precision. That raises latency, and early termination helps win some of it back.&lt;/p&gt;

&lt;p&gt;The waste is measurable. Benchmarks on a 1M-vector dataset show that with &lt;code&gt;k=60&lt;/code&gt;, which is the default result limit with default 3x oversampling, early termination reduces distance computations to about 65% of the full search. At &lt;code&gt;k=1000&lt;/code&gt;, computations drop to 30%. At &lt;code&gt;k=10000&lt;/code&gt;, just 20%. The search converges long before the exploration budget runs out, and the savings grow with &lt;code&gt;k&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Early termination lets Manticore detect this convergence and stop. The algorithm was designed with a specific precision target: lose no more than 2-4% of result set precision compared to a full HNSW search.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The algorithm tracks a simple signal: discovery rate - the fraction of distance computations that actually improve the result set.&lt;/p&gt;

&lt;p&gt;Each time a new node's distance is computed, one of two things happens: either it's good enough to enter the heap - the priority queue that holds the current best candidate neighbors - or it's worse than everything already there and gets discarded. Entering the heap counts as a "discovery." Early in the search, discoveries are frequent - the heap is filling up and most candidates are useful. As the search progresses and the heap saturates with good results, discoveries become rare. Most new distance computations just confirm that the algorithm has already found the best candidates.&lt;/p&gt;

&lt;p&gt;Manticore monitors this transition. After each round of neighbor expansion, it computes the discovery rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;discovery_rate &lt;span class="o"&gt;=&lt;/span&gt; new_candidates_collected / distances_computed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this rate stays below a threshold for several rounds in a row, the search stops. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The idea is simple: if the algorithm keeps computing distances but nothing improves the result, the search has converged.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The threshold: quantile-based adaptation
&lt;/h2&gt;

&lt;p&gt;That raises the obvious next question: what threshold should count as "low"? A fixed threshold wouldn't work well - different datasets and different regions of the same dataset have wildly different discovery rate distributions. What counts as "low" depends on context.&lt;/p&gt;

&lt;p&gt;Manticore uses a quantile-based adaptive threshold. Instead of comparing the discovery rate against a fixed number, it continuously estimates a low percentile of recent rounds (20th percentile, or 14th percentile for L2 distance) and uses that as the baseline. This keeps the method lightweight while letting it adapt to different datasets and different regions of the graph.&lt;/p&gt;

&lt;p&gt;In other words, the threshold adapts to the local search pattern. If the algorithm enters a sparse region of the graph, the threshold drops and avoids stopping too early. If it enters a richer region, the threshold rises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patience: how many bad rounds before stopping
&lt;/h2&gt;

&lt;p&gt;The threshold alone is not enough, though. A single round with a low discovery rate isn't enough to declare convergence. It could just be a temporary dip before the search finds a better path. Manticore uses a "patience counter" that requires multiple consecutive bad rounds before terminating.&lt;/p&gt;

&lt;p&gt;The patience value scales inversely with &lt;code&gt;ef&lt;/code&gt;, the HNSW exploration factor that controls how many candidates the search keeps exploring. For example, patience ranges from 9 at low &lt;code&gt;ef&lt;/code&gt; values down to 6 at very high &lt;code&gt;ef&lt;/code&gt;. Larger &lt;code&gt;ef&lt;/code&gt; values mean more total rounds, so even with lower patience the algorithm has seen more evidence before deciding to stop. The counter resets to zero whenever a round has a healthy discovery rate, so a single good round restarts the patience window. This prevents the algorithm from stopping during a temporary plateau that leads to a productive region of the graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm-up phase
&lt;/h2&gt;

&lt;p&gt;The algorithm ignores the termination signal while the heap is still filling up, meaning fewer than &lt;code&gt;ef&lt;/code&gt; candidates have been collected. During this phase, discovery rates are artificially high because almost everything enters the heap, so the signal is not useful. Early termination only starts once the heap is full and new candidates must replace existing ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark results
&lt;/h2&gt;

&lt;p&gt;The quantile thresholds were tuned to keep precision loss within 2–4%. They were tuned separately for L2 and cosine/IP distance metrics, and validated across both &lt;a href="https://manual.manticoresearch.com/Searching/KNN#Vector-quantization" rel="noopener noreferrer"&gt;quantized and non-quantized&lt;/a&gt; data.&lt;/p&gt;

&lt;p&gt;The following benchmarks were run on the &lt;a href="https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M" rel="noopener noreferrer"&gt;dbpedia-entities&lt;/a&gt; dataset (1M vectors, 768 dimensions) on a machine with 8 physical cores / 16 logical cores.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Precision" here means the fraction of true k-nearest neighbors that appear in the result set (with fixed k, this is the same as recall@k).&lt;/li&gt;
&lt;li&gt;"Precision ratio" is the precision of HNSW with early termination ("ET") divided by precision without it (1.0 means no precision loss). &lt;/li&gt;
&lt;li&gt;"Visit ratio" is the fraction of distance computations performed compared to full HNSW search (lower is better). &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Searching/KNN#KNN-vector-search" rel="noopener noreferrer"&gt;Oversampling and rescoring&lt;/a&gt; were disabled to isolate the effect of early termination on raw HNSW traversal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7unr6rkgltv8ws2y2sr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7unr6rkgltv8ws2y2sr.png" alt=" " width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The green line on the chart (precision) stays almost flat across all &lt;code&gt;k&lt;/code&gt; values, with precision ratio remaining above 0.97 throughout the benchmark. Meanwhile the orange line (visit ratio) drops steeply. At &lt;code&gt;k=100&lt;/code&gt;, it cuts distance computations nearly in half. At &lt;code&gt;k=1000&lt;/code&gt;, it saves 70%. At &lt;code&gt;k=10000&lt;/code&gt;, 80%.&lt;/p&gt;

&lt;p&gt;At &lt;code&gt;k &amp;lt;= 10&lt;/code&gt;, early termination is disabled because the search is already cheap and the savings are too small to justify any precision loss. The savings grow with &lt;code&gt;k&lt;/code&gt;, because larger result sets lead to more rounds of neighbor expansion and more chances to detect convergence early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance under concurrent load
&lt;/h2&gt;

&lt;p&gt;The benchmarks above show that early termination cuts distance computations a lot while preserving precision. But what does that mean for actual query latency, especially under concurrent load? The chart below shows latency ratios (ET / no ET) at 1, 8, and 16 concurrent threads on the same dbpedia dataset:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx61vv2acc8ytv5jd7dsa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx61vv2acc8ytv5jd7dsa.png" alt=" " width="800" height="461"&gt;&lt;/a&gt;&lt;br&gt;
At &lt;code&gt;k=1000&lt;/code&gt;, early termination reduces distance computations by 71% (ratio 0.29). The latency improvement depends on how many threads are running at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 thread:&lt;/strong&gt; 24% faster (ratio 0.76)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 threads:&lt;/strong&gt; 45% faster (ratio 0.55)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16 threads:&lt;/strong&gt; 48% faster (ratio 0.52)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The distance computation savings stay the same regardless of thread count, but the latency benefit nearly doubles from 1 to 16 threads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The main reason is lower pressure on the CPU memory system. Each distance computation pulls vector data and graph links into cache. When several threads run HNSW traversal at the same time, they compete for shared cache and memory bandwidth. Doing fewer distance computations per query reduces memory traffic, keeps each thread’s working set smaller, and lowers cache churn between queries. As a result, each thread finishes faster and interferes less with the others.&lt;/p&gt;

&lt;p&gt;Single-thread benchmarks understate the benefit of early termination. Under production-like concurrent load, the percentage latency reduction is roughly twice as large.&lt;/p&gt;
&lt;h2&gt;
  
  
  When early termination kicks in (and when it doesn't)
&lt;/h2&gt;

&lt;p&gt;Early termination is enabled by default and works on both quantized and non-quantized vector data. It is automatically disabled when &lt;code&gt;k &amp;lt;= 10&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The benefit grows with the effective exploration budget, which is &lt;code&gt;max(ef, k)&lt;/code&gt;. Since hnswlib uses this internally as the number of candidates it keeps in play, larger &lt;code&gt;k&lt;/code&gt; means more candidates, more rounds, and more chances to detect convergence.&lt;/p&gt;

&lt;p&gt;Quantized vectors are typically used with rescoring and oversampling (both enabled by default) to recover precision lost from quantization. Oversampling (default 3x) multiplies the effective &lt;code&gt;k&lt;/code&gt; passed to HNSW.  For example, a query with &lt;code&gt;k=100&lt;/code&gt; uses 300 candidates internally when oversampling is 3x. That larger search budget gives early termination more room to detect convergence and stop early. Since the performance benefit of early termination grows with &lt;code&gt;k&lt;/code&gt;, oversampling pushes queries into the range where the savings are largest.&lt;/p&gt;
&lt;h2&gt;
  
  
  Syntax
&lt;/h2&gt;

&lt;p&gt;Early termination is on by default. To disable it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL:&lt;/strong&gt;&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;-- default: early termination enabled&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knn_dist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;knn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;-- explicitly disable early termination&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knn_dist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;knn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;early_termination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;-- combine with other KNN options&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knn_dist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;knn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ef&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;early_termination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;JSON:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/search&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"knn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.33&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"early_termination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When to disable it
&lt;/h2&gt;

&lt;p&gt;There are a few scenarios where you might want to turn early termination off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Maximum precision is critical.&lt;/strong&gt; Early termination trades a small amount of recall for speed. If your application requires the absolute best recall that HNSW can provide at a given &lt;code&gt;ef&lt;/code&gt;, disable it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small k values (&amp;lt;= 30).&lt;/strong&gt; The algorithm auto-disables for &lt;code&gt;k &amp;lt;= 10&lt;/code&gt;, but even for &lt;code&gt;k&lt;/code&gt; between 11 and 30, the performance benefit is modest. If you notice any recall difference in this range, disabling early termination costs little in latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarking HNSW recall.&lt;/strong&gt; If you are measuring HNSW recall, you probably want deterministic behavior without adaptive shortcuts. Disable early termination to get a clean baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it relates to other KNN optimizations
&lt;/h2&gt;

&lt;p&gt;Early termination is one of several optimizations that Manticore applies to KNN search. It works independently of and stacks with the others:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://manticoresearch.com/blog/knn-prefiltering/" rel="noopener noreferrer"&gt;Prefiltering&lt;/a&gt; reduces wasted work by skipping filtered-out documents during HNSW traversal. Early termination reduces wasted work by stopping the traversal once the result set has converged. They solve different problems and work well together.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manticoresearch.com/blog/quantization/#why-oversampling--rescoring-matters" rel="noopener noreferrer"&gt;Oversampling&lt;/a&gt; retrieves more candidates than &lt;code&gt;k&lt;/code&gt; to improve recall after rescoring. Early termination can reduce the cost of that expanded search by stopping once enough good candidates have been found.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manticoresearch.com/blog/quantization/#why-oversampling--rescoring-matters" rel="noopener noreferrer"&gt;Rescoring&lt;/a&gt; recalculates distances using full-precision vectors after the initial search with quantized vectors. Early termination operates during the initial quantized search phase, reducing the number of candidates evaluated before rescoring kicks in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic brute-force fallback&lt;/strong&gt; skips HNSW entirely when a linear scan is cheaper. Early termination only applies when HNSW is actually used.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>algorithms</category>
      <category>database</category>
      <category>machinelearning</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Make xt850 Match xt 850</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Fri, 08 May 2026 05:30:14 +0000</pubDate>
      <link>https://dev.to/sanikolaev/how-to-make-xt850-match-xt-850-o15</link>
      <guid>https://dev.to/sanikolaev/how-to-make-xt850-match-xt-850-o15</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Since version &lt;code&gt;23.0.0&lt;/code&gt;, Manticore can make searches like &lt;code&gt;xt850&lt;/code&gt; match &lt;code&gt;xt 850&lt;/code&gt; using &lt;a href="https://manual.manticoresearch.com/dev/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_delimiter" rel="noopener noreferrer"&gt;bigram_delimiter&lt;/a&gt; together with digit-aware &lt;a href="https://manual.manticoresearch.com/dev/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_index" rel="noopener noreferrer"&gt;bigram_index&lt;/a&gt; modes.&lt;/p&gt;

&lt;p&gt;This solves a common tokenization mismatch in product search, where users remove spaces from model names but the source data stores them as separate tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assumptions and verification
&lt;/h2&gt;

&lt;p&gt;This article assumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RT tables created with SQL examples exactly as shown&lt;/li&gt;
&lt;li&gt;default tokenization unless the example explicitly changes a setting&lt;/li&gt;
&lt;li&gt;ASCII digits in model names, because &lt;code&gt;second_numeric&lt;/code&gt; and &lt;code&gt;second_has_digit&lt;/code&gt; are digit-aware modes built around &lt;code&gt;0-9&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All SQL examples and expected outputs in this article were verified against a real Manticore &lt;code&gt;23.0.0&lt;/code&gt; instance before publishing, using fresh tables created from scratch for each scenario.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader search problem
&lt;/h2&gt;

&lt;p&gt;Imagine a catalog containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;xt 850 action camera&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;iphone 5se battery case&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;canon eos 80d body&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;thinkpad x1 carbon&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now imagine users searching for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;xt850&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;iphone5se&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eos80d&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;thinkpadx1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the user's point of view, these should obviously match. From the engine's point of view, they often do not, because the indexed text is tokenized as separate terms.&lt;/p&gt;

&lt;p&gt;Search systems usually attack that mismatch in one of four ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;index prefixes or infixes&lt;/li&gt;
&lt;li&gt;add custom normalization rules&lt;/li&gt;
&lt;li&gt;duplicate content into alternate normalized fields&lt;/li&gt;
&lt;li&gt;index adjacent token pairs and optionally store glued variants too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Manticore's newer bigram functionality is a structured way to do the fourth option without awkward field duplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Baseline: why &lt;code&gt;xt850&lt;/code&gt; fails by default
&lt;/h2&gt;

&lt;p&gt;Here is the problem in its simplest form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_default_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_default_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_default_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'xt 850 action camera'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_default_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xt850'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected result:&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="n"&gt;Empty&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does this fail?&lt;/p&gt;

&lt;p&gt;Because the document is indexed as two separate tokens, &lt;code&gt;xt&lt;/code&gt; and &lt;code&gt;850&lt;/code&gt;, while the query is a single token, &lt;code&gt;xt850&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;By default, Manticore does not assume that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;xt850&lt;/code&gt; should be split into &lt;code&gt;xt&lt;/code&gt; + &lt;code&gt;850&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;or &lt;code&gt;xt&lt;/code&gt; + &lt;code&gt;850&lt;/code&gt; should also be searchable as &lt;code&gt;xt850&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this is not really a typo-tolerance problem or a phrase problem. It is a tokenization mismatch: the index sees two tokens, while the query provides one.&lt;/p&gt;

&lt;p&gt;That is the gap the newer bigram settings are designed to close. They let Manticore index selected adjacent token pairs in a form that can also match glued queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bigrams help here
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/dev/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_index" rel="noopener noreferrer"&gt;bigram_index&lt;/a&gt; can help with both &lt;a href="https://dev.to/blog/how-to-speed-up-phrase-search-with-bigram-index/"&gt;phrase acceleration&lt;/a&gt; and model-name matching, and in this article we focus on the &lt;code&gt;xt 850&lt;/code&gt; vs &lt;code&gt;xt850&lt;/code&gt; problem.&lt;/p&gt;

&lt;p&gt;The key idea is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;detect adjacent token pairs that look like model names&lt;/li&gt;
&lt;li&gt;store those pairs in a glued form too&lt;/li&gt;
&lt;li&gt;let queries such as &lt;code&gt;xt850&lt;/code&gt;, &lt;code&gt;iphone5se&lt;/code&gt;, or &lt;code&gt;thinkpadx1&lt;/code&gt; hit the spaced text&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where &lt;a href="https://manual.manticoresearch.com/dev/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_delimiter" rel="noopener noreferrer"&gt;bigram_delimiter&lt;/a&gt; matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note about &lt;a href="https://manual.manticoresearch.com/dev/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_delimiter" rel="noopener noreferrer"&gt;bigram_delimiter&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bigram_index&lt;/code&gt; decides which adjacent pairs are eligible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bigram_delimiter&lt;/code&gt; decides how eligible bigrams are stored:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;true&lt;/code&gt;: internal delimited token only&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;none&lt;/code&gt;: glued token only, such as &lt;code&gt;galaxy24&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;both&lt;/code&gt;: both forms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical difference is easiest to understand from the query side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;with &lt;code&gt;true&lt;/code&gt;, Manticore keeps the internal bigram form used for phrase optimization, but it does not keep the glued user-facing form, so a query like &lt;code&gt;xt850&lt;/code&gt; will not match &lt;code&gt;xt 850&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;none&lt;/code&gt;, Manticore keeps only the glued form, so &lt;code&gt;xt850&lt;/code&gt; can match &lt;code&gt;xt 850&lt;/code&gt;, but you are leaning entirely on the glued representation for those pairs&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;both&lt;/code&gt;, Manticore keeps both the internal bigram representation and the glued form, so &lt;code&gt;xt850&lt;/code&gt; can match &lt;code&gt;xt 850&lt;/code&gt; without giving up ordinary phrase behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For this use case, &lt;code&gt;both&lt;/code&gt; is usually the safer default because it covers the user-visible problem directly while keeping behavior less surprising for normal phrase queries and mixed workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 1: &lt;code&gt;second_numeric&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;bigram_index&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;second_numeric&lt;/span&gt;
&lt;span class="py"&gt;bigram_delimiter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;both&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This mode is aimed at model names where the second token is purely numeric.&lt;/p&gt;

&lt;p&gt;That is common in product catalogs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;xt 850&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;galaxy 24&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;playstation 5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pixel 8&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea is simple: users often search these as glued terms such as &lt;code&gt;xt850&lt;/code&gt;, &lt;code&gt;galaxy24&lt;/code&gt;, or &lt;code&gt;playstation5&lt;/code&gt;, even though the source text stores them with a space.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;second_numeric&lt;/code&gt; stores the pair only when the second token is ASCII digits only.&lt;/p&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you have product generations and numbered models&lt;/li&gt;
&lt;li&gt;users often remove spaces in search&lt;/li&gt;
&lt;li&gt;the second token is usually just digits&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;bigram_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'second_numeric'&lt;/span&gt;
  &lt;span class="n"&gt;bigram_delimiter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'both'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'xt 850 action camera'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'galaxy 24 ultra'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'playstation 5 slim'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'iphone 5se case'&lt;/span&gt;&lt;span class="p"&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="s1"&gt;'canon eos 80d body'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'thinkpad x1 carbon'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test the queries one by one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xt850'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+----------------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;                &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+----------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;xt&lt;/span&gt; &lt;span class="mi"&gt;850&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+----------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'galaxy24'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+-----------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;           &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+-----------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;galaxy&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="n"&gt;ultra&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+-----------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'playstation5'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+--------------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+--------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;playstation&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="n"&gt;slim&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+--------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'iphone5se'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Empty&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'eos80d'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Empty&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_numeric_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'thinkpadx1'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Empty&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That boundary is the whole point of the mode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;24&lt;/code&gt; and &lt;code&gt;5&lt;/code&gt; qualify&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;5se&lt;/code&gt;, &lt;code&gt;80d&lt;/code&gt;, and &lt;code&gt;x1&lt;/code&gt; do not&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mode 2: &lt;code&gt;second_has_digit&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;bigram_index&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;second_has_digit&lt;/span&gt;
&lt;span class="py"&gt;bigram_delimiter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;both&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This mode is the more flexible sibling of &lt;code&gt;second_numeric&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It stores the pair when the second token contains at least one ASCII digit. That makes it a much better fit for real product catalogs, where model identifiers are often mixed alphanumeric strings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;xt 850&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;iphone 5se&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eos 80d&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;thinkpad x1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your model names mix letters and digits&lt;/li&gt;
&lt;li&gt;users frequently remove spaces in their searches&lt;/li&gt;
&lt;li&gt;you want catalog-friendly matching without indexing every pair in the table&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;bigram_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'second_has_digit'&lt;/span&gt;
  &lt;span class="n"&gt;bigram_delimiter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'both'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'xt 850 action camera'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'galaxy 24 ultra'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'playstation 5 slim'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'iphone 5se case'&lt;/span&gt;&lt;span class="p"&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="s1"&gt;'canon eos 80d body'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'thinkpad x1 carbon'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'kindle paperwhite signature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test the queries one by one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xt850'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+----------------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;                &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+----------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;xt&lt;/span&gt; &lt;span class="mi"&gt;850&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+----------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'galaxy24'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+-----------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;           &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+-----------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;galaxy&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="n"&gt;ultra&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+-----------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'iphone5se'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;iphone&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;se&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'eos80d'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;canon&lt;/span&gt; &lt;span class="n"&gt;eos&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'thinkpadx1'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;span class="o"&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;title&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;thinkpad&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="n"&gt;carbon&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;------+---------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_second_has_digit_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kindlesignature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Empty&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is often the better fit for mixed model identifiers, because real catalog data frequently includes forms like &lt;code&gt;5se&lt;/code&gt;, &lt;code&gt;80d&lt;/code&gt;, or &lt;code&gt;x1&lt;/code&gt; rather than only clean numeric suffixes like &lt;code&gt;24&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose between the two
&lt;/h2&gt;

&lt;p&gt;If your search problem is specifically "How do I make &lt;code&gt;xt850&lt;/code&gt; find &lt;code&gt;xt 850&lt;/code&gt;?", the practical rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;second_numeric&lt;/code&gt; when the second token is digits-only&lt;/li&gt;
&lt;li&gt;use &lt;code&gt;second_has_digit&lt;/code&gt; when the second token may be mixed, like &lt;code&gt;5se&lt;/code&gt;, &lt;code&gt;80d&lt;/code&gt;, or &lt;code&gt;x1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is one practical caveat: this is compatible with other common text-processing settings in the straightforward case. &lt;code&gt;xt 850&lt;/code&gt; still matches &lt;code&gt;xt850&lt;/code&gt; with &lt;code&gt;morphology='stem_en'&lt;/code&gt; enabled and with a wordforms rule enabled.&lt;/p&gt;

&lt;p&gt;But that does not mean those settings rewrite the glued query for you. In tests, &lt;code&gt;iphones 5&lt;/code&gt; matched &lt;code&gt;iphones5&lt;/code&gt;, but not &lt;code&gt;iphone5&lt;/code&gt;, even with stemming or a wordforms rule mapping &lt;code&gt;iphones&lt;/code&gt; to &lt;code&gt;iphone&lt;/code&gt;. So the short version is: basic &lt;code&gt;xt 850&lt;/code&gt; vs &lt;code&gt;xt850&lt;/code&gt; matching stays compatible with morphology and wordforms, but if you rely on them, test the exact query shape you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;xt850&lt;/code&gt; problem is not really about one product name. It is about a broader mismatch between how users type model names and how search engines tokenize them.&lt;/p&gt;

&lt;p&gt;Since version &lt;code&gt;23.0.0&lt;/code&gt;, Manticore gives you a built-in way to handle that mismatch with &lt;code&gt;bigram_delimiter&lt;/code&gt; plus the digit-aware &lt;code&gt;bigram_index&lt;/code&gt; modes, which is much cleaner than duplicating fields or inventing custom preprocessing pipelines.&lt;/p&gt;

&lt;p&gt;If your main problem is phrase-search performance rather than glued model-name matching, see &lt;a href="https://manticoresearch.com/blog/how-to-speed-up-phrase-search-with-bigram-index/" rel="noopener noreferrer"&gt;How to Speed Up Phrase Search with bigram_index&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>nlp</category>
      <category>sql</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Speed Up Phrase Search with bigram_index</title>
      <dc:creator>Sergey Nikolaev</dc:creator>
      <pubDate>Thu, 07 May 2026 08:50:15 +0000</pubDate>
      <link>https://dev.to/sanikolaev/how-to-speed-up-phrase-search-with-bigramindex-l4f</link>
      <guid>https://dev.to/sanikolaev/how-to-speed-up-phrase-search-with-bigramindex-l4f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_index" rel="noopener noreferrer"&gt;bigram_index&lt;/a&gt; can be used for several purposes, and in this article we focus specifically on phrase-search performance: on the 1M-document benchmark below, &lt;code&gt;bigram_index='all'&lt;/code&gt; improved QPS by about &lt;code&gt;2.9x&lt;/code&gt; and cut average phrase-query latency by about &lt;code&gt;3.2x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your main problem is matching &lt;code&gt;xt850&lt;/code&gt; against &lt;code&gt;xt 850&lt;/code&gt; rather than speeding up phrase search, see &lt;a href="https://manticoresearch.com/blog/how-to-make-searches-like-xt850-match-xt-850/" rel="noopener noreferrer"&gt;How to Make xt850 Match xt 850&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Phrase search can be expensive. Even when a query is short, the engine still has to verify ordering and adjacency, and that work gets more noticeable when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the individual words are common&lt;/li&gt;
&lt;li&gt;the dataset is large&lt;/li&gt;
&lt;li&gt;phrase queries are frequent in your workload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly what &lt;a href="https://manual.manticoresearch.com/Creating_a_table/NLP_and_tokenization/Low-level_tokenization#bigram_index" rel="noopener noreferrer"&gt;bigram_index&lt;/a&gt; is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bigram indexing actually does
&lt;/h2&gt;

&lt;p&gt;Normally, a phrase like &lt;code&gt;"noise cancelling headphones"&lt;/code&gt; is handled as separate tokens that also need to appear in the right order and next to each other. Bigram indexing lets Manticore pre-store adjacent token pairs such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;noise cancelling&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cancelling headphones&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives the engine a faster way to narrow down candidate documents during phrase matching.&lt;/p&gt;

&lt;p&gt;This article focuses specifically on phrase acceleration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Important caveat: bigrams work at tokenization level
&lt;/h2&gt;

&lt;p&gt;This is the part that is easy to miss when you only look at the happy-path speedup story.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bigram_index&lt;/code&gt; works at the tokenization level only. It does not account for later transformations such as morphology, wordforms, or stopwords, and that can materially change phrase-matching expectations.&lt;/p&gt;

&lt;p&gt;The practical conclusion is simple: bigrams can be excellent for phrase speed, but if your index relies heavily on morphology, wordforms, or stopwords, test the actual phrase behavior you care about before rolling the setting out broadly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 1: Default behavior
&lt;/h2&gt;

&lt;p&gt;This is the baseline. No explicit bigram indexing is enabled, so no bigram posting lists are stored.&lt;/p&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;phrase search is rare&lt;/li&gt;
&lt;li&gt;documents are short&lt;/li&gt;
&lt;li&gt;you want the leanest indexing path&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_none_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_none_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_none_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'wireless noise cancelling headphones'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'noise cancelling microphone'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'wireless gaming headset'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_none_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"noise cancelling"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the baseline behavior. The query matches the expected rows, but Manticore has no precomputed bigram posting lists to help resolve the phrase more efficiently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 2: &lt;code&gt;all&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;bigram_index&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most aggressive phrase-acceleration mode. Every adjacent token pair gets indexed as a bigram.&lt;/p&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exact phrase search is a core feature&lt;/li&gt;
&lt;li&gt;phrase queries often include common words and produce many candidates&lt;/li&gt;
&lt;li&gt;you want the strongest phrase acceleration&lt;/li&gt;
&lt;li&gt;you do not want to tune a frequent-word list&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_all_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_all_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;bigram_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'all'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_all_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'lord of the rings trilogy'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'house of the dragon season 2'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'made for iphone charger'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_all_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"house of the dragon"'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_all_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"made for iphone"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important point here is not different matches, but different indexing strategy: &lt;code&gt;all&lt;/code&gt; stores every adjacent pair, so phrase queries have the maximum amount of bigram help available at search time.&lt;/p&gt;

&lt;p&gt;The reason to choose &lt;code&gt;all&lt;/code&gt; is when phrase search becomes more expensive because many documents match the individual words, and Manticore then has to do more positional verification to confirm the exact phrase. &lt;code&gt;all&lt;/code&gt; helps by narrowing candidates earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 3: &lt;code&gt;first_freq&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;bigram_index&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;first_freq&lt;/span&gt;
&lt;span class="py"&gt;bigram_freq_words&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;for, of, the, with&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This mode stores a pair only when the first token is in your frequent-word list.&lt;/p&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;phrase search matters&lt;/li&gt;
&lt;li&gt;you want a lighter alternative to &lt;code&gt;all&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;many phrases in your data contain words that are genuinely frequent in your own corpus&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the list above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;for iphone&lt;/code&gt; is eligible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;of the&lt;/code&gt; is eligible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;the dragon&lt;/code&gt; is eligible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;made for&lt;/code&gt; is not eligible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lord of&lt;/code&gt; is not eligible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For production use, do not pick &lt;code&gt;bigram_freq_words&lt;/code&gt; from memory. Derive it from your own data. A practical way is to dump dictionary stats with &lt;a href="https://manual.manticoresearch.com/Miscellaneous_tools#indextool" rel="noopener noreferrer"&gt;indextool&lt;/a&gt; using &lt;code&gt;--dumpdict ... --stats&lt;/code&gt;, review the most frequent tokens, and then build a small &lt;code&gt;bigram_freq_words&lt;/code&gt; list from those results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_first_freq_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_first_freq_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;bigram_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'first_freq'&lt;/span&gt;
  &lt;span class="n"&gt;bigram_freq_words&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'for,of,the,with'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_first_freq_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'made for iphone charger'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'lord of the rings trilogy'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'house of the dragon season 2'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_first_freq_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"made for iphone"'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_first_freq_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"lord of the"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queries still return the expected rows. What changes is which pairs get indexed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"made for iphone"&lt;/code&gt; benefits from &lt;code&gt;for iphone&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"lord of the"&lt;/code&gt; benefits from &lt;code&gt;of the&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes &lt;code&gt;first_freq&lt;/code&gt; a lighter alternative to &lt;code&gt;all&lt;/code&gt; when many useful phrases involve common bridge words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 4: &lt;code&gt;both_freq&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;bigram_index&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;both_freq&lt;/span&gt;
&lt;span class="py"&gt;bigram_freq_words&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;for, of, the, with&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the narrowest frequency-based mode. A pair is stored only when both tokens are in the frequent-word list.&lt;/p&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you want the most conservative bigram footprint&lt;/li&gt;
&lt;li&gt;you mainly care about pairs built from words that are highly frequent in your corpus&lt;/li&gt;
&lt;li&gt;you are tuning a large corpus and do not want to index every adjacent pair&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the same list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;of the&lt;/code&gt; is eligible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;for iphone&lt;/code&gt; is not eligible&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;the dragon&lt;/code&gt; is not eligible&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bi_both_freq_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bi_both_freq_demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;bigram_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'both_freq'&lt;/span&gt;
  &lt;span class="n"&gt;bigram_freq_words&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'for,of,the,with'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bi_both_freq_demo&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'lord of the rings trilogy'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'house of the dragon season 2'&lt;/span&gt;&lt;span class="p"&gt;),&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="s1"&gt;'made for iphone charger'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_both_freq_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"lord of the"'&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bi_both_freq_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"made for iphone"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queries still match, but the internal selectivity differs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"lord of the"&lt;/code&gt; includes &lt;code&gt;of the&lt;/code&gt;, which &lt;code&gt;both_freq&lt;/code&gt; is willing to store&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"made for iphone"&lt;/code&gt; includes &lt;code&gt;for iphone&lt;/code&gt;, which &lt;code&gt;first_freq&lt;/code&gt; would cover but &lt;code&gt;both_freq&lt;/code&gt; would not&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which performance mode should you choose?
&lt;/h2&gt;

&lt;p&gt;The benchmark in this article shows that &lt;code&gt;all&lt;/code&gt; can deliver a strong speedup, but it is still just one benchmark on one workload.&lt;/p&gt;

&lt;p&gt;Manticore's own documentation says that for most use cases, &lt;code&gt;both_freq&lt;/code&gt; is the best mode. That is a sensible default because it aims for a more balanced trade-off between phrase acceleration and indexing cost.&lt;/p&gt;

&lt;p&gt;Use the modes like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;choose &lt;code&gt;both_freq&lt;/code&gt; as the default starting point for general phrase-search workloads&lt;/li&gt;
&lt;li&gt;choose &lt;code&gt;all&lt;/code&gt; when phrase search is especially important and you want the strongest acceleration, accepting higher indexing cost&lt;/li&gt;
&lt;li&gt;choose &lt;code&gt;first_freq&lt;/code&gt; when many useful phrases in your data involve common bridge words and you want something broader than &lt;code&gt;both_freq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;choose the default behavior when phrase acceleration is not important&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Benchmark: does bigram indexing really speed up phrase search?
&lt;/h2&gt;

&lt;p&gt;Yes. In a simple local benchmark, the difference was easy to measure.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;manticore-load&lt;/code&gt; to build two 1M-document tables against the same Manticore instance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one with no explicit &lt;code&gt;bigram_index&lt;/code&gt; setting&lt;/li&gt;
&lt;li&gt;one with &lt;code&gt;bigram_index='all'&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The documents were random 60-80 word texts, and the benchmark repeatedly ran random 2-word phrase queries.&lt;/p&gt;

&lt;p&gt;For clarity, both indexing and search were run with &lt;code&gt;--threads=1&lt;/code&gt;. Multi-threaded numbers would of course be higher, but single-thread runs make it easier to see what the feature changes on one CPU core.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="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;FROM&lt;/span&gt; &lt;span class="n"&gt;bench_bigram_&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"&amp;lt;text/2/2&amp;gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Benchmark setup
&lt;/h3&gt;

&lt;p&gt;Data load without bigrams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;manticore-load &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--drop&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--batch-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CREATE TABLE bench_bigram_none_rand(title text)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"INSERT INTO bench_bigram_none_rand(id,title) VALUES(&amp;lt;increment&amp;gt;,'&amp;lt;text/60/80&amp;gt;')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data load with all bigrams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;manticore-load &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--drop&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--batch-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CREATE TABLE bench_bigram_all_rand(title text) bigram_index='all'"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"INSERT INTO bench_bigram_all_rand(id,title) VALUES(&amp;lt;increment&amp;gt;,'&amp;lt;text/60/80&amp;gt;')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search benchmark without bigrams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;manticore-load &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"SELECT COUNT(*) FROM bench_bigram_none_rand WHERE MATCH('&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;text/2/2&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search benchmark with all bigrams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;manticore-load &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"SELECT COUNT(*) FROM bench_bigram_all_rand WHERE MATCH('&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;text/2/2&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What I observed
&lt;/h3&gt;

&lt;p&gt;On this local run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;QPS&lt;/th&gt;
&lt;th&gt;Avg latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bench_bigram_none_rand&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;755&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.3 ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bench_bigram_all_rand&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2175&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.4 ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is roughly a &lt;code&gt;2.9x&lt;/code&gt; improvement in QPS and about a &lt;code&gt;3.2x&lt;/code&gt; improvement in average latency on the same 1M-document workload.&lt;/p&gt;

&lt;p&gt;Indexing was slower with &lt;code&gt;bigram_index='all'&lt;/code&gt;, which is expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;without bigrams: about &lt;code&gt;45k docs/sec&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;all&lt;/code&gt;: about &lt;code&gt;17k docs/sec&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That trade-off is exactly why multiple modes exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;If your main problem is phrase-search performance, treat &lt;code&gt;bigram_index&lt;/code&gt; first and foremost as an acceleration feature.&lt;/p&gt;

&lt;p&gt;For most real workloads, start with &lt;code&gt;both_freq&lt;/code&gt; and measure. Move to &lt;code&gt;all&lt;/code&gt; if you need a stronger effect and can afford the extra indexing cost. Consider &lt;code&gt;first_freq&lt;/code&gt; when your phrase workload is heavily shaped by common bridge words.&lt;/p&gt;

</description>
      <category>database</category>
      <category>nlp</category>
      <category>performance</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
