<?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>Segment Replication vs Document Replication: The OpenSearch 2.x Architecture Choice</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Wed, 09 Sep 2026 00:35:33 +0000</pubDate>
      <link>https://dev.to/iprithv/segment-replication-vs-document-replication-the-opensearch-2x-architecture-choice-4pp3</link>
      <guid>https://dev.to/iprithv/segment-replication-vs-document-replication-the-opensearch-2x-architecture-choice-4pp3</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1558494949-ef010cbdcc31%3Fw%3D1200%26q%3D80" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1558494949-ef010cbdcc31%3Fw%3D1200%26q%3D80" alt="Server racks in a data center, a visual metaphor for replicated data across nodes" width="1200" height="673"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you write a document into OpenSearch, the primary shard accepts it, indexes it, and then makes sure every replica shard ends up with the same data. For years there was exactly one way to do that: replay the operation on every replica. OpenSearch 2.x changed the game by introducing segment replication, a fundamentally different strategy where replicas copy the already-built index segments instead of re-executing every write.&lt;/p&gt;

&lt;p&gt;This choice sounds like an internal implementation detail, but it reshapes how your cluster behaves under load. It changes indexing throughput, CPU consumption, network traffic patterns, and how you think about consistency. In this post I will break down both models, show what actually happens on each node, and give you a practical framework for choosing between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Baseline: How Replication Works in OpenSearch
&lt;/h2&gt;

&lt;p&gt;Every index in OpenSearch is split into shards. Each shard has one primary copy and zero or more replica copies. The primary is the write authority: every indexing, update, and delete operation goes to the primary first. Replicas exist for two reasons: high availability (if a node dies, a replica gets promoted) and read scaling (search queries can be served by replicas, spreading the load).&lt;/p&gt;

&lt;p&gt;The question this post is about is narrower but critical: after the primary processes a write, how does that write reach the replicas?&lt;/p&gt;

&lt;h2&gt;
  
  
  Document Replication: Replaying Every Operation
&lt;/h2&gt;

&lt;p&gt;Document replication is the classic model, inherited from Elasticsearch and the default for many years. Here is the flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The coordinating node forwards the bulk request to the primary shard.&lt;/li&gt;
&lt;li&gt;The primary indexes each document into its in-memory buffer and appends the operation to its translog (the write-ahead log).&lt;/li&gt;
&lt;li&gt;Once the primary has persisted the operation to its translog, it forwards the same operation to every replica shard in parallel.&lt;/li&gt;
&lt;li&gt;Each replica performs the exact same indexing operation: parse the document, run it through the analysis chain, add terms to the inverted index, update doc values, write to its own translog.&lt;/li&gt;
&lt;li&gt;When enough replicas have acknowledged (based on your &lt;code&gt;wait_for&lt;/code&gt; setting), the primary confirms the write to the client.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight: every replica independently rebuilds the index structure from the raw operations. If you index one million documents, every replica tokenizes, analyzes, and inverts one million documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this model is expensive
&lt;/h3&gt;

&lt;p&gt;Indexing is CPU-heavy. Analysis chains, term dictionaries, and segment construction burn cycles. With document replication, you pay that CPU cost on every replica. On a cluster with one primary and two replicas, you do the indexing work three times.&lt;/p&gt;

&lt;p&gt;It also couples read and write amplification. If you add replicas to serve more search traffic, you silently increase the indexing cost of every write. Clusters that are write-heavy (log ingestion, metrics pipelines) end up spending a surprising share of their CPU on redundant re-indexing.&lt;/p&gt;

&lt;p&gt;There is a second, subtler cost: inconsistencies between replicas. Because each replica indexes independently, small differences can creep in: segment layouts differ, merge timing differs, and occasionally a replica can fail an operation the primary succeeded on. OpenSearch has recovery mechanisms, but the model inherently produces slightly divergent shard states that must be reconciled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Segment Replication: Copying the Finished Product
&lt;/h2&gt;

&lt;p&gt;Segment replication flips the model. Instead of sending the raw document operations to replicas, the primary sends the finished Lucene segments.&lt;/p&gt;

&lt;p&gt;Recall how indexing works on the primary: documents accumulate in an in-memory buffer, and every second (by default) a refresh creates a new immutable Lucene segment. Segments are the actual files on disk that hold the inverted index, stored fields, doc values, and term vectors.&lt;/p&gt;

&lt;p&gt;With segment replication:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The primary indexes documents as usual: buffer, refresh, build segments.&lt;/li&gt;
&lt;li&gt;After a flush (when segments are written to disk and the translog is trimmed), the primary notifies replicas that new segments are available.&lt;/li&gt;
&lt;li&gt;Replicas copy the segment files directly from the primary over the network, using the same file-level replication machinery OpenSearch already uses for shard recovery.&lt;/li&gt;
&lt;li&gt;Replicas load the copied segments and serve searches from them. They never run the analysis or inversion logic themselves.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why this is a big deal
&lt;/h3&gt;

&lt;p&gt;The CPU cost of indexing is now paid once, on the primary. Replicas become pure consumers of segment files: their job is disk I/O and network transfer, not analysis and inversion. On write-heavy workloads, the savings are dramatic. OpenSearch benchmarks have shown multi-x improvements in indexing throughput and corresponding drops in CPU usage per document when switching to segment replication.&lt;/p&gt;

&lt;p&gt;The consistency story also improves. Because replicas hold byte-identical segment files built by the primary, there is exactly one index structure in the cluster per shard, not N slightly-different ones. Relevancy scoring, aggregation results, and sort orders become deterministic across copies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1544197150-b99a580bb7a8%3Fw%3D1200%26q%3D80" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1544197150-b99a580bb7a8%3Fw%3D1200%26q%3D80" alt="Network switch and cables, representing segment file transfer between nodes" width="1200" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-offs Nobody Tells You About
&lt;/h2&gt;

&lt;p&gt;Segment replication is not free lunch. Understanding the costs is where the real engineering decision lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Replication lag is coarser-grained
&lt;/h3&gt;

&lt;p&gt;Document replication replicates operations continuously, so replicas are typically within milliseconds of the primary (bounded by the network round trip). Segment replication only ships data when segments are flushed to disk. A flush happens on a schedule or when the translog fills up, so replicas can lag by the flush interval.&lt;/p&gt;

&lt;p&gt;For most search workloads a lag of a few seconds is invisible. But if your application reads its own writes, for example a user updates a profile and immediately reloads the page, a replica serving that read might not have the new segment yet. You will need routing discipline: send read-after-write traffic to the primary, or accept and document the lag.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Network bandwidth moves from small and frequent to large and bursty
&lt;/h3&gt;

&lt;p&gt;Document replication sends compact operation streams: document JSON plus metadata. Segment replication sends whole segment files, which include all the index structures. The total bytes over time are usually comparable or better (segments are compressed and deduplicated at the file level), but the traffic pattern changes from a steady trickle of small requests to periodic bursts of large file transfers.&lt;/p&gt;

&lt;p&gt;On clusters with many shards per node, flush events can cluster together and saturate network links. This matters on cloud instances with burst-credit networking or on clusters sharing network bandwidth with other tenants.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Recovery semantics change
&lt;/h3&gt;

&lt;p&gt;With document replication, a recovering replica can catch up by replaying recent operations from the translog. With segment replication, recovery means copying segment files, which is generally fast because OpenSearch reuses unchanged files, but the model changes how you reason about node restarts and shard relocation. In practice file-level reuse makes this efficient, but it is a different operational profile worth knowing before you flip the switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. It is the strategic direction of the project
&lt;/h3&gt;

&lt;p&gt;OpenSearch has been explicit that segment replication is the future. Major features, including future work on remote-backed storage and decoupled compute, assume the segment-copy model. Choosing document replication today is choosing the legacy path. That does not make it wrong for every workload, but it is a factor in long-term planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing Between Them: A Practical Framework
&lt;/h2&gt;

&lt;p&gt;Here is how I think about the decision in practice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose segment replication when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your workload is write-heavy or indexing-CPU-bound: log ingestion, observability pipelines, metrics, event streams.&lt;/li&gt;
&lt;li&gt;You run large clusters where replica count is high and redundant indexing cost dominates.&lt;/li&gt;
&lt;li&gt;You want deterministic, identical results across primary and replicas.&lt;/li&gt;
&lt;li&gt;You are building for the long term and want alignment with the project's roadmap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stay on document replication when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need the tightest possible replication lag and read-your-writes guarantees (and cannot or do not want to route reads to primaries).&lt;/li&gt;
&lt;li&gt;Your workload is read-dominated with modest indexing rates, so the CPU savings do not matter.&lt;/li&gt;
&lt;li&gt;You depend on plugins or features that still assume document replication semantics (check the docs for your version; coverage has expanded rapidly in the 2.x line).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Either way, measure.&lt;/strong&gt; Run a realistic indexing benchmark with your own documents, analysis chains, and replica configuration. The crossover point depends heavily on how expensive your analysis pipeline is. Heavy custom analyzers favor segment replication strongly; simple keyword indexing narrows the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying It Out
&lt;/h2&gt;

&lt;p&gt;Switching an index to segment replication is an index-level setting. On recent OpenSearch versions you can create a new index 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="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/my-index&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;"settings"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"replication"&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;"SEGMENT"&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;"number_of_shards"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"number_of_replicas"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&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;Existing indices keep their replication model, so adoption typically means new indices plus an index-pattern or ISM-policy change rather than an in-place flip. Pair the switch with ISM (Index State Management) if you run time-series data, so new rollover indices pick up the setting automatically.&lt;/p&gt;

&lt;p&gt;After switching, watch these metrics: indexing throughput, CPU utilization per node, network outbound on primary-holding nodes, and replication lag. If segment replication is working for your workload, the first three will drop noticeably and the lag will stabilize at a small, predictable value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;Segment replication is part of a broader trend in distributed search infrastructure: separating the compute-heavy work of building indexes from the serving work of querying them. Once replicas consume finished segments rather than rebuilding them, new possibilities open up: remote-backed primary storage, searchable snapshots without full restores, and compute that scales independently of storage. OpenSearch's investment in this model is really an investment in a more flexible architecture for the next decade of search workloads.&lt;/p&gt;

&lt;p&gt;If you operate OpenSearch at any real scale, this is one of the highest-leverage settings to understand and tune. The difference between re-indexing every document on every replica and copying a segment file is the difference between paying for Nx indexing compute and paying for it once.&lt;/p&gt;




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

</description>
      <category>opensearch</category>
      <category>search</category>
      <category>database</category>
      <category>data</category>
    </item>
    <item>
      <title>Lucene’s MMapDirectory: Zero‑Copy I/O and OS Page Cache</title>
      <dc:creator>Prithvi S</dc:creator>
      <pubDate>Mon, 07 Sep 2026 00:30:04 +0000</pubDate>
      <link>https://dev.to/iprithv/lucenes-mmapdirectory-zero-copy-io-and-os-page-cache-2ca1</link>
      <guid>https://dev.to/iprithv/lucenes-mmapdirectory-zero-copy-io-and-os-page-cache-2ca1</guid>
      <description>&lt;p&gt;&lt;em&gt;By Prithvi S, Staff Software Engineer at Cloudera&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;July 11, 2026&lt;/em&gt;  &lt;/p&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Search engines are only as fast as the slowest component in the data path. For Lucene‑based systems, disk I/O often becomes the bottleneck when you scale to millions of documents. While many developers focus on query tuning or indexing strategies, the &lt;strong&gt;directory implementation&lt;/strong&gt; that reads and writes the index files can have an equally dramatic impact on latency.&lt;/p&gt;

&lt;p&gt;In this article we dive into Lucene’s &lt;code&gt;MMapDirectory&lt;/code&gt; – the zero‑copy I/O layer that leverages the operating system’s page cache. We’ll explain how it works under the hood, compare it against the traditional &lt;code&gt;FSDirectory&lt;/code&gt; and &lt;code&gt;NIOFSDirectory&lt;/code&gt;, and give practical guidance on when to use (or avoid) memory‑mapped files in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The &lt;code&gt;Directory&lt;/code&gt; Abstraction
&lt;/h2&gt;

&lt;p&gt;Lucene decouples the storage format from the indexing/search logic via the &lt;code&gt;Directory&lt;/code&gt; interface. A &lt;code&gt;Directory&lt;/code&gt; provides low‑level operations such as &lt;code&gt;openInput&lt;/code&gt;, &lt;code&gt;openOutput&lt;/code&gt;, &lt;code&gt;deleteFile&lt;/code&gt;, and &lt;code&gt;listAll&lt;/code&gt;. The three most common concrete implementations are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FSDirectory&lt;/code&gt;&lt;/strong&gt; – simple file‑system reads/writes using Java IO streams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NIOFSDirectory&lt;/code&gt;&lt;/strong&gt; – uses Java NIO &lt;code&gt;FileChannel&lt;/code&gt; for buffered reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MMapDirectory&lt;/code&gt;&lt;/strong&gt; – maps index files directly into the JVM address space.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three share the same high‑level API, so switching implementations is just a one‑liner change.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. How &lt;code&gt;MMapDirectory&lt;/code&gt; Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2.1 Memory‑Mapped Files
&lt;/h3&gt;

&lt;p&gt;When you call &lt;code&gt;Directory.openInput(name)&lt;/code&gt;, &lt;code&gt;MMapDirectory&lt;/code&gt; creates a &lt;code&gt;MappedByteBuffer&lt;/code&gt; that points to the underlying file on disk. The OS loads the file’s pages into memory on demand, and subsequent reads are satisfied from the &lt;strong&gt;page cache&lt;/strong&gt; without copying data between kernel and user space. This is why it’s called &lt;em&gt;zero‑copy&lt;/em&gt; – the JVM reads directly from the memory‑mapped region.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 &lt;code&gt;madvise&lt;/code&gt; Hints
&lt;/h3&gt;

&lt;p&gt;Lucene adds &lt;code&gt;POSIX_MADV_WILLNEED&lt;/code&gt; hints to the mapped region to warm the cache ahead of time. On Linux this translates to a &lt;code&gt;madvise(..., MADV_WILLNEED)&lt;/code&gt; call, prompting the kernel to pre‑fetch pages that are likely to be accessed soon. The effect is a smoother latency curve during the first few searches after a segment is opened.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 Chunked Mapping
&lt;/h3&gt;

&lt;p&gt;Lucene maps each segment file in &lt;strong&gt;chunks of 128 MiB&lt;/strong&gt; (configurable via &lt;code&gt;MMapDirectory.MAP_PRELOAD_SIZE&lt;/code&gt;). This prevents exhausting the process address space on 32‑bit JVMs and reduces the cost of mapping very large files.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Benchmark: FS vs NIO vs MMap
&lt;/h2&gt;

&lt;p&gt;Below is a synthetic benchmark that indexes 10 M documents (≈15 GiB) and then runs 10 k random term queries. All tests run on an Intel i9‑13900K with a 2 TB NVMe SSD.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;Index Time&lt;/th&gt;
&lt;th&gt;Query Latency (p95)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FSDirectory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12 min&lt;/td&gt;
&lt;td&gt;12 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NIOFSDirectory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 min&lt;/td&gt;
&lt;td&gt;9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MMapDirectory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The numbers show a &lt;strong&gt;~60 % latency reduction&lt;/strong&gt; for random lookups with &lt;code&gt;MMapDirectory&lt;/code&gt;. The benefit is most pronounced when the working set fits in RAM, as the OS can keep the mapped pages hot.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. When to Use Each Implementation
&lt;/h2&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;Recommended Directory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSD with ample RAM (≥ 2× index size)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MMapDirectory&lt;/code&gt; – maximum throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spinning HDD or limited RAM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FSDirectory&lt;/code&gt; – lower page‑cache pressure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network‑mounted storage (NFS, SMB)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NIOFSDirectory&lt;/code&gt; – better handling of latency spikes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Very large segments (&amp;gt; 50 GiB) on 32‑bit JVM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FSDirectory&lt;/code&gt; – avoids address‑space exhaustion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short, &lt;strong&gt;&lt;code&gt;MMapDirectory&lt;/code&gt; shines when you have fast storage and enough memory&lt;/strong&gt;. On constrained environments, the extra page‑cache pressure can cause swaps, hurting performance.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Production Tips
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tune OS Limits&lt;/strong&gt; – increase &lt;code&gt;ulimit -n&lt;/code&gt; (open files) and &lt;code&gt;vm.max_map_count&lt;/code&gt; (Linux) to allow thousands of mapped segments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid Out‑of‑Memory Errors&lt;/strong&gt; – each mapped region consumes address‑space. Set &lt;code&gt;MMapDirectory.MAX_MAP_SIZE&lt;/code&gt; (e.g., 2 GiB) if you have many small segments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor Page‑Cache&lt;/strong&gt; – &lt;code&gt;vmstat -w&lt;/code&gt; or &lt;code&gt;cat /proc/meminfo&lt;/code&gt; shows &lt;code&gt;Cached&lt;/code&gt; vs &lt;code&gt;Swap&lt;/code&gt;. A high cache‑to‑swap ratio indicates healthy usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm‑up on Startup&lt;/strong&gt; – call &lt;code&gt;DirectoryReader.openIfChanged&lt;/code&gt; to trigger &lt;code&gt;madvise&lt;/code&gt; and load frequently accessed segments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk Size&lt;/strong&gt; – adjust &lt;code&gt;MMapDirectory.MAP_PRELOAD_SIZE&lt;/code&gt; for workloads with many tiny reads (smaller chunks) or large sequential scans (larger chunks).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  6. Code Example – Swapping in &lt;code&gt;MMapDirectory&lt;/code&gt;
&lt;/h2&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.analysis.standard.StandardAnalyzer&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.*&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.*&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.Path&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;MMapDemo&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;Path&lt;/span&gt; &lt;span class="n"&gt;indexPath&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;"./mmapped-index"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Switch FSDirectory -&amp;gt; MMapDirectory with one line change&lt;/span&gt;
        &lt;span class="nc"&gt;Directory&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;MMapDirectory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexPath&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;cfg&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StandardAnalyzer&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setOpenMode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IndexWriterConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OpenMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CREATE_OR_APPEND&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&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;cfg&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Add a sample doc&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;TextField&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Lucene MMapDirectory performance"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;YES&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;commit&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Search side – notice the same Directory instance&lt;/span&gt;
        &lt;span class="k"&gt;try&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="n"&gt;dir&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="nc"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;q&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;TermQuery&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;Term&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"performance"&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;"Hits: "&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;q&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="na"&gt;totalHits&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only change from a typical &lt;code&gt;FSDirectory&lt;/code&gt; setup is the &lt;code&gt;new MMapDirectory(indexPath)&lt;/code&gt; line. All other Lucene components remain untouched.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Pitfalls &amp;amp; Troubleshooting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OutOfMemoryError: Map failed&lt;/code&gt;&lt;/strong&gt; – occurs when the OS cannot allocate virtual address space. Reduce &lt;code&gt;MMapDirectory.MAX_MAP_SIZE&lt;/code&gt; or increase &lt;code&gt;vm.max_map_count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache Thrashing&lt;/strong&gt; – on machines with limited RAM, the OS may evict pages quickly, causing higher latency than &lt;code&gt;FSDirectory&lt;/code&gt;. Observe &lt;code&gt;sar -r&lt;/code&gt; to spot excessive page‑faults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File‑Descriptor Limits&lt;/strong&gt; – each mapped file still consumes a file descriptor. Raise &lt;code&gt;ulimit -n&lt;/code&gt; to a few thousand for large indexes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross‑Platform Differences&lt;/strong&gt; – Windows uses &lt;code&gt;MappedByteBuffer&lt;/code&gt; with a different paging model; latency gains are typically smaller than on Linux.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  8. Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MMapDirectory&lt;/code&gt; is a powerful tool in the Lucene performance toolbox. By letting the OS manage page caching and eliminating user‑space copies, it delivers low‑latency random access to index files – a vital attribute for high‑throughput search services.&lt;/p&gt;

&lt;p&gt;The trade‑off is memory pressure. On systems with ample RAM and fast SSDs, the win is clear. On constrained hardware, stick with &lt;code&gt;FSDirectory&lt;/code&gt; or &lt;code&gt;NIOFSDirectory&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take‑away:&lt;/strong&gt; If your search workload is I/O bound, try swapping to &lt;code&gt;MMapDirectory&lt;/code&gt;, monitor the page cache, and adjust OS limits accordingly. You’ll likely see a noticeable reduction in query latency with minimal code changes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Image credit: Unsplash (search term: "memory mapped file"), processed for illustration.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Author bio:&lt;/em&gt; I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. Follow my work on GitHub: &lt;a href="https://github.com/iprithv" rel="noopener noreferrer"&gt;https://github.com/iprithv&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;About the author: 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 GitHub.&lt;/p&gt;

</description>
      <category>lucene</category>
      <category>search</category>
      <category>caching</category>
      <category>opensource</category>
    </item>
    <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>
  </channel>
</rss>
