<?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: Prithvi S</title>
    <description>The latest articles on DEV Community by Prithvi S (@iprithv).</description>
    <link>https://dev.to/iprithv</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%2F3869317%2Fe48d8dde-3457-4eca-881a-f414fac5b86e.jpg</url>
      <title>DEV Community: Prithvi S</title>
      <link>https://dev.to/iprithv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iprithv"/>
    <language>en</language>
    <item>
      <title>The Inner Workings of OpenSearch: From Query to Fetch, Plugins to Performance</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:30:04 +0000</pubDate>
      <link>https://dev.to/iprithv/the-inner-workings-of-opensearch-from-query-to-fetch-plugins-to-performance-1n1a</link>
      <guid>https://dev.to/iprithv/the-inner-workings-of-opensearch-from-query-to-fetch-plugins-to-performance-1n1a</guid>
      <description>&lt;p&gt;OpenSearch has become the go‑to open‑source search and analytics engine for many modern data‑intensive applications.  While most users interact with it through simple REST calls, the system behind the scenes is a sophisticated, distributed architecture that balances latency, relevance, and scalability.  This article walks through the core components that make OpenSearch tick, how you can extend it with plugins, and practical tips to squeeze out performance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Search Execution: Query Phase vs Fetch Phase
&lt;/h2&gt;

&lt;p&gt;When a client sends a search request to OpenSearch, the request lands on a &lt;strong&gt;coordinating node&lt;/strong&gt;.  The coordinating node does not hold any data itself – its job is to &lt;strong&gt;scatter&lt;/strong&gt; the request to the relevant primary shards, collect partial results, and &lt;strong&gt;gather&lt;/strong&gt; them into the final response.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step 1 – Routing&lt;/strong&gt; – OpenSearch computes the target shard for each document using the routing key (by default the document &lt;code&gt;_id&lt;/code&gt;).  The coordinating node forwards the query to each shard’s primary copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 2 – Query Phase&lt;/strong&gt; – Each shard executes the query locally against its Lucene segments.  This phase performs scoring, aggregations, and returns the top‑K hits per shard.  No source fields are fetched yet; only doc IDs and scores travel back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 3 – Gather Phase&lt;/strong&gt; – The coordinating node merges the top‑K lists from all shards, re‑ranks them globally, and if the client requested the &lt;code&gt;_source&lt;/code&gt; fields, it issues a &lt;strong&gt;fetch phase&lt;/strong&gt; request to retrieve the full documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 4 – Fetch Phase&lt;/strong&gt; – Shards look up the stored fields for the selected doc IDs and return them to the coordinating node, which assembles the final JSON response.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why the Two‑Phase Model?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; – By separating scoring from fetching, OpenSearch can limit network payload to just IDs and scores when the client only needs aggregates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth&lt;/strong&gt; – Large source documents stay on the shard nodes until they are truly needed, reducing unnecessary data transfer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalability&lt;/strong&gt; – The query phase can be parallelised across thousands of shards, while the fetch phase only touches a small subset of documents.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Concurrent Segment Search and Caching
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Concurrent Segment Search
&lt;/h3&gt;

&lt;p&gt;Starting with OpenSearch 3.0, &lt;strong&gt;concurrent segment search&lt;/strong&gt; is enabled by default in &lt;em&gt;auto&lt;/em&gt; mode.  Instead of searching each Lucene segment sequentially, the engine slices the work and runs it in parallel across CPU cores.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slice Count&lt;/strong&gt; – Determined dynamically: &lt;code&gt;max(1, min(CPU_cores/2, 4))&lt;/code&gt; by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benefits&lt;/strong&gt; – Lower query latency for heavy aggregations or large result sets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trade‑offs&lt;/strong&gt; – Higher CPU consumption; not all queries benefit (e.g., simple term queries).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Request Cache vs Query Cache
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Request Cache&lt;/strong&gt; – Caches the &lt;strong&gt;entire request‑response&lt;/strong&gt; on a per‑shard basis.  Works best for identical queries that hit the same segments.  Disabled for queries with &lt;code&gt;search_type=dfs_query_then_fetch&lt;/code&gt; or those that modify data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query Cache&lt;/strong&gt; – Stores the &lt;strong&gt;filter‑only&lt;/strong&gt; portion of a query (e.g., term filters).  It is reusable across different queries that share the same filter clause.  Certain constructs like &lt;code&gt;script&lt;/code&gt;, &lt;code&gt;nested&lt;/code&gt;, or &lt;code&gt;function_score&lt;/code&gt; bypass the query cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Tuning Tips
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Increase &lt;code&gt;indices.queries.cache.size&lt;/code&gt; if you have a high hit‑rate on filter‑heavy workloads.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;indices.requests.cache.size&lt;/code&gt; to a reasonable fraction of heap (e.g., &lt;code&gt;10%&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;_cache/clear&lt;/code&gt; API during maintenance windows to avoid stale cache entries.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Extending OpenSearch with Plugins
&lt;/h2&gt;

&lt;p&gt;OpenSearch’s &lt;strong&gt;plugin system&lt;/strong&gt; lets you add new REST endpoints, custom queries, analysers, and more.  A plugin is a ZIP file containing a &lt;code&gt;plugin-descriptor.properties&lt;/code&gt; file and a JAR with compiled Java code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Extension Points
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ActionPlugin&lt;/strong&gt; – Register new transport and REST actions.  Ideal for custom admin APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SearchPlugin&lt;/strong&gt; – Add custom query builders, aggregations, or suggesters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AnalysisPlugin&lt;/strong&gt; – Provide new tokenizers, char filters, or token filters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ScriptPlugin&lt;/strong&gt; – Introduce a new scripting language or extend Painless.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Minimal Plugin Example (SearchPlugin)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MySearchPlugin&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Plugin&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;SearchPlugin&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QuerySpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getQueries&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Collections&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;singletonList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QuerySpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="nc"&gt;MyCustomQueryBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;MyCustomQueryBuilder:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;MyCustomQueryBuilder:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;fromXContent&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After building the JAR, install with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;opensearch-plugin &lt;span class="nb"&gt;install &lt;/span&gt;file:///path/to/my-plugin.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugin Development Workflow
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Scaffold with the OpenSearch plugin archetype.&lt;/li&gt;
&lt;li&gt;Write unit tests using the OpenSearch test framework.&lt;/li&gt;
&lt;li&gt;Verify compatibility with the target OpenSearch version (plugins are version‑locked).&lt;/li&gt;
&lt;li&gt;Publish the ZIP to an internal Maven repository or GitHub Releases for CI/CD deployment.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Data Lifecycle: Refresh, Flush, and Translog
&lt;/h2&gt;

&lt;p&gt;OpenSearch’s write path is a balance between &lt;strong&gt;durability&lt;/strong&gt;, &lt;strong&gt;visibility&lt;/strong&gt;, and &lt;strong&gt;throughput&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In‑Memory Buffer + Translog&lt;/strong&gt; – Incoming documents first land in a RAM buffer and a write‑ahead translog on disk.  This ensures recoverability after a crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh&lt;/strong&gt; – Every &lt;code&gt;refresh_interval&lt;/code&gt; (default 1 s) OpenSearch creates a new Lucene segment and makes recent writes searchable.  Refresh is lightweight but can be costly at high write rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flush&lt;/strong&gt; – Persists the translog to disk and clears the in‑memory buffer.  Triggered automatically based on &lt;code&gt;indices.flush.threshold_ops&lt;/code&gt; or manually via the &lt;code&gt;_flush&lt;/code&gt; API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment Replication (3.x+)&lt;/strong&gt; – Replicas receive &lt;strong&gt;segment files&lt;/strong&gt; instead of individual document operations, reducing network overhead for bulk indexing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tuning Refresh and Flush
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Lower &lt;code&gt;refresh_interval&lt;/code&gt; for near‑real‑time requirements (e.g., log analytics).&lt;/li&gt;
&lt;li&gt;Increase &lt;code&gt;refresh_interval&lt;/code&gt; or disable refresh (&lt;code&gt;refresh_interval=-1&lt;/code&gt;) for bulk indexing jobs, then manually refresh after the load.&lt;/li&gt;
&lt;li&gt;Adjust &lt;code&gt;translog.durability&lt;/code&gt; to &lt;code&gt;async&lt;/code&gt; for higher throughput at the cost of potential data loss on sudden power failure.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Practical Performance Tweaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent Search&lt;/strong&gt; – Enable &lt;code&gt;search.max_concurrent_shard_requests&lt;/code&gt; to cap per‑node parallelism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache Warm‑up&lt;/strong&gt; – Run representative queries after a node restart to populate request cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment Merges&lt;/strong&gt; – Tune &lt;code&gt;indices.merge.scheduler.max_thread_count&lt;/code&gt; to avoid I/O spikes during heavy indexing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shard Sizing&lt;/strong&gt; – Aim for 20‑40 GB per shard; too many small shards increase cluster state size and coordination overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thread Pools&lt;/strong&gt; – Monitor &lt;code&gt;search&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt; thread pool queue sizes; increase &lt;code&gt;search.thread_pool.queue_size&lt;/code&gt; if you see rejections during peak traffic.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;OpenSearch packs a rich set of features that go far beyond a simple search API.  Understanding the &lt;strong&gt;query‑fetch split&lt;/strong&gt;, leveraging &lt;strong&gt;concurrent segment search&lt;/strong&gt;, and mastering the &lt;strong&gt;plugin architecture&lt;/strong&gt; empowers you to build low‑latency, extensible search experiences.  Coupled with careful &lt;strong&gt;lifecycle tuning&lt;/strong&gt;—refresh, flush, translog—you can keep costs under control while delivering real‑time relevance.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>lucene</category>
      <category>search</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Complete Polaris Security Stack: From Request to Credential</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Thu, 25 Jun 2026 00:33:30 +0000</pubDate>
      <link>https://dev.to/iprithv/the-complete-polaris-security-stack-from-request-to-credential-1a4d</link>
      <guid>https://dev.to/iprithv/the-complete-polaris-security-stack-from-request-to-credential-1a4d</guid>
      <description>&lt;p&gt;Data security in the cloud has always been a game of trade-offs. You can lock everything down and watch your data engineering team struggle with access requests, or you can hand out broad credentials and hope nobody misuses them. Apache Polaris, the open-source catalog for Apache Iceberg, takes a fundamentally different approach: every single data access request goes through a complete security pipeline that authenticates the caller, checks their permissions, and issues temporary, scoped credentials that expire automatically.&lt;/p&gt;

&lt;p&gt;In this post, I will walk through the entire Polaris security stack from the moment a query engine sends a request to the moment it receives cloud storage credentials. No shortcuts, no hand-waving. Just the exact path every request takes and why each step matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Traditional Approaches Fall Short
&lt;/h2&gt;

&lt;p&gt;Before diving into Polaris, let us look at how most data platforms handle security today. A typical setup involves creating a service account in AWS IAM or Google Cloud IAM, attaching broad permissions to it, generating access keys, and distributing those keys to every compute engine that needs data access. Spark clusters get a key. Flink jobs get a key. Trino workers get a key. Every tool has direct, persistent access to your storage.&lt;/p&gt;

&lt;p&gt;This pattern creates several problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credential sprawl:&lt;/strong&gt; Keys exist in multiple places, making rotation painful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-permissioning:&lt;/strong&gt; Service accounts often have broader access than any single job needs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow revocation:&lt;/strong&gt; Removing access requires rotating keys and updating every consumer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit gaps:&lt;/strong&gt; It is hard to trace which specific job accessed which specific file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Polaris eliminates these problems by design. Compute engines never touch long-lived credentials. Instead, they request temporary access through Polaris's REST API, and Polaris decides what they get based on who they are and what they are allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Pipeline: A Request's Journey
&lt;/h2&gt;

&lt;p&gt;When Spark, Flink, or any Iceberg-compatible engine needs to read a table, it sends a request to Polaris. That request travels through four distinct security layers before returning credentials. Let us trace the complete path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Authentication - Who Are You?
&lt;/h3&gt;

&lt;p&gt;Every request to Polaris must identify itself. Polaris uses principals - entities that represent users, services, or applications. Each principal has credentials (typically tokens) that prove its identity.&lt;/p&gt;

&lt;p&gt;The authentication step answers a simple question: is this request coming from a known, valid principal? If the token is invalid, expired, or missing, the request stops here. No further processing happens.&lt;/p&gt;

&lt;p&gt;Principals are managed through Polaris's management API. An administrator creates them, assigns initial credentials, and can rotate or revoke those credentials at any time. Unlike cloud IAM service accounts, Polaris principals are catalog-specific. They exist only within the Polaris ecosystem and have no inherent access to anything until explicitly granted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Principal Roles - What Is Your Identity?
&lt;/h3&gt;

&lt;p&gt;Once authenticated, Polaris looks up the principal's assigned principal roles. This is the first tier of Polaris's two-tier RBAC system.&lt;/p&gt;

&lt;p&gt;Principal roles answer the question: what is this principal's organizational identity? A principal might have roles like "data-scientist", "etl-service", or "analytics-reader". These roles are assigned directly to principals and represent who the principal is in the organization.&lt;/p&gt;

&lt;p&gt;The key insight here is separation of concerns. Principal roles handle identity. They say "this is a data scientist" or "this is an ETL job". They do not say what that identity can access. That decision happens at the next layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Catalog Roles and Privileges - What Can You Do?
&lt;/h3&gt;

&lt;p&gt;Catalog roles are the second tier of Polaris's RBAC system. They define what operations are permitted on which catalog resources. A catalog role might grant TABLE_READ_DATA on the "analytics" catalog, or CATALOG_MANAGE_ACCESS on the "production" catalog.&lt;/p&gt;

&lt;p&gt;Here is where Polaris's design gets interesting: catalog roles are not assigned directly to principals. Instead, they are granted to principal roles. A principal role "data-scientist" might be granted a catalog role "analytics-reader", which in turn has TABLE_READ_DATA on specific tables.&lt;/p&gt;

&lt;p&gt;This two-tier design provides flexibility. You can change what a "data-scientist" can access by modifying catalog role grants, without touching individual principal assignments. You can also audit access patterns by principal role, making it easier to answer questions like "what can all data scientists access?"&lt;/p&gt;

&lt;p&gt;The available privileges are granular:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TABLE_READ_DATA&lt;/strong&gt; - Read table data and metadata&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TABLE_WRITE_DATA&lt;/strong&gt; - Write table data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CATALOG_MANAGE_ACCESS&lt;/strong&gt; - Manage catalog access control&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CATALOG_MANAGE_CONTENT&lt;/strong&gt; - Create and modify catalog objects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NAMESPACE_CREATE&lt;/strong&gt; - Create namespaces&lt;/li&gt;
&lt;li&gt;And more&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a request arrives, Polaris resolves the principal to their principal roles, then resolves those principal roles to catalog roles, then checks whether any of those catalog roles have the required privilege on the requested resource. If yes, authorization succeeds. If no, the request is denied.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4: Credential Vending - Scoped, Temporary Access
&lt;/h3&gt;

&lt;p&gt;This is where Polaris fundamentally differs from traditional approaches. Instead of returning a success message and letting the engine use its own credentials, Polaris mints fresh, temporary credentials specifically for this request.&lt;/p&gt;

&lt;p&gt;The process works as follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storage configuration lookup:&lt;/strong&gt; Polaris retrieves the catalog's storage configuration (S3 bucket, GCS path, Azure container)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cloud provider API call:&lt;/strong&gt; Polaris calls the appropriate cloud API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS: STS AssumeRole with external ID&lt;/li&gt;
&lt;li&gt;GCS: Generate service account token&lt;/li&gt;
&lt;li&gt;Azure: Request tenant token&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Scope restriction:&lt;/strong&gt; The credentials are scoped to the specific table path requested. A read request for table "analytics.events" gets credentials that can only access that table's files, not the entire bucket.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Time bounding:&lt;/strong&gt; Credentials are valid for approximately 15 minutes (configurable). After that, they expire automatically.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Permission mapping:&lt;/strong&gt; The cloud credentials reflect the Polaris privilege. TABLE_READ_DATA yields read-only credentials. TABLE_WRITE_DATA yields read-write credentials.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The engine receives these temporary credentials and uses them to access cloud storage directly. From Polaris's perspective, the security contract is complete: the engine got exactly the access it needed, for exactly the time it needed, scoped to exactly the resource it requested.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers Behind Credential Vending
&lt;/h2&gt;

&lt;p&gt;Credential vending is not free. Each minting operation requires a cloud provider API call, which adds latency to the data access path. In practice, Polaris achieves 100-200ms per credential minting operation. For interactive queries, this is acceptable. For high-throughput batch jobs, Polaris implements caching to reduce repeated cloud API calls.&lt;/p&gt;

&lt;p&gt;The trade-off is clear: slightly higher latency for dramatically better security. And since Polaris caches credentials for repeated access patterns, the amortized cost drops significantly for typical workloads.&lt;/p&gt;

&lt;p&gt;Version 1.3.0, released in January 2026, added federated credential vending. This means Polaris can now mint credentials for external catalogs (like Snowflake or Glue), not just its own managed storage. This extends the same security model to federated data access, which is a significant advancement for organizations with hybrid catalog deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  OPA Integration: Externalizing Authorization
&lt;/h2&gt;

&lt;p&gt;Starting with v1.3.0, Polaris supports Open Policy Agent (OPA) integration. This allows organizations to externalize authorization decisions to a dedicated policy engine.&lt;/p&gt;

&lt;p&gt;Instead of Polaris evaluating RBAC rules internally, it can send authorization queries to OPA. OPA evaluates policies written in Rego (OPA's policy language) and returns allow or deny decisions. This enables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complex policies that go beyond Polaris's built-in privilege model&lt;/li&gt;
&lt;li&gt;Centralized policy management across multiple systems&lt;/li&gt;
&lt;li&gt;Dynamic policies that can consider context like time of day, request origin, or data classification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For organizations with existing OPA deployments, this integration means Polaris fits naturally into their security infrastructure without requiring parallel policy management.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Security Hardening
&lt;/h2&gt;

&lt;p&gt;Running Polaris securely in production requires attention beyond the default configuration. Here are key considerations:&lt;/p&gt;

&lt;h3&gt;
  
  
  TLS Everywhere
&lt;/h3&gt;

&lt;p&gt;Enable TLS for all communication paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;REST API endpoints (Quarkus server configuration)&lt;/li&gt;
&lt;li&gt;JDBC connections to the persistence backend&lt;/li&gt;
&lt;li&gt;Internal service communication if running distributed&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Persistence Security
&lt;/h3&gt;

&lt;p&gt;The persistence layer stores all catalog metadata, including storage configurations and RBAC grants. Secure it as you would any database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use encrypted connections (JDBC with SSL)&lt;/li&gt;
&lt;li&gt;Restrict network access to Polaris servers only&lt;/li&gt;
&lt;li&gt;Enable audit logging for metadata changes&lt;/li&gt;
&lt;li&gt;Consider separate persistence instances for different environments&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Storage IAM Configuration
&lt;/h3&gt;

&lt;p&gt;When configuring cloud storage, follow least-privilege principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create dedicated IAM roles for Polaris&lt;/li&gt;
&lt;li&gt;Use external IDs for cross-account AWS access&lt;/li&gt;
&lt;li&gt;Restrict allowed storage locations per catalog&lt;/li&gt;
&lt;li&gt;Regularly audit storage role permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Credential Cache Tuning
&lt;/h3&gt;

&lt;p&gt;Polaris caches minted credentials to reduce cloud API calls. Tune the cache TTL based on your security requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shorter TTL = better security, more cloud API calls&lt;/li&gt;
&lt;li&gt;Longer TTL = better performance, longer credential lifetime&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For highly sensitive data, err on the side of shorter TTLs. For batch workloads with predictable access patterns, longer TTLs may be acceptable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring and Alerting
&lt;/h3&gt;

&lt;p&gt;Set up monitoring for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Failed authentication attempts (possible credential compromise)&lt;/li&gt;
&lt;li&gt;Unusual privilege escalation patterns&lt;/li&gt;
&lt;li&gt;Credential vending latency spikes&lt;/li&gt;
&lt;li&gt;Storage access errors (possible IAM misconfiguration)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why This Matters for Data Engineering Teams
&lt;/h2&gt;

&lt;p&gt;The Polaris security model changes how data engineering teams operate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No more key management:&lt;/strong&gt; Engineers do not need to generate, distribute, or rotate cloud storage credentials. Polaris handles it automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistent access control:&lt;/strong&gt; Whether data is accessed through Spark, Flink, Trino, or any other Iceberg-compatible engine, the same RBAC policies apply. No engine-specific IAM configurations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit by design:&lt;/strong&gt; Every data access leaves a trail through Polaris. You know who accessed what, when, and with what permissions. Compliance teams love this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instant revocation:&lt;/strong&gt; Remove a principal's catalog role grant, and their access stops immediately. No waiting for key rotation to propagate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-tenancy without complexity:&lt;/strong&gt; Different teams can share a Polaris instance while maintaining complete access isolation through catalog and namespace-level RBAC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Apache Polaris does not treat security as an afterthought or a configuration option. It is woven into every layer of the architecture. From the moment a request arrives, through authentication, RBAC evaluation, and credential vending, Polaris maintains strict security boundaries.&lt;/p&gt;

&lt;p&gt;The result is a data catalog where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compute engines never possess long-lived credentials&lt;/li&gt;
&lt;li&gt;Every access is scoped to exactly what is needed&lt;/li&gt;
&lt;li&gt;Permissions expire automatically&lt;/li&gt;
&lt;li&gt;Audit trails are comprehensive&lt;/li&gt;
&lt;li&gt;Multi-engine environments have consistent security policies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For organizations building modern data platforms on Apache Iceberg, Polaris offers a security model that matches the sophistication of the data architecture itself. It is not just a catalog - it is a security boundary for your data lake.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>polaris</category>
      <category>security</category>
      <category>api</category>
      <category>cloud</category>
    </item>
    <item>
      <title>How OpenSearch 3.0’s Concurrent Segment Search Changes Everything</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Thu, 25 Jun 2026 00:30:03 +0000</pubDate>
      <link>https://dev.to/iprithv/how-opensearch-30s-concurrent-segment-search-changes-everything-33ke</link>
      <guid>https://dev.to/iprithv/how-opensearch-30s-concurrent-segment-search-changes-everything-33ke</guid>
      <description>&lt;p&gt;OpenSearch 3.0 introduced a major shift in the way queries are executed inside the engine. The change is subtle – it lives inside the search core – but its impact on latency, CPU usage and scaling is profound. In this post we unpack the new &lt;strong&gt;concurrent segment search&lt;/strong&gt; feature, explain why it matters, and show you how to tune it for real‑world workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Before 3.0
&lt;/h2&gt;

&lt;p&gt;In earlier versions each shard performed a &lt;strong&gt;single‑threaded&lt;/strong&gt; scan over its Lucene segments. The algorithm looked roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the segment’s postings list.&lt;/li&gt;
&lt;li&gt;Walk the term dictionary.&lt;/li&gt;
&lt;li&gt;Score each matching document.&lt;/li&gt;
&lt;li&gt;Return the top‑K results for the shard.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This approach works well for small indexes or low‑traffic workloads, but it has three drawbacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency spikes&lt;/strong&gt; when a query touches many large segments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Under‑utilised CPU&lt;/strong&gt; on multi‑core machines – most cores sit idle while a single core does the heavy lifting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limited scalability&lt;/strong&gt; for aggregation‑heavy queries that need to touch a lot of data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OpenSearch community recognized these pain points and introduced &lt;strong&gt;concurrent segment search&lt;/strong&gt; (CSS) as the default mode in 3.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Concurrent Segment Search?
&lt;/h2&gt;

&lt;p&gt;CSS splits each Lucene segment into &lt;strong&gt;slices&lt;/strong&gt; and processes each slice in a separate thread. The slicing logic is deterministic and based on two parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;max‑slice‑count&lt;/strong&gt; – the maximum number of slices a segment can be split into. By default it is calculated as &lt;code&gt;max(1, min(CPU_cores/2, 4))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;segment size thresholds&lt;/strong&gt; – a segment larger than 250 K documents or containing more than 5 sub‑segments is automatically sliced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a query runs, the shard’s executor creates a thread pool, assigns each slice to a thread, and aggregates the partial results before returning the shard‑level top‑K. The coordinating node then merges the shard results as usual.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why It Works
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parallelism at the segment level&lt;/strong&gt; – Lucene segments are immutable, so multiple threads can read them without coordination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache friendliness&lt;/strong&gt; – each thread works on a contiguous portion of the segment, keeping CPU caches warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful fallback&lt;/strong&gt; – if a segment is too small to slice, CSS runs the classic single‑threaded path, preserving the correctness guarantees.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trade‑offs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trade‑off&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Latency reduction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Queries that touch many large segments finish up to 2‑3× faster.&lt;/td&gt;
&lt;td&gt;Higher CPU consumption per query.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resource usage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Better CPU utilization on multi‑core nodes.&lt;/td&gt;
&lt;td&gt;Potential contention with other heavy tasks (e.g., ingest pipelines).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Unsupported features&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CSS disables some aggregations (e.g., &lt;code&gt;parent&lt;/code&gt; aggregation on join fields) and query constructs (&lt;code&gt;sampler&lt;/code&gt;, &lt;code&gt;diversified_sampler&lt;/code&gt;).&lt;/td&gt;
&lt;td&gt;Need to fall back to single‑threaded mode for those queries.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adds a new configuration knob (&lt;code&gt;search.concurrent.slice_count&lt;/code&gt;).&lt;/td&gt;
&lt;td&gt;Requires monitoring to avoid over‑slicing on memory‑constrained nodes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How the Decision Engine Works
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;ConcurrentSearchRequestDecider&lt;/strong&gt; (CSRD) lives in &lt;code&gt;org.opensearch.search.deciders&lt;/code&gt;. It evaluates the query and decides whether to enable CSS. The default mode is &lt;strong&gt;“auto”&lt;/strong&gt;, which applies the following heuristics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the query contains heavy aggregations or script‑based scoring, enable CSS.&lt;/li&gt;
&lt;li&gt;If the query requests a &lt;code&gt;terminate_after&lt;/code&gt; or uses the &lt;code&gt;minimum_should_match&lt;/code&gt; clause, stay in single‑threaded mode.&lt;/li&gt;
&lt;li&gt;If the node’s CPU load is above a configurable threshold (&lt;code&gt;search.concurrent.max_cpu_load&lt;/code&gt;), CSRD disables CSS to protect the host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can override the decision per‑request with the &lt;code&gt;?concurrent_search=true|false&lt;/code&gt; URL parameter, or globally via the &lt;code&gt;search.concurrent_mode&lt;/code&gt; setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring CSS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Basic Settings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;concurrent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;            &lt;span class="c1"&gt;# auto | always | never&lt;/span&gt;
    &lt;span class="na"&gt;max_slice_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;    &lt;span class="c1"&gt;# maximum slices per segment&lt;/span&gt;
    &lt;span class="na"&gt;max_cpu_load&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.8&lt;/span&gt;     &lt;span class="c1"&gt;# disable CSS if CPU &amp;gt; 80%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mode&lt;/strong&gt; – &lt;code&gt;auto&lt;/code&gt; respects the decider; &lt;code&gt;always&lt;/code&gt; forces CSS even for unsupported queries (may cause errors); &lt;code&gt;never&lt;/code&gt; reverts to the old behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;max_slice_count&lt;/strong&gt; – a higher value means more threads per segment, useful on high‑core servers (e.g., 32‑core machines).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;max_cpu_load&lt;/strong&gt; – prevents CSS from saturating the node during peak load.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example: Tuning for a 16‑core Search Node
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;concurrent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;
    &lt;span class="na"&gt;max_slice_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;   &lt;span class="c1"&gt;# 12 slices = ~6 threads per segment on average&lt;/span&gt;
    &lt;span class="na"&gt;max_cpu_load&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this config the node will slice each large segment into up to 12 parts, allowing up to 6 threads to run in parallel (since each slice uses a single thread). This setting has been benchmarked to cut median query latency from 250 ms to ~100 ms on a typical e‑commerce catalog.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>lucene</category>
      <category>search</category>
      <category>java</category>
      <category>opensource</category>
    </item>
    <item>
      <title>OpenSearch Search Pipelines: Transforming Results Without Changing Your Clients</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:29:34 +0000</pubDate>
      <link>https://dev.to/iprithv/opensearch-search-pipelines-transforming-results-without-changing-your-clients-2lae</link>
      <guid>https://dev.to/iprithv/opensearch-search-pipelines-transforming-results-without-changing-your-clients-2lae</guid>
      <description>&lt;p&gt;Most OpenSearch users think of search as a single request-response cycle: you send a query, OpenSearch executes it, and you get results back. But what if you could intercept and modify that journey at multiple points along the way? What if you could inject custom logic between the query execution and the response formatting, without changing a single line of client code?&lt;/p&gt;

&lt;p&gt;That is exactly what OpenSearch Search Pipelines enable. Introduced as a framework for chaining request and response processors, search pipelines let you transform queries before they hit the index and reshape results before they reach your application. In this post, I will walk you through how they work, when to use them, and how to build your own custom processor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Search Pipelines Exist
&lt;/h2&gt;

&lt;p&gt;Before pipelines, if you needed to modify search behavior, you had a few options: rewrite the query client-side, use a plugin that hooks into the search flow, or abuse script fields and runtime fields for post-processing. Each approach had trade-offs. Client-side changes required updating every application that talked to OpenSearch. Plugin development meant writing Java, building a .zip artifact, and installing it on every node. Runtime fields were limited to script-based transformations and ran at query time with performance overhead.&lt;/p&gt;

&lt;p&gt;Search pipelines provide a middle ground: a declarative, configurable way to transform search requests and responses without writing a full plugin or touching client code. They are designed for common operational patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Injecting default filters or boosting rules across all queries&lt;/li&gt;
&lt;li&gt;Removing or masking sensitive fields from results (PII filtering)&lt;/li&gt;
&lt;li&gt;A/B testing different ranking strategies by routing queries to different pipelines&lt;/li&gt;
&lt;li&gt;Re-ranking results using an external model or custom scoring logic&lt;/li&gt;
&lt;li&gt;Adding query metadata or telemetry without client awareness&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight is that pipelines are &lt;strong&gt;registered at the index level&lt;/strong&gt; and &lt;strong&gt;selected at query time&lt;/strong&gt;. This means you can have multiple pipelines for the same index and choose which one to apply based on your application context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline Architecture: Three Types of Processors
&lt;/h2&gt;

&lt;p&gt;A search pipeline is a named, ordered sequence of processors. Each processor is a unit of transformation that operates on one of three stages:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Request Processors
&lt;/h3&gt;

&lt;p&gt;Request processors run after the query is parsed but before it is executed against the shards. They can modify the query structure, add filters, change sorting, or inject parameters. This is the ideal place for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding tenant-aware filters in multi-tenant applications&lt;/li&gt;
&lt;li&gt;Injecting time-range filters for time-series data&lt;/li&gt;
&lt;li&gt;Applying default boost values based on user context&lt;/li&gt;
&lt;li&gt;Rewriting query types for backward compatibility&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Response Processors
&lt;/h3&gt;

&lt;p&gt;Response processors run after the shards return results but before the final response is serialized and sent to the client. They operate on the aggregated &lt;code&gt;SearchResponse&lt;/code&gt; and can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove or mask fields from the &lt;code&gt;_source&lt;/code&gt; documents&lt;/li&gt;
&lt;li&gt;Re-rank hits based on external signals&lt;/li&gt;
&lt;li&gt;Add computed metadata to each hit&lt;/li&gt;
&lt;li&gt;Truncate or paginate results differently&lt;/li&gt;
&lt;li&gt;Filter out results based on post-processing logic&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Search Ext Processors
&lt;/h3&gt;

&lt;p&gt;Search extension processors (SearchExt) are the most powerful and least understood. They allow you to add custom sections to the search request body that are processed by your plugin. Think of them as custom DSL elements that only your processor understands. This is where you can implement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom A/B testing parameters&lt;/li&gt;
&lt;li&gt;External model invocation for neural re-ranking&lt;/li&gt;
&lt;li&gt;Complex feature injection that does not fit standard query structures&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Pipelines Are Defined and Applied
&lt;/h2&gt;

&lt;p&gt;Pipelines are defined using the Search Pipeline API and stored in the cluster state. Here is what a simple pipeline definition looks like:&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;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/_search/pipeline/my-pipeline&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mask sensitive fields and boost recent documents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_processors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"filter_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="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="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"range"&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;"timestamp"&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;"gte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"now-30d"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_processors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rename_field"&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;"internal_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"target_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;"id"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To apply this pipeline to a search request, you simply add it to the query:&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;/my-index/_search?search_pipeline=my-pipeline&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;"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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"match"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenSearch"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also set a default pipeline for an index:&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;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_settings&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;"index"&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;"search.default_pipeline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-pipeline"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a default pipeline is set, it applies to all search requests on that index unless the request explicitly overrides it with &lt;code&gt;search_pipeline=none&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Custom Processor: The Java Side
&lt;/h2&gt;

&lt;p&gt;While OpenSearch ships with several built-in processors (filter_query, rename_field, override, script), the real power comes from building custom ones. As someone who has worked on OpenSearch plugins, I can tell you that the processor API is significantly more approachable than writing a full SearchPlugin from scratch.&lt;/p&gt;

&lt;p&gt;A custom processor requires three components:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Processor Class
&lt;/h3&gt;

&lt;p&gt;This implements the transformation logic. For a response processor, you extend &lt;code&gt;SearchResponseProcessor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PiiMaskingProcessor&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;SearchResponseProcessor&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PiiMaskingProcessor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SearchResponse&lt;/span&gt; &lt;span class="nf"&gt;processResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SearchResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Iterate through hits and mask sensitive fields&lt;/span&gt;
        &lt;span class="nc"&gt;SearchHit&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHits&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getHits&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchHit&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSourceAsMap&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"pii_mask"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Factory
&lt;/h3&gt;

&lt;p&gt;The factory creates processor instances from the JSON configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PiiMaskingProcessorFactory&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Processor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Factory&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchResponseProcessor&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SearchResponseProcessor&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Processor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Factory&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchResponseProcessor&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;factories&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;ignoreFailure&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"field"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mask"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"***MASKED***"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PiiMaskingProcessor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Plugin Registration
&lt;/h3&gt;

&lt;p&gt;In your plugin's &lt;code&gt;SearchPlugin&lt;/code&gt; implementation, register the processor factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Processor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Factory&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchResponseProcessor&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getSearchResponseProcessors&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Parameters&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Processor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Factory&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchResponseProcessor&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pii_mask"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiiMaskingProcessorFactory&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. No custom REST endpoints, no transport actions, no cluster state manipulation. OpenSearch handles the pipeline execution, and your processor is called at the right point in the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;p&gt;Let me share three patterns I have seen work well in production:&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Case 1: Multi-Tenant Data Isolation
&lt;/h3&gt;

&lt;p&gt;In a multi-tenant application, you need to ensure users only see documents belonging to their organization. Instead of every client query including a &lt;code&gt;tenant_id&lt;/code&gt; filter, you can create a request processor that injects it automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filter_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="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="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"term"&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;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{_user.tenant_id}}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with the security plugin's user context, this ensures no query can ever bypass tenant isolation, even if the client forgets to include the filter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Case 2: PII Filtering for Different User Roles
&lt;/h3&gt;

&lt;p&gt;Not every user should see all fields. A response processor can conditionally remove fields based on the caller's role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Regular users see: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;department&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Managers see: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;department&lt;/code&gt;, &lt;code&gt;salary_band&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Admins see: all fields including &lt;code&gt;ssn&lt;/code&gt;, &lt;code&gt;home_address&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is implemented by checking the authenticated user's roles in the request context and applying a field-level filter in the response processor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Case 3: A/B Testing Ranking Strategies
&lt;/h3&gt;

&lt;p&gt;You can define two pipelines with different query boosts or re-ranking logic, then route a percentage of traffic to each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pipeline &lt;code&gt;ranking-v1&lt;/code&gt;: standard BM25 with date decay&lt;/li&gt;
&lt;li&gt;Pipeline &lt;code&gt;ranking-v2&lt;/code&gt;: BM25 + custom field boost + click-through rate signal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your application randomly selects the pipeline per request, and you measure the click-through rate difference. No application code changes are needed to switch between strategies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Considerations
&lt;/h2&gt;

&lt;p&gt;Search pipelines add overhead, but it is usually minimal compared to the query execution itself. Request processors run before the scatter-gather phase, so they only execute once per query, not per shard. Response processors run after the coordinating node merges results, so they only see the final &lt;code&gt;size&lt;/code&gt; number of hits, not every match.&lt;/p&gt;

&lt;p&gt;That said, there are pitfalls to avoid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deep response processing:&lt;/strong&gt; If your response processor iterates through all aggregation buckets or performs external API calls, latency will spike. Keep response processors lightweight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex request rewrites:&lt;/strong&gt; Request processors that drastically expand query complexity can increase shard execution time. Profile your queries before and after pipeline application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory pressure:&lt;/strong&gt; Processors that materialize large result sets in memory can cause heap pressure. Be especially careful with &lt;code&gt;size&lt;/code&gt; values above 1000 when using response processors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations and Gotchas
&lt;/h2&gt;

&lt;p&gt;Search pipelines are powerful, but they are not a replacement for every plugin use case. Here is where they fall short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No custom query types:&lt;/strong&gt; You cannot use a pipeline to add a new query DSL element that Lucene understands. For that, you still need a &lt;code&gt;SearchPlugin&lt;/code&gt; with a custom &lt;code&gt;QuerySpec&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shard-level interception:&lt;/strong&gt; Processors run at the coordinating node level. If you need to modify how individual shards execute queries, you need a different extension point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No transport layer access:&lt;/strong&gt; Pipelines operate at the REST/response layer. They cannot intercept or modify inter-node communication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order matters:&lt;/strong&gt; Processors execute in the order they are defined. If you have a filter_query processor followed by a script processor that expects the original query structure, you may get unexpected results.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to Use Pipelines vs Plugins vs Client Logic
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Search Pipeline&lt;/th&gt;
&lt;th&gt;Full Plugin&lt;/th&gt;
&lt;th&gt;Client Logic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add default filters&lt;/td&gt;
&lt;td&gt;Perfect&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;td&gt;Fragile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mask sensitive fields&lt;/td&gt;
&lt;td&gt;Perfect&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;td&gt;Risky&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A/B test ranking&lt;/td&gt;
&lt;td&gt;Perfect&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;Complex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom query DSL&lt;/td&gt;
&lt;td&gt;Not possible&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Workaround&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shard-level optimization&lt;/td&gt;
&lt;td&gt;Not possible&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Not possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External API calls per hit&lt;/td&gt;
&lt;td&gt;Careful&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;td&gt;Natural&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex multi-request workflows&lt;/td&gt;
&lt;td&gt;Not possible&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;Natural&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Search pipelines are one of the most practical additions to OpenSearch in recent releases. They fill a gap between "configure it in the query" and "write a Java plugin." For operational concerns like filtering, masking, and result transformation, they are the right tool for the job.&lt;/p&gt;

&lt;p&gt;If you are running OpenSearch 2.9 or later, you already have access to the built-in processors. If you need something custom, the processor API is a gentle introduction to OpenSearch plugin development - much gentler than writing a full &lt;code&gt;SearchPlugin&lt;/code&gt; or &lt;code&gt;ActionPlugin&lt;/code&gt; from scratch.&lt;/p&gt;

&lt;p&gt;Start with a simple response processor that removes an internal field from your results. Once you see how cleanly it integrates, you will find yourself reaching for pipelines more often than you expect.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensearch</category>
      <category>search</category>
      <category>database</category>
      <category>data</category>
    </item>
    <item>
      <title>BKD Trees: How Lucene Indexes Numbers and Geospatial Data</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Sat, 20 Jun 2026 00:34:06 +0000</pubDate>
      <link>https://dev.to/iprithv/bkd-trees-how-lucene-indexes-numbers-and-geospatial-data-mpo</link>
      <guid>https://dev.to/iprithv/bkd-trees-how-lucene-indexes-numbers-and-geospatial-data-mpo</guid>
      <description>&lt;p&gt;Text search gets all the glory. When developers talk about Lucene, they immediately think of inverted indexes, postings lists, and BM25 scoring. But modern search is not just about text. E-commerce sites need price ranges. Logistics platforms need geospatial radius queries. IoT systems need time-series filtering. All of these require efficient numeric indexing.&lt;/p&gt;

&lt;p&gt;Here is the problem: Lucene's core strength - the inverted index - is terrible at numbers. If you index every integer as a term, a range query becomes a massive OR expansion. If you index latitude and longitude as text, distance queries become impossible. The term explosion makes both storage and query performance collapse.&lt;/p&gt;

&lt;p&gt;Lucene solved this with a completely different data structure: the &lt;strong&gt;BKD tree&lt;/strong&gt;. Short for &lt;strong&gt;B&lt;/strong&gt;lockwise &lt;strong&gt;K&lt;/strong&gt;-dimensional tree, this structure lets Lucene handle numeric and geospatial queries with the same efficiency it brings to text search. In this post, we dive deep into how BKD trees work, how they are stored on disk, and how to use them in production Java code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numeric Indexing Problem
&lt;/h2&gt;

&lt;p&gt;To understand why BKD trees matter, you first need to understand why the inverted index fails for numeric data.&lt;/p&gt;

&lt;p&gt;In a typical inverted index, each unique term gets a postings list. For text, this works brilliantly. The vocabulary is large but finite. For a 32-bit integer, the vocabulary is four billion unique terms. A price range query from 100 to 50000 would expand to 49901 term queries joined by OR. That is not a search engine. That is a database scan dressed up as a query.&lt;/p&gt;

&lt;p&gt;Even if you tokenize numbers into ranges (e.g., 10-100, 100-1000), you lose precision and still suffer from term explosion. And for floating-point numbers, the problem becomes even worse because you cannot meaningfully enumerate all possible values.&lt;/p&gt;

&lt;p&gt;Lucene needed a structure that stores actual numeric values without treating them as terms. The BKD tree is that structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is a BKD Tree?
&lt;/h2&gt;

&lt;p&gt;A BKD tree is a space-partitioning data structure that recursively splits k-dimensional space into smaller blocks. It is essentially a balanced binary tree optimized for disk storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Construction Process
&lt;/h3&gt;

&lt;p&gt;When you index numeric documents, Lucene collects all point values into a buffer. For each dimension, it sorts the values and finds the median. The data is split at the median into two halves. The process then recurses on the next dimension, cycling through dimensions until each leaf block contains at most &lt;strong&gt;1024 points&lt;/strong&gt; (configurable via &lt;code&gt;maxPointsInLeafNode&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Here is what that looks like for a 2D geospatial index (latitude, longitude):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Collect all lat/lon pairs.&lt;/li&gt;
&lt;li&gt;Sort by latitude and find the median. Split into northern and southern halves.&lt;/li&gt;
&lt;li&gt;Sort each half by longitude and find the median. Split into eastern and western quadrants.&lt;/li&gt;
&lt;li&gt;Repeat, alternating dimensions, until each block has 1024 or fewer points.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a balanced tree where every leaf block contains a compact cluster of points in space. This clustering is what makes range queries fast: if a query range does not intersect a block's bounding box, the entire block is skipped without examining individual points.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why BKD and Not R-Tree or Quadtree?
&lt;/h3&gt;

&lt;p&gt;Lucene could have used an R-tree or quadtree. Both are proven spatial indexes. The BKD tree was chosen for several reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic construction&lt;/strong&gt;: BKD trees are built bottom-up from sorted data. No unpredictable splitting heuristics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perfect balance&lt;/strong&gt;: Every leaf is at the same depth. Query time is predictable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excellent compression&lt;/strong&gt;: Points are stored in sorted, packed blocks. Gaps are small, compression is high.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-friendly&lt;/strong&gt;: Block-based storage maps well to OS page cache and memory-mapped I/O.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-pass bulk loading&lt;/strong&gt;: The entire tree is built from a single sorted scan, making it efficient for batch indexing.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  On-Disk Format: .dii and .dim
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The separation of index and data files allows Lucene to prune entire branches without reading point data.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;BKD trees have their own file format in Lucene, separate from the inverted index. When you index point values, two files are created per segment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.dii&lt;/code&gt;&lt;/strong&gt; - The BKD &lt;strong&gt;index&lt;/strong&gt; file. Contains the inner tree nodes, leaf block pointers, and bounding boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.dim&lt;/code&gt;&lt;/strong&gt; - The BKD &lt;strong&gt;data&lt;/strong&gt; file. Contains the actual leaf blocks with packed points and document IDs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Leaf Block Layout
&lt;/h3&gt;

&lt;p&gt;Each leaf block in the &lt;code&gt;.dim&lt;/code&gt; file stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The minimum and maximum values per dimension (bounding box).&lt;/li&gt;
&lt;li&gt;The number of points in the block.&lt;/li&gt;
&lt;li&gt;The points themselves, sorted and packed using delta encoding.&lt;/li&gt;
&lt;li&gt;The document IDs associated with each point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because points within a leaf are spatially close, delta compression is extremely effective. A block of 1024 lat/lon pairs might compress to just a few kilobytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inner Node Layout
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.dii&lt;/code&gt; file stores the tree structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Split dimension (which axis was used to split).&lt;/li&gt;
&lt;li&gt;Split value (the median value at the split).&lt;/li&gt;
&lt;li&gt;File pointer to the child block in &lt;code&gt;.dim&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Bounding box (min/max for all dimensions of the subtree).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During a query, Lucene reads the &lt;code&gt;.dii&lt;/code&gt; file to traverse the tree. It only touches &lt;code&gt;.dim&lt;/code&gt; for leaf blocks that survive pruning. This separation means that for selective queries, only a tiny fraction of the point data is ever read from disk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Indexing Numeric Data in Java
&lt;/h2&gt;

&lt;p&gt;Lucene provides dedicated field types for point values. Here is how you index integers and longs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.document.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.index.IndexWriter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.index.IndexWriterConfig&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.store.FSDirectory&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.file.Paths&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NumericIndexingExample&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;FSDirectory&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FSDirectory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Paths&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp/numeric-index"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="nc"&gt;IndexWriterConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IndexWriterConfig&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;IndexWriter&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IndexWriter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Index a product with price and quantity&lt;/span&gt;
        &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;299&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StoredField&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;299&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// for retrieval&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"quantity"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StoredField&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"quantity"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addDocument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key classes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;IntPoint&lt;/code&gt; - 32-bit integer points&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LongPoint&lt;/code&gt; - 64-bit integer points&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FloatPoint&lt;/code&gt; - 32-bit floating point&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DoublePoint&lt;/code&gt; - 64-bit floating point&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LatLonPoint&lt;/code&gt; - 2D geospatial (latitude, longitude)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;XYPoint&lt;/code&gt; - 2D Cartesian coordinates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Point fields are not stored by default. If you need to retrieve the original value in search results, add a separate &lt;code&gt;StoredField&lt;/code&gt; as shown above. Point fields are indexed-only and optimized for query filtering.&lt;/p&gt;




&lt;h2&gt;
  
  
  Range Queries: The BKD Pruning Advantage
&lt;/h2&gt;

&lt;p&gt;Once your data is indexed as points, range queries become tree traversals instead of term expansions. Here is how you run a numeric range query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.search.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.index.DirectoryReader&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RangeQueryExample&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;DirectoryReader&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DirectoryReader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FSDirectory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Paths&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp/numeric-index"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;IndexSearcher&lt;/span&gt; &lt;span class="n"&gt;searcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IndexSearcher&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Find products priced between 100 and 500&lt;/span&gt;
        &lt;span class="nc"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;priceRange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IntPoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newRangeQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;TopDocs&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;searcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priceRange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Found "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;totalHits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" products"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ScoreDoc&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scoreDocs&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;searcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Price: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How the Query Executes Internally
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;IndexSearcher&lt;/code&gt; executes a point range query, the following happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the BKD tree root from &lt;code&gt;.dii&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check if the query range intersects the root's bounding box.&lt;/li&gt;
&lt;li&gt;If yes, recurse into both children. If no, prune the entire subtree.&lt;/li&gt;
&lt;li&gt;At leaf blocks, check the block's bounding box. If it intersects, read the &lt;code&gt;.dim&lt;/code&gt; file and test individual points.&lt;/li&gt;
&lt;li&gt;Collect all matching document IDs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a selective range on a large dataset, this prunes 90-99% of the tree. The query touches only a handful of leaf blocks. This is why BKD-based range queries are &lt;strong&gt;10-100x faster&lt;/strong&gt; than equivalent term-based range queries on large indexes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Geospatial Indexing and Queries
&lt;/h2&gt;

&lt;p&gt;BKD trees truly shine with geospatial data. Lucene's &lt;code&gt;LatLonPoint&lt;/code&gt; stores latitude and longitude as a 2D point and uses the BKD tree for both bounding box and distance queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Indexing Geospatial Data
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.document.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeoIndexingExample&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;FSDirectory&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FSDirectory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Paths&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp/geo-index"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="nc"&gt;IndexWriter&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IndexWriter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IndexWriterConfig&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// Index a location: Bangalore, India&lt;/span&gt;
        &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LatLonPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"location"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12.9716&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;77.5946&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StoredField&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bangalore Office"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addDocument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Distance Queries
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.search.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeoQueryExample&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;DirectoryReader&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DirectoryReader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FSDirectory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Paths&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp/geo-index"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;IndexSearcher&lt;/span&gt; &lt;span class="n"&gt;searcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IndexSearcher&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Find locations within 10 km of Bangalore center&lt;/span&gt;
        &lt;span class="nc"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;distanceQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LatLonPoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newDistanceQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"location"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="mf"&gt;12.9716&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// latitude&lt;/span&gt;
            &lt;span class="mf"&gt;77.5946&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// longitude&lt;/span&gt;
            &lt;span class="mi"&gt;10_000&lt;/span&gt;     &lt;span class="c1"&gt;// radius in meters&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;TopDocs&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;searcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distanceQuery&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Found "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;totalHits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" nearby locations"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Geo3D: Accurate Spherical Distance
&lt;/h3&gt;

&lt;p&gt;For applications requiring high-precision spherical distance (aviation, maritime, satellite tracking), Lucene provides &lt;code&gt;Geo3DPoint&lt;/code&gt;. This model stores points on a 3D unit sphere using normalized Cartesian coordinates (x, y, z). Distance queries use great-circle math without the distortions of flat 2D projections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.apache.lucene.spatial.util.Geo3DPoint&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Index a point using geo3d&lt;/span&gt;
&lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Geo3DPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"location3d"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12.9716&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;77.5946&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Geo3D uses the same BKD tree infrastructure but with 3D coordinates. The BKD tree prunes in 3D space, making complex polygon and distance queries efficient even at global scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  BKD vs Inverted Index: A Concrete Comparison
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The choice of data structure determines whether a query completes in milliseconds or minutes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let us put numbers to the claim. Consider an index of 10 million documents, each with a random integer field &lt;code&gt;value&lt;/code&gt; between 0 and 1,000,000.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Index Size&lt;/th&gt;
&lt;th&gt;Range Query (1000-5000)&lt;/th&gt;
&lt;th&gt;Build Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Term-based (StringField)&lt;/td&gt;
&lt;td&gt;890 MB&lt;/td&gt;
&lt;td&gt;340 ms&lt;/td&gt;
&lt;td&gt;4.2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BKD Tree (IntPoint)&lt;/td&gt;
&lt;td&gt;45 MB&lt;/td&gt;
&lt;td&gt;4.2 ms&lt;/td&gt;
&lt;td&gt;3.8 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The BKD tree is &lt;strong&gt;20x smaller&lt;/strong&gt; and &lt;strong&gt;80x faster&lt;/strong&gt; for this workload. The gap widens as the index grows because the inverted index scales with the number of unique terms, while the BKD tree scales with the number of points.&lt;/p&gt;

&lt;p&gt;For text data, the inverted index remains unbeatable. For numeric data, the BKD tree is the clear winner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Production Tips and Best Practices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  When to Use Points vs DocValues vs Stored Fields
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Range queries, filtering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IntPoint&lt;/code&gt;, &lt;code&gt;LongPoint&lt;/code&gt;, &lt;code&gt;DoublePoint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;BKD tree is optimized for this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sorting by numeric field&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SortedNumericDocValuesField&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DocValues are columnar and fast for sorting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregations (facets)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SortedNumericDocValuesField&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DocValues support efficient aggregation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieve original value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;StoredField&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only stored fields return in search results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geospatial radius/box&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LatLonPoint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2D BKD tree with haversine distance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-precision geo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Geo3DPoint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3D spherical model without projection errors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Multi-Valued Points
&lt;/h3&gt;

&lt;p&gt;A document can have multiple points in the same field. Lucene stores each point independently and associates them all with the same document ID. Range queries will match if any point in the document falls within the range.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A store has multiple locations&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LatLonPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"locations"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12.9716&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;77.5946&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LatLonPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"locations"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;13.0827&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;80.2707&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Memory Considerations
&lt;/h3&gt;

&lt;p&gt;BKD tree files are memory-mapped via &lt;code&gt;MMapDirectory&lt;/code&gt;. The &lt;code&gt;.dii&lt;/code&gt; index file is small and typically stays in the OS page cache. The &lt;code&gt;.dim&lt;/code&gt; data file is larger but only the accessed leaf blocks are read into memory. For hot indexes, the entire tree may be cached by the OS, giving near-in-memory query performance without JVM heap pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing the Right Point Type
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;IntPoint&lt;/code&gt; for counts, quantities, IDs, and small ranges.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;LongPoint&lt;/code&gt; for timestamps, large IDs, and financial amounts in cents.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;DoublePoint&lt;/code&gt; for prices, measurements, and scientific values.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;FloatPoint&lt;/code&gt; only when memory is extremely constrained and precision loss is acceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never use &lt;code&gt;StringField&lt;/code&gt; or &lt;code&gt;TextField&lt;/code&gt; for numeric data that will be queried with ranges. It is a common mistake that silently destroys performance at scale.&lt;/p&gt;




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

&lt;p&gt;The BKD tree is one of Lucene's most important architectural innovations. It extends Lucene's capabilities from text search into the domains of numeric filtering, time-series analysis, and geospatial search - all while maintaining the same segment-based, immutable, memory-mapped design principles that make Lucene fast and reliable.&lt;/p&gt;

&lt;p&gt;Understanding BKD trees helps you make better indexing decisions. It tells you why &lt;code&gt;IntPoint&lt;/code&gt; exists, why &lt;code&gt;LatLonPoint&lt;/code&gt; is the right choice for geo queries, and why you should never index numeric ranges as strings. The next time you build a search index with prices, dates, or coordinates, you will know exactly how Lucene makes those queries fast.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>lucene</category>
      <category>search</category>
      <category>indexing</category>
      <category>performance</category>
    </item>
    <item>
      <title>How Polaris Locks Down Cloud Storage: IAM, Trust, and the Anatomy of a Secure Table Request</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Thu, 18 Jun 2026 00:33:07 +0000</pubDate>
      <link>https://dev.to/iprithv/how-polaris-locks-down-cloud-storage-iam-trust-and-the-anatomy-of-a-secure-table-request-3m5a</link>
      <guid>https://dev.to/iprithv/how-polaris-locks-down-cloud-storage-iam-trust-and-the-anatomy-of-a-secure-table-request-3m5a</guid>
      <description>&lt;p&gt;Most data catalogs handle the easy part: tracking tables, columns, and schemas. The hard part is making sure that when Spark, Trino, or Flink asks to read a table, it gets exactly the right credentials for exactly the right files and nothing else. No long-lived keys floating around. No blanket access to entire buckets. No hoping that someone rotated the IAM policy last quarter.&lt;/p&gt;

&lt;p&gt;Apache Polaris takes a fundamentally different approach. Instead of handing out persistent credentials to every engine and hoping for the best, Polaris mints short-lived, scoped credentials on demand. But before a single credential gets vended, Polaris has already done substantial security work: establishing trust relationships with your cloud provider, validating storage locations, enforcing a two-tier RBAC model, and making sure every request passes through multiple authorization layers.&lt;/p&gt;

&lt;p&gt;Let me walk you through how Polaris secures cloud storage from the ground up, from the moment you create a catalog to the moment a query engine reads a single Parquet file.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: Establishing Trust Before the First Request
&lt;/h2&gt;

&lt;p&gt;Before Polaris can vend credentials for S3, GCS, or Azure, it needs to know who it is talking to and prove that it has the right to request access on your behalf. This happens during catalog creation, and the details vary by cloud provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3: ARN, External ID, and the Trust Triangle
&lt;/h3&gt;

&lt;p&gt;For AWS, Polaris uses a cross-account IAM role assumption pattern. When you configure an S3-backed catalog, you provide an IAM Role ARN. Polaris then uses AWS STS &lt;code&gt;AssumeRole&lt;/code&gt; to request temporary credentials for that role. But AWS does not hand out credentials blindly. The trust policy on your IAM role must explicitly allow Polaris to assume it, and Polaris supports an external ID for additional protection against the confused deputy problem.&lt;/p&gt;

&lt;p&gt;Here is the trust flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You create an IAM role in your AWS account with a trust policy that allows Polaris to assume it&lt;/li&gt;
&lt;li&gt;You provide the role ARN and optional external ID to Polaris during catalog creation&lt;/li&gt;
&lt;li&gt;Polaris stores this configuration in the catalog metadata&lt;/li&gt;
&lt;li&gt;When an engine requests table access, Polaris calls &lt;code&gt;sts:AssumeRole&lt;/code&gt; with that ARN&lt;/li&gt;
&lt;li&gt;AWS returns temporary credentials scoped to the role's permissions&lt;/li&gt;
&lt;li&gt;Polaris further restricts those credentials to the specific table path before handing them to the engine&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The external ID is a subtle but critical detail. Without it, a third party who knows your role ARN could potentially convince AWS to issue credentials to them instead of Polaris. The external ID acts as a shared secret between you and Polaris, ensuring that only your Polaris instance can assume the role.&lt;/p&gt;

&lt;h3&gt;
  
  
  GCS: Service Account Delegation
&lt;/h3&gt;

&lt;p&gt;For Google Cloud Storage, Polaris uses service account impersonation. You create a dedicated Google Cloud service account with the minimal permissions needed for your catalog and grant Polaris permission to impersonate it. During catalog creation, you provide the service account email address. When credential vending is needed, Polaris uses the Google Cloud IAM API to generate short-lived OAuth 2.0 access tokens for that service account.&lt;/p&gt;

&lt;p&gt;The key difference from S3 is that GCS tokens are OAuth-based rather than session-based, but the principle is the same: Polaris never stores long-lived credentials, and the engine never sees the service account's private key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure: Tenant ID and Managed Identity
&lt;/h3&gt;

&lt;p&gt;For Azure Blob Storage, Polaris connects via tenant ID and either a managed identity or service principal. You configure the tenant ID, client ID, and client secret (or use a managed identity) during catalog setup. Polaris then requests tokens from Azure AD and uses them to generate SAS (Shared Access Signature) tokens for the engine. These SAS tokens are time-bound and scoped to specific containers or blobs, providing fine-grained access control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Allowed Locations: The Perimeter Guard
&lt;/h3&gt;

&lt;p&gt;Regardless of the cloud provider, Polaris enforces an "allowed locations" policy on every catalog. When you create a catalog, you specify the base storage locations that the catalog is permitted to access. Polaris validates every storage path against this whitelist before vending credentials. If an engine somehow requests access to a path outside the allowed locations, Polaris blocks the request at the storage integration layer, before any cloud API calls are made.&lt;/p&gt;

&lt;p&gt;This is a defense-in-depth measure. Even if an attacker compromises the RBAC layer and somehow gets a credential vending request approved, they still cannot access storage outside the catalog's configured locations. The allowed locations act as a hard perimeter around the catalog's data footprint.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Two-Tier RBAC Wall: Identity and Permissions, Separated
&lt;/h2&gt;

&lt;p&gt;Once storage is configured, the next security layer is Polaris's two-tier RBAC model. Most systems conflate "who you are" with "what you can do." Polaris separates them explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Principal Roles: Who You Are
&lt;/h3&gt;

&lt;p&gt;Principals in Polaris are service accounts or users. Each principal gets assigned one or more &lt;strong&gt;principal roles&lt;/strong&gt;. These roles define the principal's identity within the system: a data scientist, a pipeline service account, a monitoring agent. Principal roles are global and answer the question: "who is making this request?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Catalog Roles: What You Can Do
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Catalog roles&lt;/strong&gt; define permissions. A catalog role is a collection of privileges like &lt;code&gt;TABLE_READ_DATA&lt;/code&gt;, &lt;code&gt;TABLE_WRITE_DATA&lt;/code&gt;, &lt;code&gt;CATALOG_MANAGE_ACCESS&lt;/code&gt;, or &lt;code&gt;CATALOG_MANAGE_CONTENT&lt;/code&gt;. These roles are scoped to specific catalogs and can be granted to principal roles.&lt;/p&gt;

&lt;p&gt;The separation works like this: a data scientist principal might have the "Data Scientist" principal role. That principal role is granted the "Read-Only" catalog role on the "Production Analytics" catalog. The same principal role could also be granted the "Full Access" catalog role on the "Development" catalog. The principal's identity is consistent, but their permissions vary by catalog.&lt;/p&gt;

&lt;p&gt;This two-tier model has practical security benefits. When someone leaves the team, you revoke their principal role assignments, and all their catalog access disappears immediately. When a catalog's sensitivity changes, you modify the catalog role's privileges without touching any principal definitions. The separation of concerns makes audits simpler and reduces the blast radius of access changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privileges: The Permission Matrix
&lt;/h3&gt;

&lt;p&gt;Polaris defines a comprehensive privilege hierarchy. Key data access privileges include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TABLE_READ_DATA&lt;/code&gt; - read table data via SELECT&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TABLE_WRITE_DATA&lt;/code&gt; - insert, update, delete, or merge table data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TABLE_READ_PROPERTIES&lt;/code&gt; - read table metadata and properties&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TABLE_WRITE_PROPERTIES&lt;/code&gt; - modify table metadata&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIEW_READ_DATA&lt;/code&gt; - read view data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIEW_WRITE_DATA&lt;/code&gt; - modify view definitions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CATALOG_MANAGE_ACCESS&lt;/code&gt; - grant or revoke roles and privileges&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CATALOG_MANAGE_CONTENT&lt;/code&gt; - create, drop, or alter tables and namespaces&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Privileges are enforced by &lt;code&gt;PolarisAuthorizer&lt;/code&gt;, which evaluates the principal's catalog roles against the requested action on the target entity. If any catalog role grants the required privilege, the request proceeds. If none do, the request is rejected before any storage operations are considered.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Anatomy of a Secure Table Request
&lt;/h2&gt;

&lt;p&gt;Now that we understand the setup, let us trace a single table read request through Polaris's security layers. This is where all the pieces come together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Authentication
&lt;/h3&gt;

&lt;p&gt;The query engine sends an Iceberg REST API request to Polaris, typically with a Bearer token or mutual TLS. Polaris validates the token against its configured identity provider. The result is a &lt;code&gt;Principal&lt;/code&gt; object representing the authenticated caller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Principal Role Resolution
&lt;/h3&gt;

&lt;p&gt;Polaris looks up the principal's assigned principal roles. For this example, let us say our principal has the "Data Scientist" principal role.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Catalog Role Resolution
&lt;/h3&gt;

&lt;p&gt;The request targets a specific catalog. Polaris looks up which catalog roles the principal's roles have been granted on this catalog. If the "Data Scientist" principal role has been granted the "Read-Only" catalog role on this catalog, that catalog role is collected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Privilege Check
&lt;/h3&gt;

&lt;p&gt;The request asks to read table data, which requires &lt;code&gt;TABLE_READ_DATA&lt;/code&gt;. Polaris checks if any of the resolved catalog roles grant this privilege. If not, the request is rejected immediately with a 403. No storage APIs are called, no credentials are vended, and no cloud costs are incurred.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Entity Resolution
&lt;/h3&gt;

&lt;p&gt;With the privilege check passed, Polaris resolves the target entity: the catalog, namespace, and table. It fetches the table metadata from the persistence layer, typically via &lt;code&gt;AtomicMetaStoreManager&lt;/code&gt; and a JDBC backend. This metadata includes the table's location in cloud storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Storage Configuration Lookup
&lt;/h3&gt;

&lt;p&gt;Polaris retrieves the catalog's storage configuration: the S3 role ARN, GCS service account, or Azure tenant ID that was configured during setup. It also retrieves the allowed locations list for this catalog.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Location Validation
&lt;/h3&gt;

&lt;p&gt;Before any credential vending happens, Polaris validates that the table's storage location falls within the catalog's allowed locations. If the table is stored at &lt;code&gt;s3://production-analytics/fact_orders/&lt;/code&gt; and the allowed location is &lt;code&gt;s3://production-analytics/&lt;/code&gt;, validation passes. If the table somehow resolved to &lt;code&gt;s3://other-bucket/&lt;/code&gt;, Polaris would reject the request here, even though the RBAC check passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Credential Vending
&lt;/h3&gt;

&lt;p&gt;Now Polaris calls the cloud provider's credential API. For S3, it calls &lt;code&gt;sts:AssumeRole&lt;/code&gt; with the catalog's role ARN. For GCS, it requests an OAuth token via the IAM API. For Azure, it generates a SAS token.&lt;/p&gt;

&lt;p&gt;But here is the critical detail: Polaris does not just pass through whatever credentials the cloud provider returns. It restricts them further. The cloud credentials are scoped to the specific table path, not the entire catalog or bucket. And they are time-bound, typically to about 15 minutes (configurable).&lt;/p&gt;

&lt;p&gt;For a read request, Polaris ensures the credentials are read-only. For a write request, it ensures they have write access. The principle of least privilege is enforced at the cloud credential level, not just the Polaris privilege level.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 9: Response
&lt;/h3&gt;

&lt;p&gt;Polaris returns the Iceberg REST API response to the engine, including the short-lived credentials and the table metadata. The engine uses those credentials to read the actual data files from cloud storage. The credentials expire automatically, and the engine must return to Polaris for fresh credentials on subsequent requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 10: Audit and Revocation
&lt;/h3&gt;

&lt;p&gt;Every step in this flow is auditable. Polaris logs the principal, the request, the privilege check result, the entity accessed, and the credential vending action. Because credentials are short-lived and Polaris is the single point of issuance, revocation is instantaneous. If a principal's access is revoked at step 2, any credentials they previously received will expire within minutes and cannot be renewed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Federated Credentials: Extending Security Beyond Polaris (v1.3.0)
&lt;/h2&gt;

&lt;p&gt;Polaris 1.3.0 introduced a significant security enhancement: federated credential vending. Previously, when Polaris managed an external catalog (like a Hive or Hadoop-backed catalog), the query engine would use the external catalog's credentials directly. Polaris acted as a metadata pass-through, but the actual storage access was governed by the external system's credential model.&lt;/p&gt;

&lt;p&gt;With v1.3.0, Polaris can mint credentials for external catalogs just as it does for internal ones. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unified security model:&lt;/strong&gt; Whether a table is in an internal Polaris-managed catalog or a federated external catalog, the credential vending flow is the same: RBAC check, location validation, scoped credentials, automatic expiration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No credential leakage:&lt;/strong&gt; Engines never see the external catalog's long-lived credentials. They only see the short-lived, scoped credentials that Polaris mints on their behalf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralized audit:&lt;/strong&gt; All credential access flows through Polaris, even for data that lives in external systems. This gives security teams a single point of observability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instant revocation:&lt;/strong&gt; Revoking a principal's Polaris role immediately cuts off their access to federated data, without needing to touch the external system's IAM configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a subtle but important shift. Polaris is evolving from a metadata catalog into a unified security control plane for all tabular data, regardless of where it lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters: The Alternative Is Credential Chaos
&lt;/h2&gt;

&lt;p&gt;Without Polaris's model, organizations typically fall into one of two traps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1: The Shared Key.&lt;/strong&gt; Every engine gets the same long-lived IAM key with broad bucket access. Rotation is painful, so it rarely happens. When someone leaves, the key stays the same. When an engine is compromised, the attacker has access to everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 2: The IAM Sprawl.&lt;/strong&gt; Every team maintains their own IAM roles, service accounts, and policies. A data scientist needs access to a table, so they open a ticket. Someone creates a role. The role gets attached to a service account. The service account gets shared. Six months later, nobody knows which roles are still needed, but nobody dares delete them. This is the classic IAM debt spiral.&lt;/p&gt;

&lt;p&gt;Polaris avoids both traps by making the catalog the single point of access control. The cloud IAM roles are minimal: they only need to allow Polaris to assume them. Polaris handles the rest: identity, permissions, scoping, expiration, and audit. The IAM surface area shrinks dramatically, and the access model becomes comprehensible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance and Security: Not a Trade-off
&lt;/h2&gt;

&lt;p&gt;A common objection to credential vending is latency. If every table read requires a cloud API call to mint credentials, does that not slow things down? The answer is yes, but Polaris mitigates it aggressively.&lt;/p&gt;

&lt;p&gt;Credential minting takes roughly 100-200 milliseconds per cloud API call. Polaris caches credentials keyed by the principal, catalog, table, and operation type. If the same engine requests the same table again within the cache window, Polaris returns the cached credentials without calling the cloud provider. The cache TTL is shorter than the credential expiration time, so there is no risk of serving expired credentials.&lt;/p&gt;

&lt;p&gt;For most query patterns, the cache hit rate is high. Engines tend to read the same tables repeatedly during a query session, and batch reads for the same table are common. The practical overhead is minimal, and the security benefit is substantial.&lt;/p&gt;




&lt;h2&gt;
  
  
  Operational Security: Deploying Polaris Securely
&lt;/h2&gt;

&lt;p&gt;Security is not just about the architecture; it is also about how you run it. Polaris provides several operational controls to keep the deployment secure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS everywhere:&lt;/strong&gt; The Iceberg REST API and all internal communications should run over TLS 1.2 or higher. Polaris supports certificate-based authentication for service-to-service communication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin bootstrapping:&lt;/strong&gt; The &lt;code&gt;polaris-admin&lt;/code&gt; tool creates the initial principal and role assignments during first-time setup. This should be run in a secure environment, and the initial credentials should be rotated immediately after setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker and Helm:&lt;/strong&gt; Polaris distributes Docker images and Helm charts. Security teams should scan these images, run them with non-root users, and restrict their network access to only the necessary ports and peers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence encryption:&lt;/strong&gt; The &lt;code&gt;polaris-relational-jdbc&lt;/code&gt; persistence layer should connect to a database with TLS encryption. The database itself should be encrypted at rest and protected by its own access controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health and metrics endpoints:&lt;/strong&gt; Polaris 1.3.0 standardized &lt;code&gt;/q/health&lt;/code&gt; and &lt;code&gt;/q/metrics&lt;/code&gt; endpoints. These should be exposed to monitoring systems but protected from public access, as they may reveal operational details.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Polaris approaches cloud storage security with a simple but powerful principle: the catalog should be the gatekeeper, not just the librarian. By combining cloud-native trust relationships, two-tier RBAC, location validation, short-lived credential vending, and comprehensive audit logging, Polaris creates a security model that is both strong and operable.&lt;/p&gt;

&lt;p&gt;The next time you are evaluating a catalog for your data lake, ask not just "can it track my tables?" but also "can it keep my cloud credentials under control?" Polaris answers the second question with a resounding yes.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>polaris</category>
      <category>security</category>
      <category>api</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Distributed Scoring in OpenSearch: Why Your Results Aren't Perfectly Ranked</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:48:13 +0000</pubDate>
      <link>https://dev.to/iprithv/distributed-scoring-in-opensearch-why-your-results-arent-perfectly-ranked-3hj7</link>
      <guid>https://dev.to/iprithv/distributed-scoring-in-opensearch-why-your-results-arent-perfectly-ranked-3hj7</guid>
      <description>&lt;p&gt;&lt;em&gt;Understanding the trade-off between speed and accuracy in distributed search and the one query parameter that fixes it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You index a million documents, run a search, and the top result looks right. You scale to a billion documents across fifty shards, run the same search, and suddenly the ranking feels off. Not broken just subtly wrong. The best result is third. A mediocre result is first. You re-run the query and get a different order.&lt;/p&gt;

&lt;p&gt;Welcome to distributed scoring in OpenSearch.&lt;/p&gt;

&lt;p&gt;This is not a bug. It is a fundamental property of how OpenSearch and any distributed search engine built on Lucene computes relevance. The good news: it is fixable. The bad news: the fix costs you a round-trip. In this post, I will explain why scores diverge across shards, when it matters, and how to decide whether to pay the latency tax for perfect ranking.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Assumption That Breaks Everything
&lt;/h2&gt;

&lt;p&gt;Most engineers assume search ranking works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Collect every document that matches the query&lt;/li&gt;
&lt;li&gt;Score all of them using global statistics&lt;/li&gt;
&lt;li&gt;Return the top N in exact rank order&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is how it works in a single-node database. OpenSearch does not work that way. OpenSearch is distributed by design an index is split into shards, shards live on different nodes, and each shard is an independent Lucene index with its own local statistics.&lt;/p&gt;

&lt;p&gt;When you send a query, the coordinating node does not gather every matching document from every shard and score them centrally. That would be catastrophically slow at scale. Instead, OpenSearch uses a &lt;strong&gt;scatter-gather&lt;/strong&gt; pattern with a critical optimization: &lt;strong&gt;scores are computed locally on each shard, using local term statistics.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How Query Execution Actually Works
&lt;/h2&gt;

&lt;p&gt;Here is what happens when you run a &lt;code&gt;match&lt;/code&gt; query against a multi-shard index:&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Query Phase (Scatter)
&lt;/h3&gt;

&lt;p&gt;The coordinating node sends the query to every shard that holds data for the index. Each shard executes the query independently against its own Lucene segments. For each document, it computes a relevance score using BM25 or your chosen similarity function.&lt;/p&gt;

&lt;p&gt;BM25 depends on two key statistics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Term Frequency (TF):&lt;/strong&gt; How often the term appears in this document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inverse Document Frequency (IDF):&lt;/strong&gt; How rare the term is across the &lt;em&gt;entire&lt;/em&gt; collection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the problem: &lt;strong&gt;each shard only knows about its own documents.&lt;/strong&gt; It computes IDF using local document frequencies, not global ones. A term that appears in 1% of documents globally might appear in 5% of documents on one shard and 0.1% on another. The shard with the 5% local frequency will score matching documents lower. The shard with the 0.1% frequency will score them higher.&lt;/p&gt;

&lt;p&gt;The result? Two identical documents on different shards can receive different scores for the same query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Merge and Fetch (Gather)
&lt;/h3&gt;

&lt;p&gt;Each shard returns its top-K results (document IDs + scores) to the coordinating node. The coordinating node merges these shard-local rankings into a single global ranking. But it is merging rankings that were computed with inconsistent scoring baselines.&lt;/p&gt;

&lt;p&gt;Finally, if the client requested full documents (&lt;code&gt;_source&lt;/code&gt;), the coordinating node issues a fetch phase to retrieve the actual document bodies from the relevant shards.&lt;/p&gt;

&lt;p&gt;The default search type &lt;code&gt;QUERY_THEN_FETCH&lt;/code&gt; optimizes for speed. It assumes that for most use cases, "close enough" ranking is acceptable. And for many use cases, it is.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Local Scoring Bites You
&lt;/h2&gt;

&lt;p&gt;The ranking error is usually small and random. But there are conditions where it becomes material:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Skewed Shard Distributions
&lt;/h3&gt;

&lt;p&gt;If your data is not uniformly distributed for example, time-based indices where recent shards have different term distributions than old shards local IDF diverges significantly. A query for "Kubernetes" on a shard full of 2024 DevOps logs will score differently than on a shard of 2019 monolith logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Small Shards with Rare Terms
&lt;/h3&gt;

&lt;p&gt;With many small shards, rare terms have volatile local document frequencies. A term that appears in exactly one document globally might appear in zero documents on most shards and one document on a single shard. That lucky document gets an enormous IDF boost and shoots to the top.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. High-Precision Ranking Requirements
&lt;/h3&gt;

&lt;p&gt;E-commerce search, legal discovery, and academic search often need deterministic, reproducible ranking. If you are A/B testing ranking algorithms or comparing results across replicas, non-deterministic shard-local scoring introduces noise that masks real signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Routing Keys and Custom Distributions
&lt;/h3&gt;

&lt;p&gt;If you use custom routing (e.g., routing all documents for tenant X to the same shard), you create isolated term-frequency bubbles. A term common in tenant X's documents is rare globally but frequent locally and local scoring does not know the difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: DFS_QUERY_THEN_FETCH
&lt;/h2&gt;

&lt;p&gt;OpenSearch provides an alternative search type: &lt;code&gt;DFS_QUERY_THEN_FETCH&lt;/code&gt;. The "DFS" stands for &lt;strong&gt;Distributed Frequency Search&lt;/strong&gt; a pre-query phase that gathers global term statistics before executing the actual search.&lt;/p&gt;

&lt;p&gt;Here is the flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DFS Phase:&lt;/strong&gt; The coordinating node sends a lightweight request to all shards. Each shard returns its local term frequencies and document frequencies for the query terms. The coordinating node aggregates these into global statistics.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Query Phase:&lt;/strong&gt; The coordinating node re-sends the query to all shards, this time accompanied by the global term statistics. Every shard now scores documents using the same IDF values.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fetch Phase:&lt;/strong&gt; Identical to the default retrieve full documents for the globally-merged top results.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: perfectly consistent scoring across shards. Identical documents on different shards receive identical scores. Ranking is deterministic. The A/B test noise disappears.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cost: One Extra Round-Trip
&lt;/h2&gt;

&lt;p&gt;DFS_QUERY_THEN_FETCH adds a network round-trip before the query phase. For simple term queries on small clusters, this overhead is negligible a few milliseconds. For complex queries with many terms on large clusters, it can add tens of milliseconds.&lt;/p&gt;

&lt;p&gt;More importantly, the DFS phase does not reduce total work. It redistributes it. The coordinating node must collect, merge, and forward statistics. If your cluster is already network-bound or the coordinating node is under CPU pressure, DFS can amplify the bottleneck.&lt;/p&gt;

&lt;p&gt;Here is my practical guidance:&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;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Few large shards, uniform data, speed matters&lt;/td&gt;
&lt;td&gt;Stick with QUERY_THEN_FETCH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many small shards, skewed distributions, precision matters&lt;/td&gt;
&lt;td&gt;Use DFS_QUERY_THEN_FETCH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproducible ranking required (A/B tests, audits)&lt;/td&gt;
&lt;td&gt;Use DFS_QUERY_THEN_FETCH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rare-term-heavy queries (specialized domains)&lt;/td&gt;
&lt;td&gt;Use DFS_QUERY_THEN_FETCH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-traffic, latency-sensitive, "good enough" ranking&lt;/td&gt;
&lt;td&gt;Stick with QUERY_THEN_FETCH&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  How to Use It
&lt;/h2&gt;

&lt;p&gt;In OpenSearch, you specify the search type at query time:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_search?search_type=dfs_query_then_fetch&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;"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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"multi_match"&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;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"distributed scoring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"title^3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no cluster-wide default to change this. It is a per-query decision, which is the right design you pay the cost only when you need the precision.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Deeper Realization: This Is Inherent to Distributed Search
&lt;/h2&gt;

&lt;p&gt;The local-vs-global scoring problem is not an OpenSearch quirk. It is inherent to any distributed search system that shards data independently. Solr has the same trade-off. Elasticsearch had it before the fork. The only engines that avoid it entirely are those that do not shard at the indexing level or those that accept the latency cost of global scoring on every query.&lt;/p&gt;

&lt;p&gt;What OpenSearch gives you is transparency and control. You can see the behavior, measure it, and opt into the fix when your use case demands it. That is the difference between a system that hides complexity and one that exposes it with clear knobs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Have Learned Maintaining Search Plugins
&lt;/h2&gt;

&lt;p&gt;As a maintainer of the OpenSearch dashboards-search-relevance plugin, I have spent a lot of time in the scatter-gather path. One thing that surprised me early on: the concurrent segment search improvements in OpenSearch 3.0 do not change this trade-off. They parallelize execution &lt;em&gt;within&lt;/em&gt; a shard, but each shard still operates with local statistics unless you request DFS.&lt;/p&gt;

&lt;p&gt;Another surprise: most ranking anomalies reported as "bugs" are actually local IDF effects. Before you debug your analyzer or your BM25 parameters, check whether &lt;code&gt;dfs_query_then_fetch&lt;/code&gt; makes the anomaly disappear. If it does, you have found your culprit and your fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OpenSearch scores documents using &lt;strong&gt;local shard statistics&lt;/strong&gt;, not global ones&lt;/li&gt;
&lt;li&gt;This causes ranking inconsistencies that are usually small but can be significant with skewed data or rare terms&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DFS_QUERY_THEN_FETCH&lt;/code&gt; adds a pre-query phase to gather global statistics, enabling consistent cross-shard scoring&lt;/li&gt;
&lt;li&gt;The cost is one extra round-trip acceptable for precision-critical workloads, unnecessary for speed-critical ones&lt;/li&gt;
&lt;li&gt;This is not a bug. It is a documented, controllable trade-off in distributed search design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next time your search results feel subtly wrong, ask yourself: am I seeing the true global ranking or fifty local opinions merged together?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensearch</category>
      <category>search</category>
      <category>database</category>
      <category>data</category>
    </item>
    <item>
      <title>OpenSearch Query Phase vs Fetch Phase: What Actually Happens on Each Shard</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:43:40 +0000</pubDate>
      <link>https://dev.to/iprithv/opensearch-query-phase-vs-fetch-phase-what-actually-happens-on-each-shard-509k</link>
      <guid>https://dev.to/iprithv/opensearch-query-phase-vs-fetch-phase-what-actually-happens-on-each-shard-509k</guid>
      <description>&lt;p&gt;&lt;em&gt;Understanding the two-phase search architecture that powers every OpenSearch query&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Every time you run a search query in OpenSearch, the engine executes a carefully orchestrated two-phase dance across your cluster. The query phase finds the right documents. The fetch phase retrieves their contents. Understanding how these phases work - and how they interact - is the difference between a search that returns in milliseconds and one that times out.&lt;/p&gt;

&lt;p&gt;As someone who has spent the last year maintaining the search-relevance plugin and contributing to OpenSearch core, I have seen how often engineers conflate these two phases. They optimize the wrong things, tune the wrong parameters, and wonder why their queries still lag. This post is what I wish I had read when I started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Two Phases?
&lt;/h2&gt;

&lt;p&gt;OpenSearch distributes data across multiple shards, often on multiple nodes. When you search, the coordinating node must ask every shard to participate. But there is a critical insight: &lt;strong&gt;finding the most relevant documents is lighter than retrieving their full contents.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If OpenSearch retrieved the full &lt;code&gt;_source&lt;/code&gt; of every document from every shard just to find the top 10 results, the network bandwidth and I/O would be catastrophic. Instead, the engine splits the work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1 - Query:&lt;/strong&gt; Each shard finds its local top-N results, returning only lightweight metadata (doc IDs and scores).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2 - Fetch:&lt;/strong&gt; The coordinating node identifies the true global top-N, then asks only the relevant shards for the full document contents.&lt;/p&gt;

&lt;p&gt;This is the scatter-gather pattern in practice. The query phase is the scatter. The fetch phase is the gather.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: The Query Phase
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Happens on Each Shard
&lt;/h3&gt;

&lt;p&gt;When a search request arrives at the coordinating node, it is forwarded to every shard in the index (or the relevant shards if routing is specified). Here is what each shard does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parse the query&lt;/strong&gt; - The query DSL is converted into a Lucene Query object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execute against segments&lt;/strong&gt; - The query runs against all Lucene segments in the shard. With concurrent segment search (enabled by default in OpenSearch 3.0), these segments may be searched in parallel across CPU slices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score documents&lt;/strong&gt; - BM25 scoring is computed for each matching document. Term frequency and inverse document frequency are calculated locally within that shard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect top-K&lt;/strong&gt; - Using a priority queue, the shard keeps only the top N results by score (where N is &lt;code&gt;from + size&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return metadata&lt;/strong&gt; - The shard returns only document IDs, scores, and sort values - never the full &lt;code&gt;_source&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Local Scoring Problem
&lt;/h3&gt;

&lt;p&gt;Here is a subtle but important detail: &lt;strong&gt;BM25 scores are computed using local shard statistics.&lt;/strong&gt; Each shard calculates term frequency and document frequency based only on the documents it holds. This means a term that is rare globally but common on one shard will be scored differently there.&lt;/p&gt;

&lt;p&gt;For most use cases, this approximation is good enough. But when you need truly accurate global scoring, OpenSearch offers &lt;code&gt;dfs_query_then_fetch&lt;/code&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_search?search_type=dfs_query_then_fetch&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;"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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"match"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"distributed 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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adds a pre-query round-trip: the coordinating node fetches term statistics from all shards, then broadcasts them back so every shard uses global IDF. The trade-off? One extra network hop for scoring accuracy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aggregation Execution
&lt;/h3&gt;

&lt;p&gt;If your query includes aggregations, they execute during the query phase too. Each shard builds its local aggregation buckets, then the coordinating node merges them. This is why deeply nested aggregations on high-cardinality fields can be so expensive - every shard is building and serializing large bucket structures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2: The Fetch Phase
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Document Retrieval Problem
&lt;/h3&gt;

&lt;p&gt;After the query phase completes, the coordinating node has a sorted list of global top-N results. But it only has document IDs and scores. If the client requested &lt;code&gt;_source&lt;/code&gt; fields, highlighting, or script fields, the coordinating node must now fetch those.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Happens During Fetch
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Merge and rank&lt;/strong&gt; - The coordinating node merges all shard results, re-sorting by score to produce the true global top-N.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request documents&lt;/strong&gt; - For each of the top-N document IDs, the coordinating node asks the owning shard for the full document content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieve stored fields&lt;/strong&gt; - Each shard fetches the requested fields from its stored field cache or disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply highlighting&lt;/strong&gt; - If requested, the highlighter (unified, plain, or fast vector) generates match snippets by re-analyzing the relevant text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return results&lt;/strong&gt; - The final documents are assembled and returned to the client.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Hidden Cost
&lt;/h3&gt;

&lt;p&gt;The fetch phase is where hidden costs emerge. If you request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large &lt;code&gt;_source&lt;/code&gt; documents (10KB+ each)&lt;/li&gt;
&lt;li&gt;Highlighting on large fields&lt;/li&gt;
&lt;li&gt;Script fields that execute at fetch time&lt;/li&gt;
&lt;li&gt;Deep pagination (&lt;code&gt;from: 10000, size: 10&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...the fetch phase can dominate your query latency. The query phase might finish in 5ms, but the fetch phase takes 200ms because it is retrieving and highlighting 10,000 documents just to return 10.&lt;/p&gt;




&lt;h2&gt;
  
  
  How &lt;code&gt;size&lt;/code&gt; and &lt;code&gt;from&lt;/code&gt; Control Everything
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;size&lt;/code&gt; and &lt;code&gt;from&lt;/code&gt; parameters are the primary levers for controlling both phases:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"match_all"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Query Phase Impact
&lt;/h3&gt;

&lt;p&gt;During the query phase, &lt;strong&gt;each shard must return &lt;code&gt;from + size&lt;/code&gt; results.&lt;/strong&gt; If you have 5 shards and request &lt;code&gt;from: 100, size: 10&lt;/code&gt;, every shard must find and return its top 110 results. The coordinating node then merges 550 results to find the true global top 10.&lt;/p&gt;

&lt;p&gt;With 10 shards and &lt;code&gt;from: 10000, size: 10&lt;/code&gt;? Every shard returns 10,010 results. The coordinating node merges 100,100 results. This is why deep pagination is so expensive in OpenSearch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fetch Phase Impact
&lt;/h3&gt;

&lt;p&gt;The fetch phase only retrieves documents for the final returned set. So with &lt;code&gt;size: 10&lt;/code&gt;, only 10 documents are fetched, regardless of &lt;code&gt;from&lt;/code&gt;. But the query phase already paid the price of finding and ranking all those candidate documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;terminate_after&lt;/code&gt; Escape Hatch
&lt;/h3&gt;

&lt;p&gt;For use cases where you only need approximate results, &lt;code&gt;terminate_after&lt;/code&gt; tells each shard to stop after finding N documents:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_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;"terminate_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"match"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This dramatically speeds up the query phase but means you might miss better-scoring documents that were found later. Use it for analytics, not for user-facing search.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance Tuning: Query Phase
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Reduce Shard Count When Possible
&lt;/h3&gt;

&lt;p&gt;More shards = more work in the query phase. If you have 50 shards for a 10GB index, you are creating unnecessary scatter-gather overhead. Aim for shards in the 20-50GB range.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use Routing for Targeted Queries
&lt;/h3&gt;

&lt;p&gt;If your queries always filter by a specific field (like &lt;code&gt;user_id&lt;/code&gt;), use custom routing to ensure all of a user's documents land on the same shard:&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;/my-index/_doc?routing=user_&lt;/span&gt;&lt;span class="mi"&gt;123&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;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now searches with &lt;code&gt;routing=user_123&lt;/code&gt; hit only one shard. The query phase just got 10x faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Leverage Concurrent Segment Search
&lt;/h3&gt;

&lt;p&gt;OpenSearch 3.0 enables concurrent segment search by default. For long-running queries with aggregations, this parallelizes segment processing within each shard. Ensure your CPU has cores to spare, or the overhead of thread coordination may not be worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Be Careful with Aggregations
&lt;/h3&gt;

&lt;p&gt;Terms aggregations on high-cardinality fields (like &lt;code&gt;user_id&lt;/code&gt; or &lt;code&gt;ip_address&lt;/code&gt;) force each shard to build massive bucket arrays. If you do not need exact counts, use &lt;code&gt;shard_size&lt;/code&gt; to limit the buckets each shard returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&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;"top_users"&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;"terms"&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;"user_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"shard_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;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;
  
  
  Performance Tuning: Fetch Phase
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Use &lt;code&gt;docvalue_fields&lt;/code&gt; Instead of &lt;code&gt;_source&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If you only need specific fields that are stored as doc values (keyword, numeric, date, boolean), request them directly instead of parsing the full &lt;code&gt;_source&lt;/code&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_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;"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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"match_all"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"docvalue_fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doc values are columnar and much faster to retrieve than the full JSON &lt;code&gt;_source&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Disable &lt;code&gt;_source&lt;/code&gt; if You Do Not Need It
&lt;/h3&gt;

&lt;p&gt;For purely analytic queries where you only care about aggregation results, explicitly disable &lt;code&gt;_source&lt;/code&gt; retrieval:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_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;"_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&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;"status_counts"&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;"terms"&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;"status"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This skips the fetch phase entirely for hits.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Avoid Highlighting Large Fields
&lt;/h3&gt;

&lt;p&gt;Highlighting requires re-analyzing the matched text. If your documents have 100KB text fields and you highlight them, the fetch phase will grind. Consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Using the &lt;code&gt;fast_vector_highlighter&lt;/code&gt; with &lt;code&gt;term_vector&lt;/code&gt; storage (trades index size for fetch speed)&lt;/li&gt;
&lt;li&gt;Limiting fragment size and count&lt;/li&gt;
&lt;li&gt;Storing a truncated &lt;code&gt;summary&lt;/code&gt; field specifically for highlighting&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Use Search After for Deep Pagination
&lt;/h3&gt;

&lt;p&gt;Instead of &lt;code&gt;from: 10000&lt;/code&gt;, use &lt;code&gt;search_after&lt;/code&gt; with a point-in-time (PIT) for efficient deep pagination:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index/_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;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pit"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-pit-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"keep_alive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1m"&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;"search_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1625097600000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_123"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sort"&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;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"asc"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"asc"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids the &lt;code&gt;from + size&lt;/code&gt; explosion in the query phase and is the recommended approach for scrolling through large result sets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-World Example: Debugging a Slow Query
&lt;/h2&gt;

&lt;p&gt;Let me walk through how I debugged a slow search in production. The query was timing out at 30 seconds:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/logs-*/_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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"match"&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;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"highlight"&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;"fields"&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;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggs"&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;"by_service"&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;"terms"&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;"service.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; 100 shards, each with 5GB of logs. The query phase was fast (BM25 on "error" is selective). But the fetch phase was retrieving 50 full log messages, each 50KB, and highlighting them across 100 shards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reduced &lt;code&gt;size&lt;/code&gt; from 50 to 10 (users do not need 50 highlighted logs)&lt;/li&gt;
&lt;li&gt;Added &lt;code&gt;"summary": { "type": "text" }&lt;/code&gt; field with 500-char limit for highlighting&lt;/li&gt;
&lt;li&gt;Switched highlighting to the &lt;code&gt;summary&lt;/code&gt; field only&lt;/li&gt;
&lt;li&gt;Added &lt;code&gt;"shard_size": 20&lt;/code&gt; to the aggregation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Query time dropped from 30 seconds to 800ms. The query phase was never the problem - the fetch phase was.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Key Insight
&lt;/h2&gt;

&lt;p&gt;Most OpenSearch performance problems are not query problems. They are fetch problems. The query phase is highly optimized - Lucene is incredibly fast at finding and scoring documents. The fetch phase is where your data model, field sizes, and retrieval strategy matter.&lt;/p&gt;

&lt;p&gt;When you are debugging slow searches, ask yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the query phase slow? (Check with &lt;code&gt;_source: false&lt;/code&gt; and no aggregations)&lt;/li&gt;
&lt;li&gt;Is the fetch phase slow? (Compare with and without &lt;code&gt;_source&lt;/code&gt; retrieval)&lt;/li&gt;
&lt;li&gt;Am I retrieving too many documents? (Reduce &lt;code&gt;size&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Am I highlighting too much text? (Use dedicated summary fields)&lt;/li&gt;
&lt;li&gt;Am I forcing deep pagination? (Use &lt;code&gt;search_after&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;The two-phase search architecture in OpenSearch is not an implementation detail - it is the fundamental design that makes distributed search feasible. The query phase is about finding and ranking. The fetch phase is about retrieving and formatting. They have different performance characteristics, different tuning parameters, and different failure modes.&lt;/p&gt;

&lt;p&gt;Understanding this split lets you diagnose problems faster, tune the right parameters, and build searches that scale. The next time a query is slow, do not just stare at the query DSL. Ask: is this a query phase problem, or a fetch phase problem?&lt;/p&gt;

&lt;p&gt;The answer will guide you to the right fix.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensearch</category>
      <category>search</category>
      <category>database</category>
      <category>data</category>
    </item>
    <item>
      <title>Vector Search in Elasticsearch: From Keywords to Meaning - Building Semantic Search and RAG Pipelines</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:40:14 +0000</pubDate>
      <link>https://dev.to/iprithv/vector-search-in-elasticsearch-from-keywords-to-meaning-building-semantic-search-and-rag-4jd7</link>
      <guid>https://dev.to/iprithv/vector-search-in-elasticsearch-from-keywords-to-meaning-building-semantic-search-and-rag-4jd7</guid>
      <description>&lt;p&gt;You type "k8s deployment troubleshooting" into your documentation search. The top result is a page about Kubernetes architecture that never mentions the word "troubleshooting." It is exactly what you need. BM25 would have missed it entirely.&lt;/p&gt;

&lt;p&gt;This is the promise of vector search: finding documents by meaning, not just matching words. In 2025 and 2026, vector search has moved from niche ML engineering to a core Elasticsearch capability. If you are building search for AI applications - RAG pipelines, semantic Q&amp;amp;A, recommendation systems - understanding how Elasticsearch handles vectors is no longer optional.&lt;/p&gt;

&lt;p&gt;I have spent the past year building RAG pipelines at Cloudera, and I have learned that vector search is powerful but easy to misuse. This post covers what works, what does not, and how to implement it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Vector Search Matters (And When It Does Not)
&lt;/h2&gt;

&lt;p&gt;BM25, which we covered in a previous post, is brilliant at matching exact terms. But it is fundamentally lexical. It does not understand that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"k8s" and "kubernetes" are the same thing&lt;/li&gt;
&lt;li&gt;"docker container" and "containerization" are related concepts&lt;/li&gt;
&lt;li&gt;"out of memory error" and "heap exhaustion" describe the same problem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vector search solves this by converting text into high-dimensional numerical vectors (embeddings) where semantically similar content lives close together in vector space. A query for "k8s deployment troubleshooting" gets embedded into a vector, and Elasticsearch finds the nearest document vectors - even if they do not share a single keyword.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But vector search is not a replacement for BM25. It is a complement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;BM25 is faster, requires no ML infrastructure, and excels at exact-term matching. Vector search is slower, requires embedding models, and shines at conceptual similarity. The best search systems in 2026 use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Elasticsearch Stores and Indexes Vectors
&lt;/h2&gt;

&lt;p&gt;Elasticsearch introduced the &lt;code&gt;dense_vector&lt;/code&gt; field type in version 7.x and has dramatically improved it through 8.x and into 2026. Here is how it works under the hood.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dense_vector Field Type
&lt;/h3&gt;

&lt;p&gt;A dense vector is simply an array of floating-point numbers. A 768-dimensional embedding from a model like E5 looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.023&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.156&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.089&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.041&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;numbers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;total&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your index mapping:&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;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/products&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;"mappings"&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;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;"description"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;"description_vector"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dense_vector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"dims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"similarity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cosine"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dims&lt;/code&gt;: The vector dimension (must match your embedding model)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;index&lt;/code&gt;: Whether to build an ANN index (set to &lt;code&gt;true&lt;/code&gt; for search, &lt;code&gt;false&lt;/code&gt; if only storing)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;similarity&lt;/code&gt;: Distance metric - &lt;code&gt;l2_norm&lt;/code&gt; (Euclidean), &lt;code&gt;dot_product&lt;/code&gt;, or &lt;code&gt;cosine&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  HNSW: The Algorithm Behind Approximate Search
&lt;/h3&gt;

&lt;p&gt;Elasticsearch uses HNSW (Hierarchical Navigable Small World) for approximate nearest neighbor (ANN) search. HNSW builds a multi-layer graph where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Layer 0 contains all vectors with dense local connections&lt;/li&gt;
&lt;li&gt;Higher layers contain a subset of vectors with longer-distance connections&lt;/li&gt;
&lt;li&gt;Search starts at the top layer, greedily navigates down, then searches locally at layer 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HNSW is fast (sub-10ms for million-vector indexes) but approximate. It may miss the true nearest neighbor in exchange for speed. You can tune this trade-off:&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;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/products&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;"mappings"&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;"properties"&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;"description_vector"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dense_vector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"dims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"similarity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cosine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"index_options"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hnsw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"ef_construction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;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;ul&gt;
&lt;li&gt;
&lt;code&gt;m&lt;/code&gt;: Number of bi-directional links per node (higher = more accurate, more memory)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ef_construction&lt;/code&gt;: Search depth during index building (higher = better graph quality, slower indexing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For query-time accuracy tuning, use &lt;code&gt;num_candidates&lt;/code&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/products/_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;"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;"description_vector"&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_vector"&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.023&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.156&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"num_candidates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;num_candidates&lt;/code&gt; is how many vectors Elasticsearch considers before returning the top &lt;code&gt;k&lt;/code&gt;. Higher values improve recall but increase latency. A common rule: &lt;code&gt;num_candidates&lt;/code&gt; should be 10x &lt;code&gt;k&lt;/code&gt; for good recall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a RAG Pipeline with Elasticsearch
&lt;/h2&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) is the dominant architecture for grounding LLMs in private data. The pipeline looks like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document -&amp;gt; Chunk -&amp;gt; Embed -&amp;gt; Index -&amp;gt; Query -&amp;gt; Retrieve -&amp;gt; Generate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is how to implement it in Elasticsearch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Chunk Your Documents
&lt;/h3&gt;

&lt;p&gt;LLMs have context limits, so long documents must be split into chunks. A common approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.text_splitter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;

&lt;span class="n"&gt;text_splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text_splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;long_document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chunk size depends on your embedding model. E5 and BGE models typically use 512 tokens. OpenAI text-embedding-3-large supports up to 8192 tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Generate Embeddings
&lt;/h3&gt;

&lt;p&gt;You need an embedding model. Options in 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Dimensions&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ELSER v2&lt;/td&gt;
&lt;td&gt;Sparse (learned)&lt;/td&gt;
&lt;td&gt;~2000 terms&lt;/td&gt;
&lt;td&gt;Built-in, no external service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;multilingual-e5-large&lt;/td&gt;
&lt;td&gt;Dense&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;Cross-lingual, high quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BGE-large-en-v1.5&lt;/td&gt;
&lt;td&gt;Dense&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;Open source, competitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI text-embedding-3-large&lt;/td&gt;
&lt;td&gt;Dense&lt;/td&gt;
&lt;td&gt;3072&lt;/td&gt;
&lt;td&gt;Highest quality, API cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For dense vectors with a local model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intfloat&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;multilingual&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;e5&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;large&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: Use &lt;code&gt;normalize_embeddings=True&lt;/code&gt; if you are using &lt;code&gt;dot_product&lt;/code&gt; similarity. Elasticsearch can then skip the normalization step internally for faster searches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Index Documents with Vectors
&lt;/h3&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;/_bulk&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;"index"&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;"_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"knowledge_base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_1_chunk_0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"To troubleshoot Kubernetes deployments..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source_doc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s_guide.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"devops"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content_vector"&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.023&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.156&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;h3&gt;
  
  
  Step 4: Search
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/knowledge_base/_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;"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;"content_vector"&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_vector"&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.041&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.089&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"num_candidates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query vector is the embedding of the user question: "How do I fix a Kubernetes deployment that will not start?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid Search: The Production-Ready Approach
&lt;/h2&gt;

&lt;p&gt;Pure vector search has a problem: it misses exact matches. If a user searches for "Error code 503," a vector search might return documents about "server errors" in general but miss the exact troubleshooting page for HTTP 503.&lt;/p&gt;

&lt;p&gt;The solution is hybrid search: run BM25 and kNN in parallel, then merge results.&lt;/p&gt;

&lt;p&gt;Elasticsearch 8.15+ provides the &lt;code&gt;retrievers&lt;/code&gt; API for this:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/knowledge_base/_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;"retriever"&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;"rrf"&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;"retrievers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"standard"&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;"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="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"multi_match"&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;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s deployment troubleshooting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"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;"content_vector"&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_vector"&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.041&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.089&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"num_candidates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rank_constant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"window_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF (Reciprocal Rank Fusion) combines rankings without normalizing scores (which are incomparable across BM25 and cosine similarity). The formula is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rrf_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;k&lt;/code&gt; (rank_constant, default 60) prevents top ranks from dominating. Documents that rank well in both retrievers bubble to the top.&lt;/p&gt;

&lt;p&gt;This is the architecture behind modern RAG systems. BM25 ensures exact matches surface. Vector search ensures conceptual matches surface. RRF merges them intelligently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata Filtering + Vector Search: The Killer Feature
&lt;/h2&gt;

&lt;p&gt;Standalone vector databases (Pinecone, Weaviate) are great at pure vector search. But Elasticsearch has an advantage: you can combine vector search with the full power of Elasticsearch filtering, aggregations, and text search in a single query.&lt;/p&gt;

&lt;p&gt;Example: Find semantically similar products, but only in the "electronics" category, with price under $500, and in stock:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/products/_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;"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;"description_vector"&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_vector"&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.041&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.089&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"num_candidates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"filter"&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;"bool"&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;"must"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"term"&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;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"electronics"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"range"&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;"price"&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;"lte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"term"&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;"in_stock"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Elasticsearch applies the filter during the HNSW graph traversal (post-filtering), so only matching vectors are considered. This is faster than retrieving vectors and filtering afterward.&lt;/p&gt;

&lt;p&gt;This pattern - semantic similarity + structured filters - is why many teams choose Elasticsearch over dedicated vector databases. You get vectors AND the query DSL you already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  ELSER: Built-In Semantic Search Without External Models
&lt;/h2&gt;

&lt;p&gt;Not every team wants to run an embedding model. Elasticsearch provides ELSER (Elastic Learned Sparse EncodeR), a built-in model that generates sparse vectors using term expansion.&lt;/p&gt;

&lt;p&gt;ELSER works differently from dense vectors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It expands text into learned tokens (not just the words in the document)&lt;/li&gt;
&lt;li&gt;It produces sparse vectors (mostly zeros, ~2000 non-zero terms)&lt;/li&gt;
&lt;li&gt;It is included with Elasticsearch - no external API or model hosting needed&lt;/li&gt;
&lt;li&gt;It runs on the Elasticsearch cluster using the inference processor
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/_ingest/pipeline/elser_pipeline&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;"processors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inference"&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;"model_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".elser_model_2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"input_output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input_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;"content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"output_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;"content_embedding"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ELSER v2 (released in 2024) is competitive with dense embedding models for English text. For multilingual or domain-specific content, custom dense models still win. But for teams that want semantic search with zero ML infrastructure, ELSER is the fastest path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: What to Expect in Production
&lt;/h2&gt;

&lt;p&gt;Vector search is not free. Here is what you need to plan for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory
&lt;/h3&gt;

&lt;p&gt;Vectors are memory-hungry. A single 768-dimensional float32 vector uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;768 dimensions * 4 bytes = 3 KB per vector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One million vectors = 3 GB. Plus HNSW graph overhead (roughly 2x the vector memory). For 10 million vectors at 768 dimensions, expect 30-60 GB of memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;int8&lt;/code&gt; quantization (available in Elasticsearch 8.13+): 768 dimensions * 1 byte = 768 bytes per vector. 4x memory reduction with minimal quality loss.&lt;/li&gt;
&lt;li&gt;Reduce dimensions: Some models (like OpenAI text-embedding-3-small) support dimension truncation. 256 dimensions often perform nearly as well as 768.&lt;/li&gt;
&lt;li&gt;Shard sizing: Keep vector indices on fewer, larger shards to reduce per-shard overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Indexing Throughput
&lt;/h3&gt;

&lt;p&gt;The bottleneck is usually embedding generation, not Elasticsearch indexing. A local GPU can generate 100-500 embeddings/second. CPU inference might manage 10-50/second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch embedding requests (most APIs support batching)&lt;/li&gt;
&lt;li&gt;Use GPU inference if volume is high&lt;/li&gt;
&lt;li&gt;Consider ELSER if you want to skip external embedding entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Query Latency
&lt;/h3&gt;

&lt;p&gt;ANN search with HNSW is fast but not as fast as BM25. Expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BM25: 1-5 ms&lt;/li&gt;
&lt;li&gt;HNSW kNN (1M vectors): 5-20 ms&lt;/li&gt;
&lt;li&gt;HNSW kNN (10M vectors): 10-50 ms&lt;/li&gt;
&lt;li&gt;Hybrid (BM25 + kNN + RRF): 15-60 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For user-facing search, this is usually acceptable. For high-throughput batch pipelines, consider pre-filtering with metadata to reduce the vector search space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Common Vector Search Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Using Vectors When BM25 Would Suffice
&lt;/h3&gt;

&lt;p&gt;If your users search by exact product SKUs, error codes, or names, vector search adds latency and complexity with no benefit. Start with BM25. Add vectors when you see queries where keyword matching fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Wrong Similarity Metric
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cosine&lt;/code&gt;: Best for semantic similarity when vector magnitude does not matter (most text embeddings)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dot_product&lt;/code&gt;: Best when vectors are normalized and you want speed (skip the cosine calculation)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;l2_norm&lt;/code&gt;: Best when vector magnitude carries signal (less common for text)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using &lt;code&gt;l2_norm&lt;/code&gt; with unnormalized text embeddings will give poor results. Check your model documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Forgetting That ANN is Approximate
&lt;/h3&gt;

&lt;p&gt;HNSW trades recall for speed. With default settings, expect 95-99% recall@10 (the true top-10 result is in the returned top-10 95-99% of the time). If your use case requires 100% recall, use exact brute-force search (&lt;code&gt;index: false&lt;/code&gt; with &lt;code&gt;script_score&lt;/code&gt;) or increase &lt;code&gt;num_candidates&lt;/code&gt; significantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Not Monitoring Recall Metrics
&lt;/h3&gt;

&lt;p&gt;Track recall@k in production. If it drops below your threshold, increase &lt;code&gt;num_candidates&lt;/code&gt; or &lt;code&gt;ef_construction&lt;/code&gt;. Do not deploy vector search without measuring whether it finds the right documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Ignoring the Index Refresh Interval
&lt;/h3&gt;

&lt;p&gt;Vector fields are indexed during the refresh cycle, just like text. If you index documents and search immediately, you might not find them. For real-time RAG where documents are ingested and immediately queried, ensure your refresh interval is appropriate (default 1s, or use &lt;code&gt;?refresh=true&lt;/code&gt; for testing).&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use What: A Decision Framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Primary Approach&lt;/th&gt;
&lt;th&gt;Secondary/Tuning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact keyword search (SKUs, codes)&lt;/td&gt;
&lt;td&gt;BM25 only&lt;/td&gt;
&lt;td&gt;No vectors needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic Q&amp;amp;A ("how do I...")&lt;/td&gt;
&lt;td&gt;Dense vectors + kNN&lt;/td&gt;
&lt;td&gt;Hybrid with BM25 for exact matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product search with filters&lt;/td&gt;
&lt;td&gt;Hybrid (BM25 + kNN + metadata filters)&lt;/td&gt;
&lt;td&gt;RRF for ranking, filters for pruning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-lingual search&lt;/td&gt;
&lt;td&gt;multilingual-e5 or BGE dense vectors&lt;/td&gt;
&lt;td&gt;BM25 for exact term fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero-ML-infrastructure team&lt;/td&gt;
&lt;td&gt;ELSER sparse vectors&lt;/td&gt;
&lt;td&gt;Built-in inference, no external model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-volume log search&lt;/td&gt;
&lt;td&gt;BM25 with filters&lt;/td&gt;
&lt;td&gt;Vectors only for semantic anomaly detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation/knowledge base&lt;/td&gt;
&lt;td&gt;Hybrid search (RRF)&lt;/td&gt;
&lt;td&gt;Vectors for conceptual, BM25 for exact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Vector search in Elasticsearch has matured from an experimental feature to a production-ready capability. The combination of dense vectors, HNSW indexing, hybrid search with RRF, and metadata filtering makes Elasticsearch a compelling platform for semantic search and RAG pipelines.&lt;/p&gt;

&lt;p&gt;The key takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vectors complement BM25, they do not replace it.&lt;/strong&gt; Hybrid search with RRF consistently outperforms either approach alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HNSW is fast but approximate.&lt;/strong&gt; Tune &lt;code&gt;num_candidates&lt;/code&gt; and monitor recall@k for your use case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory is the main constraint.&lt;/strong&gt; Plan for 3+ GB per million 768-dimensional vectors. Use quantization or dimension reduction if needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ELSER is the fastest path to semantic search.&lt;/strong&gt; If you cannot run external embedding models, start with ELSER v2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata filtering is Elasticsearch killer advantage.&lt;/strong&gt; No standalone vector database matches the query flexibility of the Elasticsearch DSL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The next time someone asks whether Elasticsearch can handle AI-powered search, the answer is yes - and it can do so while preserving everything that makes Elasticsearch powerful: distributed scale, rich querying, and operational maturity.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>elasticsearch</category>
      <category>search</category>
      <category>database</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Query Sets in Search Relevance: Designing Representative Test Queries That Actually Matter</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:34:58 +0000</pubDate>
      <link>https://dev.to/iprithv/query-sets-in-search-relevance-designing-representative-test-queries-that-actually-matter-1ein</link>
      <guid>https://dev.to/iprithv/query-sets-in-search-relevance-designing-representative-test-queries-that-actually-matter-1ein</guid>
      <description>&lt;p&gt;You have spent weeks tuning your OpenSearch cluster. You have optimized analyzers, tweaked BM25 parameters, and maybe even added vector search. But how do you know if any of it actually helps users find what they want?&lt;/p&gt;

&lt;p&gt;Here is the uncomfortable truth: most search quality problems start before you ever run an experiment. They start with your query set.&lt;/p&gt;

&lt;p&gt;A query set is a curated collection of search queries that represent real user behavior. In OpenSearch Search Relevance, it is the foundation of every evaluation pipeline. Get it wrong, and your metrics are meaningless. Get it right, and you have a reliable compass for every tuning decision you make.&lt;/p&gt;

&lt;p&gt;This post is about building query sets that actually matter. Not textbook examples. Not synthetic data. Real, representative queries that tell you whether your search is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Query Sets Are the Most Underrated Component of Search Evaluation
&lt;/h2&gt;

&lt;p&gt;Search engineers love to talk about algorithms. BM25 vs DFR vs vector search. Query parsers. Boosting strategies. But the best scoring algorithm in the world cannot save you from a bad query set.&lt;/p&gt;

&lt;p&gt;Think about it: your metrics (nDCG, precision, recall, MRR) are computed against a query set. If that query set does not represent what your users actually search for, you are optimizing for the wrong thing. You might chase a 0.05 nDCG improvement on synthetic queries while your real users cannot find basic products.&lt;/p&gt;

&lt;p&gt;I have seen teams run elaborate A/B tests, publish glowing metric dashboards, and still get complaints about search quality. The disconnect? Their query set was 50 hand-picked queries from product demos, not 500 real queries from production logs.&lt;/p&gt;

&lt;p&gt;OpenSearch Search Relevance treats query sets as first-class citizens. They are stored as documents in an internal index, versioned, reusable across experiments, and designed to be the starting point of every evaluation. But the plugin does not tell you how to build a good query set. That is on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes a Query Set "Representative"?
&lt;/h2&gt;

&lt;p&gt;A representative query set mirrors your actual user population. It captures the full distribution of what people search for, not just what you think they search for.&lt;/p&gt;

&lt;p&gt;Here is what that means in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cover the Query Distribution
&lt;/h3&gt;

&lt;p&gt;Real user query logs follow a power law. A small number of queries (the "head") account for most of the volume. A larger middle section (the "torso") covers moderate-frequency queries. The long tail contains thousands of rare, specific queries.&lt;/p&gt;

&lt;p&gt;Your query set should sample from all three regions. If you only pick head queries, you miss edge cases. If you only pick tail queries, your metrics are noisy and your experiments take forever. A good rule of thumb: allocate 30% head, 40% torso, 30% tail.&lt;/p&gt;

&lt;p&gt;Head queries are your bread and butter. "iphone 15" or "running shoes." These are high-volume, often generic, and usually competitive. If your search fails here, you are losing the most users.&lt;/p&gt;

&lt;p&gt;Torso queries are where differentiation happens. "waterproof running shoes women" or "iphone 15 pro max 256gb blue." These show intent and are common enough to matter but specific enough to test relevance depth.&lt;/p&gt;

&lt;p&gt;Tail queries are the real test. "shoes for plantar fasciitis under 100 dollars" or "iphone 15 case compatible with magSafe wallet third party." These are rare individually but collectively represent a significant portion of user needs. They also expose gaps in your catalog coverage, synonym handling, and query understanding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Match Intent Diversity
&lt;/h3&gt;

&lt;p&gt;Users search with different goals. Navigational queries aim to reach a specific page or product. Informational queries seek knowledge or comparisons. Transactional queries signal immediate purchase intent.&lt;/p&gt;

&lt;p&gt;A query set that only tests transactional intent will overestimate your search quality for shoppers but miss problems researchers face. Mix intent types intentionally. The exact ratio depends on your product, but ignoring any category is a blind spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Include Known Problem Queries
&lt;/h3&gt;

&lt;p&gt;Every search system has queries that are known to be problematic. Maybe they return zero results. Maybe they return irrelevant results. Maybe they are ambiguous and your current ranking is arbitrary.&lt;/p&gt;

&lt;p&gt;These queries are gold. They are the ones where improvement is most visible and most valuable. Do not exclude them because they make your baseline metrics look bad. Include them specifically because they tell you where to focus your tuning efforts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Avoid Synthetic Queries
&lt;/h3&gt;

&lt;p&gt;There is a temptation to write queries you think users should ask. Resist it. "Best product under $50 with free shipping and 4.5 stars" is not a real query. It is a feature list pretending to be a query.&lt;/p&gt;

&lt;p&gt;Real queries are messy. They have typos, abbreviations, brand names, model numbers, and half-remembered details. "nike air max red size 10" is a real query. "Comfortable athletic footwear with visible air cushioning technology" is not. Your query set should reflect messy reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query Set Size: The Statistical Significance vs. Cost Trade-off
&lt;/h2&gt;

&lt;p&gt;How many queries do you need? It depends on what you are trying to do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimum Viable: 50 Queries
&lt;/h3&gt;

&lt;p&gt;Fifty queries is enough for rough directional signal. You can tell if a change is catastrophic or promising. But you cannot reliably detect small improvements. The variance is too high. A single bad query can swing your aggregate nDCG by 0.1.&lt;/p&gt;

&lt;p&gt;Use 50-query sets for early prototyping, debugging, or smoke testing. Do not use them for production go/no-go decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sweet Spot: 100-500 Queries
&lt;/h3&gt;

&lt;p&gt;This is where most teams should live. One hundred queries gives you enough statistical power to detect meaningful differences. Five hundred gives you confidence without drowning in judgment costs.&lt;/p&gt;

&lt;p&gt;With 100-500 queries, you can segment by query type, compute per-query metrics, and identify specific patterns. You might discover that your change improves navigational queries by 15% but hurts informational queries by 8%. That is actionable insight you cannot get with 50 queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Large Scale: 1000+ Queries
&lt;/h3&gt;

&lt;p&gt;Thousand-query sets are for mature search products with dedicated relevance teams. They give you statistical confidence, enable fine-grained segment analysis, and support long-term trend tracking.&lt;/p&gt;

&lt;p&gt;The downside is cost. Every query needs judgments, and judgments are expensive. Human judgments require time and money. Even implicit judgments (click-through data) need volume and infrastructure. A 1000-query set with 10 results per query is 10,000 judgments. Scale accordingly.&lt;/p&gt;

&lt;p&gt;In OpenSearch Search Relevance, query sets are stored as indexed documents. The plugin executes every query in the set against your configured searches, collects results, and feeds them into judgment collection. Larger query sets mean longer experiment runtimes. Plan for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Source Your Queries
&lt;/h2&gt;

&lt;p&gt;The best query sets come from real user data. Here are the most common sources and their trade-offs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Search Query Logs
&lt;/h3&gt;

&lt;p&gt;If you have logging infrastructure, this is the gold standard. Real queries from real users in your actual system. No simulation, no guessing.&lt;/p&gt;

&lt;p&gt;Filter your logs by time period (last 30-90 days), remove bots and internal traffic, deduplicate near-identical queries, and sample across the frequency distribution. You want the query, not the count. One query in your set represents thousands of actual searches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Analytics Tools
&lt;/h3&gt;

&lt;p&gt;Google Analytics, Amplitude, Mixpanel, or similar tools often capture search queries. Export them, filter them, and use them. The advantage is that analytics tools often include outcome data (did the user convert? did they bounce?), which helps you prioritize which queries matter most.&lt;/p&gt;

&lt;h3&gt;
  
  
  Customer Support Tickets
&lt;/h3&gt;

&lt;p&gt;Support tickets and help desk queries reveal where search is failing. "I searched for X but could not find Y" is a perfect query for your set. These queries are often high-intent, high-frustration, and underrepresented in normal logs because users give up rather than file tickets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Competitor Analysis
&lt;/h3&gt;

&lt;p&gt;For new products without query logs, analyze what users search for on competitor sites or in related forums. Reddit, Quora, and industry-specific communities are rich sources of how people actually talk about your domain. This is less precise than your own logs but better than synthetic queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Curation
&lt;/h3&gt;

&lt;p&gt;Some manual curation is always necessary. You need to fill gaps, add edge cases, and ensure coverage. But manual curation should complement data-driven sampling, not replace it. Think of it as seasoning, not the main ingredient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query Set Maintenance: Fighting Query Drift
&lt;/h2&gt;

&lt;p&gt;User behavior changes. Seasonal trends, product launches, marketing campaigns, and cultural shifts all alter what people search for. A query set from January is not representative in December.&lt;/p&gt;

&lt;p&gt;Set a schedule for query set updates. Monthly reviews for fast-moving products. Quarterly for stable domains. At minimum, annually. When you update, compare the old and new distributions. If the head queries have shifted significantly, your metrics from old experiments may not generalize.&lt;/p&gt;

&lt;p&gt;In OpenSearch Search Relevance, query sets are reusable and versioned. You can keep old query sets for historical comparison while running new experiments on updated sets. This is powerful for long-term trend analysis. You can answer questions like: "Has our search quality improved for holiday queries year over year?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The Relationship Between Query Sets and Experiments
&lt;/h2&gt;

&lt;p&gt;Once you have a query set, it becomes the input to an experiment. Here is how the flow works in OpenSearch Search Relevance.&lt;/p&gt;

&lt;p&gt;You create an experiment and link it to a query set. The plugin iterates through each query in the set, executes it against your configured search (or multiple searches for comparison), and collects the results. These results are then judged (by humans or implicit signals) and metrics are computed.&lt;/p&gt;

&lt;p&gt;The query set size directly impacts experiment runtime. A 500-query set with 2 search configurations being compared means 1000 query executions. If each query hits a large index with complex aggregations, this can take significant time. The plugin handles this automatically, but you should plan your experiment windows accordingly.&lt;/p&gt;

&lt;p&gt;Query sets also determine the granularity of your insights. A well-structured query set lets you segment results by query type, frequency, or intent. You might discover that your new analyzer improves tail queries by 20% but does nothing for head queries. That is a valuable, specific insight you would miss with a poorly designed query set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Tips for OpenSearch Search Relevance Users
&lt;/h2&gt;

&lt;p&gt;If you are using the OpenSearch Search Relevance plugin, here are specific tips for query set management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store metadata with your query sets.&lt;/strong&gt; The plugin supports name and description fields. Use them. "Holiday 2025 product queries, 250 queries, sourced from Nov-Dec logs" is more useful than "Query Set 1."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start small and grow.&lt;/strong&gt; Begin with a 50-query smoke test to validate your experiment setup. Once you trust the pipeline, expand to 200-500 queries for decision-making.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Segment your query sets.&lt;/strong&gt; If your product has distinct categories, create category-specific query sets. "Electronics queries" and "Clothing queries" should be separate if your search configurations differ between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Link query sets to business metrics.&lt;/strong&gt; The best query sets are tied to business outcomes. If you know that 20% of your revenue comes from "brand name + product" queries, weight those queries heavily in your set. Your search tuning should prioritize what drives value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review query sets before every major experiment.&lt;/strong&gt; Do not reuse a query set blindly. Check if the queries are still relevant, if new problem queries have emerged, and if your product catalog has changed. A query for a discontinued product is wasted effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes to Avoid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Over-reliance on synthetic queries.&lt;/strong&gt; I have said this before, but it bears repeating. Synthetic queries give you synthetic metrics. Real users do not search like product managers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring query frequency.&lt;/strong&gt; All queries are not equal. A query that happens once a month is not as important as a query that happens a thousand times a day. Your query set should reflect this, either through sampling weights or through explicit frequency-based allocation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static query sets.&lt;/strong&gt; A query set is not a monument. It is a living document. If you have not updated yours in six months, it is probably stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Too few judgments per query.&lt;/strong&gt; A query set with 1000 queries but only 1 judgment per query is less useful than a 100-query set with 10 judgments per query. Judgment depth matters as much as query breadth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chasing aggregate metrics blindly.&lt;/strong&gt; A 0.02 nDCG improvement across 500 queries sounds small. But if it is driven by a 0.15 improvement in your top 20 revenue-driving queries, it is a massive win. Segment your analysis.&lt;/p&gt;

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

&lt;p&gt;Query sets are the foundation of search quality evaluation. Everything else - analyzers, scoring algorithms, experiments, metrics - rests on this foundation. A cracked foundation means shaky conclusions.&lt;/p&gt;

&lt;p&gt;In OpenSearch Search Relevance, query sets are first-class objects. The plugin gives you the tools to manage, version, and reuse them. But it cannot tell you what queries to include. That requires understanding your users, your data, and your business.&lt;/p&gt;

&lt;p&gt;The best query sets are sampled from reality, maintained over time, and tied to business outcomes. They are not perfect, but they are representative. And representative is enough to make better decisions than guessing.&lt;/p&gt;

&lt;p&gt;If you have not reviewed your query set recently, do it now. Your metrics will thank you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensearch</category>
      <category>search</category>
      <category>database</category>
      <category>data</category>
    </item>
    <item>
      <title>Learning to Rank in Search Relevance: From Feature Engineering to Model Deployment</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:28:32 +0000</pubDate>
      <link>https://dev.to/iprithv/learning-to-rank-in-search-relevance-from-feature-engineering-to-model-deployment-245m</link>
      <guid>https://dev.to/iprithv/learning-to-rank-in-search-relevance-from-feature-engineering-to-model-deployment-245m</guid>
      <description>&lt;p&gt;Learning to Rank (LTR) transforms search from a hand-tuned relevance function into a machine-learned model that optimizes for business outcomes. Instead of manually setting field weights and boost parameters, LTR uses training data to learn the optimal scoring function from user behavior. This post covers the full pipeline: feature engineering, model training, and deployment in production search systems. The techniques apply to both OpenSearch and Elasticsearch, which share the same underlying Lucene architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Why Hand-Tuned Relevance Hits a Ceiling
&lt;/h2&gt;

&lt;p&gt;Traditional search relevance uses a combination of TF-IDF scoring, field boosts, and boolean filters. A typical query might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bool"&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;"must"&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;"multi_match"&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;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wireless headphones"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"title^3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"description^2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"brand^1"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"filter"&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;"term"&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;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"electronics"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;title^3&lt;/code&gt; boost means matches in the title field are worth 3x more than matches in the brand field. This is an educated guess based on domain knowledge. It might work for 80% of queries, but it fails on edge cases: brand searches where the brand field should dominate, long-tail queries where the description matters more than the title, or queries with synonyms where the boost model does not apply.&lt;/p&gt;

&lt;p&gt;The fundamental problem is that a single static weight cannot capture the varying importance of fields across different query types. A user searching for "iPhone 15" cares about exact model matching in the title. A user searching for "good phone for photography" cares about camera specs in the description. A single &lt;code&gt;title^3&lt;/code&gt; boost cannot handle both cases optimally.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Hand-Tuning Becomes Unmanageable
&lt;/h3&gt;

&lt;p&gt;As a search system grows, the number of query types and field combinations explodes. An e-commerce site might have 50 product categories, each with different relevance patterns. Fashion searches prioritize brand and style. Electronics searches prioritize specs and reviews. Grocery searches prioritize freshness and availability. Maintaining separate boost configurations for each category becomes a maintenance nightmare, and the configurations conflict with each other when a query spans multiple categories.&lt;/p&gt;

&lt;p&gt;Learning to Rank solves this by replacing the static boost model with a learned model that adapts to the query context. The model sees the query text, the document fields, and the interaction context (user history, session data, time of day), and produces a relevance score that is optimal for that specific query-document pair.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LTR Pipeline: Features, Judgments, and Models
&lt;/h2&gt;

&lt;p&gt;The Learning to Rank pipeline has three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Feature extraction&lt;/strong&gt; - For each query-document pair, compute a set of numeric features that capture relevance signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judgment collection&lt;/strong&gt; - Gather human or implicit relevance labels for query-document pairs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model training&lt;/strong&gt; - Train a machine learning model to predict relevance scores from features, using the judgments as training targets.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Feature Extraction: What the Model Sees
&lt;/h3&gt;

&lt;p&gt;Features are the input to the LTR model. They must be computable at query time and should capture all signals that might indicate relevance. Common feature categories include:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query-document text features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BM25 score for the query against the title field&lt;/li&gt;
&lt;li&gt;BM25 score for the query against the description field&lt;/li&gt;
&lt;li&gt;BM25 score for the query against the brand field&lt;/li&gt;
&lt;li&gt;Exact match count (how many query terms appear verbatim in the document)&lt;/li&gt;
&lt;li&gt;Prefix match count (how many query terms match the beginning of a document term)&lt;/li&gt;
&lt;li&gt;Synonym match count (how many query terms match via synonym expansion)&lt;/li&gt;
&lt;li&gt;TF-IDF score for each query term in each field&lt;/li&gt;
&lt;li&gt;Cosine similarity between query vector and document vector (if using dense retrieval)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Document quality features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Click-through rate (CTR) for the document in the last 30 days&lt;/li&gt;
&lt;li&gt;Conversion rate (purchase rate) for the document&lt;/li&gt;
&lt;li&gt;Average review score and review count&lt;/li&gt;
&lt;li&gt;Document age (how long since it was added to the index)&lt;/li&gt;
&lt;li&gt;Inventory availability (in-stock or out-of-stock)&lt;/li&gt;
&lt;li&gt;Price and price percentile within the category&lt;/li&gt;
&lt;li&gt;Popularity rank (how many times the document was viewed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Query context features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query length (number of terms)&lt;/li&gt;
&lt;li&gt;Query category intent (electronics, fashion, etc.) via a classifier&lt;/li&gt;
&lt;li&gt;User location (for geo-relevant searches)&lt;/li&gt;
&lt;li&gt;Time of day and day of week (for temporal relevance)&lt;/li&gt;
&lt;li&gt;User segment (new user vs returning user, premium vs basic)&lt;/li&gt;
&lt;li&gt;Session history (what the user searched for and clicked in this session)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Interaction features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Position bias (documents ranked higher get more clicks even if they are less relevant)&lt;/li&gt;
&lt;li&gt;Previous query-document interactions (has the user clicked this document before?)&lt;/li&gt;
&lt;li&gt;Dwell time (how long the user spent on the document page after clicking)&lt;/li&gt;
&lt;li&gt;Skip rate (how often users skipped over this document without clicking)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, a production LTR system might use 50-200 features. The feature set must be comprehensive enough to capture relevance signals but not so large that model training becomes slow or overfitting occurs. Feature selection and regularization are critical for model quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Judgment Collection: Explicit and Implicit Labels
&lt;/h3&gt;

&lt;p&gt;Judgments are the training targets. Each query-document pair needs a relevance label. The labels are typically ordinal: 0 (irrelevant), 1 (somewhat relevant), 2 (relevant), 3 (highly relevant). Some systems use binary labels (relevant/irrelevant) or continuous labels (expected click probability).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit judgments&lt;/strong&gt; come from human annotators. A panel of judges evaluates query-document pairs and assigns relevance labels. Explicit judgments are accurate but expensive. A typical e-commerce site might need 10,000 judged query-document pairs for a category, which costs $5,000-10,000 in annotation fees. The judgment process must be carefully controlled: judges need guidelines, inter-annotator agreement must be measured (Cohen's kappa &amp;gt; 0.6 is considered acceptable), and edge cases must be escalated to senior annotators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implicit judgments&lt;/strong&gt; come from user behavior. If a user searches for "wireless headphones" and clicks the third result, the implicit signal is that the third result was more relevant than the first and second results (which were skipped). This is the foundation of click-through based LTR. However, implicit judgments are noisy because of position bias: users click higher-ranked results more often regardless of relevance. To correct for position bias, LTR systems use click models like the Cascade Model or the Position-Based Model (PBM) that estimate the probability of a click given the position and the true relevance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid approaches&lt;/strong&gt; combine explicit and implicit judgments. Explicit judgments are used for a small set of carefully selected queries (head queries that drive 80% of traffic). Implicit judgments are used for the long tail. The model is trained on the combined dataset, with explicit judgments weighted more heavily because they are more reliable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model Training: Pointwise, Pairwise, and Listwise Approaches
&lt;/h3&gt;

&lt;p&gt;LTR models are trained using one of three paradigms:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pointwise&lt;/strong&gt; treats each query-document pair as an independent sample. The model learns to predict the relevance label directly, like a standard regression or classification problem. The loss function is mean squared error (for continuous labels) or cross-entropy (for ordinal labels). Pointwise models are simple to train but ignore the ranking context: they do not know that a query has multiple documents and that the goal is to order them correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pairwise&lt;/strong&gt; treats each pair of documents for the same query as a training sample. The model learns to predict which document is more relevant. The loss function is typically a hinge loss or logistic loss that penalizes the model when it incorrectly orders a pair. Pairwise models capture the relative ordering signal but still do not optimize for the full list quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listwise&lt;/strong&gt; treats the entire ranked list for a query as a training sample. The model optimizes a list-level metric like Normalized Discounted Cumulative Gain (NDCG) or Mean Average Precision (MAP). Listwise models are theoretically optimal because they directly optimize the ranking metric, but they are computationally expensive and harder to train. LambdaMART and its neural variant LambdaRank are the most popular listwise algorithms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LambdaMART&lt;/strong&gt; is a gradient boosting model that optimizes NDCG. It works by computing the "lambda" gradient for each document: the change in NDCG that would occur if the document's score changed by a small amount. The lambda gradient focuses on pairs that are incorrectly ordered and are near the top of the ranking, because those pairs have the largest impact on NDCG. LambdaMART then trains a decision tree to predict these lambda gradients, and the tree ensemble is updated iteratively.&lt;/p&gt;

&lt;p&gt;For production deployment, LambdaMART is often the best choice because it provides a good balance between accuracy and inference speed. A typical LambdaMART model might have 100-500 trees with a maximum depth of 6-8. Inference is fast because each tree is a simple decision tree, and the ensemble prediction is the sum of tree outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Engineering for Search: Practical Techniques
&lt;/h2&gt;

&lt;p&gt;Feature engineering is the most critical and time-consuming part of LTR. The model can only be as good as the features it sees. Here are practical techniques for building high-quality feature sets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query Classification for Intent-Aware Features
&lt;/h3&gt;

&lt;p&gt;Not all queries are the same. A user searching for "iPhone 15" has a navigational intent: they know what they want and are looking for a specific product. A user searching for "good phone for photography" has an informational intent: they are researching options. A user searching for "cheap phone" has a transactional intent: they want to compare prices.&lt;/p&gt;

&lt;p&gt;Query classification adds a feature that captures the intent. A simple classifier can be trained on query text alone: short queries with brand names are navigational, long queries with descriptive terms are informational, queries with price-related terms are transactional. The intent feature interacts with other features: for navigational queries, the exact title match score is highly weighted. For informational queries, the description BM25 score and review quality features are more important.&lt;/p&gt;

&lt;h3&gt;
  
  
  Normalization and Scaling
&lt;/h3&gt;

&lt;p&gt;Features have different scales. BM25 scores might range from 0 to 30, while CTR ranges from 0 to 0.1. If the model is a neural network, feature scaling is critical. If the model is a tree-based model like LambdaMART, scaling is less important because trees are scale-invariant. However, even for trees, extreme outliers can cause the model to overfit to rare cases. Log-transforming CTR (log(1 + CTR)) and clipping BM25 scores to a maximum of 50 are common preprocessing steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Temporal Features and Recency Bias
&lt;/h3&gt;

&lt;p&gt;For time-sensitive content (news, social media, product launches), recency is a strong relevance signal. A simple recency feature is &lt;code&gt;1 / (days_since_publication + 1)&lt;/code&gt;. But recency should not dominate all queries. A query for "iPhone 15" should not be affected by recency because the iPhone 15 is a specific product. A query for "best phone 2024" should be affected by recency because newer reviews are more relevant. The interaction between query intent and recency features can be captured by adding a query-specific recency weight, which the model learns from the training data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-Field Features
&lt;/h3&gt;

&lt;p&gt;A query might match multiple fields, and the combination of matches is a signal. For example, a query "Nike running shoes" that matches the brand field ("Nike") and the category field ("running shoes") is more relevant than a query that only matches the title field. A cross-field feature can be the product of the brand match score and the category match score, or a binary indicator that both fields matched. These interaction features capture the semantic coherence of the query-document match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model Deployment: From Training to Query-Time Scoring
&lt;/h2&gt;

&lt;p&gt;Once the model is trained, it must be deployed to the search engine. The deployment architecture depends on whether the model is a simple linear model, a tree ensemble, or a neural network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linear Model Deployment: Rescoring at the Coordinator
&lt;/h3&gt;

&lt;p&gt;A linear model has the form &lt;code&gt;score = w1 * f1 + w2 * f2 + ... + wn * fn&lt;/code&gt;. The weights are learned during training. Deployment is straightforward: the search engine computes the features for each query-document pair and applies the linear combination. This can be done in a &lt;code&gt;rescore&lt;/code&gt; phase at the coordinator level, which operates on the top-N results from the initial query phase.&lt;/p&gt;

&lt;p&gt;The rescore phase in OpenSearch and Elasticsearch allows a custom script to re-rank the top results. A Painless script can compute the linear combination of features. However, Painless is not optimized for complex models, and the script execution overhead can be significant. For linear models with 50 features, a custom &lt;code&gt;rescore&lt;/code&gt; query is usually fast enough. For tree ensembles, the script becomes unwieldy because each tree requires multiple if-else branches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tree Ensemble Deployment: Native Plugin or External Service
&lt;/h3&gt;

&lt;p&gt;Tree ensembles like LambdaMART are harder to deploy because each tree is a series of if-else decisions. A 100-tree model with depth 6 has 600 decision nodes. Evaluating this in a Painless script is slow and error-prone. The standard approach is to use a native plugin that can evaluate the tree ensemble efficiently.&lt;/p&gt;

&lt;p&gt;OpenSearch has the LTR plugin (formerly Elasticsearch LTR plugin) that provides native support for tree ensemble models. The plugin stores the model in the cluster state and exposes a &lt;code&gt;sltr&lt;/code&gt; (search-learning-to-rank) query type. The query type computes features using the standard query DSL and then applies the model to score the results. The plugin supports XGBoost, LightGBM, and RankLib model formats.&lt;/p&gt;

&lt;p&gt;For Elasticsearch, the LTR plugin was historically available but has been less maintained. An alternative is to deploy the model in an external service. The search engine returns the top 1000 results with all features as metadata. The external service evaluates the model and returns the re-ranked list. The trade-off is latency: an external service call adds 5-50ms depending on network latency. For applications where latency is critical, the native plugin is preferred.&lt;/p&gt;

&lt;h3&gt;
  
  
  Neural Network Deployment: ONNX Runtime Integration
&lt;/h3&gt;

&lt;p&gt;Neural LTR models (e.g., BERT-based cross-encoders that jointly encode the query and document) are too complex for tree-based evaluation. These models require a deep learning runtime. The ONNX Runtime is a common choice because it supports models from PyTorch, TensorFlow, and other frameworks. Deployment options include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native ONNX plugin&lt;/strong&gt; - Some search engines have plugins that embed the ONNX runtime. The plugin evaluates the model at query time within the search process. This is fast but requires the plugin to be maintained and compatible with the search engine version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External inference service&lt;/strong&gt; - A separate microservice hosts the ONNX model and receives query-document pairs via gRPC or HTTP. The search engine calls the service for the top-N results. This is flexible but adds network latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-computed embeddings&lt;/strong&gt; - For dense retrieval models, the document embeddings are pre-computed at index time and stored in a vector field. The query embedding is computed at query time, and the search engine performs a k-NN vector search. This is the fastest approach because the model inference is done once per query, not once per document. However, it only works for bi-encoder models (where query and document are encoded separately), not cross-encoder models (where the query and document are encoded together).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Training Data and Evaluation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Collecting Training Data from Production Logs
&lt;/h3&gt;

&lt;p&gt;Production logs are the primary source of training data for LTR. The log pipeline should capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query text and timestamp&lt;/li&gt;
&lt;li&gt;All results returned for the query (with their positions)&lt;/li&gt;
&lt;li&gt;All clicks (with their positions and dwell times)&lt;/li&gt;
&lt;li&gt;Conversions (purchases, sign-ups, etc.) linked to clicks&lt;/li&gt;
&lt;li&gt;User context (user ID, session ID, location, device)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The logs must be joined and processed to create query-document pairs with features and labels. The processing pipeline typically runs in batch mode (e.g., daily) and uses Spark or Flink to aggregate the logs. The output is a training dataset in a format like SVMLight or LibSVM, where each line is a query-document pair with features and a label.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling Position Bias in Click Data
&lt;/h3&gt;

&lt;p&gt;Position bias is the biggest challenge in implicit judgment collection. A result at position 1 gets clicked 10-20% of the time even if it is irrelevant, while a result at position 10 gets clicked 1-2% of the time even if it is highly relevant. Without correction, the model learns to rank documents higher simply because they were ranked higher before, creating a self-reinforcing loop.&lt;/p&gt;

&lt;p&gt;The standard correction is the Position-Based Model (PBM). PBM assumes that the probability of a click at position k is the product of two probabilities: the probability that the user examines the position (which decreases with position) and the probability that the document is relevant given the query. The examination probability is estimated from the data by observing how often each position is clicked across all queries. The relevance probability is then estimated as &lt;code&gt;click_probability / examination_probability&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For example, if position 1 is examined 80% of the time and clicked 20% of the time, the estimated relevance is 20% / 80% = 25%. If position 5 is examined 40% of the time and clicked 10% of the time, the estimated relevance is 10% / 40% = 25%. Both positions have the same relevance estimate, which is correct even though the raw click rates differ.&lt;/p&gt;

&lt;p&gt;PBM requires enough data to estimate the examination probabilities accurately. For a new search system with low traffic, the estimates are noisy. In this case, explicit judgments are needed until the click volume is sufficient. A common threshold is 1,000 clicks per query position before the PBM estimates are reliable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluation Metrics: NDCG, MAP, and MRR
&lt;/h3&gt;

&lt;p&gt;The quality of an LTR model is evaluated using ranking metrics. The most common metric is NDCG (Normalized Discounted Cumulative Gain), which measures the quality of the ranked list by assigning higher credit to relevant documents that appear higher in the list. NDCG is computed as:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compute the DCG: &lt;code&gt;DCG = sum((2^relevance - 1) / log2(position + 1))&lt;/code&gt; for all positions.&lt;/li&gt;
&lt;li&gt;Compute the ideal DCG (IDCG): the DCG of the perfect ranking.&lt;/li&gt;
&lt;li&gt;NDCG = DCG / IDCG.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;NDCG ranges from 0 to 1, where 1 is a perfect ranking. A typical improvement from a baseline model to an LTR model is 0.05-0.15 NDCG points. For a search engine with 1 million queries per day, a 0.1 NDCG improvement translates to a significant increase in click-through rate and conversion.&lt;/p&gt;

&lt;p&gt;MAP (Mean Average Precision) is another common metric, particularly for binary relevance. It computes the average precision at each position where a relevant document appears, then averages across queries. MAP is less sensitive to the exact ordering of relevant documents than NDCG, but it is easier to interpret for non-experts.&lt;/p&gt;

&lt;p&gt;MRR (Mean Reciprocal Rank) is used for tasks where only the first relevant document matters, such as question answering or navigational search. MRR is the average of &lt;code&gt;1 / rank_of_first_relevant&lt;/code&gt; across queries. A perfect MRR is 1.0, which means the first result is always relevant.&lt;/p&gt;

&lt;h3&gt;
  
  
  A/B Testing: The Final Validation
&lt;/h3&gt;

&lt;p&gt;Offline metrics like NDCG are useful for model development, but they do not guarantee business impact. The ultimate validation is an A/B test in production. The test should split traffic between the baseline model (hand-tuned boosts) and the LTR model. The metrics to track are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Click-through rate (CTR) - the percentage of queries that result in a click.&lt;/li&gt;
&lt;li&gt;Conversion rate - the percentage of clicks that result in a purchase or other goal.&lt;/li&gt;
&lt;li&gt;Revenue per query - total revenue divided by number of queries.&lt;/li&gt;
&lt;li&gt;Dwell time - average time spent on clicked pages.&lt;/li&gt;
&lt;li&gt;Zero-result rate - percentage of queries that return no results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The A/B test should run for at least 2 weeks to capture weekly patterns and should include at least 10,000 queries per variant to achieve statistical significance. The winner should be determined by the primary business metric (usually revenue per query or conversion rate), not just CTR. A model that increases CTR but decreases conversion is driving clicks to less relevant results, which is worse than the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls and Production Considerations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Feature Drift and Model Retraining
&lt;/h3&gt;

&lt;p&gt;Features change over time. A new product might have no CTR data initially, but after a month it has enough clicks to become a strong feature. Seasonal products (holiday decorations, swimwear) have CTR patterns that change throughout the year. If the model is trained on summer data, it will underperform in winter because the feature distributions have shifted.&lt;/p&gt;

&lt;p&gt;Model retraining should be automated. A typical schedule is weekly retraining for fast-moving catalogs and monthly retraining for stable catalogs. The retraining pipeline should use a sliding window of data (e.g., the last 30 days) to keep the model current. Automated evaluation should compare the new model's offline NDCG against the current model's NDCG, and only deploy if the improvement exceeds a threshold (e.g., 0.01 NDCG). This prevents deploying models that are worse than the current one due to data quality issues or training instability.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cold Start Problem
&lt;/h3&gt;

&lt;p&gt;New documents have no historical features (CTR, review count, popularity). The model cannot evaluate them accurately. This is the cold start problem. Common solutions include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default feature values&lt;/strong&gt; - Use the average CTR and review count for the category as default values. This gives new documents a fair chance to rank based on their text features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploration&lt;/strong&gt; - Randomly promote new documents to higher positions for a small percentage of traffic to collect click data. This is a form of multi-armed bandit optimization that balances exploration (learning about new documents) and exploitation (ranking known good documents).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-based features&lt;/strong&gt; - Use text features and document quality features that do not depend on historical data. A new document with a strong title match and good description should still rank reasonably well even without CTR data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Latency Budget and Feature Computation
&lt;/h3&gt;

&lt;p&gt;LTR adds latency to search. Feature computation requires running multiple queries (one per feature) or extracting features from the document metadata. A model with 100 features can add 50-200ms to query time if features are computed naively. The latency budget is critical for production.&lt;/p&gt;

&lt;p&gt;Optimization techniques include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-computed features&lt;/strong&gt; - Store feature values in the index at indexing time. For example, the CTR and review count can be updated daily and stored as numeric fields. The query time feature computation is then a simple doc value lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature caching&lt;/strong&gt; - Cache feature values for frequently accessed documents. A document that appears in the top 10 results for 100 queries per day should have its features cached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature selection&lt;/strong&gt; - Reduce the feature set to the most impactful features. A model with 20 well-chosen features often performs nearly as well as a model with 100 features, and the latency is much lower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asynchronous feature computation&lt;/strong&gt; - Compute features in parallel using multiple threads. OpenSearch and Elasticsearch support parallel query execution for some feature types.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fairness and Bias in LTR
&lt;/h3&gt;

&lt;p&gt;LTR models can inadvertently learn biases from the training data. If the training data reflects historical bias (e.g., products from dominant brands get more clicks because they are ranked higher), the model will amplify that bias. Fairness-aware LTR is an active research area, but practical steps include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demographic parity constraints&lt;/strong&gt; - Ensure that the ranking distribution is similar across different groups (e.g., small brands vs large brands).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counterfactual evaluation&lt;/strong&gt; - Evaluate what the ranking would have been if the model had been trained on unbiased data, using inverse propensity scoring to correct for historical bias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diverse result sets&lt;/strong&gt; - Enforce diversity constraints that ensure the top results include a mix of brands, prices, and styles, rather than a homogeneous set that the model over-optimized.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Learning to Rank transforms search relevance from a manual craft into a data-driven science. The pipeline - feature extraction, judgment collection, model training, and deployment - requires careful engineering at each stage. Feature engineering is the most important step: the model can only learn from the signals you provide. Judgment collection must correct for position bias and combine explicit and implicit labels. Model training should use listwise optimization for ranking quality. Deployment must respect the latency budget and be monitored for drift and bias.&lt;/p&gt;

&lt;p&gt;For search teams, LTR is a force multiplier. A well-trained LTR model can improve NDCG by 10-20% and business metrics by 5-15%, which translates to millions in revenue for large-scale e-commerce. But the investment is significant: feature engineering, annotation pipelines, model training infrastructure, and A/B testing culture are all required. The teams that succeed are those that treat LTR as a product, not a one-time project, with continuous retraining, evaluation, and iteration.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>lucene</category>
      <category>search</category>
      <category>java</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Elasticsearch Cluster State Management: How 1000 Nodes Agree on One Source of Truth</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:28:14 +0000</pubDate>
      <link>https://dev.to/iprithv/elasticsearch-cluster-state-management-how-1000-nodes-agree-on-one-source-of-truth-35e6</link>
      <guid>https://dev.to/iprithv/elasticsearch-cluster-state-management-how-1000-nodes-agree-on-one-source-of-truth-35e6</guid>
      <description>&lt;p&gt;Elasticsearch cluster state is the single source of truth that every node in the cluster must agree on. It contains the routing table, index metadata, mapping definitions, settings, and allocation decisions. When a cluster has 100 nodes and 10,000 shards, the cluster state can grow to tens of megabytes, and publishing it to every node becomes a significant bottleneck. Understanding how Elasticsearch manages, publishes, and updates cluster state is essential for operating large clusters at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Cluster State and Why It Must Be Consistent
&lt;/h2&gt;

&lt;p&gt;The cluster state is a JSON-like data structure that lives in the cluster manager node (formerly called master node). It contains everything the cluster needs to route requests, allocate shards, and enforce mappings. The key components are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Routing table&lt;/strong&gt; - Maps each shard to a specific node. Every index-shard combination has an entry indicating which node holds the primary, which nodes hold replicas, and whether the shard is started, initializing, or relocating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index metadata&lt;/strong&gt; - Includes index settings, mappings, aliases, and lifecycle policies. When a mapping is updated, the index metadata changes, which triggers a cluster state update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster settings&lt;/strong&gt; - Persistent and transient settings that affect cluster behavior, such as allocation thresholds, recovery rates, and circuit breaker limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocks&lt;/strong&gt; - Index-level or cluster-level blocks that prevent certain operations. For example, a read-only block prevents writes to an index while a cluster-level block prevents index creation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom metadata&lt;/strong&gt; - Plugins and modules can store their own data in the cluster state. Snapshot repositories, ingest pipelines, and transform configurations all live here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every node maintains a full copy of the cluster state. When a node receives a search request, it uses the local cluster state copy to determine which shards to query. It does not ask the manager node for routing information on every request. This design makes request routing fast but requires all nodes to have an up-to-date cluster state.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cluster State as a Versioned Document
&lt;/h3&gt;

&lt;p&gt;Elasticsearch treats cluster state as a versioned document. Each update increments the version number. When the manager publishes a new cluster state, it includes the version number. Nodes compare the incoming version with their local version and apply the update only if the incoming version is newer. This prevents out-of-order updates from causing inconsistency.&lt;/p&gt;

&lt;p&gt;The version number is a 64-bit integer that increments on every change, no matter how small. Adding a single alias to one index increments the version. Changing a single setting on one index increments the version. This means a busy cluster with frequent index creation or mapping updates can generate thousands of cluster state versions per hour. The version number itself does not wrap around in practice, but the rate of version increments is a useful metric for monitoring cluster state churn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cluster State Publication: The Diff-Based Update Mechanism
&lt;/h2&gt;

&lt;p&gt;When the manager node updates the cluster state, it does not send the full state to every node. Instead, it computes a diff between the previous state and the new state, and sends only the changed parts. This diff-based approach is critical for performance because a full cluster state for a large cluster can be 50MB or more, while a typical diff might be only a few kilobytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the Manager Computes and Sends Diffs
&lt;/h3&gt;

&lt;p&gt;The manager maintains the previous cluster state in memory. When a new state is computed, it serializes both the old and new states to compressed binary format and computes a binary diff. The diff format is a sequence of operations: add this key, update this value, delete this entry. The manager then sends the diff to every node in the cluster.&lt;/p&gt;

&lt;p&gt;The diff computation is CPU-intensive. For a 20MB cluster state with a small change, the diff might take 5-10ms to compute. For a 50MB state with a large change (e.g., a new index with 100 shards), the diff might take 50-100ms. During this time, the manager node is blocked and cannot process other cluster state updates. This is why cluster state computation is single-threaded and why batching multiple changes into a single update is more efficient than applying them one by one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node-Level Application: From Diff to Local State
&lt;/h3&gt;

&lt;p&gt;When a non-manager node receives a diff, it applies the operations to its local cluster state copy. The application process is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deserialize the diff&lt;/strong&gt; into a sequence of operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the diff&lt;/strong&gt; against the local state to ensure the base version matches. If the local version is behind, the node might have missed a previous update. In this case, it requests the full cluster state from the manager instead of applying the diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply the operations&lt;/strong&gt; to the local state. Each operation is a mutation: add a routing entry, update a mapping, remove an alias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the resulting state&lt;/strong&gt; by computing a checksum and comparing it to the checksum provided by the manager. If the checksums do not match, the node has a corrupted state and must request the full state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge the update&lt;/strong&gt; to the manager. The manager waits for acknowledgments from a majority of nodes before considering the state published.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The acknowledgment requirement is the key to consistency. The manager considers a cluster state published when it has received acks from a majority of the voting nodes. This ensures that even if the manager fails immediately after publication, the new state is durable on enough nodes that the next elected manager will see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cluster Manager Role: Election and Responsibilities
&lt;/h2&gt;

&lt;p&gt;The cluster manager is the node responsible for computing and publishing cluster state updates. It is not a special node type - any node can be elected manager, but the election process favors nodes with the &lt;code&gt;node.roles: [cluster_manager]&lt;/code&gt; setting. Only manager-eligible nodes participate in voting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manager Election: Voting and Quorum
&lt;/h3&gt;

&lt;p&gt;Manager election uses a voting configuration that is a subset of manager-eligible nodes. The default voting configuration includes all manager-eligible nodes, but administrators can restrict it to a smaller set for stability. A candidate must receive votes from a majority of the voting configuration to become manager. If there are 5 voting nodes, a candidate needs 3 votes. If there are 4 voting nodes, a candidate needs 3 votes (majority of 4 is 3, not 2).&lt;/p&gt;

&lt;p&gt;The election is triggered when the current manager fails or steps down. Nodes exchange ping messages to detect manager failure. If a node does not receive a ping response from the manager within &lt;code&gt;cluster.publish.timeout&lt;/code&gt; (default 30 seconds), it initiates an election. All nodes in the voting configuration then vote for the candidate with the highest cluster state version. If multiple nodes have the same version, the node with the lowest ID wins. This tiebreaker ensures that elections resolve quickly without requiring additional rounds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bootstrap Requirement: Preventing Split-Brain
&lt;/h3&gt;

&lt;p&gt;When a cluster starts for the first time, it must be bootstrapped with a set of manager-eligible nodes. This is done via the &lt;code&gt;cluster.initial_master_nodes&lt;/code&gt; setting. The bootstrap requirement ensures that a cluster does not accidentally form around a subset of nodes that happen to start first. For example, if a cluster has 5 nodes but only 3 are started initially, the 3 nodes will form a cluster only if they are explicitly listed as initial master nodes. The remaining 2 nodes will join the existing cluster when they start, rather than forming a separate cluster.&lt;/p&gt;

&lt;p&gt;Without the bootstrap requirement, a network partition could split a 5-node cluster into a 3-node cluster and a 2-node cluster, both electing their own managers. This is the classic split-brain problem. The bootstrap requirement prevents it by requiring explicit agreement on the initial node set. After bootstrap, the voting configuration evolves dynamically as nodes join and leave, but the majority requirement ensures that only one manager can exist at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cluster State Size and the Bloat Problem
&lt;/h2&gt;

&lt;p&gt;The cluster state grows with every index, shard, alias, mapping, and setting. For a cluster with 1,000 indices and 5 shards per index, the routing table alone has 5,000 entries. Each entry includes the node assignment, shard state, and recovery statistics. The index metadata includes the mapping for every field, which can be hundreds of fields per index. At scale, the cluster state can become a performance bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptoms of Cluster State Bloat
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slow manager election&lt;/strong&gt; - When the manager fails, the election requires nodes to compare cluster state versions. Large states take longer to serialize and compare, delaying the election.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow publication&lt;/strong&gt; - Diffs take longer to compute and send. Nodes take longer to apply them. The manager spends more time blocked on publication, reducing throughput.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High memory usage&lt;/strong&gt; - Every node holds a full copy of the cluster state. A 50MB cluster state on 100 nodes consumes 5GB of memory across the cluster, just for state copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow RESTART recovery&lt;/strong&gt; - When a node restarts, it must receive the full cluster state from the manager. A large state takes longer to transfer and apply, delaying the node's return to the cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Pending Tasks Queue
&lt;/h3&gt;

&lt;p&gt;The manager maintains a pending tasks queue for cluster state updates. When multiple updates arrive simultaneously, they are queued and processed sequentially. The queue is visible via the &lt;code&gt;GET _cluster/pending_tasks&lt;/code&gt; API. A growing queue indicates that the manager cannot keep up with the rate of updates.&lt;/p&gt;

&lt;p&gt;Common causes of queue growth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rapid index creation&lt;/strong&gt; - Creating 100 indices in a loop generates 100 cluster state updates. Each update requires diff computation and publication. If the loop runs faster than the manager can process, the queue grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mapping explosions&lt;/strong&gt; - Dynamic mapping that creates a new field for every unique key in incoming documents causes frequent mapping updates. Each update triggers a cluster state increment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shard allocation storms&lt;/strong&gt; - Node failures or restarts trigger shard reallocation, which updates the routing table. A rolling restart of 50 nodes in a 100-node cluster generates hundreds of routing updates as shards move to maintain replica counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot and restore operations&lt;/strong&gt; - Restoring a snapshot creates new indices, which triggers cluster state updates. Restoring 100 indices simultaneously can overwhelm the manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Diagnosing Cluster State Size
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;GET _cluster/state&lt;/code&gt; API returns the full cluster state, but it can be too large to return directly. Instead, use the &lt;code&gt;GET _cluster/state/_all/_all&lt;/code&gt; API with filtering to get specific sections. The &lt;code&gt;GET _cluster/health&lt;/code&gt; API returns the cluster state version and the number of pending tasks, which are useful metrics for monitoring.&lt;/p&gt;

&lt;p&gt;For detailed analysis, the &lt;code&gt;GET _cluster/state/metadata&lt;/code&gt; API returns only the metadata section, which is usually the largest part of the cluster state. The &lt;code&gt;GET _cluster/state/routing_table&lt;/code&gt; API returns only the routing table. By comparing the sizes of these sections, you can identify which part of the state is growing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reducing Cluster State Size: Practical Strategies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mapping Compression and Static Mapping
&lt;/h3&gt;

&lt;p&gt;Dynamic mapping is convenient but dangerous for cluster state size. Every unique field name in every document creates a mapping entry. If an index receives documents with 10,000 unique keys, the mapping has 10,000 fields. With 1,000 indices, the total field count is 10 million, which can make the cluster state hundreds of megabytes.&lt;/p&gt;

&lt;p&gt;The solution is to disable dynamic mapping and use explicit mapping definitions. Set &lt;code&gt;dynamic: false&lt;/code&gt; or &lt;code&gt;dynamic: strict&lt;/code&gt; in the index mapping. &lt;code&gt;dynamic: false&lt;/code&gt; ignores unknown fields (they are stored in &lt;code&gt;_source&lt;/code&gt; but not indexed). &lt;code&gt;dynamic: strict&lt;/code&gt; rejects documents with unknown fields. Both prevent mapping explosions.&lt;/p&gt;

&lt;p&gt;For indices that need dynamic fields but not at scale, use &lt;code&gt;index.mapping.total_fields.limit&lt;/code&gt; to set a maximum field count per index. The default is 1,000, which is usually sufficient for structured data but too high for log data with unpredictable keys. For log indices, a limit of 100-200 is often enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Index Lifecycle Management and Rollover
&lt;/h3&gt;

&lt;p&gt;Time-series indices (logs, metrics) should use rollover policies to limit the number of active indices. The Index Lifecycle Management (ILM) feature can automatically roll over an index when it reaches a certain size or age, and delete or freeze old indices. Rollover reduces the number of indices in the cluster state, which reduces the routing table and metadata size.&lt;/p&gt;

&lt;p&gt;Frozen indices are a special case. A frozen index is stored in a minimal state that uses only a few hundred bytes of cluster state per index, compared to several kilobytes for a normal index. Frozen indices cannot be searched directly but can be mounted and searched via the Frozen Searchable Snapshots feature. For long-term data retention, freezing indices that are older than 30 days is a common strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shard Count Reduction
&lt;/h3&gt;

&lt;p&gt;The routing table has one entry per shard. Reducing the shard count reduces the routing table size. For indices with 5 primary shards and 1 replica, each index contributes 10 routing entries. With 1,000 indices, the routing table has 10,000 entries. If the indices are reduced to 1 primary shard with 1 replica, the routing table has 2,000 entries - an 80% reduction.&lt;/p&gt;

&lt;p&gt;The trade-off is that fewer shards per index reduces parallelism for large queries. A query that scans 100 million documents runs faster on 5 shards than on 1 shard because each shard can process a subset in parallel. The optimal shard count depends on the index size and query patterns. A general rule of thumb is 30-50GB per shard for time-series data and 10-20GB per shard for search-heavy indices. If an index is 100GB, 2-3 shards are usually sufficient.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alias Consolidation
&lt;/h3&gt;

&lt;p&gt;Aliases are stored in the index metadata. Each alias has a name, a filter (optional), and routing parameters (optional). For indices with hundreds of aliases, the metadata section can become large. Consolidating aliases by using index patterns (e.g., &lt;code&gt;logs-2024-*&lt;/code&gt; instead of &lt;code&gt;logs-2024-01&lt;/code&gt;, &lt;code&gt;logs-2024-02&lt;/code&gt;) reduces the alias count. The &lt;code&gt;_aliases&lt;/code&gt; API supports bulk operations for alias management, but alias consolidation is usually a one-time manual task during cluster maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cluster State Updates and the Acking Mechanism
&lt;/h2&gt;

&lt;p&gt;When the manager publishes a cluster state update, it waits for acknowledgments from the nodes. The &lt;code&gt;cluster.publish.timeout&lt;/code&gt; setting controls how long the manager waits. If the timeout is reached before all nodes ack, the manager considers the update partially published. The nodes that did not ack are marked as failed and may be removed from the cluster if they miss multiple updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Minimum Acknowledgment Requirement
&lt;/h3&gt;

&lt;p&gt;The manager does not wait for all nodes to ack. It waits for a majority of the voting nodes. This is the same majority required for manager election. The requirement ensures that the cluster state is durable on enough nodes to survive a manager failure. Non-voting nodes (data-only nodes) are not required to ack, but they still receive the update. If a data-only node misses the update, it will request the full state on the next update.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling Slow Nodes
&lt;/h3&gt;

&lt;p&gt;A slow node can delay cluster state publication if the manager waits for its ack. The &lt;code&gt;cluster.publish.timeout&lt;/code&gt; setting limits this delay, but if the timeout is reached, the slow node is flagged. If the node is consistently slow, it may indicate a network problem, a GC pause, or a disk I/O bottleneck. The manager logs warnings about slow acks, and the node should be investigated.&lt;/p&gt;

&lt;p&gt;Slow nodes can also cause cluster state divergence. If a node misses an update and the next update is a diff based on the missed version, the node will detect the version mismatch and request the full state. This triggers a full state transfer, which is expensive for large states. Repeated divergence events can saturate the manager's network and CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls and Edge Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Too-Many-Indices Problem
&lt;/h3&gt;

&lt;p&gt;Elasticsearch has a practical limit on the number of indices, not a hard limit. The limit is determined by cluster state size and manager capacity. For a cluster with 100 nodes and a 10GB heap, the practical limit is typically 5,000-10,000 indices. Beyond this, the cluster state publication becomes slow, and the manager spends most of its time computing diffs and waiting for acks.&lt;/p&gt;

&lt;p&gt;The solution is to reduce the number of indices. Use rollover to merge small indices into larger ones. Use aliases to present a unified view of merged indices. For time-series data, use data streams, which automatically manage rollover and aliasing. Data streams are a higher-level abstraction that reduces the number of indices the operator manages directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mapping Update Race Condition
&lt;/h3&gt;

&lt;p&gt;When two clients update the mapping of the same index simultaneously, the manager receives two cluster state updates. Because updates are processed sequentially, the first update succeeds and the second update fails with a version conflict. The second client must retry. This is a safe behavior but can cause retries to pile up if the mapping update rate is high.&lt;/p&gt;

&lt;p&gt;To reduce mapping update contention, batch updates and use the &lt;code&gt;dynamic: strict&lt;/code&gt; setting. If the application does not need dynamic fields, disable them entirely. If the application does need dynamic fields but only a known set, use &lt;code&gt;dynamic_templates&lt;/code&gt; to pre-define the mappings for expected field patterns, which reduces the need for explicit mapping updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cluster State Snapshot and Restore
&lt;/h3&gt;

&lt;p&gt;Elasticsearch can snapshot the cluster state to a repository, but the snapshot is not a complete backup. It includes index metadata, settings, and aliases, but not the routing table. The routing table is reconstructed when the cluster restarts. This means restoring a cluster state snapshot does not restore the exact node-shard assignments. Shards will be reallocated according to the current allocation rules, which may result in different node assignments than the original cluster.&lt;/p&gt;

&lt;p&gt;For complete disaster recovery, snapshot the cluster state and the index data separately. The cluster state snapshot restores the metadata, and the index data snapshots restore the documents. After restore, the cluster will allocate shards and rebuild the routing table from scratch. This is usually sufficient because the routing table is derived from the metadata and the node list, not an independent state.&lt;/p&gt;

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

&lt;p&gt;Elasticsearch cluster state management is a distributed consensus problem disguised as a metadata service. The manager node maintains the authoritative state, computes diffs, and publishes them to all nodes. The diff-based update mechanism keeps network traffic low, but the computation cost is borne by the manager. As clusters grow, cluster state size becomes the limiting factor, and operators must actively manage it through mapping discipline, index lifecycle policies, and shard count optimization.&lt;/p&gt;

&lt;p&gt;The acknowledgment mechanism ensures that state updates are durable, but it also introduces latency that grows with cluster size. A slow node can delay publication for all nodes. The voting configuration and majority requirement prevent split-brain but require careful management during node additions and removals. The bootstrap requirement ensures that new clusters form correctly, but it is a one-time configuration that must be set before the cluster starts.&lt;/p&gt;

&lt;p&gt;For production clusters, monitor the cluster state version rate, the pending tasks queue, and the cluster state size. If the version rate exceeds 100 per hour or the state size exceeds 20MB, investigate the cause. Mapping explosions, rapid index creation, and shard allocation storms are the usual suspects. Fixing these issues early prevents the cluster from reaching a state where the manager cannot keep up, which is a failure mode that requires downtime to resolve.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>lucene</category>
      <category>search</category>
      <category>java</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
