<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title><![CDATA[Elasticsearch Labs]]></title>
        <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
        <link>https://www.elastic.co/search-labs</link>
        <image>
            <url>https://www.elastic.co/search-labs/favicon.svg</url>
            <title>Elasticsearch Labs</title>
            <link>https://www.elastic.co/search-labs</link>
        </image>
        <generator>Elasticsearch Labs</generator>
        <lastBuildDate>Sat, 30 May 2026 01:55:26 GMT</lastBuildDate>
        <atom:link href="https://www.elastic.co/search-labs/rss/feed" rel="self" type="application/rss+xml"/>
        <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
        <language><![CDATA[en]]></language>
        <item>
            <title><![CDATA[One API call per operation: how Elastic Cloud Hosted makes fleet-scale deployment management practical]]></title>
            <description><![CDATA[Elastic Cloud Hosted adds five targeted APIs for upgrade, tier scaling, user settings, tags and snapshot repository linking, each replacing a multi-step deployment plan edit with a single focused call.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-cloud-hosted-deployment-api</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-cloud-hosted-deployment-api</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Omer Kushmaro]]></dc:creator>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/08ea3fdac9f6f947403896ce3255a611eb5ea280-1999x1384.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Five new Elastic Cloud Hosted APIs replace a painful pattern: fetching a full deployment plan, editing topology elements by hand, and resubmitting the whole thing just to change one tier or bump a version. Upgrade, scale, configure, tag and share snapshot repositories, each with a single targeted API call. The same validators apply, and every operation is available in the Elastic Cloud Console too. If you run more than a handful of deployments, this is the fleet operations surface you&#x27;ve been waiting for.</p><p>Here&#x27;s what&#x27;s new.</p><h2>How to upgrade an Elastic Cloud Hosted deployment via API</h2><p><strong>Upgrade API:</strong> <code>POST /deployments/{id}/upgrade</code></p><p>Upgrading an Elastic Stack version across all resources in a deployment used to require you to construct and submit a full deployment plan update. Now, you can upgrade every resource in a deployment (Elasticsearch, Kibana, and all other components) to a target Elastic Stack version with one API call.</p><pre><code>curl -X POST https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/upgrade \
  -H "Authorization: ApiKey $EC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_version": "9.4.0"}'</code></pre><p>This pays off most when you operate many deployments and want to roll a new Elastic Stack version across your fleet quickly.</p><h2>How to resize Elasticsearch tiers in Elastic Cloud Hosted</h2><p><strong>Scale API:</strong> <code>GET / PATCH /deployments/{id}/elasticsearch/{ref_id}/tiers</code></p><p>The tiers API lets you resize any Elasticsearch tier (hot, warm, cold, frozen, master, coordinating or ML) with a single PATCH request scoped only to the tiers you want to change.Send a body keyed by tier ID. Anything you don&#x27;t mention stays exactly as it was, so you can change one tier in isolation without touching the others.</p><pre><code>curl -X PATCH https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/elasticsearch/main/tiers \
  -H "Authorization: ApiKey $EC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "hot_content": {
      "memory_size": 4096,
      "zone_count": 2
    },
    "warm": {
      "memory_size": 5120,
      "zone_count": 1
    },
    "master": {
      "memory_size": 1024,
      "zone_count": 3
    }
  }'</code></pre><p>A few things to know:</p><ul><li><strong>Valid tier IDs</strong> are <code>hot_content</code>, <code>warm</code>, <code>cold</code>, <code>frozen</code>, <code>master</code>, <code>coordinating</code>, and <code>ml</code>. Invalid keys are rejected with the list of tiers you can use.</li><li><strong><code>memory_size</code> and <code>zone_count</code> are each optional per tier.</strong> Include only the one you want to change; the other stays untouched.</li><li><strong>Dedicated master handling is automatic.</strong> Include <code>master</code> with <code>memory_size</code> and <code>zone_count</code> greater than zero, and the endpoint flips the <code>hot_content</code> tier out of the master-eligible role so your new dedicated masters take over. Include <code>master</code> with <code>memory_size</code> set to zero, and the <code>hot_content</code> tier resumes acting as master-eligible. No separate flag to manage.</li><li><strong>Same validation as the full plan API.</strong> The modified plan runs through the existing validators, so size-vs-instance-configuration mismatches, zone-count limits, and topology rules are enforced consistently.</li></ul><p>For teams automating tier-level scaling based on ingest load, rebalancing zones before a maintenance window, or promoting a deployment to dedicated masters as it grows, this collapses what used to be a multistep plan edit into a single targeted request.</p><h2>How to update elasticsearch.yml settings in Elastic Cloud Hosted</h2><p><strong>User Settings API:</strong> <code>GET</code> and <code>PUT</code> <code>/deployments/{id}/{resource_kind}/{ref_id}/user_settings</code></p><p>User-defined settings on a running deployment (<code>elasticsearch.yml</code>-style overrides, Kibana settings, APM config) now have their own endpoints. Read or update them directly with a focused JSON request, scoped to a single resource.</p><p>The endpoints cover Elasticsearch, Kibana, Elastic APM, App Search, Enterprise Search, and Integrations Server. Same endpoint shape across all of them; only the <code>{resource_kind}</code> path segment changes.</p><p>Read the current settings for a resource:</p><pre><code>curl https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/elasticsearch/main/user_settings \
  -H "Authorization: ApiKey $EC_API_KEY"</code></pre><p>Update them with a JSON body containing only the keys you want set:</p><pre><code>curl -X PUT https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/elasticsearch/main/user_settings \
  -H "Authorization: ApiKey $EC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "search.allow_expensive_queries": false,
    "indices.recovery.max_bytes_per_sec": "200mb"
  }'</code></pre><p>A few things to know:</p><ul><li><strong>JSON in, JSON out.</strong> A JSON contract that&#x27;s easy to template, diff, and check into source control.</li><li><strong>Existing YAML is auto-migrated.</strong> Any tier-level user settings you currently have in YAML are converted to JSON the first time you <code>PUT</code>, so existing deployments work without a manual rewrite or a one-time migration step.</li><li><strong>Allowlist and denylist rules still apply.</strong> Settings the platform doesn&#x27;t permit are rejected with the same validation the full plan API runs.</li><li><strong>Asynchronous.</strong> The action is acknowledged immediately and applied in the background, consistent with the other targeted endpoints in this release.</li><li><strong>Non-breaking.</strong> The full <code>PUT /deployments/{id}</code> endpoint continues to work; this is an additive surface, not a replacement.</li></ul><p>For teams that tune settings as part of a release, sweep a configuration change across a fleet, or want to manage cluster overrides as code, this is a single targeted <code>PUT</code> per deployment.</p><h2>How to manage deployment tags in Elastic Cloud Hosted</h2><p><strong>Tags API:</strong> <code>GET</code> and <code>PUT /deployments/{id}/tags</code></p><p>Deployment tags attach metadata (environment, owner, cost center, application) to deployments for filtering, reporting, and policy. The Tags API gives you a <code>GET</code> for the current tags and a <code>PUT</code> to replace them, both in one targeted call.</p><pre><code>curl -X PUT https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/tags \
  -H "Authorization: ApiKey $EC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": [{"key": "env", "value": "prod"}, {"key": "owner", "value": "search-platform"}]}'</code></pre><p><code>PUT</code> replaces all tags on the deployment. Validation enforces up to 64 tags per deployment, keys up to 32 characters, and values up to 128 characters, with explicit <code>400</code> errors on violations. Authorization follows the same model as the deployment update endpoint.</p><h2>How to link snapshot repositories across Elastic Cloud Hosted deployments</h2><p><strong>Snapshot repository APIs:</strong> Full CRUD at <code>/deployments/{id}/elasticsearch/{ref_id}/snapshot/repository</code>, plus a new Elastic Cloud console workflow</p><p>A common operational pattern in ECH is needing one deployment to access snapshots from another: migration validation, populating a staging environment, or selectively restoring one tenant&#x27;s indices across a fleet. Snapshot repository linking lets you do this without copying full snapshot data into a new deployment.</p><p>The capability creates a link between a source deployment&#x27;s managed snapshot repository and a target deployment. The target can browse the source&#x27;s snapshots and restore only what it actually needs; nothing is copied at link time. Cross-bucket credentials and the repository are wired up for you, given your user has an admin role over both deployments.</p><p>This works specifically and only for Elastic Cloud managed snapshot repositories, whose credentials are managed by Elastic Cloud.</p><p>You can manage these links in two ways: from the Elastic Cloud console UI for ad hoc, point-and-click work, or via the API for automation and infrastructure-as-code workflows.</p><h3>Link snapshot repositories from the Elastic Cloud console</h3><p>For one-off restore drills, staging refreshes, or anytime you&#x27;d rather not script it, the Elastic Cloud console now provides a guided flow to link a source deployment&#x27;s snapshot repository into a target deployment. Open the target deployment, go to the snapshot repository management screen, pick the source deployment from the list of deployments you have access to, and confirm. The console handles credentials and registration behind the scenes. No JSON, no keystore wrangling.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/195ef1e3d7e2c9d5941733555fc040d9e7ef5080-1999x1491.png" style="width: 100%;" /><p>Once the link is in place, the same screen lists all linked deployments:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/08ea3fdac9f6f947403896ce3255a611eb5ea280-1999x1384.png" style="width: 100%;" /><p>Removing a link is equally direct: Pick the linked repository, and unlink it. The source deployment is unaffected; only the read-only link from the target is torn down.</p><h3>Manage repositories linking via the API</h3><p>The snapshot repository linking operations (create, list, delete) are also available as API endpoints for automation and infrastructure-as-code workflows.</p><h4>Create a link</h4><pre><code>curl -X POST https://api.elastic-cloud.com/api/v1/deployments/{target_deployment_id}/elasticsearch/{ref_id}/snapshot/repository \
  -H "Authorization: ApiKey $EC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source_deployment_id": "<source-deployment-id>"
  }'</code></pre><p>The endpoint is idempotent. A retry after a partial failure picks up where the previous attempt left off, so you don&#x27;t end up with half-configured links. The work runs asynchronously: You get an immediate acknowledgment, and the Elastic Cloud control plane handles the rest (fetching the source repository configuration, injecting credentials into the target cluster&#x27;s keystore, and registering the read-only repository in Elasticsearch). The linking proceeds, provided the caller has the right permissions on both deployments.</p><h4>List linked snapshot repositories</h4><pre><code>curl https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/elasticsearch/{ref_id}/snapshot/repository \
  -H "Authorization: ApiKey $EC_API_KEY"</code></pre><p>Each entry in the response includes a <code>repository_name</code> field with the actual name of the repository as registered in Elasticsearch (for example, <code>_clone_abcd1234</code>). You don&#x27;t need to derive it from internal ID conventions; take the name straight from the response, and use it anywhere you&#x27;d reference a repository in the Elasticsearch snapshot and restore APIs.</p><h4>Remove a link</h4><pre><code>curl -X DELETE https://api.elastic-cloud.com/api/v1/deployments/{deployment_id}/elasticsearch/{ref_id}/snapshot/repository/{repository_name} \
  -H "Authorization: ApiKey $EC_API_KEY"</code></pre><p><code>DELETE</code> takes the <code>repository_name</code> directly (the same value returned from <code>GET</code> above). That keeps the API symmetric and avoids looking up the source deployment by ID when you&#x27;re tearing a link down.</p><p>A typical lifecycle:</p><pre><code>POST   .../snapshot/repository            (link target → source)
GET    .../snapshot/repository            (confirm the link, read back repository_name)
DELETE .../snapshot/repository/{name}     (unlink when done)</code></pre><p>This is particularly useful for multi-environment workflows where a staging deployment reads from production snapshots, tenants-per-cluster setups that need selective restores, or multitenanted restore processes that require a specific index restore.</p><h2>Choosing instance configurations per tier in Elastic Cloud Hosted</h2><p><strong>Instance configuration customization:</strong> Elastic Cloud console UI</p><p>Elastic Cloud Hosted now lets you pick an instance configuration per data tier (hot, warm, cold, frozen) directly from the Elastic Cloud Console.</p><p>Match deployment hardware to a specific workload by picking an instance configuration per data tier (hot, warm, cold, frozen):</p><ol><li>Navigate to your deployment&#x27;s overview page, and click <strong>Edit</strong> on the hardware profile.</li><li>In the hardware profile flyout, select the instance configuration that best fits each data tier&#x27;s workload.</li><li>Review the changes, and click <strong>Update</strong>. A confirmation dialog shows exactly what will change before you apply.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0775617001afe25304258b2b6c792ca885fb43f2-1999x1280.png" style="width: 100%;" /><p>Whether you need more storage density on your warm tier, higher compute on hot for search-intensive workloads, or want to move to a newer machine type in your region, you can now make that change from the console instead of crafting API requests. If your selection aligns with an existing hardware profile, that profile is automatically applied. Otherwise, the deployment is labeled <code>custom</code>, and any combination supported by your region is allowed.</p><p>The API continues to support instance configuration changes for both data tiers and stateless resources (Kibana, machine learning [ML] nodes, master nodes) for automation use cases.</p><p><a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/change-hardware">Full documentation for instance configuration customization</a> is available in the Elastic Docs.</p><h2>Elastic Cloud Hosted API reference: upgrade, scale, configure, tag, snapshot</h2><p>These endpoints, plus the new Elastic Cloud console workflows, give ECH a verb-shaped surface for fleet operations: upgrade, scale, set, tag, share, customize. One call per operation, the same validators as the full deployment plan, available from API and Terraform. That&#x27;s what makes fleet-scale automation practical.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">API</td><td style="border: 1px solid #ccc; padding: 4px;">Endpoint</td><td style="border: 1px solid #ccc; padding: 4px;">What it does</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Upgrade</td><td style="border: 1px solid #ccc; padding: 4px;">POST /deployments/{id}/upgrade</td><td style="border: 1px solid #ccc; padding: 4px;">Upgrades all resources to a target Elastic Stack version</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tiers</td><td style="border: 1px solid #ccc; padding: 4px;">PATCH /deployments/{id}/elasticsearch/{ref_id}/tiers</td><td style="border: 1px solid #ccc; padding: 4px;">Resizes one or more tiers without touching others</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">User settings</td><td style="border: 1px solid #ccc; padding: 4px;">PUT /deployments/{id}/{resource_kind}/{ref_id}/user_settings</td><td style="border: 1px solid #ccc; padding: 4px;">Reads/updates elasticsearch.yml and Kibana settings</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tags</td><td style="border: 1px solid #ccc; padding: 4px;">PUT /deployments/{id}/tags</td><td style="border: 1px solid #ccc; padding: 4px;">Reads/replaces deployment tags</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Snapshot repository</td><td style="border: 1px solid #ccc; padding: 4px;">POST/GET/DELETE /deployments/{id}/elasticsearch/{ref_id}/snapshot/repository</td><td style="border: 1px solid #ccc; padding: 4px;">Links, lists, and removes snapshot repository links</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Get started:</strong></p><ul><li>Already on Elastic Cloud Hosted? Generate an API key from your <a href="https://cloud.elastic.co/account/keys">Elastic Cloud account page</a>, and try the examples above. Full reference is in the <a href="https://www.elastic.co/docs/api/doc/cloud">Elastic Cloud API documentation</a>.</li><li>Prefer infrastructure as code? The <a href="https://registry.terraform.io/providers/elastic/ec/latest/docs">Elastic Cloud Terraform provider</a> wraps these APIs for declarative deployment management.</li><li>New to Elastic Cloud? <a href="https://cloud.elastic.co/registration">Start a free trial</a>, and deploy your first cluster in minutes.</li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How we doubled vector search throughput on Elasticsearch Serverless]]></title>
            <description><![CDATA[How we brought Elasticsearch's native SIMD scoring engine to serverless, and why serverless is where vector search innovation happens next.]]></description>
            <link>https://www.elastic.co/search-labs/blog/vector-search-serverless-simdvec-throughput</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-search-serverless-simdvec-throughput</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Chris Hegarty, Lorenzo Dematte]]></dc:creator>
            <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e7a1b50ef019d1b5a12d49c7457d63a026e1edd0-727x496.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We&#x27;ve brought simdvec, Elasticsearch&#x27;s native single instruction, multiple data (SIMD) vector scoring engine, to serverless. Search throughput nearly doubled under concurrent load, and p99.9 tail latency dropped from 237 ms to 30 ms. By giving simdvec direct access to the blob cache&#x27;s memory-mapped regions, serverless now runs the same zero-copy SIMD kernels as stateful, with identical recall and zero heap overhead. And because serverless gives us control over the entire storage layer, we believe it&#x27;s where vector search will be fastest. Here&#x27;s how we got there.</p><h2>Vector Search on Elasticsearch Serverless</h2><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-serverless-stateless-architecture">Elasticsearch Serverless</a> is built on Stateless Elasticsearch, a fully decoupled compute and storage architecture where index data lives in remote object storage and search nodes maintain only a local cache. For vector search to be fast on this architecture, the scoring engine needs to work directly with the local cache, not copy it to the heap first.</p><p>Elasticsearch <a href="https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine">simdvec</a> is the engine behind every vector distance computation in Elasticsearch. It provides hand-tuned AVX-512 and NEON kernels, bulk scoring with explicit prefetching, and off-heap memory access that keeps data flowing from storage straight to CPU registers. On stateful Elasticsearch, simdvec has always had a direct fuel line: Memory-mapped files feed native pointers straight into SIMD intrinsics. On serverless, the data was sitting right there in the blob cache&#x27;s memory-mapped regions, in exactly the right form, but there was no path connecting it to the scoring engine.</p><p>We&#x27;ve now built that path. simdvec runs on Serverless with the same off-heap, native SIMD scoring as stateful. And because serverless gives us control over the entire storage layer, this is just the beginning.</p><h2>Premium fuel only: why simdvec requires off-heap memory for vector scoring</h2><p>simdvec&#x27;s speed comes from working directly with off-heap memory. It takes a native pointer to memory-mapped data and passes it straight to C++ SIMD intrinsics. No intermediate copies, no heap allocations. The data flows from storage straight to CPU registers. This matters more than it sounds: simdvec&#x27;s kernels process vectors faster than the data can be copied, so any copy in the path becomes the bottleneck, not the scoring itself.</p><p>On stateful Elasticsearch, this just works. Lucene memory-maps index files from local disk, and the scorer extracts a native pointer directly from the mapped region. This is the path that delivers the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine#thousands-at-a-time">benchmark numbers</a> we&#x27;ve published, and it&#x27;s what we wanted to bring to serverless. To see how, we first need to understand how serverless stores and accesses data.</p><h2>The serverless blob cache: how Elasticsearch stores vector data</h2><p>In the stateless architecture, the primary copy of all index data lives in remote object storage, such as S3. Each search node maintains a local cache (called the <em>blob cache</em>) that keeps recently and frequently accessed portions of the index data on local SSD. The frozen tier on stateful Elasticsearch uses the same architecture: Searchable snapshots are backed by a similar blob cache that memory-maps regions from remote storage onto local disk. When a search hits cached data, it&#x27;s served from fast local storage. When it misses, the blob cache fetches the data from the remote store and caches it for future queries.</p><p>The blob cache is organized into fixed-size memory-mapped regions, 16MB by default. It manages its own lifecycle: tracking which regions are in use, applying a <a href="https://www.elastic.co/search-labs/blog/searchable-snapshots-benchmark">least-frequently-used eviction policy</a> when the cache is full, and reference counting to ensure regions aren&#x27;t evicted while being read. The regions are still memory-mapped through the OS, but the blob cache controls which regions exist, which are populated, and when they&#x27;re reclaimed. On stateful, those decisions are left entirely to the OS.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e7a1b50ef019d1b5a12d49c7457d63a026e1edd0-727x496.png" style="width: 100%;" /><p>Crucially, because each region is memory-mapped, the blob cache already holds vector data in exactly the form simdvec needs. But before <a href="https://github.com/elastic/elasticsearch/pull/141718">we built the connection</a>, there was no way to get at it. Every vector comparison was copied into a heap array and handed to a slower scorer. No direct memory pointers, no SIMD, and garbage collection pressure on every call.</p><h2>Unified scoring: one SIMD path for all storage tiers</h2><p>We introduced a new abstraction that lets the scorer safely borrow direct memory from whatever storage layer is underneath, just long enough to run the SIMD computation. If the data is available as direct memory, simdvec&#x27;s native kernels run. If not (data not yet cached or spanning a region boundary), the scorer falls back to a heap copy. In practice, the fallback is rare.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fd9583ffce724665018af46ce0409dc4e0825078-828x259.png" style="width: 100%;" /><p>This gave us a single scoring entry point across all tiers:</p><ol><li><strong>Stateful</strong> (local disk): The scorer extracts a native pointer from the OS memory map.</li><li><strong>Blob cache</strong> (serverless, frozen tier): The scorer borrows a direct memory slice from a cache region.</li><li><strong>Fallback</strong>: The scorer copies bytes to the heap. Rare in practice.</li></ol><p>The scorer doesn&#x27;t know which tier it&#x27;s running on, and it doesn&#x27;t need to. It also means we no longer maintain separate scoring implementations; previously, there was a fast native path for stateful and a slower path for everything else. Now every improvement to simdvec benefits all tiers automatically, including its most powerful capability: bulk scoring.</p><h2>Bulk vector scoring across blob cache regions</h2><p>A single query may score thousands of candidate vectors. simdvec&#x27;s <a href="https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine#thousands-at-a-time">bulk scoring</a> processes these in batches with multi-accumulator inner loops, query amortization, and cache-line prefetching, up to 4x faster than single-vector alternatives when data exceeds CPU cache.</p><p>Search over an Inverted file (IVF) index is where bulk scoring has the most impact. The query selects a set of candidate posting lists and sweeps through the quantized vectors, scoring them in large batches against the query vector. On stateful, those vectors live in one contiguous memory-mapped file, so bulk scoring resolves them with straightforward pointer arithmetic and scores a batch in a single native call.</p><p>On serverless, a sweep through a posting list may cross blob cache region boundaries. We extended the direct memory abstraction with a bulk access method that resolves multiple vector offsets to their respective cache regions in a single call. If all vectors in the batch are cached and none cross a region boundary, the scorer gets a direct memory slice and passes the whole batch to simdvec&#x27;s native bulk kernel with the same prefetching and pipelining as stateful. When a vector does cross a boundary, the system falls back to per-vector scoring: still zero-copy, just without the batching benefit. With 16MB regions and 1024-byte vectors, that happens roughly once every 16,000 vectors.</p><p>simdvec&#x27;s bulk scoring architecture, the key differentiator highlighted in the simdvec <a href="https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine">benchmarks</a>, now operates on serverless with the same characteristics that make it fast on stateful. So how does it perform in practice?</p><h2>simdvec on Elasticsearch Serverless: vector search lap times</h2><p>We benchmarked with an 18 million vector <a href="https://github.com/elastic/rally-tracks/tree/master/msmarco-v2-vector">MSMARCO</a> dataset at 1024 dimensions, using IVF with Better Binary Quantization (BBQ) 1-bit quantization. All results are on a warm blob cache with the full dataset resident in local cache regions, so we&#x27;re measuring the scoring path rather than remote fetch latency.</p><p><strong>Throughput.</strong> Under concurrent load, search throughput nearly doubled, jumping from 398 to 739 ops/s. Single-client gains were 23-39%, but the real difference shows up under concurrency: The improvement was 2-3x larger because eliminating heap copies removes the GC pressure and allocation contention that previously throttled concurrent scoring.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fc152731c4b99b85a448e8bd4e01915fefcb55a3-919x533.png" style="width: 100%;" /><p><strong>Tail latency.</strong> The direct memory path transformed tail latency under load:</p><ul><li><em>p99.9</em> dropped from 237 ms to 30 ms (87% reduction).</li><li><em>p99.99</em> dropped from 9.1 seconds to 55 ms (99.4% reduction).</li></ul><p><em>p100</em> dropped from 11.4 seconds to under 100 ms.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/003874d85261507d95274504a83bc016af0beb13-818x555.png" style="width: 100%;" /><p>The worst-case outliers that previously took seconds now complete in tens of milliseconds. The heap-copy-induced queueing that caused latency spikes is gone.</p><p>Recall is identical. The same vectors are scored, producing the same results. And we&#x27;re just getting started.</p><h2>Beyond parity: what Elasticsearch Serverless can do for vector search that stateful can&#x27;t</h2><p>Reaching parity with stateful was the goal. But the more interesting realization is what the stateless architecture lets us do that stateful can’t.</p><p>On stateful, the OS controls memory-mapped file behavior: which pages stay resident, when to evict, how aggressively to read ahead. The application can offer hints, but they apply to entire file mappings, and the kernel may ignore them. Worse, search and indexing happen concurrently on the same node, so a hint that benefits one access pattern can hurt another. In practice, to balance different needs, you have to be conservative.</p><p>On serverless, two things are fundamentally different. The blob cache manages its own memory-mapped regions with full application-level control. And serverless <a href="https://github.com/elastic/elasticsearch/issues/147626">separates indexing and search onto dedicated tiers</a>: Search nodes never merge, indexing nodes never serve queries. No conflicting access patterns means we can be aggressive with memory advice. Here’s what we’re working on:</p><ul><li><strong>Per-region memory advice.</strong> The blob cache knows what type of data each region holds. It can issue <a href="https://github.com/elastic/elasticsearch/issues/147625">random-access hints for rescoring regions</a>, where raw float32 vectors are read in unpredictable order and the kernel’s default readahead would waste memory on pages that will never be used. It can apply sequential readahead for scans through quantized vectors. On the indexing tier, merges read data sequentially, so aggressive readahead brings pages in before they&#x27;re needed, with no risk of harming concurrent random reads that simply aren&#x27;t happening on that node.</li><li><strong>Cache-aware prefetching.</strong> simdvec already prefetches at the CPU cache-line level. On serverless, we can coordinate this with the blob cache&#x27;s knowledge of region residency, prefetching at multiple levels: remote store to cache, OS pages to RAM, and cache lines to CPU. The blob cache can <a href="https://github.com/elastic/elasticsearch/pull/147964">tell the scorer</a> which regions are resident before scoring begins, avoiding work on data that would trigger a remote fetch.</li><li><strong>Workload-aware eviction.</strong> The blob cache can prioritize retaining data that vector search depends on: IVF centroid indexes that are checked on every query or quantized vectors that are scored in bulk, over data that&#x27;s accessed infrequently. The OS page cache evicts based on generic heuristics with no understanding of what the data represents. On serverless, eviction policy can be tuned to the workload.</li></ul><p>The blob cache gives us a level of control over the memory hierarchy that the OS page cache simply can’t. This is why we see serverless as the most promising platform for the next generation of vector search performance work. Not just matching stateful, but surpassing it. And vectors are just the beginning.</p><h2>Vector search on Elasticsearch Serverless: what we shipped and what&#x27;s next</h2><p>simdvec now runs everywhere Elasticsearch runs (stateful, serverless, and frozen tier) with the same native SIMD scoring, the same bulk scoring, and the same off-heap efficiency. The abstraction we built is general-purpose and already wired through every layer in the storage chain, so the same approach could benefit term lookups, aggregations, sorting, and stored field retrieval in the future.</p><p>Elasticsearch Serverless is where we&#x27;re investing most heavily in vector search performance. Every improvement to simdvec, every optimization to the blob cache, and every new storage-level improvement lands here first. If you&#x27;re choosing where to run your vector workloads, serverless is the platform that keeps getting faster. You can get started with a free <a href="https://cloud.elastic.co/registration">Elastic Cloud trial</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How Elasticsearch cuts time-series storage by 34% with synthetic _id and bloom filters]]></title>
            <description><![CDATA[Learn how synthetic _id uses bloom filters to cut time-series storage by 34% while maintaining full API compatibility.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-synthetic-id-time-series-storage</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-synthetic-id-time-series-storage</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tanguy Leroux, Francisco Fernández Castaño, Anton Persson]]></dc:creator>
            <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a70c5403064d5bbceff66a17373332362227f13c-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Synthetic <code>_id</code> reduces time-series index storage by up to 34% and eliminates 6% CPU overhead at ingest. Instead of building an inverted index for <code>_id</code>, Elasticsearch computes the document identifier on the fly from <code>_tsid</code> and <code>@timestamp</code>, using a bloom filter for deduplication. This optimization ships in Elasticsearch 9.4 and is already live on Elastic Cloud Serverless.</p><p>This post is a deep dive into the implementation. For context on how synthetic <code>_id</code> fits into the broader metrics performance story, see <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">How we rebuilt Elasticsearch as a leading columnar metrics datastore</a> to achieve up to 6.6x improvement in storage efficiency and 50% improvement in indexing throughput for OpenTelemetry metrics.</p><p>We&#x27;ll start by explaining why the <code>_id</code> field is expensive for time-series workloads. We&#x27;ll then describe how synthetic <code>_id</code> works and how it uses a bloom filter to optimize document deduplications instead of maintaining a traditional inverted index. Finally, we&#x27;ll share the performance results from our benchmarks and serverless production deployments.</p><h2>The hidden cost of _id in time-series indices</h2><p>Time-series indices are a specialized index mode optimized for metrics, logs, traces, and other timestamped data. They store sequences of data points (like CPU usage, stock prices, or sensor readings) that track changes to specific entities over time. In Elasticsearch, each of these data points is indexed as a document with a unique identifier called <code>_id</code>. This identifier is used to look up, update, or delete specific documents. When a document is indexed in Elasticsearch, the system checks whether a document with the same <code>_id</code> already exists. Depending on the operation type (<code>op_type</code>), an existing document is either replaced (<code>index</code>) or the new document is rejected (<code>create</code>); the latter is the most common path for metrics ingestion.</p><p>To perform this lookup efficiently, Elasticsearch builds an <a href="https://en.wikipedia.org/wiki/Inverted_index">inverted index</a> for the <code>_id</code> field. This inverted index maps each <code>_id</code> value to its location in the index, enabling fast document lookups. Until version 8.11, the <code>_id</code> value was also stored separately in order to be returned in search results and other APIs. From 8.11 and onwards, we optimized Elasticsearch to only store this value temporarily for document replication purposes, the value being quickly merged away and reconstructed on demand.</p><p>For many use cases, building the inverted index and storing it is an acceptable overhead. But for time-series data, like metrics or traces, the cost can add up quickly. Our experiments showed that building the inverted index for the field <code>_id</code> adds 6% CPU overhead compared to indexing without it. In some extreme cases, we benchmarked that it could reduce indexing throughput by 25%.</p><p>This overhead is especially painful for time-series workloads where data points are typically small (often just a timestamp and a few numeric values) and compress extremely well. The <code>_id</code> field, however, doesn&#x27;t benefit from the same compression. As a result, the inverted index for <code>_id</code> can represent a disproportionate share of the total storage. In our benchmarks with OpenTelemetry (OTel) metrics, the <code>_id</code> inverted index alone consumed around 5 bytes of the total 25 bytes per data point.</p><p>We considered several approaches to eliminate this overhead:</p><ul><li>Stop indexing <code>_id</code> and checking for duplicates: This would be the simplest solution, but without deduplication, duplicate data points could corrupt aggregations. A gauge average, for instance, would be skewed by repeated values.</li><li>Accept duplicates during indexing, deduplicate at query time: This preserves correctness but adds overhead to every query, degrading dashboard responsiveness.</li><li>Deduplicate during segment merges: Duplicates would eventually be removed, but queries on unmerged segments would still return results with duplicates.</li><li>Synthetic <code>_id</code>: Compute the document identifier on the fly from fields that already uniquely identify each data point, and use a lightweight bloom filter for deduplication instead of a full inverted index.</li></ul><p>We chose synthetic <code>_id</code> because it maintains correctness at ingest time while eliminating the storage and CPU overhead of the traditional approach. And we decided to implement it for time-series indices because they’re very well suited for this optimization.</p><p>In time-series indices, the <code>_id</code> isn’t arbitrary. Each document has a <strong>time series identifier</strong> (<code>_tsid</code>) and a <strong>timestamp</strong> (<code>@timestamp</code>). The <code>_tsid</code> is generated from the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-dimension">dimensions fields</a> of the document (like <code>host.name</code>, <code>pod.name</code>, or <code>sensor_id</code>), while the <code>@timestamp</code> marks the point in time of the document. Together, these two fields uniquely identify the document: There can only be one data point for a given time series at a given moment in time. This means we can derive the <code>_id</code> from the <code>_tsid</code> and <code>@timestamp</code> field values, rather than storing it separately.</p><h2>How does synthetic _id work in Elasticsearch?</h2><p>With synthetic <code>_id</code>, Elasticsearch computes the document identifier on the fly as the combination of the <code>_tsid</code> and <code>@timestamp</code> fields. This computed value is used wherever <code>_id</code> would normally be used: in API responses, for document lookups, and for deduplication. However, it’s never stored in an inverted index nor is it stored on disk for later retrieval.</p><p>The challenge is deduplication. When a new document arrives, Elasticsearch must verify that no document with the same <code>_id</code> already exists. Without an inverted index on <code>_id</code>, how can we perform this check efficiently?</p><h3>How synthetic _id simulates an inverted index without building one</h3><p>Our Elastic Lucene experts suggested a clever idea: Since <code>_tsid</code> and <code>@timestamp</code> are already stored as doc values, we could expose our own custom Lucene postings format that simulates an inverted index without actually building one.</p><p>This means that when Elasticsearch needs to look up a document by its <code>_id</code>, it uses the same code path as usual: It queries the underlying Lucene index to look up the <code>_id</code> term. But instead of hitting a real inverted index, our custom postings format intercepts the call, extracts the <code>_tsid</code> and <code>@timestamp</code> encoded in the synthetic <code>_id</code>, and uses their doc values to locate the document. Because time-series indices are sorted by these fields, documents belonging to the same time series are stored contiguously. This allows Elasticsearch to skip large subsets of nonmatching documents (sometimes entire segments) to find the target document(s) quickly.</p><p>While this process is efficient, it can involve several random-access reads: looking up the <code>_tsid</code> value, scanning for matching documents, and reading timestamps. For the common case in time-series indices where we don’t expect the document to already exist, we wanted to fail fast without touching doc values at all.</p><h3>Bloom filters for fast membership testing</h3><p>We solve this problem using a <a href="https://en.wikipedia.org/wiki/Bloom_filter"><strong>bloom filter</strong></a>, a probabilistic data structure that can quickly answer the question <em>Could this element be in the set?</em> with a small risk of false positives but no risk of false negatives. In other words, a bloom filter might occasionally say <em>yes</em> when the answer is actually <em>no</em>, but it will never say <em>no</em> when the answer is <em>yes</em>.</p><p>When a document is indexed, its synthetic <code>_id</code> is added to the bloom filter. When a new document arrives, we first check the bloom filter. If the bloom filter says <em>no</em>, we know for certain that no document with this <code>_id</code> exists and we can proceed with indexing immediately. If the bloom filter says <em>maybe yes</em>, we fall back to the more expensive verification using the <code>_tsid</code> and <code>@timestamp</code> doc values.</p><h3>Synthetic _id indexing workflow: step by step</h3><p>Let&#x27;s walk through what happens when a document is indexed into a time-series index with synthetic <code>_id</code> enabled:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/61ef279f09c5447009d3695f154129fba6fe510d-1048x1462.png" style="width: 100%;" /><ol><li><strong>Compute the synthetic <code>_id</code></strong>: Elasticsearch calculates <code>_id</code> as a combination of <code>_tsid || @timestamp</code>.</li><li><strong>Check the live version map</strong>: Like today, we first check an in-memory map of recently indexed documents. If the document is present in this map, we can handle the duplicate immediately.</li><li><strong>Filter segments by timestamp</strong>: Time-series indices are sorted by <code>_tsid</code> and <code>@timestamp</code>. We can skip any segment whose timestamp range does not overlap with the incoming document&#x27;s timestamp.</li><li><strong>Check the bloom filter</strong>: For each candidate segment, we test whether the <code>_id</code> might exist using the bloom filter.</li><li><strong>Verify if needed</strong>: If the bloom filter returns a positive result, we look up the document using the <code>_tsid</code> and <code>@timestamp</code> doc values. Since documents are sorted by these fields, this lookup is efficient.</li><li><strong>Index the document</strong>: If no existing version is found, the document is indexed. The <code>_id</code> is added to the segment&#x27;s bloom filter, but no inverted index is built and the field value is never stored.</li></ol><p>In the common case where new data arrives with recent timestamps, step 3 eliminates most segments from consideration, and step 4 quickly confirms that the document is new. The expensive verification in step 5 only happens on bloom filter false positives, which are expected to be rare.</p><h3>Bloom filter false positive rate: how Elasticsearch keeps it low</h3><p>One challenge with bloom-filter-based deduplication is controlling the false positive rate without sacrificing the storage efficiency we were after. To size bloom filters effectively, we consider the number of data points in each segment and target both a low false positive rate and a bit set saturation below 50%.</p><p>The saturation target serves a specific purpose: When segments are merged, we OR the bit sets rather than rebuilding bloom filters from scratch. This makes merges fast but means the false positive rate converges toward 100% as segments are merged repeatedly. Keeping saturation below 50% before merging buys headroom, delaying that convergence.</p><p>The low false positive rate target is justified by access patterns: Recent segments are checked far more often than older ones, since we prune the search space based on data point timestamps. Older, heavily merged segments with degraded bloom filters are unlikely to be checked.</p><h2>Synthetic _id performance benchmarks: indexing and storage</h2><p>We ran extensive benchmarks to validate our implementation.</p><h3>Indexing throughput</h3><p>A core goal of this effort was to match or improve on existing indexing throughput. In principle, the new approach does less work: Building an inverted index for <code>_id</code> requires hashing each value, building and maintaining complex data structures in memory, and flushing them to disk. These structures must also be reconstructed during segment merges, adding CPU and I/O overhead in high-throughput use cases.</p><p>Building a bloom filter isn&#x27;t free (we still hash each value), but the memory footprint is smaller and there are no complex data structures to maintain or flush. The bloom filter is also cheap to merge: When possible, we simply OR the bit sets together rather than rebuilding from scratch.</p><p>The main cost of synthetic <code>_id</code> comes from verifying potential duplicates using doc values. However, this cost is mitigated by two factors: First, bloom filter false positives are rare, so most documents skip this step entirely. Second, time-series indices are sorted by <code>_tsid</code> and <code>@timestamp</code>, which means doc value lookups can skip large blocks of nonmatching documents efficiently.</p><p>In practice, that&#x27;s exactly what we observed. Even accounting for the extra seeks needed to verify matches against the tsid and timestamp when a bloom filter returns a positive, throughput came out comparable or better than before. The savings from not building and merging the inverted index outweigh the occasional cost of a false positive check, as confirmed by our <a href="https://elasticsearch-benchmark-analytics.elastic.co/app/dashboards#/view/f7e091a0-1db1-11ed-920a-3b1141502d24?_g=(refreshInterval:(pause:!t,value:60000),time:(from:&#x27;2026-03-16T00:00:00.000Z&#x27;,to:&#x27;2026-03-19T23:30:00.000Z&#x27;))&amp;_a=(viewMode:view)">nightly benchmarks</a>:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/32b5b068ef6dd7cd512f12dc9d50d8769f2930d4-1999x509.png" style="width: 100%;" /><h3>Storage savings</h3><p>In our benchmarks with OTel metrics, synthetic <code>_id</code> reduced storage by approximately 5 bytes per data point. For a dataset where documents average 25 bytes per data point, this represents a 20% reduction in storage from this single optimization alone.</p><p>These results were soon confirmed by our <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/tsdb/nightly/default/90d">nightly benchmarks</a>.The chart below shows the storage footprint reduction over time as we enabled the synthetic <code>_id</code> feature on March 19, 2026:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0c0ac1f2c9716a6f99b6e055535db0def77bf930-1898x956.png" style="width: 100%;" /><p>Our standard time series database (TSDB) benchmark showed a reduction from 2.5 GiB to 1.9 GiB (24%). Similarly the time-series downsampling benchmark showed a comparable reduction from 3 GiB to 2.3 GiB (23%).</p><p>Another benchmark, more focused on metrics, <a href="https://elasticsearch-benchmark-analytics.elastic.co/app/dashboards#/view/37270832-cd2d-4ea7-8222-e61e8ad742a3?_g=(refreshInterval:(pause:!t,value:60000),time:(from:&#x27;2026-03-16T00:00:00.000Z&#x27;,to:&#x27;2026-03-19T23:30:00.000Z&#x27;))&amp;_a=(viewMode:view)">showed an even better reduction</a>, from 3.0 GiB to 2.0 GiB (34%):</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2a243db0f3ad95e761fb334ffd6fea47f7787eee-1999x447.png" style="width: 100%;" /><h2>API compatibility</h2><p>An important design goal was maintaining compatibility with existing Elasticsearch APIs. With synthetic <code>_id</code>, all document APIs continue to work as expected: Bulk, Get, Update, Delete, Reindex, and Update/Delete by Query. This compatibility layer also limited the blast radius of the change, ensuring any issues would be contained to the internal implementation.</p><p>When the <code>_id</code> isn’t provided in an API request, Elasticsearch computes it from the <code>_tsid</code> and <code>@timestamp</code> fields. To check if the document already exists, it first queries the bloom filter and, if needed, falls back to doc values. The <code>_id</code> is also synthesized on demand from doc values when returning documents in search results or API responses.</p><p>One case that requires special handling is searching or filtering by <code>_id</code> prefix or pattern. Such queries require scanning many documents to find matching documents, and while this works correctly, it incurs a performance penalty compared to a direct <code>_id</code> lookup. We don’t expect this use case to be common for time-series indices though.</p><h2>Elasticsearch 9.4 and Elastic Cloud Serverless availability</h2><p>The synthetic <code>_id</code> feature will be released in Elasticsearch 9.4.0 and is already available on <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a>.</p><p>No configuration is required: The feature is enabled by default, and newly created time-series indices (including those created on datastream rollover) will automatically benefit from this optimization. Existing time-series indices created before 9.4 will continue to create inverted indices for the <code>_id</code> field.</p><p>We expect synthetic <code>_id</code> to perform well across all time-series use cases. However, in some very specific, update-heavy use cases, if you encounter performance issues, the feature can be disabled by setting <code>index.mapping.synthetic_id</code> to <code>false</code> for new indices.</p><h2>Summary: synthetic _id storage and performance gains</h2><p>In this article, we’ve presented how synthetic <code>_id</code> eliminates the storage and compute overhead of document identifiers in time-series indices. By computing <code>_id</code> on the fly from <code>_tsid</code> and <code>@timestamp</code>, and using a bloom filter for deduplication, we achieve comparable or better indexing performance with up to 34% reduction in storage footprint while maintaining full API compatibility. For users running large-scale time-series workloads, this translates directly into lower infrastructure costs.</p><h2>Roadmap: what comes after synthetic _id</h2><p>Synthetic <code>_id</code> is part of a broader effort to reduce storage overhead in Elasticsearch.</p><ul><li><strong>Sequence number trimming:</strong> Every document carries a sequence number for replication and concurrency control. For append-only time-series data, these become redundant after segments are merged. Elasticsearch 9.4 now trims them during merges to reclaim even more storage: We&#x27;ll cover this optimization in detail in an upcoming blog post.</li><li><strong>Synthetic _id beyond time-series:</strong> We’re exploring how to bring synthetic <code>_id</code> to regular indices by letting users declare which fields uniquely identify their documents and configuring index sorting on those fields to enable efficient lookups.</li></ul><p>Stay tuned!</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Small model, big benchmarks: how Jina-VLM beat the competition at 2.4B and what ICLR told us is coming next]]></title>
            <description><![CDATA[Jina-VLM is a 2.4B open multilingual VLM leading VQA benchmarks across 29 languages. Plus: five days of ICLR 2026 takeaways on RLVR, sparse embeddings and retrieval.]]></description>
            <link>https://www.elastic.co/search-labs/blog/jina-vlm-multilingual-vqa-iclr-2026</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-vlm-multilingual-vqa-iclr-2026</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Andreas Koukounas, Georgios Mastrapas, Scott Martens]]></dc:creator>
            <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3668b433275c8d75fbc0729346123ea87307d012-1999x1125.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Jina-VLM is a 2.4B-parameter vision-language model that currently leads open 2B-scale models on multilingual VQA benchmarks (MMMB and Multilingual MMBench) across 29 languages. It pairs a SigLIP2 vision encoder with a Qwen3 language decoder and handles arbitrary-resolution inputs without sacrificing token efficiency. Jina by Elastic engineers presented the model at the DATA-FM workshop at ICLR 2026 in Rio. This post covers the architecture, the training approach and what five days at the conference told us about where retrieval, embeddings and reasoning are headed.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c44a895e29152640ee1fe82fb2b5a9ddeb82bb8b-1999x1125.png" style="width: 100%;" /><p><a href="https://jina.ai/models/jina-vlm/"><strong>jina-vlm</strong></a> is a 2.4B-parameter vision-language model that pairs a SigLIP2 vision encoder with a Qwen3 language decoder, using attention pooling over image tiles for token-efficient handling of arbitrary-resolution inputs. Beyond the model itself, the paper&#x27;s main contribution is its “leave-one-out” ablative data-mixture: By removing one task, domain, modality, or language category at a time during training, you can figure out which slices of data are significant or redundant, and whether learning in one domain transfers to others. The result is a compact model that, despite its size, achieves state-of-the-art multilingual VQA performance.</p><p>Rio delivered everything you&#x27;d hope for: warm, sunny beach weather, the easy walk between Copacabana and Ipanema, the view from Christ the Redeemer, the colors of Escadaria Selarón. A welcome contrast to a still-chilly European spring.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/537af5b77e73da88cfdaccda6c6a42c2541dd054-1100x1100.png" style="width: 100%;" /><h2>What was trending at ICLR 2026: RLVR, test-time compute and retrieval</h2><p>Conferences like ICLR give everyone a chance to take the field’s pulse and find out what’s hot, what’s not, and what’s coming up. After a few days of walking the aisles at the poster sessions and dropping in on oral sessions, you start to get a sense for things. You start to see the same words on poster after poster, and you notice which sessions are the most crowded.</p><p>Here are a few things we picked up on:</p><p></p><p><strong>Reinforcement Learning with Verifiable Rewards (RLVR) is now the dominant paradigm for post-training refinement. </strong>Almost every reasoning-focused poster we stopped at was using some form of <em>Group Relative Policy Optimization</em> (GRPO) for math correctness, code execution, and formal-logic checks, rather than <em>Reinforcement Learning from Human Feedback</em> (RLHF). <em>Direct Preference Optimization</em> (DPO) fine-tuning, which felt like the default a year ago, was conspicuously rare. It makes sense: If you can use code to check for correctness, you no longer need to get annotated data and the training loop goes much faster.</p><p></p><p><strong>Test-time compute has stopped being a curiosity and become a design problem.</strong> <em>Test-time compute</em> – the time a system spends generating a response – is an increasingly important study variable. Papers now measure it as part of their experimental setup and developers try to optimize for it. Models are now built with the expectation that inference will be expensive and clever, not just a single forward pass through a neural network.</p><p></p><p><strong>Vision-Language Models (VLMs) are everywhere, and Vision-Language-Action models (VLAs) are not far behind</strong>. A big chunk of the conference was about how to make multimodal AI work better, like better tokenization for images, better positional encodings for non-text media, and more efficient ways to compress visual information before it overwhelms your model. Vision-Language-Action models that extend multimodal AI recipes to robotics and embodied agents are no longer niche research. They brought in the crowds at their presentations and hosted vibrant debates.</p><p></p><p><strong>Reports of the death of State-Space Models (SSMs) have been greatly exaggerated</strong>. Attention models still dominate AI, but Mamba, SSM variants and recurrent neural networks still draw attention and research, both as full replacements for Transformers and as components inside hybrid attention-based stacks. Whether they&#x27;ll ever genuinely displace Transformers is an open question, but the line of research is alive and well.</p><p></p><p><strong>Agentic AI safety is taken very seriously.</strong> A lot of papers and presentations discussed problems like machine unlearning and jailbreaking, and some of the most interesting work was on prompt injection through agentic tool use, like when a model dutifully follows instructions hidden in a webpage or an API response it just fetched. A repeated, slightly unsettling observation: models that follow instructions <em>better </em>tend to be <em>more</em> vulnerable to this kind of attack, not less. This capability-vulnerability tension is going to define a lot of the next few years of safety research.</p><p></p><p><strong>Hallucination and factuality are increasingly framed as retrieval problems</strong>. Several talks made that point explicitly: A generative model that has to invent facts will inevitably hallucinate them, while a model that retrieves information can ground its responses in verifiable ways. That framing is, of course, exactly the bet that search AI engineers have been making all along.</p><p></p><h2>ICLR 2026 invited talks: hidden universe imaging and open AI development</h2><p>Two of the invited talks stood out to us, albeit for very different reasons:</p><h3><a href="https://iclr.cc/virtual/2026/invited-talk/10020868">Images of the Hidden Universe</a></h3><p></p><p><a href="https://en.wikipedia.org/wiki/Katie_Bouman">Katie Bouman</a> presented a tour of how physics, prior knowledge, and machine learning combine to reconstruct information that the universe never gives us directly, like the silhouettes of supermassive black holes and the invisible dark matter structures. She walked us through the <a href="https://eventhorizontelescope.org/blog/astronomers-reveal-first-image-black-hole-heart-our-galaxy">Event Horizon Telescope&#x27;s imaging of M87 and Sagittarius A</a>, building images up from indirect and incomplete radio measurements, and then extended the same machinery to mapping dark matter through gravitational lensing.</p><p><a href="https://iclr.cc/virtual/2026/invited-talk/10020868">This talk</a> was a useful reminder of why machine learning matters outside the LLM bubble. The more you already know, the more you can learn from a little bit more information. This principle generalizes beyond astronomy to knowledge in general, and to machine learning in particular. Any decision system that uses sparse, noisy observations is confronted with it.</p><p>_____________________________________________________________________________________</p><h3><a href="https://iclr.cc/virtual/2026/invited-talk/10020867">Marin: Open Development of Frontier AI</a></h3><p></p><p><a href="https://en.wikipedia.org/wiki/Percy_Liang">Percy Liang</a> opened his presentation with a blunt observation: As AI capabilities skyrocket, openness plummets. His response is <a href="https://marin.community/"><strong>Marin</strong></a>, a platform for community-driven AI research where every experiment is open, every suggestion or discussion is on public fora, and anyone can review or rerun a result.</p><p>What makes Marin interesting isn&#x27;t just creating open weight models - plenty of projects do that - but creating an <em>open process </em>for making models. Project pre-registration, peer review, and reproducibility have long been part of the natural sciences, and Marin attempts to maintain that tradition for AI. Model training is treated as a matter of public scientific record.</p><p><a href="https://iclr.cc/virtual/2026/invited-talk/10020867">The talk</a> presented concrete scientific results from this approach (optimizer findings and scaling-law results), suggesting that community-scale science isn&#x27;t just an aspiration but a workable methodology.</p><p>_____________________________________________________________________________________</p><p>Bouman and Liang made a pleasingly complementary pair: one a reminder of how much ML has to offer the world outside ML, the other a challenge to how the field organizes itself.</p><p></p><h2>ICLR 2026 research highlights: embedding models, retrievers and sparse representations</h2><p>We attended many oral presentations and poster sessions. The papers below stood out because of their potential to impact how we make and use embedding models.</p><h3>Rethinking pretraining for representations</h3><p>Decoder-only models have dominated the LLM leaderboards for years, but one paper makes a case for encoder models.</p><p><a href="https://arxiv.org/abs/2507.11412"><em>Seq vs Seq: An Open Suite of Paired Encoders and Decoders</em></a> does a repeatable, open-data, architecture-controlled comparison of encoder-only and decoder-only models trained identically. They used the same data, same architecture, same training recipe, and differed only in their training paradigms: <em>Bidirectional Masked Language Modeling</em> (MLM), typically associated with encoders, vs. <em>Causal Language Modeling</em> (CLM), usually used in decoders. Their results confirm prior findings that encoders excel at classification and retrieval while decoders excel at generation. A key finding is that cross-objective continuous pretraining does not close the performance gap between the encoders and decoders. A 400M parameter encoder beats a 1B parameter decoder in classification and retrieval, and vice versa for generative tasks. All artifacts including data, checkpoints, and code are open-sourced.</p><p>Their study delivers a definitive empirical finding for the AI community: Encoder-only pretraining is substantially more efficient for classification and retrieval tasks than adapting decoders to act like encoders, even with post-training on high-quality data. This challenges the recent trend of adapting large decoder LLMs (like LLM2Vec) for embedding tasks. Dedicated encoder pretraining from scratch remains the most reliable path to strong retrieval performance. Additionally, the public release of 200+ checkpoints with batch-ordered training data makes their work an invaluable resource for studying how retrieval-relevant representations emerge during training and how they scale with parameter count and tokens.</p><h3>New paradigms for training retrievers and embedders</h3><p><a href="https://arxiv.org/abs/2506.16552"><em>Revela: Dense Retriever Learning via Language Modeling</em></a> reframes dense retriever training as a language modeling problem. Rather than using supervised training with query-document pairs, it trains a retriever model jointly with a language model by conditioning next-token prediction on all the other documents in the batch. This innovative <em>in-batch attention mechanism</em> modifies the model’s Transformer blocks by injecting the similarity scores of documents in each batch into the cross-document attention weights. Training is done on raw text, without query-document pairs, hard negatives, or synthetic data generation. The resulting 3B parameter model outperforms E5-Mistral-7B-Instruct (with 7B parameters) as well as proprietary closed-weight embedding models like OpenAI, Cohere, and Voyage. On retrieval benchmarks, it matches E5 despite using roughly 1000 times less training data and approximately 10 times less compute.</p><p>This demonstrates that next-token prediction can still serve as an effective training objective for high-quality dense retrieval AI. This is important because plain text data – what you need for next-token prediction – is widespread and inexpensive and this paper shows that it’s all you need to train competitive embedding models.</p><p><em><a href="https://arxiv.org/abs/2509.24291">Let LLMs Speak Embedding Languages: Generative Text Embeddings via Iterative Contrastive Refinement</a> </em>advances the proposition that LLMs should learn to &quot;speak an embedding language,&quot; i.e., generate sequences of “soft tokens” optimized for semantic representation rather than human readability. They outline innovative loss functions and objectives in support of this goal, and show that the resulting models have very competitive performance, while generating only a handful of additional tokens.They also show that generating more tokens at inference time steadily improves embedding quality in a way analogous to chain-of-thought scaling in reasoning LLMs. KV-caching reduces the computational overhead of the generation process to within 1.1 times that of standard single-pass embedding models. This approach represents a new paradigm for representation learning, complementary to encoder-only and single-pass approaches.</p><p><a href="https://arxiv.org/abs/2603.03389"><em>Towards Improved Sentence Representations using Token Graphs</em></a> frames the problem of generating embeddings for sentences from token-level representations as a relational learning problem rather than a compression problem. Instead of pooling tokens, it uses a supplementary neural network that processes a dynamically constructed graph made from output token similarities. This added network is compact, with very few trainable parameters, and can be implemented without doing any additional training on the main language model. The result is competitive with current frontier models.</p><p>This approach can be dropped into any language model at a very reasonable additional training cost, giving it immediate practical significance. Furthermore, the resulting models hold up well in the presence of noise, a known problem, especially for long-context models.</p><h3>Sparse and ultra-efficient embeddings</h3><p><a href="https://arxiv.org/abs/2505.12260"><em>LightRetriever: A LLM-based Text Retrieval Architecture with Extremely Faster Query Inference</em></a> introduces an asymmetric dual-encoder architecture for embeddings-based retrieval in which the query encoder is much smaller and faster than the document one. The key insight is that while document embeddings benefit from the modeling power of a large language model, query embeddings are much less demanding. During training, they propose to learn per-token query embeddings, then, at query time, those embeddings are retrieved and averaged to produce a full query embedding. Documents must still be encoded at storage time using a potentially large encoder, but there is no need to invoke an embedding model at query time at all. The result retains approximately 95% of the performance of the query encoder it replaced. This has immediate implications for computational constrained, time-sensitive, or resource-efficient text information retrieval systems.</p><p><a href="https://arxiv.org/abs/2602.05735"><em>CSRv2: Unlocking Ultra-Sparse Embeddings</em></a> addresses the computational cost of embeddings-based retrieval using dense, high-dimensional vectors. It tackles that cost with <em>Contrastive Sparse Representation</em> (CSR), which maps dense vectors into a much higher-dimensional space where only a few vector entries are non-zero, so that search can use highly efficient sparse-vector search techniques like inverted-indexes.</p><p>CSR approaches tend to break down when the number of dimensions with non-zero values becomes very low. This paper addresses this problem with an innovative training approach that makes ultra-sparse representations viable, opening up the possibility of much faster, less computationally demanding retrieval without loss of accuracy.</p><h3>Multi-step and multimodal retrieval</h3><p><a href="https://arxiv.org/abs/2511.07328"><em>Q-RAG: Long-Context Multi-Step Retrieval via Value-Based Embedder Training</em></a> frames the problem of multi-step retrieval-augmented generation (RAG) in terms of optimizing the embeddings used in RAG search. RAG systems are typically based on a single retrieval step: Input to an LLM becomes a query to a vector store, and a selection of the results are presented to the LLM as a basis for composing a response. However, agentic approaches that involve multi-step interactions between the LLM and vector store can improve RAG performance significantly, especially for large input contexts that might contain millions of tokens. This paper seeks to optimize the embedding model used for retrieval to better support this usage scenario with <em>Reinforcement Learning with Verifiable Rewards</em> (RLVR).</p><p>This paper is one of the more elegant intersections of two of the conference&#x27;s biggest themes — RLVR and retrieval — and it gives a glimpse of what retrieval looks like when it has to operate inside an agentic loop, not just before one.</p><h3>Foundations and evaluation</h3><p><a href="https://arxiv.org/abs/2510.10062"><em>HUME: Measuring the Human-Model Performance Gap in Text Embedding Tasks</em></a> undertakes the unusual task of systematically measuring human performance on the <em>Massive Text Embedding Benchmark</em> (MTEB), the most widely used benchmark for embeddings-based information retrieval. Using 16 datasets in 5 languages, they find that average human retrieval accuracy is 77.6%, while the best embedding models currently score over 80%. However, this performance gap is uneven. Models may outperform humans on standard tasks but fall apart when faced with low-resource languages, where human intuition still holds a significant lead.</p><p>This paper also shows that &quot;superhuman&quot; scores on low-agreement tasks are mostly artifacts of fitting noise, not genuine capability. This underlines the problem of our current suite of embedding benchmarks: New models are not improving benchmark performance very much. To make progress, we need new, harder challenges and a total rethink of how we evaluate models.</p><h3>Training dynamics for foundation models</h3><p><a href="https://arxiv.org/abs/2511.18903"><em>How Learning Rate Decay Wastes Your Best Data in Curriculum-Based LLM Pretraining</em></a> identifies a significant but underexplored problem in AI model training. Large training sets can create a problem with models forgetting things that they’ve learned as they’re presented with more data. Curriculum-based pretraining — sorting data from low to high quality — should help, but in practice the results have been disappointing. The reason, the authors argue, is that the model encounters the highest-quality data late in the training schedule when the learning rate is at its lowest. Its gradient contribution is therefore greatly reduced. They confirm that hypothesis empirically by showing that curriculum training significantly beats random shuffling if training uses a constant learning rate.</p><p>They propose two simple strategies to fix this: Let the learning rate decay more slowly, or replace learning rate decay with weight-averaging over the multiple final checkpoints. Combining the two yields a 1.64% average benchmark improvement over standard practices with no additional data refinement. The broader message - that data composition and optimization schedule need to be co-designed - applies well beyond pretraining, and is a useful frame for embedding training too.</p><h2>What ICLR 2026 means for retrieval and embedding research</h2><p>Science has always been conducted through print and publication, but in-person conferences are still the only way to put people together in a room. Over five days, we met a steady stream of researchers from very different backgrounds — academia and industry, large labs and small startups, half a dozen countries — and conversations ranged from research trends to philosophical questions that have haunted AI from the beginning. Are LLMs really reasoning, or are they doing something more like very high-dimensional memorization with interpolation? Where exactly is the line, and does it matter for what we can build on top of them?</p><p>These conversations rarely produce answers, but they sharpen the questions, which is most of what good research is.</p><p>For the information retrieval work we do at Jina by Elastic, the future looks bright. Retrieval, long relegated to merely applied research, is increasingly recognized as the engine for keeping language models grounded. Better encoders, better embedding training paradigms, sparser representations, and retrieval that operates at the core of reasoning loops – these things matter to us all. What we saw and heard at ICLR 2026 convinces us that this is where a meaningful share of the next round of progress will come from.</p><p>We&#x27;re already looking forward to seeing where the field is next year.</p><p><br/></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Cutting Elasticsearch DiskBBQ query quantization time by 5x]]></title>
            <description><![CDATA[See how asymmetric quantization cuts DiskBBQ query quantization overhead from about 20% to 4% with little recall impact.]]></description>
            <link>https://www.elastic.co/search-labs/blog/diskbbq-asymmetric-query-quantization</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/diskbbq-asymmetric-query-quantization</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Benjamin Trent, Thomas Veasey]]></dc:creator>
            <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/265ce999fd38f21943d91e29c0bc49ab01f0196d-1999x1546.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Asymmetric quantization cuts the time Elasticsearch DiskBBQ spends quantizing queries by 5x. We discovered that too much time was spent quantizing queries. DiskBBQ started off quantizing queries with the same centroids as the indexed documents. However, we can make this cheaper by quantizing the queries with coarser-grained centroids. This improves query latency with very little observed recall impact in our tests.</p><h2>How DiskBBQ uses two centroid tiers for asymmetric quantization</h2><p>DiskBBQ now uses two centroid tiers (fine-grained document centroids and coarser query centroids) so queries are quantized once per parent centroid instead of once per document centroid.</p><p>The old mental model is &quot;one centroid does everything for a posting list.&quot; The new model splits responsibilities:</p><ul><li>Document centroids (fine-grained): Still used for posting-list structure and document centering.</li><li>Query centroids (coarser): A parent centroid reused across multiple document centroids.</li></ul><p>So instead of quantizing the query independently for every document centroid we visit, we quantize per parent centroid and reuse that work across all of its children. Since we were already using two-tier clustering logic as the index size grew, it was a natural fit. We can reuse the work we already do during querying.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1fcb6cfb5b0186ea976e3f3942b043d3c9f33a6a-1378x1560.png" style="max-width:100%; margin-bottom:1em;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4980bc720abedc4626bf48bbb3d2f6bec06eaff4-1286x1560.png" style="max-width:100%; margin-bottom:1em;" /><p>These images are a simple representation of our goal: Quantizing per centroid gives us overhead per centroid. Let’s get rid of it!</p><p>The goal is to significantly reduce the number of times we actually need to quantize a given query.</p><h2>The math behind asymmetric BBQ in Elasticsearch</h2><p>To center the data prior to computing quantized query and document vectors, $q$ and $d$, we rewrite the dot product $q^td$ as $(m+q-m)^t(m+d-m)$ and expand. We can perform exactly the same operation but using different centroids for the query vector $q$ and document vector $d$. Specifically,</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f395654cf3cc965254dfc8d7e66b57743d29cf41-1566x206.png" style="width: 100%;" /><p>As for standard Better Binary Quantization (BBQ), we quantize $q-m_q$ and $d-m_d$ in order to estimate the per (document, query) pair component of the dot product. The quantities $m_d ^tq$&nbsp; and $m_q ^t(d-m_d)$ are scalars so just two extra additions per dot product we compute. For $m_d ^tq$, we compute naturally when finding the nearest centroid. For $m_q ^t(d-m_d)$, this can be stored with the quantized document vectors, which are just 4 bytes overhead. Below, we’ll discuss how to manage the other term on the fly.</p><h3>Asymmetric BBQ in DiskBBQ</h3><p>We cluster the document centroids (using k-means, for example) into $k_q&lt;k_d$ clusters, for $k_q$ and $k_d$ the query and document centroid count, respectively. This means there’s a many-to-one mapping from document centroids to query centroids. We’ll denote the document centroids by their index $i ∈ [k_d]$ and define this mapping to the query centroids as $j : [k_d]$→$[k_q]$.</p><p>Since there’s a unique query centroid for each document centroid, we only need to cache one value for $m_q^t(d-m_d)$ per quantized document vector, that is, for each document vector $d_k$ in posting list $i$, we need to cache $m_j(_i)^t(y_k-m_i)$ with the quantized document vector.</p><p>When we come to compute the dot products between a query and the document vectors in a cluster, we look up the quantized query vector corresponding to $q-m_j(_i)$ and we compute $m_i^tq$ once and use it to process the whole posting list. The quantization process is significantly more expensive than computing the dot product, so this is a big net win.</p><p>The $(q-m_q)^t(d-m_d)$ term is estimated using the usual <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-optimization">BBQ machinery</a>, that is, these vectors will be quantized and the dot product value estimated from the quantized vectors. Then we can use (1) to compute the final dot product estimate. Notice that this means we only need to quantize the query at most $k_q$ times. Furthermore, we typically visit many centroids from the same parent centroid in a search because they’re close to one another.</p><h3>Euclidean distance corrections for asymmetric quantization</h3><p>For Euclidean, we can write $||q-d ||^2=||q||^2+||d||^2-2q^td$ and treat the $q^td$ term exactly as above. In fact, there’s a slightly nicer form. Substituting, we have that:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8faf7cd146c3221f3a3929e07286ceb82ac95a04-1598x122.png" style="width: 100%;" /><p>We can rewrite this as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/91d700d38d0e7842f2efef5d7778a6a34142c384-1172x362.png" style="width: 100%;" /><p>The corrective terms are the norm of query vector $q$ minus the document centroid $m_d$, the norm of the document vector $d$ minus the query centroid $m_q$, and the norm of the difference of query and document centroids. As before, $||d-m_q||^2-||m_d-m_q||^2$ can be stored as a single float with each document.</p><h2>What changed in DiskBBQ indexing and scoring</h2><p>At indexing/merge time, centroids can be clustered into parent groups when centroid count is large enough. Posting metadata moved from &quot;centroid ordinal + centroid score&quot; to a shape that explicitly carries query-centroid ordinal and document-centroid score. That decoupling is what lets scoring read documents and query centering from different places. For Euclidean, let’s break it down further by our mathematics above:</p><div class="katex">   =||q-m_d||^2+||d-m_q||^2-||m_d-m_q||^2</div><p>$||q-m_d||^2$ &lt;- This is the distance from a “query vector $q$” to “document centroid $m_d$”. We already gather this when we find the nearest centroids during querying. No new work.</p><p>$||d-m_q||^2$ &lt;- This is the distance from “document vector $d$” to “query centroid $m_q$”. However, recalling our <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-optimization">original quantization work</a>, this can simply replace a previously stored float value. No new storage is required.</p><p>$||m_d-m_q||^2$ &lt;- This is just the distance between query centroid $m_q$ and document centroid $m_d$. This is just a single extra floating point value per postings list.</p><p>The practical change for dot product spaces is even simpler; the only correction value change is $m_j(_i)^t(y_k-m_i)$ being stored instead of $y^tm_i$.</p><p>These changes don’t introduce new computation costs and marginally reduce storage costs because we no longer quantize queries with document centroids. Those raw centroids don’t need to be present with the posting lists.</p><p>One cost we did add is a small cache of quantized query values. This is to account for clustering edge cases. For example, it&#x27;s possible that query $q$ is very close to query centroid $qc_0 =\{dc_0, dc_1, dc_2\}$ but not quite as close as $qc_1 = \{dc_3, dc_4, dc_5\}$. That said, the actual nearest three document centroids could have a relative order: $[dc_0, dc_3, dc_1]$. So, to prevent the query from being quantized twice, we keep a limited cache of the most recent quantized values for a given query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d0df2be8b601fb46005667bfa81fc89b2fdaee48-1538x1092.png" style="width: 100%;" /><p>Here’s a visualization of the situation described above. In the typical iteration scenario, we don’t want to risk unnecessarily quantizing the query against the same query centroid multiple times.</p><h2>DiskBBQ asymmetric quantization: performance results</h2><p>The flame graphs below show a before and after comparison. Before, about 20% of the time was spent quantizing queries when we visited each cluster. After our adjustment, it dropped to about 4%.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a17c3618a29b83d4196088d8422a7ede6eba5c3e-1999x655.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bb47abf8a7c7da5f5e8990beaaf3319741abfc52-1999x661.png" style="width: 100%;" /><p>Of course, the bulk of the cost is still just scoring the vectors in each cluster. But every little bit helps.</p><p>Here’s a better view of the full end-to-end performance and recall. The data set was 1 million <a href="https://github.com/iai-group/DBpedia-Entity/">DBpedia</a> docs encoded with the <a href="https://huggingface.co/thenlper/gte-base">GTE-Base</a> model. Here, “sec” indicates the number of clusters per secondary (parent) cluster. Note that symmetric quantization is still impacted by the secondary cluster size as it also impacts the two-tier clustering indexing we do already.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/92ec17f1d4ca18a98d3c20b430af72cdf8d8be8a-1260x900.png" style="width: 100%;" /><p>However, the impact on our current index structure is still dominated by centroid scoring and scoring vectors in the cluster. Asymmetric quantization removes a frustratingly expensive part of our scoring overhead, but the impact isn’t dramatic given our current structure.</p><h2>What&#x27;s next for DiskBBQ quantization</h2><p>This simple piece of mathematics decouples our query quantization from our document quantization, giving us better storage efficiency and faster queries. This is in Elasticsearch Serverless now and will be in Elastic Stack version 9.4.0.</p><p>This now means that query quantization time isn’t a direct concern for future decisions. We can make larger index changes without worrying about the consistent overhead of quantization directly with document centroids.</p><p>This was a nerdy one. I hope you survived all the math (and that I copied it all down correctly). It’s always fun to be able to tackle complex problems with simple mathematics, and the results are actually positive in real use cases and data.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Kibana dashboards as code: GitOps, drift detection and Terraform for Kibana dashboards in Elastic 9.4]]></title>
            <description><![CDATA[Elastic 9.4 ships a typed Dashboards API and a native Terraform resource that bring drift detection, PR-reviewable diffs and git-based rollback to Kibana dashboards for the first time.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-dashboards-as-code-terraform-api</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboards-as-code-terraform-api</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Teresa Alvarez Soler, Omer Kushmaro, Devon Thomson]]></dc:creator>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b75eb6be84f117b6786938654c5ffd0297b6fd48-2048x968.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elastic 9.4 ships a purpose-built Kibana Dashboards API: five typed endpoints, validated schemas for twelve visualization types, and a new <code>elasticstack_kibana_dashboard</code> Terraform resource that brings drift detection, plan diffs, per-environment deployments and git-based rollback to Kibana dashboards. Define a dashboard in HCL, review changes in a pull request and roll back by reverting a commit. Here&#x27;s how it works.</p><h2>Why do dashboards need an API?</h2><p>The existing Saved Object API exports dashboards as a <strong>single stringified JSON blob</strong>: visualization state, internal references, UUIDs, and metadata all tangled together. Changing one field in a Kibana Lens chart means parsing 200 lines of internal state that was never meant to be read. There&#x27;s no clean way to diff it, review it in a pull request, or edit it programmatically.</p><p>That makes standard <strong>GitOps workflows</strong> impossible: no drift detection, no automated promotion across environments, no rollback to a known-good state without overwriting the whole document by hand.</p><p>For <strong>large language models (LLMs)</strong>, it&#x27;s not easy either. The Saved Object format is too complex and error-prone for language models to generate or modify reliably; a simpler, validated schema is a prerequisite for natural-language dashboard authoring, which is exactly how we&#x27;re <a href="https://www.elastic.co/search-labs/blog/ai-dashboard-generation-elastic-agent-kibana">using it in the Elastic AI Chat</a> and <a href="https://www.elastic.co/search-labs/blog/agent-skills-elastic">dashboard skills</a> for third-party tools integration.</p><h2>What the new Dashboards API provides</h2><p>The API works with your existing dashboards; it&#x27;s not limited to dashboards created through the new interface.</p><p>Five endpoints cover the full lifecycle: </p><ul><li>Create a new dashboard (<code>POST /api/dashboards</code>).</li><li>Read an existing dashboard (<code>GET /api/dashboards/{id}</code>).</li><li>Update an existing dashboard or create a new one if the ID doesn’t exist (<a href="https://www.jefferydurand.com/api/architecture/design/2014/12/03/the-importance-of-upsert.html">upsert</a>) (<code>PUT /api/dashboards/{id}</code>).</li><li>Delete a dashboard (<code>DELETE /api/dashboards/{id}</code>).</li><li>List all existing dashboards with pagination and query parameters (<code>GET /api/dashboards</code>).</li></ul><p>What makes it different from the legacy Saved Object API:</p><ul><li><strong>Typed schemas for each panel.</strong> Every visualization type (XY chart, metric, pie, gauge, heatmap, data table, treemap, mosaic, waffle, tag cloud, region map) has its own validated schema with sensible defaults. Markdown panels, controls, and drilldowns are also supported.</li><li><strong>ES|QL and data view support.</strong> Each visualization can be backed by a data view or an Elasticsearch Query Language (ES|QL) query; the schema cleanly separates the two modes.</li><li><strong>Dashboard-level filters.</strong> Condition filters (<code>is</code>, <code>is_one_of</code>, <code>range</code>, <code>exists</code>), group filters (<code>and/or</code> nesting), raw Query DSL filters, and spatial filters, all typed and defined at the dashboard level.</li><li><strong>Structural validation on write.</strong> POST and PUT validate your definition up front, so errors surface at write time rather than render time.</li><li><strong>Layout control. </strong>Panels specify grid position on a 48-column grid; dashboards can be organized into collapsible sections.</li><li><strong>Library panels. </strong>Panels can exist only as part of the dashboard, or they can be saved in the library if they need to be reused in multiple dashboards. The API supports both types of panels.</li></ul><h2>How the Kibana Dashboards API was built: the transforms layer</h2><p>At the outset of this project, it was quickly discovered that the biggest culprit to the unusability of the Saved Objects APIs was the stringification of the dashboard contents. The dashboard’s entire panels array, for instance, was stored in one key, called “panelsJSON,” which could be thousands upon thousands of characters long, all stuffed into one line. We lovingly dubbed these fields “JSON Bags.”</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/120d779e205c308aa321fb1f074330823a78d422-1999x412.png" style="width: 100%;" /><p>If we could stop the stringification process for these keys and instead store the JSON directly in Elasticsearch, all of our problems would be solved. Unfortunately, after we de-stringified the JSON and truly saw what was inside, we found a jumbled mess.</p><p>Kibana has stored user-created UI content since day one. This content was stored with <strong>exactly one</strong> purpose in mind: allowing the UI to restore that content later. Because the actual shape of the storage was inconsequential, it was stored in Elasticsearch as a direct snapshot of the UI state. UI state is optimized for the specific UI that created it and is not meant to be looked at. Structures like deep nesting, objects with randomly generated IDs, parallel arrays, and internal references between different portions of the state are helpful in UI state but detrimental to readability.</p><p>Here, we faced a choice:</p><ul><li>Leave the existing UI code in place and build a new alternative API schema for the public endpoints that bypasses the existing dashboard system and allows for <em>some</em> of its most-used functionality.</li></ul><p>OR</p><ul><li>Inject a new API schema into the existing dashboard system and build middleware to allow the same API schema to underpin both the UI and the public endpoints.</li></ul><p>We chose the second option and set about the monumental task of crystalizing and redefining every key in every panel of the jumbled mess of existing UI state into something we would be comfortable publishing in a formal API schema. This process was completed over a long time frame, by multiple teams, in live code. Backward compatibility was of utmost importance as, throughout the entire process, dashboards were being saved and loaded by our users every day.</p><p>The middleware that made this possible is called the <em>transforms layer;</em> it’s responsible for translating back and forth between the legacy, stringified, state shape and the cleaner API shape. Transform functions are registered for each panel that a dashboard can host. The teams that owned those panels would iteratively improve on the schema and make matching changes to the UI with each PR to ensure that the UI continues to operate correctly.</p><p>As each panel registers its transform functions with the dashboard, it also has the ability to register a schema that becomes part of the contract of the public Dashboards API. Schemas registered like this must be fully complete to reduce the risk of breaking changes to the API in the future. If a panel type doesn’t have a schema registered, it will be stripped from public API responses. This allows us to iteratively include more content in the public APIs into tech preview and beyond, while ensuring that users who rely on the API can trust that the shape will not change.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4ca54754f78a2bc9226f65b7288231066d842d5b-1999x1124.png" style="width: 100%;" /><h2>Using the Kibana Dashboards API: a walkthrough</h2><p>The Kibana Dashboards API is available in Elastic 9.4 and works with existing dashboards.</p><p>The best place to get started is the <a href="https://dashboardsapispec.kibana.dev/dashboards.html">Dashboards API documentation</a>, where you’ll find all schema definitions and examples.</p><p>The following example uses the Kibana sample logs data to walk through the API; not a realistic GitOps workflow, but a simple way to see how it works.</p><ul><li>Open a dashboard, and click <strong>Export JSON</strong> from the top menu. This dashboard contains one control and two sections: one with two metrics and another one with two time series.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a2cf8a7023f551f4724a29e35a35d12709e16044-1999x896.png" style="width: 100%;" /><ul><li>You’ll see the JSON of this dashboard displayed in a flyout. Now click <strong>Open in Console</strong>.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5daba64c0223fc5ab7805dc159812efbe26650b-1999x945.png" style="width: 100%;" /><ul><li>You’ll be redirected to the Developer tools, and a new POST request is automatically prefilled for you with the HTTP request.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4b18278696f7cfa52c512bed767eff37495d3e32-1999x934.png" style="width: 100%;" /><ul><li>Include the space ID in the POST request URL.</li></ul><pre><code>POST kbn:/s/production/api/dashboards?</code></pre><ul><li>Go to the destination space, and see how the dashboard has been created.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fdf56cdb1076dec4f2d3e78c5d1bbe66111cdaae-1999x957.png" style="width: 100%;" /><h2>Terraform support: Dashboards as native HCL</h2><p>The <a href="https://github.com/elastic/terraform-provider-elasticstack">Elastic Stack Terraform provider</a> ships a new <code>elasticstack_kibana_dashboard</code> resource that maps the Dashboards API to native Terraform HCL. This means you get <code>terraform plan</code>, <code>terraform apply</code>, drift detection, import, and all the standard Terraform lifecycle operations for dashboards.</p><p>The provider doesn&#x27;t cover every API capability yet; it focuses on the features that matter most for GitOps workflows.</p><h2>Terraform example</h2><h3>Example: a Kibana dashboard in Terraform HCL</h3><p>Start with the basics: a dashboard with a time series chart tracking request counts, a metric panel showing a KPI, and a markdown panel for context.</p><pre><code>resource "elasticstack_kibana_dashboard" "service_overview" {
  title       = "Service Overview"
  description = "Key metrics for the payments service"
  tags        = ["production", "payments"]

  time_range       = { from = "now-1h", to = "now" }
  refresh_interval = { pause = false, value = 30000 }
  query            = { language = "kql", text = "service.name:payments" }

  panels = [
    {
      type = "vis"
      grid = { x = 0, y = 0, w = 32, h = 15 }
      xy_chart_config = {
        axis = {
          x = { title = { visible = true } }
          y = {
            title       = { visible = true }
            scale       = "linear"
            domain_json = jsonencode({ type = "fit" })
          }
        }
        legend      = { visibility = "visible", inside = false, position = "right" }
        fitting     = { type = "none" }
        decorations = { fill_opacity = 0.3 }
        query       = { language = "kql", expression = "" }
        layers = [{
          type = "area"
          data_layer = {
            ignore_global_filters = false
            sampling              = 1
            data_source_json      = jsonencode({ type = "data_view_spec", index_pattern = "logs-*" })
            y = [{
              config_json = jsonencode({ operation = "count", empty_as_null = true, color = { type = "auto" } })
            }]
          }
        }]
      }
    },
    {
      type = "vis"
      grid = { x = 32, y = 0, w = 16, h = 15 }
      metric_chart_config = {
        ignore_global_filters = false
        sampling              = 1
        data_source_json      = jsonencode({ type = "data_view_spec", index_pattern =   "logs-*", time_field = "@timestamp" })
        query                 = { language = "kql", expression = "" }
        metrics = [{
          config_json = jsonencode({
            type = "primary", operation = "count", empty_as_null = false,
            color = { type = "auto" },             format = { type = "number", decimals = 2, compact = false }
          })
        }]
      }
    }
  ]
}</code></pre><p>This is readable, reviewable, and diffable. When someone changes the time range or adds a filter, the <code>terraform plan</code> output tells you exactly what changed.</p><h2>Dashboard access control in Terraform</h2><p>The Terraform provider supports the dashboard access control model, letting you restrict write access and set ownership:</p><pre><code>resource "elasticstack_kibana_dashboard" "protected" {
  title = "Production SLOs"
  # ...

  access_control = {
    access_mode = "write_restricted"
  }
}</code></pre><p>When <code>access_mode</code> is set to <code>&quot;write_restricted&quot;</code>, only the creator can make changes. This is especially useful for production dashboards where you want to ensure all changes flow through your Terraform pipeline.</p><h2>The GitOps workflow for Kibana dashboards</h2><p>With the new Dashboards API and Terraform support, you can now treat dashboards like any other infrastructure artifact:</p><ol><li><strong>Define</strong> dashboards in HCL, alongside your Elasticsearch indices, data views, and alerting rules.</li><li><strong>Review</strong> changes through pull requests: <code>terraform plan</code> shows exactly what&#x27;s changing in each visualization.</li><li><strong>Deploy</strong> across environments using Terraform workspaces or variable files per environment.</li><li><strong>Detect drift</strong> when someone edits a dashboard manually in the Kibana UI: <code>terraform plan</code> will show the difference.</li><li><strong>Roll back</strong> by reverting to a previous commit and running <code>terraform apply</code>.</li></ol><p>Elastic users can now enjoy the benefit of GitOps-enabled dashboards, with a typed, HCL-native experience that goes beyond just managing dashboards.</p><h2>Getting started with the Terraform provider</h2><p>The <code>elasticstack_kibana_dashboard</code> resource requires <strong>Elastic Stack 9.4 or later</strong> and is available in the latest versions of the <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs">Elastic Stack Terraform provider</a>.</p><p>To get started:</p><ul><li><strong>Set up your provider</strong> with Kibana connectivity:</li></ul><pre><code>terraform {
  required_providers {
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~> 0.14"
    }
  }
}

provider "elasticstack" {
  kibana {}
}</code></pre><ul><li><strong>Define your first dashboard</strong> using any of the examples above.</li><li><strong>Run <code>terraform plan</code></strong> to preview, and then run <code>terraform apply</code> to create.</li><li><strong>Import existing dashboards</strong> into Terraform state:</li></ul><pre><code>terraform import elasticstack_kibana_dashboard.my_dashboard <space_id>/<dashboard_id></code></pre><p>For the full resource schema and documentation, visit the <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs">Terraform Registry</a>.</p><h2>Kibana Dashboards API roadmap</h2><p>The Dashboards API is in <strong>technical preview</strong> in Elastic 9.4, and both the API and Terraform provider are actively evolving. On the roadmap:</p><ul><li><strong>All panel types will be supported by the API. </strong>There are a few panels that are still missing API support, like the Links panel, Machine Learning panels, Alerts, Log analysis panels, Vega visualization, and Maps.</li><li><strong>More panel types in Terraform.</strong> Typed HCL config blocks for image, links, Service Level Objective (SLO), and Synthetics panels that the API already supports.</li><li><strong>Dashboard-level filters in Terraform.</strong> Filter pills, controls, and drilldowns that the API already supports.</li></ul><p>Try it out and let us know what you think. Send your feedback by using the <strong>Submit Feedback</strong> icon in the top menu, file issues on the <a href="https://github.com/elastic/terraform-provider-elasticstack/issues">Terraform provider GitHub repo</a>, or join the conversation on <a href="https://discuss.elastic.co/c/orchestration">Discuss</a>. We’re looking forward to hearing from you!</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/60c0861ade03a764ddb4d3c4a86af31a7dea02ed-1490x634.png" style="width: 100%;" /><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Cutting agent costs with pre-computed context]]></title>
            <description><![CDATA[Pre-computing context as Knowledge Indicators reduces LLM agent token costs by up to 75% and improves answer accuracy from 60% to 92%. This post covers the extraction, retrieval and feedback loop that make it work, tested against the BrowseComp-Plus benchmark.]]></description>
            <link>https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Joe McElroy]]></dc:creator>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a8808fe44ff08040e6e8329d86903a4dba31302a-2048x1501.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Most of the conversation around agent context treats it as a memory problem. How do you give the model more room, longer windows, better recall. That&#x27;s the wrong frame. Context is a retrieval problem. Agents stall on real workloads because they burn their token and step budget navigating raw sources before they get to the answer, and the fix is better retrieval, not more memory. In this post, we explore an experiment which leverages an LLM to extract structured facts ahead of time (&quot;Knowledge Indicators&quot;, or KIs) which an agent can query through a natural-language interface backed by hybrid semantic and lexical retrieval.</p><p>Using the BrowseComp-Plus public dataset and a cost-controlled agent harness, accuracy moved from 60% to 70% to 92% across three stages of iterations, with input tokens dropping by up to 75% versus standard RAG. Most of the final jump came from feeding the agent&#x27;s own wrong answers back into the extractor to create new KIs.</p><p>Pre-computed context only works inside a system that retrieves well, manages the data over time, and learns from its own failures.</p><h2>Agents stall before they reach the answer</h2><p>Frontier models are extremely capable when armed with the ability to access sources of information — they can crawl web pages, parse spreadsheets, navigate logs, run queries. The challenge is doing this without running out of tokens before arriving at the answer.</p><p>This challenge is familiar to engineering leaders who are building agents with access to data. The agent gets a task, decides it needs information, searches, retrieves, evaluates, decides it needs more, searches again, reads, stitches together a partial picture, loops. By the time the model is ready to answer, most of the token and latency budget is gone. Sometimes the answer is in the corpus and the context window fills before the agent gets to it. Sometimes the agent picks the wrong thread and never recovers.</p><p>OpenAI ran into this building their own <a href="https://openai.com/index/inside-our-in-house-data-agent/">internal data agent</a>. Raw data access didn&#x27;t scale. They had to layer in human annotations, institutional knowledge, and learned corrections before it was reliable enough for daily use. We&#x27;re seeing the same with customers using Elastic Agent Builder. Model intelligence is rarely the limitation; what breaks agents is the context before the reasoning step.</p><p>The shapes of the workloads vary wildly across different domains:</p><ul><li><strong>Log Anomaly Triage:</strong> processing of machine-generated system alerts. Because individual data points lack sufficient context, the goal is to extract insights <em>across</em> multiple anomalies to separate benign, seasonal patterns from truly actionable incidents.</li><li><strong>Financial Payment Analytics:</strong> querying historical logs to track end-to-end transaction journeys based on fuzzy identifiers. It requires cross-record retrieval to map full service lineages and diagnose payment failures or latency.</li><li><strong>Product Support:</strong> assisting customers with questions on products using internal documentation, building insights across multiple documents.</li></ul><p>This post covers a strategy we&#x27;re testing for that problem: do the data orientation work once, ahead of time, and let the agent read the result.</p><h2>Bottom-up context strategy: extracting knowledge from the source</h2><p>There are two parts to the approach: extracting context ahead of time using Knowledge Indicators, and giving the agent a clean way to query it.</p><h3>Use agents to learn how to extract and maintain context effectively</h3><p>Most of the data an agent needs is already somewhere in the enterprise — records in databases, documents in Google Drive, Confluence, or SharePoint, logs and metrics in Elasticsearch, files in S3. The strategy works against the sources where they already live, with the access controls they already have.</p><p>Every domain utilizes sources differently. So instead of creating generic extraction pipelines that treat every source and domain the same, we&#x27;re planning to use agents to tailor the extraction needs. An agent reads a sample of the source, works out the shape — the schema, the field semantics, the ways the data is typically queried — and writes that understanding out as structured metadata. Some of that metadata is facts pre-computed from the data; some of it describes how to query the source itself.</p><pre><code>Document:
  docid: <docid>
  url:   <url>
  text:  <body text>
Return a JSON object with key "facts" containing 0–15 atomic facts.
Long, fact-dense documents (Wikipedia articles, news features, profiles,
academic-staff pages) typically warrant 8–15 facts. Short or generic
documents may warrant 0–3.
Each fact MUST be self-contained: title + description together fully answer
the implied W-question (who/what/when/where/how/which) without requiring the
source document. A future agent should be able to commit to an answer by
reading just title+description — the description must include the answer
value, supporting evidence (date, location, named witness, exact quantity,
physical detail), and a short verbatim quote (≤30 words) when it adds
disambiguating signal. This bias toward density is intentional even at the
cost of slightly longer descriptions.
Each fact:
  {
    "title":            "<one natural sentence ≤140 chars stating the fact, ending with the answer value when possible (e.g. \"Townsend was last seen wearing a red shirt.\")>",
    "description": x     "<2-3 sentences ≤350 chars carrying the answer + evidence: entity, relation, value, date/location/source detail, and an inline verbatim quote when it disambiguates. Avoid restating the title verbatim.>",
    "subject":          "<canonical entity name>",
    "predicate":        "<precise snake_case relation, ≤32 chars>",
    "object":           "<the value of the fact, plain prose>",
    "evidence_span":    "<verbatim 1-3 sentence quote, substring of the doc text above>",
    "confidence":       <0..100 integer>,
    "tags":             ["<entity/topic/year tags, lowercase, alphanumeric+hyphen>", ...]
  }
Coverage priorities — extract a fact for EACH of the following whenever it's grounded in the doc text:
- Every named person mentioned + their role / position / title (no matter how briefly named —
  a one-line mention of "the secretary, Mary" still warrants its own fact).
- Every named organisation + its relation to the main entity.
- Every concrete date + the event that occurred on it (graduation 22 June 2003, trip 1 Nov 2022, etc.).
- Every named location + what happened there.
- Every distinctive descriptive detail: clothing colour, building material, exact age, weight,
  height, vehicle, distinguishing feature, last-seen description.
- Every cross-entity relationship: X collaborated with Y, X worked for Y, X spoke at Y's
  conference, X's child is Z, X co-edited a book with Y.
Anti-patterns — do NOT do these:
- Don't only extract facts about the most famous / dominant entity in the doc. Secondary
  individuals named once still warrant their own fact.
- Don't fill the budget with generic claims (founded-year, location, leadership) at the
  expense of specific concrete details that sit deeper in the doc body.
- Don't skip a fact because it seems minor — minor facts are often what disambiguate two
  similar entities at retrieval time.
Predicate guidance:
- Use a precise snake_case predicate (≤32 chars). Prefer reusing common terms when they fit:
  located_in, founded_in, founded_by, held_event, published_article, won_award, member_of,
  position_held, born_in, died_in, created_by, parent_of, succeeded_by, field_of_study,
  co_authored_with, organized_by, attended_by, physical_description, last_seen_wearing,
  clothing_worn, cross_link.
- Coin a new specific predicate when none of those fit. AVOID the catch-all `affiliated_with`.
Title and description constraints (CRITICAL — items violating these are dropped):
- title and description MUST read as natural standalone fact statements.
- They MUST NOT contain the strings: "BrowseComp", "qid", "qid:",
  "use this fact", "anchor a criterion", "without re-reading".
- They MUST NOT mention the document, the dataset, or this task.
Fact constraints:
- Favor specificity (proper nouns, dates, numbers) over generic claims.
- Skip the doc entirely (return empty facts list) for navigation pages, login walls,
  error pages, very short or generic content.
- evidence_span must be a verbatim substring of the doc text supplied above.</code></pre><p><em>The extraction prompt was created by the agent. You can find earlier prompts created by the agent <a href="https://gist.github.com/joemcelroy/2f7fcd749d42473f2caa9859f75e0fe7">here</a>.</em></p><h4>Knowledge Indicators</h4><p>We call this unit of pre-computed metadata a <strong>Knowledge Indicator</strong> — KI for short. KIs aren&#x27;t a new concept for us: they already run in production in <a href="https://www.elastic.co/docs/solutions/observability/streams/management/knowledge-indicators">Elastic Observability Streams</a>, where the same extraction pattern is applied to raw log data. There, an agent samples logs from a stream and extracts structured facts about the environment — which services are running, the infrastructure they sit on, how they depend on each other, the log schemas they use — and the KIs feed downstream into topology graphs, rules, dashboards, and agent investigations. The KIs auto-expire after 7 days if a service stops showing up, so the index stays current without manual cleanup.</p><p>The work in this post applies the same pattern to a different shape of corpus — documents instead of logs — but the unit is the same. Some KIs are facts; others describe sources. They share the same shape:</p><pre><code>{
  "type": "knowledge_indicator",
  "id":   "ki-bcpc-d1478-tony-blair-position-held-prime-minister-of-the-un",
  "title": "Sir Tony Blair served as the Prime Minister of the United Kingdom from 1997 to 2007.",
  "description": "Blair was a British politician who held the office of Prime Minister for ten years starting in May 1997, and was the first person to lead the Labour Party to three consecutive general election victories. The text describes him as \"a British politician who served as Prime Minister of the United Kingdom from 1997 to 2007\".",
  "references": ["index://browsecomp-plus-corpus"],
  "tags": [
    "entity:tony-blair",
    "doc:1478",
    "tony-blair",
    "prime-minister",
    "politics",
    "1997-2007"
  ],
  "evidence_doc_ids": ["1478"],
  "payload": {
    "type": "feature",
    "subtype": "dataset_fact",
    "properties": {
      "subject":   "Tony Blair",
      "predicate": "position_held",
      "object":    "Prime Minister of the United Kingdom",
      "docid":     "1478"
    },
    "evidence": [
      "Sir Anthony Charles Lynton Blair (born 6 May 1953) is a British politician who served as Prime Minister of the United Kingdom from 1997 to 2007"
    ],
    "confidence": 100,
    "status":     "active",
    "last_seen":  "2026-05-10T11:12:41Z"
  }
}</code></pre><p>In practice this is harder than it sounds, and the difficulty is why this work needs a system with optimized search, retrieval, and data engineering to work effectively.</p><p>The part that makes this work is measuring how the metadata holds up. We watch how agents use it to answer real questions, where they fail, where they fall back to scanning the raw source, where they give up. Those failures are the signal. They tell the setup agent what it missed, the metadata gets regenerated, and the next batch of failures looks different. Without that loop, the extraction agent overfits to whatever small set of questions it was first tuned against, and the strategy doesn&#x27;t scale past a demo.</p><p>The shape of the loop, end to end:<br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/274c07441dd4574c9c4591b2832c97db359b871e-1999x1428.png" style="width: 100%;" /><p>The user contributes once - a set of example questions and a pointer at the source. Everything inside the loop runs on its own, and keeps running as new traces come in.</p><h3>Give the agent a way to ask</h3><p>The second part is the interface. The agent shouldn&#x27;t need to know which source it&#x27;s querying, what the index schema looks like, or whether the answer is going to come from knowledge indicators or from a fallback into the raw source. It should ask one question and get back something it can use.</p><h4>Querying Knowledge Indicators</h4><p>We settled on a natural-language interface for querying the metadata. The agent sends a question; an LLM behind the interface rewrites it into the underlying query against the index. The agent doesn&#x27;t need to know the schema, the retrieval strategy, or which source the answer is coming from.</p><pre><code>POST /api/_get_context
{
  "query":        "Wilkinson 2014 creatine review rheumatoid arthritis article title",
  "size":         10,
  "execute":      true
}</code></pre><p>And this gets transformed by the use of an LLM to the following:</p><pre><code>FROM .chat-context-items METADATA _id,_index,_score
| FORK
    (
      // Branch 1 — exact entity tag match for Wilkinson
      WHERE tags : "entity:wilkinson" OR tags : "wilkinson"
      | KEEP id, type, title, description, tags, references, evidence_doc_ids, _id, _index, _score
      | SORT _score DESC | LIMIT 25
    )
    (
      // Branch 2 — lexical match on title for specific keywords
      WHERE MATCH(title, "Wilkinson 2014 creatine review rheumatoid arthritis")
      | KEEP id, type, title, description, tags, references, evidence_doc_ids, _id, _index, _score
      | SORT _score DESC | LIMIT 25
    )
    (
      // Branch 3 — semantic match on description for the research intent
      WHERE MATCH(description.semantic, "Wilkinson 2014 review on creatine supplementation for rheumatoid arthritis")
      | KEEP id, type, title, description, tags, references, evidence_doc_ids, _id, _index, _score
      | SORT _score DESC | LIMIT 25
    )
| FUSE
| SORT _score DESC
| LIMIT 10</code></pre><p>The response carries more than the matching KIs. Each KI comes with its tags and source references, and the API returns aggregations across the result set — counts by tag, by source, by entity — so the agent can see the shape of what&#x27;s there before reading any individual KI. If the first ask returns a fan of results spread across three sources, the agent knows how to narrow. If they&#x27;re all tagged with the same entity, the agent knows to follow that thread. Tags and aggregations are how it navigates the index quickly, without rereading.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4c5b5a59856044218b048ed238cd00d8ecfaa302-1999x975.png" style="width: 100%;" /><h2>The three metrics we tracked</h2><p>We want to know whether pre-computing context represented in knowledge indicators helps an agent answer faster and more accurately than the standard RAG pattern when both are working against the same corpus with the same budget.</p><p>Three things we care about:</p><ul><li><strong>Accuracy.</strong> How often does the agent commit to the correct answer? Answer match against the gold answer (judged by an LLM), plus F1 scores.</li><li><strong>Input tokens consumed.</strong> Every step the agent takes costs tokens. Fewer tokens to the same answer is the whole point of the strategy. If with-context answers more questions but burns the same budget doing it, that&#x27;s not a win for any real deployment.</li><li><strong>Whether the agent converges within the step budget.</strong> A wrong-but-committed answer and a &quot;ran out of steps and gave up&quot; answer are both failures, but they fail for different reasons and the fix is different. We track timeouts separately.</li></ul><p>We&#x27;re not chasing leaderboard accuracy. We&#x27;re testing whether the strategy holds up where the budget is tight, which is where every real agent lives.</p><h2>Experiment Setup</h2><p>Before the numbers, a quick walk through the setup:</p><p><strong>Dataset.</strong> <a href="https://huggingface.co/datasets/Tevatron/browsecomp-plus">BrowseComp-Plus</a> — 830 hard factual questions in the test split, each paired with gold source documents inside a roughly 100k-document web corpus. The questions are deliberately cryptic and multi-criteria; an agent typically has to chain two to five retrievals to converge on a short, exact answer like a name, title, or date.</p><p><strong>Agent harness.</strong> Both setups use the same harness, based on the <a href="https://docs.langchain.com/oss/python/deepagents/overview">LangChain deepagents</a> middleware stack. The agent has local shell access to call the skill&#x27;s scripts. The only thing that changes between runs is which skill is loaded. We use Claude Sonnet 4.6 as the agent&#x27;s model.</p><p><strong>Agent harness budget.</strong> The harness has a <strong>43-step recursion budget</strong>. BrowseComp-Plus leaderboard runs typically give agents far more headroom. We&#x27;re not trying to compete on raw accuracy — given enough steps the agent will eventually get to the right answer either way. The point of this experiment is to hold the step and token budget low, and measure how well each retrieval strategy converges <em>within</em> a realistic budget.</p><p><strong>Force-commit safety net.</strong> If the agent reaches the 43-step ceiling without emitting an Answer: line, the harness makes one final LLM call asking it to commit to its best guess from the trace so far. A timeout is not automatically a failure — the agent can still land on the right answer at the limit.</p><p><strong>Baseline (search-and-fetch RAG).</strong> The baseline skill exposes two helpers:</p><ul><li><code>Search</code> runs an ES|QL semantic and lexical <code>MATCH(text, …)</code> query against the corpus and returns up to ten hits, each with <code>docid</code>, <code>url</code>, <code>_score</code>, and three body snippets of up to 700 words. The agent reads relevant passages inline, in a single call, without the full document body.</li><li><code>get_by_doc_id</code> is the escape hatch for fetching a full body when the snippets don&#x27;t cover the answer.</li></ul><p>A caveat on the baseline: this is the standard search-and-fetch pattern most teams use today, but it&#x27;s not the most optimized RAG setup possible. Someone tuning RAG hard against this benchmark — chunking strategies, reranking, query expansion — would close some of the gap. The comparison is against the setup most customers actually run, not the theoretical best.</p><p><strong>In-context setup.</strong> The in-context skill exposes two helpers:</p><ul><li><code>get_context</code> POSTs a natural language question to query relevant KIs.</li><li><code>execute_esql</code> is available as a raw fallback against the corpus.</li></ul><p>The skill&#x27;s <code>SKILL.md</code> teaches the agent to chain <code>get_context</code> calls for entity-anchored multi-hop retrieval and only drop to body searches after two or more KI calls have come up short.</p><p><strong>How KIs are indexed.</strong> Each KI&#x27;s title and description are mapped as Elasticsearch <code>semantic_text</code> fields backed by the <code>.jina-embeddings-v5-text-small</code> inference endpoint. It&#x27;s a hybrid search (semantic + lexical) match against pre-extracted knowledge.</p><p>We took a 96 question sample (from the 830 question dataset) and transformed 25k (out of the 100k-document web corpus) documents into KIs (a 25% sample) which included the golden docs in each question. This produced around 240k KIs, built on Gemini Flash over roughly 7 hours.</p><h3>Stage 1: Setup Agent Optimization</h3><p>At setup time, we took a small slice of 15 questions and worked with the agent to improve the KI extraction process — iterating on the prompt to reduce the number of steps and tokens the agent harness consumed to reach an answer.</p><p>Against that same set of 15 questions and after 4 agent loop iterations refining the KI extractor prompt, the in-context setup posted:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric</td><td style="border: 1px solid #ccc; padding: 4px;">baseline RAG</td><td style="border: 1px solid #ccc; padding: 4px;">with-context</td><td style="border: 1px solid #ccc; padding: 4px;">change</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">exact match</td><td style="border: 1px solid #ccc; padding: 4px;">4 / 15 (26.7%)</td><td style="border: 1px solid #ccc; padding: 4px;">9 / 15 (60.0%)</td><td style="border: 1px solid #ccc; padding: 4px;">+33 pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">F1</td><td style="border: 1px solid #ccc; padding: 4px;">0.39</td><td style="border: 1px solid #ccc; padding: 4px;">0.62</td><td style="border: 1px solid #ccc; padding: 4px;">+0.24</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">input tokens</td><td style="border: 1px solid #ccc; padding: 4px;">9.0M</td><td style="border: 1px solid #ccc; padding: 4px;">6.3M</td><td style="border: 1px solid #ccc; padding: 4px;">−30%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">output tokens</td><td style="border: 1px solid #ccc; padding: 4px;">47.9k</td><td style="border: 1px solid #ccc; padding: 4px;">43.9k</td><td style="border: 1px solid #ccc; padding: 4px;">−8%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">wall time</td><td style="border: 1px solid #ccc; padding: 4px;">1,475s</td><td style="border: 1px solid #ccc; padding: 4px;">1,448s</td><td style="border: 1px solid #ccc; padding: 4px;">flat</td>
                    </tr>
              </tbody>
            </table>
          <p>The headline isn&#x27;t really the 30% token saving. Exact-match accuracy more than doubled, while wall time stayed flat and tokens went down. Five new questions came out right that the baseline got wrong, and zero baseline wins were lost in the swap.</p><p>A note on what these numbers mean. The accuracy figures here are not directly comparable to the BrowseComp-Plus leaderboard. Leaderboard runs optimize for accuracy with generous step and token budgets. This experiment deliberately constrains both to simulate the real-world case we care about — agents working against limited budgets where retrieval efficiency, not raw reasoning headroom, decides whether the answer lands.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/77eb38566a179dc0932dc55b8263d2c7b0e5d43a-1538x916.png" style="width: 100%;" /><p>The wins clustered around questions where a KI directly answered the query. The baseline on the same questions burns through call after call of keyword search, often hitting its recursion limit before it converges. On four of the new wins, the baseline didn&#x27;t fail because it was wrong, it failed because it ran out of steps. The KI route gets to the answer in a fraction of the budget, which is what the 80–95% token savings on those specific questions reflect.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4c5289bdfda0a19572cce060a75feb8e1866e332-1999x1766.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e2fe00a6a4c8aa698c88c2ec0364e6f6da58c545-1999x1901.png" style="width: 100%;" /><p>However, it wasn&#x27;t uniform. On a couple of questions the in-context setup actually used <em>more</em> tokens than the baseline (one was +151%, another +723%), because no KI covered the question well and the agent fell through to body searches after exhausting the KI route. That&#x27;s the failure mode the feedback loop is built to close — every one of these traces is a signal that the extractor missed something the agent needed, and the agent would continually improve the extractor prompt to capture these facts for the domain more effectively.</p><h3>Stage 2: Scaling up, but the failure mode changes</h3><p>Strong numbers on a small slice. The next thing was to find out how much of that survives when you widen the eval set.</p><p>We ran the same setup over a 96-question expanded set and compared again to the snippet-baseline RAG.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric</td><td style="border: 1px solid #ccc; padding: 4px;">baseline RAG</td><td style="border: 1px solid #ccc; padding: 4px;">with-context</td><td style="border: 1px solid #ccc; padding: 4px;">change</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">judge correct</td><td style="border: 1px solid #ccc; padding: 4px;">60 / 96 (62.5%)</td><td style="border: 1px solid #ccc; padding: 4px;">67 / 96 (69.8%)</td><td style="border: 1px solid #ccc; padding: 4px;">+7.3 pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">F1</td><td style="border: 1px solid #ccc; padding: 4px;">0.561</td><td style="border: 1px solid #ccc; padding: 4px;">0.624</td><td style="border: 1px solid #ccc; padding: 4px;">+0.063</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">input tokens</td><td style="border: 1px solid #ccc; padding: 4px;">174.8M</td><td style="border: 1px solid #ccc; padding: 4px;">48.3M</td><td style="border: 1px solid #ccc; padding: 4px;">−72%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">output tokens</td><td style="border: 1px solid #ccc; padding: 4px;">373k</td><td style="border: 1px solid #ccc; padding: 4px;">345k</td><td style="border: 1px solid #ccc; padding: 4px;">−7%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">timeouts (43-step limit)</td><td style="border: 1px solid #ccc; padding: 4px;">28 / 96</td><td style="border: 1px solid #ccc; padding: 4px;">37 / 96</td><td style="border: 1px solid #ccc; padding: 4px;">+9</td>
                    </tr>
              </tbody>
            </table>
          <p>With-context wins on accuracy and cost — 7 more questions correct and about 3.6× cheaper per question. But it also times out more, 37 against 28. That&#x27;s worth understanding, because it changes how to read the timeout number.</p><p>A baseline search call returns up to 10 hits with three 700-word snippets each — one call can drop ~21k words of body text into the agent&#x27;s context. The agent has to read that, find the answer-bearing sentence, and commit. Each miss means another search and more body text. With-context&#x27;s get_context call returns ~10 KIs — single-sentence pre-extracted facts, ~2k tokens total. When a KI states the fact, the agent reads one sentence and commits. So with-context spends its budget on more retrieval calls, each one cheaper and sharper; baseline spends it on fewer, more expensive ones and then works through the text they return.</p><h4>Higher Timeouts</h4><p>Under a tight step budget, a timeout isn&#x27;t a failure. It means the agent ran out of room before writing its answer. This is the realistic case for any budget-constrained deployment: the agent often has the answer in hand and simply hasn&#x27;t committed it yet. 21 of with-context&#x27;s 37 timeouts were judged correct on this run, because by step 43 the answer is usually already in the conversation from a KI retrieved already within the context, and the force-commit safety net picks it up. Baseline&#x27;s timeouts fail outright more often, because its conversation is mostly raw body text and the force-commit pass is guessing from a haystack.</p><p>The safety net is catching answers the agent already had but didn&#x27;t commit — so the agent is timing out on questions it could have answered itself, sooner and for fewer tokens. We want the agent to commit faster. That&#x27;s a balance between two levers: the agent&#x27;s instructions — when to keep retrieving, when to commit, when to fall through to body search — and the context it&#x27;s working from — whether the KIs in front of it are sharp enough to commit on. Getting that balance right isn&#x27;t a one-time fix. It&#x27;s what the feedback loop is for: watch where the agent stalls, where it commits late, where it commits wrong, and feed that back into both the instructions and the extraction. Stage 3 is the first turn of that loop.</p><h3>Stage 3: Teaching the agent from its own mistakes</h3><p>Stage 2 left with-context ahead on accuracy and 3.6× cheaper per question, but 29 failures still on the table. We pulled the traces and looked at what was actually going wrong.</p><p>The failures grouped into three shapes:</p><ul><li><strong>Wrong-twin commits (19 of 29).</strong> The agent retrieved KIs that were topically right but couldn&#x27;t disambiguate between near-neighbours, and picked the closer-looking one. q79&#x27;s <em>University of Aberdeen</em> came back as <em>University of Edinburgh</em>. q193&#x27;s <em>Secret Oral Teachings in Tibetan Buddhist Sects</em> came back as <em>The Mystic Spiral</em>. q775&#x27;s <em>Boston</em> came back as <em>Jerusalem</em>. Same failure mode each time — confident commit to the wrong entity.</li><li><strong>Wrong-value commits (3 of 29).</strong> Same mechanism, applied to numbers. q209&#x27;s <em>9</em> came back as <em>7</em>. q624&#x27;s <em>65%</em> came back as <em>26%</em>. q1090&#x27;s <em>500 Egyptian pounds</em> came back as <em>$1,500</em>.</li><li><strong>No-candidate failures (7 of 29).</strong> The agent&#x27;s organic retrieval did not find relevant candidates and the force-commit safety net produced a guess that didn&#x27;t survive judging.</li></ul><p>In all three cases, the agent retrieved <em>something</em>, the something didn&#x27;t sharply distinguish the right answer from a plausible neighbour, and the agent committed wrong. The fix requires identifying distinguishing criteria before the agent responds.</p><p>For each failure we had four things: the question, the correct answer, the agent&#x27;s wrong commit, and the gold doc body. We then used an LLM to write a single <strong>disambiguation KI</strong> per failure, with a title that names both entities and the criterion that separates them:</p><p><em>&quot;Joseph Dalton Hooker (19th-century British botanist, Director at Kew) is associated with the second origin narrative — distinguished from 16th-century German botanist Leonhart Rauwolf.&quot;</em></p><p>A guardrail rejected any KI whose title didn&#x27;t lexically contain both the gold answer and the wrong prediction, so the disambiguator was guaranteed to land inside the semantic-text embedding rather than buried in a description field the retrieval might skip. 33 of 38 attempted KIs passed the guardrail and got indexed into the same retrieval layer the agent already queries. The five rejections were cases where the wrong-pred was empty or contained unicode quoting that broke the lexical match.</p><p>We then re-ran the 96-question set as a single live evaluation:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric</td><td style="border: 1px solid #ccc; padding: 4px;">baseline RAG</td><td style="border: 1px solid #ccc; padding: 4px;">with-context</td><td style="border: 1px solid #ccc; padding: 4px;">with-context + disambig</td><td style="border: 1px solid #ccc; padding: 4px;">change vs no-disambig</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">judge correct</td><td style="border: 1px solid #ccc; padding: 4px;">60 / 96 (62.5%)</td><td style="border: 1px solid #ccc; padding: 4px;">67 / 96 (69.8%)</td><td style="border: 1px solid #ccc; padding: 4px;">88 / 96 (91.7%)</td><td style="border: 1px solid #ccc; padding: 4px;">+21.9 pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">F1</td><td style="border: 1px solid #ccc; padding: 4px;">0.561</td><td style="border: 1px solid #ccc; padding: 4px;">0.624</td><td style="border: 1px solid #ccc; padding: 4px;">0.827</td><td style="border: 1px solid #ccc; padding: 4px;">+0.203</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">input tokens</td><td style="border: 1px solid #ccc; padding: 4px;">174.8M</td><td style="border: 1px solid #ccc; padding: 4px;">48.3M</td><td style="border: 1px solid #ccc; padding: 4px;">42.6M</td><td style="border: 1px solid #ccc; padding: 4px;">−12%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">timeouts (43-step limit)</td><td style="border: 1px solid #ccc; padding: 4px;">28 / 96</td><td style="border: 1px solid #ccc; padding: 4px;">37 / 96</td><td style="border: 1px solid #ccc; padding: 4px;">27 / 96</td><td style="border: 1px solid #ccc; padding: 4px;">−10</td>
                    </tr>
              </tbody>
            </table>
          <img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a8808fe44ff08040e6e8329d86903a4dba31302a-2048x1501.png" style="width: 100%;" /><p>Force-commits landed correct on 22 of 28 fires this run, which is why timeouts dropped <em>and</em> accuracy jumped.</p><pre><code>{
  "title": "The manuscript was co-authored by TJ Wilkinson, TD O'Brien, and AB Lemmey.",
  "description": "The 2014 review article on creatine supplementation for rheumatoid arthritis was authored by TJ Wilkinson, TD O'Brien, and AB Lemmey, all from the Aberystwyth University research group.",
  "subject":   "Wilkinson, O'Brien, Lemmey",
  "predicate": "co_authored_with",
  "object":    "Oral creatine supplementation review",
  "tags":      ["doc:51481", "entity:wilkinson", "td-obrien", "ab-lemmey", "2014", "co-authors"],
  "evidence_doc_ids": ["51481"]
}</code></pre><p><em>Example of a disambig knowledge indicator</em></p><p>21 of the 29 failures flipped. Wrong-twin: 16 of 19. Numeric: 1 of 3 — the two misses didn&#x27;t surface their disambig KI on this run, which looks stochastic. No-candidate: 4 of 7, because a disambig KI with an answer-bearing title surfaces during retrieval <em>before</em> the force-commit safety net fires.</p><p>Accuracy improved and the agent got faster doing it. Input tokens dropped 12%, timeouts dropped from 37 to 27, and the typical winning trace lands in around 25 steps.</p><p>8 persistent failures remain. Three failed because the agent committed a <em>different</em> wrong twin on this run than the disambig was built for — example: q83&#x27;s Stage-2 disambig separated Hooker from Rauwolf, but this run committed Francisco Hernández, who isn&#x27;t in the KI title. The challenge is figuring out how to retrieve and guide the agent away from new wrong twins. An improved loop names multiple plausible wrong-preds, or iterates the disambig build over multiple runs. The loop needs to run continuously because the failure modes drift.</p><p>The headline result is what this says about that loop. Stage 1 and Stage 2 knowledge indicators once and read it back. A static index has a ceiling — better retrieval over the same static facts only goes so far. Stage 3 is the same system using its own failures as the next batch of context. Every wrong-but-confident commit is a diagnostic signal: which two entities the corpus failed to disambiguate, and what the wrong commit was. That becomes a KI the next run reads first.</p><h2>Conclusion: extraction, retrieval, and a feedback loop</h2><p>Across the three stages, accuracy went from 60% to 70% to 92%, and input tokens dropped by up to 75% versus standard RAG. To be clear this experiment is not claiming a fixed multiplier you can expect everywhere. The baseline could be tuned harder and a different domain could shift the failure modes around. What the experiment does show is simpler: a system built around pre-computed context represented as knowledge indicators can beat search-and-fetch on a tight budget, and the gap is big enough to justify the work.</p><h3>Extraction has to be tuned to the domain</h3><p>A generic extractor produces generic facts, and generic facts don&#x27;t disambiguate. The 96-question run worked because the extractor prompt was tuned to the questions from the human created eval dataset — what types of entities, dates, and details to pull out. Point the same extractor at a logs corpus or a payments corpus and the KIs become meaningless.</p><p>This tuning isn&#x27;t a setup step you do once either. It runs as an agent in a loop, always improving. The extraction agent writes the prompt, the eval shows where it failed, the agent rewrites the prompt against those failures. Stage 1 did exactly this over 4 iterations. And the loop keeps running after launch, because sources change and the questions drift. Every new domain needs an extractor that fits its source, plus a process that keeps it fitting.</p><h3>Retrieval is what gets the right fact to the agent</h3><p>In Stage 2 the agent picked the wrong neighbour 19 times out of 29 failures. The right fact was usually in the index. The agent just couldn&#x27;t tell it apart from a plausible twin. That&#x27;s a retrieval problem, and text similarity alone won&#x27;t fix it.</p><p>This is why hybrid semantic and lexical search, tags, and aggregations exist — the agent needs to see the shape of the result set before it commits. Aggregations give it counts by tag, source, and entity, so it knows if the answer is spread across three sources or sitting in one. Tag filtering lets it narrow to a single entity in one call. Together these let the agent scan a large result set fast and decide where to look, instead of reading KIs one at a time and burning steps.</p><h3>The feedback loop is what moves the ceiling</h3><p>Stages 1 and 2 extracted context once and read it back. Accuracy stalled at 70%. Extracting more facts the same way wasn&#x27;t going to help, because the problem wasn&#x27;t missing facts. It was the agent picking the wrong one of two that looked alike, and tuning the extractor alone couldn&#x27;t fix that.</p><p>What moves the ceiling is the failures themselves. When the agent commits to a wrong answer, the trace tells you something precise — exactly which two entities the index couldn&#x27;t separate, and which one the agent picked instead. That&#x27;s specific enough to act on. The system takes those failures and builds new KIs aimed straight at the gap, each one naming both entities and what separates them. Stage 1&#x27;s tuning improves how facts get pulled; the feedback loop adds targeted facts the extractor would never have written on its own. Stage 3 did this, and accuracy moved from 70% to 92%.</p><h2>What&#x27;s next</h2><p>This is just the first strategy we&#x27;re excited to share — bottom-up extraction into knowledge indicators — and there are more we&#x27;re hoping to explore that leverage our platform for retrieval, data management and agent feedback. We think there&#x27;s real room to make agents dramatically cheaper and sharper when tokens are tight, and we can&#x27;t wait to show you where this goes next.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI Chat in Kibana now natively renders dashboards]]></title>
            <description><![CDATA[The Elastic AI Chat in Kibana now builds dashboards from natural language, keeping your visuals and analysis in one thread and letting you save them as reusable Kibana objects.]]></description>
            <link>https://www.elastic.co/search-labs/blog/ai-dashboard-generation-elastic-agent-kibana</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-dashboard-generation-elastic-agent-kibana</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Teresa Alvarez Soler, Robert Jaszczurek]]></dc:creator>
            <pubDate>Mon, 25 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f3c7ce5e05cabea693363616e62f5e30e0be2cd5-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>The <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/chat#get-started">Elastic AI Chat</a> in Kibana now turns a plain-language question into <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL</a>-backed <strong>visualizations</strong> or a full <strong>dashboard</strong> — right inside your <strong>conversation</strong>. Describe the metrics you need, refine as you go, and save when the story holds. Everything <strong>stays in the conversation</strong> until you&#x27;re ready to <strong>save</strong> them, then becomes a first-class Kibana object your team can open, edit, and reuse. Available as a technical preview in Elastic 9.4</p><a href="https://videos.elastic.co/watch/aLpHUCdWroLGBsHxMkYjHt">Watch video on Vidyard</a><p>The agent builds dashboards from scratch, but it also works with what you already have. Open the AI Chat sidebar while viewing a dashboard and it <strong>attaches</strong> <strong>automatically</strong>. Ask why a metric spiked, break it down by region, or add a comparison panel. Your existing dashboard becomes the <strong>starting point</strong>, not just the end product.</p><h2>Behind the scenes: How we built dashboards in the AI Chat</h2><p>We teach the agent specific tasks through <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-skills">skills</a> — structured descriptions of how to operate on a given problem. But building a dashboard skill meant teaching an LLM to generate valid Kibana dashboards, and the legacy Saved Object API made that painful: deeply nested JSON, subtle version-to-version changes, brittle references. We needed a different approach</p><h3>A purpose-built API for programmatic dashboards</h3><p>The new <a href="https://dashboardsapispec.kibana.dev/dashboards.html">Dashboards API</a> was built for exactly this scenario. Instead of exposing raw internal state, it offers typed, validated schemas for every panel type. The API handles the translation between clean external structures and Kibana’s internal representations, so the agent can focus on what the dashboard should contain rather than on how to format it.</p><h3>One skill, one tool, many operations</h3><p>The <code>dashboard-management</code> skill exposes a single <code>manage_dashboard</code> tool that accepts an ordered array of <strong>operations</strong>. Each operation is a discrete action: Set metadata, add a markdown panel, create ES|QL-backed visualizations from natural language, edit existing panels, group panels into collapsible sections, or reposition items on the grid.</p><p>The agent can describe an entire dashboard: title, description, sections, and every panel inside them in a single call:</p><pre><code>{
 "operations": [
   { "operation": "set_metadata", "title": "Checkout latency investigation" },
   {
     "operation": "add_section",
     "title": "Overview",
     "panels": [
       { "query": "p95 checkout latency over the last 24h", "chartType": "xy" },
       { "query": "checkout error rate by region", "chartType": "metric" }
     ]
   }
 ]
}</code></pre><p>Operations execute in order, so later steps can reference and build upon earlier ones. This design keeps the conversation focused on intent rather than on implementation details.</p><h3>The visualization pipeline: Natural language to ES|QL to visualizations</h3><p></p><p>When you ask for a dashboard, the agent explores your data — indices, field mappings, types — then plans the visualizations and calls manage_dashboard.</p><p>Each panel runs through its own pipeline: chart type selection, ES|QL generation, visualization configuration, and validation. We isolated this from the main agent thread — visualization construction takes several model calls per panel, and mixing it into the main context would bloat the window and muddy the reasoning.</p><p>Inside manage_dashboard, all panels build concurrently, then reassemble in order. The result is a complete dashboard with embedded panels — no orphaned visualizations, no sync issues.</p><h3>Why we moved visualization creation inside the dashboard tool</h3><p>Our first approach used a separate create_visualization tool — one call per panel, then hand each attachment to the dashboard tool. It worked, but every visualization needed its own tool call, its own lifecycle, and an explicit handoff. Worse, editing a visualization in the conversation didn&#x27;t update the dashboard panel, which confused users.</p><p>We folded visualization creation directly into manage_dashboard. The same parallel workflows run, but panels assemble into the dashboard structure without intermediate attachments. Fewer calls, no sync issues, one lifecycle.</p><p>Standalone visualizations still work — you can drop existing charts into a dashboard via attachment references — but for building from scratch, inline creation is the cleaner path</p><h2>For security teams</h2><p>SOC analysts and detection engineers can&#x27;t afford a round trip to the dashboard editor mid-investigation. With the AI Chat, ask for alert volume by rule type, host, or MITRE tactic and see it in your thread in about a minute. As the hunt develops, layer in panels — process execution anomalies, network connections, timeline comparisons — without breaking context.</p><p>Save when you&#x27;re done. The dashboard becomes a reference for the post-incident review, a starting point for the next analyst, or a weekly threat briefing — no re-explanation needed.</p><p>Read more about how security teams can use dashboard creation and other recently launched AI Chat capabilities in this <a href="https://www.elastic.co/security-labs/skills-elastic-security-9-4">blog post</a>.</p><h2>For observability and site reliability engineers (SREs)</h2><p>When a service degrades at 2 a.m., there&#x27;s no time to build dashboards from scratch. With the AI Chat, an SRE can describe the metrics they need (p99 latency by service, error rate against deployment events, pod restarts over the last hour) and get a full dashboard in the investigation thread in about a minute. The agent can refine it step by step as the picture sharpens: Add a panel, change the time window, break down by region.</p><p>Save the dashboard, and it&#x27;s immediately available in the war room (same panels, same framing) for everyone joining the incident bridge. After the incident, it becomes the foundation for the postmortem.</p><h2>What’s next</h2><p>We&#x27;re working on token optimization, richer full-screen interactions, broader panel support, and continued quality improvements. Technical preview is the right time to shape priorities — if something is missing, tell us via the &quot;<strong>Submit feedback</strong>&quot; icon in the top menu.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1bb71a01a12641961134f2231778344a6249e8f4-1490x634.png" style="width: 100%;" /><h2>Try it</h2><p>Upgrade to <strong>Elastic 9.4</strong> (or start a trial), open the <strong>AI Chat </strong>in full-screen mode, and try it on a real investigation. Ask the agent to chart the metrics you&#x27;re looking at, then ask for the next breakdown. When the story holds, save and share — same panels, same framing, no re-explanation needed. Requires an enterprise license (<a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/chat#get-started">get started</a>).<br/><em>The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Kibana cuts dashboard load time by up to 25% - here's the polling strategy behind it]]></title>
            <description><![CDATA[Find out how Kibana uses continuous polling and browser-side HTTP/2 detection to cut dashboard load times by up to 25%, with automatic fallback on HTTP/1.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-dashboard-performance-continuous-polling</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboard-performance-continuous-polling</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Drew Tate, Matthias Wilhelm]]></dc:creator>
            <pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5cd43366f62de628c3a054ffb7bf0b05d7a1390-1500x600.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Kibana dashboards and Discover now load up to 25% faster thanks to continuous polling. Instead of sleeping between periodic checks, Kibana now keeps HTTP connections open and delivers Elasticsearch query results the moment they&#x27;re ready. On HTTP/2+ (the Kibana default since 9.0) this kicks in automatically with no configuration required. On HTTP/1, Kibana falls back to traditional polling to prevent connection pool exhaustion.</p><h2>How Kibana fetches data when loading a dashboard</h2><p>When a Dashboard is opened, most of the panels (internally, we call these <em>embeddables</em>) kick off one or more Elasticsearch queries. But instead of the simple call-and-response of a synchronous (sync) search, we use the power of asynchronous (async) search (<a href="https://www.elastic.co/docs/solutions/search/async-search-api">docs</a>).</p><p>With async search, query results are kept available in Elasticsearch outside of any particular HTTP request. This is important because it</p><ul><li>makes data loading resilient to network turbulence</li><li>powers our <a href="https://www.elastic.co/docs/explore-analyze/discover/background-search">background search feature</a> which allows users to work on other things in Kibana while they wait for a long-running dashboard or Discover session</li></ul><p>After the initial query is submitted, Kibana monitors the search to detect when it is complete and retrieve the result set.</p><h3>How traditional polling affects Kibana dashboard load times</h3><p>In traditional polling, Kibana submits a query, closes the initial connection, then periodically checks Elasticsearch for completion.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2f44064a2e627866e129eb626f68ddd62230a4e2-1999x719.png" style="width: 100%;" /><p>We do give Elasticsearch a short amount of time after query submission to simply complete the search and return results. If the search completes that quickly, it amounts to a simple call-and-response. But for longer searches, the initial connection is closed and Kibana begins periodically checking the search for completion. This is called <em>polling</em>.</p><h4>Performance drawbacks of traditional polling</h4><p>Looking at the figure above, perhaps you can already see the performance drawback to this approach: the search is most likely to finish during one of Kibana’s sleep intervals, leading to lost time.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ad8b80d9e8d6d774065d62e352aad47c3ddb4686-1999x719.png" style="width: 100%;" /><p>In the worst case scenario (when a search completes at the beginning of a sleep period) the entire duration of the polling interval will be wasted.</p><h4>The impact of a backoff strategy</h4><p>It’s standard practice when polling to apply a backoff strategy. This means that the longer the duration of the search, the less frequently we poll.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/77665de4a0fd166b533a2c2c55f6f28e38cf37ce-1200x338.png" style="width: 100%;" /><p>However, this also means that the potential lost time scales with the duration of the search.</p><h4>How polling intervals create sawtooth latency patterns</h4><p>Putting these factors together, our lost time becomes a stepwise sawtooth function.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5cd43366f62de628c3a054ffb7bf0b05d7a1390-1500x600.png" style="width: 100%;" /><p>Here, the peaks are worst-case scenarios and the troughs are best case scenarios. This illustrates that traditional polling costs us between nothing and the full duration of the polling interval, depending on the search duration (and network conditions).</p><h2>Continuous polling: how Kibana eliminates wait time</h2><p>The problem with traditional polling is a fundamental lack of coordination between Kibana and Elasticsearch. Ideally, Kibana knows immediately when results are available. So, what if we inverted the polling pattern to where nearly all of the time is spent checking Elasticsearch and no time is spent sleeping?</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7edc138528dfe1310df42f393ee7279fe3c4703b-1999x713.png" style="width: 100%;" /><p>With this combination of long polling and no more sleep periods, results are delivered as soon as they are ready.</p><h3>HTTP/1 degradation</h3><p>The theory is solid. So why does this Kibana deployment look so degraded when we turn on continuous polling?</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/517378a73d36bd95927b81b1f912ce465b7adf4c-800x412.gif" style="width: 100%;" /><p>The key is that this deployment is running over HTTP/1. In HTTP/1, HTTP requests are mapped 1:1 to TCP connections. So several long-lived polling requests are hogging the browser’s finite connection pool, causing other requests to be queued.</p><p>In HTTP/2+ on the other hand, network requests can share TCP connections via multiplexing, so we don’t run into this problem.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f60f3a37baf5ec16c9f1773f08855e7f9e3a7491-1536x1024.png" style="width: 100%;" /><p>So, on HTTP/2+ continuous polling is a virtue but on HTTP/1 it becomes a vice.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">HTTP/1</td><td style="border: 1px solid #ccc; padding: 4px;">HTTP/2+</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">TCP connections</td><td style="border: 1px solid #ccc; padding: 4px;">One per HTTP request</td><td style="border: 1px solid #ccc; padding: 4px;">Multiplexed (many requests share connections)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Continuous polling behaviour</td><td style="border: 1px solid #ccc; padding: 4px;">Degrades performance (connection pool exhaustion)</td><td style="border: 1px solid #ccc; padding: 4px;">Full benefit (results delivered immediately)</td>
                    </tr>
              </tbody>
            </table>
          <h4>How Kibana detects HTTP protocol for optimal polling</h4><p>HTTP/2 is the recommended protocol and it’s the Kibana default since 9.0, so it would be a shame not to ship this performance enhancement. On the other hand, the HTTP/1 experience is so degraded that it isn’t acceptable to risk it on any on-prem deployments who haven’t yet upgraded their protocol. The answer is clear: we need to detect which protocol is in use and apply the optimal polling strategy.</p><p>It is certainly possible for the Kibana server to know which protocol it is speaking. But, there’s a catch: the limiting factor is the browser’s connection pool. That means that what really matters is what the <em>browser</em> is speaking.</p><p>Because of proxies, these are not always the same.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/13e38001fc4bd3fc60cfc69114fb9098fd745906-1970x786.png" style="width: 100%;" /><p>If we based our optimization on the server protocol, we could get things wrong in one of two ways.</p><ol><li>Apply continuous polling when we shouldn’t and degrade the experience.</li><li>Fail to apply continuous polling when we should and miss out on the optimization.</li></ol><p>Luckily, modern browsers provide a way to detect the protocol of the last network hop of any completed request through the use of a <code>PerformanceObserver</code>. So, we watch for the protocol of the first query submission and optimize based on that.</p><pre><code>new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const entry = entries.find(({ name }) => name.includes('/internal/search/'));
  if (entry) {
    this.protocolSupportsMultiplexing = ['h2', 'h3'].includes(entry.nextHopProtocol);
  }
});</code></pre><h2>Lab results: continuous polling vs. traditional polling in Kibana</h2><p>To validate continuous polling, we created dashboards with query delays ranging from 1 to 23 seconds and measured load times with and without the optimization enabled. We then loaded the dashboards with and without continuous polling to measure the gains (we had a lot of fun with <a href="https://github.com/kertal/race-for-the-prize">race-for-the-prize</a>).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5f19fb45c5307a7491037fe1ad1a302728793203-1200x742.png" style="width: 100%;" /><p>The pattern echoes our original sawtooth diagram. For some query durations, the gains are small while for others they amount to several seconds.</p><h2>Conclusion</h2><p>This optimization successfully replaces the latency inherent in traditional polling with a more efficient continuous polling strategy. The primary challenge was implementing this optimization conditionally to prevent performance degradation on HTTP/1 deployments. We solved it using the browser’s <code>PerformanceObserver</code> to reliably detect the protocol in use for the final network hop.</p><p>Laboratory testing validates the theory, showing continuous polling delivers results as soon as they are ready. On average, this leads to a meaningful improvement in user experience, making data load up to 25% faster.</p><p>This work is the latest step in our commitment to driving down time-to-insight for our users. By making Kibana a more transparent proxy to Elasticsearch data, we push the limits of performance within our sphere of influence. More to come!</p><p>(In 2025, Thomas Neirynk gave an <a href="https://www.elastic.co/search-labs/blog/kibana-dashboard-rendering-time">excellent overview</a> of the methods and motivation behind improving Kibana dashboard performance. This is an update on that initiative.)</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Describe it, don't draw it: AI-native Kibana dashboards via MCP and ES|QL]]></title>
            <description><![CDATA[From prompt to dashboard. Learn how to build Kibana dashboards with natural language, using example-mcp-dashbuilder: an open source MCP application that writes ES|QL queries, creates interactive charts and exports fully functional dashboards directly to Kibana.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-dashboard-builder-mcp-esql</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboard-builder-mcp-esql</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Stratoula Kalafateli]]></dc:creator>
            <pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0d38385fd64c1445b2e955ba20532570f7f38679-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>example-mcp-dashbuilder is an open source MCP application that turns a plain-English prompt into a live, interactive Kibana dashboard, all inside your editor&#x27;s chat window. Describe the dashboard you want, and the AI discovers your index structure, writes correct ES|QL aggregations for each visualization, and renders a preview inline as it works. When you&#x27;re done, one command exports a fully functional Kibana dashboard: real Lens visualizations, your exact grid layout, custom colors preserved. Six chart types are supported today, with the full Kibana Lens set on the roadmap.</p><h2>What is a Kibana dashboard builder?</h2><p>What if you could describe the dashboard you want in plain English and watch it appear, complete with interactive charts, a drag-and-drop layout, and one-click export to Kibana?</p><p>That&#x27;s exactly what <a href="https://github.com/elastic/example-mcp-dashbuilder.git"><strong>example-mcp-dashbuilder</strong></a> does. It&#x27;s an open source (Model Context Protocol (MCP) application that connects AI assistants to Elasticsearch, letting you create full Kibana Dashboards through conversation. No clicking through menus. No manually writing visualization configs. Just describe what you need, and the AI explores your data, writes the Elasticsearch Query Language (ES|QL) queries, builds the charts, and delivers a live, interactive dashboard, all within your editor&#x27;s chat window.</p><a href="https://videos.elastic.co/watch/nuwPaEh6XtWuuccb9yCpkm">Watch video on Vidyard</a><h2><strong>From prompt to dashboard in seconds</strong></h2><p>Here&#x27;s what it looks like in practice. You type something like:</p><p>&quot;Build me a web traffic dashboard from logstash-* with total requests, bytes transferred over time, top geographic sources, and a response code breakdown&quot;</p><p>The AI then:</p><ol><li><strong>Discovers your data:</strong> Lists indices, inspects field mappings.</li><li><strong>Writes ES|QL queries:</strong> Tailored to your schema, using the right aggregations.</li><li><strong>Creates visualizations:</strong> Bar charts, line charts, metrics with sparklines, heatmaps, pie charts.</li><li><strong>Organizes everything:</strong> Collapsible sections, meaningful titles, proper layout.</li><li><strong>Renders an interactive preview:</strong> Right in the chat, with tooltips, a time picker, and drag-and-drop.</li></ol><p>Each chart appears inline as it&#x27;s created, so you can see progress in real time. Then <code>view_dashboard</code> shows the complete dashboard with all panels laid out in Kibana&#x27;s 48-column grid.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dcbf47c4f17bf1a184fb0167408ebeb861ef6c9d-1404x1568.png" style="width: 100%;" /><p><em>Single chart preview inline.</em></p><h2><strong>Powered by ES|QL</strong></h2><p>All data retrieval uses <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL</a>, Elasticsearch&#x27;s piped query language. The AI doesn&#x27;t just pass through raw queries, it also uses built-in knowledge of ES|QL syntax along with information on the structure of your data to write correct, efficient queries for each visualization type.</p><p>The server includes a comprehensive ES|QL reference as an MCP resource. Before writing any query, the AI reads this reference to understand the available commands, functions, and patterns. Combined with a dataviz best-practices guide (also served as a resource), the AI knows not just <em>how</em> to query, but <em>what</em> makes a good visualization:</p><ul><li>Use <code>BUCKET(@timestamp, 1 day)</code> for time series; always <code>SORT</code> by the time field.</li><li>Limit pie charts to six slices with <code>| SORT value DESC | LIMIT 6</code>.</li><li>Choose bar charts for category comparisons, line charts for trends, metrics for key performance indicators (KPIs).</li></ul><h2><strong>AI-driven data exploration with open-ended analysis</strong></h2><p>Building a dashboard you&#x27;ve already designed in your head is one thing. Asking &quot;What&#x27;s interesting in this index?&quot; and getting a useful answer is harder; it requires the AI to know how to <em>explore</em>, not just how to draw.</p><p>example-mcp-dashbuilder ships an <code>analysis://guidelines</code> resource that defines a structured exploration flow: Profile the data, run targeted aggregations, surface patterns worth investigating, build charts for the most interesting findings, and propose drill-down queries the user might want next. Trigger phrases, like &quot;analyze my logs&quot; or &quot;find patterns in this index,&quot; cause the AI to read the playbook before doing anything else, so an open-ended prompt produces a coherent investigation rather than a random pile of charts.</p><p>The result: You can hand the AI an unfamiliar index and get back a starting point: a dashboard plus a short list of &quot;Here&#x27;s what I noticed, want me to dig into any of these?&quot; prompts.</p><h2><strong>Kibana dashboard export and import: the full round trip</strong></h2><p>The export/import round trip is where example-mcp-dashbuilder becomes genuinely useful for teams already working in Kibana.. example-mcp-dashbuilder is its own thing, a conversational dashboard surface that lives inside your editor, but it doesn&#x27;t trap your work there. Dashboards built here can move into Kibana when you want them to, and existing Kibana dashboards can come the other way for AI-assisted editing.</p><h3><strong>Export to Kibana</strong></h3><p>When you&#x27;re happy with your dashboard, one command exports it:</p><p>&quot;Export this dashboard to Kibana&quot;</p><p>Every panel is translated to a real Kibana Lens visualization. The translation preserves:</p><ul><li><strong>ES|QL queries:</strong> Transferred directly as Lens ES|QL data sources.</li><li><strong>Grid positions:</strong> The same 48-column system Kibana uses, so your layout looks identical.</li><li><strong>Custom colors:</strong> Series palettes, metric backgrounds, heatmap color ramps.</li></ul><p>The result is a fully functional Kibana dashboard. Not a screenshot. Not an embed. A real dashboard you can share and continue editing in Kibana.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e27777bc0a82cafb373943f65298bdb21d66176-1999x902.png" style="width: 100%;" /><p><em>Kibana dashboard and dashboard in Cursor chat side by side.</em></p><h3><strong>Import from Kibana</strong></h3><p>The round trip works in the other direction too:</p><p>&quot;Import the Kibana dashboard with ID abc-123&quot;</p><p>This fetches an existing Kibana dashboard, translates its Lens visualizations back to editable chart configs, preserves the grid layout and sections, and loads everything into example-mcp-dashbuilder. From there, you can modify it with natural language and re-export.</p><p>This makes the AI a collaborator in your existing Kibana workflow, not a replacement for it.</p><h2><strong>Custom themes and colors</strong></h2><p>Want a branded dashboard? Just ask:</p><p>&quot;Create a pink-themed dashboard with custom colors&quot;</p><p>Every visualization type supports custom color configuration:</p><ul><li><strong>Charts:</strong> <code>palette</code> accepts an array of hex colors for series and slices.</li><li><strong>Metrics:</strong> <code>color</code> sets the background color.</li><li><strong>Heatmaps:</strong> <code>colorRamp</code> defines the gradient, from low to high values.</li></ul><p>The AI picks up on theme requests naturally. Say, &quot;Ocean theme,&quot; and it&#x27;ll choose blues and teals. Say, &quot;Match our brand colors&quot; and provide hex values, and they&#x27;ll carry through to Kibana on export.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4aceba013ac9cbb4a541109efd6acddf8a6ec47d-1562x1568.png" style="width: 100%;" /><p><em>A themed dashboard with custom colors.</em></p><p><strong>How example-mcp-dashbuilder works: MCP architecture</strong></p><p>example-mcp-dashbuilder is built on <a href="https://modelcontextprotocol.io/">MCP</a>, the open standard for connecting AI assistants to external tools and data. Here&#x27;s the architecture at a high level:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cbfeabe151ec1ee2b0655f4d17468c9bb358df7e-1024x559.png" style="width: 100%;" /><p>The <strong>MCP server</strong> exposes 25 tools that the AI can call directly, everything from running ES|QL queries to exporting dashboards, alongside a handful of internal &quot;app-only&quot; tools that the inline preview uses to fetch data, persist layout changes, and detect time fields. It serves three resources: a dataviz best-practices guide, an ES|QL reference, and a deep-analysis playbook that kicks in for open-ended prompts (&quot;analyze my logs&quot;, &quot;what&#x27;s interesting in this index&quot;). And it runs over either stdio or HTTP; the HTTP transport supports streamable responses and session management, so multiple clients can connect to one server.</p><p>The <strong>MCP App</strong> is the interactive preview. It&#x27;s built with React, <a href="https://elastic.github.io/elastic-charts">Elastic Charts</a>, and <a href="https://eui.elastic.co/">Elastic UI</a>, bundled into a single self-contained HTML file. When the AI calls <code>view_dashboard</code> or creates a chart, the host renders this HTML in a sandboxed iframe. The app communicates with the server entirely through the <a href="https://modelcontextprotocol.io/extensions/apps/overview">MCP Apps protocol</a>, using <code>callServerTool()</code> over postMessage to fetch data, save layouts, and detect time fields. There&#x27;s no localhost server, no port to configure, no external network dependency.</p><p>This means it works with any MCP-compatible client: Cursor, Claude Desktop, Claude.ai, VS Code with Copilot, and more.</p><h2><strong>What chart types does example-mcp-dashbuilder support?</strong></h2><p>At time of writing, six chart types that cover the most common dashboard scenarios are supported:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Type</td><td style="border: 1px solid #ccc; padding: 4px;">Best for</td><td style="border: 1px solid #ccc; padding: 4px;">Example</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Bar</td><td style="border: 1px solid #ccc; padding: 4px;">Comparing categories</td><td style="border: 1px solid #ccc; padding: 4px;">Requests by geographic source</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Line</td><td style="border: 1px solid #ccc; padding: 4px;">Trends over time</td><td style="border: 1px solid #ccc; padding: 4px;">Bytes transferred per hour</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Area</td><td style="border: 1px solid #ccc; padding: 4px;">Volume over time</td><td style="border: 1px solid #ccc; padding: 4px;">Request volume over time</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pie</td><td style="border: 1px solid #ccc; padding: 4px;">Part-of-whole (max six slices)</td><td style="border: 1px solid #ccc; padding: 4px;">Response code distribution</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric</td><td style="border: 1px solid #ccc; padding: 4px;">Single KPI with sparkline</td><td style="border: 1px solid #ccc; padding: 4px;">Total requests with hourly trend</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Heatmap</td><td style="border: 1px solid #ccc; padding: 4px;">Patterns across two dimensions</td><td style="border: 1px solid #ccc; padding: 4px;">Requests by day of week and hour</td>
                    </tr>
              </tbody>
            </table>
          <p>Dashboards support collapsible sections for organization, a time picker with automatic time field detection,and the ability to save and switch between multiple dashboards; parallel chat sessions stay isolated from each other via a <code>dashboardId</code> threaded through every tool call.</p><h2><strong>How to install and run example-mcp-dashbuilder</strong></h2><p>example-mcp-dashbuilder is open source and ready to use. You&#x27;ll need Node.js 22+, an Elasticsearch instance (local or Elastic Cloud), and an MCP-compatible client.</p><p><strong>Claude Desktop:</strong> Download the latest <code>.mcpb</code> from <a href="https://github.com/elastic/example-mcp-dashbuilder/releases">GitHub Releases</a>, and double-click it. Claude Desktop will prompt you for your Elasticsearch credentials.</p><p><strong>Cursor / Claude Code / VS Code Copilot:</strong> Point your MCP config at the released tarball; no clone, no <code>npm install</code>:</p><pre><code>{
  "mcpServers": {
    "example-mcp-dashbuilder": {
      "type": "stdio",
      "command": "npx",
      "args": ["https://github.com/elastic/example-mcp-dashbuilder/releases/latest/download/example-mcp-dashbuilder.tgz"]
    }
  }
}</code></pre><p>Set <code>ES_NODE, ES_API_KEY</code> (or <code>ES_USERNAME / ES_PASSWORD</code>) and <code>KIBANA_URL</code> as environment variables. If you&#x27;d rather work from source, clone the repo and run <code>npm run setup</code> for an interactive wizard that handles both local Elasticsearch and Elastic Cloud (Cloud ID + API key).</p><p>And start building:</p><p>&quot;Explore the logs index and build me the most insightful dashboard you can&quot;</p><p>The AI takes it from there. 😉</p><h2><strong>Roadmap: what&#x27;s coming to example-mcp-dashbuilder</strong></h2><p>This is an early release, and we&#x27;re actively developing it. Some areas we&#x27;re focused on:</p><ul><li><strong>More chart types:</strong> Gauge, donut, treemap, data table, and tag cloud to match Lens&#x27;s full capabilities.</li><li><strong>Push dashboards to Git: </strong>Write dashboard configurations into a repository for version control and code-review workflows.</li><li><strong>Better error UX: </strong>More detailed feedback when ES|QL queries fail, with suggestions for common fixes.</li><li><strong>Richer analysis flows: </strong>Extending the deep-analysis playbook to cover more data shapes (logs, metrics, traces).</li></ul><p>We&#x27;d love to hear what you build with it. Try it out, file issues, and let us know what visualizations and workflows would be most useful for your team.</p><p><a href="https://github.com/elastic/example-mcp-dashbuilder">GitHub: elastic/example-mcp-dashbuilder</a></p><h3>Acknowledgments</h3><p>Thanks to <a href="mailto:walter.rafelsberger@elastic.co">Walter Rafelsberger</a> and <a href="mailto:tim.schnell@elastic.co">Tim Schnell</a> for their contributions to the implementation.</p><h3>FAQ</h3><p><strong>What is example-mcp-dashbuilder?</strong> example-mcp-dashbuilder is an open source MCP (Model Context Protocol) application that connects AI assistants to Elasticsearch. It lets you describe a Kibana dashboard in plain English and automatically generates ES|QL queries, creates visualizations, and delivers a live interactive dashboard inside your editor&#x27;s chat window.</p><p><strong>What query language does example-mcp-dashbuilder use to retrieve data?</strong> All data retrieval uses ES|QL, Elasticsearch&#x27;s piped query language. The MCP server includes a built-in ES|QL reference that the AI reads before writing any query, ensuring correct syntax and efficient aggregations for each visualization type.</p><p><strong>Can I export dashboards built with example-mcp-dashbuilder to Kibana?</strong> Yes. Running &quot;Export this dashboard to Kibana&quot; translates every panel into a real Kibana Lens visualization, preserving ES|QL queries, the 48-column grid layout, custom colors and series palettes. The result is a fully functional Kibana dashboard, not a screenshot or embed.</p><p><strong>Can I import an existing Kibana dashboard into example-mcp-dashbuilder for AI-assisted editing?</strong> Yes. Providing a Kibana dashboard ID fetches the existing dashboard, translates its Lens visualizations into editable chart configurations, and loads them into example-mcp-dashbuilder. You can then modify the dashboard using natural language and re-export to Kibana.</p><p><strong>Which MCP clients are compatible with example-mcp-dashbuilder?</strong> example-mcp-dashbuilder works with any MCP-compatible client, including Cursor, Claude Desktop, Claude.ai, and VS Code with Copilot. It supports both stdio and HTTP transport, with no localhost server or port configuration required.</p><p><strong>What chart types does example-mcp-dashbuilder support?</strong> The current release supports six chart types: bar, line, area, pie, metric (with sparkline), and heatmap. Planned additions include gauge, donut, treemap, data table, and tag cloud to match Kibana Lens&#x27;s full capabilities.</p><p><strong>What do I need to run example-mcp-dashbuilder?</strong> You need Node.js 22 or higher, an Elasticsearch instance (local or Elastic Cloud), and an MCP-compatible client. Set the environment variables ES_NODE, ES_API_KEY (or ES_USERNAME/ES_PASSWORD), and KIBANA_URL. For Claude Desktop, download the .mcpb file from GitHub Releases and double-click to install.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Up to 3x faster stored-vector queries in Elasticsearch]]></title>
            <description><![CDATA[Elasticsearch 9.4 provides a simpler way to search with vectors stored in an Elasticsearch index, with up to 3x lower latency.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-lookup</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-lookup</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
            <pubDate>Thu, 21 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fd579a01fc841bdf913c2f9166c38572bed7fcbb-1600x900.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Finding documents similar to a stored vector in Elasticsearch used to require two round trips: Fetch the vector with <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-get"><code>GET</code></a>, and then send it back in a <a href="https://www.elastic.co/docs/solutions/search/vector/knn">k-nearest neighbor (kNN)</a> query. Elasticsearch 9.4 collapses that flow into one request with <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query#knn-query-builder-lookup"><code>query_vector_builder.lookup</code></a>, simplifying the API and improving latency by up to 3x in a two-node Google Cloud Platform (GCP) benchmark.</p><h2>Why stored-vector search used to require two requests</h2><p>Previously, when you wanted to find documents similar to a stored vector, you needed to:</p><ul><li>Call <code>GET</code> to fetch the vector value from Elasticsearch.</li><li>Call <code>_search</code> referencing that vector value in Elasticsearch:<ul><li>Serialize the vector value via JSON.</li></ul></li></ul><p>This means paying serialization and network costs twice:</p><ul><li>Serialization and deserialization of the vector for both requests.</li><li>Network latency costs in both directions.</li><li>Potential egress costs in cloud deployments.</li></ul><p>In Python, the pattern would be:</p><pre><code>from elasticsearch import Elasticsearch

es = Elasticsearch(HOST)

# 1) Fetch the seed vector from Elasticsearch
seed_doc = es.get(
    index=source_index,
    id=seed_id,
    _source_includes=[vector_field],
)
query_vector = seed_doc["_source"][vector_field]

# 2) Send it back in a kNN query
resp = es.search(
    index=target_index,
    query={
        "knn": {
            "field": vector_field,
            "k": 10,
            "num_candidates": 100,
            "query_vector": query_vector,
        }
    },
)</code></pre><p></p><p>While these two calls seem cheap, the overhead is unnecessary. Let’s make this better.</p><h2>How query_vector_builder.lookup works in Elasticsearch 9.4</h2><p>In Elasticsearch 9.4, we added lookup to simplify the API and eliminate unnecessary costs:</p><pre><code>from elasticsearch import Elasticsearch

es = Elasticsearch(HOST)

resp = es.search(
    index="products",
    query={
        "knn": {
            "field": "product-vector",
            "k": 10,
            "num_candidates": 100,
            "query_vector_builder": {
                "lookup": {
                    "index": "seed-products",
                    "id": "product-123",
                    "path": "product-vector"
                }
            },
        }
    },
)</code></pre><p>This request now grabs the <code>dense_vector</code> value stored in the <code>product-vector</code> field, in the document with ID <code>product-123</code> in the <code>seed-products</code> index. This example is a “more like this” search, finding the nearest vectors to the one with ID <code>product-123</code>. You can refer to any index, effectively using lookup as a query vector store.</p><h2>How much latency lookup vector search can remove</h2><p>The goal is to simplify the experience and make it faster. The performance gains aren&#x27;t just from eliminating the client round trip. Many Elasticsearch instances involve multiple nodes, and traffic between nodes can carry its own serialization and network costs. Elasticsearch actively biases execution toward the local node, which cuts network serialization costs on the server side, too.</p><p>To illustrate the potential performance improvements, here’s a benchmark we ran. We used a modified version of our <a href="https://github.com/elastic/rally-tracks/tree/master/so_vector"><code>so_vector</code></a>, where instead of using the query vectors, one path did the <code>GET</code> and then <code>_search</code> pattern and the other used <code>lookup</code>. Running on two nodes in the same zone in GCP, the results were strong. Latency was consistently improved by almost 3x. Even when nodes are within the same data center and the same availability zone, network and serialization costs can have a real impact.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Percentile</td><td style="border: 1px solid #ccc; padding: 4px;">get-then-knn (ms)</td><td style="border: 1px solid #ccc; padding: 4px;">lookup-knn (ms)</td><td style="border: 1px solid #ccc; padding: 4px;">Reduction</td><td style="border: 1px solid #ccc; padding: 4px;">Speedup</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">p50</td><td style="border: 1px solid #ccc; padding: 4px;">10.3796</td><td style="border: 1px solid #ccc; padding: 4px;">3.14093</td><td style="border: 1px solid #ccc; padding: 4px;">69.74%</td><td style="border: 1px solid #ccc; padding: 4px;">3.30x</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">p90</td><td style="border: 1px solid #ccc; padding: 4px;">25.4429</td><td style="border: 1px solid #ccc; padding: 4px;">5.89807</td><td style="border: 1px solid #ccc; padding: 4px;">76.82%</td><td style="border: 1px solid #ccc; padding: 4px;">4.31x</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">p99</td><td style="border: 1px solid #ccc; padding: 4px;">27.7167</td><td style="border: 1px solid #ccc; padding: 4px;">8.07109</td><td style="border: 1px solid #ccc; padding: 4px;">70.88%</td><td style="border: 1px solid #ccc; padding: 4px;">3.43x</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">max (p100)</td><td style="border: 1px solid #ccc; padding: 4px;">28.522</td><td style="border: 1px solid #ccc; padding: 4px;">12.6497</td><td style="border: 1px solid #ccc; padding: 4px;">55.65%</td><td style="border: 1px solid #ccc; padding: 4px;">2.25x</td>
                    </tr>
              </tbody>
            </table>
          <p>This benchmark ran with 2M documents, and the latency improvement will depend on your overall search costs. Even when the speedup is smaller, lookup still removes the extra client-side request. Less code, fewer round trips.</p><h2>A simpler path for stored-vector search</h2><p>Sometimes small changes can have an outsized impact. While this is a simple feature, I hope it removes some unnecessary friction in your Elasticsearch usage and makes us that much more lovable.</p><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch downsampling methods: last-value vs. aggregate sampling]]></title>
            <description><![CDATA[Elasticsearch downsampling now gives you a choice: last-value sampling for maximum storage savings or aggregate sampling for precise rate calculations and counter resets, both fully queryable in ES|QL.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-downsampling-methods-esql</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-downsampling-methods-esql</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Mary Gouseti]]></dc:creator>
            <pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fb2b1ee780266f563a68439e29673e646463c3ac-720x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch downsampling cuts time series storage by 94% in our OTel benchmark, and as of 9.4 it&#x27;s fully queryable in <a href="https://www.elastic.co/docs/reference/query-languages/esql/limitations#_supported_types">ES|QL</a>. For each metric type you now choose: last-value keeps one observation per bucket for maximum storage savings, aggregate keeps min/max/sum/count and preserves counter resets for accurate rate calculations. Both methods support histograms. Any ES|QL dashboard built on raw time series data runs unchanged on downsampled data; the trade-off is per-bucket averages instead of raw values.</p><p><a href="https://www.elastic.co/blog/whats-new-elasticsearch-8-7-0">Downsampling</a> (available since Elasticsearch 8.7) shrinks the footprint of your time series data by summarising data points into broader time buckets. It frees up storage and speeds up queries by orders of magnitude. Recently, our engineering focus has shifted from simply optimising the underlying downsampling engine to expanding what it can do. The new features give you more control over how your data is summarised.</p><p>In this post, we’ll explore the new downsampling features including:</p><ul><li>A choice between two distinct sampling methods: <strong>lightweight last-value sampling</strong> (for maximum storage savings) and <strong>high-fidelity aggregate sampling</strong> (for precise mathematical accuracy, such as counter resets).</li><li>Expanded support for new metric types, including histograms.</li><li>Full ES|QL support for downsampled gauges.</li></ul><p>To begin, here is a quick review of the terminology used for time series data streams (TSDS):</p><ul><li><a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-metric">Metrics</a> are the actual measurements that change over time, such as CPU usage.</li><li><a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-dimension">Dimensions</a> are the identifying names and values associated with a measurement, which collectively determine the unique time series ID (<a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#tsid">_tsid</a>).</li><li>The timestamp marks the exact moment a measurement was taken.</li><li>Finally, a (downsampled) bucket represents the result of reducing a metric&#x27;s values across a specified time interval for a single <code>_tsid</code>.</li></ul><h2>How does downsampling work?</h2><p>The downsampling process is initiated via the downsample API and can <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/run-downsampling">be automated using ILM (Index Lifecycle Management) or data stream lifecycle</a> which downsamples and replaces the index with the raw data after the indexing has finished.</p><p>Since downsampling operates on a <strong>whole backing index</strong> (which must be read-only), and because the backing indices are <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-bound-tsds">time-bound</a> for time series data, the system can generate the downsampled buckets from the data in a single index.</p><p>The downsampling task is optimised for efficiency: it reads all documents sorted by their time series dimensions and their timestamp in descending order. This specific sorting ensures that all data points belonging to a single time series bucket are collected sequentially without interleaving with other time series data. Once all documents that contribute to a single bucket have been read, their field values are collected for summarisation.</p><p>We will use the following data to show the effects of downsampling throughout this post. This data represents two nodes reporting their CPU usage and their number of requests every 10 seconds.</p><pre><code># Original data
{ "@timestamp": "2025-09-08T21:25:01.930Z", "node": "node-0001", "cpu.usage": 49.9, "requests": 3 }
{ "@timestamp": "2025-09-08T21:25:11.920Z", "node": "node-0001", "cpu.usage": 39.9, "requests": 6 }
{ "@timestamp": "2025-09-08T21:25:21.940Z", "node": "node-0001", "cpu.usage": 59.9, "requests": 2 }
{ "@timestamp": "2025-09-08T21:25:13.780Z", "node": "node-0002", "cpu.usage": 19.9, "requests": 100 }
{ "@timestamp": "2025-09-08T21:25:23.870Z", "node": "node-0002", "cpu.usage": 29.9, "requests": 102 }</code></pre><p>Let’s see what downsampling (up to version 9.3) produces from this data using a 10-minute interval:</p><pre><code># Downsampled data
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0001", "cpu.usage": {"min": 39.9, "max": 59.9, "sum": 149.7, "value_count": 3}, "requests": 2 }
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0002", "cpu.usage": {"min": 19.9, "max": 29.9, "sum": 49.8, "value_count": 2}, "requests": 102}</code></pre><p>A time series is uniquely identified by its <strong>dimension values</strong>, the node field in our example. Therefore, all documents summarised into a single bucket will share the same dimension values, meaning only one instance of the dimension values needs to be stored per bucket.</p><p>The <strong>timestamp</strong> and <strong>metrics</strong> are the fields whose values vary per document. For the timestamp (<code>@timestamp</code>), a rounding operation is performed to align it with the beginning of the bucket interval. For instance, in our example, the resulting timestamps are normalised to <code>2025-09-08T21:20:00.000Z</code> (UTC).</p><p>For the metrics, up to 9.3 we used to downsample them based on their metric type, for gauges (<code>cpu.usage</code>) we stored the min, max, sum, and count of the encountered values and for counters (<code>requests</code>) the observed last value.</p><p>As you can see, up to 9.3 we effectively used a last value sampling method for counters, and the aggregate method for gauges. From 9.4 onward, for each field type, both sampling methods are available for all metric types and you get to choose which one best fits your data and the available system resources.</p><h2>How downsampling sampling methods work</h2><p>Different use cases require different trade-offs. For maximum storage reduction, last-value sampling keeps only the most recent observation per bucket. For accurate aggregations, the aggregate method keeps min, max, sum and count. Some applications demand maximum storage reduction and fast downsampling. Other use cases prioritise results that retain the highest possible fidelity to the original data, optimising for accuracy over sheer speed or space savings.</p><p>For this reason, in versions 9.3 and 9.4 we worked on offering two distinct ways of downsampling metrics. In 9.3 we introduced a new sampling method called last value and in 9.4 we differentiated the way we downsample counters between the last value and the aggregate method.</p><h3>Last value sampling method</h3><p>The last value method consistently downsamples data across all field types. For each time series bucket, it creates a single document. This document is timestamped with the start of the bucket, and all fields retain the last observed value from that period. The fact that it only needs one value makes it very efficient since it does not need to go over all values. Looking at our previous example, the downsampled documents look like this:</p><pre><code># 10 minute interval, last value method
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0001", "cpu.usage": 59.9, "requests": 2 }
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0002", "cpu.usage": 29.9, "requests": 102 }</code></pre><p>While this method sacrifices data accuracy by discarding data points, it is a standard practice in time series solutions. Its primary benefit is preserving long-term trends while lowering the cost of data storage and querying. It is also lightweight, reducing the resources needed to generate downsampled data.</p><h3>Aggregate sampling method</h3><p>The aggregate sampling method does not skip any metric values, it collects all of them and then summarises them appropriately. The aggregate method processes each metric type differently, as explained below. Our example data, when downsampled with the aggregate method:</p><pre><code># 10 minute interval, aggregate method
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0001", "cpu.usage": {"min": 39.9, "max": 59.9, "sum": 149.7, "value_count": 3}, "requests": 3 }
{ "@timestamp": "2025-09-08T21:25:11.920Z", "node": "node-0001", "requests": 6 }
{ "@timestamp": "2025-09-08T21:25:21.940Z", "node": "node-0001", "requests": 2 }
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0002", "cpu.usage": {"min": 19.9, "max": 29.9, "sum": 49.8, "value_count": 2}, "requests": 100}</code></pre><p>In the next sections we will see how the aggregate method summarises each metric type.</p><h4>Gauges</h4><p>Gauges are a fundamental type of metric. Unlike counters or histograms, their values can both increase and decrease, reflecting the current state of a system component (in our example, <code>cpu.usage</code>). A single value per bucket isn&#x27;t enough to keep gauge aggregations accurate, since gauge values fluctuate. Instead, we track multiple statistical aggregates over each downsample interval:</p><ol><li><code>min</code> tracks the lowest value recorded for the gauge within the aggregation interval.</li><li><code>max</code> tracks the highest value recorded for the gauge within the aggregation interval.</li><li><code>sum</code> is the arithmetic sum of all individual gauge readings taken during the interval.</li><li><code>value_count</code> tracks the total number of individual gauge measurements that contributed to the aggregation interval.</li></ol><p>With these four statistics, we can answer the direct aggregations exactly (we know the true minimum, maximum, sum and count) and compute the average over the interval by dividing the recorded sum by the <code>value_count</code>. This approach preserves the shape of the gauge&#x27;s behaviour over time, regardless of the downsampling interval.</p><p>In our example, we see that for <code>node-0001</code> the downsampled document is stored in the format of an <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/aggregate-metric-double">aggregate metric double</a>:</p><pre><code>{"min": 39.9, "max": 59.9, "sum": 149.7, "value_count": 3}</code></pre><p>Some operations such as value filtering or standard deviation aren&#x27;t covered by the summary statistics. In 9.4, we address this by using the average value in ES|QL. This is a major milestone because now the pre-aggregated gauges can be fully supported by ES|QL. Any ES|QL dashboard built on top of raw data can now use downsampled data with no errors, just with the expected loss of accuracy from using the average value per downsample interval.</p><p>We chose the average as the most representative signal we have available for the original samples in each downsample interval. For example, let’s think of the following query:</p><pre><code>FROM my-data | STATS stddev = std_dev(cpu.usage)
FROM my-downsampled-data | STATS stddev = std_dev(cpu.usage)</code></pre><p>The first query is applied to the original data and uses the individual data points. The second query is applied on the aggregated cpu usage values so we do not have individual data points. We could use min or max, but then the values used would be skewed towards the min or the max point. For this reason, we decided to use the average that would better capture the values within an interval.</p><h4>Counters</h4><p>Counters (<code>requests</code> in our examples), which are measurements that only ever increase (for cumulative <a href="https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality">temporality</a>), seem straightforward for downsampling: just keeping a single value should be enough. However, when a process restarts, the counter value resets to zero.</p><p>The most common aggregation on a counter is its <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions/rate">rate of change</a>. Missing a counter reset when downsampling can drastically skew this rate, leading to inaccurate monitoring. Therefore, our downsampling process ensures that the rate calculation algorithm can still detect a reset, even when analysing downsampled data.</p><p>A rate algorithm detects a reset when a counter&#x27;s current value is lower than its previous recorded value. To maintain accuracy in downsampled data, we need to ensure that the last value before the reset is preserved, and the next value seen is correctly compared against it.</p><p>So, let’s see how the counter <code>requests</code> in a downsampled bucket with reset look like:</p><pre><code># Main downsampled document
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0001", "requests": 3, "cpu.usage": ...}
# Pre-reset value
{ "@timestamp": "2025-09-08T21:25:11.920Z", "node": "node-0001", "requests": 6 }
# Post-reset value
{ "@timestamp": "2025-09-08T21:25:21.940Z", "node": "node-0001", "requests": 2 }</code></pre><p>The main downsampled bucket stores the first counter value observed in that time frame. We choose the first value because it is the closest to the bucket&#x27;s start timestamp. In addition to that, when a counter reset occurs within this time bucket, we store auxiliary documents with their original timestamps that hold the values to preserve the reset event:</p><ol><li><strong>Last value before reset</strong> is stored in an auxiliary document. This records the maximum value the counter reached before the reset event.</li><li><strong>Value after reset (conditional)</strong> may also be stored in a second auxiliary document. This is <strong>optional </strong>because if the first value of the next downsampled bucket is already lower than the stored pre-reset value, the rate algorithm can infer the reset without needing this intermediate data point.</li></ol><p>Let’s consider a more elaborate example, we have a stream of counter values within a time window, simulating a reset:</p><p><strong>Bucket 1</strong>: <strong>1000</strong> 1003 1010 1040 <strong>1060 (reset)</strong> 20 30 40 70 80</p><p><strong>Bucket 2</strong>: <strong>90</strong> ...</p><p>This sequence results in the following downsampled documents:</p><ul><li>Downsampled bucket 1: stores the first value (1000).</li><li>Auxiliary document: stores the pre-reset value (1060).</li><li>Post-reset value: we do not need to store value 20, because the next downsampled bucket&#x27;s starting value (90) is already lower than the pre-reset value (1060). The rate calculation will correctly detect the reset by comparing 90 against 1060.</li><li>Next downsampled bucket 2: stores the first value (90).</li></ul><p>The total change (delta) in the counter value is calculated via:</p><div class="katex">delta = (preResetMax - bucket_1) + bucket_2</div><p>Notice that it remains the same for the original and the aggregated data because we have all the values available in both data sets: $(1060 - 1000) + 90 = 150$. In comparison, the last value misses the increase within the first bucket and only observes an increase of 90, 40% less.</p><p>Although the delta is accurately observed, the final downsampled rate may not be exactly the same as the raw data rate because of the rate extrapolation and interpolation. Still, this method keeps the result close to the raw rate, even when counters reset.</p><h4>How aggregate method handles histogram metrics</h4><p>The aggregate downsampling method merges histogram metrics into a single representative histogram per bucket, preserving distribution shape while reducing volume. The process depends on the histogram type.</p><p>For <a href="https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql">exponential histograms</a>, which are designed for efficient storage of data with a wide range, their values and counts are aggregated and merged into a single, representative exponential histogram. This maintains the proportional distribution and statistical characteristics of the original datasets.</p><p>Conversely, for the older <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/histogram">histogram</a> field type and the newer <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/t-digest">tdigest</a> field type, the merging is performed using the TDigest algorithm. This ensures that essential metrics derived from the distributions, such as the median or the 95th percentile, remain statistically reliable after the downsampling process is complete.</p><p>Let’s see an example with exponential histograms:</p><pre><code># Original data
{ "@timestamp": "2025-09-08T21:25:01.930Z", "node": "node-0001", "histogram": { "scale": 0, "sum": 7.0, "min": 1.0, "max": 4.0, "positive": {"indices": [0,1], "counts": [3,2]}}}
{ "@timestamp": "2025-09-08T21:25:11.920Z", "node": "node-0001", "histogram": { "scale": 0, "sum": 30.0, "min": 4.0, "max": 16.0,
  "positive": {"indices": [2,3], "counts": [4,1]}}}
{ "@timestamp": "2025-09-08T21:25:21.940Z", "node": "node-0001", "histogram": { "scale": 0, "sum": 55.0, "min": 0.5, "max": 32.0, "zero": { "count": 2, "threshold": 0.5 }, "positive": {"indices": [1,4], "counts": [5,3]}}}

# 10 minute interval, aggregate method
{ "@timestamp": "2025-09-08T21:20:00.000Z", "node": "node-0001", "histogram": { "scale": 0, "sum": 92.0, "min": 0.5, "max": 32.0, "zero": { "count": 2, "threshold": 0.5 }, "positive": { "indices": [0,1,2,3,4], "counts": [3,7,4,1,3] }}}</code></pre><p>As you can see, the downsampled histogram is capturing the values of all three histograms and not just the last one as the last value method would have done.</p><p><em>Note</em>: the <code>histogram</code> field type does not record which algorithm was used to build it. Histograms built with TDigest are downsampled correctly with the aggregate method. Histograms built with High Dynamic Range (HDR) are not, because the aggregate method cannot detect that. For HDR data, use the last value method instead.</p><h3>How to configure Elasticsearch downsampling methods</h3><p>You can configure the sampling method of a time series data stream using either data stream lifecycle or ILM:</p><pre><code># Data stream lifecycle
PUT _data_stream/my-data-stream/_lifecycle
{
  "data_retention": "7d",
  "downsampling_method": "aggregate",
  "downsampling": [
     {
       "after": "1m",
       "fixed_interval": "10m"
      },
      {
        "after": "1d",
        "fixed_interval": "1h"
      }
   ]
}

# ILM
PUT _ilm/policy/datastream_policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover" : {
            "max_age": "5m"
          },
          "downsample": {
  	        "fixed_interval": "5m",
  	        "sampling_method": "aggregate"
  	      }
        }
      },
      "warm": {
        "actions": {
          "downsample": {
            "fixed_interval": "1h",
  	        "sampling_method": "aggregate"
          }
        }
      }
    }
  }
}</code></pre><p><em>Note</em>: all downsampling actions in a single ILM policy should have the same sampling method configured.</p><h4>How do I switch between downsampling methods?</h4><p>It is possible to switch from one sampling method to another, but a downsampled index can be downsampled further only with the same sampling method.</p><p>The data stream lifecycle takes care of this, so if you change the sampling method in a data stream lifecycle configuration, the data stream lifecycle will apply it to the original indices, but if there are already downsampled indices it will continue applying the compatible one.</p><p>On the other hand, if you are using ILM and the policy has <strong>more than one downsampling action</strong>, it is recommended to create a new policy with the new sampling method. This way, the existing indices will continue downsampling with the old sampling method and the new ones will transition to the new one. More specifically, we recommend the following steps:</p><ol><li>Copy the ILM policy and change the sampling method in the downsampling actions.</li><li>Update the relevant index templates to use the new ILM policy.</li></ol><h3>How do last-value and aggregate downsampling compare?</h3><p>Now let’s compare the two methods on more realistic data. We generated an hour of OTel metrics from a receiver. This was done using <a href="https://github.com/elastic/metricsgenreceiver">metricsgenreceiver</a> with the following configuration:</p><ul><li>scenario: <code>hostmetricsreceiver</code></li><li>interval: <code>5s</code></li></ul><p>Next, we downsampled the data into 10-minute buckets using both sampling methods. We then compared key performance characteristics, including the resulting document count, the size of the dataset (after being force-merged into a single segment), and the time taken for the downsampling process.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Doc count</td><td style="border: 1px solid #ccc; padding: 4px;">Data set size</td><td style="border: 1px solid #ccc; padding: 4px;">Duration</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Raw</td><td style="border: 1px solid #ccc; padding: 4px;">11509670</td><td style="border: 1px solid #ccc; padding: 4px;">176.2 MB</td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Aggregate</td><td style="border: 1px solid #ccc; padding: 4px;">(-98.83%) 134370</td><td style="border: 1px solid #ccc; padding: 4px;">(-93.64%) 11.2 MB</td><td style="border: 1px solid #ccc; padding: 4px;">2.8s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Last value</td><td style="border: 1px solid #ccc; padding: 4px;">(-98.89%) 127170</td><td style="border: 1px solid #ccc; padding: 4px;">(-94.72%) 9.3 MB</td><td style="border: 1px solid #ccc; padding: 4px;">(-7.14%) 2.6s</td>
                    </tr>
              </tbody>
            </table>
          <p>Both downsampling methods (last value and aggregate) significantly reduce the initial 1.51 million documents by 98%. However, the aggregate method retains slightly more documents to account for counter resets.</p><p>When considering the overall data set size, both methods show a substantial reduction compared to the raw data, but a more noticeable difference emerges between the two: the last value method achieves a greater size reduction than the aggregate method. This difference is not solely due to counter resets; it&#x27;s also because the aggregate method stores more detailed information per gauge to maintain the accuracy of common aggregations.</p><p>The last value downsampling method is also faster, completing the process 7% sooner than the aggregate method. This efficiency is because the last value method does not need to collect every value; after the last value is found, the rest are ignored.</p><p>The last value method wins on data size and speed; the aggregate method is more accurate on queries, which is its main advantage.</p><p>The following section will demonstrate this with three ES|QL query examples using the recently introduced <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts">TS command</a> which uses time series semantics and lets us use <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">time series aggregations</a> to examine how time series change over time. These queries will run against all three indices (raw, last value, and aggregate) to compare how the downsampled data relates to the raw data concerning query accuracy.</p><h4>Gauges</h4><p>For downsampling gauges, we will compare the following time series aggregations:</p><ul><li>The minimum of <code>min_over_time</code></li><li>The maximum of <code>max_over_time</code></li><li>The average of <code>avg_over_time</code></li><li>The standard deviation (<code>std_dev</code>) of <code>last_over_time</code></li><li>The standard deviation (<code>std_dev</code>) of <code>avg_over_time</code></li></ul><pre><code>TS <data> | STATS 
min = MIN(MIN_OVER_TIME(system.cpu.logical.count)), 
max = MAX(MAX_OVER_TIME(system.cpu.logical.count)), 
avg = AVG(AVG_OVER_TIME(system.cpu.logical.count))

TS <data> | STATS stddev = std_dev(last_over_time(system.cpu.logical.count))
TS <data> | STATS stddev = std_dev(avg_over_time(system.cpu.logical.count))</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">min</td><td style="border: 1px solid #ccc; padding: 4px;">max</td><td style="border: 1px solid #ccc; padding: 4px;">avg</td><td style="border: 1px solid #ccc; padding: 4px;">std_dev of last value</td><td style="border: 1px solid #ccc; padding: 4px;">Std_dev of avg</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Raw data</td><td style="border: 1px solid #ccc; padding: 4px;">0</td><td style="border: 1px solid #ccc; padding: 4px;">55</td><td style="border: 1px solid #ccc; padding: 4px;">23.3369</td><td style="border: 1px solid #ccc; padding: 4px;">0.79372</td><td style="border: 1px solid #ccc; padding: 4px;">0.04318</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Aggregate</td><td style="border: 1px solid #ccc; padding: 4px;">0</td><td style="border: 1px solid #ccc; padding: 4px;">55</td><td style="border: 1px solid #ccc; padding: 4px;">23.3369</td><td style="border: 1px solid #ccc; padding: 4px;">0.13253</td><td style="border: 1px solid #ccc; padding: 4px;">0.04318</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Last value</td><td style="border: 1px solid #ccc; padding: 4px;">0</td><td style="border: 1px solid #ccc; padding: 4px;">41</td><td style="border: 1px solid #ccc; padding: 4px;">18.5261</td><td style="border: 1px solid #ccc; padding: 4px;">0.79372</td><td style="border: 1px solid #ccc; padding: 4px;">0.46364</td>
                    </tr>
              </tbody>
            </table>
          <p>The aggregate sampling method preserves accuracy for most results, such as min, max, and average, because it uses pre-aggregated data from the buckets. This data accurately reflects the raw data for all metrics except the standard deviation of the last value.</p><p>Since the pre-aggregated data does not include the raw last value, the system defaults to using the <em>average value</em> for the bucket to calculate the last value over time. This substitution reduces the overall variance and, consequently, the standard deviation compared to the raw data.</p><p>Conversely, the calculation based on the last value alone loses accuracy for min, max, and average because it omits certain data points. However, this method maintains the accuracy of the standard deviation of the last value query, as both rely on the same single last value within the bucket.</p><h4>Counters</h4><p>For counters, we will focus solely on the rate, as it is the most practical aggregation. Specifically, we will query the minimum and maximum rate observed across the time series.</p><pre><code>TS <data> | STATS 
min_rate = min(rate(system.disk.io)), 
max_rate = max(rate(system.disk.io))</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Min rate</td><td style="border: 1px solid #ccc; padding: 4px;">Max rate</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Raw</td><td style="border: 1px solid #ccc; padding: 4px;">332214509.24</td><td style="border: 1px solid #ccc; padding: 4px;">381462158.55</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Aggregate</td><td style="border: 1px solid #ccc; padding: 4px;">322801874.77</td><td style="border: 1px solid #ccc; padding: 4px;">370653936.12</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Last value</td><td style="border: 1px solid #ccc; padding: 4px;">11.78</td><td style="border: 1px solid #ccc; padding: 4px;">370653936.71</td>
                    </tr>
              </tbody>
            </table>
          <p>The maximum rate observed closely matches that of the raw data for both sampling methods. However, the minimum rate detected shows a significant difference because the last value method results in the loss of all reset information calculating a significantly lower value.</p><h3>Choosing the right downsampling method</h3><p>As we demonstrated in the previous sections, downsampling can substantially reduce data size and improve query performance, regardless of the sampling method used. The default sampling method is still the aggregate method, so accuracy is preserved by default. However, if your primary concern is reducing resource consumption during the downsampling process and in the resulting data, you now have the option to accept a slight decrease in accuracy for a more resource-efficient outcome.</p><h2>What&#x27;s next for Elasticsearch downsampling?</h2><p>Three areas are in active development: sparse data performance, layered downsampling, and lifting the read-only index requirement.</p><h3>Performance improvements for sparse data</h3><p>Downsampling has been optimised keeping in mind that all the documents of a time series data stream will have the same fields. For example, in a data stream containing node metrics, we expected that all nodes would be defined by the same set of dimensions and all documents would contain the same metrics about these nodes.</p><p>However, in practice we see that this is not always the case, and quite often a time series data stream contains documents that represent measurements, that are defined by different sets of dimensions and contain different metrics. For example, the same data stream could have kubernetes metrics but also application metrics.</p><p>This is reflected in recent improvements, since 9.3, time series indices use doc values skippers, a form of sparse indices, instead of inverted indices and BKD trees, allowing them to be very efficient in this case. Following this direction, we will focus on improving the downsampling algorithm to use optimisation opportunities that this type of data provides.</p><h3>The future of downsampling</h3><p>We believe the improvements described above enable adoption of downsampling for most metrics applications. Three constraints still shape how teams use it:</p><ul><li>downsampling depends on ILM or data stream lifecycle for automation,</li><li>requires the source index to be read-only,</li><li>and replaces the raw data once complete.</li></ul><p>We plan to address all three with a more flexible downsampling solution built around multiple downsampling layers.</p><p>Layers will lift the requirement to delete the original data, so you can start downsampling much earlier and query the pre-aggregated data even for the most recent information.</p><p>Multiple layers will also let you downsample at different granularities and manage each layer separately. For example, you could move the original data to the frozen tier sooner and keep only the pre-aggregated data in the hot tier, balancing cost and query performance per layer.</p><p>We expect to share design details and an early preview of the layered model in an upcoming post.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[12x faster Elasticsearch vector indexing: deploying NVIDIA cuVS with GPU and CPU tiers]]></title>
            <description><![CDATA[Two patterns for deploying NVIDIA cuVS GPU-accelerated HNSW indexing in Elasticsearch: combined build-and-serve nodes for small clusters and a dedicated GPU ingest tier with ILM handoff to CPU for production at scale.]]></description>
            <link>https://www.elastic.co/search-labs/blog/nvidia-cuvs-elasticsearch-gpu-vector-indexing</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/nvidia-cuvs-elasticsearch-gpu-vector-indexing</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Blake Holden]]></dc:creator>
            <pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/be87b2ffebac84d33b9ace1f332e314b7705267d-960x540.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>NVIDIA cuVS builds HNSW graphs on the GPU, delivering up to 12x faster vector indexing in Elasticsearch. This post covers two production deployment patterns: Pattern A runs build and serve on the same GPU node, suited for clusters under five data nodes. Pattern B uses three lean GPU ingest nodes (64 GB RAM each) that hand off via ILM rollover to seven CPU hot-serving nodes (192 GB each) - the right default for production at scale. Full index template, <a href="https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management">ILM</a> policy, and sizing math for a 300M-vector corpus are included.</p><h2><strong>What does the Elasticsearch cuVS GPU plugin actually do?</strong></h2><p>The Elasticsearch cuVS GPU plugin takes over HNSW graph construction during two operations: segment flush and force merge. The plugin requires a supported NVIDIA GPU attached to the data node, the cuVS plugin installed, and <code>vectors.indexing.use_gpu: true</code> in <code>elasticsearch.yml</code>.</p><ol><li><strong>Segment flush.</strong> Vectors accumulate in the JVM write buffer. At flush, the plugin batches them, copies them to GPU VRAM via PCIe, constructs a <a href="https://docs.rapids.ai/api/cuvs/stable/python_api/neighbors_cagra/">CAGRA</a> graph in VRAM, converts it to HNSW, copies the result back to system RAM via PCIe, and then Lucene writes the segment file to local NVMe. The segment is subsequently memory-mapped into the OS page cache for query serving.</li><li><strong>Force merge.</strong> When segments are merged, the same GPU path speeds up graph reconstruction, delivering approximately 7x faster force-merge times according to the published benchmark.</li></ol><p>Everything else (HTTP request handling, query serving, cluster state management, ILM execution) runs on the CPU and uses system RAM. The GPU is a coprocessor for two write-path operations, not a replacement for the host compute environment.</p><p>The separation of GPU build from CPU query serving matters because GPU-attached nodes don&#x27;t need to be the query-serving tier.They can be dedicated to the write path and hand off built shards to cheaper CPU hot-serving nodes. That&#x27;s the core insight behind Pattern B.</p><h2><strong>Pattern A: combined GPU build and serve in one Elasticsearch node</strong></h2><p>Pattern A is the simpler deployment. Every GPU node builds shards <em>and</em> serves queries on those shards. This is how the cuVS benchmark was run: a single g6.4xlarge (64 GB RAM, 1x NVIDIA L4) running indexing on a single Elasticsearch process. The node is capable of both indexing and search concurrently, though the published benchmark measured indexing throughput specifically.</p><h3><strong>When to use combined GPU nodes (Pattern A)</strong></h3><ul><li>Small clusters (fewer than 5 data nodes).</li><li>Proof-of-concept or edge deployments where operational simplicity outweighs cost optimization.</li><li>Corpora small enough that the full HNSW graph fits in each node&#x27;s page cache alongside normal operations.</li></ul><h3><strong>Node configuration (Pattern A)</strong></h3><p>Every data node gets a GPU and the same <code>elasticsearch.yml</code> config:</p><pre><code># elasticsearch.yml: Pattern A (combined GPU + serving node)
node.roles: [data_hot, data_content]

# auto (default): use GPU when available. true: fail to start if GPU unavailable.
# Use true on dedicated GPU nodes to catch misconfiguration at boot.
vectors.indexing.use_gpu: true

# JVM heap: ~32 GB max (compressed OOPs boundary)
# Remaining system RAM goes to OS page cache for HNSW graph</code></pre><p>No shard-routing tricks, no ILM tier separation. Shards land where Elasticsearch&#x27;s default allocator puts them, and every node can both build and serve.</p><p>Note: there is also an index-level setting, <code>index.vectors.indexing.use_gpu</code>, that can override the node-level default on a per-index basis. Valid values are <code>auto</code> (default, uses GPU when available), <code>true</code> (requires GPU, fails if unavailable), and <code>false</code> (disables GPU for this index).</p><h3><strong>Sizing implications</strong></h3><p>Because each node holds long-lived shards, it needs enough system RAM for:</p><ul><li>JVM heap (~32 GB)</li><li>OS page cache holding the HNSW graph for its share of the corpus</li><li>OS and CUDA overhead (~10-15 GB)</li></ul><p>For a 300M-vector int8 corpus with HA (two copies), each of 10 data nodes holds ~65 GB of HNSW data in page cache. Add JVM and overhead and you land at roughly <strong>128–192 GB of system RAM per node</strong>. That&#x27;s much more than the 64 GB reference benchmark because the benchmark held only 2.6M vectors on a single node.</p><p>System RAM per node scales with shards-per-node, not with total corpus size. More nodes at lower RAM, or fewer nodes at higher RAM. The tradeoff is operational complexity versus hardware cost.</p><p><strong>A note on the published benchmark.</strong> The ~12x indexing throughput and ~7x force-merge numbers were measured using 2.6M vectors at 1,536 dimensions with float32 <code>hnsw</code> on a single g6.4xlarge node. This post&#x27;s examples use 1,024 dimensions with <code>int8_hnsw</code>. Performance characteristics may vary with different dimension counts and quantization levels, so benchmark your own corpus for production sizing.</p><h2><strong>Pattern B: dedicated GPU ingest nodes with ILM shard handoff to CPU</strong></h2><p>Pattern B separates the cluster into two data tiers with different hardware profiles and different roles:</p><ul><li><strong>GPU ingest tier.</strong> Small number of nodes with NVIDIA GPUs and modest system RAM (64 GB). These nodes accept writes, build HNSW segments on GPU via cuVS, and own the active write shard.</li><li><strong>CPU hot-serving tier.</strong> Larger number of nodes without GPUs and larger system RAM (192 GB). These nodes receive migrated shards from the GPU ingest tier via ILM rollover and serve all query traffic.</li></ul><p>Once a shard rolls over and migrates to the CPU hot-serving tier, the GPU ingest node no longer owns it. The GPU ingest node&#x27;s page cache footprint stays small because it only ever holds the currently-writing index.</p><h3><strong>When to use GPU ingest + CPU serving (Pattern B)</strong></h3><ul><li>Production clusters with 5+ data nodes, where ILM rollover is already part of the operational cadence.</li><li>When GPU-attached hardware is expensive and you want to minimize how many nodes need GPUs.</li><li>When query-serving workloads benefit from dedicated CPU and RAM that isn&#x27;t shared with GPU build operations.</li><li>When ILM is already part of the operational model (which it should be for any time-series or lifecycle-managed vector corpus).</li></ul><h3><strong>Node configuration (Pattern B)</strong></h3><p><strong>GPU ingest nodes</strong> (<code>elasticsearch.yml</code>):</p><pre><code># GPU ingest node: builds shards, does not serve queries long-term
node.roles: [data_hot]
node.attr.tier_function: gpu_ingest

vectors.indexing.use_gpu: true</code></pre><p><strong>CPU hot-serving nodes</strong> (<code>elasticsearch.yml</code>):</p><pre><code># CPU hot-serving node: receives migrated shards, serves queries
node.roles: [data_hot]
node.attr.tier_function: cpu_serve</code></pre><p>Both node types share the <code>data_hot</code> role because they participate in the same logical tier. The custom attribute <code>node.attr.tier_function</code> lets us control which nodes receive new writes versus migrated shards.</p><h3><strong>Index template: route new writes to GPU nodes</strong></h3><pre><code>PUT _index_template/vectors-template
{
  "index_patterns": ["vectors-*"],
  "template": {
    "settings": {
      "index.routing.allocation.require.tier_function": "gpu_ingest",
      "index.lifecycle.name": "vectors-lifecycle",
      "index.lifecycle.rollover_alias": "vectors-active"
    },
    "mappings": {
      "properties": {
        "embedding": {
          "type": "dense_vector",
          "dims": 1024,
          "index": true,
          "index_options": {
            "type": "int8_hnsw"
          }
        }
      }
    }
  }
}
</code></pre><p>New indices matching <code>vectors-*</code> are allocated exclusively to nodes with <code>tier_function: gpu_ingest</code>. Writes flow to GPU ingest nodes, and cuVS builds the HNSW graph on the GPU.</p><p><strong>Critical: you must explicitly set `index_options.type` to `int8_hnsw` (or `hnsw`) for GPU acceleration.</strong> In Elasticsearch 9.1 and later, <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector">`dense_vector`</a> fields with 384 or more dimensions default to <code>bbq_hnsw</code>, which cuVS does not support. If you create vector fields outside this template without specifying <code>int8_hnsw</code>, the GPU plugin will fall back to CPU for indexing. The template above sets this correctly, but every vector field in the cluster that should benefit from cuVS needs the explicit setting.</p><h3><strong>ILM policy: roll over and migrate to CPU serving nodes</strong></h3><pre><code>PUT _ilm/policy/vectors-lifecycle
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_age": "7d",
            "max_primary_shard_size": "50gb"
          },
          "set_priority": {
            "priority": 100
          }
        }
      },
      "warm": {
        "min_age": "0ms",
        "actions": {
          "migrate": {
            "enabled": false
          },
          "allocate": {
            "require": {
              "tier_function": "cpu_serve"
            }
          },
          "set_priority": {
            "priority": 50
          }
        }
      },
      "cold": {
        "min_age": "180d",
        "actions": {
          "migrate": {
            "enabled": false
          },
          "allocate": {
            "require": {
              "tier_function": "cpu_serve"
            }
          }
        }
      },
      "delete": {
        "min_age": "730d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}
</code></pre><p>A few things to note in this policy.</p><p>The <strong>`&quot;migrate&quot;: {&quot;enabled&quot;: false}`</strong> in the warm and cold phases is important. The warm-phase migrate action would normally attempt to set <code>_tier_preference</code> to <code>data_warm</code>, but since no nodes in this architecture have the data_warm role, shards could become unallocatable. Disabling migrate and using an explicit <code>allocate</code> action with our custom <code>tier_function</code> attribute gives us precise control over where shards land.</p><p>The <strong>`min_age: &quot;0ms&quot;`</strong> on the warm phase means migration happens immediately after rollover, not after a time delay. This is intentional: we want shards off the GPU ingest nodes within minutes of rollover to keep the GPU ingest tier lean.</p><p><strong>Alternative approach</strong>: if the naming confusion between &quot;warm&quot; (ILM phase name) and &quot;hot serving&quot; (what the CPU nodes actually do) is a problem for your team, you can instead assign GPU ingest nodes <code>node.roles: [data_hot]</code> and CPU hot-serving nodes <code>node.roles: [data_warm]</code>, then let ILM&#x27;s standard tier migration handle the move without custom attributes. The tradeoff is simpler ILM but potentially confusing semantics: your heaviest query-serving nodes are labeled &quot;warm.&quot;</p><h3><strong>Why 64 GB system RAM is enough for GPU ingest nodes</strong></h3><p>Under Pattern B, a GPU ingest node only holds the following (approximate, based on standard Elasticsearch JVM sizing guidance and observed CUDA runtime overhead):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Consumer</td><td style="border: 1px solid #ccc; padding: 4px;">Approximate RAM</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">JVM heap</td><td style="border: 1px solid #ccc; padding: 4px;">~32 GB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">CUDA driver + pinned PCIe buffers</td><td style="border: 1px solid #ccc; padding: 4px;">~10 GB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Page cache for the active write shard</td><td style="border: 1px solid #ccc; padding: 4px;">~10-15 GB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OS and container overhead</td><td style="border: 1px solid #ccc; padding: 4px;">~5 GB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total</td><td style="border: 1px solid #ccc; padding: 4px;">~60 GB</td>
                    </tr>
              </tbody>
            </table>
          <p>This matches the published cuVS benchmark hardware: AWS g6.4xlarge with 64 GB RAM. The GPU ingest node does not need to hold the accumulated HNSW graph for the entire corpus, only the currently-writing index, which rolls over on a short cadence.</p><p>CPU hot-serving nodes, by contrast, hold the accumulated corpus in page cache and need 128–192 GB depending on vectors-per-node.</p><h3><strong>Sizing summary (Pattern B, 300M vectors, int8_hnsw, HA)</strong></h3>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Node type</td><td style="border: 1px solid #ccc; padding: 4px;">Count</td><td style="border: 1px solid #ccc; padding: 4px;">System RAM</td><td style="border: 1px solid #ccc; padding: 4px;">GPU</td><td style="border: 1px solid #ccc; padding: 4px;">Role</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">GPU ingest</td><td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">64 GB</td><td style="border: 1px solid #ccc; padding: 4px;">1x L40S (48 GB VRAM)</td><td style="border: 1px solid #ccc; padding: 4px;">Build shards, cuVS</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">CPU hot-serving</td><td style="border: 1px solid #ccc; padding: 4px;">7</td><td style="border: 1px solid #ccc; padding: 4px;">192 GB</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">Serve queries, hold HNSW in page cache</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Warm/cold (BBQ)</td><td style="border: 1px solid #ccc; padding: 4px;">5</td><td style="border: 1px solid #ccc; padding: 4px;">64 GB</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">Aged data, ~8x compression</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Masters</td><td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">16 GB</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">Cluster quorum</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Coord + Kibana</td><td style="border: 1px solid #ccc; padding: 4px;">4</td><td style="border: 1px solid #ccc; padding: 4px;">32–64 GB</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">Query routing, UI</td>
                    </tr>
              </tbody>
            </table>
          <p>Total ERU (Elastic Resource Unit) under <a href="https://www.elastic.co/elastic-cloud-kubernetes">ECK</a>: ~32.</p><h2><strong>How data flows through Pattern B, end to end</strong></h2><p>In Pattern B, vectors flow from the client to a GPU ingest node, where cuVS builds the HNSW segment on the GPU before ILM migrates the finished shard to a CPU hot-serving node. The sequence diagram below shows the round-trip for one batch of vectors, followed by the rollover that moves the finished shard to the serving tier:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/44756ff580e94ddb2200368f98e53f32356a817e-1361x725.png" style="width: 100%;" /><p>In step form:</p><ol><li>A client sends a <code>_bulk</code> request with vectors encoded as base64 strings (recommended for throughput).</li><li>The coordinating node routes the request to the GPU ingest node that owns the active write shard.</li><li>The GPU ingest node&#x27;s JVM parses the request, queues vectors in the write buffer.</li><li>At flush threshold, the cuVS plugin batches vectors and copies them from system RAM to GPU VRAM via PCIe.</li><li>The GPU constructs a CAGRA graph in VRAM and converts it to HNSW format.</li><li>The HNSW data is copied back to system RAM via PCIe.</li><li>Lucene writes the segment file from system RAM to local NVMe.</li><li>The segment is memory-mapped into OS page cache (system RAM) and is now queryable.</li><li>After the configured rollover threshold (time or size), ILM rolls the index over and creates a new write index on the GPU ingest tier.</li><li>The rolled index&#x27;s allocation requirement changes from <code>gpu_ingest</code> to <code>cpu_serve</code>, and Elasticsearch&#x27;s shard allocator migrates the shards to CPU hot-serving nodes over the network.</li><li>CPU hot-serving nodes memory-map the received segments into their page cache and begin serving queries.</li><li>The GPU ingest node&#x27;s page cache is released as shards leave, freeing it for the next write cycle.</li></ol><p>At no point does query traffic touch the GPU ingest nodes. At no point does the GPU need to hold more than one batch of vectors in VRAM. The GPU is busy during steps 4–6 and idle otherwise. System RAM is in the path on both sides of the GPU: as the staging area for PCIe transfers, and as the persistent page cache for serving.</p><h2><strong>Adding a BBQ cold tier with requantization</strong></h2><p>Pattern B extends to a BBQ cold tier for long-retention corpora. BBQ delivers roughly 8x compression over int8, shrinking the cold tier&#x27;s RAM and disk footprint dramatically. The ILM policy adds a phase that reindexes the int8_hnsw data into a new index configured with bbq_hnsw:</p><pre><code>"cold": {
  "min_age": "180d",
  "actions": {
    "allocate": {
      "require": {
        "tier_function": "cpu_serve"
      }
    }
  }
}</code></pre><p>In practice, the requantization happens via a separate reindex job triggered alongside the cold-phase transition. BBQ delivers roughly 8x compression over int8 (and ~32x over float32), so the cold tier&#x27;s RAM and disk footprint shrinks dramatically.</p><p>Note that BBQ is a CPU-only quantization path. <code>bbq_hnsw</code> is not supported by cuVS in Elasticsearch as of 9.5. This is why the GPU ingest tier builds with <code>int8_hnsw</code> and the BBQ conversion happens later, on CPU warm or cold nodes. There is no GPU dependency on the cold path.</p><h2><strong>Which pattern should you choose?</strong></h2><p>For most production clusters with five or more data nodes, Pattern B is the better default. Pattern A is the right choice for proof-of-concept deployments and small clusters where operational simplicity matters more than cost.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Factor</td><td style="border: 1px solid #ccc; padding: 4px;">Pattern A (combined)</td><td style="border: 1px solid #ccc; padding: 4px;">Pattern B (dedicated GPU ingest)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Cluster size</td><td style="border: 1px solid #ccc; padding: 4px;">Fewer than ~5 data nodes</td><td style="border: 1px solid #ccc; padding: 4px;">5+ data nodes</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Operational complexity</td><td style="border: 1px solid #ccc; padding: 4px;">Lower</td><td style="border: 1px solid #ccc; padding: 4px;">Higher (ILM + allocation routing)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">GPU pod system RAM</td><td style="border: 1px solid #ccc; padding: 4px;">128–192 GB</td><td style="border: 1px solid #ccc; padding: 4px;">64 GB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">GPU utilization</td><td style="border: 1px solid #ccc; padding: 4px;">GPU idle during queries</td><td style="border: 1px solid #ccc; padding: 4px;">GPU idle between flushes, but node is not serving queries</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hardware cost</td><td style="border: 1px solid #ccc; padding: 4px;">Higher (each GPU node needs query-serving RAM)</td><td style="border: 1px solid #ccc; padding: 4px;">Lower (GPU ingest nodes are lean)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ERU impact</td><td style="border: 1px solid #ccc; padding: 4px;">Higher</td><td style="border: 1px solid #ccc; padding: 4px;">Lower</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Best for</td><td style="border: 1px solid #ccc; padding: 4px;">POC or edge deployments</td><td style="border: 1px solid #ccc; padding: 4px;">Production at 5+ data nodes</td>
                    </tr>
              </tbody>
            </table>
          <p>For most production deployments, Pattern B is the better default. The operational complexity is modest (the ILM policy and allocation attributes shown in this post are the entire implementation), and the savings in GPU-node RAM and ERU are material.</p><h2><strong>Getting started with NVIDIA cuVS GPU vector indexing</strong></h2><p>GPU-accelerated vector indexing with NVIDIA cuVS is available in technical preview for Elasticsearch 9.3 (self-managed Enterprise) and is targeted for general availability in Elasticsearch 9.5.</p><p>Requirements:</p><ul><li>Elasticsearch 9.3+ with Enterprise subscription</li><li>NVIDIA Ampere architecture GPU or newer (compute capability ≥ 8.0), minimum 8 GB VRAM</li><li>CUDA 12.x and cuVS runtime libraries (refer to the <a href="https://www.elastic.co/support/matrix">Elastic support matrix</a> for exact supported versions)</li><li>Java 22 or higher on the GPU node</li><li>Linux x86_64</li><li>Fast local NVMe (network-attached storage is not recommended)</li></ul><p>For teams weighing this against FAISS or a dedicated vector database like Milvus or Pinecone, the operational pitch is the same single platform, same ATO, same ops model, with GPU-accelerated ingest layered onto an existing Elasticsearch cluster. The broader Elastic and NVIDIA partnership context is in <a href="https://www.elastic.co/blog/elastic-nvidia-cuvs-integration">Elastic and NVIDIA together unlock next generation enterprise AI search</a>.</p><p>Start with Pattern A on a single GPU node to validate throughput on your corpus. Once you have confidence in the numbers, move to Pattern B with the ILM policy above, scale the CPU hot-serving tier to match your query SLA, and let the GPU ingest tier do the one thing it is built for: building HNSW graphs at 12x the speed of CPU.</p><p><br/><em>For the cuVS benchmark methodology and results, see [<a href="https://www.elastic.co/search-labs/blog/elasticsearch-gpu-accelerated-vector-indexing-nvidia">Up to 12x faster vector indexing in Elasticsearch with NVIDIA cuVS</a>]. For the broader Elastic and NVIDIA partnership, see [<a href="https://www.elastic.co/blog/elastic-nvidia-cuvs-integration">Elastic and NVIDIA together unlock next generation enterprise AI search</a>].</em></p><p><br/></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[One query, multiple Elasticsearch Serverless projects: introducing cross-project search]]></title>
            <description><![CDATA[Cross-project search in Elastic Cloud Serverless lets you query data across isolated projects in a single Elasticsearch or ES|QL request: no duplication, no network peering, and no egress costs from copying logs.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-cross-project-search-serverless</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-cross-project-search-serverless</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Michael Peterson, Najwa Harif]]></dc:creator>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c43c52492a7d6158487958becc31f57cb81b168d-720x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/explore-analyze/cross-project-search">Cross-project search (CPS)</a> is now available in Elastic Cloud Serverless. With a single query like <code>FROM logs*</code>, you can search data across multiple isolated projects - no network peering, no certificate management, no data duplication. Projects stay in their own regions and clouds; only the results come back to you. For teams dealing with data residency requirements, tenant isolation, or high egress costs from copying logs, CPS means your data can live exactly where it belongs and still be queried as one.</p><p>Elastic Cloud Serverless already removes the headache of managing infrastructure and version upgrades. CPS takes that a step further. We&#x27;ve replaced complex network peering and manual certificate management with a simple linking model. Now, you can just treat your Elastic Cloud Serverless projects as simple namespaces for your data. Whether you&#x27;re dealing with strict data residency laws, isolating tenant data, or just trying to avoid the massive network egress fees that come from duplicating logs, CPS lets you search your data exactly where it lives with a single query.</p><p>In this post, we’ll walk through how CPS works, how to control your searches using project tags, and how this new model differs from traditional <a href="https://www.elastic.co/docs/solutions/search/cross-cluster-search">cross-cluster search (CCS)</a>.</p><h2>How to link projects for cross-project search</h2><p>To get started with cross-project search, link projects in the Elastic Cloud Console or API. Linking is simple and unidirectional: you choose an origin project, then connect the projects it should search. Those links can span regions, cloud providers, and project types, so your data can stay where it belongs without giving up a unified search experience.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e3edbf5f9edc9ffde2e9b7f7dd61efad2a5650e6-1999x1004.png" style="width: 100%;" /><p>Once the link is created, it usually takes effect within about a minute. If you already have Kibana open, refresh to see the new cross-project search capabilities.</p><h2>How cross-project search queries all linked projects by default</h2><p>Once projects are linked, cross-project search turns separate projects into a single logical search surface. If your logs live across multiple projects, a query like <code>FROM logs*</code> searches the origin project and any linked project that has matching data. You do not need to name each remote target up front.</p><p>That is a major improvement over cross-cluster search. In CCS, reaching both local and remote data often means writing something like <code>FROM logs*,*:logs*</code>. For users, that means less query complexity. For teams, it moves us closer to a true single pane of glass across distributed data.</p><p>For more information on this, see the <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-search#cps-init-search-model">CPS search model</a> docs.</p><p>If you&#x27;re interested in learning about technical details on how we built this, see <a href="https://www.elastic.co/search-labs/blog/cross-project-search-elasticsearch-serverless">How cross-project search (CPS) works in Elasticsearch Serverless</a>.</p><h2>Control of searches via Project Routing</h2><p>Searching across every linked project by default is convenient and useful for many workflows, but not every search should go everywhere. Cross-project search introduces <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-project-routing"><strong>project routing</strong></a>, a way to limit a query to a specific subset of projects.</p><p>It works through <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/project-settings#project-tags">project tags</a> defined in Elastic Cloud. Every project has built-in attributes such as its alias, cloud provider, and region. You can also add your own tags to reflect how your organization thinks about its estate, such as <code>environment:prod, environment:test</code>, a business unit, or a customer name. Elasticsearch can then use that metadata to decide which linked projects should participate in a search.</p><p>All <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search#cps-supported-apis">Elasticsearch endpoints</a> that support cross-project search accept a <code>project_routing</code> parameter. In the technical preview, routing is limited to using project alias. For example, setting project_routing to <code>_alias:my-linked-project</code> sends the query only to that linked project, while <code>_alias:_origin</code> keeps the query on the origin project. Over time, this model opens the door to much richer routing, where search scope can follow the logical structure of your organization instead of the physical layout of your infrastructure.</p><p>See the <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search#project-routing-examples">project routing documents</a> for examples and more details about how they work.</p><h2>Kibana Space-level default project routing</h2><p>As an example of where more precision is needed for your search routing, searching all linked projects might trigger a flood of false positives in your Kibana rules or confusing results in your existing dashboards. To fix this, you can set a <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-manage-scope">Space-level default project scope</a> in Kibana. This acts as a safe preset for that specific Space—meaning all dashboards, Discover sessions, and alerting rules automatically respect it. Analysts can still override the scope manually during an investigation if they need a broader view.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c34fe7e7b290981a0d1c2d61b22a42340a015049-1999x946.png" style="width: 100%;" /><p>This matters for teams sharing a central project, such as MSPs, MSSPs, and centers of excellence: you can assign each team their own Kibana space and restrict it to only query their specific customer projects, guaranteeing tenant-specific experiences. Analysts can still override the scope manually during an investigation if they need a broader view.</p><p>You can configure this Space default before or after you actually link your projects in the Cloud UI. But because CPS immediately turns on the &quot;search all&quot; behavior the second a link is made, setting your Kibana defaults first ensures your existing detection rules don&#x27;t suddenly run against a massive global dataset and overwhelm your team.</p><h2>Using tags in searches</h2><p>In addition to using tags for project routing, you can also use tags in your ES|QL and _search queries. This can be useful to identify where each record or row in a result set came from, or to sort, filter or aggregate by those tags.</p><p>For example, if you want to see which project every row in an ES|QL response came from, you can add the <code>_project._alias</code> tag to the ES|QL query:</p><pre><code>FROM logs* METADATA _project._alias | ...</code></pre><p>and this allows you to use _project._alias in other parts of the query including KEEP clauses in order to see it in the final result:</p><pre><code>FROM logs* METADATA _project._alias | ... | KEEP @timestamp, message, _project._alias</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e8969965c8cff25d916a3620d047975f4a28185d-1612x524.png" style="width: 100%;" /><p>For more examples of using tags in queries see <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-tags#tag-queries">this doc</a> that describes how to use them both in Search APIs and in ES|QL.</p><p>If you&#x27;re interested in learning about technical details on how we added tags to Search and ES|QL queries, see <a href="https://www.elastic.co/search-labs/blog/serverless-cross-project-search-project-tags-routing">Faster cross-project search in Elasticsearch Serverless with project tags and routing</a>.</p><h2>How cross-project search handles origin and linked projects equally</h2><p>If you have used CCS, you might be aware that the local cluster is treated differently from remote clusters in a few ways.</p><ul><li>Errors from the local cluster are handled differently than errors from remote clusters. In particular, CCS uses the <a href="https://www.elastic.co/docs/explore-analyze/cross-cluster-search#skip-unavailable-clusters">skip_unavailable</a> setting to control how errors from remote clusters behave, but that setting does not exist for the local cluster. </li><li>The local cluster has no &quot;cluster alias&quot;, so the index expression <code>*:logs*</code> searches all the remote projects, but skips the local cluster. To search both, you have to use the index expression <code>logs*,*:logs*</code>.</li></ul><p>In CPS, we have changed both of these behaviors to put the origin project and linked projects on a more even footing.</p><p>First, the <code>skip_unavailable</code> setting is not used in Elastic Cloud Serverless. Instead, you control whether you want partial results on a search via the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search#operation-search-allow_partial_search_results">allow_partial_search_results</a> parameter in _search or _async_search or the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-esql-query#operation-esql-query-allow_partial_results">allow_partial_results</a> parameter in ES|QL.</p><p>Second, in Elastic Cloud Serverless, the origin project has a project alias. It is defined in Elastic cloud like all project tags. Thus, in CPS, all of the queries below are equivalent - they target all projects with a &quot;logs&quot; index:</p><pre><code>POST logs/_search

POST *:logs/_search


POST logs/search 
{
  "project_routing": "_alias:*"
}
</code></pre><p><em>Note</em>: there is an important difference between the <em>qualified</em> index expression <code>*:logs</code> and the <em>unqualified</em> expression <code>logs</code> in terms of how error handling around missing indices works. For details see <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-search#search-expressions">Unqualified and qualified search expressions</a> in the public documentation.</p><h2>Access control and security model for cross-project search</h2><p>Elastic has created a new cloud-based security model, <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search#security">Universal Identity and Access Management</a> (UIAM), that enables a key principle for cross-project search: <strong>the projects and data you can access do not depend on where you access it from</strong>.</p><p>Whether you initiate a search from your primary observability project or an ad-hoc analytics project, your access to the linked data remains consistent, since the access rights were defined in a centralized location. The cloud-based authentication and authorization model uses the cloud UIAM service to ensure your access permissions are uniform regardless of the origin project.</p><h2>Try Cross-project search</h2><p>Ultimately, Elastic Cloud Serverless and CPS together <strong>reduce operational friction and give you additional options for organizing data based on logical considerations rather than physical or operational considerations.</strong> Cross-project search allows your users to focus purely on the logical organization of their data, delivering a unified search experience without the physical complexities of the past.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Agentic AI search with deterministic guardrails in Elasticsearch for safe query execution]]></title>
            <description><![CDATA[Agentic AI search systems often fail when LLMs generate queries directly. Learn how deterministic guardrails and a control plane architecture enable safe, reliable, and governed query execution with Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agentic-ai-search-deterministic-guardrail-query-execution</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agentic-ai-search-deterministic-guardrail-query-execution</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/769cdc7b53cbb222f52095193cd423277e8017d9-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/agentic-ai-search-deterministic-guardrail-query-execution">Parts 1 through 7</a> of this series described a governed control plane for ecommerce search. A user types a query. The control plane classifies intent, enforces business constraints, resolves policy conflicts, and routes to the appropriate retrieval strategy, all before the product catalog is ever queried. The entire architecture assumes that the input is a search string typed by a human shopper.</p><p>This final post asks: What changes when the input comes from an AI agent instead?</p><p>The answer is that the architecture doesn&#x27;t change, but the stakes do. Every property of the governed control plane that matters for human-authored queries matters <em>more</em> when the upstream decision-maker is a large language model (LLM). Determinism, auditability, conflict resolution, and constraint enforcement become critical guardrails rather than operational conveniences, because the system producing the input is probabilistic by nature.</p><h2>The agentic search problem</h2><p>The most common approach to AI-driven search is straightforward: Give the LLM the database schema, provide business rules in the prompt, and let the agent generate the query directly.</p><p>For an ecommerce chatbot, this means injecting the Elasticsearch index mapping, field types, category taxonomies, pricing logic, and business constraints into the agent&#x27;s context window, and then asking the LLM to translate natural language into valid Elasticsearch Query DSL. The LLM becomes the query author.</p><p>This approach works in demos. It fails in production for four reasons.</p><h3>Context bloat</h3><p>An enterprise ecommerce index mapping is not a trivial document. Field definitions, nested objects, multi-field configurations, and analyzer settings can run to thousands of tokens before any business logic is added. On top of the mapping, the agent needs category taxonomies (which in enterprise ecommerce can contain tens of thousands of values), pricing rules, brand hierarchies, eligibility constraints, and campaign logic.</p><p>The result is a context window dominated by structural metadata rather than the user&#x27;s actual intent. This increases latency, increases token cost, and degrades the LLM&#x27;s ability to follow instructions as the context grows. This is a well-documented phenomenon, sometimes called <a href="https://www.trychroma.com/research/context-rot"><em>context rot</em></a>: As the prompt gets longer, the model&#x27;s attention to any particular instruction weakens.</p><h3>Probabilistic hallucination</h3><p>LLMs generate queries based on patterns in their training data and the context provided. When asked to produce Elasticsearch Query DSL, the model can hallucinate field names that don&#x27;t exist, construct syntactically invalid query clauses, misapply filter types to the wrong field types, or produce queries that are syntactically valid but semantically wrong, returning results that don&#x27;t match the user&#x27;s intent.</p><p>Google Cloud&#x27;s <a href="https://cloud.google.com/blog/products/databases/how-to-get-gemini-to-deeply-understand-your-database">BIRD benchmark for Text-to-SQL</a> illustrates the ceiling of this approach. Google&#x27;s state-of-the-art single-model result achieved between 70% and 80% accuracy, meaning that nearly one in four generated queries was incorrect. This is for SQL, which is far more standardized than Elasticsearch Query DSL. The error rate for LLM-generated Elasticsearch queries in a real production environment, with complex mappings and business-specific semantics, would likely be higher.</p><p>For a revenue-critical ecommerce system, a one in four query error rate isn’t a tuning problem to be solved iteratively. It’s an architectural limitation of the approach.</p><h3>The security gap</h3><p>When the LLM has access to the database schema and acts as the query author, the system is vulnerable to indirect prompt injection. A user interacting with an ecommerce chatbot can craft inputs designed to manipulate the agent into generating unintended queries.</p><p>This isn’t a theoretical risk. <a href="https://www.elastic.co/blog/owasp-top-10-for-llms-guide">Prompt injection</a> is one of the most actively researched attack surfaces in deployed LLM systems. The fundamental issue is that when the agent authors the query, there’s no structural boundary between user intent and query execution. The LLM is simultaneously interpreting the user&#x27;s request and constructing the database operation. Any manipulation of the first directly affects the second.</p><h3>High-cardinality scaling failure</h3><p>Certain ecommerce fields have extreme cardinality. A product catalog might have 17,000 category values, thousands of brand names, and hundreds of attribute combinations. Standard agentic workflows require injecting these values into the context so the LLM can select the correct one when constructing a query.</p><p>This creates an impossible trade-off: Either inject all possible values (consuming enormous context and degrading performance), inject a subset (and accept that the agent cannot reference values outside that subset), or fall back to ungoverned search. This connects directly to the core problem from <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval">Part 1</a>: If the LLM searches for “oranges” and Elasticsearch returns orange soda, the chat experience degrades in the same way a search experience does. The absence of governance means the system cannot enforce the shopper&#x27;s intended resolution.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f11c44feb5291002d4ec4ac79484ea39d4e48a95-642x133.png" style="width: 100%;" /><p>Retrieving relevant values dynamically based on the query is a known alternative, but it introduces an additional nondeterministic step where the retrieval itself can miss relevant values. Additionally, this adds latency and complexity to every query.</p><h2>The architectural alternative: Decoupling intent from execution</h2><p>The governed control plane described in Parts 1 through 7 offers a fundamentally different approach. Instead of the LLM authoring the final query, the LLM&#x27;s role is reduced to a single, well-bounded task: extracting a search intent string from the user&#x27;s natural language input.</p><p>The user says: &quot;I&#x27;m looking for cheap brown shoes.&quot; The agent&#x27;s job isn’t to generate an Elasticsearch query. It’s to extract and pass along the search intent, (in this case, something like &quot;cheap brown shoes&quot;) to the control plane. The control plane then does what it has always done: percolates the intent string against stored policies, composes matching policies through cascading transformations, resolves conflicts deterministically, and produces a governed Elasticsearch query.</p><p>The LLM never sees the index mapping. It never knows about field types, category taxonomies, or pricing thresholds. It never constructs a query clause. It operates on the natural language side of an architectural boundary that we call the <em>metadata air gap</em>, a strict separation between the probabilistic component (the LLM) and the structured data layer (schema, policies, and query construction).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/12dacc77f0c481c9ada84725eff370c7e2c4b429-642x143.png" style="width: 100%;" /><h3>What the metadata air gap provides</h3><ul><li><strong>Schema blindness.</strong> The LLM has no access to the database schema and therefore cannot generate invalid queries, hallucinate field names, or be manipulated into exposing structural information. The schema exists only on the deterministic side of the air gap.</li><li><strong>Minimal context.</strong> Instead of thousands of tokens of mapping data, business rules, and category taxonomies, the LLM&#x27;s prompt contains only a persona and intent extraction instructions. This dramatically reduces token cost, latency, and context rot.</li><li><strong>Deterministic execution.</strong> Every query that reaches Elasticsearch is constructed by the control plane using human-vetted policy templates, not generated probabilistically by an LLM. Syntactic validity is guaranteed. Semantic correctness is enforced by the same policy framework that Parts 1 through 6 described.</li><li><strong>Security by architecture.</strong> Prompt injection becomes structurally ineffective. Even if a user manipulates the agent into producing an unusual intent string, that string is percolated against stored policies. If no policy matches, no query is generated. The user cannot instruct the agent to construct a query because the agent doesn&#x27;t construct queries. The control plane does, and the control plane is deterministic.</li></ul><h2>How the pieces connect</h2><p>The following walkthrough shows how the governed control plane handles an agent-mediated query.</p><h3>Step 1: The user speaks to the agent</h3><p>A shopper interacting with an ecommerce chatbot says: &quot;I&#x27;m looking for cheap chocolate, nothing with peanuts.&quot;</p><h3>Step 2: The agent extracts intent</h3><p>The LLM&#x27;s role is intent extraction, not query generation. Given a minimal prompt that instructs it to identify the product intent, the agent produces a search intent string: &quot;cheap chocolate without peanuts&quot;.</p><p>This is a lightweight classification task. The LLM doesn’t need the index mapping, category taxonomy, or pricing rules to perform it. It needs to understand natural language, which is exactly what LLMs are good at.</p><h3>Step 3: The control plane governs the query</h3><p>The intent string &quot;cheap chocolate without peanuts&quot; is passed to the control plane, which percolates it against the policy index. Three policies match:</p><ul><li>The &quot;cheap&quot; policy (extracts &quot;cheap&quot;, applies a price filter based on the product category).</li><li>The &quot;chocolate&quot; policy (constrains results to chocolate categories).</li><li>The &quot;without&quot; negation policy (extracts the exclusion target and applies a <code>must_not</code> filter)</li></ul><p>The control plane applies these policies through the same cascading transformation described in <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">Part 4</a>: priority ordering, per-field conflict resolution, consumed phrase tracking. If a “Christmas campaign” policy is also active, it composes with the product policies exactly as described in <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3,</a> the agent&#x27;s involvement doesn&#x27;t change the governance model at all.</p><h3>Step 4: The governed query executes</h3><p>The control plane produces a fully governed Elasticsearch query: a search for “chocolate”, constrained to the appropriate categories, with a price ceiling derived from the “cheap” policy, an exclusion filter for peanut-containing products, and any active campaign boosts applied. If the “chocolate” policy also includes economic optimization weights (<a href="https://www.elastic.co/search-labs/blog/ecommerce-search-optimization-query-governed">Part 7</a>), those are applied as well. Margin boosting is set to 3.0x because “chocolate” is a browsing query where the retailer benefits from promoting higher-margin products. If the shopper has purchase history (<a href="https://www.elastic.co/search-labs/blog/elasticsearch-personalized-search-governed-ecommerce">Part 6</a>), personalization signals are layered on top. This query is syntactically valid by construction and semantically correct by policy design.</p><h3>Step 5: Results return through the agent</h3><p>The product results are returned to the agent, which presents them conversationally to the user. The agent&#x27;s role in the return path is presentation: formatting results, answering follow-up questions, providing product details. The retrieval itself was governed, deterministic, and explainable.</p><h2>What the agent is good at (and what it isn&#x27;t)</h2><p>This architecture leverages the LLM for what it does well and protects the system from what it does poorly.</p><p>LLMs excel at understanding natural language intent. &quot;I&#x27;m looking for cheap chocolate, nothing with peanuts&quot; is a natural language understanding task, parsing intent, identifying product references, recognizing negation. LLMs handle this reliably because it&#x27;s a classification problem, not a generation problem. The output is a short intent string, not a complex structured query.</p><p>LLMs struggle with precise structured output under complex constraints. Generating valid Elasticsearch Query DSL requires exact field names, correct clause nesting, appropriate filter types for each field, and consistent application of business rules across thousands of edge cases. These are exactly the properties that a deterministic system enforces trivially and that a probabilistic system enforces unreliably.</p><p>The governed control plane puts each component where it belongs: the LLM on the natural language side, the deterministic policy engine on the query construction side, and an architectural boundary between them.</p><h2>Governance constrains the blast radius</h2><p>This is the same insight from <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a>, extended to the agentic context. In Part 3, we observed that governance makes semantic retrieval safer by narrowing the candidate set before retrieval begins. A semantic search over 500 products in a governed category is a fundamentally different proposition from a semantic search over 500,000 SKUs.</p><p>The same principle applies to agent-mediated queries. Without governance, an agent that misinterprets &quot;cheap chocolate&quot; could generate a query that searches the entire catalog with no price constraint, no category filter, and no exclusions. With governance, even if the agent produces an imperfect intent string, the control plane constrains the query to the policies that match. The worst case is that fewer policies fire, not that an unbounded query hits the product catalog.</p><p>Governance narrows the blast radius of probabilistic errors. This is true whether the probabilistic component is a semantic retrieval model or an LLM agent.</p><h2>LLM-suggested policies: Expanding coverage</h2><p><a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">Part 2</a> introduced the idea that an LLM can suggest new policies that enter the same Author → Test → Promote pipeline as human-authored ones. In the agentic context, this becomes a powerful feedback loop.</p><p>An LLM can analyze query logs, identify patterns where the control plane has no matching policy (queries that fall through to unmodified retrieval), and suggest new policies to cover those gaps. A merchandiser reviews each suggestion, tests it, and promotes it if it produces the expected behavior. The governance model ensures that no LLM-suggested policy reaches production without human validation.</p><p>Over time, this creates a virtuous cycle: The control plane&#x27;s policy coverage expands, the proportion of queries that require unmodified retrieval shrinks, and the system becomes progressively more governed, with every policy auditable, versioned, and individually reversible.</p><h2>The broader pattern: Deterministic guardrails for probabilistic systems</h2><p>The architecture described in this series, a deterministic control plane that sits between a probabilistic input source and a data retrieval system, isn’t specific to ecommerce search. The same pattern applies wherever an AI agent needs to interact with structured data.</p><p>An agent querying a SQL database faces the same challenges: context bloat from schema injection, hallucinated column names, prompt injection risks, and high-cardinality value selection. An agent interacting with a ticketing system like Jira, a customer relationship management (CRM) system like Salesforce, or a code repository like GitHub faces analogous problems. In every case, the core architectural question is the same: Should the LLM author the query, or should the LLM extract intent and pass it to a deterministic layer that authors the query?</p><p>The governed control plane provides a repeatable answer to that question. Policies are data. Intent extraction is the LLM&#x27;s job. Query construction is the control plane&#x27;s job. The metadata air gap keeps them separated. And the governance framework (priority ordering, conflict resolution, cascading transformations, auditability) ensures that the deterministic layer is operationally manageable as the number of policies grows.</p><h2>Conclusion</h2><p>The ecommerce search governance patterns described in this series (policies as data, the Author → Test → Promote workflow, cascading transformations, per-field conflict resolution, percolator-based reverse matching, and multi-tier fallback) were designed for a world where a merchandiser authors policies and a shopper types queries. But the architecture can enable much more than its initial use case.</p><p>When the input source is an AI agent rather than a human shopper, the governed control plane becomes the critical safety layer between a probabilistic system and a production data store. It provides the deterministic guarantees (syntactic validity, semantic correctness, auditability, and security) that enterprise systems require and that LLMs cannot provide on their own.</p><p>The deterministic control plane doesn’t replace the AI agent. It makes the AI agent safe to deploy.</p><h2>Put governed ecommerce search into practice</h2><p>The governed control plane architecture described in this series, from the policy-as-data paradigm to the percolator-based lookup to personalization, economic optimization, and the agentic air gap, was designed and built by Elastic Services Engineering. Every pattern described across this series comes from a working system built and validated against enterprise-scale product catalogs.</p><p>If your team is building AI-powered search experiences and needs deterministic guardrails for agent-mediated queries, or if you want to implement a governed, business-editable search architecture on Elasticsearch, Elastic Professional Services can accelerate your implementation. Contact <a href="https://www.elastic.co/consulting">Elastic Professional Services</a>.</p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elastic Cloud on Kubernetes, simplified: zone awareness, restarts, and mTLS]]></title>
            <description><![CDATA[ECK 3.4 reduces zone-aware HA from 40 lines of YAML to one field, adds declarative rolling restarts via annotation, and wires Kibana-Elasticsearch mTLS automatically.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-kubernetes-zone-awareness-restarts-mtls</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-kubernetes-zone-awareness-restarts-mtls</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Omer Kushmaro]]></dc:creator>
            <pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d8a9512a3839164368d348637803c0d486cb1cb2-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>ECK 3.4 makes the Elastic Stack on Kubernetes simpler to operate. Zone-aware HA, safe rolling restarts, and Kibana↔Elasticsearch mTLS each become a one-line answer in your manifest.</p><p>If you operate <a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s">Elastic Cloud on Kubernetes</a> (ECK), this release is about reducing the friction in the things you do every day.</p><h2>Easier to operate, easier to understand</h2><p>ECK 3.4 is a release focused on reducing what you have to think about when you run The Elastic Stack on Kubernetes. Each headline change picks a multi-step task and turns it into a single declarative answer:</p><ul><li><strong>Simplified zone awareness.</strong> Telling ECK that a cluster should be spread across availability zones is now a single field on the NodeSet. The operator handles the topology, the scheduling, and the Elasticsearch-side awareness configuration on your behalf. Your manifests reflect what you mean, not how it&#x27;s wired.</li><li><strong>Restart a cluster the same way you do everything else.</strong> Triggering a rolling restart is now an annotation on the Elasticsearch resource. It&#x27;s declarative, fits GitOps, and leaves an audit trail. No force-edit on an unrelated field to get a rollout.</li><li><strong>mTLS is automatically configured by the operator.</strong> Wiring mutual TLS between Kibana and Elasticsearch by hand requires managing CAs, per-component client certificates, mounts, rotation, and configurations on both ends. ECK 3.4 takes care of all of that: flip a flag on Elasticsearch, point Kibana at it, and the operator manages the rest.</li></ul><p>This release is to make day-to-day ECK operations boring, in the best sense: fewer fields to remember, fewer side trips to keep in sync, and simpler-to-understand manifests.</p><h2>Simplified zone awareness</h2><p>Make an Elasticsearch cluster highly available across availability zones by setting one field on the NodeSet. ECK 3.4 handles the topology spread, the pod scheduling, and the Elasticsearch-side awareness configuration for you.</p><p><a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s/advanced-elasticsearch-node-scheduling#k8s-availability-zone-awareness-example">Before</a>, you had to wire all of this by hand across four separate objects: an annotation on the Elasticsearch resource for downward node labels, awareness attributes in the NodeSet config, a <code>fieldRef</code> env var in the pod template to surface the zone, and a matching <code>topologySpreadConstraints</code> block plus a <code>nodeAffinity</code> rule pinning the cluster to specific zones. Roughly forty lines of YAML, easy to misconfigure.</p><p>In ECK 3.4, the same zone-aware cluster is four lines:</p><pre><code>apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: my-cluster
spec:
  version: 9.4.0
  nodeSets:
  - name: default
    count: 3
    zoneAwareness: {}</code></pre><p>To pin to a specific set of zones, name them, and ECK adds the matching required node affinity rules:</p><pre><code>spec:
  nodeSets:
  - name: hot
    count: 3
    zoneAwareness:
      zones: ["us-east-1a", "us-east-1b", "us-east-1c"]</code></pre><p>If you do need to customize <code>maxSkew</code> or <code>whenUnsatisfiable</code>, providing a matching topology spread constraint with the same <code>topologyKey</code> in <code>podTemplate</code> still wins. Your override stays an override.</p><p>One note for upgrades: enabling <code>zoneAwareness</code> on an existing NodeSet changes the StatefulSet pod template (new topology spread constraints, <code>ZONE</code> env var, node affinity, <code>node.attr.zone</code>), which triggers a one-time rolling restart of the affected NodeSet. Plan accordingly.</p><p>To learn more about simplified zones management, you can <a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s/advanced-elasticsearch-node-scheduling">read this page at Elastic Docs</a>.</p><h2>Declarative rolling restarts</h2><p>Restarting an Elasticsearch cluster without changing its spec is now a first-class workflow in 3.4. Two new annotations on the Elasticsearch resource do the work:</p><ul><li><code>eck.k8s.elastic.co/restart-trigger</code>: set or change this value (a timestamp is the conventional choice) to start a rolling restart. Changing the value triggers another restart later; removing the annotation does not.</li><li><code>eck.k8s.elastic.co/restart-allocation-delay</code>: optional duration string (e.g. &quot;20m&quot;) passed to the Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-shutdown-put-node">node shutdown API</a> as the allocation delay during the restart, so you can hold off on rebalancing while a pod recycles.</li></ul><pre><code>apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: my-cluster
  annotations:
    eck.k8s.elastic.co/restart-trigger: "2026-04-30T10:00:00Z"
    eck.k8s.elastic.co/restart-allocation-delay: "20m"
spec:
  version: 9.4.0</code></pre><p>Under the hood, ECK propagates the trigger value to pod annotations, which changes the StatefulSet template hash and feeds every pod through the existing rolling-upgrade path (node shutdown API, predicates, one-pod-at-a-time deletion). There&#x27;s no new restart mechanism to learn, and the status messages and observability you already have on rolling upgrades carry over.</p><p>For GitOps users, this means a Flux/ArgoCD pipeline can request a restart by patching one annotation: no spec drift, no diff churn, no force-edit on an unrelated field.</p><h2>Managed mTLS for Kibana ↔ Elasticsearch</h2><p><a href="https://www.elastic.co/docs/deploy-manage/security/set-up-basic-security-plus-https">Mutual TLS</a> orchestration between Kibana and Elasticsearch arrives with this release. The Elasticsearch CRD accepts a single new field, <code>spec.http.tls.client.authentication: true</code>, that tells the cluster to require client certificates on its HTTPs interface. ECK does the rest: it builds a trust bundle from any secret labeled <code>eck.k8s.elastic.co/client-certificate: true</code>, mounts it into the Elasticsearch pods, sets <code>xpack.security.http.ssl.client_authentication: required</code>, and issues an operator-side client certificate so it can keep talking to the cluster throughout the rollout.</p><p>This makes enabling and configuring mTLS for the stack (Elasticsearch and Kibana only, in this release) a much simpler task.</p><p>Enabling mTLS on Elasticsearch:</p><pre><code>apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: secure-cluster
spec:
  version: 9.4.0
  http:
    tls:
      client:
        authentication: true # <---- This is all you need
  nodeSets:
  - name: default
    count: 3</code></pre><p>On the client side, Kibana&#x27;s association controller now detects the <code>client-authentication-required</code> annotation on the referenced Elasticsearch and automatically generates a client certificate for Kibana — no extra config needed. If you want to bring your own cert (cert-manager, an internal PKI), point at the secret you&#x27;ve already provisioned:</p><pre><code>apiVersion: kibana.k8s.elastic.co/v1
kind: Kibana
metadata:
  name: kibana
spec:
  version: 9.4.0
  count: 1
  elasticsearchRef:
    name: secure-cluster
    clientCertificateSecretName: my-custom-client-cert</code></pre><p>ECK rotates the certificate, mounts the secret into the Kibana pod, and wires <code>elasticsearch.ssl.certificate</code> and <code>elasticsearch.ssl.key</code>. Cleanup of mTLS resources is deferred until all pods have rolled, so connectivity holds throughout the transition.</p><p>Kibana is the first Stack component to get this first-class treatment in 3.4. Support for APM Server, Beats, Fleet Server, Elastic Agent, Logstash, Maps, and Enterprise Search ships in the near future. In the meantime, a <a href="https://github.com/elastic/cloud-on-k8s/pull/9124">new recipe</a> walks through manual mTLS for those components using cert-manager.</p><h2>Other notable improvements</h2><p>This release includes other improvements worth highlighting. Here is a list with their related pull requests.</p><ul><li><strong>Native Go FIPS 140-3 in the FIPS-enabled operator (separate image).</strong> The FIPS-flavored ECK image (<code>docker.elastic.co/eck/eck-operator-fips:3.4.0</code>, plus a UBI variant <code>eck-operator-ubi-fips:3.4.0</code>) now ships with native Go FIPS 140-3 support, pinned at the certified <code>GOFIPS140=v1.0.0</code> module and enforced at runtime. The standard <code>eck-operator</code> image is unchanged. For Elasticsearch 9.4.0 or later, the operator also generates and mounts a FIPS-compliant keystore password automatically when <code>xpack.security.fips_mode.enabled: true</code> is set (<a href="https://github.com/elastic/cloud-on-k8s/pull/9263">#9263</a>, <a href="https://github.com/elastic/cloud-on-k8s/pull/9287">#9287</a>).</li><li><strong>Reliability fixes worth calling out:</strong><ul><li>Stale CAs in the certificate chain are now detected and trigger reissuance (<a href="https://github.com/elastic/cloud-on-k8s/pull/9197">#9197</a>).</li><li>Remote-CA secret generation failures are non-blocking (<a href="https://github.com/elastic/cloud-on-k8s/pull/9271">#9271</a>).</li><li>The NetworkPolicy namespace selector label is fixed for soft multi-tenancy setups (<a href="https://github.com/elastic/cloud-on-k8s/pull/9153">#9153</a>).</li><li>The Elasticsearch controller skips its default PVC if a volume of the same name already exists (<a href="https://github.com/elastic/cloud-on-k8s/pull/9199">#9199</a>).</li><li>The DaemonSet reconciler handles stale cache the same way the Deployment reconciler does (<a href="https://github.com/elastic/cloud-on-k8s/pull/9256">#9256</a>).</li></ul></li></ul><h2>Getting started</h2><p>If you&#x27;re already running ECK, upgrade to 3.4.0 with Helm:</p><pre><code>helm upgrade elastic-operator elastic/eck-operator -n elastic-system</code></pre><p>Or apply the latest operator manifest directly:</p><pre><code>kubectl apply -f https://download.elastic.co/downloads/eck/3.4.0/crds.yaml
kubectl apply -f https://download.elastic.co/downloads/eck/3.4.0/operator.yaml</code></pre><p>If you&#x27;re new to ECK, start with the <a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s#eck-quickstart">quickstart guide</a> to get an Elasticsearch cluster running on Kubernetes in minutes.</p><p>For the full list of changes, see the <a href="https://github.com/elastic/cloud-on-k8s/releases/tag/v3.4.0">ECK 3.4.0 release notes on GitHub</a>.</p><p>To start using Elastic Cloud today, log in to the <a href="https://cloud.elastic.co/">Elastic Cloud console</a> or sign up for a <a href="https://cloud.elastic.co/registration">free trial</a>.</p><h2>Frequently asked questions</h2><p><strong>How do I make an Elasticsearch cluster zone-aware in ECK without writing topology spread constraints?</strong></p><p>Set <code>spec.nodeSets[].zoneAwareness: {}</code> on the Elasticsearch resource. ECK derives the topology, attaches <code>node.attr.zone</code>, sets <code>maxSkew=1</code> topology spread constraints, and injects the downward labels for you. Provide <code>zones: [...]</code> if you want to pin to a specific set of availability zones. Enabling this on an existing NodeSet causes a one-time rolling restart.</p><p><strong>Can I trigger a rolling restart of an Elasticsearch cluster on Kubernetes without editing the spec?</strong></p><p>Yes. ECK 3.4 introduces two annotations on the Elasticsearch resource: <code>eck.k8s.elastic.co/restart-trigger</code> (set or change the value, e.g. a timestamp, to start a rolling restart) and <code>eck.k8s.elastic.co/restart-allocation-delay</code> (optional duration string passed to the Elasticsearch node shutdown API). Removing the trigger annotation does not start a new restart.</p><p><strong>How do I enable mutual TLS between Kibana and Elasticsearch on Kubernetes?</strong></p><p>With ECK 3.4, set <code>spec.http.tls.client.authentication: true</code> on the Elasticsearch CRD and reference it from Kibana via <code>elasticsearchRef</code>. ECK auto-generates a client certificate for Kibana, builds a trust bundle from any secret labeled <code>eck.k8s.elastic.co/client-certificate: true</code>, and configures <code>xpack.security.http.ssl.client_authentication: required</code> for you. mTLS for Kibana ↔ Elasticsearch is a technical preview in 3.4.</p><p><strong>Does ECK 3.4 mTLS support cover all Stack components like Beats and Fleet?</strong></p><p>Not yet. Kibana is the first Stack component to get first-class mTLS support in 3.4 — the operator auto-generates its client certificate. Support for APM Server, Beats, Fleet Server, Elastic Agent, Logstash, Maps, and Enterprise Search ships in the next release. A new recipe walks through manual mTLS for those components using cert-manager in the meantime.</p><p><strong>Does ECK support FIPS 140-3?</strong></p><p>Yes, in a separate operator image. ECK 3.4 publishes a FIPS-flavored build (<code>docker.elastic.co/eck/eck-operator-fips:3.4.0</code>, plus a UBI variant) with native Go FIPS 140-3 support. The standard <code>eck-operator</code> image is unchanged. For Elasticsearch 9.4.0 or later, ECK also generates and mounts a FIPS-compliant keystore password automatically when <code>xpack.security.fips_mode.enabled: true</code> is set.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Faster cross-project search in Elasticsearch Serverless with project tags and routing]]></title>
            <description><![CDATA[Scope cross-project search in Elasticsearch Serverless with project routing to skip non-matching projects entirely, or with project tag fields to filter, aggregate, and sort by tag inside the query.]]></description>
            <link>https://www.elastic.co/search-labs/blog/serverless-cross-project-search-project-tags-routing</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/serverless-cross-project-search-project-tags-routing</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Stas Malyshev,  Luigi Dell'Aquila]]></dc:creator>
            <pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1bb10651596fb7bdafebd5ace919f935d88347f6-1536x1024.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Cross-project search (CPS) in <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> lets you query data across multiple projects in a single request, a need many organizations have. With project tags and routing, you can easily scope that search. Project routing filters by project alias before the query runs, so Elasticsearch never touches non-matching projects: no index resolution, no coordination, no compute. Project tag fields work inside the query like any other field, so you can filter, aggregate, sort, and group results by tag values such as environment or department.</p><p>This blog introduces Serverless project tags and details how to use them in the context of cross-project search to target specific projects in queries and improve the query performance.</p><h2>Project tags background</h2><p>Each Serverless project has a set of key-value pairs associated with it, allowing you to categorize and organize your projects. These are called <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/project-settings#project-tags"><em>project tags</em></a>. There are two types of tags:</p><ul><li><strong>Prebuilt tags:</strong> Predefined tags that exist for every project and are generated automatically; for example, <code>_alias</code>, <code>_id</code>, <code>_type</code>, corresponding to the project alias, project ID, and project type. Predefined tag names always begin with an underscore.</li><li><strong>Custom tags:</strong> User-defined tags; they can have any name that begins with a letter and can contain lowercase letters, numbers, underscores, and hyphens. For example, you could have the tag <code>env:qa</code> for your QA environment projects and <code>env:production</code> for your production environment.</li></ul><p>In the Elastic Cloud console, you can manage project tags in the “Manage project” screen:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6701c991a9662772489dc2517c40dbc2e0776f07-2048x422.png" style="width: 100%;" /><p>And programmatically, you can see the tags by using the <code>GET _project/tags</code> Elasticsearch API:</p><pre><code>{
 "origin": {
   "f58ef00d1538476f884c137bf7d304ff": {
     "_alias": "my-origin-project-1",
     "_csp": "aws",
     "_id": "f58ef00d1538476f884c137bf7d304ff",
     "_organization": "500590167",
     "_region": "aws-eu-west-1",
     "_type": "security"
   }
 },
 "linked_projects": {
   "a4f8c2fa86824395a845e4a055e1ce83": {
     "_alias": "my-linked-1",
     "_csp": "aws",
     "_id": "a4f8c2fa86824395a845e4a055e1ce83",
     "_organization": "500590167",
     "_region": "aws-eu-west-1",
     "_type": "observability",
     "env": "qa",
     "test": "abcd"
   }
 }
}</code></pre><p>In your queries, you can use project tags to limit the scope of the search to a subset of your linked projects; for example, only observability projects or only projects belonging to the billing department. This is called <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-project-routing"><em>project routing</em></a>; for each search, you can provide a filtering expression that confines the search only to the projects which have the tags matching the expression. This is a very efficient way of reducing the search space, because it doesn’t require accessing any projects that don’t have the matching data.</p><p>In addition, you can use the project tags in queries just like any index fields are used: for matches, aggregations, sorting, as part of the search fields output, and more. When used in this way, project tags look exactly like any mapped field, but their values aren’t stored in the index; they’re loaded dynamically from the project configuration. This adds flexibility in using project tag information in searches and aggregations.</p><p>When used in this manner, project tags are always prefixed with <code>_project.</code>; for example, project tag <code>_alias</code> becomes <code>_project._alias</code>, and project tag <code>department</code> becomes _project.department.</p><h2>Project routing</h2><p>Project routing restricts cross-project search to a subset of linked projects by applying a filter expression, which uses a subset of <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/lucene-query-syntax">Lucene query syntax</a>, before the query processing begins; for example, <code>_alias:my_search_project</code>. This is a very efficient way of limiting the query, since the filter is applied before any of the operations are executed, and the linked projects that don’t match the filter won’t even be contacted in this query. However, this way of filtering is limited because you can only use it to exclude the whole project.</p><p>This filtering is applied to every index expression, so for example, when running <code>GET logs/_search</code> without the routing expression, it would look for <code>logs</code> index on the origin project and every linked project; however, with routing expression <code>_alias:my-o11y-project</code>, only the project with the alias <code>my-o11y-project</code> will be used.</p><p>For convenience, you can store frequently used routing expressions as <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search/cross-project-search-project-routing#creating-and-managing-named-project-routing-expressions">named project routing expressions</a> and reuse them between different queries by specifying just the expression name: <code>@routing-expression</code>.</p><p><strong>In the Technical Preview release, </strong>project routing is limited to only filtering by <code>_alias</code> tag matching; for example, <code>_alias:my-security-*</code>. In future releases, we plan to support all tags and most of the Lucene filter syntax.</p><h3>Query DSL</h3><p>Project routing can be specified for search using request body field:</p><pre><code>GET logs/_search 
{
  "project_routing": "_alias:my_search_project"
}</code></pre><h3>Elasticsearch Query Language (ES|QL)</h3><p>You can specify project routing as a statement preceding your main query:</p><pre><code>SET project_routing = "_alias:my-project-alias" ;
FROM my_index
| LIMIT 10</code></pre><p>Alternatively, you could use a <code>project_routing</code> query parameter in an API call, as above, but within ES|QL, the <code>SET</code> syntax is preferred.</p><p><strong>Precedence rule:</strong> If both the <code>SET project_routing</code> syntax is used and a <code>project_routing</code> query parameter is provided via the API, the <code>SET</code> syntax takes precedence.</p><h2>Project tags as fields</h2><p>Project tags can be used as fields in queries (for fetching, matching, aggregating, and sorting) by referencing them with the <code>_project.</code> prefix. This is implemented by creating a special dynamic mapping type and attaching it to the field named <code>_project</code>. By itself, this field doesn’t have any data and cannot be used directly, but it has subfields with names matching project tag names; for example, <code>_project._alias</code> or <code>_project.env</code>. These fields return constant keyword values, taken directly from the project tags map stored in memory.</p><h3>Query DSL</h3><p>In Query DSL, you can use the project tag fields just as you do with regular fields, wherever a field name is accepted; for example:</p><p>Fetching:</p><pre><code>{
  "fields": ["count", "_project._id", "_project._alias", "_project.env"]
}</code></pre><p>Wildcard patterns work, too:</p><pre><code>{
  "fields": ["count", "_project.my-tag-*"]
}</code></pre><p>By default, however, project tags aren’t in the output fields (including when using <code>*</code> as fields); you always need to include them explicitly with the <code>_project.</code> prefix. You don’t need to add project tags to the output to use them for matching or aggregations; these functions are independent of each other.</p><p>Matching:</p><pre><code>"query": {
"match": {
"_project._alias": "my-project"
}
}</code></pre><p>For this simple example, project routing may be a more efficient way; however, more complex matching can be used, too:</p><pre><code>{
  "query": {
    "bool": {
      "should": [
        { "term": { "_project._alias": "my-project" } },
        { "term": { "_project.env": "qa" } }
      ],
      "minimum_should_match": 1
    }
  }
}</code></pre><p>Unlike project routing, the full set of Elasticsearch matching expressions can be used here, even in the Technical Preview release. Keep in mind that this comes at a performance cost: All indices named in the index expression will still be resolved, and all linked projects will be contacted, even if the match expression ultimately excludes all their data.</p><p>You can also use aggregation on project tags:</p><pre><code>{
	"size": 0,
	"aggs": {
		"by_project": {
			"terms": {
				"field": "_project._alias"
			},
			"aggs": {
				"total_count": {
					"sum": {
						"field": "int_count"
					}
				}
			}
		}
	}
}</code></pre><p>And sort by a project tag:</p><pre><code>{
	"size": 4,
	"sort": [
		{
			"_project._alias": {
				"order": "asc"
			}
		}
	],
	"fields": [
		"count",
		"_project._id",
		"_project._alias"
	]
}</code></pre><h3>ES|QL</h3><p>To use project tags as fields in your query results, you must explicitly include them using the <code>METADATA</code> keyword in your <code>FROM</code> clause:</p><pre><code>FROM my_index METADATA _project._alias
| LIMIT 5</code></pre><p>Wildcards can be used to include multiple tags:</p><pre><code>FROM my_index METADATA _project.*
| LIMIT 5</code></pre><p>Once included, these tag fields behave like any other field, usable by all ES|QL commands; for example:</p><pre><code>FROM my_index METADATA _project._alias
| WHERE _project._alias == "production-data"
| STATS count() by status</code></pre><p>In ES|QL, there are a few important differences between using <code>project_routing</code> and defining filters based on project tags in a <code>WHERE</code> command.</p><p><strong>Index resolution:</strong></p><ul><li>With <code>project_routing=&quot;...&quot;</code>, indices are only resolved on the specified projects, so you won’t see fields that are only defined in the mappings of the excluded projects.</li><li>With a simple filter, for example, <code>WHERE _project._alias LIKE &quot;...&quot;</code>, the index resolution will be routed to all the projects, so you’ll also see columns that are only defined in projects that don’t match the alias pattern.</li></ul><p><strong>Query routing:</strong></p><ul><li>With <code>project_routing=&quot;...&quot;</code>, the query will be routed only to the involved projects.</li><li>With <code>WHERE _project._alias LIKE &quot;...&quot;</code>, the query will be routed to <strong>all</strong> the projects, so you’ll pay at least for the cost of query coordination.</li></ul><p><strong>Query execution:</strong></p><ul><li>With <code>project_routing=&quot;...&quot;</code>, the query will be executed only on the nodes that match the expression.</li><li>With <code>WHERE _project._alias LIKE &quot;...&quot;</code>, the query will be routed to all the nodes, but ES|QL will perform a second optimization phase and will replace project tags with constants, performing constant folding that will transform the original filter into a <code>WHERE true</code> for projects that match the condition and with <code>WHERE false</code> for projects that don’t match the condition. In this case, the engine will recognize that the query will return no results, so in practice there will be no execution. In conclusion, you’ll pay for the cost of coordination and for the cost of local replanning, but the actual execution will be a no-op.</li></ul><p><strong>Flexibility:</strong></p><ul><li>In the Technical Preview release, <code>project_routing</code> only allows filtering based on project aliases. Future releases will support more complex syntax and all tags.</li><li>The ES|QL language supports project tags in any command or expression where a field name is allowed, including filters, even with very complex expressions.</li></ul><h2>Conclusion</h2><p>Both project routing and project tag fields give you control over which projects participate in a cross-project search, but they serve different purposes.</p><p><strong>Use project routing when performance matters most.</strong> Whether you set <code>project_routing</code> in a Query DSL request body or use <code>SET project_routing</code> in ES|QL, the effect is the same: Non-matching projects are excluded before any query work begins; no index resolution, no coordination overhead, no wasted compute. If you know exactly which projects to target, this is always the most efficient path.</p><p><strong>Use project tag fields when you need flexibility.</strong> Project tags behave like regular fields in both Query DSL and ES|QL. You can filter, aggregate, sort, and include them in output. This opens up scenarios that routing alone cannot handle, such as combining tags with Boolean logic, grouping results by project, or filtering on custom tags like “env” or “department”. The trade-off is that all linked projects are still contacted, even if the filter ultimately excludes their data.</p><p><strong>Combine both for the best of both worlds. </strong>Start with project routing to narrow down to the relevant projects, and then use project tag fields within your query for finer-grained logic. This gives you the performance benefits of early exclusion with the expressiveness of full query support.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Don't leave metrics on the table: query them with the ES|QL TS command]]></title>
            <description><![CDATA[Recalibrate your mental model for time series queries: learn why FROM can produce inaccurate results for metrics, how TS fixes that, and when to use each command.]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-ts-command-querying-metrics</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-ts-command-querying-metrics</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Felix Barnsteiner]]></dc:creator>
            <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e2eb1ba006612a352f1317c1621e4ebc5b2a12b6-1376x768.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>If you use ES|QL for logs and traces, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/from"><code>FROM</code></a> is probably second nature, but on metrics it can return numerically wrong answers. A query like <code>FROM metrics-* | STATS SUM(request_count)</code> adds up cumulative counter values across every sample on every host. The result grows without bound and isn&#x27;t a rate, a count, or anything else useful. <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> fixes that by grouping samples into time series first, then exposing functions like <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-rate"><code>RATE</code></a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-avg_over_time"><code>AVG_OVER_TIME</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-last_over_time"><code>LAST_OVER_TIME</code></a> that operate per series.</p><p>For a high-level tour of metrics analytics across ES|QL and Discover, see <a href="https://www.elastic.co/observability-labs/blog/metrics-explore-analyze-with-esql-discover">Explore and Analyze Metrics with Ease in Elastic Observability</a>. This post zooms in on the mechanics.</p><p>Here is the mental model in five bullets:</p><ul><li><code>FROM</code> treats every document as an independent row. That is right for events, but metric aggregations often need the time series that each row belongs to.</li><li><code>TS</code> adds that time series context: it groups and aggregates data points by time series before any other aggregation runs, and enables functions like <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-rate"><code>RATE</code></a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-avg_over_time"><code>AVG_OVER_TIME</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-last_over_time"><code>LAST_OVER_TIME</code></a>.</li><li>A <code>TS | STATS</code> query normally has two aggregation phases. The inner phase reduces samples inside each time series; the outer phase groups and combines those per-series results.</li><li>The default inner aggregation is <code>LAST_OVER_TIME</code>, which is why <code>TS metrics | STATS AVG(cpu_usage)</code> and <code>FROM metrics | STATS AVG(cpu_usage)</code> can return different numbers.</li><li>Use <code>TS</code> to query a time series data stream (<a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">TSDS</a>). Use <code>FROM</code> for events and raw document inspection.</li></ul><h2>What is a time series, really?</h2><p>A time series is a sequence of <code>(timestamp, value)</code> data points identified by the metric name and a unique set of dimension values.</p><p>For example, <code>request_count</code> reported every 30 seconds by host <code>h1</code> in data center <code>dc1</code> is one time series. The same metric on host <code>h2</code> in <code>dc1</code> is a different time series.</p><p>In a time series data stream, every metric document carries an internal <code>_tsid</code> field that uniquely identifies a time series. Samples that share a <code>_tsid</code> belong to the same time series and are stored sequentially, sorted by timestamp.</p><p>That storage layout enables efficient per-series aggregations. It also explains why <code>TS</code> only works on time series data streams. Other index modes have no notion of a time series, so the per-series operations <code>TS</code> relies on have no such identifier to attach to. <code>FROM</code> does not support those operations, which is what the next section is about.</p><h2>Why FROM leaves metrics on the table</h2><p>Consider a counter named <code>request_count</code> collected every 30 seconds from three hosts.</p><p>A counter is a cumulative metric: each sample is the running total since the process started reporting it. For <code>request_count</code>, a value of <code>1,000</code> means &quot;this time series has observed 1,000 requests so far&quot;, not &quot;1,000 requests happened since the previous sample&quot;. Counters reset to zero on process restart, so a sample of <code>4</code> right after <code>1,004</code> is a fresh count, not negative traffic. The ES|QL <code>RATE</code> function computes the per-second change within a time series and handles resets without glitches.</p><p>You want to calculate the total request rate across all hosts, bucketed by 5 minutes.</p><p>If you are used to writing ES|QL over event data, you might start with this query:</p><pre><code>FROM metrics-*
| WHERE TRANGE(1h)
| STATS SUM(request_count) BY BUCKET(@timestamp, 5m)</code></pre><p>The chart it produces looks plausible at first: a line that goes up over time. But the number on the y-axis is the sum of every cumulative counter value reported in the bucket. Each host contributes its own running total, repeatedly, once per sample. Because the query uses <code>SUM</code> on those cumulative values, the result is not a rate, it is not the number of requests in the bucket, and it grows without bound even if the application stops receiving requests.</p><p><code>request_count</code> is a monotonically increasing counter, so its raw values represent &quot;how many requests have ever happened on this host&quot;, not how many happened in the bucket. The right computation is &quot;how much did this counter increase per second on each host, then sum across hosts.&quot; <code>FROM</code> cannot express that operation directly. It can group rows by fields, but it has no built-in notion of &quot;the same time series over time&quot; and no way to ask for the change of a counter within each time series. It also cannot use sliding-window time series functions such as <code>RATE(request_count, 5m)</code>, which we will come back to below.</p><p><code>TS</code> was introduced for this purpose, providing a succinct syntax to express time series aggregations:</p><pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS SUM(RATE(request_count)) BY TBUCKET(5m)</code></pre><p><code>RATE(request_count)</code> runs per time series and produces a per-second rate that handles counter resets correctly. <code>SUM</code> then adds those rates across hosts.</p><h2>Two aggregation phases: inner and outer</h2><p>Every <code>TS | STATS</code> query has two distinct aggregation phases.</p><p>Let&#x27;s make that concrete with a query that calculates the request rate per data center:</p><pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS SUM(RATE(request_count)) BY datacenter, TBUCKET(5m)</code></pre><p>The diagram below shows how <code>TS</code> evaluates this query. It first reduces samples inside each time series, then groups and combines those per-series values into one result per <code>datacenter</code> and time bucket.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1d3d765ab8e27e539ac6bedf3e7444632a4bbe7e-3050x900.png" style="width: 100%;" /><p>The phases are:</p><p><strong>Inner (within a time series).</strong> Runs separately for each time series. It collapses many <code>(timestamp, value)</code> data points within a bucket into a single value per time series per bucket by applying the inner aggregation function, such as <code>RATE</code> in the example above. Functions: <code>RATE</code>, <code>AVG_OVER_TIME</code>, <code>MAX_OVER_TIME</code>, <code>LAST_OVER_TIME</code>, <code>STDDEV_OVER_TIME</code>, and so on. The full list is on the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">time series aggregation functions</a> page.</p><p><strong>Outer (across time series, the &quot;grouping&quot; phase).</strong> Combines the per-series values into a single value per group per bucket. Functions: <code>SUM</code>, <code>AVG</code>, <code>MAX</code>, <code>MIN</code>, percentiles, and the rest of the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/aggregation-functions">regular ES|QL aggregates</a>.</p><p>In <code>SUM(RATE(request_count)) BY datacenter, TBUCKET(5m)</code>:</p><ul><li><code>RATE(request_count)</code> is the inner aggregation. It runs per time series.</li><li><code>SUM(...)</code> is the outer aggregation. It combines time series within the same <code>datacenter</code> and bucket.</li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/grouping-functions#esql-tbucket"><code>TBUCKET(5m)</code></a> defines the bucket boundaries (equivalent to <code>BUCKET(@timestamp, 5m)</code>).</li></ul><p>The outer aggregation is optional. If you only need the per-time-series result, use the time series aggregation function directly:</p><pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS request_rate = RATE(request_count) BY TBUCKET(5m)</code></pre><p>That query keeps the per-series rate for each bucket instead of wrapping it in <code>SUM</code>, <code>AVG</code>, or another aggregate across time series.</p><h2>The default inner aggregation: LAST_OVER_TIME</h2><p><code>TS</code> has to reduce raw samples inside each time series before it can run the outer aggregation. That means every metric field in a <code>TS | STATS</code> aggregation needs an inner aggregation, even when the query does not spell one out.</p><p>Consider a metric named <code>cpu_usage</code>. It is a gauge: a metric that captures a value at a point in time and can move up and down freely. A sample of <code>0.42</code> means &quot;this host is at 42% CPU at this time&quot;. For a gauge, the natural &quot;value in this bucket&quot; is the most recent sample.</p><p>That is what ES|QL fills in for you. If you write <code>TS metrics | STATS AVG(cpu_usage) BY host.name, TBUCKET(5m)</code>, the implicit inner aggregation is <code>LAST_OVER_TIME(cpu_usage)</code> and the query is equivalent to:</p><pre><code>TS metrics
| WHERE TRANGE(1h)
| STATS AVG(LAST_OVER_TIME(cpu_usage)) BY host.name, TBUCKET(5m)</code></pre><p>For each time series, <code>LAST_OVER_TIME</code> picks the latest sample in the bucket. Then <code>AVG</code> averages across time series.</p><p>It is also why the same-looking query against <code>FROM</code> and <code>TS</code> can return different numbers. <code>FROM</code> averages every individual document. <code>TS</code> averages one value per time series per bucket. If your hosts publish at slightly different rates, those averages diverge. For example, in a five-minute bucket, a host that publishes every second contributes 300 documents while a host that publishes every two minutes contributes only two or three. With <code>FROM | STATS AVG(cpu_usage)</code>, the chatty host dominates the average. With <code>TS</code>, each time series is reduced to one bucket value first, so the outer average gives each host one value to contribute.</p><p>If you want the average value during the bucket instead of the latest value, make the inner aggregation explicit:</p><pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS AVG(AVG_OVER_TIME(cpu_usage)) BY host.name, TBUCKET(5m)</code></pre><p><code>AVG_OVER_TIME</code> averages all CPU utilization samples within each time series. The outer <code>AVG</code> then averages those per-series values across matching hosts. That makes the result sample-weighted within each time series, then equally weighted across time series. Use this when you care about how the value behaved during the bucket, not just where it ended up.</p><p>The same rule applies to peaks and troughs. For a peak CPU chart, use <code>MAX(MAX_OVER_TIME(cpu_usage))</code>, not just <code>MAX(cpu_usage)</code>. The inner <code>MAX_OVER_TIME</code> finds the peak within each time series; the outer <code>MAX</code> finds the peak across matching time series.</p><p>Counters work the other way around. Their sample value is a running total, so the latest sample on its own is rarely meaningful. For a counter, the inner aggregation you almost always want is <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#esql-rate"><code>RATE</code></a> for a per-second rate, or <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions"><code>INCREASE</code></a> for the total change in the bucket. Falling back on the default <code>LAST_OVER_TIME</code> gives you the most recent cumulative value, which is the trap the FROM query in the previous section walked into.</p><p>Pick the inner function deliberately. The outer function is the easy part.</p><h2>When to use TS, when to use FROM</h2><p>A practical rule of thumb:</p><ul><li>Use <code>TS</code> for metric aggregations against a <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time series data stream</a>. It is the source command designed for that data, and it applies per-series semantics by default.</li><li>Use <code>FROM</code> for events: logs, traces, audit records, transactions. Each row is independent. There is no time series context.</li></ul><p><code>FROM</code> still works on TSDS indices and is occasionally useful, for example when you want to inspect raw metric documents without per-series grouping. For dashboards, alerts, and any kind of charting, <code>TS</code> is the right default.</p><p>If you first need to discover which metrics or time series exist in the data, use <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/metrics-info"><code>METRICS_INFO</code></a> or <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts-info"><code>TS_INFO</code></a> after <code>TS</code> and before <code>STATS</code>. See <a href="https://www.elastic.co/search-labs/blog/esql-metrics-info-ts-info-time-series-catalog">ES|QL METRICS_INFO and TS_INFO: Catalog your time series data</a> for a deeper walkthrough.</p><h2>Post-process TS results with ES|QL</h2><p>The first <code>STATS</code> command is the boundary between time series processing and regular ES|QL processing. Before that first <code>STATS</code>, <code>TS</code> needs to keep the data grouped by <code>_tsid</code>, so commands that change row order or shape are not allowed. After that first <code>STATS</code>, the output is a regular ES|QL table. You can sort it, limit it, join lookup data, enrich it, or compute derived columns.</p><p>For example, this query calculates average CPU per host and bucket, finds the maximum bucketed average for each host, and returns the ratio:</p><pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS avg_cpu = AVG(AVG_OVER_TIME(cpu_usage)) BY host.name, time_bucket = TBUCKET(5m)
| INLINE STATS max_avg_cpu = MAX(avg_cpu) BY host.name
| EVAL cpu_ratio = avg_cpu / max_avg_cpu
| KEEP host.name, time_bucket, cpu_ratio
| SORT host.name, time_bucket DESC</code></pre><h2>Sliding windows for the inner aggregation</h2><p>Time series aggregation functions accept a second argument: the window size for the inner phase.</p><pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS AVG(RATE(app.requests, 5m)) BY TBUCKET(1m)</code></pre><p>This computes the rate over a 5-minute sliding window, but reports a value every minute. It is useful when you want a smoother chart at fine bucket sizes.</p><p>The window is the ES|QL counterpart to a PromQL <a href="https://prometheus.io/docs/prometheus/latest/querying/basics/#range-vector-selectors">range vector selector</a>: <code>RATE(app.requests, 5m)</code> serves the same purpose as <code>rate(app_requests[5m])</code>.</p><h2>Gotchas worth knowing</h2><p>A few things in <code>TS</code> can seem surprising, especially when coming from the events-based <code>FROM</code> mental model. None of these are bugs; most are direct consequences of the per-series model. Here is what to watch for.</p><p><strong><code>COUNT(*)</code></strong> <strong>is rejected.</strong> Say you want to know how many samples were collected per service in each bucket. The instinct from <code>FROM</code> is <code>COUNT(*)</code>, but <code>TS</code> rejects it: there is no plain &quot;row&quot; once data is grouped by time series, so a row count has no defined meaning. Pick what you actually want to count:</p><ul><li>Number of samples per service: <code>STATS samples = SUM(COUNT_OVER_TIME(cpu_usage)) BY service.name, TBUCKET(5m)</code>. The inner <code>COUNT_OVER_TIME</code> counts samples per time series; the outer <code>SUM</code> adds them across the time series in the group.</li><li>Number of distinct hosts reporting per service: <code>STATS hosts = COUNT_DISTINCT(host.name) BY service.name, TBUCKET(5m)</code>. This counts unique label values across time series.</li></ul><p><strong>You cannot sort, limit, lookup join, or enrich before</strong> <strong><code>STATS</code>.</strong> <code>TS metrics | SORT @timestamp | STATS ...</code> will fail. The grouping by <code>_tsid</code> must happen first, before anything else can run. Filter with <code>WHERE</code> if you need to narrow the scope. After the first <code>STATS</code>, the output is regular ES|QL and you can pipe it through any command, as shown in the previous section.</p><p><strong>Gauge vs counter mapping.</strong> Time series functions are sensitive to the metric type set in the field mapping. <code>RATE</code> only works on counters; <code>*_OVER_TIME</code> functions are intended for gauges. If you build TSDS mappings by hand, pay special attention to this part.</p><p>This can be a source of friction for Prometheus users. Prometheus metric type metadata is not always available in the data Elasticsearch receives, so the metric type may have to be inferred from naming conventions (<code>_total</code> for counters, and so on). Those heuristics are imperfect, and a misclassified metric is rejected by the function that should accept it. The deeper mechanics, including how Prometheus Remote Write maps metric types into TSDS, are covered in <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">How Prometheus Remote Write Ingestion Works in Elasticsearch</a>.</p><p>Explicit converter functions (gauge-to-counter and counter-to-gauge) are on the roadmap to make these cases easier to recover from at query time.</p><p><strong>Kibana charts go empty when you zoom in too far.</strong> In Kibana, <code>TBUCKET</code> adapts to the date picker, so zooming in shrinks the bucket size. When the bucket size drops below the data&#x27;s collection interval, every other bucket has no sample, <code>RATE</code> and the rest return null, and the chart silently goes blank. Elastic is evaluating mitigations such as a runtime warning when the bucket size is too small, a configurable minimum bucket size, or automatic widening of the window or bucket size.</p><h2>Wrap up</h2><p>For metric queries, start with <code>TS</code> unless you specifically need raw documents. Then choose the inner aggregation based on what the value should mean inside each time series: <code>RATE</code> for counters, <code>LAST_OVER_TIME</code> for current gauge values, and explicit <code>*_OVER_TIME</code> functions for peaks, averages, minimum values, or distributions.</p><p>Once the per-series value is right, the outer aggregation is the familiar part: group and reduce those time series into the chart, alert, or table you need.</p><p>For the full reference, see the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts">command docs</a> and the list of <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">time series aggregation functions</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ Approximate queries in Elasticsearch ES|QL: 100x faster on billions of records, with built-in confidence intervals]]></title>
            <description><![CDATA[ES|QL now supports approximate query execution. Add one line to your queries, and get results orders of magnitude faster, with built-in confidence intervals that tell you exactly how much to trust them.]]></description>
            <link>https://www.elastic.co/search-labs/blog/approximate-queries-esql-analytics</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/approximate-queries-esql-analytics</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Aris Papadopoulos]]></dc:creator>
            <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a9bcb929a90d1b3f5777fbe121d01e078cf99b9c-1999x1574.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Add one line to any Elasticsearch Query Language (ES|QL) query, and get answers 100x+ faster on billions of documents. Your gains grow as your data grows. Built-in confidence signals tell you when results carry formal guarantees and when they’re best estimates.</p><h2>One line: Speed that scales with your data</h2><p>On billions of documents, analytical queries push against a real efficiency-precision trade-off. We’ve been hard at work pushing back. Our <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-logsdb-storage-evolution">native columnar support</a> is one of the best there are. ES|QL itself is a fast, purpose-built analytical engine, <a href="https://www.elastic.co/search-labs/blog/esql-swiss-hash-stats">getting ever smarter at aggregation execution</a>. And Elasticsearch ships a steady stream of efficiency innovations, like <a href="https://www.elastic.co/search-labs/blog/Elasticsearch-sorting-speed-up">Block k-dimensional (BKD) tree pruning</a>, with more landing all the time.</p><p>But even with all of that, native approximate queries really shine through. Starting in Elasticsearch 9.4, ES|QL supports approximate query execution. All you have to do is add one line to your queries: Prepend <code>SET approximation = true</code>. Now Elasticsearch will automatically sample a subset of your data, run the aggregation on that sample, extrapolate the results, and report confidence intervals. All transparently.</p><pre><code>SET approximation = true;
FROM logs-*
| STATS count = COUNT(*) BY time = BUCKET(@timestamp, 5 MINUTE)
| SORT time</code></pre><p>Your existing query stays unchanged. The <code>SET</code> directive tells Elasticsearch to handle the sampling, extrapolation, and statistical validation for you. No query rewriting, no manual sampling math, no guessing at sampling probabilities.</p><p><code>SET approximation = true</code> is a forward-compatible directive. Today, it speeds up the most heavily used aggregations. As we expand support to more capabilities, your existing queries benefit automatically. Queries that aren’t yet approximated run exactly without errors; a warning header explains why.</p><h2>How much faster?</h2><p>On the ClickBench benchmark, well-behaved analytical queries ran on average 23x faster with confidence intervals enabled. Individual queries hit <strong>~100x</strong>. Disabling confidence interval computation, the highest-leverage queries land near <strong>~300x</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fd32f0fddea4dd09a1ceddb9b9e226ac83794117-1999x1551.png" style="width: 100%;" /><p>The advantage grows as your datasets grow. Approximate-mode cost is capped by the configured sample size, while exact-execution cost scales with row count. Doubling your index doubles exact-query time but barely changes approximate-query time for the same accuracy! This is a beautiful property of the underlying math, not an engineering trick, and it’s why approximation gets more valuable as you scale.</p><p>Speedup also depends on query shape, grouping cardinality, and sample size. See the FAQ for the full set of factors and tuning tips. Read “Fast approximate ES|QL” in two parts ([Part 1], [Part 2]), straight from the creators of the feature.</p><h2>What you get back</h2><p>The response includes your original aggregated values, automatically scaled to represent the full dataset; a <code>COUNT</code> on a 1% sample comes back as the estimated total, not the sample count. Column names and types are preserved (backwards-compatible). Plus two additional columns per approximated value:</p><ul><li><strong>Confidence interval:</strong> A range that bounds the true value at the configured confidence level (default 90%). For example, a count of 268,473 with interval [264,444–273,179] means you can be 90% confident the true count falls in that range.</li><li><strong>Certified flag:</strong> A Boolean indicating whether the confidence interval for that value meets formal statistical guarantees. When certified is <code>true</code>, the data distribution allows us to rely on the results at face value. When <code>false</code>, the approximation is still often close but we can’t claim the same formal guarantees, typically because the distribution may be highly skewed or involve too few documents in a group. Think of it as the difference between &quot;statistically proven&quot; and &quot;best estimate.&quot;</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/db6da3424aa6ee8b789d975f591afe7ca3a92501-1999x392.png" style="width: 100%;" /><p>This is a deliberate design choice: Consumers that don&#x27;t care about the confidence metadata can choose to not compute them at all (see “Granular control when you need it”) or ignore the extra columns and use the results exactly as before. Consumers that do care (like AI agents that read the results programmatically) get everything they need without a second query.</p><h2>Use cases for approximate queries in ES|QL: Where this matters</h2><h3>AI agents and agentic workflows</h3><p>Approximate queries don’t just speed up agent queries; they enable a <em>scan-then-enhance</em> investigation pattern that wasn’t practical at scale before. An agent can sweep billions of documents in sub-second time, identify candidates, and zoom in for exact answers, all inside a single reasoning loop. The <code>certified</code> flag turns approximation into a decision signal: Proceed at face value when it’s <code>true</code>, escalate to an exact query when it’s <code>false</code> and the step needs a tight guarantee. As ES|QL becomes the foundation for agentic analytics in Elastic, approximation is the speed layer that makes investigation possible at this scale.</p><h3>Dashboards and charts on large datasets</h3><p>Dashboards that aggregate weeks or months of data can become sluggish as data volumes grow. With <code>SET approximation = true</code>, the same dashboard loads faster. In the future, Kibana will inject the setting transparently, so users won&#x27;t need to know it&#x27;s happening; they’ll just see faster charts.</p><h3>Log pattern analysis in ES|QL at scale</h3><p><code>CATEGORIZE</code>, <code>GROK</code>, and regex-heavy conditions are among the most compute-intensive parts of ES|QL because they require nontrivial compute per document. With approximate execution enabled, these large-scale pattern and exploration workflows become practical on very large indices.</p><h3>Exploratory analysis and hypothesis testing</h3><p>When you&#x27;re exploring data to form hypotheses, for example, &quot;Which services have the highest error rates this week?&quot;, you rarely need exact counts. You need shapes, relative magnitudes, and outliers. Approximate mode gives you those at interactive speed, and the confidence intervals tell you when to switch back to exact mode for the final answer.</p><h2>How approximate queries work in ES|QL, without the math</h2><p>The speedup is real engineering, not a query-planner trick. Sampling happens at the Lucene layer: Elasticsearch reads only the documents in the sample, so I/O and compute savings are proportional to the sampling rate. The aggregation runs on the sample, and the result is automatically scaled to represent the full dataset.</p><p>Confidence intervals are computed by a bootstrap procedure over multiple sub-partitions of the sample: statistically rigorous, not a heuristic or a guess. This is what backs the <code>certified</code> flag: When the methodology’s assumptions are met, the intervals carry formal guarantees.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a9bcb929a90d1b3f5777fbe121d01e078cf99b9c-1999x1574.png" style="width: 100%;" /><h2>Granular control when you need it</h2><p>The defaults are designed to work well out of the box, but you can tune them:</p><pre><code>SET approximation = {"rows": 500000, "confidence_level": 0.95};
FROM logs-*
| STATS count = COUNT(*), avg_duration = AVG(duration) BY service.name</code></pre><ul><li><strong>rows:</strong> How many documents to sample (default: 100,000 for ungrouped queries, 1,000,000 for grouped). More rows means higher accuracy and longer runtime.</li><li><strong>confidence_level:</strong> The confidence level for intervals. Defaults to: 0.9. Set it to a higher level for an increased probability that the value is within the confidence interval.</li><li><strong>Skip confidence intervals for maximum speed:</strong> Set <code>confidence_level</code> to <code>null</code>, and Elasticsearch returns just the point estimates, adding another 2–5x speed on top of approximate execution. This is how the highest-leverage queries land near <strong>300x</strong>.</li></ul><h2>What&#x27;s next</h2><p><code>SET approximation = true</code> is a forward-compatible directive. As we add support for <code>FORK</code>, <code>JOIN</code>, chained <code>STATS</code>, and additional aggregations, your existing queries automatically benefit.</p><p>Future work also includes tighter integration with Kibana so dashboards and Discover can enable approximation automatically and improved handling of highly skewed grouping fields.</p><p>Additionally, we’ll make approximate queries natively accessible to agents, so they can opt into fast execution as part of their analytics tools and reasoning loop.</p><h2>Get started</h2><p>Approximate queries are available in Elasticsearch 9.4 as a technical preview on the Enterprise subscription tier. Add <code>SET approximation = true;</code> to the beginning of your query, and see the difference. Check the <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL SET command reference</a> for configuration options.</p><p><br/><strong>FAQ</strong></p><p><strong>What is approximate query execution in Elasticsearch?</strong></p><p>Approximate query execution is a mode where Elasticsearch samples a subset of your data, runs the aggregation on the sample, and extrapolates the result to represent the full dataset. You get back the estimated value plus a confidence interval showing how much to trust it. It&#x27;s controlled by a single <code>SET</code> directive prepended to your existing ES|QL query; no query rewriting required.</p><p><strong>How do I speed up ES|QL aggregations without reducing my data retention?</strong></p><p>Just add <code>SET approximation = true</code> to your query. Approximate execution samples at query time, not at index time. Your data stays fully indexed, fully retained, and queryable both exactly and approximately. Elasticsearch handles sampling and extrapolation on the fly. Drop the directive any time you want exact results; nothing about the underlying data changes.</p><p><strong>How much faster are approximate queries?</strong></p><p>On the ClickBench benchmark, aggregation-heavy ES|QL queries that are well-suited to sampling typically run 10–40x faster with confidence intervals enabled, with individual queries reaching 100x or more. Disabling confidence interval computation (<code>SET approximation = {&quot;confidence_level&quot;: null}</code>) adds another 2–5x on top, so the highest-leverage queries hit nearly 300x. The advantage grows with dataset size: Sampling cost is capped by the configured sample size, while exact execution cost scales with the row count, so the bigger your index, the bigger the win for the same precision.</p><p><strong>How accurate are approximate queries? Can I trust the results?</strong> </p><p>Each approximated value comes back with two signals: a confidence interval (a range bounding the true value at a configurable confidence level) and a certified Boolean flag. When certified is <code>true</code>, the confidence interval carries formal statistical guarantees. When <code>false</code>, the result is still often close, but the data distribution didn&#x27;t meet the assumptions required for a formal guarantee. Accuracy depends on data characteristics and query shape, not on document count, so speedup gains increase as your dataset grows.</p><p><strong>What does the speedup depend on?</strong></p><p>Five main factors:</p><ul><li>Dataset size. <strong>Larger datasets produce larger speedups</strong>, for the reason described above (exact scans grow with N; sampled scans don’t).</li><li>Query shape. Queries that scan a lot to compute relatively little (large <code>STATS</code>, especially <code>MEDIAN</code> and <code>PERCENTILE</code>) benefit most. Queries that are already cheap (small <code>WHERE</code> filters matching few rows, or simple counts that hit indexed summary statistics) see little speedup.</li><li>Grouping cardinality and distribution. Well-distributed <code>BY</code> fields with healthy per-group sample counts benefit cleanly. Very sparse or highly skewed grouping (for example, a near-unique field or a long tail of rare values) can erode the gain because rare groups end up with too few sampled documents.</li><li>Confidence interval computation. Computing intervals adds overhead. Set <code>confidence_level</code> to <code>null</code>, and you trade interval reporting for an additional 2–5x speedup.</li><li>Sample size. The defaults (100k for ungrouped <code>STATS</code>, 1M for <code>STATS … BY</code>) work well for most queries. Increasing rows improves accuracy on high-cardinality grouping at the cost of some speedup; decreasing it does the reverse.</li></ul><p><strong>Can I use approximate queries for log analysis and pattern detection?</strong> </p><p>Yes. <code>CATEGORIZE</code>, <code>GROK</code>, and regex-heavy conditions are among the most compute-intensive operations in ES|QL because they require per-document processing. With <code>SET approximation = true</code>, these operations run on a sampled subset instead of the full index, making large-scale log pattern analysis and exploration fast on very large datasets.</p><p><strong>Do I have to rewrite my ES|QL queries to use approximate mode?</strong> </p><p>No. Prepend <code>SET approximation = true</code> to your existing query. The aggregation expressions, column names, and output types stay the same. The response adds two columns per approximated value (the confidence interval and the certified flag), but existing consumers that don&#x27;t use those columns see no breaking change.</p><p><strong>What aggregations does approximate mode support in 9.4?</strong> </p><p><code>COUNT</code>, <code>SUM</code>, <code>AVG</code>, <code>WEIGHTED_AVG</code>, <code>MEDIAN</code>, <code>PERCENTILE</code> (except extremes), <code>MEDIAN_ABSOLUTE_DEVIATION</code>, and <code>STD_DEV</code> (with caveats for highly skewed distributions). More coverage on the way.</p><p><strong>Will I get the same result twice for the same query?</strong></p><p>Not exactly. Approximate execution randomly samples documents at query time, so successive runs of the same query return slightly different point estimates and confidence intervals. The variation between runs is small relative to the confidence interval each run reports. If you need bit-for-bit reproducibility, run the exact query. For dashboards, depending on the use case, the variation can typically be smaller than the visual resolution of the chart.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Ecommerce search optimization using margin and popularity boosting in Elasticsearch]]></title>
            <description><![CDATA[Learn how to optimize ecommerce search using margin and popularity boosting. This blog explains how a governed control plane treats economic optimization in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/ecommerce-search-optimization-query-governed</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ecommerce-search-optimization-query-governed</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/44a9dc320fa5f36f263e48c7ab2a050955e1d071-721x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Parts 1 through 6 of this series describe a governed control plane that classifies intent, enforces constraints, resolves conflicts, personalizes results, and routes to the appropriate retrieval strategy. This post introduces a different objective: ensuring the retailer&#x27;s business priorities influence which of those relevant products rank highest, with that optimization governed per query through policies rather than applied as a static global setting.</p><p>In most ecommerce deployments, economic signals, like profit margin and product popularity, are either ignored in search ranking or applied as static, global weights. A fixed margin boost might push high-margin products up across every query, which works for &quot;chocolate&quot; (where shoppers are open to suggestion) but backfires for &quot;baby formula&quot; (where shoppers want the trusted, popular brand).</p><p>The governed control plane makes it possible to treat economic optimization as a per-query decision, expressed as policy data and managed through the same admin UI as every other governance mechanism. A merchandiser can say &quot;for chocolate queries, prioritize margin&quot; and &quot;for baby formula queries, prioritize popularity&quot;, without writing code, without deploying changes, and with full auditability.</p><p>For the mathematical foundation of margin and popularity boosting in Elasticsearch, including the logarithmic scaling formula and factor tuning explanation, see <a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch">Boosting e-commerce search by profit and popularity with the function score query in Elasticsearch</a>.</p><h2>Two business signals: Margin and popularity</h2><p>Every product document in our product catalog carries two numeric fields:</p><ul><li><strong><code>margin</code>:</strong> The product&#x27;s profit margin as a percentage (0 to 200 in our dataset).</li><li><strong><code>popularity</code>:</strong> A relative sales volume metric (0 to 10,000 in our dataset), such as weekly average units sold.</li></ul><p>These fields represent two fundamentally different business objectives. <em>Margin optimization</em> pushes profit per transaction. <em>Popularity optimization</em> pushes conversion probability since products that many shoppers buy are products that the current shopper is likely to buy.</p><h2>The baseline: Global boosting with business signals</h2><p>Before introducing per-query policy overrides, the system applies a default boost for both margin and popularity. These are implemented using Elasticsearch&#x27;s <code>field_value_factor</code> with logarithmic scaling inside a <code>function_score</code> query as described in <a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch">Boosting e-commerce search by profit and popularity with the function score query in Elasticsearch</a>.</p><p>The design has three properties worth noting:</p><ul><li><strong>Calibrated range.</strong> Each signal&#x27;s factor is calibrated so that it contributes at most approximately +1.0 to the boost multiplier at the top of its range. Combined with a baseline weight of 1, the final multiplier ranges from 1.0 (a product with zero margin and zero popularity) to approximately 3.0 (maximum margin plus maximum popularity). A product with strong business signals scores roughly 3x higher than an identical product with none, regardless of the BM25 score magnitude.</li><li><strong>Logarithmic scaling.</strong> The <code>ln1p</code> modifier grows fast at small values (rewarding incremental gains) but flattens at high values (preventing runaway scores from a single dominant product). This also makes the system resilient to data distribution changes: If the maximum popularity in a dataset shifts significantly, the boost curve stretches rather than breaking.</li><li><strong>Multiplicative, not additive.</strong> The business-signal boost is applied multiplicatively against BM25 (<code>boost_mode: &quot;multiply&quot;</code>) rather than added to it. BM25 scores vary dramatically across queries, so an additive boost would have inconsistent impact depending on query specificity. Multiplicative scaling guarantees a consistent percentage uplift regardless of the absolute BM25 magnitude.</li></ul><h2>Per-query boosting overrides through policies</h2><p>The default weights (1.0 for both margin and popularity) apply to every query. But the governed control plane makes it possible to override these weights on a per-query basis through the same policy engine described in <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">Part 4</a>.</p><p>Each policy document has two optional fields: <code>margin_boost_weight</code> and <code>popularity_boost_weight</code>. When a policy matches a query and includes weight overrides, those values flow through to the <code>function_score</code> construction, replacing the defaults.</p><h2>Why per-query control matters</h2><p>Consider two queries and why they demand different economic optimization strategies.</p><h3>Margin boosting: Chocolate</h3><p>A shopper searching for &quot;chocolate&quot; is browsing. They&#x27;ll be satisfied by many chocolate-related products. The retailer&#x27;s store-brand chocolate truffles at 60% margin might be just as appealing as the name-brand bar at 15% margin. Aggressive margin boosting pays for itself if the shopper doesn&#x27;t care which chocolate they purchase and buys one of the margin-boosted hits.</p><h3>Chocolate results without margin boosting</h3><p>To isolate the effect of per-query margin boosting, we first disable margin boosting entirely for this query (margin boost weight: 0). Without any margin signal, the ranking is driven by text relevance. In our dataset, the first hit has a margin of 10 and the next one has a margin of 84 (out of a max of 200) as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/12b36c0c2104fc30c87c993e751f469522a876b2-660x845.png" style="width: 100%;" /><h3>Setting a margin boost on queries for “chocolate”</h3><p>A merchandiser who decides that “chocolate” queries should prioritize margin makes that change in the admin UI, tests it against representative queries, and promotes it to production. The change takes effect on the next query. No engineering ticket, no deployment, no code change. The following &quot;chocolate&quot; policy sets <code>margin_boost_weight: 3.0</code>, which ensures that searches for chocolate will aggressively promote high-margin items.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a70dcbffc8fdf04d9161b327f79008548646478c-1097x1052.png" style="width: 100%;" /><h3>Chocolate results with margin boosting</h3><p>With the above margin boost policy enabled, the higher-margin chocolates with a margin of 197 and 184 are boosted to the top of the results as follows (remember that the maximum margin in our dataset is 200):</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d22245cbc5cd2198d1f27067718c4c8719d096fe-660x940.png" style="width: 100%;" /><h3>Popularity boosting: Baby formula</h3><p>A parent searching for baby formula is not experimenting. They want the product that other parents trust. Pushing a high-margin store-brand formula above the established brand that thousands of parents are buying would feel wrong and erode trust. Popularity is the right signal here because it functions as social proof for a high-stakes purchase.</p><h3>Baby formula results without a popularity boost</h3><p>To isolate the effect of per-query popularity boosting, we first disable popularity boosting entirely for this query (<code>popularity_boost_weight: 0</code>). Without any popularity signal, the ranking is driven by text relevance. In this example, the top hit has a popularity of 50 on a scale that goes up to 10,000.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e8f1121de8f5982882f6ed8cef2359b2bb76e45f-651x752.png" style="width: 100%;" /><h3>Setting a popularity boost on queries for “baby formula”</h3><p>A &quot;baby formula&quot; policy sets <code>popularity_boost_weight: 5.0</code> and <code>margin_boost_weight: 0</code>; formula searches prioritize what&#x27;s popular, completely ignoring margin.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/72cdf6ac8bc24dc93d3a540a6d359a5b03145cef-1083x1042.png" style="width: 100%;" /><h3>Baby formula results with popularity boosting</h3><p>If we enable the above rule, then the most popular baby formula (Lactogen 2 with a popularity of 9979) will be boosted to the top of the results, as shown below.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2ff438b375cf8bf21fc5fe27ee311afe510220df-662x732.png" style="width: 100%;" /><h2>Disabling business signals: Clearance</h2><p>Not every query benefits from economic boosting. A shopper searching for &quot;clearance&quot; is looking for deals; margin and popularity are both irrelevant to that intent. A high-margin product is the opposite of what the shopper wants, and a popular product may not be on clearance at all.</p><p>A &quot;clearance&quot; policy sets <code>margin_boost_weight: 0</code> and <code>popularity_boost_weight: 0</code>, which disables both business signals entirely. Results are ranked on pure text relevance with no economic influence. This completes the design space: Policies can amplify either signal independently, rebalance them, or turn them off altogether.</p><h2>How overrides flow through the control plane</h2><p>When the percolator returns matching policies, the control plane checks for <code>margin_boost_weight</code> and <code>popularity_boost_weight</code> fields on the highest-priority matching policy. If present, those values replace the defaults in the <code>RewriteState</code>. If no matching policy includes weight overrides, the default values (1.0 for both) are used.</p><p>The weights then flow through to the <code>function_score</code> construction when the final Elasticsearch query is assembled. The structure of the <code>function_score</code> doesn&#x27;t change; only the <code>weight</code> values on the margin and popularity functions.</p><p>Weight overrides participate in the same governance model as every other policy mechanism. They’re subject to priority ordering: A Christmas campaign policy with <code>margin_boost_weight: 0.5</code> will override a product-category policy with <code>margin_boost_weight: 3.0</code> if the campaign policy has higher priority. The cascading transformation model from <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> applies: Economic optimization parameters are just another field in the policy&#x27;s execution plan.</p><h2>Interaction with other policies</h2><p>Per-query weight overrides compose naturally with the constraint enforcement, conflict resolution, and personalization mechanisms described in earlier parts of this series.</p><p>Consider a search for &quot;cheap chocolate&quot; during a Christmas campaign, with a shopper who has a purchase history and belongs to a vegan cohort. The control plane processes this query through the full governance stack:</p><ol><li>The &quot;cheap&quot; policy extracts the price constraint and removes &quot;cheap&quot; from the query.</li><li>The &quot;chocolate&quot; policy sets <code>margin_boost_weight: 3.0</code> and constrains results to chocolate categories.</li><li>The “Christmas campaign” policy (higher priority) overrides the category constraint with seasonal categories and adjusts the price ceiling.</li><li>The “vegan cohort” policy applies a soft boost to vegan-certified products.</li><li>The margin and popularity boosts are applied with the governed weights (margin at 3.0× from the “chocolate” policy, popularity at the default 1.0×).</li><li>The shopper&#x27;s purchase history boosts are applied as the outermost scoring layer.</li></ol><p>Every layer stacks multiplicatively. The economic optimization weights are governed by the same policy framework that controls category constraints, campaign overrides, and cohort-specific boosts. A merchandiser can tune all of these through the admin UI, all without code changes.</p><p>This example also illustrates where economic optimization sits in the scoring stack. The layers nest in a deliberate order: the base query (keyword or semantic match), then governance constraints (hard filters and soft boosts from <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">Part 4</a>), then business-signal boosts (margin and popularity with governed weights), and then purchase history personalization (<a href="https://www.elastic.co/search-labs/blog/elasticsearch-personalized-search-governed-ecommerce">Part 6</a>). Each layer wraps the previous one, and the effects compound multiplicatively. Governance controls what appears. Economic optimization influences what ranks highest from the retailer&#x27;s perspective. Personalization adjusts ranking further from the shopper&#x27;s perspective.</p><h2>Tuning guidance</h2><p>The factor values in the baseline <code>function_score</code> are calibrated for the demo dataset&#x27;s field ranges. A production deployment with substantially different ranges for margin or popularity should recalibrate the factors so that each signal contributes a consistent maximum boost. The logarithmic scaling provides built-in resilience to outliers and distribution shifts, but the factors are worth reviewing whenever the underlying data changes significantly. For the calibration methodology, see <a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch">Boosting e-commerce search by profit and popularity with the function score query in Elasticsearch</a>.</p><h2>From economic optimization to agentic AI</h2><p>The governed control plane now handles intent classification, constraint enforcement, conflict resolution, personalization, and economic optimization, all expressed as policy data, all managed through a business-editable admin UI, and all composable through a deterministic transformation framework.</p><p>The final post in this series asks what happens when the input to this system isn’t a search string typed by a human shopper, but an intent string extracted by an AI agent, and why the deterministic properties of the governed control plane become even more critical when the upstream decision-maker is probabilistic.</p><h2>Put governed ecommerce search into practice</h2><p>The per-query economic optimization described in this post (policy-governed margin and popularity weights composing with governance constraints, personalization, and campaign overrides) was designed and built by Elastic Services Engineering as part of our repeatable ecommerce search accelerators. Contact <a href="https://www.elastic.co/consulting">Elastic Professional Services</a>.</p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch Vector DiskBBQ filter search is now 3–5x faster]]></title>
            <description><![CDATA[Learn how Elasticsearch 9.4 makes restrictive filtered DiskBBQ vector search 3–5x faster and more stable by avoiding wasted centroid and postings-list work when selectivity is high.]]></description>
            <link>https://www.elastic.co/search-labs/blog/faster-restrictive-filters-diskbbq</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/faster-restrictive-filters-diskbbq</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
            <pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ca1fc70bae03d3a9e9a3ecdacfa83e040f0441ee-1999x1546.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch 9.4 makes restrictive <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a> filtered vector search 3–5x faster. DiskBBQ is Elasticsearch’s new partition based index. It strives to provide the best trade-off for cost and performance by making the vector index as sympathetic as possible to the underlying system. While DiskBBQ does well with broad filters, it struggled with restrictive filters. Continuing our journey for a simple, fast, and efficient vector index, we adjust how we apply filtering and significantly improve latency.</p><h2>What’s hard about filtering partition indices</h2><p>With partition indices, all searches are done in two phases:</p><ul><li>Find the nearest centroids.</li><li>Find all the nearest vectors within the nearest centroids’ clusters.</li></ul><p>For DiskBBQ, the centroids are quantized and possibly indexed in their own structure. The cluster contents (we’ll call them <em>postings lists</em> from now on) are laid out in an effort to make scoring the vectors fast. Postings are stored in blocks of 32, with each block in doc_id order. Doc IDs are delta-encoded to minimize disk usage with low decode overhead. Vector values are also block-encoded, separating dimensions from quantized corrections to maximize <a href="https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine">single instruction, multiple data (SIMD) throughput using our optimized kernels</a>.</p><pre><code>Vector Cluster (Posting List) layout
  | metadata |
  | doc_deltas[32] | vec_quant[32] | vec_quant_corrections[32] |
  | doc_deltas[32] | vec_quant[32] | vec_quant_corrections[32] |
  | ... |
  | doc_deltas[T]  | vec_quant[T]  | vec_quant_corrections[T]  |   (T <= 32)</code></pre><p>Once we reach a postings list, the layout is optimized for fast scoring and filtering. For example, if an index sort is provided, blocks of vectors that match a filter will be stored and scored together within a list. This unlocks scoring contiguous blocks of vectors at a time, taking full advantage of the underlying CPU throughput.</p><p>That said, we don’t know if a cluster matches a given filter until we actually check its doc_ids. Once verified, we can be sure to only score against the relevant vectors. In restrictive-filter cases, we can inspect a centroid and still find that none of its vectors match the filter. To compensate, we keep scoring and exploring centroids until we get a representative group of vectors scored.</p><p>This meant wasted work for restrictive filters. We score centroids, not knowing if they have vectors relevant to the filter or not; prepare to score the postings list, only to find that none of the blocks apply. The wasted compute adds up:</p><ul><li>Unnecessarily scoring the centroid.</li><li>Loading a filtered-out postings list because it’s close to the query vector.</li><li>Decoding and checking the document IDs in the list, only to find out none match.</li><li>Continue the search, potentially hitting another completely filtered-out centroid, until we visit enough to get the desired recall.</li></ul><p>Here’s an example showing the old flow. Check all the centroids, see what matches, move on to centroids that have matching vectors. Rinse and repeat.</p><a href="https://videos.elastic.co/watch/AmsfHM4f8ZczNH9tAHkjE3">Watch video on Vidyard</a><h2>How do we get to the right centroids quickly?</h2><p>The simplest solution would be to skip centroids that contain no valid vectors. But we don’t want to index additional information for all potential filter fields and values. A user can provide many variations of complex or simple filters. This is a strength of Elasticsearch, and we don’t want to hamper that.</p><p>Instead, we simply store the mapping of <code>doc_id -&gt; centroid_ord</code>. This gives us an immediate view of all docs and their centroid membership. Allowing us to iterate any provided filter in document order, quickly determining the relevant centroids. Of course, iterating every document to check if it passes a filter is not free. We only apply this eager logic if the average number of documents per cluster that match the filter is <code>1.25</code>. Yes, this is a “magic number”; however, it&#x27;s empirically based. Assuming the filter is random, we’re validating at least one matching vector per centroid with some overhead. We may refine this in the future, but early experimentation found this number to be a sweet spot for most users.</p><a href="https://videos.elastic.co/watch/3jhP9PJ6uq6RQ9an8XeLoe">Watch video on Vidyard</a><p>Here’s the new way. Detecting we have a restrictive filter, go straight to the filtered centroids.</p><h2>Benchmark, benchmark, benchmark</h2><p>Here’s a macro-benchmark with a random filter. The filter selectivity is purposefully extreme to show the significant improvement on hyper-restrictive filters. Here we see almost an order-of-magnitude improvement. Where before, when filters got very restrictive, there would be a horrible elbow. Now latency remains consistent and will in fact improve as filters get more restrictive.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fdf97ce48c1ed108f67c0ac3b2773bdefd9b09c6-1980x1170.png" style="width: 100%;" /><p>A further validation is our nightly runs of <a href="https://github.com/elastic/rally-tracks/tree/master/so_vector">so-vector</a> with <a href="https://github.com/elastic/rally">rally</a> showing the improvement. You can try this yourself by specifying <code>bbq_disk</code> in the <code>vector_index_type</code> in the rally configuration.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f811ef3d7029a8ef8f26cd0b4cfdb9bf2f38f287-1530x1304.png" style="width: 100%;" /><h2>What&#x27;s next?</h2><p>This is in Elastic Serverless now and will be in stack release 9.4.0. We aren’t done improving vector search in Elasticsearch. This is just another step in our journey to bring you simple, efficient, practical, and fast vector search. Thank you so much for using the code that we write. We ❤️ you.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch query logs: One coordinator-level line per query for ES|QL, DSL, SQL, and EQL]]></title>
            <description><![CDATA[Easily understand query impact on cluster performance with Elasticsearch query logs. One coordinator-level line records ES|QL, DSL, SQL, and EQL per request and provides full query text, tracing, optional user context, and CCS hints]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-query-logs</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-query-logs</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Najwa Harif, Valentin Crettaz]]></dc:creator>
            <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/31f1d882d6c0b62bd5ba320c89bda5700434c25c-1672x941.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Your dashboard times out and CPU spikes, but which query actually ran? Slow logs give you one line per shard; Elasticsearch query logs give you one JSON line per request, with the same end-to-end duration as the took you already trust from the API. That single line also captures full query text for ES|QL, DSL, SQL, and EQL, outcomes, tracing, optional user context, and cross-cluster hints when relevant.</p><p>They’re ECS-aligned, ready for Discover and out-of-the-box dashboards once you ship the log, no custom schema project. Below: why we built this, how it differs from slow logs, what each line contains, and how to turn it on.</p><h2>Why we built this (you asked, a lot!)</h2><p>Coordinator-level query logging has been a very popular request; we listened and delivered! The same pain kept showing up: You want the <em>response</em> duration for Service Level Objectives (SLOs) and dashboards. You want to know the execution time of queries executed in your cluster, and you want to be able to see the full query.</p><p>If using cross-cluster search, a search that fans out across clusters looks like one operation from the app or Kibana, but operationally it’s a chain of work: coordination, remote execution, merges, timeouts, and partial results. When something is slow or flaky, teams need to know not only how long the request took but also which clusters contributed and whether the outcome was success, partial, or a hard failure.</p><p><strong>What you get:</strong> One log stream, one entry per query! Every entry has the coordinator duration (the very same <code>took</code> time that actually matches your search API response), success or failure, and the full query text. Elastic Common Schema–compliant (ECS) JSON, optional duration threshold and user/audit fields, plus <code>X-Opaque-Id</code> that lets you <a href="https://www.elastic.co/docs/troubleshoot/kibana/trace-elasticsearch-query-to-the-origin-in-kibana">trace a hot query</a> back to the saved object it originates from, and the trace ID so you can correlate with Kibana or your own tooling.</p><p><strong>What’s more:</strong> Logs follow a stable, ECS-aligned schema, which means you don’t need to design your own ingestion pipelines or field mappings. This consistency enables out-of-the-box dashboards and analytics that work immediately once logs are shipped.</p><h2>Slow logs vs. query logs: The 30-second version</h2><p>Slow logs have been the go-to tool for years. They tell you which search operation is slow, but they emit <strong>one line per shard</strong> that took part, where each line reflects that shard’s piece of the work. This means that they don’t provide a single row that says how long the query execution took, from the client’s perspective. Query logs do exactly that: <strong>one line per query</strong>, with the <strong>end-to-end (wall clock)</strong> duration that lines up with the <code>took</code> time in the search API response. This makes them much better suited for understanding workload patterns and identifying problematic queries quickly.</p><p>Slow and query logs also differ in when they fire and what they cover. Slow logs only write when a shard’s slice breaches a duration threshold; that is, you’re optimized for “show me unusually slow shard work.” Query logs can record every query (or only those above a configurable threshold you set at the cluster level), so you can tune volume for analytics versus troubleshooting. Slow logs only support DSL queries, while query logs cover <strong>ES|QL, DSL, SQL, and EQL</strong>, which matches how you reason about “what ran on my cluster” in a modern stack. Both provide the same support in terms of correlation with headers, traces, and audit information (when you turn on user context).</p><p>The table below summarizes the main differences between the historical slow logs and the new query logs features.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Slow logs</td><td style="border: 1px solid #ccc; padding: 4px;">Query logs</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">What they’re for</td><td style="border: 1px solid #ccc; padding: 4px;">Finding hot shards / slow index operations on specific indices and classic performance tuning inside one cluster.</td><td style="border: 1px solid #ccc; padding: 4px;">Understanding what query ran, how long the operation took end to end from the coordinator, and whether it succeeded, which is better for SLOs, analytics, and incident investigations.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Granularity</td><td style="border: 1px solid #ccc; padding: 4px;">Per shard (and per phase) for searching slow logs: One user search can produce many lines across shards/replicas.</td><td style="border: 1px solid #ccc; padding: 4px;">Per coordinator-level query: One query maps to one log event.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Scope of work</td><td style="border: 1px solid #ccc; padding: 4px;">Query + indexing</td><td style="border: 1px solid #ccc; padding: 4px;">Query only; indexing will come soon.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">What you learn</td><td style="border: 1px solid #ccc; padding: 4px;">“This shard on this index exceeded N ms in query/fetch phase.”</td><td style="border: 1px solid #ccc; padding: 4px;">“This query (full text), this duration, this outcome, and (when relevant) federation/cross-cluster summary fields.”</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query types</td><td style="border: 1px solid #ccc; padding: 4px;">DSL only</td><td style="border: 1px solid #ccc; padding: 4px;">ES|QL, DSL, SQL, and EQL</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Threshold model</td><td style="border: 1px solid #ccc; padding: 4px;">Often tiered (for example, multiple time thresholds per log levels) and per index.</td><td style="border: 1px solid #ccc; padding: 4px;">Single duration gate at the cluster level (for example, “log if duration ≥ 500ms”)</td>
                    </tr>
              </tbody>
            </table>
          <h2>What you get in each log line</h2><p>Every line is one JSON object (one request) in a dedicated file (for example, <code>*_querylog.json</code> under your Elasticsearch log directory). Below is what you can <em>do</em> with the data:</p><p><strong>Did it succeed, how long did it take, and what broke?</strong> Outcome (whether the request was successful or not), duration (<code>took / took_millis</code>, in line with the API), and a clear failure or timeout when something goes wrong. That’s the core signal for alerting, SLOs, and dashboards: “Are we green? If not, what’s the error?” You also get how many rows or hits came back (<code>result_count</code>), so you can separate “slow but empty” from “slow and huge.”</p><p><strong>What actually ran?</strong> Query type (<code>esql</code>, <code>dsl</code>, <code>sql</code>, <code>eql</code>) plus the <strong>full query text</strong>. That answers “Which dashboard rule, saved search, or client pattern is hammering us?” Mix it with duration and outcome to find the worst offenders to fix or throttle.</p><p><strong>Who asked for it</strong>, and how do I trace it end to end? <strong>X-Opaque-Id</strong> and <strong>trace ID</strong> tie a line back to Kibana or your own headers. Task and optional parent task IDs help follow work that was enqueued or chained (async or nested operations).</p><p><strong>Cross-cluster search: </strong>Who participated, and did anyone misbehave? When cross-cluster search (CCS) is in play, the log can carry <strong>remote cluster aliases</strong>, per-cluster duration, and status (successful, failed, partial, skipped). You can see at a glance whether a slow search was local or a specific remote dragging the response. DSL can also record that a search was served from a remote alias; ES|QL exposes the richer cluster map; EQL logs a lighter view (for example, which remotes and how many) when remotes are involved.</p><p><strong>Security (optional).</strong> With <code>elasticsearch.querylog.include.user</code>, you get the usual identity and realm fields (plus effective user when run-as applies), and API key metadata when applicable. Pair with query text and duration for governance and capacity conversations that use names, not only IPs.</p><p>There’s more available than we covered here, including additional execution details, shard-level outcomes, and optional profiling information depending on the query type. For every field path and setting, see the <a href="https://www.elastic.co/docs/deploy-manage/monitor/logging-configuration/query-logs">Elasticsearch documentation on query logs</a>.</p><h2>Where the logs live (and how to use them)</h2><p>Logs land in your Elasticsearch log directory as <code>*_querylog.json</code> (for example, <code>mycluster_querylog.json</code>) on the coordinating node. Ship them with the <code>querylog</code> fileset in the <a href="https://www.elastic.co/docs/reference/beats/filebeat/filebeat-module-elasticsearch#_querylog_log_fileset_settings">Filebeat Elasticsearch module</a>, so you can then inspect them in Discover (filter by <code>event.dataset: elasticsearch.querylog</code>). On Elastic Cloud, you need to enable Logs on your deployment, and the query logs are shipped as soon as you enable them.</p><p><strong>Two workflows.</strong> If you need a one-off look to find out who’s hammering the cluster, what the query mix is, or a quick audit, just turn logging on, set a duration threshold so you only log what matters (for example, ≥ 1 s or ≥ 5 min), and then turn it off when you’re done. If you want <strong>ongoing query analytics</strong>, simply enable logging, point Filebeat at the log, and open a dashboard on the monitoring cluster. Two very simple steps, enable + ship, and you’re done. One request per line, one duration per request, no custom pipeline.</p><p>The dashboard below builds upon the new query logs and is provided out of the box. On the top row, you can find the P95/P99 query latencies (with an optional “acceptable latency” bar), the query type breakdown, the success and failure ratio, the user and system queries ratio, and (for DSL) hits versus aggregations. Underneath that, the latency over time (avg, p50, p95, p99, max) with a reference line so you can spot regressions, query volume over time (stacked by type), and tables for top indices, top users, and top error types. Filtering for cluster, user, or index lets you zoom into exactly what you want to focus on.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f913f860b7a7235fea9d4eeff935bd2e2aa61c0f-1999x1406.png" style="width: 100%;" /><p><strong>Heads up.</strong> Logging of queries is asynchronous, so it doesn’t block the query execution. Use the duration threshold to cap volume. Also worth noting that at very high queries per second (QPS), we may drop some lines rather than slow your cluster down. For analytics, shipping to a separate monitoring cluster keeps the cluster you’re debugging from taking the extra load.</p><h2>Some configuration and code samples</h2><p>Query logging is <strong>off by default</strong>. Flip it on in <code>elasticsearch.yml</code> or via the cluster settings API. Here’s how.</p><h3>Enable query logging</h3><p>In <code>elasticsearch.yml</code>:</p><pre><code>elasticsearch.querylog.enabled: true</code></pre><p>Or dynamically via the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-update-settings.html">cluster settings API</a>:</p><pre><code>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true"
  }
}</code></pre><h3>Only log queries above a duration threshold</h3><p>If you don’t want to log every health check or tiny request, simply set a threshold so only queries that run at least this long get an entry. Duration is in <strong>time units</strong>:</p><pre><code>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.threshold": "1s"
  }
}</code></pre><h3>Include user/audit information</h3><p>If you use the Security plugin and want to see <em>who</em> ran each query:</p><pre><code>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.include.user": "true"
  }
}</code></pre><h3>Log DSL searches that hit only system indices</h3><p>By default, searches that target <em>only</em> system indices aren’t logged. To include them, enable query logging and set:</p><pre><code>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.include.system_indices": "true"
  }
}</code></pre><h3>Example log entries</h3><p>One line = one JSON object = one request with the same shape for ES|QL, DSL, SQL, EQL. Below: a successful DSL search and a<strong> </strong>failed EQL query with timestamp, duration, query type, and full query. On success, you get result count and shard stats, on failure an <code>error</code> block. User-inclusion and X-Opaque-Id show up when you’ve enabled them.</p><p><strong>Success (DSL search):</strong></p><pre><code>{
  "@timestamp": "2026-03-04T19:40:34.736Z",
  "log": {
    "level": "INFO",
    "logger": "elasticsearch.querylog"
  },
  "event": {
    "duration": 1000000,
    "outcome": "success"
  },
  "elasticsearch": {
    "querylog": {
      "type": "dsl",
      "query": "{\"size\":10,\"query\":{\"match_all\":{\"boost\":1.0}}}",
      "indices": ["query_log_test_index"],
      "result_count": 3,
      "search": { "total_count": 3 },
      "shards": { "successful": 1 },
      "took": 1000000,
      "took_millis": 1
    },
    "node": { "name": "node-1" },
    "cluster": { "name": "my-es-cluster" }
  },
  "http": {
    "request": {
      "headers": { "x_opaque_id": "opaque-1772653234" }
    }
  },
  "user": {
    "name": "elastic",
    "realm": "reserved"
  }
}</code></pre><p><strong>Failure (EQL query):</strong></p><pre><code>{
  "@timestamp": "2026-03-04T19:40:35.271Z",
  "log": {
    "level": "INFO",
    "logger": "elasticsearch.querylog"
  },
  "event": {
    "duration": 1326334,
    "outcome": "failure"
  },
  "elasticsearch": {
    "querylog": {
      "type": "eql",
      "query": "any where true",
      "indices": ["nonexistent_index_xyz"],
      "result_count": 0,
      "took_millis": 1
    },
    "node": { "name": "node-1" },
    "cluster": { "name": "my-es-cluster" }
  },
  "error": {
    "type": "org.elasticsearch.index.IndexNotFoundException",
    "message": "no such index [Unknown index [nonexistent_index_xyz]]"
  }
}</code></pre><h2>Wrapping up</h2><p><strong>Elasticsearch query logs</strong> provide you with one single coordinator-level log for every query (ES|QL, DSL, SQL, EQL). One line per request, coordinator duration, full query, optional user and <code>X-Opaque-Id</code>. Enable it, set a duration threshold and user-inclusion if you want them, and you’re done. Logs live in your log dir (<code>*_querylog.json</code>), and when shipped with Filebeat, you can find them in Discover under the <code>elasticsearch.querylog</code> dataset.</p><p>Head to the <a href="https://www.elastic.co/docs/deploy-manage/monitor/logging-configuration/query-logs">Elasticsearch documentation on query logs</a> for the full list of configuration settings, and field references. Slow or broken queries can also be found in <a href="https://www.elastic.co/search-labs/blog/slow-search-elasticsearch-query-autoops">AutoOps</a>, which leverages the <code>X-Opaque-Id</code> to tie a long-running search back to its origin, such as a dashboard, a saved search, or an alerting rule.</p><p>Finally, it’s also worth noting that this new query log is an evolution of the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-query-log">ES|QL-only query log</a> that we released in 9.2. We recommend adopting the new query log since it not only supports ES|QL queries, but also all your other queries.</p><p>Now, go see what’s actually running in your cluster.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch ES|QL “schema on read”: Your unmapped fields were there all along]]></title>
            <description><![CDATA[ES|QL's new unmapped fields feature makes any never-mapped field queryable against historical data (no reindexing needed).]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-unmapped-fields</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-unmapped-fields</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tyler Perkins]]></dc:creator>
            <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/65ad422fc0c6d23b71025f42e8abeb19b461a386-1999x1093.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/elasticsearch">Elasticsearch</a> Query Language (<a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a>) now has schema-on-read. Add one line: <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/set"><code>SET unmapped_fields=&quot;load&quot;</code></a>, and every unmapped field in <code>_source</code> becomes queryable. No reindex. No pipeline change. Works retroactively against data you ingested months ago.</p><p>Add <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions/json_extract"><code>JSON_EXTRACT</code></a> for surgical extraction from raw JSON strings: flattened fields, embedded payloads, OTel resource attributes (60+ semantic conventions that standard mappings don&#x27;t index individually). Together, these turn schema from a gate into a spectrum: indexed fields for speed, <code>_source</code> fallback for everything else. See also our companion posts on <a href="https://www.elastic.co/search-labs/blog/elasticsearch-esql-logical-views">ES|QL views</a> and <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from">ES|QL subqueries</a>.</p><h2>The mapping cliff edge</h2><p>Elasticsearch mappings define how fields are indexed. When a mapping is complete, queries are fast; they hit inverted indices, doc values, and all the performance structures Elasticsearch builds at index time. But when a field was never mapped (missed during onboarding, added by a new integration, or simply not anticipated), it&#x27;s invisible. Queries that reference it fail. The traditional fix: Update the mapping, reindex the data. For a multi-terabyte index, that means hours of reprocessing and doubled storage during the reindex window.</p><p>ES|QL schema-on-read changes the contract. The mapping is no longer a cliff edge where &quot;mapped&quot; means queryable and &quot;unmapped&quot; means invisible. Instead, it&#x27;s a spectrum:</p><ul><li><strong>Mapped fields</strong> → fast path. Queries hit indexed structures. This is still the preferred mode for production workloads.</li><li><strong>Unmapped fields with </strong><code>SET unmapped_fields=&quot;load&quot;</code> → queryable from <code>_source</code> at query time. Slower than the indexed path (no inverted index to accelerate filters), but the data is accessible in seconds instead of not at all: immediately, retroactively, against historical indices.</li></ul><p>You can discover and query first and then decide later whether a field is worth mapping for performance.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/214273bd807e00dfacbe6dffbe0f56dae001d2d1-1999x1093.png" style="width: 100%;" /><h2>Unmapped field access: The strategic abstraction</h2><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/set"><code>SET</code> directives</a> are query-level settings that appear at the top of an ES|QL query, before the <code>FROM</code> clause. They configure how the query engine behaves for that specific query without affecting anything else. The <code>unmapped_fields</code> directive controls what happens when a query references a field that doesn&#x27;t exist in the mapping.</p><p>Consider an OTel logs index where <code>resource.cost_center</code> was never mapped. Without <code>SET unmapped_fields</code>, referencing it produces an error:</p><pre><code>-- This fails: "Unknown column [resource.cost_center]"
FROM otel-logs-*
| STATS errors = COUNT(*) BY service.name, resource.cost_center</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/720f96811824e04b55aea6a68284ad09988d1e3a-928x620.png" style="width: 100%;" /><p>Add one line and the query works:</p><pre><code>SET unmapped_fields="load";
FROM otel-logs-*
| WHERE log.level IN ("error", "warn")
| STATS errors = COUNT(*), latest = MAX(@timestamp)
    BY service.name, resource.cost_center
| SORT errors DESC</code></pre><p>The field is loaded from <code>_source</code> at query time. No mapping change. No reindex. Works against data that was ingested weeks or months ago.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cd90195049306fb4d7602db0dcd2a73884880bb5-1322x316.png" style="width: 100%;" />
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Mode</td><td style="border: 1px solid #ccc; padding: 4px;">Behavior</td><td style="border: 1px solid #ccc; padding: 4px;">Use when</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`default`</td><td style="border: 1px solid #ccc; padding: 4px;">Unmapped fields are invisible; queries that reference them error (pre-9.4 behavior).</td><td style="border: 1px solid #ccc; padding: 4px;">You want strict schema enforcement.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`nullify`</td><td style="border: 1px solid #ccc; padding: 4px;">Unmapped fields appear as columns with `null` values.</td><td style="border: 1px solid #ccc; padding: 4px;">You want the column shape without loading data, or you need compatibility with subqueries and views.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`load`</td><td style="border: 1px solid #ccc; padding: 4px;">Unmapped fields are loaded from `_source` at query time.</td><td style="border: 1px solid #ccc; padding: 4px;">You need to filter, aggregate, or inspect the actual values.</td>
                    </tr>
              </tbody>
            </table>
          <p><code>nullify</code> is useful when you want queries to succeed even if some indices in a wildcard pattern don&#x27;t have a particular field mapped. <code>load</code> is the mode you reach for when you actually need the data.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2ad24d479c42b2f1b0354d24b01887f933b4828c-1218x364.png" style="width: 100%;" /><h3>Partially mapped fields</h3><p>The power of <code>unmapped_fields</code> really shows with wildcard index patterns where a field exists in some indices but not others. Suppose <code>resource.team</code> was mapped in recent indices but not in older ones:</p><pre><code>SET unmapped_fields="load";
FROM otel-logs-*
| WHERE log.level IN ("error", "warn")
| STATS errors = COUNT(*), latest = MAX(@timestamp)
    BY service.name, resource.team
| SORT errors DESC</code></pre><p>For indices where <code>resource.team</code> is mapped, values come from the fast indexed path. For indices where it isn&#x27;t, values are loaded from <code>_source</code>. The query returns a unified result across the entire time range; no reindexing of historical data required.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bffb5fc4bebce799aa60fcf633ee3cd2c00e7f2c-1999x1093.png" style="width: 100%;" /><h2>JSON_EXTRACT: The lower-level tool</h2><p><code>SET unmapped_fields</code> is the right answer for fields that exist in <code>_source</code> under predictable names. But some data requires more surgical extraction, reaching into a JSON string stored in a text field or navigating nested objects inside a flattened field type where dot notation can&#x27;t reach subkeys.</p><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions/json_extract"><code>JSON_EXTRACT</code></a> is the lower-level escape hatch. It takes a source field and a JSON path expression and returns the value at that path:</p><h3>Extracting from string fields</h3><p>Payment services often store structured error details as a JSON string in a response body field. <code>JSON_EXTRACT</code> reaches into that string at query time:</p><pre><code>FROM svc-payments-*
| WHERE transaction.status IN ("failed", "timeout")
| EVAL error_code = JSON_EXTRACT(response_body, "$.error_code"),
       reason     = JSON_EXTRACT(response_body, "$.reason"),
       retryable  = JSON_EXTRACT(response_body, "$.retry")
| STATS failure_count = COUNT(*) BY error_code, reason, retryable
| SORT failure_count DESC</code></pre><p>No ingest pipeline needed to extract <code>error_code</code> from the response body. The schema can evolve without reindexing.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5407b8562eb3e135d2af089b761e8386431130f3-1999x502.png" style="width: 100%;" /><h3>Extracting from _source: Flattened fields and OTel data</h3><p>For fields mapped as <code>flattened</code>, such as <code>resource.attributes</code> in many OTel indices, which can contain 20–30 nested keys per document, the entire JSON object is stored as an opaque token. Elasticsearch indexes the leaf values as flat keywords for filtering, but doesn&#x27;t decompose them into separate mapped fields, so dot notation has nothing to resolve against. <code>JSON_EXTRACT</code> on <code>_source</code> follows the actual nesting in the stored document:</p><pre><code>FROM svc-auth-* METADATA _source
| EVAL svc_version = JSON_EXTRACT(_source, "$.resource.attributes['service.version']"),
       env         = JSON_EXTRACT(_source, "$.resource.attributes['deployment.environment']"),
       host        = JSON_EXTRACT(_source, "$.resource.attributes['host.name']")
| STATS login_failures = COUNT(*) BY svc_version, env, host
| SORT login_failures DESC</code></pre><p>Use dot notation for the nested structure and then bracket notation for leaf keys that contain dots (like <code>service.version</code> as a single key name). This is a common pattern for OpenTelemetry data.</p><h2>What to use when</h2>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Scenario</td><td style="border: 1px solid #ccc; padding: 4px;">Tool</td><td style="border: 1px solid #ccc; padding: 4px;">Why</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Field was never mapped</td><td style="border: 1px solid #ccc; padding: 4px;">`SET unmapped_fields="load"`</td><td style="border: 1px solid #ccc; padding: 4px;">The strategic solution for schema evolution. One-line directive, works retroactively.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Need to extract a key from a raw JSON string</td><td style="border: 1px solid #ccc; padding: 4px;">`JSON_EXTRACT`</td><td style="border: 1px solid #ccc; padding: 4px;">Surgical extraction from string fields. Path notation gives precise control.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Field is inside a flattened type</td><td style="border: 1px solid #ccc; padding: 4px;">`JSON_EXTRACT` on `_source`</td><td style="border: 1px solid #ccc; padding: 4px;">Bridge solution. Native flattened field support in ES|QL is planned.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query needs to work with subqueries or views</td><td style="border: 1px solid #ccc; padding: 4px;">`SET unmapped_fields="nullify"`</td><td style="border: 1px solid #ccc; padding: 4px;">`load` mode is incompatible with subqueries and views in the current release. `nullify` works everywhere.</td>
                    </tr>
              </tbody>
            </table>
          <p><code>SET unmapped_fields</code> is the abstraction most users should reach for first. <code>JSON_EXTRACT</code> is the tool for cases that need direct JSON manipulation or for data patterns like flattened fields that aren&#x27;t natively handled yet.</p><h2>How this compares</h2><p>&quot;Schema on read&quot; is Splunk&#x27;s founding narrative; the idea that you index everything as raw text and decide the schema at search time. ES|QL takes a fundamentally different position: You get both. Here&#x27;s how that plays out in practice.</p><p><strong>Splunk SPL</strong> extracts fields at search time using <a href="https://help.splunk.com/en/splunk-enterprise/search/search-manual/10.2/evaluate-and-manipulate-fields/extract-fields-with-search-commands">search commands</a> like <a href="https://docs.splunk.com/Documentation/Splunk/latest/SearchReference/Spath"><code>spath</code></a>, <a href="https://docs.splunk.com/Documentation/Splunk/latest/SearchReference/Rex"><code>rex</code></a>, search-time field extractions, and calculated fields. The benefit is flexibility; you never need to declare a schema up front. The cost: field-value searches scan raw event data. Splunk&#x27;s TSIDX files index metadata (host, source, sourcetype) and index-time fields, but user-defined search-time fields hit raw events on every query. A query that touches a billion events scans a billion events. Splunk compensates with <a href="https://docs.splunk.com/Documentation/Splunk/latest/Knowledge/Aboutsummaryindexing">summary indexing</a> and <a href="https://docs.splunk.com/Documentation/Splunk/latest/Knowledge/Aboutdatamodels">data models</a>, but those are manual, preconfigured accelerations that trade flexibility back for performance; the same trade-off Elasticsearch mappings make, just with extra steps. ES|QL&#x27;s <code>SET unmapped_fields</code> gives you the same &quot;query fields you never declared&quot; flexibility, but mapped fields still hit Elasticsearch&#x27;s indexed structures at full speed. You pay the <code>_source</code> scan cost only for the specific fields that aren&#x27;t mapped, not for every field in the query.</p><p><strong>Elasticsearch Query DSL</strong> has <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime.html">runtime fields</a> and <strong>OpenSearch</strong> has <a href="https://opensearch.org/docs/latest/field-types/supported-field-types/derived/">derived fields</a> — both extract from <code>_source</code> at query time without reindexing. But both require per-field configuration: Runtime fields need a Painless script and type declaration per field, either at the index mapping level or in each query; derived fields require index-level or cluster-level configuration. You have to know the field name and define the extraction logic before you can query it. <code>SET unmapped_fields=&quot;load&quot;</code> is a per-query directive that skips all of that; one line, and every unmapped field in the index becomes queryable. No per-field definitions, no index settings changes, no scripts.</p><p><strong>ClickHouse</strong> requires a strict schema for standard columns; adding one means <code>ALTER TABLE</code>. However, ClickHouse&#x27;s <code>JSON</code> type (GA in 25.3) automatically creates typed dynamic subcolumns for every path encountered at insert time, with no per-field declaration needed. The limitation is retroactive access; data already ingested into a <code>String</code> column requires <code>JSONExtract*</code> functions for field access, similar to ES|QL&#x27;s <code>JSON_EXTRACT</code>, and cannot be retroactively migrated to a <code>JSON</code> column without a data pipeline change. There&#x27;s no equivalent to <code>SET unmapped_fields</code> that makes arbitrary historical fields queryable without touching the schema or re-ingesting.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Capability</td><td style="border: 1px solid #ccc; padding: 4px;">Splunk SPL</td><td style="border: 1px solid #ccc; padding: 4px;">ES|QL</td><td style="border: 1px solid #ccc; padding: 4px;">OpenSearch / Query DSL</td><td style="border: 1px solid #ccc; padding: 4px;">ClickHouse</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Schema model</td><td style="border: 1px solid #ccc; padding: 4px;">Schema-on-read (extract at search time)</td><td style="border: 1px solid #ccc; padding: 4px;">Both (indexed fast path + `_source` fallback)</td><td style="border: 1px solid #ccc; padding: 4px;">Schema-on-write + per-field query-time extraction</td><td style="border: 1px solid #ccc; padding: 4px;">Schema-on-write; JSON type auto-creates subcolumns</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Unmapped field access</td><td style="border: 1px solid #ccc; padding: 4px;">Always available (all fields extracted at search time)</td><td style="border: 1px solid #ccc; padding: 4px;">`SET unmapped_fields="load"`; per-query, zero config</td><td style="border: 1px solid #ccc; padding: 4px;">Runtime fields / derived fields; per-field config required</td><td style="border: 1px solid #ccc; padding: 4px;">JSON type: automatic. String columns: `JSONExtract*` only</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">JSON extraction</td><td style="border: 1px solid #ccc; padding: 4px;">`spath`</td><td style="border: 1px solid #ccc; padding: 4px;">`JSON_EXTRACT` with JSONPath subset</td><td style="border: 1px solid #ccc; padding: 4px;">Painless scripting</td><td style="border: 1px solid #ccc; padding: 4px;">`JSONExtract*` functions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Performance on mapped fields</td><td style="border: 1px solid #ccc; padding: 4px;">Full scan (no indexed structures)</td><td style="border: 1px solid #ccc; padding: 4px;">Inverted index + doc values + columnar</td><td style="border: 1px solid #ccc; padding: 4px;">Inverted index + doc values</td><td style="border: 1px solid #ccc; padding: 4px;">Columnar + primary index</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Retroactive access to old data</td><td style="border: 1px solid #ccc; padding: 4px;">Yes (always raw)</td><td style="border: 1px solid #ccc; padding: 4px;">Yes (via `_source`)</td><td style="border: 1px solid #ccc; padding: 4px;">Yes (via runtime / derived fields, per-field setup)</td><td style="border: 1px solid #ccc; padding: 4px;">No; string columns can't be migrated to JSON retroactively</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Cost of flexibility</td><td style="border: 1px solid #ccc; padding: 4px;">Every query pays the scan cost</td><td style="border: 1px solid #ccc; padding: 4px;">Only unmapped fields pay the scan cost</td><td style="border: 1px solid #ccc; padding: 4px;">Per-field Painless script or index-level config</td><td style="border: 1px solid #ccc; padding: 4px;">Must choose JSON type at table creation time</td>
                    </tr>
              </tbody>
            </table>
          <p>The key difference: Splunk makes you choose between flexibility and performance at the platform level. ES|QL lets you choose per field, per query. Mapped fields are fast. Unmapped fields are accessible. You don&#x27;t have to pick one model for your entire dataset.</p><h2>Current constraints</h2><p><code>SET unmapped_fields=&quot;load&quot;</code> is incompatible with subqueries and views in the current release; use <code>nullify</code> mode instead when composing queries. See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/set">SET documentation</a> for details.</p><h2>What&#x27;s next</h2><p>Schema-on-read is a strategy, not just two features. The direction is to make more data queryable at query time without requiring a perfect schema at ingest time:</p><ul><li><strong>Native flattened field support</strong> is next; dot notation directly into flattened fields without the <code>JSON_EXTRACT</code> on <code>_source</code> workaround. This eliminates the most common reason users need <code>JSON_EXTRACT</code> today and is planned.</li><li><strong>Lifting the <code>load</code> mode restriction</strong> for subqueries and views will let you combine schema-on-read with the composition primitives from our views and subqueries posts.</li></ul><p>The long-term goal: <code>SET unmapped_fields</code> becomes the primary way users handle schema evolution, with <code>JSON_EXTRACT</code> reserved for truly surgical JSON manipulation.</p><h2>Try it</h2><p>Unmapped field access and <code>JSON_EXTRACT</code> are available as Tech Preview features. Try them in <a href="https://www.elastic.co/kibana">Kibana</a> Dev Tools or Discover. We&#x27;d love your feedback; file a <a href="https://github.com/elastic/elasticsearch/issues">GitHub issue</a> with the <code>ES|QL</code> label.</p><p><em>ES|QL unmapped field access and JSON_EXTRACT are Tech Preview features. Tech Preview features are subject to change and are not covered by the support SLA of GA features. The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bringing Fire to Elasticsearch: Adding Native Prometheus API Support]]></title>
            <description><![CDATA[Query Elasticsearch directly from Prometheus-compatible clients via native PromQL, discovery, and metadata endpoints. Send data to Elasticsearch with Prometheus Remote Write.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Felix Barnsteiner]]></dc:creator>
            <pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c7b333bd73e8a1f4e18486b2d692ba742788dcfd-1376x768.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Point any Prometheus-compatible client at Elasticsearch and run PromQL directly against your existing metrics. Elasticsearch is adding native Prometheus query, discovery, and metadata endpoints as a tech preview that work over metrics ingested through Prometheus Remote Write, OpenTelemetry, or the Bulk API. The API runs on top of Elasticsearch&#x27;s time series data streams (TSDS), so there&#x27;s no separate Prometheus-specific storage layer to operate.</p><p>This post explains how the query, discovery, and metadata endpoints build on the earlier ingest and query work to form that API surface. Companion posts go deeper on individual pieces:</p><ul><li><a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Native PromQL support in ES|QL</a> covers how PromQL queries are translated into ES|QL execution plans.</li><li><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch">Ship Prometheus Metrics to Elasticsearch with Remote Write</a> covers ingestion setup.</li><li><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">How Prometheus Remote Write Ingestion Works in Elasticsearch</a> covers the remote write internals.</li></ul><p>This is still a work in progress. The sections below call out what is supported today and which parts are still evolving.</p><h2>The API surface</h2><p>Today, the Prometheus-compatible API surface falls into three groups.</p><h3>Query endpoints</h3><p>The query endpoints let Prometheus-compatible clients evaluate PromQL expressions:</p><ul><li><code>GET /_prometheus/api/v1/query_range</code> evaluates a PromQL expression over a time window (matrix results).</li><li><code>GET /_prometheus/api/v1/query</code> evaluates at a single point in time (vector results). Currently implemented as a short range query that returns the last sample.</li></ul><p>Only GET is supported for query endpoints today. Some clients default to POST, so you may need to configure them to use GET. The Prometheus POST convention uses <code>application/x-www-form-urlencoded</code> bodies, which Elasticsearch&#x27;s HTTP layer rejects as a CSRF safeguard before the request ever reaches the handler.</p><p>For the full PromQL coverage status, see the <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">companion post on PromQL in ES|QL</a>.</p><h3>Metadata endpoints</h3><p>The metadata endpoints serve the discovery information that clients need for autocomplete, variable dropdowns, and metric browsing.</p><p>The series, labels, and label values endpoints all accept <code>match[]</code> selectors and a time range (<code>start</code>/<code>end</code>). The <code>match[]</code> parameter takes a Prometheus series selector like <code>http_requests_total{job=&quot;api&quot;}</code> and restricts the response to time series that match. This keeps responses fast and relevant on clusters with large numbers of metrics. For example:</p><pre><code>GET /_prometheus/api/v1/series?match[]=http_requests_total{job="api"}</code></pre><pre><code>GET /_prometheus/api/v1/labels?match[]=http_requests_total</code></pre><pre><code>GET /_prometheus/api/v1/label/instance/values?match[]=http_requests_total{job="api"}</code></pre><p>The first returns all series for <code>http_requests_total</code> where <code>job=&quot;api&quot;</code>, with their full label sets. The second returns only the label names that exist on <code>http_requests_total</code> series. The third returns only the <code>instance</code> values that appear on matching series.</p><p><code>GET /_prometheus/api/v1/metadata</code> is different: it returns type and unit for each metric, optionally filtered by name via a <code>metric</code> parameter.</p><pre><code>GET /_prometheus/api/v1/metadata?metric=http_requests_total</code></pre><p>It does not accept <code>match[]</code> selectors or a time range. In Prometheus, metadata is collected from active scrape targets (the <code>HELP</code>, <code>TYPE</code>, and <code>UNIT</code> lines they expose), so the response does not involve a data scan. Elasticsearch does not have a dedicated metadata store like that, so the current implementation discovers metric metadata by visiting time series data from the last 24 hours. This keeps the query fast without requiring a full index scan. That 24-hour lookback is fixed today: the Prometheus metadata API does not expose <code>start</code> or <code>end</code> parameters that Elasticsearch could use to make it user-adjustable.</p><p>How the metadata endpoints work under the hood, including the <code>TS_INFO</code> and <code>METRICS_INFO</code> commands that power them, is covered <a href="https://www.elastic.co/search-labs/blog//elasticsearch-native-prometheus-api#ts-info-and-metrics-info">below</a>.</p><h3>Index pre-filtering</h3><p>All query and metadata endpoints accept an optional <code>{index}</code> path segment after <code>/_prometheus/</code>:</p><pre><code>GET /_prometheus/metrics-prod-*/api/v1/query_range?query=up&start=...&end=...</code></pre><p>This restricts which Elasticsearch indices the query runs against before any expression evaluation begins. On clusters with many data streams across teams or environments, this avoids scanning unrelated indices and can significantly reduce query latency. You can configure separate data sources per index pattern to give teams scoped access to their own metrics.</p><h3>A note about Remote Write</h3><p>For ingestion, Elasticsearch also exposes the standard Prometheus Remote Write endpoint:</p><ul><li><code>POST /_prometheus/api/v1/write</code> ingests time series via the Prometheus Remote Write v1 protocol. v2 is not yet supported.</li></ul><p>Remote Write writes into Elasticsearch&#x27;s existing time series data streams (TSDS), not a separate Prometheus-specific storage layer. Prometheus labels become TSDS dimensions, and metric names become fields in the index mapping. The <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">remote write architecture post</a> covers the full mapping in detail, including how metric types are inferred and how labels are stored with a <code>labels.</code> prefix.</p><h3>How it works</h3><p>Under the hood, all endpoints work the same way: parse the incoming HTTP parameters, build an ES|QL query plan, execute it against time series data streams, and convert the columnar result back into the JSON format Prometheus clients expect.</p><h2>TS_INFO and METRICS_INFO</h2><p>The metadata endpoints need to answer questions like &quot;what labels exist?&quot; or &quot;what metric types are defined?&quot; across potentially millions of time series, without scanning every data point.</p><p>Internally, the Prometheus metadata endpoints answer those questions by building ES|QL plans around two new processing commands: <code>METRICS_INFO</code> and <code>TS_INFO</code>. You do not need to use these commands directly to use the Prometheus API, but they are the core execution primitives behind the metadata responses. Both work by visiting only one document per time series to extract its metadata, rather than scanning all samples. This means their cost scales with the number of distinct time series, not the number of data points.</p><p><code>METRICS_INFO</code> returns one row per distinct metric with its name, type, unit, and associated dimension fields. <code>TS_INFO</code> is more granular: one row per (metric, time series) combination, including the actual dimension values as a JSON object.</p><pre><code>TS metrics-*
| METRICS_INFO
| SORT metric_name</code></pre><p>A dedicated blog post on <code>TS_INFO</code> and <code>METRICS_INFO</code> is coming soon, covering the two-phase execution model, how they scale, and how to use them directly in ES|QL queries beyond the Prometheus API.</p><h3>How the metadata endpoints use them</h3><p>Each metadata endpoint constructs an ES|QL plan with one of these commands at its core.</p><p><code>/api/v1/labels</code> and <code>/api/v1/series</code> use <code>TS_INFO</code>, since they need per-time-series detail (which labels exist, which dimension values identify each series). <code>/api/v1/metadata</code> and <code>/api/v1/label/__name__/values</code> use <code>METRICS_INFO</code>, since they only need per-metric information (metric names, types, units).</p><p><code>/api/v1/label/{name}/values</code> for regular labels (anything other than <code>__name__</code>) does not use either command. Regular labels like <code>job</code> or <code>instance</code> are actual dimension fields in the index, so the endpoint can query them directly with a group-by aggregation. When <code>match[]</code> selectors are provided, they are translated into a <code>WHERE</code> clause that filters the time series before the aggregation runs.</p><p>The <code>__name__</code> label needs a different strategy because it is not always present as a dimension field. Prometheus Remote Write does store <code>labels.__name__</code>, but metrics ingested through other paths (OpenTelemetry, the bulk API) do not have it. The metric name is encoded in the field name itself (e.g., <code>metrics.http_requests_total</code>). You could look at the index mappings to enumerate field names, but mappings alone do not tell you which metric has which dimensions, and they cannot be filtered by label values from a <code>match[]</code> selector. <code>METRICS_INFO</code> can do both: it enumerates metric names across indices while respecting upstream <code>WHERE</code> filters.</p><p>In all cases, the API layer handles the translation back to Prometheus conventions: stripping the <code>labels.</code> and <code>metrics.</code> storage prefixes and synthesizing <code>__name__</code> for non-Prometheus metrics that lack it.</p><h2>In conclusion</h2><p>The result: any Prometheus-compatible client can query and explore Elasticsearch metrics through endpoints it already understands. Remote Write metrics, OpenTelemetry metrics, and metrics indexed through other paths all show up through the same API, backed by the same TSDS indices.</p><p>All the Prometheus APIs mentioned here are available as tech preview in Elasticsearch Serverless today. For self-managed clusters and Elastic Cloud Hosted deployments, available as tech preview in Elasticsearch 9.4, with the exception of <code>GET /_prometheus/api/v1/metadata</code>. To experiment locally, use <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">start-local</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Personalizing ecommerce search: Integrating purchase history and user cohorts]]></title>
            <description><![CDATA[Learn how to create a personalized ecommerce search experience in Elasticsearch without breaking governance. This post explains how to boost products a shopper has purchased before and how to activate cohort-specific policies based on user profiles.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-personalized-search-governed-ecommerce</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-personalized-search-governed-ecommerce</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/92c517a2e7b36122a18feee317a0215981b62b6b-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/series/governed-search-patterns">Parts 1 through 5</a> of this series describe a governed control plane that classifies intent, enforces constraints, resolves policy conflicts, and routes to the appropriate retrieval strategy, all before the product catalog is queried. Every mechanism described so far treats all shoppers identically. A search for &quot;chocolate&quot; produces the same governed result set, whether the shopper is a vegan, a parent buying for a child&#x27;s birthday, or a halal-observant consumer.</p><p>This post introduces two personalization mechanisms that extend the governed control plane without changing its architecture. Both mechanisms stack multiplicatively with the governance layer from Parts 1 through 5: Policies still fire, constraints are still enforced, conflicts are still resolved, and personalization signals are composed into the same governed query, ensuring that the results Elasticsearch returns are already personalized.</p><p>The first mechanism boosts products the individual shopper has purchased before. The second activates cohort-specific policies based on the shopper&#x27;s profile. Together, they demonstrate that personalization is not a separate system bolted alongside search or applied as post-retrieval processing; it’s a natural extension of the policy-driven control plane.</p><p>For a deep dive into the mathematics of the personalization techniques used in this post, see <a href="https://alexmarquardt.com/elastic/personalizing-search-in-elasticsearch-without-ml-post-processing/">Personalizing search in Elasticsearch without ML post-processing</a> and <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch">Cohort-aware ranking in Elasticsearch</a>.</p><p>To see a live demonstration of how purchase history can be used to boost search results for returning customers, watch the video: <a href="https://www.youtube.com/watch?v=TGf_pOWHA5M">Explainable Personalization: Boosting Search with Purchase History</a>.</p><h2>Individual purchase history boosting</h2><p>The simplest form of personalization is also one of the most effective: If a shopper has bought a product before, boost it when they search for something related. A shopper who regularly buys a particular brand of chocolate chip cookies should see those cookies ranked higher when they search for &quot;cookies&quot;, not because a model predicted a preference, but because there’s direct behavioral evidence.</p><h3>How it works</h3><p>When a search request includes a user identifier, as would be the case for a user that has an open session, the control plane runs two Elasticsearch queries in parallel using a thread pool:</p><ol><li>The percolator query against the policy index (the same governance lookup described in Parts 3 and 4).</li><li>A purchase history query against a <code>user_purchases</code> index, filtered to the specific user by <code>term(user_id)</code> and then matching the current search string against that user&#x27;s product titles.</li></ol><p>These run concurrently (neither waits for the other), so the personalization lookup adds no meaningful latency to the governance pipeline.</p><p>The purchase history query uses <a href="https://www.elastic.co/docs/manage-data/data-store/text-analysis">Elasticsearch&#x27;s text analysis</a> (stemming, tokenization) when matching the current search string against stored product titles. This means a search for &quot;cookies&quot; will match a past purchase of &quot;brownie cookies&quot; through standard text analysis, without requiring exact string matching.</p><h3>Computing boost weights</h3><p>Not all past purchases deserve the same boost. The weight accounts for two intuitive factors: how often the shopper bought the product, and how recently. A product purchased 15 times last week is a much stronger signal than a product purchased once six months ago. The weighting uses logarithmic scaling on frequency (so a single heavily purchased item doesn&#x27;t overwhelm everything else) and exponential decay on recency (so older purchases fade naturally over time).</p><p>For the mathematical details of the boost formula, see <a href="https://alexmarquardt.com/elastic/personalizing-search-in-elasticsearch-without-ml-post-processing/">Personalizing search in Elasticsearch without ML post-processing</a>.</p><h3>How it becomes a query</h3><p>The purchase history boosts are composed into the query as the outermost scoring layer, wrapping the governance policy filters and boosts from Parts 3 and 4 and any<a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch"> business-signal boosts, such as margin and popularity</a> (which we’ll explore in Part 7). This means a product that’s removed by a governance policy won’t reappear because of a purchase history boost. <em>Governance</em> controls the result set; <em>personalization</em> adjusts ordering within it. Products without any purchase history are not penalized. Their governed ranking is preserved, though products with relevant purchase history will rank above them, all else being equal.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/80f0285bd80935703d39b7a4e1fd6094d71af0aa-545x273.jpg" style="width: 100%;" /><h3>Why query Elasticsearch on every search?</h3><p>The purchase history is queried from Elasticsearch on every search rather than cached in the application layer. This is a deliberate design choice. Because the query matches the current search string against product titles using Elasticsearch&#x27;s text analysis pipeline, the system benefits from the same stemming, tokenization, and language handling that powers the product search itself. A cached in-memory lookup would require reimplementing that analysis or accepting cruder matching.</p><p>To see why this ordering matters, consider a shopper who previously purchased orange juice and now searches for &quot;oranges”. The purchase history query matches &quot;orange juice&quot; against the search term &quot;oranges&quot; through text analysis and computes a boost for that product. But the governance layer has already constrained &quot;oranges&quot; to the produce category, filtering out orange juice entirely. The purchase history boost for orange juice is present in the query, but it has no effect because there’s no matching document in the governed result set for it to act on. The shopper sees fresh oranges, ranked by relevance and personalization. The governance guardrail holds.</p><p>The performance cost is minimal: The purchase history index is small (a user&#x27;s purchase history is typically dozens to hundreds of documents, not millions), and the query runs in parallel with the percolator lookup, so it doesn&#x27;t extend the critical path.</p><h3>Example query for “spring water” without user history</h3><p>If a non-logged-in user or a user that has never purchased “spring water” searches, they might see results similar to the following:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1d03558c8f6492a0999e1ac4f1d22680c8f3a6ce-1130x1028.png" style="width: 100%;" /><h3>Example user purchase history</h3><p>On the other hand, a user called Carol has a shopping history that contains the following products:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/31c1fb789efc6cef673984e9711d571efce8ed27-661x523.png" style="width: 100%;" /><h3>Example search for “spring water” with the above purchase history</h3><p>If Carol searches for “spring water”, she’ll see personalized results that reflect what she has purchased in the past. Looking at the purchase history above, she purchased “Carbonated Spring Water” (the green bottle) above, about 40 times, and most recently two days ago. If she searches for “spring water”, then that product is boosted up, as we know that she likes it. Notice that in the non-personalized results, the Rubicon spring water was the first hit instead.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6fce63ff051e345a79fef934cd6e71ba113ae585-1159x1062.png" style="width: 100%;" /><h2>Cohort-aware policy activation</h2><p>Individual purchase history works well for returning customers with established behavior. But many shoppers are new, anonymous, or browsing outside their usual patterns. For these shoppers, cohort membership provides a different kind of personalization, one based on who the shopper is, not what they&#x27;ve done.</p><p>A vegan shopper searching for &quot;chocolate&quot; should see vegan chocolate ranked higher. A halal-observant shopper searching for &quot;snacks&quot; should see halal-certified options prominently. A health-conscious shopper searching for &quot;yogurt&quot; should see probiotic options boosted.</p><h3>Cohorts as policies, not product tags</h3><p>Products already carry their normal attributes, including fields like <code>dietary_restrictions: [&quot;vegan&quot;]</code> or <code>dietary_restrictions: [&quot;halal&quot;]</code>. The question is where the logic lives that connects a shopper&#x27;s cohort to those product attributes.</p><p>The naive approach would be to hard-code that mapping in the application layer or in the search template: If the user is vegan, add a boost on <code>dietary_restrictions: &quot;vegan&quot;</code>. But this is the same application-layer spaghetti described in <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval">Part 1</a>, and it creates the same operational friction: Adding a new cohort or changing what a cohort means requires a code change.</p><p>The governed control plane keeps the cohort logic in the policy engine instead. A cohort policy bridges two things: a shopper&#x27;s cohort membership (for example, “vegan”) and a product attribute (for example, <code>dietary_restrictions: “vegan”</code>). The policy defines the connection: When a shopper in the vegan cohort searches, boost products where <code>dietary_restrictions</code> includes “vegan”.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2b6fe359774bbea059aaf93f3fa4a03eb31233ea-544x290.jpg" style="width: 100%;" /><p>Because cohort logic lives in the policy engine rather than application code, this means:</p><ul><li>Adding a new cohort can be done by creating a new policy; no product reindexing required.</li><li>Cohort policies use the full rule engine: They can add filters, apply soft boosts, expand synonyms, change retrieval strategy, or any other action a policy can take.</li><li>Cohort behavior is managed through the same admin UI as all other policies: A merchandiser can create, test, and promote cohort policies through the Author → Test → Promote workflow described in <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">Part 2</a>.</li></ul><h3>Example vegan cohort policy</h3><p>A merchandiser creates a cohort policy with the following characteristics:</p><ul><li><strong>Cohorts:</strong> <code>[&quot;vegan&quot;]</code>.</li><li><strong>Match criteria:</strong> Matches any query (or a specific product category).</li></ul><p><strong>Action:</strong> Soft boost on <code>dietary_restrictions: &quot;vegan&quot;</code> with a boost weight of 2.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fc58bbd97c0dd1fa3ce757394ca117d0789c52f6-1080x1018.png" style="width: 100%;" /><h3>How cohort activation works</h3><p>Each policy document has a <code>cohorts</code> field. Universal policies that apply to all shoppers regardless of cohort can leave this field blank, and these will internally be assigned a value of <code>&quot;_all&quot;</code> by the control plane. Cohort-specific policies store their target cohort names, such as <code>[&quot;vegan&quot;, &quot;kosher&quot;, “sweet_tooth”]</code>.</p><p>When a search request includes a user profile, the control plane constructs a simple <code>terms</code> filter for the percolator query:</p><pre><code>{ "terms": { "cohorts": ["_all", "vegan", "health_conscious"] } }</code></pre><p>This single filter includes all universal policies plus the user&#x27;s cohort-specific policies. The <code>_all</code> sentinel makes this a clean inclusion filter: No <code>must_not</code> or <code>exists</code> queries are needed to handle the case where a policy has no cohort restriction.</p><p>The percolator then evaluates policy matches as usual. The only difference is that the candidate policy set has been narrowed to those relevant to this shopper&#x27;s cohorts. Everything downstream (cascading transformations, per-field conflict resolution, consumed phrase tracking) operates identically to the non-personalized flow described in Parts 3 and 4.</p><h3>Non-vegan (standard) user results when searching for “chocolate”</h3><p>When a non-vegan user searches for chocolate, there’s no vegan cohort boost applied to their results. They would often see non-vegan chocolates in the top hits, as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5bade79944ef294e2cb835cfd6e3231392e8fbd0-1159x1104.png" style="width: 100%;" /><h3>Vegan cohort policy results when searching for “chocolate”</h3><p>When a vegan-cohort shopper searches for &quot;chocolate&quot;, this policy is included in the percolator candidate set. It matches, and the control plane applies a soft boost to vegan-certified chocolates. The boost is multiplicative: Vegan chocolates rank higher, but non-vegan chocolates are not fully excluded because the above filter is defined as a <em>soft boost</em>, which we described in detail in Part 3 of this series.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fc6f7ec6a9de30f3a6d8f32bb9ee7ec457dea458-1138x1255.png" style="width: 100%;" /><p>However, if the shopper explicitly searches for &quot;Hershey milk chocolate&quot;, the vegan boost still applies but may be outweighed by the stronger text relevance of Hershey milk chocolate products.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f47bb8bfa58106f897c4c6c143494f4367355528-1136x1142.png" style="width: 100%;" /><p>A shopper outside the vegan cohort searching for the same query never sees the “vegan cohort” policy; it’s not in their candidate set. The governance layer is identical; only the active policy set differs.</p><h3>Cohorts with purchase history</h3><p>A vegan shopper with extensive purchase history gets vegan-cohort-specific policy activation as well as purchase history boosts. For new or anonymous shoppers, implied cohort membership alone provides meaningful personalization without requiring any behavioral data (for example, perhaps an anonymous user has only searched for vegan products, and so we classify them as a member of the vegan cohort). A shopper who self-identifies as halal-observant during account creation immediately receives halal-tailored results on their first search.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/81af35a533a567d99324860c8e69cf9752533c8f-545x301.jpg" style="width: 100%;" /><h2>How personalization layers compose</h2><p>The nesting order of <code>function_score</code> layers matters. From innermost to outermost:</p><ol><li><strong>Base query:</strong> The keyword or semantic match with named queries (<code>fulltext_match</code>, <code>title_phrase_match</code>).</li><li><strong>Governance policy layer:</strong> Hard filters as <code>bool.filter</code> clauses, soft boosts as <code>function_score</code> functions (Parts 3 and 4).</li><li><strong>Business-signal boosts:</strong> Margin and popularity boosting (which we’ll explore in Part 7).</li><li><strong>Purchase history boosts:</strong> The outermost <code>function_score</code> layer.</li></ol><p>This ordering ensures that governance controls the result set (what appears), business signals adjust ranking within that set (what appears first from the retailer&#x27;s perspective), and purchase history adjusts ranking further based on individual behavior (what appears first from the shopper&#x27;s perspective). Each layer wraps the previous one multiplicatively, so the effects compound rather than conflict.</p><h2>What this means operationally</h2><p>Personalization through the governed control plane preserves every operational property described in Parts 1 and 2:</p><ul><li><strong>Zero-deploy changes.</strong> Cohort policies are created, tested, and promoted through the admin UI. Adding a new dietary cohort or adjusting a boost weight requires no code changes and no engineering involvement.</li><li><strong>Auditability.</strong> Every cohort policy is a discrete, versioned document. When a merchandiser asks, &quot;Why are vegan products ranking higher for this user?&quot;, the answer is a specific policy with a specific priority, visible in the debug panel alongside every other policy that fired for that query.</li><li><strong>Conflict resolution.</strong> Cohort policies participate in the same per-field conflict resolution described in Part 3. If a cohort policy&#x27;s category boost conflicts with a campaign policy&#x27;s category override, the conflict is resolved deterministically by the same priority and strategy framework, no special handling needed.</li><li><strong>Measurability.</strong> Because cohort policies are discrete and individually toggleable, their impact on conversion, click-through, and add-to-cart rates can be measured independently, just like any other policy in the system.</li></ul><h2>What&#x27;s next in this series</h2><p>The next post explores another dimension of the governed control plane: how margin and popularity boosting can be tuned per query through policies, turning economic optimization into a governance decision rather than a static configuration.</p><p>See Part 7: Query-governed economic optimization: Per-query margin and popularity boosting</p><h2>Put governed ecommerce search into practice</h2><p>The personalization patterns described in this post (individual purchase history boosting and cohort-aware policy activation) were designed and built by Elastic Services Engineering as part of our repeatable ecommerce search accelerator. Both mechanisms integrate with the governed control plane architecture described throughout this series. Contact <a href="https://www.elastic.co/consulting">Elastic Professional Services</a>.</p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[One index, all media: Introducing jina-embeddings-v5-omni]]></title>
            <description><![CDATA[jina-embeddings-v5-omni lets you embed text, images, video, and audio into a single Elasticsearch index and query across all of them at once.]]></description>
            <link>https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Scott Martens]]></dc:creator>
            <pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b51e488ba5b90bec77f75af0b9cb4f0e25e20b91-1130x635.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><code>jina-embeddings-v5-omni</code> brings text, images, video, and audio into a single Elasticsearch index. Extending the best-in-class <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text"><code>jina-embeddings-v5-text</code></a> models, the v5-omni suite adds visual and audio encoding through an innovative architecture that leaves the text backbone identical, providing frontier-class performance in one very compact embedding model.</p><p>You can now create high-performance semantic embeddings for <strong>text</strong>, <strong>images</strong>, <strong>videos</strong>, and <strong>audio recordings</strong>, spanning <strong>nearly 100 languages</strong>, and use them for classification, clustering, semantic similarity measurement, and indexing for retrieval. If your data lives in PDFs, recordings, and video alongside text, you no longer need separate pipelines for each.</p><p>The <code>jina-embeddings-v5-omni</code> family is the <strong>most compact embedding model currently on the market with support for pictures, speech, print, and video</strong>. It offers:</p><ul><li><strong><code>jina-embeddings-v5-text</code>’’s frontier-class text embeddings</strong> for retrieval, analysis, and AI agent applications.</li><li><strong>Best-in-size-class embeddings</strong> <strong>for visual semantic similarity, visual understanding, and image retrieval.</strong> <code>jina-embeddings-v5-omni-small</code> has the best performance on image benchmarks of any model in the 1 billion (10⁹) parameters and is superior to our own previous <a href="https://www.elastic.co/search-labs/blog/jina-models-elasticsearch-guide#jina-clip-v2"><code>jina-clip-v2</code></a>. Only a few models with three to 30 times as many parameters can beat it.</li><li><strong>State-of-the-art embeddings for multilingual visual understanding and retrieval</strong>, beating models as much as 20 times larger.</li><li><strong>Best-in-size-class audio embeddings</strong>, with only models that have double or more the number of parameters performing better on standard benchmarks.</li><li><strong>Support for video</strong>, especially for locating objects and events in footage.</li></ul><p>This has applications in all areas of information retrieval, document processing, and data analysis. <code>jina-embeddings-v5-omni</code> opens up access to information locked up in different media silos and makes it accessible for retrieval, analysis, and use by AI agents. Information in audio and video recordings, PDF, printed page scans, and infographics are on an equal footing with digitized texts in your data ecosystem.</p><p>Just like <code>jina-embeddings-v5-text</code>, these models come in two sizes: <code>small</code> and <code>nano</code>. Both models extend their corresponding text equivalent with additional modules supporting audio and visual input. Users can select modules at load time. In addition, task-specific extensions for semantic similarity, classification, clustering, and information retrieval are implemented as compact low-rank adapters (LoRAs) and are all loaded, so users can select them at inference time.</p><p>Both models are very compact. <code>jina-embeddings-v5-omni-small</code> can run on conventional GPU-equipped servers, and <code>jina-embeddings-v5-omni-nano</code> is small enough to run on commodity hardware. This represents a large potential savings in compute costs and makes possible licensed local installation and edge processing, reducing latency and increasing your control of your own data.</p><p>The v5-omni suite uses innovative model design and machine learning techniques to compose new embedding models from previously trained ones without having to retrain them. We use encoders from pretrained, language-aligned, embedding models for audio and video media as input preprocessors for our existing <code>jina-embeddings-v5-text</code> model suite. The resulting models generate embeddings for images and sound recordings that are semantically compatible with the embeddings it generates for texts.</p><p>The v5-omni models produce text embeddings that are identical to <code>jina-embeddings-v5-text</code> (that is, <code>jina-embeddings-v5-omni-small</code> with <code>jina-embeddings-v5-text-small</code>; and <code>jina-embeddings-v5-omni-nano</code> with <code>jina-embeddings-v5-text-nano</code>) so you can extend existing text retrieval repositories to multimedia applications without rebuilding your indices.</p><p>The integrated encoders are all derived from open-weight sources. For images and video, we’ve used encoders from <a href="https://qwen.ai/blog?id=qwen3.5">Qwen3.5</a> models:</p><ul><li>For <code>jina-embeddings-v5-omni-nano</code>, the fine-tuned <a href="https://huggingface.co/google/siglip2-base-patch16-224">SigLIP2 Base</a> encoder from <a href="https://huggingface.co/Qwen/Qwen3.5-0.8B">Qwen3.5-0.8B</a>.</li><li>For <code>jina-embeddings-v5-omni-small</code>, the fine-tuned <a href="https://huggingface.co/google/siglip2-so400m-patch14-384">SigLIP2 So400m</a> encoder from <a href="https://huggingface.co/Qwen/Qwen3.5-2B">Qwen3.5-2B</a>.</li><li>For audio support, we’ve added the encoder from <a href="https://huggingface.co/openai/whisper-large-v3">Whisper-large-v3</a>, extracted from <a href="https://huggingface.co/Qwen/Qwen2.5-Omni-7B">Qwen2.5-Omni-7B</a>, to both the small and nano versions.</li></ul><p>We’ve connected these media-specific encoders to the text-processing backbone with trained cross-modal projectors. These projectors translate their native outputs to input embeddings compatible with <code>jina-embeddings-v5-text</code>. The only newly trained parts of the <code>jina-embeddings-v5-omni</code> models are the weights in those projectors.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4cb834b7e5fd63cdd78600de313615feffabbb1f-1999x1000.jpg" style="width: 100%;" /><p>This architecture means we only need to train the cross-model projectors, roughly 5.5 million parameters for <code>jina-embeddings-v5-omni-small</code> and under 3.5 million for <code>jina-embeddings-v5-omni-nano</code>, for each of the four LoRA adapters. This approach minimizes the additional training needed to connect different embedding models, leveraging the specialized training of each to produce an extremely compact, high-performance, modular embedding suite.</p><h2>Selected model properties</h2><h3>Input/output</h3>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model name</td><td style="border: 1px solid #ccc; padding: 4px;">Input contextwindow size</td><td style="border: 1px solid #ccc; padding: 4px;">Embedding size</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-embeddings-v5-omni-small</td><td style="border: 1px solid #ccc; padding: 4px;">32,768 tokens*</td><td style="border: 1px solid #ccc; padding: 4px;">1024 dims(minimum: 32)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-embeddings-v5-omni-nano</td><td style="border: 1px solid #ccc; padding: 4px;">8,192 tokens*</td><td style="border: 1px solid #ccc; padding: 4px;">768 dims(minimum: 32)</td>
                    </tr>
              </tbody>
            </table>
          <p>* See <strong>Using jina-embeddings-v5-omni</strong> below for more on how non-text media is tokenized.</p><h3>Size</h3>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model name</td><td style="border: 1px solid #ccc; padding: 4px;">Total size</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-embeddings-v5-omni-small(text-only base model + 4 LoRA adapters)</td><td style="border: 1px solid #ccc; padding: 4px;">700M params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">image/video support(SigLIP2 So400m encoder extracted from Qwen3.5-2B)</td><td style="border: 1px solid #ccc; padding: 4px;">1.006B params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">audio support(Whisper-large-v3 encoder extracted from Qwen2.5-Omni-7B)</td><td style="border: 1px solid #ccc; padding: 4px;">1.354B params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">both</td><td style="border: 1px solid #ccc; padding: 4px;">1.660B params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LoRA adapters (each)</td><td style="border: 1px solid #ccc; padding: 4px;">20M</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-embeddings-v5-omni-nano(text-only base model + 4 LoRA adapters)</td><td style="border: 1px solid #ccc; padding: 4px;">266M params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">image/video support(SigLIP2 Base encoder extracted from Qwen3.5-0.8B)</td><td style="border: 1px solid #ccc; padding: 4px;">354M params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">audio support(Whisper-large-v3 encoder extracted from Qwen2.5-Omni-7B)</td><td style="border: 1px solid #ccc; padding: 4px;">916M params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">both</td><td style="border: 1px solid #ccc; padding: 4px;">1.004B params</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LoRA adapters (each)</td><td style="border: 1px solid #ccc; padding: 4px;">7M</td>
                    </tr>
              </tbody>
            </table>
          <p>* See <strong>Using jina-embeddings-v5-omni</strong> below for more on how non-text media is tokenized.</p><h2>Task-specific training</h2><p>The <code>jina-embeddings-v5-omni</code> family supports the same task-specific LoRA adapters as <code>jina-embeddings-v5-text</code>:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Task</td><td style="border: 1px solid #ccc; padding: 4px;">Example uses</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Retrieval</td><td style="border: 1px solid #ccc; padding: 4px;">Information retrieval, by itself or in conjunction with other retrieval and candidate evaluation techniques. With the v5-omni models, you can retrieve audio, video, and images in one query from one index.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Clustering</td><td style="border: 1px solid #ccc; padding: 4px;">Topic discovery and automatic topical organization across all media.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Classification</td><td style="border: 1px solid #ccc; padding: 4px;">Categorization, sentiment analysis, and related kinds of tasks.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Semantic similarity</td><td style="border: 1px solid #ccc; padding: 4px;">Data deduplication across media, recommender systems, related media, finding texts to match speech, identifying translations, and similar tasks.</td>
                    </tr>
              </tbody>
            </table>
          <p>Output embeddings depend on the selected task category. For example, you shouldn’t use retrieval-oriented embeddings for clustering or semantic similarity embeddings for classification.</p><h2>Multimedia, multimodal, multilingual, multifunctional</h2><p>To show what <code>jina-embeddings-v5-omni</code> can do, let’s take the famous opening passages of two novels and measure their semantic similarity:</p><p><strong><em>A Tale of Two Cities</em> (Charles Dickens)</strong></p><pre><code>It was the best of times, it was the worst of times, it was the
age of wisdom, it was the age of foolishness, 
it was the epoch of belief, it was the epoch of incredulity,
it was the season of Light, it was the season of Darkness,
it was the spring of hope, it was the winter of despair,
we had everything before us, we had nothing before us,
we were all going direct to Heaven, we were all going
direct the other way—in short, the period was so far like
the present period, that some of its noisiest authorities
insisted on its being received, for good or for evil, in 
the superlative degree of comparison only.</code></pre><p><strong><em>Pride and Prejudice</em> (Jane Austen)</strong></p><pre><code>It is a truth universally acknowledged, that a 
single man in possession of a good fortune must
be in want of a wife. However little known the
feelings or views of such a man may be on his first
entering a neighbourhood, this truth is so well
fixed in the minds of the surrounding families,
that he is considered as the rightful property of
some one or other of their daughters.</code></pre><p>Using <code>jina-embeddings-v5-omni-small</code>, with its semantic similarity adapter, these texts have a similarity of <strong>0.5329</strong>.</p><p>That number doesn’t mean much without something to compare it with, so let’s compare these two texts to their French translations using the same model and adapter:</p><p><strong>Semantic similarity scores for texts across languages</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (English)</td><td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tale of Two Cities (French)(Paris et Londres en 1783, tr. H. Loreau)</td><td style="border: 1px solid #ccc; padding: 4px;">0.9095</td><td style="border: 1px solid #ccc; padding: 4px;">0.5074</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (French)(Orgueil et Préjugés,tr. Leconte et Pressoir)</td><td style="border: 1px solid #ccc; padding: 4px;">0.4826</td><td style="border: 1px solid #ccc; padding: 4px;">0.8784</td>
                    </tr>
              </tbody>
            </table>
          <p>The two texts show much greater similarity to their translations than to other texts in the same language or a different one. This reflects the very high performance multilingual semantic embeddings of <code>jina-embeddings-v5-text-small</code>, included unchanged in <code>jina-embeddings-v5-omni-small</code>.</p><p>Adding multimedia support to <code>jina-embeddings-v5-omni</code> means we can extend this experiment to whole other types of data. For example, we fetched scans of the first pages of both novels from old print editions:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e92d928a4813ccecc7d02639eea699e3a71c66e3-1999x1692.png" style="width: 100%;" /><p><strong>Figure 2:</strong> <em>A Tale of Two Cities</em>, undated 19th-century edition, and <em>Pride and Prejudice</em>, 1903 Macmillan edition.</p><p>Let’s compare both texts to the scans, again using the semantic similarity adapter:</p><p><strong>Semantic similarity scores between texts and images</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (scan)</td><td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (scan)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tale of Two Cities (text)</td><td style="border: 1px solid #ccc; padding: 4px;">0.7336</td><td style="border: 1px solid #ccc; padding: 4px;">0.4891</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (text)</td><td style="border: 1px solid #ccc; padding: 4px;">0.4804</td><td style="border: 1px solid #ccc; padding: 4px;">0.7213</td>
                    </tr>
              </tbody>
            </table>
          <p>You see that semantic similarity scores strongly favor texts that match image contents.</p><p>We can also compare the texts to a screenshot of a social media post and a meme that reference those texts, using the same setup:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/619b80b9626596c1f1ccf3a23381189fac45f144-535x521.png" style="max-width:100%; margin-bottom:1em;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8695bddd7148af647a19a97cbc31b70d6b8a5267-640x680.jpg" style="max-width:100%; margin-bottom:1em;" /><p><strong>Figure 3:</strong> An Elon Musk tweet referencing <em>A Tale of Two Cities</em>, and a meme referencing the famous opening of <em>Pride and Prejudice</em>.</p><p><strong>Semantic similarity scores between texts and images</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities</td><td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Musk tweet (image)</td><td style="border: 1px solid #ccc; padding: 4px;">0.7156</td><td style="border: 1px solid #ccc; padding: 4px;">0.4912</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Keep calm meme (image)</td><td style="border: 1px solid #ccc; padding: 4px;">0.4555</td><td style="border: 1px solid #ccc; padding: 4px;">0.6244</td>
                    </tr>
              </tbody>
            </table>
          <p>We can do the same for speech. We obtained recordings of readings of both texts, in English and in French:</p><ul><li><a href="https://drive.google.com/file/d/1IK_bR6L47TSrnESdXLEpHCX6o-FzPmOx/view?usp=sharing"><em>A Tale of Two Cities</em> (English audio from Librivox)</a>.</li><li><a href="https://drive.google.com/file/d/1KurbhD6INc6oZlq_SJcWq7MBVfJQ3pof/view?usp=sharing"><em>A Tale of Two Cities</em> (French audio generated by OmniVoice AI)</a>.</li><li><a href="https://drive.google.com/file/d/1D_G2FOCb7ct-FBVY1fyivXlY2e_yGZw1/view?usp=sharing"><em>Pride and Prejudice</em> (English audio from Librivox).</a></li><li><a href="https://drive.google.com/file/d/1jutY61YSFGBIyE8m-ltEJx-8dEXNJJ5N/view?usp=sharing"><em>Pride and Prejudice</em> (French audio generated by OmniVoice AI)</a>.</li></ul><p><strong>Semantic similarity scores between texts and audio across languages</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (English audio)</td><td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (French audio)</td><td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English audio)</td><td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (French audio)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities(English text)</td><td style="border: 1px solid #ccc; padding: 4px;">0.3816</td><td style="border: 1px solid #ccc; padding: 4px;">0.3106</td><td style="border: 1px solid #ccc; padding: 4px;">0.1607</td><td style="border: 1px solid #ccc; padding: 4px;">0.1774</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities(French text)</td><td style="border: 1px solid #ccc; padding: 4px;">0.3528</td><td style="border: 1px solid #ccc; padding: 4px;">0.3253</td><td style="border: 1px solid #ccc; padding: 4px;">0.1598</td><td style="border: 1px solid #ccc; padding: 4px;">0.1721</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice(English text)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1910</td><td style="border: 1px solid #ccc; padding: 4px;">0.1682</td><td style="border: 1px solid #ccc; padding: 4px;">0.3511</td><td style="border: 1px solid #ccc; padding: 4px;">0.3398</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice(French text)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1667</td><td style="border: 1px solid #ccc; padding: 4px;">0.1474</td><td style="border: 1px solid #ccc; padding: 4px;">0.3018</td><td style="border: 1px solid #ccc; padding: 4px;">0.3702</td>
                    </tr>
              </tbody>
            </table>
          <p>This multilingual and multimedia ability extends to information retrieval.</p><p>The retrieval adapters for the <code>jina-embeddings-v5-omni</code> models implement asymmetric retrieval. This means they embed queries differently from the way they embed retrieval target documents, so cross-modal queries are always in some direction, with queries in one media and documents in another, giving different scores from when they’re reversed.</p><p>The tables below show the retrieval scores for text, audio, and page scan images for <em>A Tale of Two Cities</em> and <em>Pride and Prejudice</em>, when the text from <em>A Tale of Two Cities</em> (in English) is encoded as the query:</p><p><strong>Text to text</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Document</td><td style="border: 1px solid #ccc; padding: 4px;">Retrieval score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (French text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.7597</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1482</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (French text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.0523</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Text to image</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Document</td><td style="border: 1px solid #ccc; padding: 4px;">Retrieval score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (English page scan)</td><td style="border: 1px solid #ccc; padding: 4px;">0.5517</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (French page scan)</td><td style="border: 1px solid #ccc; padding: 4px;">0.3576</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English page scan)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1917</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Text to audio</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Document</td><td style="border: 1px solid #ccc; padding: 4px;">Retrieval score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (English audio)</td><td style="border: 1px solid #ccc; padding: 4px;">0.3277</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (French audio)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1980</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English audio)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1419</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (French audio)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1759</td>
                    </tr>
              </tbody>
            </table>
          <p>Users can also run the query the other way around, doing audio-to-text and image-to-text retrieval.</p><p>Below are the scores using the English audio of <em>A Tale of Two Cities</em> as a query and various texts as documents:</p><p><strong>Image to text</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Document</td><td style="border: 1px solid #ccc; padding: 4px;">Retrieval score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (English text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.3352</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (French text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.2650</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1626</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (French text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1385</td>
                    </tr>
              </tbody>
            </table>
          <p>And the scores using a scan of page one of <em>A Tale of Two Cities</em> (in English) as a query:</p><p><strong>Audio to text</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Document</td><td style="border: 1px solid #ccc; padding: 4px;">Retrieval score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (English text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.5304</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">A Tale of Two Cities (French text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.4845</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (English text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.1467</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Pride and Prejudice (French text extract)</td><td style="border: 1px solid #ccc; padding: 4px;">0.0761</td>
                    </tr>
              </tbody>
            </table>
          <img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/673f5116c1d7a5a6f9adfbb2f48b9e6920e3229b-668x130.png" style="width: 100%;" /><h2>Video search</h2><p>The <code>jina-embeddings-v5-omni</code>‘s capabilities for video indexing and search bring new capabilities to Elasticsearch databases, but it’s subject to many of the same warnings that apply to texts. Generating a single embedding for a long film is like embedding a very long novel: Detailed information will be swamped, and the resulting embedding will be a good match for many very spurious queries.</p><p>If you embed the whole text of <em>Lord of the Rings</em> (~500,000 words), it’s likely to be a good match for most queries, no matter what you’re looking for. Similarly, if you index a two-hour Hollywood film, you’ll get a lot of spurious matching and totally missed details. <code>jina-embeddings-v5-omni</code> is optimal with short clips.</p><p>For this example, we downloaded the trailer to the 1961 film <em>Breakfast At Tiffany’s</em>, which is just 158 seconds long and in the public domain. You can see the trailer <a href="https://archive.org/details/turner_video_311/311.mp4">on the Internet Archive</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1948f585027d1342528c0dd93fe99e3ec7ba17f9-800x1200.png" style="width: 100%;" /><p><strong>Figure 4: </strong>The theatrical poster for <em>Breakfast at Tiffany’s</em>.</p><p>We used <a href="https://www.scenedetect.com/">PySceneDetect </a>to split the trailer into 28 individual scenes, with lengths varying from 1.877 seconds (45 frames) to 18.393 seconds (441 frames). Scene detection is imperfect, but it provides an adequate mechanism for splitting video into bite-sized chucks for retrieval. Then we generated document embeddings for each of the 28 segments, using <code>jina-embeddings-v5-omni-small</code>, so we could test the effectiveness of text queries at finding specific elements in the video.</p><p>For example, querying for “cat” returned the following clips as the top three results. The one scene with a cat in it is at the top, with a score of <strong>0.1634</strong>:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/82bd1759ebb65202f01a1e290447619af39f9a3d-735x426.png" style="width: 100%;" /><p><a href="https://drive.google.com/file/d/1O3r-97rQJE7HAlUHsLygmFLwlZihRF26/view?usp=drive_link">Watch clip one</a>.</p><p>The next highest match, with a score of <strong>0.1237</strong>, is much lower:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/de4eb704ef751cc4df2bead7faa177decd20b1b3-735x426.png" style="width: 100%;" /><p><a href="https://drive.google.com/file/d/1DEK2H4bCpLVzyipJri9NSqE2jkeZ5jLm/view?usp=drive_link">Watch clip 2</a>.</p><p>You can also query for actions. If you query with the string “kiss”, the top four matches all contain kisses:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/18efdcbbb31954dfab8eafe5055773b6b8e3de72-735x426.png" style="width: 100%;" /><p><a href="https://drive.google.com/file/d/1Pmr_D4wKjUCCiq_Dzy5PQeWldJLjZ8kI/view?usp=drive_link">Watch clip 3.</a> Its score is 0.2864.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1145f1870204f35def99c92981a00df7f58c5aca-735x426.png" style="max-width:100%; margin-bottom:1em;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5a28c770f2013710fc734932e1003781d64f5794-735x426.png" style="max-width:100%; margin-bottom:1em;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6729cf42f1f64fa0dd6fb6a33ef256eea2f80879-735x426.png" style="max-width:100%; margin-bottom:1em;" /><p>Scores: For the <a href="https://drive.google.com/file/d/1sS_4qTnmHU2uOwbN1fmdXeS3IvZAI_IN/view?usp=drive_link">second match</a> (0.2494), <a href="https://drive.google.com/file/d/1ofsrZHTUb3huwQ0JB6Hs5dOw4myTj-sk/view?usp=drive_link">third match</a> (0.2099), and <a href="https://drive.google.com/file/d/1qaS4eueUCcQWdHLEjfEfneXsaWsx6P_5/view?usp=drive_link">fourth match</a> (0.2068), respectively</p><p>And you can search for text displayed in videos, like for “Buddy Ebsen”, which only appears once. <code>jina-embeddings-v5-omni-small</code> readily identifies it as the best match with a score of <strong>0.3885</strong>, considerably higher than the next best match:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dabcae9f44b9d4a671f88717aa21a2c6cf685f6b-735x426.png" style="width: 100%;" /><p><a href="https://drive.google.com/file/d/1rdQ1OqtPBD-3iGG8A900jU5DpC-muye4/view?usp=sharing">Buddy Ebsen clip</a>.</p><h2>Visual document retrieval</h2><p>Jina AI multimodal embedding models are top performers in visual document processing and state-of-the-art in multilingual visual document processing. This means handling image data that contains text, figures, and structured information. Important data is often in the form of print scans, PDF files, diagrams, technical drawings, screenshots, pictures, infographics, and the like. These kinds of images are often mechanically composed or computer generated. They can’t usually be reduced to text without loss of meaning and are poorly suited to computational vision models designed for photography of natural scenes.</p><p><code>jina-embeddings-v5-omni</code>’s embeddings encompass information about the things in the image, the text printed on them, and the relationships between the two. Visual document retrieval makes it possible to index rich images that contain both things and relevant text and to do so across languages.</p><p>As an example, let’s use four product images from various ecommerce websites:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/639481349aed78d4139f0d388d44f79b6ba29f88-1297x650.png" style="width: 100%;" /><p>Now, let’s see how well <code>jina-embeddings-v5-omni-small</code> scores these four images for the query “ramen noodles”:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Campbell’s Chunky Chicken Noodle(Canadian packaging)</td><td style="border: 1px solid #ccc; padding: 4px;">Kraft Dinner(Canadian packaging)</td><td style="border: 1px solid #ccc; padding: 4px;">Maruchan Miso Flavour Fresh Ramen(Japanese packaging)</td><td style="border: 1px solid #ccc; padding: 4px;">Birkel Spaghetti (German packaging)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.0872</td><td style="border: 1px solid #ccc; padding: 4px;">0.0711</td><td style="border: 1px solid #ccc; padding: 4px;">0.1123</td><td style="border: 1px solid #ccc; padding: 4px;">0.0886</td>
                    </tr>
              </tbody>
            </table>
          <p>It readily finds the Japanese match.</p><p>Now, let’s try a query for “マカロニチーズ” (Japanese for <em>macaroni and cheese</em>):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Campbell’s Chunky Chicken Noodle(Canadian packaging)</td><td style="border: 1px solid #ccc; padding: 4px;">Kraft Dinner(Canadian packaging)</td><td style="border: 1px solid #ccc; padding: 4px;">Maruchan Miso Flavour Fresh Ramen(Japanese packaging)</td><td style="border: 1px solid #ccc; padding: 4px;">Birkel Spaghetti (German packaging)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.2207</td><td style="border: 1px solid #ccc; padding: 4px;">0.3487</td><td style="border: 1px solid #ccc; padding: 4px;">0.2760</td><td style="border: 1px solid #ccc; padding: 4px;">0.2674</td>
                    </tr>
              </tbody>
            </table>
          <p>It finds the correct match with the same ease as an English query.</p><p><code>jina-embeddings-v5-omni</code> also excels at interpreting information-rich images, like charts. To see this in action, look at these two bar charts:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/df5c9bf1a54f20b37b57d881b06a1c24a5ff72ac-960x1020.png" style="max-width:100%; margin-bottom:1em;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/88203c037e11b4254b39332f648cfa69985af0b2-742x455.png" style="max-width:100%; margin-bottom:1em;" /><p>Two charts, <strong>Chart 1</strong> to the left, about the global burden of disease, and <strong>Chart 2</strong> to the right, about the lifespans of dog breeds.</p><p>Let’s see how well they match two potential text questions, each relevant to one but not both charts, using <code>jina-embeddings-v5-omni-small</code> for retrieval:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Text question</td><td style="border: 1px solid #ccc; padding: 4px;">Chart 1</td><td style="border: 1px solid #ccc; padding: 4px;">Chart 2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">“What are some common medical problems for elderly people?”</td><td style="border: 1px solid #ccc; padding: 4px;">0.2787</td><td style="border: 1px solid #ccc; padding: 4px;">0.1099</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">“How long do dogs live?”</td><td style="border: 1px solid #ccc; padding: 4px;">0.1350</td><td style="border: 1px solid #ccc; padding: 4px;">0.3564</td>
                    </tr>
              </tbody>
            </table>
          <p>You can also reverse the search, using images as queries to find texts. The table below shows target documents extracted from the abstracts of topically related scientific papers and their retrieval scores, using the chart images as queries:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Text 1</td><td style="border: 1px solid #ccc; padding: 4px;">Text 2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">The health of populations living in extreme poverty has been a long-standing focus of global development efforts, and continues to be a priority during the Sustainable Development Goal era. However, there has not been a systematic attempt to quantify the magnitude and causes of the burden in this specific population for almost two decades. We estimated disease rates by cause for the world’s poorest billion and compared these rates to those in high-income populations.</td><td style="border: 1px solid #ccc; padding: 4px;">The companion dog is one of the most phenotypically diverse species. Variability between breeds extends not only to morphology and aspects of behaviour, but also to longevity. Despite this fact, little research has been devoted to assessing variation in life expectancy between breeds or evaluating the potential for phylogenetic characterisation of longevity.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Chart 1</td><td style="border: 1px solid #ccc; padding: 4px;">0.2377</td><td style="border: 1px solid #ccc; padding: 4px;">0.1357</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Chart 2</td><td style="border: 1px solid #ccc; padding: 4px;">0.0673</td><td style="border: 1px solid #ccc; padding: 4px;">0.3576</td>
                    </tr>
              </tbody>
            </table>
          <h2>Features</h2><h3>Truncatable embeddings</h3><p>We trained the backbone <code>jina-embeddings-v5-text</code> models underpinning <code>jina-embeddings-v5-omni</code> with <a href="https://arxiv.org/abs/2205.13147">Matryoshka Representation Learning</a>, so you can truncate both text and multimedia embeddings from these models.</p><p>By default, <code>jina-embeddings-v5-omni-small</code> generates embeddings with 1024 dimensions, taking 2KB to store at 16-bit precision. <code>jina-embeddings-v5-omni-nano</code>’s embeddings have 768 dimensions, taking up about 1.5KB. You can reduce the size of these embeddings down to 32 dimensions (64 bytes) at some cost to accuracy but with a large gain in processing speed and reduced resource costs. In general, reducing embedding sizes by half lowers accuracy by about 2%, down to 128 dimensions, below which accuracy falls much faster.</p><p>Truncatable embeddings allow users to decide the optimal trade-off between accuracy, speed, and cost, given their own use cases.</p><h3>Quantization</h3><p>The <code>jina-embeddings-v5-omni</code> family also inherits robust performance under quantization from its <code>jina-embeddings-v5-text</code> backbone. This further increases speed and reduces computing and storage costs by storing less precise numbers. We’ve trained them to work with <a href="https://elastic.co/elasticsearch">Elasticsearch</a>’s <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/bbq">Better Binary Quantization</a> (BBQ) to provide near-identical performance to unquantized embeddings. On the Massive Text Embedding Benchmark (MTEB) retrieval benchmark suite, binarization reduces performance by less than 3% compared to full 16-bit values, while saving 93% of the space and dramatically increasing processing and retrieval speeds.</p><h3>Cross-language performance</h3><p><code>jina-embeddings-v5-text</code>’s extensive multilingual training carries over to <code>jina-embeddings-v5-omni</code>, with nearly 100 languages in <code>jina-embeddings-v5-text-small</code>’s pretraining and 15 major global languages in <code>jina-embeddings-v5-text-nano</code>’s. For audio media, the <code>Whisper-large-v3</code> model has roughly 100 languages in its training, and the Qwen-modified SigLip2 vision models integrated in <code>jina-embeddings-v5-omni-small</code> and <code>-nano</code> were trained with data from 201 distinct languages and dialects.</p><h2>Benchmark performance</h2><h3>Text</h3><p><code>jina-embeddings-v5-omni</code> models are identical to <code>jina-embeddings-v5-text</code> models when used just for text. They’re the top performers on the <a href="https://huggingface.co/spaces/mteb/leaderboard">MMTEB benchmark</a> suite in their respective size categories for semantic text embeddings.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8687106cf82e6ec2b3f79f893cfe011e4a5b4a21-1095x1095.png" style="width: 100%;" /><p><strong>Figure 5</strong>: <code>jina-embeddings-v5-omni</code>’s size and performance on text benchmarks, compared to competing models. The cited size is without loading extensions for other media.</p><h3>Visual semantic similarity</h3><p>On standard visual semantic similarity benchmarks, <code>jina-embeddings-v5-omni</code> delivers the best scores of any model near its size. <code>jina-embeddings-v5-omni</code> models show by far the best performance for public open-weight models of comparable size. <code>jina-embeddings-v5-omni-small</code> is only beaten by a model three times its size on visual semantic similarity tasks, and <code>jina-embeddings-v5-omni-nano</code> is beaten only by <code>jina-embeddings-v5-omni-small</code> and by models 10 to 25 times larger.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f2ae56764865953bdf8c54747276189efd6d45e1-1496x877.png" style="width: 100%;" /><p><strong>Figure 6</strong>: Visual semantic similarity benchmark mean scores for <code>jina-embeddings-v5-omni-small</code>, <code>jina-embeddings-v5-omni-nano</code>, and comparable models, as well as their sizes including vision extensions.</p><h3>Visual document retrieval</h3><p><code>jina-embeddings-v5-omni-small</code> is competitive with three and seven billion parameter models while remaining under one billion parameters. <code>jina-embeddings-v5-omni-nano</code> similarly stands out for its size, beating models ten to sixty times larger.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cde672f18de8f4a30dd81938b08bb06e08444be0-1223x905.png" style="width: 100%;" /><p><strong>Figure 7</strong>: Mean <a href="https://huggingface.co/spaces/vidore/vidore-leaderboard">ViDoRe visual document retrieval</a> scores on six benchmarks: <em>DocVQA</em>, <em>InfoVQA</em>, <em>ShiftProj</em>, <em>SynAI</em>, <em>Tabfquad</em>, and <em>TatDQA</em>.</p><h3>Audio retrieval</h3><p>On the standard MAEB (Massive Audio Embedding Benchmark) audio retrieval benchmarks, both <code>jina-embeddings-v5-omni-small</code> and <code>jina-embeddings-v5-omni-nano</code> rank among the top performers. Only very large models – more than three times the size of <code>jina-embeddings-v5-omni-small</code> – beat its score.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a8c896bbbd710026c7b053e38af460194e7730a6-1650x912.png" style="width: 100%;" /><p><strong>Figure 8</strong>: Mean score for various models on the MAEB audio retrieval benchmarks.</p><p>Although LAION’s <code>larger_clap_general</code> model does improve on jina-embeddings-v5-omni-nano ‘s score while having fewer parameters, it’s an audio-only model with none of the additional multimodal features of the v5-omni suite.</p><h3>Video</h3><p>On video, <code>jina-embeddings-v5-omni-small</code> excels at finding the place in a video that matches a text query. The Charades-STA and MomentSeeker tests are the standard benchmarks for this task, and you can see from the charts below that <code>jina-embeddings-v5-omni-small</code> is the top scorer among comparable open-weight models despite a far smaller size.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a2d7744d39fefb6b8b1f66e5b3f227828ef4ad4e-1350x672.png" style="width: 100%;" /><p><strong>Figure 9</strong>: Charades-STA scores for various models, along with their sizes.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cf7578863fd509edb62c15878816d39657abc715-1550x845.png" style="width: 100%;" /><p><strong>Figure 10</strong>: MomentSeeker scores for various models, along with their sizes.</p><p>We also compared <code>jina-embeddings-v5-omni-small</code> to ByteDance&#x27;s <a href="https://seed.bytedance.com/en/blog/built-on-seed1-6-flash-seed-1-6-embedding-launched">Seed 1.6</a>, a closed-weight model with undisclosed parameter count. Our model beats Seed 1.6 by a large margin on the Charades-STA benchmark and nearly equals it on MomentSeeker.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model</td><td style="border: 1px solid #ccc; padding: 4px;">Charades-STA score</td><td style="border: 1px solid #ccc; padding: 4px;">MomentSeeker score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">seed-1.6-embedding</td><td style="border: 1px solid #ccc; padding: 4px;">29.30</td><td style="border: 1px solid #ccc; padding: 4px;">59.30</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-embeddings-v5-omni-small</td><td style="border: 1px solid #ccc; padding: 4px;">55.57</td><td style="border: 1px solid #ccc; padding: 4px;">58.93</td>
                    </tr>
              </tbody>
            </table>
          <h2>Strengths and limitations</h2><p><code>jina-embeddings-v5-omni</code> models expand users’ ability to index, search, and analyze digitized information in a number of ways, particularly:</p><ul><li>Multilingual speech retrieval from text queries.</li><li>PDF, scans, and visual document search.</li><li>Video temporal grounding, that is, identifying parts of videos that match natural language text descriptions.</li><li>Audio genre classification, including musical genres.</li><li>Image classification based on scene information and object identification.</li></ul><p>Performance is more limited in some other areas. It may be possible to use <code>jina-embeddings-v5-omni</code> to do these tasks, but we haven’t trained for them and results may be poor.</p><p>We’re actively working at improving our technology in these areas:</p><ul><li>Finding specific videos from natural language descriptions.</li><li>Image-to-image semantic similarity and retrieval.</li><li>Intent classification in speech, like recognizing verbal commands.</li><li>Processing mixed media inputs, that is, images and accompanying text, or audio, images, and texts combined.</li></ul><h2>Using <strong>jina-embeddings-v5-omni</strong></h2><p>This model suite supports input via three entry points: text, audio, and images and video together. <code>jina-embeddings-v5-omni</code> runs within a framework that converts a broad array of standard formats and does other preprocessing.</p><p>We process images using the same <a href="https://arxiv.org/abs/2502.14786">NaFlex approach</a> provided in the initial SigLip2 release: If the input is smaller than 262,144 pixels (equivalent to 512x512), it’s upscaled until it&#x27;s larger than that minimum; and if larger than 3,072,000 pixels, then it’s downscaled until it’s smaller than that maximum. The conversion process ensures that both the height and width of the image is a multiple of 14 pixels, with as little aspect ratio distortion as possible to accomplish that goal. The result is split into patches of 28x28 pixels, so the total number of patches is however many 28x28 squares are needed to cover the image. Each patch is treated like a single token at inference time, and each image input is accompanied by special start and end tokens to delimit a single image.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5ab7a8951780afdfe6eda9dca14add0375ae36bd-668x130.png" style="width: 100%;" /><p>The <code>jina-embeddings-v5-omni</code> models modify video resolution in the same way that images are modified (see above), and we extract up to 32 frames from the video. If the video has more than 32 frames (which is likely, since standard formats are usually at least 24 frames per second), we evenly space the frames we extract. Then, for every two frames, the video preprocessor generates one set of tokens equal to the number of 28x28 squares needed to cover the video.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a38491c1cc47cfc65344c786e8ab57b9abf67e0b-1999x851.png" style="width: 100%;" /><p><strong>Figure 11:</strong> <code>jina-embeddings-v5-omni</code> extracts 32 equally spaced frames from the video. If you have a long video, this means a lot will be lost.</p><p>For more details on video preprocessing, see the <a href="https://arxiv.org/abs/2502.14786">SigLip2 technical documentation</a>.</p><p>Audio tokenization follows the approach built into Qwen-2.5-Omni: Sound files are cut into 30-second segments; if longer than 30 seconds, resampled to 16kHz, transformed into a 128-channel mel-spectrogram. Each 40ms is treated as a single token, so every 30-second segment is handled as 750 tokens, one token per 40ms of audio, plus special start and end tokens to delimit a single sample.</p><p>For more details on audio preprocessing, see the <a href="https://arxiv.org/abs/2503.20215">Qwen-2.5-Omni Technical Report</a>.</p><h2>Availability</h2><p>Both <code>jina-embeddings-v5-omni-small</code> and <code>jina-embeddings-v5-omni-nano</code> are available on the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS), via the <a href="https://jina.ai/embeddings">Jina API</a>, and for local installation via download (<a href="https://huggingface.co/jinaai/jina-embeddings-v5-omni-small"><code>small</code></a> and <a href="https://huggingface.co/jinaai/jina-embeddings-v5-omni-nano"><code>nano</code></a>). Model weights are distributed freely to try out on a non-commercial license. Contact <a href="https://www.elastic.co/contact?pg=global&amp;plcmt=nav&amp;cta=205352">Elastic sales</a> for commercial use.</p><h2>Getting started</h2><p>To use <code>jina-embeddings-v5-omni</code> for text, you can integrate using the <code>semantic_text</code> field just like with <code>jina-embeddings-v5-text</code>. Just set the <code>inference_id</code> to <code>.jina-embeddings-v5-omni-small</code> or <code>.jina-embeddings-v5-omni-nano</code>. See the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text-how-tos">Reference Guide</a> for instructions.</p><p>To embed other media with <code>jina-embeddings-v5-omni</code>, you need to <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-embedding">use the inference API</a>. For example:</p><pre><code>POST _inference/embedding/.jina-embeddings-v5-omni-small
{
  "input": [
    {
      "content": { 
        "type": "image", 
        "format": "base64", 
        "value": "data:image/jpeg;base64,..." 
      } 
    }, 
    { 
      "content": { 
        "type": "text", 
        "value": "Some text to create an embedding" 
      } 
    } 
  ] 
}</code></pre><p>For <code>jina-embeddings-v5-omni-nano</code>, change the <code>POST</code> URI to <code>_inference/embedding/.jina-embeddings-v5-omni-nano</code>.</p><p>To encode documents in other media, or generate embeddings for classification or clustering, you need to <span style="text-decoration:underline"><a href="https://www.elastic.co/docs/api/doc/elasticsearch-serverless/operation/operation-inference-put-jinaai">create an inference endpoint with the </a><code>jinaai</code><a href="https://www.elastic.co/docs/api/doc/elasticsearch-serverless/operation/operation-inference-put-jinaai"> service</a></span>.</p><p>For queries, use the query builder as in the example below. Replace the <code>inference_id</code> value with <code>.jina-embeddings-v5-omni-nano</code> to use the <code>nano</code> model instead of <code>small</code>.</p><pre><code>POST my-index/_search
{
  "knn": {
    "field": "dense-vector-field",
    "k": 10,
    "num_candidates": 100,
    "query_vector_builder": {
      "embedding": {
        "inference_id": ".jina-embeddings-v5-omni-small",
        "input": {
          "type": "image",
          "format": "base64",
          "value": "data:image/jpeg;base64,..."
        }
      }
    }
  }
}</code></pre><p>See the <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query#knn-query-builder-embedding">query builder documentation</a> for more information.</p><p>To use BBQ with <code>jina-embeddings-v5-omni</code>, follow<a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/bbq"> the instructions for BBQ indexing</a>.</p><h2>More information</h2><p>For more information about <code>jina-embeddings-v5-omni</code>, see the model’s <a href="https://arxiv.org/html/2605.08384v2">technical report</a> and page on the <a href="https://jina.ai/models/">Jina AI website</a>. The <code>jina-embeddings-v5-omni</code> <a href="https://huggingface.co/collections/jinaai/jina-embeddings-v5-omni">collection page on Hugging Face</a> also contains technical information and instructions for downloading and running these models locally. The <code>jina-embeddings-v5-omni</code> models can be downloaded under a <a href="https://creativecommons.org/licenses/by-nc/4.0/deed.en">CC-BY-NC-4.0</a> license, so you’re free to try them out, but for commercial use, please contact Elastic sales.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From averages to any percentile: Elasticsearch ships native exponential histogram support in ES|QL]]></title>
            <description><![CDATA[Query any percentile at any time. Elasticsearch natively stores OTel exponential histograms and lets you analyze distributions in ES|QL without fixed buckets or lossy conversions.]]></description>
            <link>https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jonas Kunz]]></dc:creator>
            <pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f44560ece4dcc46e6a01826b597e094169e99691-848x477.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch adds native support for OpenTelemetry exponential histograms in ES|QL. Unlike fixed-bucket histograms, exponential histograms dynamically adapt to your data — giving you accurate percentile estimates (median, p99, any percentile you want) at query time with guaranteed error bounds. No more pre-defining buckets, no more lossy conversions. </p><p>Just send your OTel metrics to the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-otlp">Elasticsearch OTLP/HTTP endpoint</a> and they&#x27;re stored using the new <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/exponential-histogram">exponential_histogram</a> type and queryable immediately. Already have historical data stored in the classic histogram type? A simple ::exponential_histogram cast in your ES|QL queries handles the migration transparently. Already using <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/downsampling-time-series-data-stream">downsampling</a>? Both histogram field types are now fully supported.</p><h2>Histogram metrics</h2><p>When dealing with metrics (in OpenTelemetry or Prometheus, for instance), counters and gauges are the most common metric types. Gauges allow you to monitor values that rise or fall (e.g., CPU utilization). Counters allow you to, well, count things, such as the total number of HTTP requests your service is handling. Counters normally just increase in value, with a few exceptions when they reset, like when a server reboots.</p><p>In the case of counters, you can additionally collect a counter measuring the total sum of your HTTP response times, which allows you to derive the average response time by dividing that sum by the total number of requests. However, average response times provide limited insights into the collected data and the system behavior. The best insights are gained by analyzing the collected metric distribution, e.g., through median and percentile calculations. This is where counters fall short.</p><p>In the past, workarounds have been applied: For example, classic Prometheus-style histograms attempt to capture the distribution using a set of counters. By defining fixed buckets (e.g., one for response times in the range <code>[0s, 1s)</code>, one for <code>[1s, 4s)</code>, and so on) and associating a counter with each, we can at least estimate percentiles broadly. However, the key problem here is that we have to know the distribution of our data up front to properly define these buckets.</p><p>To that end, the OpenTelemetry community has come up with a better solution: exponential histograms. Exponential histograms assign collected values to buckets, just like classic Prometheus-style histograms. The key differentiator is that these buckets vary dynamically based on the collected values. The name &quot;exponential&quot; comes from the fact that the bucket sizes increase exponentially: we use small buckets for small values and wider buckets for larger values. You can find an excellent introduction in the <a href="https://opentelemetry.io/blog/2022/exponential-histograms/">OpenTelemetry exponential histograms introduction</a>.</p><p>Note that in addition to classic histograms, Prometheus also added <a href="https://prometheus.io/docs/specs/native_histograms/">native histograms</a>, which directly map to OTel <a href="https://prometheus.io/docs/specs/native_histograms/#opentelemetry-interoperability">exponential histograms</a>. Native histograms have their own <a href="https://prometheus.io/docs/specs/native_histograms/#promql">PromQL syntax</a>. We are actively working on adding support for that syntax to the <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Elasticsearch PromQL implementation</a>, so that you can directly query exponential histograms using PromQL.</p><h2>Demo setup</h2><p>Let&#x27;s start by collecting some histogram metrics to show how they can be stored and analyzed in Elasticsearch using ES|QL.</p><p>We&#x27;ll focus on a Java JVM metric: garbage collection durations. OpenTelemetry defines the <a href="https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmgcduration">jvm.gc.duration</a>, which is a histogram-typed metric. The <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation">OpenTelemetry Java agent</a> natively supports collecting this metric.</p><p>We&#x27;ll spin up a JVM running a <a href="https://renaissance.dev/">Renaissance benchmark</a> to put it under stress. We&#x27;ll start that JVM with the vanilla OpenTelemetry Java agent attached and have it send the metrics directly to Elasticsearch.</p><p>You can find the ready-to-run Docker-compose file <a href="https://github.com/JonasKunz/es-histogram-demo">here</a>. You&#x27;ll just need to insert your <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-otlp">Elasticsearch OTLP/HTTP endpoint</a> and API key in the <code>docker-compose.yml</code>:</p><pre><code>OTEL_EXPORTER_OTLP_ENDPOINT: https://<elasticsearch url>/_otlp
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey <base64 API key>"</code></pre><p>Note that you don&#x27;t have to use this demo setup. We even encourage you to try it with your own application. Here are the other important OpenTelemetry agent settings the demo already includes, which you should include too if you&#x27;re bringing your own app:</p><pre><code>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM
OTEL_INSTRUMENTATION_RUNTIME_TELEMETRY_ENABLED: "true"</code></pre><p>Let&#x27;s step through them:</p><ul><li><em>Temporality preference</em>: OpenTelemetry supports both cumulative and delta-based histograms. Cumulative means that the histogram is only cleared after an application restart, while delta clears it after each export. At the time of writing, Elasticsearch only supports delta temporality for histograms. We are actively working on supporting cumulative histograms as well.</li><li><em>Default Histogram Aggregation</em>: By default, OpenTelemetry exports histograms in the Prometheus-style fixed bucket format. Since we want to reap the benefits of exponential histograms, we tell the agent to use them instead.</li><li><em>Runtime Telemetry enabled</em>: This tells the agent to actually collect the detailed JVM metrics, which include <code>jvm.gc.duration</code>.</li></ul><p>Now we are ready to go! We&#x27;ll let the application run in the background and switch over to Kibana to analyze the GC metric.</p><h2>Querying with ES|QL</h2><p>Now let&#x27;s open up Kibana and navigate to &quot;Discover&quot;. There we&#x27;ll switch to <a href="https://www.elastic.co/docs/explore-analyze/discover/try-esql">ES|QL mode</a>, and start querying the collected data:</p><pre><code>TS metrics-* | STATS COUNT(jvm.gc.duration)</code></pre><p>As a response, we now see the metric panel shown below. If you don&#x27;t see any data, make sure to double-check the Kibana <a href="https://www.elastic.co/docs/explore-analyze/query-filter/filtering#set-time-filter">time range filter</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b863b2e272ac6584ac193661a6c4419abffdd243-729x190.png" style="width: 100%;" /><p>This number represents the total number of garbage collection operations that happened in our test application during the selected time frame.</p><p>Similarly, we can query the total time spent on those garbage collection operations:</p><pre><code>TS metrics-* | STATS SUM(jvm.gc.duration)</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/eda37c5fa244a42258bb452d18f5cbab3ff76eaf-717x190.png" style="width: 100%;" /><p>So we have roughly 270k garbage collections, which in total took 713 seconds. Given these two numbers, we can now compute the average if we are still fluent in primary school-level math. Even if not, you can just let ES|QL do that for you:</p><pre><code>TS metrics-* | STATS AVG(jvm.gc.duration)</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9fc5d601604b05378beeb4e6e94b613f95fe2fbc-712x188.png" style="width: 100%;" /><p>Now we know that the average garbage collection operation took about 3 milliseconds. However, Java experts might know that there are different kinds of garbage collections happening, which can have significantly different pause times. Fortunately the OpenTelemetry metric comes with attributes, which allow us to slice the data accordingly:</p><pre><code>TS metrics-* | STATS AVG(jvm.gc.duration) BY jvm.gc.action</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/535d44cdb2ec3ed2ac9afe6b259d7b69c9167bbd-989x476.png" style="width: 100%;" /><p>As expected, major garbage collections take a lot more time per collection than minor ones, at least on average. So far, we have done nothing you couldn&#x27;t also achieve by just using counters. Let&#x27;s now use histograms to understand the actual distribution of the GC latency. We&#x27;ll look at the data over time (by grouping using <code>TBUCKET</code>) and focus on the major garbage collections:</p><pre><code>TS metrics-* 
| WHERE jvm.gc.action == "end of major GC"
| STATS MAX(jvm.gc.duration),
        PERCENTILE(jvm.gc.duration, 99),
        MEDIAN(jvm.gc.duration),
        MIN(jvm.gc.duration)
 BY TBUCKET(100)</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ce62a4498d5a6e3fcc3bc85ea78f45a18f6e7576-1016x477.png" style="width: 100%;" /><p>The graph now shows us the minimum, maximum, median and 99th percentile for major garbage collections. Note that we aren&#x27;t bound to only querying the median and the 99th percentile. We can query any percentile we&#x27;d like to see, as these are estimated at query time from the raw exponential histograms.</p><h2>A note on backwards compatibility</h2><p>So far, we have seen how you can use the new shiny toy in Elasticsearch and ES|QL: exponential histograms. However, since this has just reached general availability (GA) in the 9.4 release, what about your historical data?</p><p>Before exponential histograms were added, Elasticsearch was already capable of storing OpenTelemetry histograms in the <code>histogram</code> field type. To do so, we converted them to a different data structure supported by the <code>histogram</code> field type: <a href="https://github.com/tdunning/t-digest/blob/main/docs/t-digest-paper/histo.pdf">T-Digest</a>. T-Digest provides good accuracy for extreme percentiles (e.g., 99th percentile) at the cost of accuracy for percentiles in the middle of the distribution, such as the median. In contrast, exponential histograms provide a guaranteed upper bound on the relative error for every percentile. As conversions always introduce errors, we are happy to now have native support for exponential histograms, allowing you to collect and analyze your metrics end-to-end without unnecessary conversions.</p><p>But still, what should you do if you have historical data and still want to query it? Thanks to <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-multi-index#esql-multi-index-union-types">ES|QL union types</a>, the answer is actually easy: You just have to add a <code>::exponential_histogram</code> suffix to the histogram metrics in your queries:</p><pre><code>TS metrics-* | STATS AVG(jvm.gc.duration::exponential_histogram)</code></pre><p>When this query encounters <code>histogram</code> fields, it will attempt to convert them to exponential histograms. When operating on <code>exponential_histogram</code> fields, the <code>::exponential_histogram</code> cast has no effect. Note that this also works with mixed data sets: if your backing indices use both types, the query will just do the right thing.</p><p>So if you are building queries or dashboards that you expect to run on pre-9.4 ingested data, we recommend that you simply add: <code>::exponential_histogram</code> casts.</p><h2>Wrapping up</h2><p>Native support for OpenTelemetry exponential histograms in Elasticsearch gives you better metric fidelity and more flexible analysis in ES|QL. In this blog post, we have shown you how to easily ingest and analyze your histogram metrics with ES|QL using various aggregations and the impact exponential histograms have.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/exponential-histogram">Exponential histograms</a> are <strong>generally available</strong> in Elasticsearch basic starting with the 9.4.0 release. They will be available in Elastic Cloud <a href="https://www.elastic.co/cloud/serverless">Serverless</a> a few weeks after the 9.4.0 release, once <a href="https://www.elastic.co/docs/reference/opentelemetry/motlp">mOTLP</a> (the managed observability OTLP intake) switches to use the Elasticsearch OTLP endpoint. We&#x27;ll update this blog post and add a note on the Elastic Cloud Serverless release notes when that happens.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What’s new in Kibana Dashboards: flexible controls, improved defaults and a visual refresh]]></title>
            <description><![CDATA[Kibana 9.4 ships a batch of dashboard improvements — from better defaults to layout controls to a visual refresh. Here's what changed and why.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-dashboards-improvements</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboards-improvements</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Teresa Alvarez Soler]]></dc:creator>
            <pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fea993e3578d858b5c4f95244df052b1caeb8409-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Building a time series in Kibana Dashboards used to take 14 clicks. In 9.4, it takes three. Beyond the headline features in this release — a <a href="https://dashboardsapispec.kibana.dev/dashboards.html#tag/Dashboards">brand new API</a> and <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-dashboards-and-visualizations">natural-language dashboard creation with the AI Chat </a>— we are also shipping a collection of focused changes: improved defaults, freely placed controls, a cleaner visual design. Together, they compound into dashboards faster to set up and easier to scan.</p><h2>Place controls anywhere in the dashboard</h2><p>Most dashboarding tools lock filters into a fixed bar at the top of the page. Kibana 9.4 breaks that constraint: <strong>controls are now regular panels</strong> you can drag anywhere to place them where you need them. <strong>Pin them</strong> to the top so they stay visible while you scroll, or place them inside a <strong>collapsible section</strong> where they automatically <strong>scope their filters to that section only</strong> — so a single dashboard can serve multiple use cases without one filter resetting all the panels.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f0dc157853f6ca8ef3c66c163b80407cf91acf75-1672x814.gif" style="width: 100%;" /><h2>Fewer clicks to a readable chart</h2><p>Building a time series in Kibana Dashboards used to take 14 clicks. Now, it takes three. The new defaults give you a <strong>line chart</strong> with a <strong>bottom legend</strong> and cleaner <strong>x-axis labels</strong> — the way you&#x27;d configure them by hand. A right-hand legend still makes sense past ten series, but for fewer, the bottom list avoids the empty margin a side legend leaves behind. For top-value breakdowns, the default jumps <strong>from three series to nine</strong> — matching how many distinct colors the eye can parse at once — and the <strong>colors</strong> themselves are darker and more separated, so series stand out at a glance</p><p><em>Before (bar chart by default, legend to the right, redundant x-axis label):</em></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b4d0b0b181ed4007918762a549b0a95dc9abe5c7-876x340.png" style="width: 100%;" /><p><em>Now (line chart by default, list legend at the bottom, more contrasted color palette for lines readability):</em></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/de06a5c7879d4df8f4095e489f98d9ecc24b23b4-867x340.png" style="width: 100%;" /><h2>A calmer look for dense dashboards</h2><p><strong>Dense dashboards</strong> in most tools still look like a wall of boxes. We redesigned Kibana&#x27;s dashboard visuals to feel <strong>flatter</strong> and <strong>more cohesive</strong> — more like a single page than a grid of disconnected panels. See some of these changes highlighted in the picture below compared to what the same dashboard looks like in 9.3.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/36c04a1a68aed706ca9eba38aa073e6dacef7cb6-1448x1556.png" style="width: 100%;" /><h2>Other improvements</h2><h3>Collapse filter pills</h3><p>Another common filtering approach is using <strong>filter pills</strong> at the top of the dashboard. When you stack many pills, they used to consume <strong>vertical space</strong>; they now <strong>scroll</strong> automatically after a threshold, and you can <strong>collapse</strong> the row to reclaim the space.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3d92a22759326688bce759c26eb4cc81efc28c80-1428x660.gif" style="width: 100%;" /><h3>Closing the gap for ES|QL panels</h3><p>Elasticsearch Query Language <strong>(ES|QL) panels </strong>are closer to parity with the rest of dashboards:<strong> Drilldowns </strong>between dashboards work for ES|QL visualizations, and <strong>click-to-filter </strong>from the chart keeps improving. Upcoming releases aim to close remaining gaps, such as annotations and saving ES|QL visualizations to the library.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/319a69d3174d5b6bdaa6279304471fcc359e98a3-1501x789.png" style="width: 100%;" /><h3>Panel size visibility</h3><p>Before 9.4, it was hard to match panel height and width because there was no readout for panel size. <strong>Panel sizing</strong> is now shown while you resize, so you can align dimensions across panels for a more harmonious layout.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4cb6ae2a09c451b1bc0c4101e6bd613b6c298888-1726x856.gif" style="width: 100%;" /><h3>Easier section reordering</h3><p><strong>Collapsible sections</strong> picked up usability fixes, too: You can reorder sections while they’re expanded, and you can drag a section from its header without hunting for the small drag handle.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/db6b114fd84243d64c12746d90c4e337b62038d5-1724x856.gif" style="width: 100%;" /><h3>ES|QL support for Vega visualizations</h3><p><a href="https://www.elastic.co/search-labs/blog/kibana-vega-building-advanced-visualizations">Vega</a> lets you build custom visualizations beyond what built-in chart types — radar charts, chord diagrams, or anything the grammar supports. The hardest part has always been the data query: writing verbose Elasticsearch Query DSL with nested aggregations and format paths just to feed data into the chart. <a href="https://www.elastic.co/docs/explore-analyze/visualize/custom-visualizations-with-vega#vega-esql-queries">Vega now accepts ES|QL</a> as a data source, so you can get the data with a single readable query. Less time wiring up the data, more time on the visualization itself.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/63d1c19f61f7dead135fe9cad3fd8411590fd037-1606x1033.png" style="width: 100%;" /><h2>What’s next</h2><p>This is just the start. Upcoming releases will keep pushing on smarter defaults and closing the gap between ES|QL and data-view visualizations so the two feel interchangeable.</p><p>Got a pain point or a feature request? Hit <strong>Submit feedback</strong> in the top menu — we&#x27;re listening.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/60c0861ade03a764ddb4d3c4a86af31a7dea02ed-1490x634.png" style="width: 100%;" /><h2>How to try it</h2><p>If you use <strong>Elastic Cloud Serverless</strong>, you may already be on these changes. Otherwise, <strong>upgrade to 9.4</strong> and then <strong>create a dashboard</strong> or <strong>open an existing one</strong>. Many updates apply <strong>automatically to new visualizations</strong>, while <strong>layout</strong> and <strong>panel</strong> options appear in <strong>edit</strong> mode. If you aren’t on <strong>Elastic Cloud</strong> yet, <a href="https://cloud.elastic.co/registration"><strong>start a trial</strong></a> and explore the latest <strong>Kibana</strong> Dashboards there.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[30x faster than Prometheus: How we rebuilt Elasticsearch as a leading columnar metrics datastore]]></title>
            <description><![CDATA[Elasticsearch now stores OTel metrics at 3.75 bytes per data point and queries them up to 30x faster than Prometheus. Here's how we rebuilt TSDS and ES|QL.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Kostas Krikellas, Martijn Van Groningen, Nhat Nguyen, Felix Barnsteiner]]></dc:creator>
            <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9c0a835962fc8db27d382b654db787045fb3e4da-1376x768.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch now stores OTel metrics at <strong>3.75 bytes per data point</strong> — down from 25 bytes a year ago — and queries them up to <strong>30x</strong> faster and with up to <strong>2.5x</strong> better storage efficiency, compared to <strong>Prometheus</strong>, <strong>Mimir</strong> and <strong>ClickHouse</strong>. These gains came from rebuilding <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">TSDS</a> storage and the ES|QL compute engine into a <strong>fully columnar metrics engine</strong>, with native OTel ingestion added as part of the effort — all while keeping Elasticsearch&#x27;s ability to store and query logs, traces, and any other data alongside metrics.</p><p>Elasticsearch has supported storing metrics in time-series data streams (<a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">TSDS</a>) since <strong>version 8.7</strong>. This offering mainly focused on storage gains as explained in an earlier <a href="https://www.elastic.co/search-labs/blog/time-series-data-elasticsearch-storage-wins">blog post</a>. Still, performance was not on par with specialized systems for storing and querying metrics, in terms of storage efficiency, indexing throughput and query latency.</p><p>In the past year, we revisited the storage layer, optimized ingestion for OTel metrics and extended the ES|QL compute engine with vectorized processing for time series data. These efforts led to substantial performance wins across the board, compared to earlier versions of TSDS:</p><ol><li>Up to <strong>6.6x</strong> improvement in storage efficiency, reaching 3.75 bytes per data point in OTel metrics</li><li>Up to <strong>50%</strong> improvement in indexing throughput for OTel data</li><li>Up to <strong>160x</strong> improvement in query latency, including blazing fast counter rate evaluation and window support in time series aggregations</li></ol><p>Elasticsearch has thus become a <strong>leading columnar metrics engine</strong>, matching or exceeding the competition (like <strong>Prometheus</strong>, <strong>Mimir</strong>, and <strong>ClickHouse</strong>) in indexing throughput and exceeding it by up to <strong>2.5x</strong> in storage efficiency and <strong>30x</strong> in query performance. All while maintaining the ability to store logs and other data and fully use the rich querying capabilities of ES|QL (e.g. <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/inlinestats-by">inline stats</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join">lookup join</a>) — which other PromQL-based systems lack. Elasticsearch can thus serve as a unified storage and query engine for all user data, with no compromises for metrics and observability applications.</p><h2>How TSDS is organized</h2><p>TSDS has the following properties that help improve the performance of time-series codecs and produce correct results when aggregating data points per time series:</p><ul><li>The <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-metric">metric</a> name and the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-dimension">dimension</a> names and values are used to calculate the <code>_tsid</code>, a unique identifier per time series.</li><li>TSDS get sorted by <code>[_tsid ascending, timestamp descending]</code> order. Each time series is thus stored in sequence on disk, with newer data points appearing first. Since the <code>_tsid</code> is calculated over dimension values, the latter are also clustered on disk.</li><li>Shard routing is based on <code>_tsid</code>, with each <code>_tsid</code> value appearing in one shard only.</li><li>Backing indices are <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-bound-tsds">time-bound</a>, with no overlap over time between them.</li></ul><p>The rest of this post explains how we use these properties to improve storage, indexing, and query performance.</p><h2>Storage optimizations</h2><p>TSDS <a href="https://www.elastic.co/search-labs/blog/time-series-data-elasticsearch-storage-wins">already</a> achieved a very competitive storage footprint, reaching <strong>0.9 bytes per data point</strong>, when it is possible to combine many metrics in a single doc, sharing the same dimension values. However, when most data points have a unique set of dimensions (which is typical for OTel or Prometheus metrics), docs end up containing a single data point. In this setup, storage required 25 bytes per data point, with dedicated metrics stores requiring less than 10 bytes per data point.</p><p>To further reduce the storage footprint, we applied a series of optimizations over the past year:</p><h3>Replace inverted indices and BKD trees with doc value skippers</h3><p>Elasticsearch creates inverted indices (for text values) or BKD trees (for numeric values) by default for all non-metric fields, i.e. for <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams#backing-indices">@timestamp</a> and dimensions. These indices improve performance for queries including filters on these fields, but have significant impact to storage — effectively doubling the footprint for each field. More so, they are also processed during <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/merge">segment merging</a>, increasing the cpu, memory and storage overhead and slowing down the system — especially in high ingest throughput scenarios, as is often the case with metrics.</p><p>Lucene has been extended with <a href="https://lucene.apache.org/core/10_1_0/core/org/apache/lucene/index/DocValuesSkipper.html">doc value skippers</a>, a form of hierarchical sparse indices that store the minimum and maximum value of blocks of documents. Range queries can check these min and max values and ignore blocks that don&#x27;t fall into the requested range. Skippers work particularly well on sorted fields. Since TSDS are sorted by <code>[_tsid, timestamp desc]</code>, dimension values get also clustered on disk. It&#x27;s therefore possible to replace indices on <code>@timestamp</code> and dimension fields with doc value skippers that <strong>amplify the columnar layout</strong> — each field stored in its own files, with no duplicate tracking of each doc for indexing purposes.</p><p>Doc value skippers have negligible storage overhead — replacing indices with them led to a reduction of <strong>10 bytes</strong> out of the initial 25 bytes per data point in OTel. Moreover, they work very well in practice when queries include filters on time ranges or dimension values (including prefixes and regex) — there was no noticeable regression in query performance in our benchmarks when they replaced separate indices. Doc value skippers are enabled for TSDS by default since <strong>version 9.3</strong>.</p><h3>Enable synthetic IDs</h3><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-id-field"><code>_id</code></a> metadata field was another big contributor to the storage footprint. TSDS has already been extended to trim the doc values once they were no longer needed for replication, but the inverted index was kept around to efficiently support the id-based APIs (<a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-get">Get</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-delete">Delete</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-update">Update</a>).</p><p>The ID value for TSDS is synthesized by combining the <code>_tsid</code> and <code>@timestamp</code> values that uniquely identify each data point. Since these fields are configured with doc value skippers, it&#x27;s possible to replace the inverted index on <code>_id</code> with (a) retrieval of the <code>_tsid</code> and <code>@timestamp</code> value from the <code>_id</code> value, and (b) checks for matches using doc value skippers respectively. Care has to be taken to avoid expensive checks for duplicate IDs during metric ingestion, with segment-level bloom-filters keeping the overhead at bay.</p><p>Supporting synthetic IDs in metrics is a first for Elasticsearch. It led to a reduction of <strong>5 bytes</strong> out of the initial 25 bytes per data point for OTel metrics, with no loss of functionality. Synthetic IDs are enabled for TSDS by default in <strong>version 9.4</strong>. We plan to extend their uses in logs and other applications after further evaluation.</p><h3>Trim sequence numbers</h3><p>Sequence numbers are used as part of replication, but also to provide strong consistency semantics on doc modification operations through <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/optimistic-concurrency-control">Optimistic Concurrency Control</a> (OCC). While such semantics are applicable to certain scenarios, they don&#x27;t fit in metrics where concurrent updates are very rare, with no practical need for guarding against concurrent operations on data points with matching ids. We therefore decided to <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules#index-disable-sequence-numbers">disable the use of sequence numbers</a> in all APIs, along with OCC support, for TSDS, in <strong>version 9.4</strong>. This leads to a substantial storage reduction of <strong>4 bytes</strong> out of the initial 25 bytes per data point for OTel data, as there&#x27;s no inverted index and sequence numbers get trimmed once no longer needed for replication. <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/update-by-query-api">Update</a> and <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-delete-by-query">delete</a> by query operations are still supported, albeit with weaker consistency semantics.</p><p>If OCC is still deemed important for a particular metrics application, the old behavior can be restored by setting <code>index.disable_sequence_numbers: false</code> in the index template of the involved TSDS.</p><h3>Use large numeric codec blocks</h3><p>TSDS already uses an advanced codec, as explained in an earlier <a href="https://www.elastic.co/search-labs/blog/time-series-data-elasticsearch-storage-wins#specialized-codecs">article</a>. The codec works very well in most cases, but has poor performance in case of repeated sequences of keywords and numbers, leading to an inflated storage footprint for dimensions containing IP and MAC addresses. We identified that the existing logic for identifying repeated sequences requires larger codec blocks to work well, especially as the sequence length increases. After experimentation, the numeric block size was increased from 128 to 512 elements in <strong>version 9.3</strong>, leading to a reduction of <strong>2 bytes</strong> out of the initial 25 bytes per data point for an OTel dataset containing IP and MAC addresses as dimensions. We&#x27;re also working on a more configurable codec layout that will allow more flexibility with block sizes and other parameters, based on field type and cardinality.</p><h2>Indexing throughput</h2><p>Elasticsearch has support for bulk ingestion of documents. This entrypoint has long been optimized for leniency, ensuring that all docs get accepted. This flexibility, however, incurs additional processing cost during indexing. Metric applications proved good candidates for using different approaches to reduce this overhead, as explained below.</p><h3>Introduce OTLP protobuf entrypoint</h3><p>OTel metrics and Prometheus have established protocols for metrics ingestion, using protocol buffers. In the past, a translation step was required to convert collected protobuf messages to bulk requests that Elasticsearch can consume.</p><p>Elasticsearch was recently extended with endpoints accepting messages from OTel metrics collectors and over Prometheus remote write. Parsing and processing these (binary) messages is cheaper, compared to json parsing, while hash operation over dimensions for <code>_tsid</code> calculations get reused and amortized across more data points within a single protobuf message. Furthermore, <code>_tsid</code>s get evaluated once per doc in the coordinator nodes and propagated to data nodes for indexing, thus deduplicating an expensive step per indexed doc. These improvements led to up to a 20% speedup in indexing throughput for OTel metrics. The OTLP entrypoint was added in version 9.2 (tech preview) and reached GA in <strong>version 9.3</strong>. We&#x27;ve added similar entrypoints for <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch">Prometheus remote write</a> in <strong>version 9.4</strong> (tech preview) and are actively working to cover OTel Logs and Traces.</p><h3>Reduce indexing CPU with doc value skippers</h3><p>In addition to a substantial storage footprint, inverted indices require a lot of cpu to build and reconstruct during segment merging. The use of doc value skippers in their place helps also reduce cpu load at ingestion and thus improves indexing throughput by 10%, a welcome bonus on top of the aforementioned storage wins.</p><h3>Synthetic recovery source</h3><p>The original <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field">source</a> of a document, as provided at index time, is never stored for metrics. Still, Elasticsearch needed to temporarily store it for replication purposes. That changed in <strong>version 9.1</strong>, where the source gets synthesized on demand for replication purposes. This is known as synthetic recovery source and reduces disk I/O by 50%, with a significant impact to metrics indexing performance. Check out this <a href="https://www.elastic.co/search-labs/blog/elastic-logsdb-tsds-enhancements">article</a> for more details.</p><h2>Query execution</h2><p>Replacing inverted indices with doc value skippers leads to a pure columnar storage layout for TSDS, with metric and dimension fields stored as Lucene doc values, each field encoded and compressed in their own file. Combined with the introduction of the <a href="https://www.elastic.co/blog/elasticsearch-query-language-esql#dedicated-query-engine">ES|QL compute engine</a> that uses vectorized execution internally, it became possible to introduce a fully columnar storage and query processing engine for metrics in Elasticsearch. We pushed this idea to the extreme and implemented a <strong>columnar metrics processing engine</strong> that comfortably outperforms dedicated metrics engines and other columnar stores in query performance.</p><h3>Time series integration in compute engine</h3><p>Time series processing is largely based on applying aggregation functions per time series (or <code>_tsid</code>), such as a <a href="https://opentelemetry.io/docs/specs/otel/metrics/data-model/#gauge">gauge</a> average or a <a href="https://opentelemetry.io/docs/specs/otel/metrics/data-model/#sums">counter</a> rate. These partial results are then reduced by a secondary function to produce results for the grouping dimensions, e.g. per host and process. Observability dashboards are built on top of this execution model, providing summary views of how metrics evolve over time and allowing for quick deep-dives by filtering on dimension values and time ranges.</p><p>To support this execution model, we introduced the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts#description">TS source command</a>, providing a simple yet powerful syntax for executing such queries that combine an inner aggregation function per time series with an outer aggregation over the partial results of the former. For instance, the following query calculates the hourly sum of rate of search requests per host over the last day:</p><pre><code>TS metrics
  | WHERE TRANGE(1d)
  | STATS SUM(RATE(search_requests)) BY TBUCKET(1h), host</code></pre><p>To execute this query, the compute engine is aware of how data is stored and applies the inner aggregation function per <code>_tsid</code> value. Since data are sorted by <code>_tsid</code>, time series aggregation functions process metric values as they get fetched, until the <code>_tsid</code> changes or the timestamp belongs to the next time bucket. This leads to vectorized execution of these functions over the fetched columns of metric values, while dimension values are only fetched (once) when the <code>_tsid</code> changes. The evaluation of the secondary aggregation function is also efficient, with partial aggregates stored in arrays of primitive values that get populated when <code>_tsid</code> values change.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8a6987ff92c80ac813167c9aa655d2ec4dd1bd50-1024x559.png" style="width: 100%;" /><p>The compute engine has inherent support for parallel query evaluation, taking full advantage of the available processing cores. Time series aggregations fully use this feature and process data points in parallel as applicable, reducing response times through improved cpu utilization.</p><p>Time series processing in ES|QL was introduced in version 9.2 as tech preview and reaches GA in <strong>version 9.4</strong>. We expect all metrics applications to adopt it and benefit from the much improved query performance wins.</p><h3>Zero-copy data decoding and loading</h3><p>Vectorized processing of time series data delivered immediate performance wins (<strong>8x</strong> for some queries), compared to aggregations through the <code>/_search</code> API, but the performance was still inferior when compared to competitive metrics stores. Benchmarking and profiling showed that there were too many array copies within the compute engine, between data decoding and evaluation of aggregation functions. To that end, the following optimizations were introduced:</p><ul><li>The codec for TSDS was extended to decode on-disk data directly into primitive arrays inside blocks that the compute engine uses to evaluate time series aggregations. No additional copies required, as the compute engine can bulk-read these blocks and process their arrays, one column at a time.</li><li>Blocks containing a single value N times are represented as constant blocks with these 2 values, as opposed to an array with length N, a form of in-memory run-length encoding. Filtering and aggregation operations were extended to efficiently consume these blocks. This reduced memory pressure and cpu overhead for the <code>_tsid</code> and dimension fields, as their values get clustered due to index sorting.</li><li>Documents with null values for the aggregated metric fields are filtered out at the Lucene level, before they get decoded and copied into blocks.</li><li>All filters and regular expressions on the timestamp and dimension fields get pushed down to Lucene that makes use of doc value skippers to efficiently filter out non-matching docs.</li></ul><p>Combined, these optimizations led to query execution speedups exceeding <strong>10x</strong> (totaling 80x when combined with the 8x speedup from vectorized execution). They were included since the introduction of the TS source command in <strong>version 9.2</strong>, and fine-tuned ever since.</p><h3>Optimized counter rate evaluation</h3><p>While most time series aggregations can be trivially parallelized and evaluated, rate evaluation of cumulative counters is tricky as it requires processing all data points in order to detect counter resets (e.g. when a host restarts). To address this, the compute engine uses the <code>_tsid</code> prefix to shard time series across threads. Care has been taken to assign in-order ranges of <code>_tsid</code> values to each thread, as opposed to hash-partitioning <code>_tsid</code>s, so that each thread can scan on-disk data in order, still making use of efficient decoding and zero-copying into blocks. The performance wins are impressive, with rate evaluation performance far exceeding dedicated metrics stores as we shall see in the next section.</p><p>Another interesting problem for cumulative counters is how to properly calculate counter increases for the entire time bucket when there are no data points at the bucket boundary timestamps. Metrics systems often use extrapolation, extending the first and last data points of each time bucket to the boundaries, or calculate the delta between the last data point of adjacent buckets. We posit we can do better, by interpolating between the last data point of each bucket and the first of the next, to get an estimate of the value on each boundary. The delta is then calculated over the interpolated values of the lower and upper boundary of each time bucket.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b09313b3359360dc3d6117c436dff05ca6de3653-1360x1520.png" style="width: 100%;" /><h3>Sliding window support</h3><p>Elasticsearch has long supported aggregations bucketed by time, but it was not possible to extend the window of processed data beyond each time bucket. Using windows larger than the time bucket, e.g. a window of 5 minutes for per-minute bucketing, helps smoothen out spikes and observe the underlying trend per time series with reduced noise:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/66c96d577a9a76f7d5a0cc7df3d121f38fe018bd-1999x1014.png" style="width: 100%;" /><p>All <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">time-series aggregation functions</a> have been extended with window support, as an optional argument. In case the window is a multiple of the time bucket (e.g. 1h window with <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/grouping-functions/tbucket">TBUCKET</a><code>(5m)</code>), the compute engine first aggregates data points over intervals matching the time bucket span, and then combines these partial results per window span. This 2-phase approach eliminates repeated scans of data points and makes optimal reuse of intermediate results, improving response times. Window support was introduced as tech preview in version 9.3 and reaches GA in <strong>version 9.4</strong>.</p><h3>Efficient datetime rounding</h3><p>Queries on time-series data commonly include time bucketing. While data points can be trivially assigned to sub-hour time buckets, larger buckets start interfering with issues like time zones, daylight savings, variable days per month etc. Elasticsearch has elaborate logic for datetime rounding that takes these peculiarities into account, but that has relatively high cpu cost when processing time series data.</p><p>To mitigate this, the compute engine has been extended to identify cases where simpler logic can be employed to assign data points to time buckets. For instance, it can identify when the buckets are sub-hour or when timezones and daylight savings don&#x27;t affect a particular query, and switches to simple modulo operations for datetime rounding. This led to a further <strong>30%</strong> improvement in response times for certain queries. This change is introduced in <strong>version 9.4</strong>.</p><h2>Performance evaluation</h2><p>To evaluate the performance of our offering and track how it evolves and improves over time, we focused on OTel metrics since (a) Open Telemetry is the industry standard for collecting metrics, with universal adoption by all cloud providers and (b) they lead to a storage layout with 1 metric per doc, a setup that traditionally hurt performance for Elasticsearch.</p><p>We rely on <a href="https://github.com/elastic/metricsgenreceiver">Metricsgenreceiver</a> to generate datasets. This tool is inspired by <a href="https://github.com/timescale/TSBS">TSBS</a>, producing data simulating the data points collected by the OTel <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/hostmetricsreceiver#readme">hostmetricreceiver</a>. We used two datasets:</p><ol><li>A low-cardinality setup, with 100 hosts sending metrics every 1s, containing 14k time series in total</li><li>A high-cardinality setup, with 10k hosts sending metrics every 10s, containing 1.4M time series in total</li></ol><p>We benchmarked on single-node deployments on EC2, using <a href="https://aws.amazon.com/ec2/instance-types/c6i/">c6i.4xlarge</a> and <a href="https://aws.amazon.com/ec2/instance-types/c8g/">c8g.8xlarge</a> machines for the low- and high-cardinality datasets respectively.</p><p>For competitive comparison, we used Prometheus (v.3.11.1), Mimir (v.3.0.6.) and ClickHouse (v26.3.9.8-lts). Prometheus and Mimir have proper time series processing, e.g. for counter rate, whereas ClickHouse <a href="https://clickhouse.com/docs/use-cases/time-series/analysis-functions">lacks such support</a> and only provides approximate values at best (for instance, it can&#x27;t track counter resets consistently). We still report response times for ClickHouse to showcase that, once we optimize Elasticsearch for columnar query processing, it can exceed competing columnar engines even when they don&#x27;t process the data per time series as expected.</p><p>We strived to use the default configuration for every system (including Elasticsearch), without tweaking them to optimize performance for the particular workload. This helps understand the user experience when systems are deployed by novice users, without much experience (or time) to tweak before receiving metrics traffic and setting up dashboards. We focused on single-node runs to keep noise low and accommodate all systems (Prometheus doesn&#x27;t offer a multi-node setup out of the box). Elasticsearch performance provably scales well with the number of nodes; we plan to share scalability results in a future post.</p><h3>Storage efficiency and indexing throughput</h3><p>Our efforts to improve storage efficiency paid big dividends. Performance on OTel metrics dropped <strong>from 25 to 3.75</strong> bytes per data point, in a year. Such an improvement, on top of an offering already optimized for time series, is really impressive and very rare in the industry.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8b3d185e252f427b4753aa93f173f1fe0930acbf-1999x364.png" style="width: 100%;" /><p>The competitive picture looks favorable at this point, with Elasticsearch:</p><ul><li>Slightly outperforming Mimir in storage efficiency and indexing throughput</li><li>Outperforming Prometheus by 2.5x in storage efficiency and by a small margin in indexing throughput</li><li>Outperforming ClickHouse by 2x in storage efficiency and by 40% in indexing throughput</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a4075e617142de780400f0c318b9d1fa99e4e7a8-1187x851.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/068fb9a3c1c0d8b23b3d34ae2f8ffe968dd71d30-1221x851.png" style="width: 100%;" /><h3>Query performance</h3><p>The novel columnar engine for metrics processing proves very efficient in practice. We used a mix of queries based on gauge averages and counter rates, the most common operations that require different optimization approaches. The queried interval was 4 hours of data, covering all time series per metric.</p><p>ClickHouse doesn&#x27;t support time series aggregations, so the results have limited value and are not directly comparable to Prometheus or Mimir that natively support time series processing. We used the published <a href="https://clickhouse.com/docs/use-cases/time-series/analysis-functions">guidelines</a> to adjust each query to get similar results to the extent possible. The point is to show how our columnar engine compares to generic columnar stores.</p><p>Here is a summary of the results:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query type</td><td style="border: 1px solid #ccc; padding: 4px;">Mimir</td><td style="border: 1px solid #ccc; padding: 4px;">Prometheus</td><td style="border: 1px solid #ccc; padding: 4px;">ClickHouse †</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Gauge average</td><td style="border: 1px solid #ccc; padding: 4px;">up to 30x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 30x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 8x faster</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Counter rate</td><td style="border: 1px solid #ccc; padding: 4px;">up to 30x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 30x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 3.5x faster</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Prefix filter on host name</td><td style="border: 1px solid #ccc; padding: 4px;">up to 5x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 5x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 3x faster</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Gauge average with window</td><td style="border: 1px solid #ccc; padding: 4px;">up to 25x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 25x faster</td><td style="border: 1px solid #ccc; padding: 4px;">up to 4x faster</td>
                    </tr>
              </tbody>
            </table>
          <p>†ClickHouse lacks native support for time series aggregations and counter reset detection.</p><h4>Gauge average</h4><p>We compared performance of evaluating the per-host hourly average of average memory utilization per time series, using the following queries:</p><pre><code># PromQL
avg by (host.name) (avg_over_time(system.memory.utilization[1h]))</code></pre><pre><code># ES|QL
TS metrics-hostmetricsreceiver.otel-default
| STATS AVG(AVG_OVER_TIME(system.memory.utilization)) BY host.name, TBUCKET(1h)</code></pre><p>Elasticsearch comfortably outperforms the other systems by up to 30x, in both low and high cardinality datasets.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0a78beb0bd1e5b37cb7001f384fa13d4a44dbcbf-1210x851.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cedb803077cc660b9b85b4cf0f123be8d4c932d7-1227x851.png" style="width: 100%;" /><h4>Counter rate</h4><p>We next compared performance of evaluating the per-host hourly average of cpu rate, using the following queries:</p><pre><code># PromQL
avg by (host.name) (rate(system.cpu.time[1h]))</code></pre><pre><code># ES|QL
TS metrics-hostmetricsreceiver.otel-default
| STATS AVG(RATE(system.cpu.time)) BY host.name, TBUCKET(1h)</code></pre><p>Despite processing data points per time series in order, counter rate performance matches calculating gauge average (the involved time series have 6.6x more docs than the query above). Elasticsearch maintains its wide advantage compared to the other systems and outperforms Mimir and Prometheus by 30x in the low cardinality dataset and by 16x in the high cardinality one.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d31b3fe1c85854a9daa8814a318b9bf86007a823-1227x851.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/82735acdb0228686ca5d8b3da56da6fc6256eb35-1243x851.png" style="width: 100%;" /><p>It&#x27;s really impressive that, for the high cardinality dataset, Elasticsearch is able to process 4 hours of data for half a million time series in less than 2 seconds, while the other systems take more than 30 seconds, leading to unresponsive dashboards for such queries. ClickHouse is also slower, despite having no logic to detect counter resets and extrapolate/interpolate deltas across buckets.</p><h4>Prefix filter on host name</h4><p>We next compared performance of filtering on host names based on their prefix, using the following queries:</p><pre><code># PromQL
avg by (host_name)
  (avg_over_time(system_cpu_load_average_1m{host_name=~"host-.*"}[5m]))</code></pre><pre><code># ES|QL
TS metrics-hostmetricsreceiver.otel-default
| WHERE host.name LIKE "host-*"
| STATS AVG(AVG_OVER_TIME(system.cpu.load_average.1m)) BY host.name, TBUCKET(5m)</code></pre><p>Elasticsearch manages to maintain an advantage of up to 5x compared to the other systems, despite replacing the inverted index on <code>host.name</code> with a doc value skipper.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3a28e59f33996df9cfee82aafcf4f30f9f51c045-1205x851.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b0765ee99190d3f77635293e2258250530deb945-1226x851.png" style="width: 100%;" /><h4>Gauge average with window</h4><p>We compared the performance of time series aggregations with a window of 90 minutes and time buckets of 30 minutes, using the following queries:</p><pre><code># PromQL
avg by (host.name) (avg_over_time(system.memory.utilization[90m]))&step=30m</code></pre><pre><code># ES|QL
TS metrics-hostmetricsreceiver.otel-default
| STATS AVG(AVG_OVER_TIME(system.memory.utilization, 90m))
    BY host.name, TBUCKET(30m)</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b1cb772391b5c16355df75b8be791957d1a0570a-1211x851.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/87b09774ac09d5fc38b8e534c7643e80a9f811b5-1235x851.png" style="width: 100%;" /><p>Elasticsearch maintains an advantage that reaches 25x for the low cardinality dataset and 8x for the high cardinality one. ClickHouse is outperformed by close to 4x, denoting the efficiency of our approach for windowed query operations.</p><h2>What&#x27;s next for Elasticsearch metrics</h2><p>Elasticsearch has been extended with metrics storage and processing capabilities that outperform Prometheus, Mimir, and ClickHouse. We&#x27;re making fast progress with supporting <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">PromQL</a> and <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">Prometheus remote write</a>, also available as tech preview in <strong>version 9.4</strong>. These extensions enable users familiar with Prometheus and relevant systems to switch their applications to Elasticsearch — no need to migrate existing dashboards. Since Prometheus integration reuses the same storage and query engine that has been presented in this article, the same performance wins are also expected for Prometheus. Furthermore, collected metrics can be queried with PromQL and ES|QL, side-by-side or in ES|QL query pipelines, further boosting the analytics capabilities far beyond what was conceivable so far with Prometheus-based systems.</p><p>The improvements in storage efficiency, indexing throughput and query performance are already impressive, but we&#x27;re not done. We&#x27;ll be introducing more refinements to the codec for time series data, further reducing bytes per data point. Batch processing of ingested metrics will be further improved, reducing synchronization overhead and redundant processing layers that are not needed for well-formatted collected metrics. We&#x27;re also planning to make wider use of doc value skippers, storing pre-computed aggregates like sum and count per block of values, to shortcut data point loading and processing where applicable, as well as use more cpu-friendly partitioning and grouping operations.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Multi-tier search with Elastic for ecommerce search governance: Fixing poor recall]]></title>
            <description><![CDATA[Learn how to build a multi-tier retrieval strategy used to execute ecommerce governed search plans and improve recall management. We'll cover how to orchestrate semantic matching while maintaining stable results, facets, and pagination.]]></description>
            <link>https://www.elastic.co/search-labs/blog/multi-tier-search-ecommerce-governance</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multi-tier-search-ecommerce-governance</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5d64716981e76396b401fd069d0a635b6929ba94-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>A common issue in ecommerce search is poor recall. This occurs when a system lacks a governed fallback strategy. The solution is a multi-tier execution model. This post describes a multi-tier retrieval strategy used to execute governed search plans. It explains how to orchestrate strict, relaxed, and semantic matching while maintaining stable results, facets, and pagination.</p><h2><strong>From policy logic to retrieval architecture</strong></h2><p><a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">Part 4</a> provided a technical deep dive into the governed control plane and its implementation using the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/percolator">Elasticsearch percolator</a>. Once the logic layer has identified which policies to apply, the system must address the retrieval strategy used to execute the search.</p><p>Managing the transition from precision to recall is a critical function of any ecommerce search engine. For example, a basic search implementation often defaults to broad keyword matching. If a shopper searches for &quot;organic Pink Lady apples&quot;, this can lead to irrelevant results, such as apple-scented dish soap, apple juice, or organic pink grapefruit, appearing at the top of the list simply because they share a common term. While these items are technically matches, they fail to satisfy the user&#x27;s intent and typically lead to high bounce rates. However, a &quot;No results&quot; page is equally detrimental to conversion. This conflict is resolved by implementing a <strong>three-tier execution model</strong>, which uses the governed control plane to orchestrate a principled fallback strategy.</p><h2><strong>The three-tier execution model</strong></h2><p>This architecture executes up to three retrieval tiers in a sequence, each with a specific matching logic.</p><h3><strong>Highest tier: Strict matching</strong></h3><p><em>Strict matching</em> is a lexical match that requires that <strong>all</strong> query terms appear in the product metadata.</p><ul><li><strong>The logic:</strong> A search for &quot;organic navel oranges&quot; returns only products containing all three terms.</li><li><strong>Application:</strong> This tier provides the highest precision. When a customer types a precise product name, such as &quot;organic navel oranges&quot;, they’re typically seeking that exact item rather than an alternative.</li></ul><h3><strong>Mid-tier: Relaxed matching</strong></h3><p>If the strict tier fails to return sufficient results, the system expands the search parameters.</p><ul><li><strong>The logic:</strong> This tier allows for a subset of terms to lexically match, using <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-minimum-should-match">Elasticsearch&#x27;s minimum_should_match</a> logic.</li><li><strong>Application:</strong> Relaxed matching maintains lexical grounding. A search for &quot;organic navel oranges&quot; might surface &quot;navel oranges&quot; (missing the &quot;organic&quot; term) or &quot;organic oranges&quot; (missing the &quot;navel&quot; term). These represent intuitive, keyword-based alternatives for the shopper.</li></ul><h3><strong>Lowest tier: Semantic matching</strong></h3><ul><li><strong>The logic:</strong> This tier uses vector/semantic embeddings (such as Elastic Learned Sparse EncodeR [ELSER], E5, or Jina) to retrieve conceptually related products, regardless of direct keyword overlap.</li><li><strong>Application:</strong> A search for &quot;organic navel oranges&quot; might surface &quot;mandarins&quot; or &quot;clementines”. This serves as the final retrieval tier, intended to provide relevant options when literal keyword matches are unavailable.</li></ul><p>To see this multi-tier orchestration in action and how the Engine steps down from lexical to semantic matching, watch the video: <a href="https://youtu.be/k02NHvIAHsk?si=tJKwmc4ds3zjcRPF">Eliminating Zero-Result Pages: PRISM’s Multi-Tier Search Fallback</a>.</p><h2><strong>Tier orchestration: The &quot;bucket filling&quot; logic</strong></h2><p>While the governed control plane provides the logic and the queries for each tier, the application layer is responsible for the execution. The application executes these tiers sequentially and excludes lower tiers once the accumulated result count on the first page reaches or exceeds 10 items (or whatever number of results you want to display on the first page). This threshold ensures a full first page of results while prioritizing the most accurate retrieval method.</p><h3>Scenario 1: High-intent search (&quot;oranges&quot;)</h3><p>The first tier returns 15 hits. Since 15 is more than 10, the current result set is locked to only strict matches (which can be paged through) and subsequent tiers are not executed.</p><pre><code>Strict tier:   [##########]##### (>= 10 found: Exact matches)
Relaxed tier:  [          ]      (Tier bypassed)
Semantic tier: [          ]      (Tier bypassed)</code></pre><h3>Scenario 2: Specific but limited results (&quot;organic blood oranges&quot;)</h3><p>The strict tier finds only four items. Since this is less than 10, the system triggers the relaxed tier, which finds 12 more relevant products. The combined total (16) meets the threshold of 10, so the current result set is locked to the strict and relaxed tiers. Subsequent paging will only surface results from these two tiers (preventing lower-quality semantic hits from appearing on later pages).</p><pre><code>Strict tier:   [####      ]       (4 found)
Relaxed tier:  [    ######]###### (>= 6 found)
Semantic tier: [          ]       (Tier bypassed)</code></pre><h3>Scenario 3: Abstract or intent-based search (&quot;high vitamin C snacks&quot;)</h3><p>Keyword matches are limited (only five hits between tiers 1 and 2). The system triggers the semantic tier to find conceptually relevant items, such as kiwis, guavas, or red peppers, to fill the result set. The result set for this query includes products from all tiers.</p><pre><code>Strict tier:   [##        ]             (2 found)
Relaxed tier:  [  ###     ]             (3 found)
Semantic tier: [     #####]######################...</code></pre><p>This orchestration optimizes for latency, as the computational cost of the semantic tier is only incurred when the keyword-based tiers are insufficient. Additionally, this allows fast-responding keyword results to be displayed while semantic results are integrated shortly after, maintaining a responsive user interface.</p><h2><strong>Determining intent via tier activation</strong></h2><p>The logic used to fill the first page serves a critical secondary purpose: It acts as a diagnostic for user intent. The application uses the logic returned by the governed control plane to determine which tiers remain active for the current result set and paging.</p><p>If the strict and relaxed tiers together yield fewer than 10 results, the query is likely exploratory or abstract. In this case, activating the semantic tier is a benefit. Because the query is diagnosed as exploratory, the system allows the shopper to page through the entire depth of the semantic results. This provides access to conceptually related alternatives that lexical matching would have missed, which is appropriate for an abstract search.</p><p>Conversely, if the strict tier returns a robust set of results (for example, 30 hits), it confirms that the system has found high-precision matches. The user can page through those 30 hits and will likely find what they’re looking for. In this scenario, there’s no need to provide additional, less relevant exploratory hits. By disabling lower tiers for these high-precision queries, we ensure that a shopper deep diving into specific results isn’t distracted by irrelevant semantic fallback as they paginate through the current result set.</p><h2><strong>Governance across tiers</strong></h2><p>A critical component of this architecture is that policies apply globally across all tiers. If a user has a &quot;vegan&quot; preference profile, the governed control plane injects that constraint into the strict, relaxed, and semantic queries. This ensures that even when the system uses semantic fallback to return &quot;mandarins&quot; for an orange search, the results remain compliant with the user&#x27;s broader dietary preferences or business constraints.</p><h2><strong>The problem of facet instability</strong></h2><p>A challenge with multi-tier search is maintaining consistent faceted navigation (sidebar filters). If a search for &quot;chocolate&quot; yields 12 strict results, the sidebar filters might show &quot;dark&quot; and &quot;milk&quot;. If a user selects &quot;dark&quot; and the result count drops, a naive system might trigger the semantic tier to fill the page, which could suddenly introduce &quot;red wine&quot; into the filters due to a semantic relationship.</p><p>The governed control plane identifies which tiers contributed to the initial search and locks the facets to those tiers. This prevents the sidebar from changing unexpectedly during a filtered session, ensuring a stable user experience.</p><h2><strong>The pagination challenge: Seamless multi-tier paging</strong></h2><p>Pagination in a tiered system requires precise state management. As established, the first page determines the scope of the <strong>current result set</strong>. If the first page required semantic results, the user can page through all available results from all three tiers. On the other hand, if the first page was satisfied by high-intent keyword matches, the semantic tier is not retrieved for that specific result set.</p><p>The governed control plane manages this through:</p><ul><li><strong>Tier locking:</strong> The response includes an array identifying the contributing tiers. The front end returns this on subsequent requests to keep the tier composition consistent across all pages.</li><li><strong>Dynamic offset calculation:</strong> The back end calculates an offset based on the requested page and the total products returned in preceding tiers.<strong>Example:</strong> If the first page has returned seven strict matches and three relaxed matches, a request for page 2 (starting at index 10) would execute a relaxed tier query with an offset of three.</li><li><strong>ID exclusion for lower tiers:</strong> The system retrieves IDs from the higher tiers (which, by definition, will always be fewer than the page size threshold) and explicitly excludes them from lower-tier results using an ID-only query (which avoids the overhead of a full fetch phase for excluded items).</li></ul><h2><strong>Summary</strong></h2><p>The multi-tier approach ensures search results are precise when data is available and helpful when it is not. By providing a governed fallback sequence for the application to execute, the architecture maintains high relevance while eliminating &quot;no results&quot; scenarios.</p><h2><strong>What&#x27;s next in this series</strong></h2><p>The next posts in this series extend the governed control plane into new territory. Part 6 explores personalization (using purchase history boosting and cohort-aware policies), and Part 7 demonstrates per-query economic optimization. Stay tuned!</p><h2><strong>Put governed ecommerce search into practice</strong></h2><p>The search architecture described in this post, where retrieval tiers, economic weights, and governance constraints compose into a single request, was designed and built by Elastic Services Engineering as part of our repeatable ecommerce search accelerators.</p><p>To learn more about applying these patterns to your business, <strong><a href="https://www.elastic.co/contact">Contact Elastic Professional Services</a>.</strong></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Three indices walk into a FROM clause: ES|QL subqueries in Elasticsearch]]></title>
            <description><![CDATA[ES|QL subqueries give each data source its own pipeline and filters, eliminating CASE chains, restoring predicate pushdown, and making multi-index queries extensible by design.]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-subquery-from</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-subquery-from</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tyler Perkins]]></dc:creator>
            <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2eb755dd2b2b69b8e0e8867a0da85940eb744176-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language</a> (ES|QL) now has <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">subqueries in <code>FROM</code></a>. Three indices, different schemas, one query; each source gets its own pipeline with its own filters and transforms. No more <code>CASE</code> chains. No more client-side stitching. Add a fourth source? Add a fourth branch; zero changes to the existing three.</p><h2>The problem: Heterogeneous data, one query</h2><p>Consider a production incident investigation. Errors are spread across three microservices: an API gateway, a payments service, and an auth service, each with different field names and different conventions. Before subqueries, combining them in a single ES|QL query meant cramming everything into one <code>FROM</code> with <code>CASE</code> chains:</p><pre><code>FROM svc-gateway-*, svc-payments-*, svc-auth-* METADATA _index
| WHERE http.response.status_code >= 500
    OR transaction.status IN ("failed", "timeout")
    OR (event.action == "login" AND event.outcome == "failure")
| EVAL
    service = CASE(
      _index LIKE "svc-gateway*", "gateway",
      ... /* one branch per source */),
    error_detail = CASE(
      _index LIKE "svc-gateway*", CONCAT("HTTP ", http.response.status_code::string),
      ... /* one branch per source */)
| KEEP @timestamp, service, error_detail, source.ip
| SORT @timestamp DESC</code></pre><p>This is brittle and slow. The disjunctive <code>OR</code> prevents predicate pushdown; every index scans every condition. Every <code>CASE</code> chain grows with every source. Copy it into five dashboards and three alert rules, and you have eight places to update when anything changes.</p><h2>The fix: Independent pipelines</h2><p>Subqueries replace the monolithic <code>FROM</code> + <code>CASE</code> pattern. Each data source gets its own complete pipeline:</p><pre><code>FROM
  (FROM svc-gateway-*
   | WHERE http.response.status_code >= 500
   | EVAL service = "gateway",
         error_detail = CONCAT("HTTP ", http.response.status_code::string)
   | KEEP @timestamp, service, error_detail, source.ip),
  (FROM svc-payments-*
   | WHERE transaction.status IN ("failed", "timeout")
   | EVAL service = "payments",
         error_detail = transaction.status
   | KEEP @timestamp, service, error_detail, source.ip),
  (FROM svc-auth-*
   | WHERE event.action == "login" AND event.outcome == "failure"
   | EVAL service = "auth",
         error_detail = CONCAT(event.action, " ", event.outcome)
   | KEEP @timestamp, service, error_detail, source.ip)
| SORT @timestamp DESC
| LIMIT 20</code></pre><p>The gateway branch only scans for HTTP 500s. The payments branch only looks at transaction statuses. The auth branch only checks login failures. Because each branch has its own <code>WHERE</code>, the optimizer pushes filters independently into each index, restoring the predicate pushdown that a single <code>FROM</code> with <code>OR</code> conditions prevents. Fields that exist in one branch but not another are filled with <code>null</code>.</p><p>Adding a fourth service means adding a fourth branch. Existing branches don&#x27;t change.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f210b98824cb34c514b6cdb2d44b2bc81a51bb7b-1999x1084.png" style="width: 100%;" /><h2>Save it as a view</h2><p>This is where subqueries and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-esql-logical-views">logical views</a> combine. Wrap the subquery above in a named view, with one API call:</p><pre><code>PUT _query/view/error_triage
{
  "query": "FROM (FROM svc-gateway-* | WHERE ...) , (FROM svc-payments-* | WHERE ...) , (FROM svc-auth-* | WHERE ...)"
}</code></pre><p>Now consumers just write <code>FROM error_triage | STATS error_count = COUNT(*) BY service</code>. Three indices, three pipelines, one name. If you have 10 dashboards and five alert rules consuming this pattern, that&#x27;s 15 copies of the same logic today; with a view, it&#x27;s one definition and zero consumer-side edits when you add a fourth service. See <a href="https://www.elastic.co/search-labs/blog/elasticsearch-esql-logical-views">Elasticsearch ES|QL Views</a> for the full views deep dive.</p><h2>What you can do inside a branch</h2><p>Each branch supports the full ES|QL pipeline: <code>WHERE</code>, <code>EVAL</code>, <code>STATS</code>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/enrich"><code>ENRICH</code></a>, and more. See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">subquery documentation</a> for the complete list.</p><h2>Aggregate different metrics, and then combine</h2><p>Each branch can compute its own summary before results are merged. This is useful when different indices track the same concept under different field names:</p><pre><code>FROM
  (FROM svc-gateway-*
   | STATS avg_latency = AVG(http.response.time_ms) BY hour = BUCKET(@timestamp, 1 hour)
   | EVAL service = "gateway"),
  (FROM svc-payments-*
   | STATS avg_latency = AVG(transaction.duration_ms) BY hour = BUCKET(@timestamp, 1 hour)
   | EVAL service = "payments")
| SORT hour DESC, service</code></pre><p>Both branches produce <code>avg_latency</code> and <code>hour</code>, but each computes it from a different source field. The combined result is a single table you can chart or alert on, without normalizing field names at ingest time. This pattern is impossible with a single <code>FROM</code>; you can&#x27;t compute different aggregations per index without subqueries.</p><h2>Subqueries vs. FORK</h2><p>ES|QL also has <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> (now generally available), which creates parallel execution branches from the same input. The distinction:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b5728fa2ce08df32b67ef9368c4d75d04b74ab95-1999x992.png" style="width: 100%;" /><p>Different indices → subqueries. Same data, different analyses → FORK.</p><h2>How this compares</h2><p>If you&#x27;re coming from other query languages, here&#x27;s how ES|QL subqueries stack up at the time of writing:</p><p><strong>Splunk SPL/SPL2</strong> has <code>append</code> and <code>multisearch</code> in classic SPL, and SPL2 adds a <a href="https://help.splunk.com/en/splunk-cloud-platform/search/spl2-search-reference/union-command/union-command-examples">union command</a> that merges events from multiple datasets (the closest analogue to ES|QL subqueries). Federated Search extends this across remote Splunk deployments (analogous to CCS). The differences are in how the engine handles each branch: ES|QL subqueries give each branch independent predicate pushdown, meaning filters are pushed into each index&#x27;s shard-level structures separately. SPL2 <code>union</code> merges datasets but optimization across branches is limited to what the search scheduler can parallelize. Wrapping ES|QL subqueries in a <a href="https://www.elastic.co/search-labs/blog/elasticsearch-esql-logical-views">view</a> gives you engine-level encapsulation with role-based access control (RBAC); Splunk&#x27;s equivalent is saved searches and macros, which are text substitution expanded at parse time.</p><p><strong>SQL databases</strong> have <code>UNION ALL</code>, which is the closest analog. The difference is that SQL <code>UNION ALL</code> typically requires matching column counts and types at parse time. ES|QL subqueries are more forgiving; columns that exist in one branch but not another get null-padded automatically, which matters when your sources have different schemas (the norm in observability data). SQL views solve the reuse problem similarly, but ES|QL views are cluster-level objects, not database-scoped; they work across <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-cross-clusters.html">cross-cluster search</a> boundaries.</p><p><strong>Grafana / Datadog / other dashboarding tools</strong> handle multisource composition at the visualization layer: Run separate queries, merge in the panel. This works for display but breaks for alerting, downstream queries, and anything that needs a single result set programmatically. ES|QL subqueries push the composition into the engine, so alerts, views, and API consumers all get the same unified result.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Capability</td><td style="border: 1px solid #ccc; padding: 4px;">Splunk SPL/SPL2</td><td style="border: 1px solid #ccc; padding: 4px;">SQL UNION ALL</td><td style="border: 1px solid #ccc; padding: 4px;">Dashboard-layer merge</td><td style="border: 1px solid #ccc; padding: 4px;">ES|QL subqueries</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Independent filters per source</td><td style="border: 1px solid #ccc; padding: 4px;">SPL2 `union` merges datasets; optimization is scheduler-level</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td><td style="border: 1px solid #ccc; padding: 4px;">N/A (separate queries)</td><td style="border: 1px solid #ccc; padding: 4px;">Yes; parallel with pushdown</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Schema mismatch handling</td><td style="border: 1px solid #ccc; padding: 4px;">Manual field normalization</td><td style="border: 1px solid #ccc; padding: 4px;">Strict column matching</td><td style="border: 1px solid #ccc; padding: 4px;">Manual in panel config</td><td style="border: 1px solid #ccc; padding: 4px;">Automatic null-padding</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Engine-level reuse</td><td style="border: 1px solid #ccc; padding: 4px;">Text macros (parse-time expansion)</td><td style="border: 1px solid #ccc; padding: 4px;">Database-scoped views</td><td style="border: 1px solid #ccc; padding: 4px;">Dashboard variables</td><td style="border: 1px solid #ccc; padding: 4px;">Cluster-level views with RBAC</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Works for alerts + API</td><td style="border: 1px solid #ccc; padding: 4px;">Limited (summary indexing)</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td><td style="border: 1px solid #ccc; padding: 4px;">No; display only</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Add a source</td><td style="border: 1px solid #ccc; padding: 4px;">Edit every macro/saved search</td><td style="border: 1px solid #ccc; padding: 4px;">Add a UNION branch</td><td style="border: 1px solid #ccc; padding: 4px;">Add a panel query</td><td style="border: 1px solid #ccc; padding: 4px;">Add a branch; existing branches unchanged</td>
                    </tr>
              </tbody>
            </table>
          <h2>Current constraints</h2><p>In the Tech Preview release, subqueries are non-correlated; branches run independently and can&#x27;t reference the outer query. They&#x27;re supported in <code>FROM</code> only (not <code>TS</code>), and <code>FORK</code> can&#x27;t be used inside or after subqueries. See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">subquery documentation</a> for details.</p><h2>What&#x27;s next for subqueries</h2><p><a href="https://github.com/elastic/roadmap/issues/60"><code>WHERE</code> subqueries</a> — <code>WHERE field IN (FROM other_index | ...)</code> and other correlated forms — will extend the composition model from <code>FROM</code> into filtering. This brings the familiar SQL pattern of nested filtering to ES|QL.</p><h2>Try it</h2><p>Subqueries in <code>FROM</code> are available as a Tech Preview. Try them in <a href="https://www.elastic.co/kibana">Kibana</a> Dev Tools or Discover. We&#x27;d love your feedback; file a <a href="https://github.com/elastic/elasticsearch/issues">GitHub issue</a> with the <code>ES|QL</code> label.</p><p><em>ES|QL subqueries in FROM are a Tech Preview feature. Tech Preview features are subject to change and are not covered by the support SLA of GA features. The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch's BBQ vs. TurboQuant: 10–40× faster on CPU and lower ranking noise]]></title>
            <description><![CDATA[A head-to-head look at Elasticsearch BBQ and TurboQuant, including throughput, ranking accuracy, and why uniform quantization wins for CPU vector search with up to 40× faster comparisons and smaller ranking noise.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-bbq-osq-vs-turbo</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-bbq-osq-vs-turbo</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Thomas Veasey]]></dc:creator>
            <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/81cf4cbba360511e166ea59f2cc747cdd69b7dcf-1840x892.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In CPU vector search, Elasticsearch&#x27;s <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-optimization">Optimized Scalar Quantization (OSQ)</a> (the algorithm behind <a href="https://www.elastic.co/search-labs/blog/optimized-scalar-quantization-elasticsearch">Better Binary Quantization (BBQ)</a>) beats <a href="https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/">TurboQuant</a> where production systems care most: throughput, ranking accuracy, and storage efficiency. In our tests on Apple M2 Max, OSQ&#x27;s symmetric kernels are 10-40x faster, and on shifted embeddings its 1-bit document encoding beats TurboQuant at 4 bits on ranking accuracy while using far less storage. TurboQuant still wins on raw reconstruction MSE, but that advantage comes mostly from the Hadamard rotation and does not translate into better CPU search behavior.</p><h4>A brief quantization primer</h4><p>Vector search indexes often store millions or billions of embedding vectors, each hundreds or thousands of floats wide. Scalar quantization compresses each float coordinate independently to a small integer, typically 1, 2, or 4 bits, reducing storage by 8-32× and enabling fast integer-arithmetic scoring.<br/>Why does per-coordinate quantization work at all? Because the expected squared error decomposes as a sum of independent per-coordinate terms: $\mathbb{E}[\|x - q(x)\|^2] = \sum_i \mathbb{E}[(x_i - q_i(x_i))^2]$, by linearity of expectation. Each coordinate can be quantized independently regardless of the joint distribution. The remaining question is how to make each per-coordinate quantizer <em>good</em>.</p><h4>BBQ and Optimized Scalar Quantization</h4><p><a href="https://www.elastic.co/search-labs/blog/optimized-scalar-quantization-elasticsearch">Better Binary Quantization (BBQ)</a>, and its underlying algorithm <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-optimization">Optimized Scalar Quantization (OSQ)</a>, have been part of Elasticsearch for multiple releases. OSQ is an evolution of several techniques to make scalar quantization more accurate, specifically for vector search.</p><p>Each vector&#x27;s components are mapped to a uniform grid over an interval $[a, b]$. The interval is initialized from the vector&#x27;s statistics (assuming approximately normal residuals), using the <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-optimization#initializing-the-quantization-interval">exact same optimization objective</a> as TurboQuant but with an additional constraint on centroid positions. They are then refined by coordinate descent to minimize an anisotropic loss $L = (1-\lambda)(x \cdot e)^2/\|x\|^2 + \lambda\|e\|^2$, where $e$ is the quantization error vector. With the production default $\lambda = 0.1$, this deliberately sacrifices some MSE to concentrate accuracy along the query direction. This is the direction that matters for ranking.</p><p>Before quantization, the segment (or cluster in the case of Inverted Vector File (IVF) indices) centroid $c$ is subtracted from every vector. This removes the dominant shared component that would otherwise waste the quantizer&#x27;s dynamic range. Both the symmetric and asymmetric dot-product paths also center the query using the same segment centroid, so the only quantized inner product is between centered residuals. The correction terms, $\langle c, x \rangle$ for each vector and $\|c\|^2$, depend on only a single vector each, and can be precomputed exactly. Centering therefore adds no per-pair cost.</p><p>Documents are quantized at 1-bit (32× compression), queries at 4-bit (cheap since there are only a handful per search). The storage constraint binds on documents, not queries, so spending more bits on the query side recovers float-query accuracy while keeping per-document footprint minimal.</p><p>A block-diagonal <a href="https://en.wikipedia.org/wiki/Orthogonal_matrix">orthogonal</a> preconditioner equalizes coordinate variances and normalizes their distribution before quantization. This is the same goal as a full <a href="https://en.wikipedia.org/wiki/Hadamard_transform">Hadamard rotation</a> used by TurboQuant, but with no power-of-2 padding overhead.</p><p>Because the grid is uniform, quantized dot products decompose into integer dot products with scalar corrections. This enables NEON/SVE and SSE/AVX popcount and multiply-accumulate pipelines: bit-plane decomposition for 1-bit and 2-bit, nibble multiply for 4-bit, and a <a href="https://www.elastic.co/search-labs/studio/content/posts;d8336fba-c31a-42fe-8c27-0178f7779203%2Ctemplate%3Dpost">RaBitQ-style</a> mixed 4×1 kernel that decomposes to four 1-bit kernels.</p><p>For a deeper dive into the sparse rotation and what it brings to robustness, see the <a href="https://www.elastic.co/search-labs/blog/robust-optimized-scalar-quantization">Robust Optimized Scalar Quantization</a> blog. For a full walkthrough of optimized scalar quantization, see this <a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch">OSQ deep dive</a>.</p><h4>TurboQuant</h4><p><a href="https://arxiv.org/pdf/2504.19874">TurboQuant</a> (Google, ICLR 2026) takes a slightly different path to the same starting observation: that concentrated, predictable per-coordinate distributions are easy to quantize well.</p><p>Rather than adapting the quantizer per vector, TurboQuant normalizes the vector and applies a shared randomized Hadamard rotation to the entire dataset. This general sort of idea was first proposed and formalized by <a href="https://arxiv.org/pdf/2405.12497">RaBitQ</a>, which showed that the random rotation yields worst-case bounds over the data, holding for any fixed unit vector, for their quantization scheme. The idea of implementing via a Hadamard rotation was suggested by <a href="https://weaviate.io/blog/8-bit-rotational-quantization">Weaviate for rotational quantization</a>. After normalization and rotation, each coordinate&#x27;s distribution almost always converges to $\mathcal{N}(0, 1/d)$ in high dimensions, regardless of the original data. TurboQuant builds on this foundation: with the distribution pinned down, it solves for the optimal <a href="https://en.wikipedia.org/wiki/Lloyd%27s_algorithm">Lloyd-Max</a> scalar quantizer, a 1-D $k$-means problem on the known density. The resulting non-uniform centroids bunch up where the density is highest (near zero) and spread out in the tails. This achievies provably near-optimal MSE: within ~2.7× of the information-theoretic lower bound in general, and as tight as 1.45× at 1-bit.</p><p>For inner products, MSE-optimal quantizers introduce a multiplicative bias (which is most severe at 1-bit: $2/\pi \approx 0.64$). TurboQuant corrects this with a two-stage design ($Q_\text{prod}$): spend $b-1$ bits on the MSE quantizer, then use the remaining 1 bit for a <a href="https://arxiv.org/pdf/2406.03482">Quantized Johnson-Lindenstrauss</a> (QJL) sketch of the residual, yielding a provably unbiased inner-product estimator.</p><p>The paper&#x27;s nearest-neighbour experiments were conducted on GPU (NVIDIA A100), where the lookup-table access pattern maps naturally onto shared memory.</p><h4>The key design divergence: integer arithmetic vs. lookup tables</h4><p>The difference between uniform and non-uniform centroids may seem minor, but it creates a large computational gap.</p><p>OSQ&#x27;s uniform grid means each quantized coordinate is an integer whose arithmetic meaning is preserved. The dot product of two quantized vectors decomposes into an integer dot product, directly exploitable by SIMD: <code>vpdpbusd</code> on x86, multiply-accumulate and <code>vcnt</code> (popcount) on ARM NEON. The pipeline is branch-free and the data access pattern is sequential.</p><p>TurboQuant&#x27;s non-uniform centroids break this. Each coordinate pair requires looking up a centroid value from a shared codebook, and the access pattern is data-dependent with each index selecting a different table entry. On NEON, which lacks a float gather instruction, this means scalar loads to build each vector register before the Fused Multiply-Add (FMA). Precomputing per-coordinate product tables ($d \times 2^b$ entries, amortized over all documents) doesn&#x27;t help either: the FMA is relatively cheap on modern cores, so the bottleneck remains the data-dependent gather, not the arithmetic. Our benchmarks confirm this: precomputed ADC tables are no faster (and sometimes slower due to the larger working set) than inline centroid lookup.</p><h4><strong>Terminology </strong>used in the comparisons</h4><p>The results sections below refer to several OSQ scoring configurations. All use uniform-grid quantization with scalar correction terms to recover the exact dot product up to quantization noise.</p><p><em>Symmetric $n$-bit</em><strong> </strong>quantizes both query and document at $n$ bits per coordinate.</p><p><em>Asymmetric</em> keeps the query as a full float vector and quantizes only the document. The dot product is a float-times-integer sum. This is more expensive per pair than symmetric, but avoids any query quantization noise. TurboQuant&#x27;s scoring is always asymmetric (float query dotted against quantized document via centroid lookup).</p><p><em>1-4</em> is the production configuration for OSQ: documents at 1-bit (32× compression), queries at 4-bit. This exploits the asymmetry of search: there is one query but millions of documents, so query storage is free but document storage is the binding constraint.</p><p><em>Centered</em> means the segment centroid $c$ has been subtracted from all vectors (and the query) before quantization, with the exact correction recovered from precomputed scalar terms. Centering focuses the quantizer&#x27;s dynamic range on the information-bearing residual rather than the shared mean.</p><p>$\lambda$ controls the anisotropic loss tradeoff: $\lambda = 1$ minimizes pure MSE, $\lambda = 0.1$ (production default) sacrifices some MSE to concentrate accuracy along the query direction, the direction that determines ranking.</p><h4>How do they compare in practice?</h4><p>The following results were obtained on an Apple M2 Max. The code to reproduce all these results is available <a href="https://github.com/elastic/osq_vs_turboquant">here</a>.</p><h4>Head-to-head: MSE</h4><p>On reconstruction MSE, the metric TurboQuant was designed to optimize, TurboQuant outperforms plain OSQ at every bit-width.</p><p>Relative MSE ($\|x - \hat{x}\|^2 / \|x\|^2$) on $d = 768$ Gaussian vectors (1,000 vectors, lower is better):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Bits</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ (λ=0.1)</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ (λ=1)</td><td style="border: 1px solid #ccc; padding: 4px;">TurboQuant</td><td style="border: 1px solid #ccc; padding: 4px;">TQ vs OSQ ($\lambda=1$)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1</td><td style="border: 1px solid #ccc; padding: 4px;">0.512</td><td style="border: 1px solid #ccc; padding: 4px;">0.362</td><td style="border: 1px solid #ccc; padding: 4px;">0.307</td><td style="border: 1px solid #ccc; padding: 4px;">1.18×</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td><td style="border: 1px solid #ccc; padding: 4px;">0.138</td><td style="border: 1px solid #ccc; padding: 4px;">0.118</td><td style="border: 1px solid #ccc; padding: 4px;">0.092</td><td style="border: 1px solid #ccc; padding: 4px;">1.28×</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">0.038</td><td style="border: 1px solid #ccc; padding: 4px;">0.037</td><td style="border: 1px solid #ccc; padding: 4px;">0.026</td><td style="border: 1px solid #ccc; padding: 4px;">1.42×</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4</td><td style="border: 1px solid #ccc; padding: 4px;">0.011</td><td style="border: 1px solid #ccc; padding: 4px;">0.011</td><td style="border: 1px solid #ccc; padding: 4px;">0.007</td><td style="border: 1px solid #ccc; padding: 4px;">1.61×</td>
                    </tr>
              </tbody>
            </table>
          <p>The $\lambda$ columns reveal that OSQ&#x27;s production setting ($\lambda = 0.1$) deliberately sacrifices MSE for dot-product accuracy. With $\lambda = 1$ (pure MSE), the gap narrows, to just 1.18× at 1-bit.</p><p>But where does TurboQuant&#x27;s remaining MSE advantage actually come from, the Lloyd-Max centroids, or the Hadamard rotation? We can answer this directly by applying the same randomized Hadamard rotation to OSQ (zero-pad 768→1024, random sign flips, Walsh-Hadamard butterfly, quantize in rotated space, invert). Theory predicts the MSE improves by a factor of $d/d&#x27; = 768/1024 = 0.75$:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Bits</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ (λ=1)</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ + Hadamard</td><td style="border: 1px solid #ccc; padding: 4px;">TurboQuant</td><td style="border: 1px solid #ccc; padding: 4px;">Ratio (OSQ/QSQ + Hadamard)</td><td style="border: 1px solid #ccc; padding: 4px;">Theory (d'/d)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1</td><td style="border: 1px solid #ccc; padding: 4px;">0.362</td><td style="border: 1px solid #ccc; padding: 4px;">0.306</td><td style="border: 1px solid #ccc; padding: 4px;">0.307</td><td style="border: 1px solid #ccc; padding: 4px;">1.19</td><td style="border: 1px solid #ccc; padding: 4px;">1.33</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td><td style="border: 1px solid #ccc; padding: 4px;">0.118</td><td style="border: 1px solid #ccc; padding: 4px;">0.092</td><td style="border: 1px solid #ccc; padding: 4px;">0.092</td><td style="border: 1px solid #ccc; padding: 4px;">1.28</td><td style="border: 1px solid #ccc; padding: 4px;">1.33</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">0.037</td><td style="border: 1px solid #ccc; padding: 4px;">0.028</td><td style="border: 1px solid #ccc; padding: 4px;">0.026</td><td style="border: 1px solid #ccc; padding: 4px;">1.31</td><td style="border: 1px solid #ccc; padding: 4px;">1.33</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4</td><td style="border: 1px solid #ccc; padding: 4px;">0.011</td><td style="border: 1px solid #ccc; padding: 4px;">0.009</td><td style="border: 1px solid #ccc; padding: 4px;">0.007</td><td style="border: 1px solid #ccc; padding: 4px;">1.33</td><td style="border: 1px solid #ccc; padding: 4px;">1.33</td>
                    </tr>
              </tbody>
            </table>
          <p>OSQ + Hadamard matches TurboQuant almost exactly at 1-bit (0.306 vs 0.307) and 2-bit (0.092 vs 0.092). TurboQuant&#x27;s MSE advantage is the rotation, not the centroids. At 3–4 bits the Lloyd-Max placement contributes a modest ~1.1× edge, real but small.</p><p>The convergence of the improvement ratio applying the Hadamard transformation to OSQ is itself informative: at 4-bit it hits the theoretical 1.33 exactly, but at 1-bit it&#x27;s only 1.19. The shortfall quantifies the value of OSQ&#x27;s data-dependent interval refinement: it already captures ~40% of the dimension expansion and component equalization benefit that Hadamard provides. The coordinate-descent is doing some of the same work as the rotation, adapting to each vector rather than relying on a data-oblivious transform. However, the real advantage, as we discuss below, is this formulation allows us to concentrate accuracy along the query direction.</p><p>This raises a natural question: how does OSQ&#x27;s block-diagonal sparse preconditioner compare to the full Hadamard rotation in practice?</p><h4>Head-to-head: sparse preconditioner vs Hadamard</h4><p>OSQ&#x27;s <a href="https://www.elastic.co/search-labs/blog/robust-optimized-scalar-quantization">sparse preconditioner</a> applies a block-diagonal random orthogonal transformation: dimensions are randomly permuted into blocks (64×64 in production), each block is multiplied by an independent random orthogonal matrix. This equalizes coordinate distributions within each block. The Hadamard rotation achieves the same goal globally but requires zero-padding to the next power of 2.</p><p>We test on anisotropic Gaussian data ($d = 768$, $\sigma_i$ ramping from 1 to 5 across coordinates), a challenging distribution where some coordinates carry far more variance than others.</p><p>Transform latency ($d = 768$, 1,100 vectors, ARM NEON, lower is better):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Method</td><td style="border: 1px solid #ccc; padding: 4px;">ns/vec</td><td style="border: 1px solid #ccc; padding: 4px;">Effective dim</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Block 32×32</td><td style="border: 1px solid #ccc; padding: 4px;">1,811</td><td style="border: 1px solid #ccc; padding: 4px;">768</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Block 64×64</td><td style="border: 1px solid #ccc; padding: 4px;">4,887</td><td style="border: 1px solid #ccc; padding: 4px;">768</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Full dense</td><td style="border: 1px solid #ccc; padding: 4px;">244,752</td><td style="border: 1px solid #ccc; padding: 4px;">768</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hadamard</td><td style="border: 1px solid #ccc; padding: 4px;">1,556</td><td style="border: 1px solid #ccc; padding: 4px;">1,024</td>
                    </tr>
              </tbody>
            </table>
          <p>Hadamard is the fastest non-trivial option thanks to $O(d \log d)$ butterflies vs $O(d \times b)$ for block size $b$, though all block-diagonal variants are fast enough to be negligible in practice with even the 64×64 block at 4.9 μs is tiny compared to typical search latencies. The full dense $d \times d$ rotation is impractical at 244 μs/vec but serves as a theoretical reference. Note that the block-diagonal transform works for arbitrary dimensions: no power-of-2 padding is required, and the effective dimension stays at $d$.</p><p>MSE (relative MSE, $\lambda = 1$, anisotropic data, lower is better):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Method</td><td style="border: 1px solid #ccc; padding: 4px;">1 bit</td><td style="border: 1px solid #ccc; padding: 4px;">2 bit</td><td style="border: 1px solid #ccc; padding: 4px;">4 bit</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">No transform</td><td style="border: 1px solid #ccc; padding: 4px;">0.443</td><td style="border: 1px solid #ccc; padding: 4px;">0.157</td><td style="border: 1px solid #ccc; padding: 4px;">0.0182</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Block 32×32</td><td style="border: 1px solid #ccc; padding: 4px;">0.368</td><td style="border: 1px solid #ccc; padding: 4px;">0.121</td><td style="border: 1px solid #ccc; padding: 4px;">0.0120</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Block 64×64</td><td style="border: 1px solid #ccc; padding: 4px;">0.365</td><td style="border: 1px solid #ccc; padding: 4px;">0.119</td><td style="border: 1px solid #ccc; padding: 4px;">0.0117</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Full dense</td><td style="border: 1px solid #ccc; padding: 4px;">0.362</td><td style="border: 1px solid #ccc; padding: 4px;">0.118</td><td style="border: 1px solid #ccc; padding: 4px;">0.0113</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hadamard</td><td style="border: 1px solid #ccc; padding: 4px;">0.362</td><td style="border: 1px solid #ccc; padding: 4px;">0.118</td><td style="border: 1px solid #ccc; padding: 4px;">0.0112</td>
                    </tr>
              </tbody>
            </table>
          <p>Even 32×32 blocks recover most of the gap from no-transform (0.443) to full rotation (0.362), 93% at 1-bit. Block 64×64 closes the gap further. On isotropic data (not shown), all methods produce identical MSE (~0.362 at 1-bit), confirming there is nothing to equalize when coordinates already have equal variance.</p><p>Dot-product accuracy (1-4 centered, raw relative dot-product error; note these are raw RMSE including multiplicative bias, which is appropriate for comparing preconditioner variants against each other since the bias structure is similar, lower is better):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Method</td><td style="border: 1px solid #ccc; padding: 4px;">Anisotropic</td><td style="border: 1px solid #ccc; padding: 4px;">Isotropic</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">No transform</td><td style="border: 1px solid #ccc; padding: 4px;">0.690</td><td style="border: 1px solid #ccc; padding: 4px;">0.722</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Block 32×32</td><td style="border: 1px solid #ccc; padding: 4px;">0.606</td><td style="border: 1px solid #ccc; padding: 4px;">0.724</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Block 64×64</td><td style="border: 1px solid #ccc; padding: 4px;">0.602</td><td style="border: 1px solid #ccc; padding: 4px;">0.720</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Full dense</td><td style="border: 1px solid #ccc; padding: 4px;">0.595</td><td style="border: 1px solid #ccc; padding: 4px;">0.723</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hadamard</td><td style="border: 1px solid #ccc; padding: 4px;">0.566</td><td style="border: 1px solid #ccc; padding: 4px;">0.629</td>
                    </tr>
              </tbody>
            </table>
          <p>On isotropic data the block-diagonal methods and the full dense rotation produce the same dot-product error as no transform since there is nothing to fix. Hadamard is the outlier, improving from 0.723 to 0.629. But this improvement is not from better preconditioning: the full dense rotation, which is an equally good random orthogonal transform, shows no improvement at all. The difference is the padding. Hadamard operates in 1024 dimensions, so 1-bit documents store 1024 bits instead of 768. This is 33% more storage. The improvement ratio (0.723 / 0.629 = 1.15) matches $\sqrt{d&#x27;/d} = \sqrt{1024/768} = 1.155$ almost exactly, confirming that the entire dot-product advantage is attributable to the extra bits, not the rotation.</p><p>On anisotropic data, the block-diagonal rotation does help dot-product accuracy (0.690 → 0.602 for block 64), which is the real value from coordinate equalization. Hadamard goes further (0.566), but the incremental improvement over a full dense rotation at the same dimension (0.595 → 0.566) is again consistent with the padding benefit.</p><p>The practical implication: for CPU-based search where storage efficiency matters, the block-diagonal preconditioner delivers the same MSE improvement as Hadamard at the same effective bit rate, works for any dimension without padding, and the dot-product gap we see in our experiments is a padding artifact, not a preconditioning advantage.</p><h4>Head-to-head: dot-product accuracy</h4><p>MSE measures reconstruction quality, but search engines rank by dot products. These are different objectives, and the gap between them is where OSQ&#x27;s design choices pay off.</p><p>We measure relative dot-product error: $\sqrt{\sum(q \cdot x - \hat{q} \cdot \hat{x})^2 / \sum(q \cdot x)^2}$, varying the angle between query and document. The small-angle regime (0°–20°) matters most: real transformer embeddings occupy a narrow cone rather than spreading uniformly on the sphere (<a href="https://arxiv.org/pdf/1909.00512">Ethayarajh 2019</a>). Furthermore, near-parallel vectors, corresponding to the nearest neighbours of a query in the dataset, are where ranking accuracy is critical.</p><p>Our production configuration is 1-bit documents, 4-bit queries, centroid centering, with integer scoring.</p><p>Raw dot-product error conflates two distinct components: a multiplicative bias (a global scale factor that preserves ranking order) and noise (random per-pair deviations that can swap rankings). For search, only the noise matters: a biased estimator that consistently scales all scores by the same factor produces the same ranking as the exact scores. TurboQuant&#x27;s MSE quantizer at 1-bit has a well-known multiplicative bias of $2/\pi \approx 0.64$, meaning raw dot-product errors of ~0.36 are almost entirely this ranking-irrelevant scale factor. To give a fair comparison, we report the debiased RMSE after fitting and removing the best multiplicative scale: $\alpha = \sum(\hat{d} \cdot d) / \sum(d^2)$, then measuring $\sqrt{\sum(\hat{d}/\alpha - d)^2 / \sum d^2}$.</p><p>Zero-mean corpus ($d = 768$, 500 vectors, 5 queries per vector, lower is better):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Angle</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ asymmetric (debiased)</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ 1-4 (debiased)</td><td style="border: 1px solid #ccc; padding: 4px;">TQ @1-bit (debiased)</td><td style="border: 1px solid #ccc; padding: 4px;">TQ @4-bit (debiased)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0035</td><td style="border: 1px solid #ccc; padding: 4px;">0.0067</td><td style="border: 1px solid #ccc; padding: 4px;">0.0083</td><td style="border: 1px solid #ccc; padding: 4px;">0.0052</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0042</td><td style="border: 1px solid #ccc; padding: 4px;">0.0060</td><td style="border: 1px solid #ccc; padding: 4px;">0.0085</td><td style="border: 1px solid #ccc; padding: 4px;">0.0052</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">10°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0057</td><td style="border: 1px solid #ccc; padding: 4px;">0.0074</td><td style="border: 1px solid #ccc; padding: 4px;">0.0091</td><td style="border: 1px solid #ccc; padding: 4px;">0.0052</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">20°</td><td style="border: 1px solid #ccc; padding: 4px;">0.010</td><td style="border: 1px solid #ccc; padding: 4px;">0.011</td><td style="border: 1px solid #ccc; padding: 4px;">0.012</td><td style="border: 1px solid #ccc; padding: 4px;">0.0053</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">45°</td><td style="border: 1px solid #ccc; padding: 4px;">0.027</td><td style="border: 1px solid #ccc; padding: 4px;">0.029</td><td style="border: 1px solid #ccc; padding: 4px;">0.025</td><td style="border: 1px solid #ccc; padding: 4px;">0.0060</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">60°</td><td style="border: 1px solid #ccc; padding: 4px;">0.048</td><td style="border: 1px solid #ccc; padding: 4px;">0.049</td><td style="border: 1px solid #ccc; padding: 4px;">0.042</td><td style="border: 1px solid #ccc; padding: 4px;">0.0074</td>
                    </tr>
              </tbody>
            </table>
          <p>Shifted corpus (shift = 2.0, modeling real embedding bias, lower is better):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Angle</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ asymmetric (debiased)</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ 1-4 (debiased)</td><td style="border: 1px solid #ccc; padding: 4px;">TQ @1-bit (debiased)</td><td style="border: 1px solid #ccc; padding: 4px;">TQ @4-bit (debiased)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0008</td><td style="border: 1px solid #ccc; padding: 4px;">0.0013</td><td style="border: 1px solid #ccc; padding: 4px;">0.0073</td><td style="border: 1px solid #ccc; padding: 4px;">0.0054</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0013</td><td style="border: 1px solid #ccc; padding: 4px;">0.0015</td><td style="border: 1px solid #ccc; padding: 4px;">0.0076</td><td style="border: 1px solid #ccc; padding: 4px;">0.0054</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">10°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0021</td><td style="border: 1px solid #ccc; padding: 4px;">0.0023</td><td style="border: 1px solid #ccc; padding: 4px;">0.0084</td><td style="border: 1px solid #ccc; padding: 4px;">0.0054</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">20°</td><td style="border: 1px solid #ccc; padding: 4px;">0.0041</td><td style="border: 1px solid #ccc; padding: 4px;">0.0043</td><td style="border: 1px solid #ccc; padding: 4px;">0.012</td><td style="border: 1px solid #ccc; padding: 4px;">0.0055</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">45°</td><td style="border: 1px solid #ccc; padding: 4px;">0.012</td><td style="border: 1px solid #ccc; padding: 4px;">0.012</td><td style="border: 1px solid #ccc; padding: 4px;">0.025</td><td style="border: 1px solid #ccc; padding: 4px;">0.0064</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">60°</td><td style="border: 1px solid #ccc; padding: 4px;">0.022</td><td style="border: 1px solid #ccc; padding: 4px;">0.022</td><td style="border: 1px solid #ccc; padding: 4px;">0.043</td><td style="border: 1px solid #ccc; padding: 4px;">0.0078</td>
                    </tr>
              </tbody>
            </table>
          <p>On zero-mean data, the raw error numbers (omitted for brevity, TQ @1-bit&#x27;s is ~0.363, almost entirely due to the $2/\pi$ multiplicative bias) are misleading; only the debiased ranking noise matters. The asymmetric column (float query, 1-bit document) is the most directly comparable to TQ since both quantize only the document: at 0° OSQ achieves 2.4× lower noise (0.0035 vs 0.0083). This is the payoff of the anisotropic loss ($\lambda = 0.1$), which concentrates accuracy along the query direction at the expense of off-axis components. The symmetric 4-bit query recovers some of this advantage (0.0035 → 0.0067), showing that query quantization is now the dominant noise source at small angles. Even so, OSQ symmetric still beats TQ @1-bit by 1.2–1.4× through 10°. The tradeoff is visible at wider angles where TQ @1-bit has <em>lower</em> noise than OSQ (0.042 vs 0.049 at 60°): the Hadamard rotation distributes information uniformly across all directions, while OSQ deliberately favors the directions that matter for search.</p><p>What about TurboQuant&#x27;s $Q_\text{prod}$ variant? TurboQuant&#x27;s inner-product variant ($Q_\text{prod}$) was designed to address exactly this bias, spending $b-1$ bits on the MSE quantizer and 1 bit on a QJL sketch of the residual to produce a provably unbiased estimator. At 1-bit $Q_\text{prod}$ is not viable (0 bits for MSE), so the minimum is 2-bit. But for ranking, the cure is worse than the disease: $Q_\text{prod}$ trades ranking-irrelevant bias for ranking-relevant noise. At 60°, $Q_\text{prod}$&#x27;s debiased noise is consistently higher than MSE-only at the same total bit width, 0.031 vs 0.025 at 2-bit and 0.011 vs 0.007 at 4-bit, because each bit spent on QJL correction would have been better spent on quantization. Since search cares only about ranking, MSE-only is the better choice. The bias is harmless and the extra quantization bit reduces the noise that actually matters.</p><p>The picture changes on shifted data, where centroid centering gives OSQ a decisive advantage. At 0° the debiased noise drops to 0.0008, which is 9× lower than TQ @1-bit&#x27;s 0.0073, and 7× lower than TQ @4-bit&#x27;s 0.0054. Centering removes the dominant shared component before quantization, letting the quantizer focus its bits on the information-bearing residual. TurboQuant&#x27;s data-oblivious rotation cannot exploit this structure. The advantage persists through 20° (OSQ 0.0041 vs TQ @1-bit 0.012) and only narrows at wide angles (60°: OSQ 0.022 vs TQ @1-bit 0.043), where OSQ remains competitive.</p><p>On shifted data, OSQ at 1-bit per document (debiased noise 0.001) beats TurboQuant at 4-bit per document (debiased noise 0.006): better ranking accuracy at over 5× less storage (768 bits vs 4,096 bits, since TQ pads 768→1024 for the Hadamard transform). This is the payoff of the data-dependent design: centering and anisotropic interval refinement extract structure that a data-oblivious rotation cannot.</p><p>TQ @4-bit MSE is consistently the lowest-noise option on zero-mean data (debiased 0.005-0.008 across all angles), but at 5× the storage cost per document. On shifted data it is actually substantially worse than OSQ symmetric for angles less than 20°.</p><h4>Head-to-head: throughput</h4><p>Throughput is where the uniform grid constraint really shines. Here are the throughput figures on $d = 768$, 10k documents, Apple M2 Max, 100 repetitions:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Bits</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ asymmetric</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ symmetric</td><td style="border: 1px solid #ccc; padding: 4px;">OSQ 1-4</td><td style="border: 1px solid #ccc; padding: 4px;">TurboQuant</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1</td><td style="border: 1px solid #ccc; padding: 4px;">67 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">7 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">—</td><td style="border: 1px solid #ccc; padding: 4px;">275 ns/doc</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td><td style="border: 1px solid #ccc; padding: 4px;">132 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">14 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">—</td><td style="border: 1px solid #ccc; padding: 4px;">293 ns/doc</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4</td><td style="border: 1px solid #ccc; padding: 4px;">94 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">22 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">14 ns/doc</td><td style="border: 1px solid #ccc; padding: 4px;">216 ns/doc</td>
                    </tr>
              </tbody>
            </table>
          <p>OSQ&#x27;s symmetric kernels are 10–40× faster than TurboQuant!</p><p>We made a fair effort to optimize both implementations to use ARM NEON instructions effectively, but do not claim these are optimal. The key techniques:</p><p>The 1-bit kernel reduces to <code>popcount(a AND b)</code> via NEON&#x27;s <code>vcntq_u8</code>, processing 32 bytes per iteration with dual accumulators for pipeline parallelism. For $d = 768$ the entire packed vector is 96 bytes, a single pass yields 7 ns/doc.</p><p>The 2-bit kernel decomposes each 2-bit index into two bit-planes (precomputed at quantize time), reducing the dot product to 4 AND+popcount passes over the same 96-byte planes: $\sum(2x_1+x_0)(2y_1+y_0) = 4 \cdot \text{pc}(x_1 \wedge y_1) + 2 \cdot \text{pc}(x_1 \wedge y_0) + 2 \cdot \text{pc}(x_0 \wedge y_1) + \text{pc}(x_0 \wedge y_0)$. At 14 ns/doc this is 2× the 1-bit time rather than the naive 4× because all four plane pairs share the same data loads, each 96-byte plane is read once and reused across passes.</p><p>The 4-bit kernel uses direct NEON nibble multiply with <code>vandq/vshrq</code> to split packed bytes into lo/hi nibbles, multiply, and accumulate via <code>vpaddlq_u8 </code>widening adds. At 22 ns/doc, this is faster than the 16-popcount bit-plane alternative ($4^2 = 16$ plane combinations).</p><p>A mixed 4×1 kernel is the production workhorse. It precomputes the 4-bit query&#x27;s 4 bit-planes at quantize time (each 96 bytes in the same 1-bit packed layout as the document). Per-document scoring is then 4 AND+popcount passes, i.e. the RaBitQ decomposition: $\sum \text{idx4}_i \times \text{idx1}_i = 8 \cdot \text{pc}(\text{plane3} \wedge \text{bits1}) + 4 \cdot \text{pc}(\text{plane2} \wedge \text{bits1}) + \ldots$ At 14 ns/doc this is 21.3× faster than TurboQuant&#x27;s 1-bit path at 3/4 the document storage.</p><p>TurboQuant&#x27;s bottleneck is the data-dependent gather: each coordinate requires a scalar load from the centroid table to build a NEON float vector. The arithmetic (FMA) is essentially free in comparison.</p><h4>Conclusion</h4><p>TurboQuant is a theoretically elegant construction that builds directly on the OSQ formulation. The provable MSE bound, the unbiased inner-product estimator, and the clean data-oblivious design are real contributions. For applications requiring calibrated scores (not just rankings), or running on GPU hardware where gather operations are cheap, TurboQuant&#x27;s architecture is well-motivated. The calibration-free design is also a natural fit for settings where quantization must happen on the fly with zero training overhead, KV cache compression during LLM inference is a prime example. There, every vector is quantized once as it enters the cache and discarded after the forward pass, so there is no opportunity to amortize a per-vector coordinate descent. A fixed codebook derived from the known post-rotation distribution is exactly the right tool: rotate, snap, store.</p><p>But for CPU-based vector search, the setting where Elasticsearch and most operational systems execute queries, the empirical picture is clear across all three axes:</p><p><strong>MSE:</strong> TurboQuant&#x27;s advantage comes from the Hadamard rotation, not the Lloyd-Max centroids. OSQ with the same rotation matches TurboQuant at 1–2 bits and comes within 1.1× at 3–4 bits. OSQ&#x27;s sparse preconditioner already provides this benefit without padding overhead.</p><p><strong>Dot-product accuracy:</strong> After removing ranking-irrelevant multiplicative bias (including TQ&#x27;s $2/\pi$ scale factor at 1-bit), OSQ has 1.2–1.4× lower ranking noise than TQ @1-bit at small angles on zero-mean data even with a quantized query and without the 25% pad, thanks to the anisotropic loss concentrating accuracy along the query direction. On shifted data, the regime that matters in practice because embeddings typically have a non-zero mean, centering amplifies the advantage further: debiased noise of 0.0008 at 0° vs TQ @1-bit&#x27;s 0.0073 and even TQ @4-bit&#x27;s 0.0054. OSQ at 1-bit beats TurboQuant at 4-bit on ranking accuracy at less than 1/5 the storage. TurboQuant&#x27;s $Q_\text{prod}$ variant addresses bias explicitly but trades it for higher noise, making MSE-only the better choice for search.</p><p><strong>Throughput:</strong> 10–40× faster symmetric scoring, with the mixed 4-1 kernel at 14 ns/doc versus TurboQuant&#x27;s 293 ns/doc using NEON intrinsics. This reflects a fundamental architectural divide between integer arithmetic and lookup-table gather, not a constant factor that disappears with batching.</p><p>The uniform grid, far from being a compromise, turns out to be the right trade: it sacrifices a theoretical MSE margin that almost vanishes under equivalent rotation, and in return unlocks the integer-arithmetic pipeline that makes sub-millisecond search at scale practical.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch ES|QL views: One query to rule twelve dashboards]]></title>
            <description><![CDATA[With ES|QL views, you only need one query for multiple dashboards. Define it once and let Elasticsearch keep everything in sync.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-esql-logical-views</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-esql-logical-views</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tyler Perkins]]></dc:creator>
            <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/852bff427ac62b79974d88e27ce9670dc132bc46-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch Query Language (ES|QL) now has <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-views">logical views</a>. Define a query once, and reference it by name in <code>FROM</code>, like an index. Twelve dashboards, one definition, zero copy-paste. Update the view, and every consumer gets the change automatically.</p><p>Views don&#x27;t store data; they re-execute on every read, so results always reflect the current data and the current definition. If you&#x27;ve used views in SQL databases, this will feel familiar. The difference: ES|QL views are engine-level virtual indices stored at the Elasticsearch cluster level, not saved query text that gets expanded client-side. They appear in <a href="https://www.elastic.co/kibana">Kibana</a> autocomplete, support <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-cross-clusters.html">cross-cluster search</a> (CCS), and are governed by dedicated role-based access control (RBAC) privileges.</p><h2>A simple view</h2><p>A view can wrap any ES|QL query. Start with a straightforward filter — HTTP 500 errors from the API gateway:</p><pre><code>PUT _query/view/error_triage
{
  "query": """
    FROM svc-gateway-*
    | WHERE http.response.status_code >= 500
    | KEEP @timestamp, http.response.status_code, url.path, source.ip
  """
}</code></pre><p>Now anyone can write <code>FROM error_triage</code> without knowing the index pattern or filter condition:</p><pre><code>FROM error_triage
| STATS error_count = COUNT(*) BY url.path
| SORT error_count DESC</code></pre><p>The query is defined once. Consumers reference a name.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/68ff5a52b0f3ed3dfaa07d2af6e7f08a8c9c0f55-1999x702.png" style="width: 100%;" /><p>Views support full create, read, list, update, and delete (CRUD) via the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-esql-put-view"><code>_query/view REST API</code></a>.</p><h2>Update propagation</h2><p>Say the team decides <code>error_triage</code> should also capture client errors, not just 500s. Update the definition in place:</p><pre><code>PUT _query/view/error_triage
{
  "query": """
    FROM svc-gateway-*
    | WHERE http.response.status_code >= 400
    | KEEP @timestamp, http.response.status_code, url.path, source.ip
  """
}</code></pre><p>Every dashboard panel, alert rule, and ad-hoc query using <code>FROM error_triage</code> immediately reflects the broader filter. No saved objects to hunt down. No stale copies. Change once, update everywhere.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/aad2b22fd4d45f8db1140ae93429c6b9ca345031-1999x440.png" style="width: 100%;" /><h2>Nested views</h2><p>Views can reference other views, enabling layered abstractions. Create views for suspicious IPs and threat intelligence, and then compose them:</p><pre><code>PUT _query/view/suspicious_ips
{
  "query": """
    FROM svc-auth-*
    | WHERE event.action == "login" AND event.outcome == "failure"
    | STATS attempts    = COUNT(*),
            first_seen  = FIRST(@timestamp, @timestamp),
            latest_user = LAST(user.name, @timestamp)
        BY source.ip
    | WHERE attempts > 3
  """
}

PUT _query/view/known_threats
{
  "query": """
    FROM threat-intel
  """
}

PUT _query/view/security_overview
{
  "query": """
    FROM suspicious_ips, known_threats
  """
}

FROM security_overview
| WHERE source.ip IS NOT NULL
| EVAL is_known_threat = threat.category IS NOT NULL
| KEEP source.ip, attempts, threat.category, threat.severity, is_known_threat
| SORT is_known_threat DESC, attempts DESC</code></pre><p>Security teams query <code>FROM security_overview</code> without knowing the underlying data model. They&#x27;re also shielded from any changes made to <code>suspicious_ips</code> by its owner; the abstraction boundary is real, not syntactic.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fd8f17e0a5bd80a73737f0fe08f9e3480d70ad68-1822x642.png" style="width: 100%;" /><h2>Multisource views with subqueries</h2><p>A view can wrap any ES|QL query, including multisource compositions, using <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from"><code>subqueries in FROM</code></a>. Each subquery branch queries one service independently (its own filters, its own field normalization), and the results combine automatically:</p><pre><code>PUT _query/view/all_errors
{
  "query": """
    FROM
      (FROM svc-gateway-*
       | WHERE http.response.status_code >= 500
       | EVAL service = "gateway",
              error_detail = CONCAT("HTTP ", http.response.status_code::string)
       | KEEP @timestamp, service, error_detail, source.ip),
      (FROM svc-payments-*
       | WHERE transaction.status IN ("failed", "timeout")
       | EVAL service = "payments", error_detail = transaction.status
       | KEEP @timestamp, service, error_detail, source.ip)
  """
}</code></pre><p>Consumers just write:</p><pre><code>FROM all_errors
| STATS error_count = COUNT(*) BY service
| SORT error_count DESC</code></pre><p>Two indices, two independent pipelines, one name. To add a third service later, add a third branch; existing branches don&#x27;t change, and every downstream dashboard and alert reflects the update automatically. For a deep dive on subquery syntax and what you can do inside each branch, see <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from">Three Indices Walk Into a FROM Clause</a>.</p><h2>How views work under the hood</h2><p>When you write <code>FROM view_name</code>, ES|QL resolves the view&#x27;s stored query and executes it inline. Views are re-executed on every read, so results always reflect the current data and the current definition.</p><p>Views share a namespace with indices, aliases, and data streams. A view cannot have the same name as any of these (enforced at creation time). This keeps <code>FROM my_name</code> unambiguous regardless of whether the name resolves to a view, an index, or an alias.</p><h2>Security model</h2><p>Views are governed by four dedicated RBAC privileges: <code>create_view</code>, <code>read_view_metadata</code>, <code>delete_view</code>, and <code>manage_view</code>. Elasticsearch checks the privileges of the user running the query (invoker security), not the user who defined the view. The user querying a view needs permissions on both the view and its underlying indices.</p><h2>Kibana integration</h2><p>Views appear in Discover&#x27;s ES|QL editor autocomplete alongside indices. ES|QL-based dashboard panels work with views transparently. In the initial Tech Preview release, view management is API-only. A Kibana UI for creating and managing views is planned.</p><h2>Cross-cluster search</h2><p>A view&#x27;s definition can reference remote indices using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-cross-clusters.html">CCS syntax</a>:</p><pre><code>PUT _query/view/cross_cluster_errors
{
  "query": """
    FROM cluster-west:logs-*, cluster-east:logs-*
    | WHERE log.level IN ("error", "crit")
  """
}</code></pre><p>Consumers query <code>FROM cross_cluster_errors</code> without knowing which clusters are involved.</p><h2>Current constraints</h2><p>In the Tech Preview release, view management is API-only and SET directives can&#x27;t appear inside view definitions; the caller applies them when querying. Subquery-based views can&#x27;t be nested inside other multisource <code>FROM</code> expressions. See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/views">views documentation</a> for the full list.</p><h2>What&#x27;s next for views</h2><p>Views today are always fresh; they re-execute on read. <a href="https://github.com/elastic/roadmap/issues/49">Materialized views</a> flip that tradeoff: Pre-compute once, read instantly. Think pre-aggregated rollup views for Service Level Agreement (SLA) dashboards that load in milliseconds instead of scanning raw data on every refresh. A Kibana CRUD UI for views, including a &quot;Save as View&quot; workflow in Discover, is also planned.</p><h2>Try it</h2><p>Logical views are available as a Tech Preview. Try them in <a href="https://www.elastic.co/kibana">Kibana</a> Dev Tools or Discover. We&#x27;d love your feedback; file a <a href="https://github.com/elastic/elasticsearch/issues">GitHub issue</a> with the <code>ES|QL</code> label.</p><p><em>ES|QL logical views are a Tech Preview feature. Tech Preview features are subject to change and are not covered by the support SLA of GA features. The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elastic Agent Builder: How we taught AI agents to manage their own context]]></title>
            <description><![CDATA[Agent Builder in Elasticsearch 9.4 ships dynamically loaded skills, a conversation context store, selective compaction, and external connectors to cut token costs by 40% and let agents handle their own context management.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-agent-builder-ai-agents-context-management</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-agent-builder-ai-agents-context-management</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Anish Mathur, Deepti Dheer, Evan Castle]]></dc:creator>
            <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ae1daed5e4424866a30e20884493f859f65a9fd2-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Every developer building agents has to answer one question: what should the agent know, and when? The usual starting point is simple. Write a system prompt, connect a few tools, and the agent works. But as the scope grows, you add more instructions, sources and tools, and runs get longer. Eventually the context window fills up, fidelity drops and token costs climb. Managing context is a first-class concern. This post shares how we built context handling into agents so they can decide what to fetch, what to summarize, what to drop, and how memory should carry across steps.</p><p>Agent Builder in 9.4 makes context the agent&#x27;s problem, not yours. Skills provide reusable instructions that load on demand, so only what the current task needs is in context. Large result sets go into a conversation context store rather than sitting in the prompt. For long-running tasks, context gets compacted selectively so the agent doesn&#x27;t drift. Token and turn counts are monitored as the conversation runs. And connectors handle reaching enterprise data where it lives.</p><p>The goal of all of it is the same. Load the context you need, when you need it. Internally we&#x27;ve seen this cut token costs by up to 40%, and the agent&#x27;s context stays reliable across datasets when it would have degraded before.</p><h2>Getting agents to know what you know</h2><p>Three context problems show up again and again: managing bloated prompts, executing complex actions, and controlling enterprise data sources.</p><p>Firstly, prompts get bloated because every instruction has to live in them. Skills fix that by loading on demand and with fewer input tokens. <strong>Skills </strong>provide structured descriptions of how an agent should operate and act in a specific task. Agent Builder ships with built-in skills for common data analysis patterns, but the real value is that users can also build custom skills. A security team can encode its triage playbook as a skill. An SRE team can describe how they want root cause analysis to proceed. A developer can encode their API design conventions and error handling patterns. Skills are reusable and shareable across agents, which means a pattern that works for one team&#x27;s deployment doesn&#x27;t have to be reinvented by the next.</p><p>In practice, this looks like: a team lead defines a &quot;Summarize this incident&quot; skill with the process they care about, the severity classification their org uses, and the output format their runbook expects. Anyone on the team invokes it by typing in the chat input and selecting it from autocomplete. Skills follow the <a href="https://github.com/elastic/agent-skills">Agent Skills open format</a>, so you can pull them from a shared library, write your own, or craft skills using an agent of your choice.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ad073b40ea280077e71225df2b5747c9a5061f3-1280x766.gif" style="width: 100%;" /><p>In internal testing, we found that removing instructions from the agent prompt and placing them in dynamically loaded skills showed a 21 to 39% reduction in input token usage across test datasets. The key architectural improvement is that skills and their associated tools are loaded only when the agent needs them. All other skills stay as lightweight stubs with just a name and description, consuming almost no context.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2479d95570de548aad7d47f036da50e00e13ff56-1328x1176.png" style="width: 100%;" /><p><strong>Chat with your data and act on it too (dashboards, workflows, queries and more): </strong>Agentic tasks don&#x27;t stay simple for long. Agent Builder now has contextual awareness of objects in Kibana. With agentic dashboard creation, a user can describe what they want to see in plain language, and the agent generates a dashboard with panels, visualizations, queries, and everything that&#x27;s needed. Users can refine it conversationally: &quot;break that out by region,&quot; &quot;add a filter for the last 7 days,&quot; &quot;swap the bar chart for a line chart.&quot;</p><p>Dashboards, alerts, and rules also work as inputs. Once a dashboard exists, it can be retrieved from the Agent’s context. This unlocks the &quot;act&quot; side of agents. Once a dashboard or alert is in context, the agent can modify it, extend it, or create new ones. The agent can reason about what the data shows, suggest follow-up analyses, or modify the dashboard based on what it sees. It&#x27;s a feedback loop: the user describes intent, the agent produces a visual artifact, and both the user and the agent can reason over that artifact together.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0a931862b202e7293c44b1e9fbcde199480a5187-1280x766.gif" style="width: 100%;" /><p>For business analysts and operations teams, this collapses the gap between &quot;I have a question about the data&quot; and &quot;I have a dashboard I can share with my team,&quot; reducing hours of manual work to a few minutes of conversation.</p><p>Lastly, using enterprise data for context creates governance you didn&#x27;t ask for. <strong>Connectors</strong> close the loop for data that lives outside Elastic. We added prebuilt OAuth-based connectors for sources like Google Drive, Salesforce, and Slack. The design principle here is worth calling out: data stays at the source. The agent searches data via the connector with the user&#x27;s own permissions enforced. Agents do not accumulate copies of enterprise data in new locations just to be able to answer questions about it.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/67d8d0c028611faa04d84ce5dbab8078fbf30fa3-1280x767.gif" style="width: 100%;" /><p>This matters more than it might seem. Enterprise data governance isn&#x27;t just a compliance checkbox; it&#x27;s a load-bearing infrastructure that most teams don&#x27;t notice until it fails. When agents start routing around it, accumulating copies in vector stores and context windows, you&#x27;ve quietly created a new class of data sprawl that your security team didn&#x27;t sign off on, and your audit logs don&#x27;t capture. The connector approach eliminates this risk by constraint: if the data never moves, it can&#x27;t end up somewhere it shouldn&#x27;t. The user&#x27;s permissions travel with every query because the query goes to the source, not to a cached copy. You get agents that are genuinely useful on enterprise data.</p><h2>Ensure agents don&#x27;t outgrow the context window</h2><p>Giving agents too much context creates a new problem. A security analyst investigating a complex threat might pull in dozens of alerts, correlate across multiple indices, and go back and forth with the agent for twenty or thirty turns. At some point, you&#x27;re pushing past what the context window can hold and degrading the quality of the model&#x27;s responses. The problem is that each retrieval call adds latency to the user&#x27;s request and pushes infrastructure costs higher, and a single user interaction can trigger dozens of these calls.</p><p><strong>We built a context store for retrieval results</strong>. As the agent retrieves data from indexes, the results can grow large and crowd the context window. We introduced a temporary store that holds the results of a query in an in-memory “file store” and only pulls the results into the active context when needed. This allows for conversations to extend and deal with multiple related data sets without blowing out the context. We are also optimizing the retrieval results themselves, applying top snippets retrieval, which demonstrated a 27 to 34% reduction in token usage.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1ca179ece56a059ebc05b4fc44b48d25c6bc2679-1292x1278.png" style="width: 100%;" /><p>We also added intelligent context compaction for longer interactions: As a conversation progresses, the agent manages what stays in the active context and what gets compressed into a summary that can be retrieved if needed. This isn&#x27;t a simple truncation; it&#x27;s selective compaction that preserves the information most likely to matter for the next turn.</p><p>This enables agents to handle larger result sets, more complex queries, and longer conversations without the token cost scaling linearly with every turn. With context compaction agents, the context window remains within a limit even for chats with 30 or more turns, rather than quickly ballooning to max size.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0541cdd4b21eaf45b92fc90e747f49473aefe65d-983x502.png" style="width: 100%;" /><p>For teams running multi-step investigations or summarizations, this is the difference between an agent that stays coherent through turn thirty and one that starts contradicting itself at turn twelve.</p><p><strong>Monitoring: </strong>In 9.4, we also shipped monitoring for agents to track token usage. With an API available to monitor conversation turns and tool calIs. This matters because agents aren&#x27;t static. Their behavior shifts based on the context they receive and the tools they call, and without visibility into those patterns, optimizing cost and performance is guesswork.</p><h2>Agentic consumption model</h2><p>To support these new capabilities, we&#x27;re introducing an agent pricing model that directly aligns the value users gain from their agents and how they scale. Agent Builder usage will be measured by Executions. Executions are free for the first 1,000 each month in Elasticsearch and 10,000 in Elastic Security and Observability projects.</p><p>An Agent Builder execution represents a completed round of interaction with the agen. In most cases, sending a chat message and receiving a successful response from the agent counts as one execution. For messages that demand significant processing, it will be calculated as multiple executions based on the total number of input tokens required, grouped into 50,000 input token units. For example, a deep investigative task that requires 130,000 input tokens will be billed as 3 executions. This model ensures your consumption aligns with the value your agents deliver and becomes more cost-effective as your agents achieve greater context efficiency.</p><h2>Where are we going with agents</h2><p>Agents that can optimize context over operational data need the same kind of careful context engineering that we&#x27;ve spent years applying to search relevance. Getting the right information in front of the model at the right time and at the right level of detail is the new retrieval problem. These capabilities are foundational towards enabling agents that are more reliable, scalable, and cost-efficient as they scale.</p><p>Get started with an <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;pg=en-enterprise-search-page">Elastic Cloud Trial</a>, and check out the documentation <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">here</a>. For existing customers, Agent Builder is available in Cloud Serverless and on the Enterprise Tier in Elastic Cloud Hosted and self-managed.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch percolator for ecommerce search governance: translating ambiguous queries into controlled retrieval strategies]]></title>
            <description><![CDATA[Learn how to use the Elasticsearch percolator to implement search governance. In this blog, we outline the patterns needed to create a governed policy engine in production and create a controlled retrieval strategy.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e485cdd52d78419ff0ac30a4192b953f6d70c61-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>This post is a technical deep dive into the Elasticsearch implementation of the control plane architecture described in <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a>, showing how to build it using the Elasticsearch percolator. It outlines the patterns used to implement a deterministic, governed policy engine in production.</p><h2><strong>From architecture to implementation</strong></h2><p><a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> described the control plane architecture: reverse matching as a lookup primitive, policy documents that separate match from action, and cascading transformations that compose multiple policies into a single execution plan. This post goes hands-on with the Elasticsearch feature that powers the policy lookup: the <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-percolate-query">percolator query</a>.</p><p>The percolator is a natural fit for governance because it inverts the direction of search in exactly the way a control plane needs. This post walks through the implementation step by step, starting with a clear explanation of what the percolator does and why it matters, and then moving through index design, policy storage, query-time evaluation, and multi-policy composition.</p><h2><strong>How normal search works</strong></h2><p>In an ecommerce system, you may have hundreds of thousands or millions of product documents containing fields such as <code>title</code>, <code>category</code>, and <code>price</code>. When a user searches for matching documents, you&#x27;re asking Elasticsearch to compare the user’s search string against one or more stored fields in these product documents. Elasticsearch&#x27;s default analyzer, <a href="https://www.elastic.co/docs/reference/text-analysis/analysis-standard-analyzer">the standard analyzer</a>, lowercases text and splits it into tokens. A search for “oranges” matches “Oranges” because of lowercasing. With a language-aware analyzer that includes stemming, it also matches “orange” because both forms reduce to the same stem. For example, the following <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-match-query">match query</a> returns documents that have “orange” or “oranges” in their <code>“title”</code> field.</p><pre><code>POST products/_search
{
  "query": {
    "match": {
      "title": "oranges"
    }
  }
}</code></pre><p>So for the above query, Elasticsearch returns the product documents whose <code>title</code> field matches “oranges”, which could include results such as “Orange Fruit Spread”, “Orange Juice”, “Juicy oranges”, “Orange Marmalade”, and so on. The key point to remember is that Elasticsearch is commonly used to compare a search string against documents and to return the documents that match the search string.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ba758f25616f2106d245ce0d47926c174766e028-642x318.png" style="width: 100%;" /><h2><strong>The governance problem: Finding relevant policies before searching for products</strong></h2><p>As established in <a href="https://www.elastic.co/search-labs/blog/series/governed-search-patterns">Parts 1 through 3</a>, a governed search system does not send the user&#x27;s search string directly to the product catalog. First, it checks whether any policies apply to that search string.</p><p>A merchandiser has decided that when someone searches for exactly &quot;oranges&quot;, results should be restricted to the Oranges category, eliminating orange juice, orange marmalade, and orange soda. That business decision is stored as a policy. When a user types &quot;oranges&quot;, the control plane needs to find that policy, read its instructions, and modify the search against the product catalog accordingly. In order to do this, the control plane needs to figure out which stored policies are relevant for this search string.</p><p>An enterprise deployment might have hundreds or thousands such policies. Checking them one by one with if/else logic is the application-layer anti-pattern described in <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">Part 2</a>. What we need is a way to store all of those policies in an index and instantly find the ones that match a given search string. This is where the percolator comes in.</p><h2><strong>Flipping the direction: The percolator</strong></h2><p>We previously mentioned that in a normal search, Elasticsearch is commonly used to compare a search string against documents and to return the documents that contain that search string.</p><p>The percolator inverts this. With a percolator, you have an index where each document stores a query pattern, and then an incoming search string is checked against these stored queries to determine which of these stored query patterns has triggered.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1e6348531d1c0be57b385f51d248488cf58489ff-642x279.png" style="width: 100%;" /><p>For governance, the &quot;stored query patterns&quot; are policies. Each policy contains a pattern that describes the kind of search string it should match. For example, does the search string exactly match “oranges”, or does the search string contain “olive oil”? The incoming string is the user&#x27;s search text, which arrives at query time and needs to be checked against all stored policy patterns. This is covered in a <a href="https://youtu.be/Ap5K2Y00Xjc?t=246">related PRISM video at 4:09</a>.</p><h2>Step by step: How a search for &quot;oranges&quot; finds its policy</h2><h3>The policy</h3><p>A merchandiser has authored a policy that matches if a user searches for exactly &quot;oranges&quot; without any other words. Once the percolator matches, the remainder of the document includes the rules that the control plane will use to build the Product query; in this example, one of the rules is to restrict (filter) results to the Fruits category.</p><pre><code>{
  "percolator": {
    "match_phrase": { "query": "START oranges END" }
  },
  "rule_type": "filter",
  "rule_args": {
    "filters": [
      {
        "field": "categories",
        "values": ["Fruits"],
        "mode": "hard_filter",
        "on_conflict": "soft_boost",
        "on_conflict_boost_weight": 1.0
      }
    ]
  },
  "priority": 0,
  "enabled": true
}</code></pre><p>The <code>percolator</code> field contains the pattern that defines when this policy should fire. In this case, it matches the phrase <code>&quot;START oranges END&quot;</code>. The <code>rule_type</code> and <code>rule_args</code> fields define what the policy should do when it fires. The <code>START</code> and <code>END</code> tokens are boundary markers, which we will explain shortly.</p><p>You can see how a policy is authored in the PRISM Studio UI at <a href="https://youtu.be/Ap5K2Y00Xjc?t=172">2:52 of the related PRISM video</a>.</p><h3>The user searches</h3><p>A shopper types &quot;oranges&quot; into the search bar.</p><h3>The control plane checks for matching policies</h3><p>Before searching the product catalog, the control plane intercepts the user search string, wraps it in boundary markers, and sends it to the percolator:</p><pre><code>POST policies/_search
{
  "query": {
    "percolate": {
      "field": "percolator",
      "document": {
        "query": "START oranges END"
      }
    }
  }
}</code></pre><p>The string <code>&quot;START oranges END&quot;</code> is checked against all stored policy patterns. Internally, Elasticsearch runs the stored policy patterns against this string and returns the ones that match. That&#x27;s the percolator. The user&#x27;s search string was checked against all stored policy patterns, and the ones that matched were returned. No if/else chains. No sequential evaluation. The index handles the matching.</p><h3>The control plane applies the policy</h3><p>The control plane reads the matched policies’ actions. The above policy instructs the control plane to restrict results to the Fruits category. The control plane builds the final Elasticsearch query against the product catalog as follows:</p><pre><code>POST products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "oranges" } }
      ],
      "filter": [
        { "terms": { "categories": ["Fruits"] } }
      ]
    }
  }
}</code></pre><p>The user searched for &quot;oranges”. The product catalog receives a query for &quot;oranges&quot; constrained to the Fruits category. Because of this constraint, orange juice, orange marmalade, and orange soda are excluded.</p><h3>Why &quot;orange marmalade&quot; does NOT trigger the oranges policy</h3><p>Suppose a different user searches for &quot;orange marmalade”. The control plane wraps the string and percolates: <code>&quot;START orange marmalade END&quot;</code>. The oranges policy&#x27;s pattern is <code>match_phrase: &quot;START oranges END&quot;</code>. The oranges policy does not match and therefore the policy isn’t applied, and the results aren’t constrained to the Fruits category.</p><p>This is the purpose of the <code>START</code> and <code>END</code> boundary markers. Without them, a policy that matches on the word &quot;oranges&quot; could accidentally fire on a query like &quot;orange marmalade&quot;. By wrapping the user&#x27;s search string with <code>START</code> and <code>END</code> and including those markers in the policy&#x27;s pattern, we ensure that the policy only fires when &quot;oranges&quot; is the complete search string, without any other words. This matches both the shoppers and the merchandiser&#x27;s intent.</p><h2>A second policy: &quot;olive oil&quot; on the stemmed field</h2><p>Not every policy needs an exact string match. The “olive oil” policy matches on a stemmed field, so it fires regardless of minor word-form variations:</p><pre><code>{
  "percolator": {
    "bool": {
      "should": [
        { "match_phrase": { "query.stemmed": "START olive oil END" } }
      ]
    }
  },
  "rule_type": "filter",
  "rule_args": {
    "filters": [
      {
        "field": "categories",
        "values": ["Olive oils"],
        "mode": "hard_filter",
        "on_conflict": "soft_boost",
        "on_conflict_boost_weight": 1.0
      }
    ]
  },
  "priority": 300,
  "enabled": true
}</code></pre><p>This policy&#x27;s pattern matches against <code>query.stemmed</code> instead of <code>query</code>. When the user&#x27;s search string arrives, it’s stored in both a <code>query</code> field (the exact text) and a <code>query.stemmed</code> field (analyzed with a stemming analyzer that reduces words to their stems, so &quot;olives&quot; and &quot;olive&quot; both reduce to the same stem, as do &quot;oils&quot; and &quot;oil&quot;). The policy&#x27;s pattern is checked against the stemmed version of the string, so it fires regardless of minor word-form variations.</p><p>The <code>START</code> and <code>END</code> boundary markers work on the stemmed field, as well, ensuring this policy only fires when &quot;olive oil&quot; is the entire search string, not when it appears as part of something longer.</p><p>The rest of this post covers the implementation details that make this production-ready: the index mapping that supports both matching modes, how highlights drive phrase removal and consumed phrase tracking, and how multiple conflicting policies compose into a single execution plan.</p><h2><strong>The policy index mapping</strong></h2><p>The policy index needs a percolator field to hold stored query patterns and a text field that mirrors the structure of the incoming search string the percolator will match against. The mapping below is simplified for clarity. A production deployment is more complex, using custom analyzers to handle boundary markers, variable pattern matching (for example, recognizing that &quot;under $4&quot; contains a currency value), and other kinds of analysis.</p><pre><code>PUT policies
{
  "mappings": {
    "properties": {
      "percolator": {
        "type": "percolator"
      },
      "query": {
        "type": "text",
        "fields": {
          "stemmed": {
            "type": "text",
            "analyzer": "stemming"
          }
        }
      },
      "rule_type": { "type": "keyword" },
      "rule_args": { "type": "object", "enabled": false },
      "priority": { "type": "integer" },
      "enabled": { "type": "boolean" }
    }
  }
}</code></pre><p>The index is named <code>policies</code> because each document represents a complete governed policy as defined in <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">Part 2</a>. This includes match criteria, action, priority, and metadata. The <code>rule_type</code> and <code>rule_args</code> fields contain the action component of the policy, which contain the instructions that the control plane will use to compose the query for execution against the product catalog.</p><p>The <code>query</code> field is the string that the percolator matches against. It has two variants: an exact version and a stemmed version. When the user&#x27;s search string arrives, it’s placed into this field in the temporary in-memory index. Policies that match on <code>query</code> see the exact string; policies that match on <code>query.stemmed</code> see the stemmed version.</p><h2><strong>Percolating with highlights, filtering, and sorting</strong></h2><p>The simple examples above showed minimal percolation requests. In practice, the control plane adds highlighting, filters disabled policies, and sorts by priority:</p><pre><code>POST policies/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "percolate": {
            "field": "percolator",
            "document": {
              "query": "START olive oil END"
            }
          }
        },
        {
          "term": { "enabled": true }
        }
      ]
    }
  },
  "highlight": {
    "fields": {
      "query": {
        "matched_fields": ["query.stemmed"]
      }
    }
  },
  "sort": [
    { "priority": { "order": "desc" } }
  ]
}</code></pre><p>The highlight configuration uses <code>&quot;query&quot;</code> as the field key with <code>&quot;query.stemmed&quot;</code> in <code>matched_fields</code>. This tells Elasticsearch&#x27;s unified <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/highlighting">highlighter</a> to return highlights on the parent <code>query</code> field but to also consider matches from the <code>query.stemmed</code> subfield when determining which tokens to highlight. This is what allows a policy that matches on the stemmed field to still produce accurate highlight spans on the original text, which the control plane needs for phrase removal and consumed phrase tracking.</p><p>The <code>enabled: true</code> filter ensures that disabled policies are skipped. The <code>sort</code> on priority ensures that higher-priority policies are returned first, so the control plane can process them in the correct order for cascading transformations. The <code>highlight</code> field is the most important addition; it tells us exactly which words in the user&#x27;s search string triggered each match.</p><p>The response for an &quot;olive oil&quot; search may look as follows:</p><pre><code>{
  "hits": {
    "hits": [
      {
        "_id": "en_2c3021c8",
        "_source": {
          "rule_type": "filter",
          "rule_args": {
            "filters": [
              {
                "field": "categories",
                "values": ["Olive oils"],
                "mode": "hard_filter",
                "on_conflict": "soft_boost",
                "on_conflict_boost_weight": 1.0
              }
            ]
          },
          "priority": 300
        },
        "highlight": {
          "query": ["<em>START olive oil END</em>"]
        }
      }
    ]
  }
}</code></pre><h2><strong>Why highlights matter</strong></h2><p>Notice the highlight in the response: <code>&quot;&lt;em&gt;START olive oil END&lt;/em&gt;&quot;</code>. Elasticsearch is telling us exactly which words in the user&#x27;s search string caused the policy to match. This isn’t cosmetic. The highlight metadata drives two critical downstream behaviors:</p><p><strong>Phrase removal.</strong> Some policies need to remove the matched text from the search string before constructing the product catalog query. For example, a policy that matches on &quot;cheap&quot; removes that word and converts it into a price filter instead. The highlight identifies exactly which span of the search string the policy matched, so the system knows what to remove.</p><p><strong>Consumed phrase tracking.</strong> As described in <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a>, when multiple policies match the same search string, a higher-priority policy might remove words that a lower-priority policy also matched on. By comparing each policy&#x27;s highlight against the current (evolving) search string, the system can detect that a phrase has been consumed and skip the lower-priority policy. This prevents double-processing and ensures deterministic behavior.</p><p>You can learn more about how highlighting works in <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/how-es-highlighters-work-internally">this article</a>.</p><h2><strong>From percolation to execution plan</strong></h2><p>The percolator returns a set of matching policies. But as <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> described, the lookup is only half the story. The other half is composing those matches into a coherent execution plan. Here’s what that looks like for a concrete query.</p><h3><strong>Worked example: &quot;Cheap chocolate&quot; during a Christmas campaign</strong></h3><p>Suppose the system has two active policies: the &quot;Cheap chocolate&quot; policy (priority 210) and the &quot;Christmas chocolates&quot; policy (priority 300), both described in detail in <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a>.</p><p><strong>Step 1: Percolate.</strong> The user searches for &quot;cheap chocolate.&quot; The control plane wraps the search string as <code>&quot;START cheap chocolate END&quot;</code> and sends it to the percolator. Two policies match: The &quot;Cheap chocolate&quot; policy&#x27;s pattern matches on the phrase &quot;cheap chocolate&quot;; and the &quot;Christmas chocolates&quot; policy&#x27;s pattern matches on &quot;chocolate&quot; via the stemmed field.</p><p><strong>Step 2: Sort by priority.</strong> The percolator returns both policies, sorted by priority in descending order. The “Christmas chocolates” policy (300) is processed first, followed by the “Cheap chocolate” policy (210).</p><p><strong>Step 3: Apply the cascading transformation.</strong> This is the <code>initial state → [Policy A] → state&#x27; → [Policy B] → state&#x27;&#x27; → execution plan</code> model from <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a>.</p><p>The “Christmas chocolates” policy (priority 300) applies first:</p><ul><li>Adds a category hard filter: &quot;Christmas foods and drinks,&quot; &quot;Christmas sweets&quot;.</li><li>Adds a price filter: less than $7.</li><li>Adds a category soft boost: &quot;Advent calendars&quot; (3x).</li></ul><p>The “Cheap chocolate” policy (priority 210) applies next against the modified state:</p><ul><li>Attempts to add a category hard filter: &quot;Chocolates,&quot; &quot;Milk chocolates&quot;; but the Christmas policy already set this field with <code>on_conflict: override</code>, so the Cheap chocolate categories are dropped.</li><li>Attempts to add a price filter: $2, the Christmas policy set <code>on_conflict: restrict</code> for price, and $2 is more restrictive than $7, so $2 wins.</li><li>Removes &quot;cheap&quot; from the search string.</li></ul><p><strong>Step 4: Build the Elasticsearch query.</strong> The control plane assembles the execution plan into a single Elasticsearch query against the product catalog:</p><pre><code>POST products/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            { "match": { "title": "chocolate" } }
          ],
          "filter": [
            { "terms": { "categories": ["Christmas foods and drinks", "Christmas sweets"] } },
            { "range": { "price": { "lt": 2 } } }
          ]
        }
      },
      "functions": [
        {
          "weight": 1
        },
        {
          "filter": { "terms": { "categories": ["Advent calendars"] } },
          "weight": 3
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}</code></pre><p>The original search string was &quot;cheap chocolate”. The query that reaches the product catalog is a governed, intent-aware retrieval plan: The word &quot;cheap&quot; has been consumed and converted into a price constraint, results are restricted to Christmas seasonal categories, Advent calendar products receive a ranking boost, and the price ceiling reflects the more restrictive value from the lower-priority policy. Every transformation is deterministic, traceable, and explainable.</p><p>For a quick overview about how these multipliers interact with the base BM25 score, see <a href="https://youtu.be/Ap5K2Y00Xjc?t=525">8:45 in the related PRISM video</a>, where we briefly discuss multiplicative boosts.</p><h2><strong>Why this scales</strong></h2><p>The percolator is efficient for this use case because of the asymmetry: An enterprise ecommerce system might have millions of products but only hundreds or thousands of governance policies. The percolator is checking one incoming search string against that set of stored policy patterns, not scanning the full product catalog. The cost is proportional to the number of policies, and Elasticsearch applies internal optimizations (indexing terms from stored query patterns, short-circuiting Boolean logic) to keep matching fast.</p><p>Adding a new policy is just indexing a new document. Disabling one is a field update. No code changes, no deploys, no restarts.</p><h2><strong>From lookup to governed retrieval</strong></h2><p>The percolator provides the fast reverse-matching primitive that makes the control plane architecture from <a href="http://elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">Part 3</a> practical at scale. Policies are data which are stored and indexed, and efficiently matched against incoming search strings. The control plane composes matching policies into a governed execution plan through the cascading transformation and per-field conflict resolution described in Part 3. And the retrieval engine executes the governed execution plan against the product catalog.</p><p>The result is a system where a merchandiser can author a new policy without touching application code, test it against representative queries, promote it to production, and immediately see the effect. The percolator makes the policy lookup fast; the control plane makes the policy composition deterministic; and the governed workflow makes the whole process safe.</p><h2><strong>What&#x27;s next in this series</strong></h2><p>The next post in this series extends the governed control plane into new territory. It introduces a <strong>multi-tier search architecture</strong>, explaining how to orchestrate strict, relaxed, and semantic retrieval while maintaining stable pagination and facets.</p><h2><strong>Put governed ecommerce search into practice</strong></h2><p>The percolator-based control plane described in this post, from index mappings and boundary markers to highlight-driven phrase tracking and cascading policy composition, was built by Elastic Services Engineering as part of our repeatable ecommerce search accelerators. Every query example and policy structure shown here comes from a working system validated against enterprise-scale product catalogs.</p><p>If you want to implement a governed, policy-driven control plane on Elasticsearch, Elastic Services can get you there faster. Contact <a href="https://www.elastic.co/consulting">Elastic Professional Services</a>.</p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to measure and improve Elasticsearch search recall: from 0.43 to 0.75 with hybrid search]]></title>
            <description><![CDATA[Learn how to measure and improve search recall in Elasticsearch by combining BM25 lexical search with Jina AI vector embeddings, using the rank_eval API to validate the improvement with real numbers.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-relevance-tuning-improve-recall</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-relevance-tuning-improve-recall</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/492c9b5432a2b9e40cebb3b60f0df019a8c7bf6d-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/solutions/search/full-text">Lexical search</a> using the <a href="https://www.elastic.co/blog/practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch">BM25 ranking algorithm</a> is cheap, fast, and very effective for a wide range of queries. But it has a blind spot: queries that don&#x27;t share tokens with your documents. In this article, you’ll measure exactly where BM25 falls short. We&#x27;ll use Elasticsearch&#x27;s <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval">ranking evaluation API</a> (<code>rank_eval</code>) and close that gap by adding <a href="https://www.elastic.co/search-labs/es/blog/jina-embeddings-v3-elastic-inference-service">Jina AI embeddings</a> through <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS). You’ll see the recall score go from <code>0.43</code> to <code>0.75</code> and understand why.</p><h2>What is recall?</h2><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall">Recall</a> measures on a scale from <code>0</code> to <code>1</code> how many of the documents that your users actually want appear somewhere in your search results. If a query should surface three products and your search returns only two of them in the top 10, <code>recall@10 = 0.67</code> for that query. It’s a set-based metric: It doesn’t care about the position of the relevant documents within those <em>k</em> results. A relevant document in position 10 counts the same as one in position 1. Having a high recall means that you’re not losing relevant results.</p><p><br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b13af2a5d0ca055535d8bfe3dfe4b3d1093ee6da-1457x796.png" style="width: 100%;" /><p>The diagram shows two sets: all relevant documents (left) and what BM25 actually retrieved (top 10, right). Only the intersection counts toward recall, <code>prod_1</code> and <code>prod_2</code> were found, while <code>prod_3</code>, <code>prod_4</code>, and <code>prod_6</code> were missed entirely. Result: <code>Recall@10 = 2/5 = <strong>0.40</strong></code>.</p><h2>Prerequisites</h2><p>Let&#x27;s get down to business to better understand how recall works. This demonstration uses Python. You can follow along with it on the companion notebook (<a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/relevance-tuning-improving-recall-adding-vectors/notebook.ipynb">notebook.ipynb</a>), where every code block is a cell ready to run.</p><p>The code provided uses the following:</p><ul><li>Elasticsearch 9.3+</li><li>Python 3.10+</li></ul><pre><code>pip install elasticsearch pandas plotly python-dotenv</code></pre><ul><li>A <code>.env</code> file with your Elasticsearch credentials</li></ul><pre><code>ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-key</code></pre><h2>The dataset</h2><p>We’ll use a product catalog of 1,000 products, spanning categories such as footwear, electronics, tools, and more.</p><p>Each document has four fields:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Field</td><td style="border: 1px solid #ccc; padding: 4px;">Type</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`title`</td><td style="border: 1px solid #ccc; padding: 4px;">text</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`description`</td><td style="border: 1px solid #ccc; padding: 4px;">text</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`brand`</td><td style="border: 1px solid #ccc; padding: 4px;">keyword</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`category`</td><td style="border: 1px solid #ccc; padding: 4px;">keyword</td>
                    </tr>
              </tbody>
            </table>
          <p>The dataset is loaded from <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/relevance-tuning-improving-recall-adding-vectors/dataset.csv"><code>dataset.csv</code></a>.</p><h2>The power and limits of lexical search</h2><p>BM25 is the default ranking algorithm in Elasticsearch and most search engines. It scores documents by how often your query terms appear in them, adjusted for document length and the frequency of those terms across the entire index. You get <a href="https://www.elastic.co/docs/reference/text-analysis/analyzer-reference">analyzers</a> on top: lowercase normalization, stemming, and stopword removal. A query for &quot;running shoes&quot; will match &quot;Running Shoes&quot; and likely &quot;run&quot; as well.</p><p>This works well for a large class of queries:</p><ul><li>&quot;running shoes&quot; immediately matches products with those exact tokens in the title.</li><li>&quot;bluetooth speaker&quot; surfaces portable audio products because the tokens appear verbatim.</li></ul><p>The results are deterministic and explainable: A document ranks highly because the query terms appear in it. Debugging relevance is straightforward.</p><h3>Where it breaks</h3><p>Now let’s try these queries against the same catalog:</p><ul><li><strong>&quot;skincare routine&quot;:</strong> The word &quot;routine&quot; doesn’t appear in any product title. BM25 can partially match on &quot;skincare,&quot; but face serums, body oils, and moisturizers are described using terms like &quot;vitamin C,&quot; &quot;retinol,&quot; or &quot;brightening,&quot; none of which overlap with the query. Products that form a complete skincare routine are scattered across the index with no shared token to anchor them.</li></ul><pre><code>ID: B06XX6DS3P, Score: 9.0552, Title: Replenix Retinol Smooth + Tighten Body Lotion - Collagen-Boosting, Regenerating Anti-Aging Body Cream, Reduces Appearance of Stretch Marks, 6.7 oz.

  ID: B08XMPKJ1L, Score: 5.2699, Title: Bio-Oil Skincare Body Oil (Natural) Serum for Scars and Stretchmarks, Face and Body Moisturizer Hydrates Skin, with Organic Jojoba Oil and Vitamin E, For All Skin Types, 6.7 oz

  ID: B01CY764KQ, Score: 5.0057, Title: Nike Up Or Down Men Deodorant - Pack of 2 | Long-Lasting Fragrance, Body Spray Combo for Men | Deodorant for Active Living | Nike Men's Deo Set | Ultimate Odor Protection | Grooming Essentials | Signature Nike Scent | High-Performance Men's Deodorant</code></pre><ul><li><strong>&quot;pet travel accessories&quot;:</strong> This is a use-case grouping, not a product category. A dog sling carrier, a pet car seat, and a travel crate are all relevant, but their descriptions talk about portability, safety, and comfort rather than &quot;travel accessories.&quot; BM25 matches &quot;pet&quot; broadly but has no signal to distinguish travel-specific products from the rest of the pet catalog.</li></ul><pre><code>ID: B0BVV7BKTW, Score: 7.4371, Title: Large Foldable Travel Duffel Bag with Shoes Compartment

ID: B07TNPHYNV, Score: 6.6455, Title: 40 Pieces Christmas Bronze Jingle Bells Craft Small Bells

ID: B08R8FRW53, Score: 6.6335, Title: CUBY Dog and Cat Sling Carrier
ID: B08QMCQYGM, Score: 6.5259, Title: YTFGGY Whiteboard Pinstripe Tape 6 Rolls 1/8"
ID: B0CP3LQSWM, Score: 6.2994, Title: Portable Dog Water Bottle 32 Oz</code></pre><p>This is a <strong>recall problem</strong>. The relevant documents exist in your index. BM25 just cannot find them because the user&#x27;s words and the document&#x27;s words do not match closely enough.</p><p>Adding synonyms helps for known cases. But you cannot enumerate every way a user might express an intent. That is where vectors come in.</p><h2>Why you should measure recall</h2><p>Before fixing a problem, you need to quantify it.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall"><strong>Recall@k</strong></a> measures how many of the documents that your users actually want appear somewhere in your search results. Formally:</p><pre><code>Recall@k = (relevant documents found in top k) / (total relevant documents)</code></pre><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-precision"><strong>Precision@k</strong></a> measures the top k results and how many are actually relevant:</p><pre><code>Precision@k = (relevant documents in top k) / k</code></pre><p>High precision means that the results you do return are good. In ecommerce, missing a relevant product (low recall) is often worse than showing a slightly imperfect result (lower precision), because a hidden product is a lost sale.</p><p>Elasticsearch&#x27;s <code>rank_eval</code> API lets you measure both systematically. You provide a list of queries, each with a set of rated documents, and Elasticsearch computes the metrics for you across all queries.</p><h2>Setting up the evaluation</h2><p>The <code>rank_eval</code> API needs a <strong>ratings dataset</strong>: a mapping of queries to the documents that are relevant for each one, along with a relevance grade (0 = not relevant, 1 = relevant, 2 = highly relevant).</p><p>In the notebook, this is the <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr#learning-to-rank-judgement-list">judgments list</a>:</p><pre><code>judgments = [
    # Query 1: "running shoes" BM25 handles well (tokens appear in product titles) 
    {"query_id": "q1", "doc_id": "B09NQJFRW6", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B08JMD4LMM", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B08VRJ6F2Q", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B07S8NRRWR", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B01HD620I8", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B07DX86321", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B0968YVLQ8", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B093QJ39ZS", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B096FGSC39", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B01GVQWVV2", "grade": 1, "query": "running shoes"},

    # Query 2: "skincare routine" intent-based, "routine" never appears in product titles
    {"query_id": "q2", "doc_id": "B08XMPKJ1L", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B0BN3WQB92", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B0BT7B7P5T", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B00NPA2WEY", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B06XX6DS3P", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B07PDRD1KT", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B074J7869B", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B08JV31QW4", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B00K3TVJMQ", "grade": 1, "query": "skincare routine"},

    # Query 3: "study desk setup" intent-based, products are desks/stands/organizers
    {"query_id": "q3", "doc_id": "B08CS35J2T", "grade": 2, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B09B3LFDXJ", "grade": 2, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B07W58LMND", "grade": 1, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B0CHYDX91L", "grade": 1, "query": "study desk setup"},

    # Query 4: "pet travel accessories" use-case grouping, products are carriers/crates/seats
    {"query_id": "q4", "doc_id": "B08R8FRW53", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B01MYUYX33", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B003C5RKE4", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B09GF8GBF6", "grade": 1, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B0CP3LQSWM", "grade": 1, "query": "pet travel accessories"},
]</code></pre><p>The mix is intentional: <code>q1</code> is a query that BM25 handles well (exact tokens in product titles), while <code>q2</code>, <code>q3</code>, and <code>q4</code> are intent-based queries where the user&#x27;s intent is expressed as a concept rather than specific product keywords.</p><h2>Measuring BM25 baseline recall</h2><p>First, set up the Elasticsearch client and index the raw text data:</p><pre><code>import os
import json
import pandas as pd
import plotly.graph_objects as go
from elasticsearch import Elasticsearch, helpers
from dotenv import load_dotenv

load_dotenv()

es = Elasticsearch(
    os.getenv("ELASTICSEARCH_URL"),
    api_key=os.getenv("ELASTICSEARCH_API_KEY")
)

INDEX_NAME = "ecommerce-products"</code></pre><p>Now build the <code>rank_eval</code> request for BM25. Each request in the list combines a query with its ratings:</p><pre><code>judgments_df = pd.DataFrame(judgments)

bm25_requests = []
for query_id, query_text in (
    judgments_df[["query_id", "query"]].drop_duplicates().values
):
    relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
    ratings = [
        {"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
        for _, row in relevant_docs.iterrows()
    ]

    bm25_requests.append({
        "id": query_id,
        "request": {
            "query": {
                "multi_match": {
                    "query": query_text,
                    "fields": ["title", "description"]
                }
            }
        },
        "ratings": ratings,
    })

bm25_eval = {
    "requests": bm25_requests,
    "metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}

bm25_result = es.rank_eval(index=INDEX_NAME, body=bm25_eval)
print("BM25 Recall@10:", bm25_result.body["metric_score"])</code></pre><p>Result:</p><pre><code>BM25 Recall@10: 0.43</code></pre><p><code>0.43</code> means that across all four queries, BM25 finds only 43% of the documents it should find. The shortfall is concentrated in the intent-based queries: &quot;skincare routine&quot; misses face serums and body oils because &quot;routine&quot; never appears in product titles, and &quot;pet travel accessories&quot; retrieves off-topic pet products while missing carriers and crates described in terms of portability and safety rather than &quot;travel accessories.&quot;</p><p>This is our baseline. Now we have a number to beat.</p><h2>Adding vector search with Jina embeddings</h2><p><a href="https://www.elastic.co/docs/solutions/search/vector"><code>Vector search</code></a> encodes documents and queries as high-dimensional vectors, a type of vector made up of hundreds or thousands of numerical values, each encoding a specific feature of the data it represents. Documents with similar meaning end up close together in vector space, even if they share no words. &quot;Gym equipment&quot; and &quot;dumbbell set&quot; will be nearby because the concepts are related. I chose Elasticsearch as my vector database because it supports hybrid search, giving me both semantic understanding and keyword precision out of the box.</p><p><a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a> includes out-of-the-box support for embedding models through its <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">inference API</a>.</p><h3>Step 1: Using Jina embeddings v5 as an inference endpoint</h3><pre><code>INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"</code></pre><p>If your cluster has GPU resources (available in Elastic Cloud and Elasticsearch 9.3+), the embeddings are generated on GPU, which is significantly faster than CPU inference and removes the performance trade-off that historically made vectors expensive at scale.</p><p>Why Jina embeddings specifically? <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">jina-embeddings-v5-text</a> is a multilingual model (119+ languages) with a 32,000-token context window and support for task-specific <a href="https://arxiv.org/abs/2106.09685">Low-Rank Adaptation (LoRA) adapters</a>. It works well for short product descriptions out of the box. Read more about <code>jina-embeddings-v5-text</code> model <a href="https://huggingface.co/jinaai/jina-embeddings-v5-text-small">here</a>.</p><h3>Step 2: Create the index with a semantic field</h3><pre><code>index_mappings = {
    "mappings": {
        "properties": {
            "title": {"type": "text", "copy_to": "semantic_field"},
            "description": {"type": "text", "copy_to": "semantic_field"},
            "brand": {"type": "keyword"},
            "category": {"type": "keyword"},
            "semantic_field": {
                "type": "semantic_text",
                "inference_id": INFERENCE_ENDPOINT_ID,
            },
        }
    }
}

if not es.indices.exists(index=INDEX_NAME):
    es.indices.create(index=INDEX_NAME, body=index_mappings)
    print(f"Created index: {INDEX_NAME}")</code></pre><p>The <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text"><code>semantic_text</code></a> field type is the key here. It’s a higher-level abstraction over <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a>: You point it at an inference endpoint, and Elasticsearch takes care of generating embeddings automatically.</p><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> property on <code>title</code> and <code>description</code> means content from both fields flows into <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_field</code></a> for embedding, so a single vector captures the full product representation.</p><h3>Step 3: Index the products</h3><pre><code>def bulk_index(products, index_name):
    actions = []
    for product in products:
        doc_id = product.get("_id")
        source = {k: v for k, v in product.items() if k != "_id"}
        action = {"_index": index_name, "_source": source}
        if doc_id:
            action["_id"] = doc_id
        actions.append(action)

    success, failed = helpers.bulk(es, actions, raise_on_error=False)
    if failed:
        for error in failed:
            print(f"Error: {error}")
    else:
        print(f"Successfully indexed {success} documents")

bulk_index(products, INDEX_NAME)</code></pre><p>At index time, Elasticsearch calls the inference endpoint for each document and stores the resulting embedding in <code>semantic_field</code>. No extra code on your side.</p><h2>Hybrid search: Combining BM25 and vectors with RRF</h2><p>Adding vectors improves recall, but using vectors alone risks losing precision on exact-match queries; &quot;running shoes&quot; should still rank verbatim matches first. Hybrid search retains the lexical component specifically to preserve that precision.</p><p>Hybrid search with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">Reciprocal Rank Fusion</a> (RRF) keeps the best of both:</p><ul><li>BM25 handles exact and near-exact queries with high precision.</li><li>Semantic search handles intent-based and multilingual queries with high recall.</li><li>RRF combines the two ranked lists into a single ranking.</li></ul><p>The RRF formula assigns each document a score based on its rank in each result list:</p><pre><code>score = sum(1 / (rank_constant + rank))</code></pre><p>A document that ranks highly in both lists gets a higher combined score. The <code>rank_constant</code> controls how much weight lower-ranked documents receive.</p><pre><code>hybrid_requests = []

for query_id, query_text in (
    judgments_df[["query_id", "query"]].drop_duplicates().values
):
    relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
    ratings = [
        {"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
        for _, row in relevant_docs.iterrows()
    ]

    hybrid_requests.append({
        "id": query_id,
        "request": {
            "retriever": {
                "rrf": {
                    "retrievers": [
                        {
                            "standard": {
                                "query": {
                                    "multi_match": {
                                        "query": query_text,
                                        "fields": ["title", "description"],
                                    }
                                }
                            }
                        },
                        {
                            "standard": {
                                "query": {
                                    "match": {
                                        "semantic_field": {"query": query_text}
                                    }
                                }
                            }
                        },
                    ],
                    "rank_window_size": 50,
                    "rank_constant": 5,
                }
            }
        },
        "ratings": ratings,
    })

hybrid_eval = {
    "requests": hybrid_requests,
    "metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}

hybrid_result = es.rank_eval(index=INDEX_NAME, body=hybrid_eval)
print("Hybrid Recall@10:", hybrid_result.body["metric_score"])</code></pre><p>Result:</p><pre><code>Hybrid Recall@10: 0.75</code></pre><p>Hybrid improves substantially over BM25 (<code>0.43</code>) and preserves precision for exact-match queries like &quot;running shoes.&quot;</p><h2>Results: Before and after</h2><p>Here’s the full comparison across all three approaches:</p><pre><code>methods = {
    "BM25 (Lexical)": bm25_requests,
    "Hybrid (BM25 + Vectors)": hybrid_requests,
}

recall_metric = {"recall": {"k": 10, "relevant_rating_threshold": 1}}

comparison_data = []
for method_name, requests in methods.items():
    result = es.rank_eval(
        index=INDEX_NAME,
        body={"requests": requests, "metric": recall_metric}
    )
    comparison_data.append({
        "method": method_name,
        "recall@10": result.body["metric_score"]
    })

comparison_df = pd.DataFrame(comparison_data)
print(comparison_df.to_string(index=False))</code></pre><p>Result:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Method</td><td style="border: 1px solid #ccc; padding: 4px;">Recall@10</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">BM25 (Lexical)</td><td style="border: 1px solid #ccc; padding: 4px;">0.43</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hybrid (BM25 + Vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">0.75</td>
                    </tr>
              </tbody>
            </table>
          <img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e49f6c10516b0a48a0ad75962c6590ee07311407-700x500.png" style="width: 100%;" /><p>Breaking it down by query:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/40e36dcb7b34cbf4649c512bcb60cef60f1778a6-700x500.png" style="width: 100%;" /><h2>Conclusion</h2><p>Throughout this post, we saw that BM25 lexical search is reliable when users type exact queries, but it loses recall when they search by intent rather than keywords. Using <code>rank_eval</code>, we established a reproducible baseline to measure that gap with real numbers. From there, we added a <code>semantic_text</code> field powered by Jina embeddings and ran the evaluation again. The result: Hybrid search improved recall from <code>0.43</code> to <code>0.75</code> while preserving precision on exact-match queries, though the actual margin will depend on your query mix.</p><p>The pattern scales beyond this example: Collect judgments from your users&#x27; actual queries, run <code>rank_eval</code> as a baseline, add <code>semantic_text</code>, and measure again. You&#x27;ll know exactly what improved and by how much.</p><h2>Next steps</h2><ul><li>Dive deeper into recall and vector search: <a href="https://www.elastic.co/search-labs/blog/recall-vector-search-quantization">Recall and vector search quantization</a> by Jeff Vestal</li><li>Add reranking for even better precision on the top results</li><li>Explore <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html">Elasticsearch hybrid search documentation</a></li><li>Read more about the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html"><code>rank_eval</code> API</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a control plane to govern ecommerce search]]></title>
            <description><![CDATA[How to build a governed control plane for ecommerce that composes conflicting search policies into a single execution plan (without code changes).]]></description>
            <link>https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4e2cd5244ef8b9a05af6337a4825252f321a9a43-1377x768.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval">Part 1</a> and <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">part 2</a> of this series established why ecommerce search needs a <em>governance layer</em>, a decision layer between the user&#x27;s query and the retrieval engine that classifies intent, enforces constraints, and routes to the correct retrieval strategy (for example, BM25, semantic, hybrid). This post shows how to build that layer using a simple architectural primitive where query interpretation policies are stored as documents and retrieved at query time via fast reverse matching. Because new retrieval policies (for example, “boost brand X” or “only show category Y”) don’t require code changes, the result is a routing layer that stays stable while policies evolve and that keeps the retrieval engines safe in high-stakes environments. If you want to see the end result of this architecture before reading further, check out this video: <a href="https://www.youtube.com/watch?v=e1GuL9CYWAk">Fixing Search Relevance in Seconds: Introducing PRISM</a>.</p><h2>Why query interpretation is often a challenge</h2><p>Storing policies as code (if/else blocks in the application layer) produces tens of thousands of lines of brittle logic that lacks any indexing for efficient policy retrieval at query time. Iteration is slow (a single query behavior change may require a six-week deployment cycle), accountability is unclear (why did results change?), and business users cannot modify search behavior without engineering involvement. This is shown on the left side in the following image:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4e2cd5244ef8b9a05af6337a4825252f321a9a43-1377x768.png" style="width: 100%;" /><p>Storing policies as data in an Elasticsearch index is shown on the right side of the above image. This approach solves all of the issues associated with hard-coded query resolution logic. However, for this to work, you need a way to quickly determine which policies match the user query and how conflicts should be resolved. This is where the governed control plane comes in.</p><h2>The control plane pattern</h2><p>A governed control plane sits between the raw user query and an Elasticsearch retrieval. It receives user text as its input, and its output is an execution plan that includes filters, boosts, and retrieval routing decisions.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5562da5de521f3c83ed55a13e9be87ca7fa70109-546x489.png" style="width: 100%;" /><p>A control plane pipeline consists of:</p><ol><li><strong>User query: </strong>A user enters a string of what they’re looking for, such as “oranges” or “gift for grandpa”.</li><li><strong>Policy lookup: </strong>Match the user query against the policy index.</li><li><strong>Return matching policies:</strong> Policies that match the user query are returned from the policy index.</li><li><strong>Policy application: </strong>The control plane analyzes these returned policies and composes matched policies into a single coherent execution plan that includes filters, boosts, overrides, and guardrails and that applies the appropriate retrieval method (for example, lexical versus semantic versus hybrid).</li><li><strong>Execute:</strong> The modified <em>intent-aware</em> Elasticsearch query is passed to the application to be executed against a product catalog index.</li><li><strong>Explain (optional):</strong> In addition to creating a query that provides business and intent-aligned results, the control plane provides an optional explainability payload to show which policies were triggered and how they were combined.</li></ol><p>Finding which policies should be applied for a user’s search string requires a fast reverse-matching primitive, which we solve with the <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-percolate-query">percolator query</a>. After retrieving relevant policies, combining multiple matched policies into a unified execution plan requires a judgment framework: priorities, conflict strategies, consumed phrase tracking, and cascading transformations that apply policies in sequence rather than independently. Additionally, the most appropriate retrieval technology needs to be selected (for example, <a href="https://www.elastic.co/elasticon/conf/2016/sf/improved-text-scoring-with-bm25">BM25</a> for “oranges” versus <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> for “gift for grandpa”).</p><h2>Policy lookup: Checking the query before searching for products</h2><p>When a shopper types a query, a search system with a governed control plane doesn’t send that query directly to be executed against the product catalog. First, the query is checked against a set of stored policies and modified to reflect the intent of the query and business priorities.</p><h3>Policy structure</h3><p>Each policy is a simple document that defines two things:</p><ul><li><strong>Match criteria:</strong> What query text should cause this policy to fire. This could be an exact phrase, a single word, a pattern, or a combination.</li><li><strong>Action:</strong> What to do when the policy fires. This could be applying a category filter, excluding products, extracting a price constraint, or changing the retrieval strategy.</li></ul><p>The system finds all matching policies, composes them into an execution plan, and only then runs the product search. Taken together, policies act like a knowledgeable store associate who understands what you’re looking for and walks you to the right aisle.</p><h3>The policy pattern</h3><p>The first articles in this series introduced examples of policies in action: constraining &quot;oranges&quot; to the produce category, treating &quot;without peanuts&quot; as an exclusion, and routing &quot;gift for grandpa&quot; to semantic retrieval. The key architectural point is that in each case, the query is checked against stored policies before the product search begins. The policies determine what constraints to apply, which text to modify, and which retrieval strategy to use. The query against the product catalog comes after the policies have been applied and a new rewritten query has been created.</p><h3>Why this is fast</h3><p>An enterprise ecommerce system might have millions of products but only hundreds or thousands of policies. The policy lookup step is searching against a small curated index, not the full product catalog, and is therefore fast. And because policies are stored as data in their own index, a merchandiser adding a new policy doesn&#x27;t touch the application code, and an engineer optimizing the product search doesn&#x27;t touch the policy index. The two concerns evolve independently.</p><p>The examples above describe what happens conceptually. Under the hood, the policy lookup is implemented using the Elasticsearch <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-percolate-query">percolator query</a> type, which is purpose-built for this kind of pattern: matching incoming text against a set of stored queries. <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">Part 4</a> in this series provides a hands-on deep dive into the percolator implementation, including index mappings, boundary markers, and highlight-driven phrase tracking. With the lookup mechanism covered in depth in Part 4, let&#x27;s turn to what a policy document actually contains and how the control plane composes multiple policies into a single execution plan.</p><h2>Example policies</h2><p>Now that we&#x27;ve seen what policies do conceptually, let&#x27;s look at what they actually contain. The two policies below have been designed to intentionally conflict, which will demonstrate the conflict resolution system described in subsequent sections.</p><h3>Cheap chocolate</h3><p>The policy shown below detects if a user has submitted a search containing the phrase “cheap chocolate”. If so, results are restricted to the “Chocolates” and “Milk chocolates” categories. This policy also applies a price filter of $2. Also, notice that this policy has a priority of 210; we’ll come back to this when we discuss conflict resolution in more detail.</p><p>The filter mode and conflict strategy settings shown here (hard_filter, soft_boost, restrict, override) are explained in detail in the conflict resolution section below.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bbcd66b20fc3aa861b5880ca67daf8e809698717-1002x890.png" style="width: 100%;" /><p>When the above policy is activated, a search for “cheap chocolate” respects the price filter of $2 and restricts results to the “Chocolates” and “Milk chocolates” categories. Example results are shown below:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3f373af9a985864315d7639440a416e45a882a1b-1133x1146.png" style="width: 100%;" /><h3>Christmas chocolate</h3><p>The policy shown below is an example of a policy that one could imagine applying at Christmas. This example restricts results to “Christmas foods and drinks” and “Christmas sweets”, boosts any products that are also in the “Advent calendars” category, and applies a price filter of less than $7 to focus on affordable seasonal items. Additionally, notice that this policy has a priority of 300. We’ll come back to this when we discuss conflict resolution in more detail.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8f1179342d0e05cf78266d142b046021a3694368-1007x941.png" style="width: 100%;" /><p>When the above policy is activated without any conflicting policies, a search for “chocolate” respects the price filter of $7, and restricts results to the “Christmas food and drinks” and “Christmas sweets” categories, and boosts any products tagged as “Advent calendars”. Example results are shown below:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/662b0e40cb3a9291c17816c33169e9ff5b68f98d-1129x1085.png" style="width: 100%;" /><h2>Combining matched policies</h2><p>The policy lookup described above is half the story. The other half is what happens when multiple policies match the same query.</p><p>In any nontrivial deployment, a single query will routinely trigger several policies at once. &quot;Cheap chocolate&quot; will match both of the policies that we demonstrated above. Each policy is correct in isolation. The challenge is composing them into a single, coherent execution plan without contradictions, without double-counting, and without one policy silently undoing the work of another.</p><p>This isn’t a lookup problem; it’s a judgment problem. The system must decide:</p><ul><li><strong>Order of application:</strong> If a negation policy removes &quot;without peanuts&quot; from the query, does the price policy still see the original text or the modified text?</li><li><strong>Filter conflicts:</strong> If two policies set different price ceilings, which one wins? Is the loser silently dropped, or does it degrade gracefully into a soft boost?</li><li><strong>Phrase ownership:</strong> If two policies both matched on the same word and the first one already consumed it, should the second one still fire?</li></ul><p>A naive implementation (apply all matched policies independently, merge the results) breaks as soon as policies interact. The architecture needs an explicit model for how policies compose. The next two sections describe that model: a priority and conflict resolution framework; and a cascading transformation model that makes policy interaction deterministic.</p><p>The key insight is that policy application isn’t a set of independent operations; it’s a cascading transformation. Each policy receives the rewrite state produced by all higher-priority policies and transforms it further:</p><p>initial state → [Policy A] → state&#x27; → [Policy B] → state&#x27;&#x27; → ... → execution plan</p><p>The state carries the rewritten query text, accumulated filters, current intent, and any synonym expansions. A high-priority policy can remove text from the query, and every subsequent policy sees the modified query, not the original. Context accumulates. Order matters.</p><h2>Precedence and conflict resolution: Determinism matters</h2><p>The specific conflict strategies are a design choice. Different organizations may resolve conflicts differently, depending on their business requirements. The following approach illustrates the kind of judgment framework a control plane needs. The important thing is not these specific strategies but that the system has explicit, deterministic strategies rather than letting conflicts resolve through unpredictable interactions.</p><h3>Priority ordering</h3><p>Policies are sorted by priority (highest first). When multiple policies match the same query, they’re applied in priority order. If two policies try to set the same filter field, the higher-priority policy&#x27;s declared strategy for that field takes precedence. If there are multiple policies triggered that have the same priority, then the policy with the highest ID is given precedence (as if it were assigned a higher priority); this choice ensures deterministic behavior when conflicts arise.</p><h3>Per-field resolution, not per policy</h3><p>A critical design principle: Conflict resolution operates per field (for example, brand, category, or description), not per policy. When two policies produce filters that overlap on specific fields, only those specific fields are affected by the conflict resolution strategy, and the resolution strategy is defined by the highest-priority matching policy. Non-conflicting fields from both policies survive intact.</p><p>This matters because the alternative of a per-policy approach would force the system to either accept or reject an entire policy when only one of its fields conflicts.</p><p>Per-field resolution preserves the maximum amount of useful constraint information.</p><h3>Three settings per filter field</h3><p>Each filter field in a policy has three independent settings:</p><p><strong>Filter mode:</strong> How the filter is applied when there’s no conflict.</p><ul><li><code>hard_filter</code> (default): Applied as an <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query#score-bool-filter">Elasticsearch <code>bool.filter</code></a> clause. This is useful for excluding unrelated products entirely. For example, restricting a search for &quot;oranges&quot; to the produce category eliminates hits such as orange juice and orange marmalade. Non-matching documents are completely excluded from results.</li><li><code>soft_boost</code>: Applied as an <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">Elasticsearch <code>function_score</code></a> weight with a configurable <code>boost_weight</code>. Documents that match get a ranking boost, but non-matching documents aren’t excluded. This is useful for something like boosting a brand, without excluding other brands.</li></ul><h3>Conflict strategy</h3><p>What happens when a lower-priority policy sets the same field:</p><ul><li><code>override</code>: This high-priority policy&#x27;s value wins; the lower-priority value is dropped entirely. Valid for all field types.</li><li><code>restrict</code>: Take the more restrictive numeric value (for example, the lower ceiling for price__max, the higher floor for price__min). Valid for numeric range fields only.</li><li><code>merge</code>: Combine both values into a union. Valid for non-numeric fields only.</li><li><code>soft_boost</code>: Convert the conflicting filter to a <code>function_score</code> weight with a configurable <code>boost_weight</code> instead of a hard filter. For more details on function_score boosting, see <a href="https://www.elastic.co/search-labs/blog/bm25-ranking-multiplicative-boosting-elasticsearch">Influencing BM25 ranking with multiplicative boosting in Elasticsearch</a>. This is only valid for non-negation fields.</li></ul><p><strong>Value:</strong> The actual filter value (for example, a categories list, a price threshold).</p><p><strong>Strategies by field type: </strong>Not all strategies make sense for all field types. For instance, an exclusion is inherently binary, so it cannot be soft-boosted. The following table shows which strategies are available for each field type:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Field type</td><td style="border: 1px solid #ccc; padding: 4px;">Available strategies</td><td style="border: 1px solid #ccc; padding: 4px;">Default</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Negation fields (__not, __match__not)</td><td style="border: 1px solid #ccc; padding: 4px;">override, merge</td><td style="border: 1px solid #ccc; padding: 4px;">override</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Numeric range fields (__max, __min, __gt, __lt)</td><td style="border: 1px solid #ccc; padding: 4px;">restrict, override, soft_boost</td><td style="border: 1px solid #ccc; padding: 4px;">restrict</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">All other fields (keyword, text)</td><td style="border: 1px solid #ccc; padding: 4px;">soft_boost, override, merge</td><td style="border: 1px solid #ccc; padding: 4px;">soft_boost</td>
                    </tr>
              </tbody>
            </table>
          <p>Negation fields cannot be soft-boosted because exclusions are binary. Converting &quot;never show canned foods&quot; to &quot;slightly prefer not-canned-foods&quot; fundamentally changes the semantics; a product from &quot;canned foods&quot; would still appear, just ranked slightly lower, which defeats the purpose of the exclusion.</p><h2>A concrete example: Searching for &quot;cheap chocolate&quot; during a Christmas campaign</h2><p>Suppose a merchandiser has created the two policies for chocolate that we previously demonstrated, a lower priority one for cheap chocolate and another higher-priority chocolate-related policy that will be enabled during Christmas. If both of these policies are enabled, then how these are combined depends on the filter mode and conflict strategy of the higher-precedence policy. If both of the previously discussed policies are enabled, they’ll be combined as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0405e193522172bde283180df96ed3651178fafc-529x447.png" style="width: 100%;" /><p>This shows two conflicts, one on categories and one on price. It’s worth noting that the query that will be executed after this transformation has the following characteristics:</p><ul><li>Only products from the “Christmas foods and drinks” and “Christmas sweets” categories will be shown.</li><li>Within those categories, if the products are also tagged as being in the “Advent calendars” category, they’ll be boosted up by 3x.</li><li>A price filter for $2 is applied, which came from the lower-priority policy (because the higher-priority policy specified to “Restrict” on conflict).</li><li>The word “cheap” is removed, only returning products matching “chocolate”.</li></ul><p>With both of these policies enabled, “cheap chocolate” returns results similar to the image shown below:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/01bbab1c5bd3d0fd37e39c25973d60141f9796e9-1126x1123.png" style="width: 100%;" /><h3>Relaxing constraints</h3><p>Perhaps the retailer doesn’t want to exclude products in the categories of “Chocolates” and “Milk chocolates” during Christmas. The settings on the Christmas policy might have overreached and inadvertently removed categories applied by the “cheap chocolate” policy. This is an example that shows why it might be more desirable to combine lower-priority policies with conflicting higher-priority policies. For example, we could modify the Christmas chocolates promotion so that instead of “Override” on conflict, we do a soft boost. The change to that policy would be as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/45e88311014d67933ca8cf8381d8f91de090e2b4-1090x103.png" style="width: 100%;" /><p>After this modification, the query rewriter transformation pipeline execution for “cheap chocolate” looks as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/396b360e48327421c2c38bcf4a039fb1a6d5a8e0-519x445.png" style="width: 100%;" /><p>With the soft boost on conflict, the conflicting filters are converted into soft boosts rather than being dropped. The query that will be executed on the product catalog after this transformation has the following characteristics:</p><ul><li>Because “On conflict” is specified as “Soft boost” on the higher-priority policy, the conflicts will be converted to boosts as follows:<ul><li>Products from the “Christmas foods and drinks” and “Christmas sweets” categories will have a boost of 1x applied to them.</li><li>Products from the “Chocolates” and “Milk chocolates” categories will have a boost of 3x applied to them.</li></ul></li><li>As in the previous example, if the products are also tagged as being in the “Advent calendars” category, they’ll be boosted up by 3x.</li><li>As in the previous example, a price filter for $2 is applied.</li><li>The word “cheap” is removed, only returning products matching “chocolate”.</li></ul><p>With relaxed filtering, results look as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7a68c54d878dadfe8b1821dd3860b7b60f9ce45f-1126x1123.png" style="width: 100%;" /><h3>Overriding price from a high-priority policy</h3><p>Or perhaps the retailer wants to allow slightly more expensive chocolates to be shown during Christmas by increasing the price max to $7. To ensure that the max price from the Christmas chocolates policy is not overridden if someone searches for “cheap chocolates”, we can set the conflict mode on the price to “override” rather than “restrict”, as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c2621e6513281f545b84eb77362f2b93e1c46a1f-996x70.png" style="width: 100%;" /><p>With this override, the query for “cheap chocolate” ignores maximum price that is defined in the “cheap chocolate policy” and only applies the price specified in the “Christmas chocolates” policy, as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d50da7900beb3c08439e9fd79cbe2ddd98196441-511x389.png" style="width: 100%;" /><p>This is similar to the previous example, with the difference being that the max price is set to the $7 value from the higher-priority policy because that policy specified “Override” on conflict. With the Christmas price filter taking precedence, the results look as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/635ee6353ba84727486e7e053764788fb26b6f44-1134x1079.png" style="width: 100%;" /><p>These three variations (override, soft_boost, and override on price) demonstrate a key property of the system: A merchandiser can change how two policies interact by modifying a setting on a single field within a single policy, without deploying any code. The conflict strategy is the lever that controls business behavior.</p><h2>Consumed phrase tracking</h2><p>There’s a subtler form of conflict: two policies that match on the same phrase. If a higher-priority policy removes &quot;without peanuts&quot; from the query, a lower-priority policy that also matched on &quot;without&quot; has nothing left to act on. The system detects if the matched phrase is no longer present in the rewritten query and skips the lower-priority policy.</p><p>Intent policies are exempt from consumed phrase tracking: They set the retrieval strategy based on the original query match, regardless of what text has been removed by higher-priority policies.</p><p>Priority ordering, per-field conflict resolution, and consumed phrase tracking together give the control plane a deterministic composition model. With that foundation in place, the system can make a routing decision that would be risky without it.</p><h2>Governance makes retrieval strategy safe</h2><p>An important insight about routing to the correct retrieval method (text, semantic, or hybrid) is that it executes after governance. If your policies have already enforced &quot;produce category”, then semantic retrieval becomes far less risky because the candidate set is constrained. A semantic search over 500 product items is a very different proposition from a semantic search over 500,000 SKUs. Governance narrows the blast radius before retrieval begins.</p><p>For example, without governance, a semantic query for “Fruit high in vitamin C under $4”, in addition to fruits, might return vitamin bottles, carrots, and green pepper. The control plane ensures that these undesired results aren’t even considered as part of the semantic expansion.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6dccd5b8a94bfa81f68e3d1c4ad8929ce8cc4e5e-990x378.png" style="width: 100%;" /><p>With that constraint in place, the control plane applies pragmatic routing logic:</p><ul><li><strong>Lexical</strong> for navigational and head queries where deterministic precision matters.</li><li><strong>Semantic</strong> for descriptive discovery queries where concept matching helps.</li><li><strong>Hybrid</strong> selectively, when constraints have already been enforced and the business accepts broader recall.</li></ul><h2>From architecture to implementation</h2><p>The governed control plane translates business intent into deterministic, composable execution plans, without embedding that logic in application code. Policies are data: matched at query time, resolved through explicit per-field conflict strategies, and applied as cascading transformations that produce explainable results. Elastic Services Engineering has built and deployed this architecture for enterprise ecommerce teams, using repeatable patterns and accelerators that compress the path from concept to production. You can see a demo of our implementation of a control plane on YouTube at: <a href="https://www.youtube.com/watch?v=e1GuL9CYWAk">Fixing Search Relevance in Seconds: Introducing PRISM</a>.</p><h3><strong>What&#x27;s next in this series</strong></h3><p>The next post goes hands-on with the implementation: how the Elasticsearch percolator powers the policy lookup, including index mappings, boundary markers, highlight-driven phrase tracking, and concrete query examples.</p><h2>Put governed ecommerce search into practice</h2><p>The control plane architecture described in this post (per-field conflict resolution, cascading policy transformations, and governance-constrained retrieval routing) was designed and built by Elastic Services Engineering. Every pattern, screenshot, and transformation pipeline shown in this series comes from a working system built by Elastic Services Engineering and validated against enterprise-scale product catalogs.</p><p>If you want to implement a governed, policy-driven control plane on Elasticsearch, <a href="https://www.elastic.co/consulting">Elastic Services</a> can get you there faster.</p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch ES|QL query builder for JavaScript and TypeScript: Fluent, type-safe query construction]]></title>
            <description><![CDATA[Exploring the Elasticsearch ES|QL query builder for JavaScript and TypeScript and explaining how to build ES|QL queries with practical examples.]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-query-builder-javascript-typescript</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-query-builder-javascript-typescript</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Margaret Gu]]></dc:creator>
            <pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d8cc9dc5b2bcae4c589b402d62a5b7c8c6d63fb7-720x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We&#x27;re pleased to announce that the Elasticsearch Query Language (ES|QL) query builder is now available for JavaScript and TypeScript. It&#x27;s a fluent, type-safe library that lets you construct ES|QL queries with method chaining, automatic value escaping, and full integrated development environment (IDE) support; no more raw string concatenation.</p><p>Learn how to get started with practical examples you can use right away.</p><h2>Elasticsearch ES|QL query builder for JavaScript and TypeScript</h2><p>If you&#x27;ve ever built an ES|QL query in JavaScript, you&#x27;ve probably written something like this:</p><pre><code>const query = `FROM logs-*
| WHERE status_code >= ${minStatus}
  AND host.name == ${hostname}
  AND @timestamp >= "${startDate}"
| STATS error_count = COUNT(*) BY status_code
| SORT error_count DESC
| LIMIT 10`</code></pre><p>It looks fine until <strong><code>hostname</code></strong> is<strong> <code>O&#x27;Brien&#x27;s server</code></strong> and the whole thing blows up with a parse error. Or until a user passes <strong><code>&quot;; DROP INDEX logs</code></strong> into a search field and you realize you&#x27;ve been building queries with raw string concatenation this entire time.</p><p>There&#x27;s a better way. The ES|QL query builder for JavaScript and TypeScript lets you write queries like this instead:</p><pre><code>import { ESQL, E, f } from '@elastic/elasticsearch-esql-dsl'

const query = ESQL.from('logs-*')
  .where(E('status_code').gte(minStatus))
  .where(E('host.name').eq(hostname))
  .where(E('@timestamp').gte(startDate))
  .stats({ error_count: f.count() })
  .by('status_code')
  .sort(E('error_count').desc())
  .limit(10)</code></pre><p>Values are escaped automatically. You get autocomplete in your editor. And you can see exactly what the query does, without mentally parsing a template literal.</p><p>ES|QL query builders are already available across Elastic&#x27;s language clients, including Python, Ruby, and others. This article focuses on the JavaScript and TypeScript version, walking through practical examples you can start using today.</p><h2>Getting started</h2><p>Install the package:</p><pre><code>npm install @elastic/elasticsearch-esql-dsl</code></pre><p>Here’s a minimal query:</p><pre><code>import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'

const query = ESQL.from('employees')
  .where(E('still_hired').eq(true))
  .sort(E('last_name').asc())
  .limit(10)

console.log(query.render())</code></pre><p>This renders:</p><pre><code>FROM employees
| WHERE still_hired == true
| SORT last_name ASC
| LIMIT 10</code></pre><p>To run it against Elasticsearch:</p><pre><code>import { Client } from '@elastic/elasticsearch'

const client = new Client({ node: 'http://localhost:9200' })
const response = await client.esql.query({ query: query.render() })</code></pre><p>That’s it. No string interpolation, no manual escaping.</p><h2><strong>Building a real query, step by step</strong></h2><p>Let&#x27;s walk through a realistic scenario: You&#x27;re building a dashboard that analyzes web server error logs. We&#x27;ll start simple and layer on features.</p><h3><strong>Step 1: Filter error logs</strong></h3><pre><code>import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'

const errors = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .limit(100)</code></pre><pre><code>FROM logs-*
| WHERE status_code >= 400
| LIMIT 100</code></pre><h3><strong>Step 2: Add a computed column</strong></h3><p>Your timestamps are in milliseconds, but you want response time in seconds:</p><pre><code>const errors = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .eval({ response_secs: E('response_time_ms').div(1000) })
  .limit(100)</code></pre><pre><code>FROM logs-*
| WHERE status_code >= 400
| EVAL response_secs = response_time_ms / 1000
| LIMIT 100</code></pre><h3><strong>Step 3: Aggregate errors by status code</strong></h3><pre><code>import { f } from '@elastic/elasticsearch-esql-dsl'

const errorBreakdown = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .stats({
    error_count: f.count(),
    avg_response: f.avg('response_time_ms'),
  })
  .by('status_code')
  .sort(E('error_count').desc())</code></pre><pre><code>FROM logs-*
| WHERE status_code >= 400
| STATS error_count = COUNT(*), avg_response = AVG(response_time_ms) BY status_code
| SORT error_count DESC</code></pre><p>The <strong><code>f</code></strong> namespace gives you access to 150+ ES|QL function wrappers: aggregations, string functions, date functions, math, geo, and more. They all return chainable expressions, so you can use them anywhere you&#x27;d use <strong><code>E()</code></strong>.</p><h3><strong>Step 4: Use date functions for time-based analysis</strong></h3><pre><code>const hourlyErrors = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .eval({ hour: f.dateTrunc('@timestamp', '1 hour') })
  .stats({ error_count: f.count() })
  .by('hour')
  .sort(E('hour'))</code></pre><pre><code>FROM logs-*
| WHERE status_code >= 400
| EVAL hour = DATE_TRUNC(@timestamp, "1 hour")
| STATS error_count = COUNT(*) BY hour
| SORT hour</code></pre><h3><strong>Step 5: Branch queries safely</strong></h3><p>Every method returns a new query object. The original is never mutated. This means you can build a base query and branch it for different views:</p><pre><code>const base = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .where(E('@timestamp').gte('2026-01-01T00:00:00Z'))

const byStatus = base
  .stats({ count: f.count() })
  .by('status_code')
  .sort(E('count').desc())

const byHost = base
  .stats({ count: f.count() })
  .by('host.name')
  .sort(E('count').desc())
  .limit(20)

const recent = base
  .sort(E('@timestamp').desc())
  .keep('@timestamp', 'status_code', 'url.path', 'message')
  .limit(50)</code></pre><p>Three different queries, one shared base. Change the filter on <strong><code>base</code>,</strong> and all three update. This is especially useful for dashboards where multiple panels query the same dataset with different aggregations.</p><h2><strong>Three ways to write expressions</strong></h2><p>The domain‑specific language (DSL) gives you flexibility in how you write conditions. Here&#x27;s the same WHERE clause written three different ways:</p><p><strong>Raw strings:</strong> When you&#x27;re writing a quick one-off:</p><pre><code>.where('status_code >= 400 AND host.name == "web-01"')</code></pre><p><strong>The <code>E()</code> expression builder: </strong>When you want type safety and autocomplete:</p><pre><code>import { and_ } from '@elastic/elasticsearch-esql-dsl'

.where(and_(
  E('status_code').gte(400),
  E('host.name').eq('web-01')
))</code></pre><p><strong>The <code>esql</code> template tag: </strong>-When you want safe interpolation of dynamic values:</p><pre><code>import { esql } from '@elastic/elasticsearch-esql-dsl'

const minStatus = 400
const host = 'web-01'
.where(esql`status_code >= ${minStatus} AND host.name == ${host}`)</code></pre><p>All three produce the same ES|QL. Pick whichever fits your situation: raw strings for simple cases, <strong><code>E()</code></strong> when building expressions programmatically, and the template tag when mixing literal ES|QL with dynamic values.</p><h2><strong>Keeping queries safe</strong></h2><p>If any part of your query comes from user input, you need to think about injection. ES|QL supports parameter binding, and the DSL makes it straightforward:</p><pre><code>function searchLogs(userQuery: string) {
  const query = ESQL.from('logs-*')
    .where(E('message').eq(E('?')))
    .limit(100)

  return client.esql.query({
    query: query.render(),
    params: [userQuery],
  })
}</code></pre><p>The <strong><code>?</code></strong> placeholder is replaced server-side by Elasticsearch, so the user&#x27;s input never touches the query string. No escaping, no injection risk.</p><h2><strong>Beyond the basics</strong></h2><p>Once you&#x27;re comfortable with the core commands, the DSL supports every advanced ES|QL feature:</p><p><strong>Hybrid search with FORK and FUSE:</strong></p><pre><code>const results = ESQL.from('articles')
  .fork(
    ESQL.branch()
      .where(f.match('title', 'elasticsearch'))
      .sort(E('_score').desc())
      .limit(50),
    ESQL.branch()
      .where(f.knn('embedding', 10))
      .sort(E('_score').desc())
      .limit(50),
  )
  .fuse('RRF')
  .limit(10)</code></pre><p><strong>Data enrichment:</strong></p><pre><code>const enriched = ESQL.from('logs-*')
  .enrich('ip_lookup')
  .on('client.ip')
  .with('geo.city', 'geo.country')</code></pre><p><strong>Conditional aggregation:</strong></p><pre><code>const stats = ESQL.from('employees')
  .stats({
    eng_avg: f.avg('salary').where(E('dept').eq('Engineering')),
    sales_avg: f.avg('salary').where(E('dept').eq('Sales')),
    total: f.count(),
  })</code></pre><p><strong>AI/machine learning (ML) integration:</strong></p><pre><code>const summarized = ESQL.from('docs')
  .completion('Summarize this document')
  .with({ inferenceId: 'my-llm' })</code></pre><p>For the full list of commands and functions, check out the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/javascript-dsl">ES|QL query builder documentation</a>.</p><h2><strong>What&#x27;s next</strong></h2><p>This is the initial release of <strong><code>@elastic/elasticsearch-esql-dsl</code></strong>. You can find the package on <a href="https://www.npmjs.com/package/@elastic/elasticsearch-esql-dsl">npm</a>, explore the source on <a href="https://github.com/elastic/elasticsearch-dsl-js">GitHub</a>, and read the full documentation in the repository. If you run into issues or have feature requests, open an issue; we&#x27;re actively developing this and want to build what JavaScript and TypeScript developers actually need.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How cross-project search (CPS) works in Elasticsearch Serverless]]></title>
            <description><![CDATA[Elastic Cloud Serverless cross-project search (CPS) treats index expressions as cross-project by default. This post explains how TransportSearchAction scopes projects, resolves index expressions, skips projects with no matches, and validates index resolution against allow_no_indices and ignore_unavailable.]]></description>
            <link>https://www.elastic.co/search-labs/blog/cross-project-search-elasticsearch-serverless</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/cross-project-search-elasticsearch-serverless</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Matteo Piergiovanni, Pawan Kartik]]></dc:creator>
            <pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c8bbf6c6b27b8db8a13313f5f3db2cad948f840b-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>With the introduction of cross-project search (CPS) in our Elastic Cloud Serverless offering, we wanted to provide our customers with a streamlined search experience. Our vision is for our users to not worry about which project the data is stored in but instead to focus on the information they want an answer for.</p><p>Users familiar with <a href="https://www.elastic.co/docs/explore-analyze/cross-cluster-search">cross-cluster search</a> (CCS) know that to include a remote cluster in a search you must specifically reference it with a cluster alias prefix, such as <code>remote:metrics*</code> (or <code>*:metrics*</code> to target all remotes). With CPS, &quot;bare&quot; index names or patterns such as <code>metrics*</code> in a search index expression implicitly reference all instances of that index that can be found on the origin project and on all of its linked projects.</p><p>In this post, we pull back the curtains on how the Elasticsearch TransportSearchAction, the code that underlies the _search API (and _async_search) APIs, determines which indices, aliases, and datastreams to search on which projects when running a cross-project search.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9a7c9134b4509dab0dbe26def7e058c40562cfd5-2400x1350.webp" style="width: 100%;" /><h2>Analyzing index expressions</h2><p>One important consequence of the new CPS model is that the Elasticsearch query parameters <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search#operation-search-allow_no_indices"><code>allow_no_indices</code></a> and <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search#operation-search-ignore_unavailable"><code>ignore_unavailable</code></a> require different handling than before. In general, these parameters control whether a search should throw an error if a concrete index name (for example, &quot;logs&quot;) cannot be found or isn’t accessible (<code>ignore_unavailable=false</code>), or a wildcard pattern (for example, &quot;logs*&quot;) doesn’t match anything or if there are no indices at all to search (<code>allow_no_indices=false</code>).</p><p>In CCS, those parameters are analyzed on each cluster separately: Each cluster can just consult which indices, aliases, or data streams exist locally. But in CPS, we need to account for whether each resource (index, alias, or data stream) referenced by an index expression matches on <em>any</em> project (origin or linked), rather than <em>every</em> project.</p><p>To illustrate, suppose a user has one linked project (<code>linked1</code>) and they issue a cross-project query with index expression <code>logs*,metrics-1</code>, along with <code>allow_no_indices=false,ignore_unavailable=false</code>. As long as we find one resource (index, alias, or data stream) that matches <code>logs*</code> and one that matches <code>metrics-1</code> on any project, then the search can proceed. If metrics-1, for example, is found on linked1 but not the origin project, that suffices to pass the <code>ignore_unavailable=false</code> constraint. Only if it’s found nowhere would we throw an IndexNotFoundException.</p><p>To handle this, the _search API in cross-project search mode needs to gather information from each linked project before kicking off the actual search.</p><p>The serverless node that receives the _search REST request is considered to be the origin project, and it acts as the overall search coordinator. To fully analyze and process an index expression before kicking off the actual search, the search coordinator needs to:</p><ol><li>Determine which projects are in scope for the search.</li><li>Determine which indices, aliases, or data streams should be searched in each individual project.</li><li>Do a final reconciliation step of all the information gathered: 1) Have we found all the indices required to proceed (as defined by the allow_no_indices and ignore_unavailable settings)?; and 2) Are there any projects that should be skipped since they have no matching indices?</li></ol><p>To illustrate, we’ll follow a CPS request against the _search API from start to finish. Suppose that the incoming user request is to search <code>metrics*</code> and that the origin project has two indices which match the expression, namely <code>metrics-ES</code> and <code>metrics-KB</code>, and that the origin project is linked to projects <code>P1</code> and <code>P2</code>, where <code>P1</code> has index <code>metrics-ES</code> and <code>P2</code> has no index, alias, or data stream that matches <code>metrics*</code>.</p><h2>Determining projects in scope</h2><p>The search coordinator starts by grabbing the full list of linked projects from Elasticsearch cluster state. It then determines, based on <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search#security">Universal Identity and Access Management</a> (UIAM) credentials provided with the query, which of those projects the user has access to and keeps the subset of projects that the user is allowed to access.</p><p>If a <code>project_routing</code> parameter is present on the request, that can further limit which projects are in scope for the query. For instance, <code>&quot;project_routing&quot;:&quot;_alias:_origin&quot;</code> would cull the list to just the origin project, while <code>&quot;project_routing&quot;:&quot;_alias:P*&quot;</code> would include the linked projects P1 and P2 and remove the origin project from the list of projects in scope for the query.</p><p>The index expression itself can also limit which projects to access. Qualified index expressions, like <code>&quot;P*:metrics*&quot;,</code> would indicate that we should search only on projects whose alias starts with &quot;P&quot;. For our case, the &quot;bare&quot; or unqualified index expression <code>&quot;metrics*&quot;</code> means &quot;search on all projects that are in scope,&quot; where &quot;in scope&quot; is modified by security access and project_routing.</p><h2>Determining which indices each project has</h2><p>Once we know which projects to search, we need to determine which matching indices each one has. To support this, the search coordinator creates a <code>ResolvedIndexExpressions</code> data structure that allows tracking which indices on each project should be included in the search.</p><p>For each index expression provided by the user, that data structure tracks:</p><ul><li>Original expression: The index expression, as provided by the user.</li><li>Local resolution: The local expressions that will replace the original together with the resolution result.</li><li>Remote expressions: A set of remote expressions one for each project a query can target.</li></ul><p>On the search coordinator that received the request, we’ve so far been able to determine which projects are in scope (origin, P1, and P2) and which, if any, matching indices exist on origin. So, for our example search against <code>metrics*</code>, we’ll have the following structure at that point in time:</p><ul><li>Original expression: <code>metrics*</code>.</li><li>Local resolution: <code>SUCCESS</code>, <code>&lt;metrics-ES, metrics-KB&gt;</code>.</li><li>Remote expressions: <code>&lt;P1:metrics*, P2:metrics*&gt;</code>.</li></ul><p>The remote expressions are left unresolved until we later contact the P1 and P2 projects to fill in that information.</p><p>The index resolution can be any of the following:</p><ul><li><code>SUCCESS</code>: Local resolution completed successfully.</li><li><code>NOT_VISIBLE</code>: Indicates that a non-wildcard expression was resolved to nothing, either because the index doesn’t exist or is closed.</li><li><code>UNAUTHORIZED</code>: Indicates that the expression could be resolved to a concrete index, but the requesting user isn’t authorized to access it.</li><li><code>NONE</code>: No local resolution was attempted, typically because the expression is remote-only (for example, <code>P1:index</code>).</li></ul><h2>Check indices on the linked projects</h2><p>In the search API, how we check for indices on the linked projects depends on whether the cross-project search is being run with <a href="https://www.elastic.co/docs/explore-analyze/cross-cluster-search#ccs-min-roundtrips-true">minimize round trips</a> or not. Most searches in CPS are set internally to run with minimize_roundtrips=true, so we’ll focus on that pathway.</p><p>In CCS, where index expression analysis is done locally on each cluster, we just send the same request to every cluster and, with minimize_roundtrips=true, each remote cluster sends back an entire SearchResponse that the primary search coordinator collects and eventually merges into all the other responses it receives.</p><p>By contrast, for CPS, an additional phase was introduced to search where we contact each linked project to assess which indices are present and which ones the user has access to. This is an additional round trip that uses <code>ResolveIndexAction</code>, the class that implements the functionality of the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search#operation-search-ignore_unavailable"><code>_resolve/index</code></a> endpoint. Upon receiving the <code>ResolveIndex</code> responses from all the linked projects, we can fill in the ResolvedIndexExpressions data structure on the primary search coordinator. For this case, since <code>P1</code> has index <code>metrics-ES</code> and <code>P2</code> has no matching indices, the updated data structure would be:</p><ul><li>Original expression: <code>metrics*</code>.</li><li>Local resolution: <code>SUCCESS</code>, <code>&lt;metrics-ES, metrics-KB&gt;</code>.</li><li>Remote expressions: <code>&lt;P1: SUCCESS &lt;metrics-ES&gt;, P2: NOT_VISIBLE&gt;</code>.</li></ul><h2>CPS validator</h2><p>Once we have all the linked project information, we can run the validation to honor the <code>IndicesOptions</code> specified by the caller.</p><p>If the user had specified a qualified index expression (for example, original=<code>P1:metrics*</code>), the CPS validator needs to make sure that project <code>P1</code> has at least one index matching <code>metrics*</code>, otherwise a 404 index not found exception would be returned to the user. In other words, qualified expressions imply that an index, alias, or data stream matching that name must be present on all projects specified by the qualifier.</p><p>On the other hand, for “bare” (unqualified) CPS index expressions (for example, original=<code>metrics*</code>), we only need to check whether the original index expression exists anywhere.</p><p>For the example we’re following in this post, the ResolveIndexExpressions show that at least one index, alias, or data stream matching <code>metrics*</code> was found, so the search can proceed.</p><p>However, we can also see that the P2 cluster has no matching indices. In that case, we can remove it from the rest of the query. So now, for the actual query, we’ll:</p><ul><li>On the origin project, search <code>metrics-ES, metrics-KB</code>.</li><li>On the <code>P1</code> project, search <code>metrics-ES</code>.</li><li>Skip the <code>P2</code> project. Note: Since it wasn’t included in the query at all, it won’t show up on the <a href="https://www.elastic.co/docs/explore-analyze/cross-project-search#cps-examples">_cluster/details</a> of the SearchResponse.</li></ul><h2>Conclusions</h2><p>We’ve described how the TransportSearchAction class in Elasticsearch supports some key new features of cross-project search. That class supports a large number of Elasticsearch REST APIs, such as, _search, _async_search, _msearch, _eql, _sql and _count, all of which use the mechanisms described in this document.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elastic-caveman: Cutting AI response tokens by 64% without losing the best of Elastic]]></title>
            <description><![CDATA[Learn how to use elastic-caveman to cut AI response tokens while keeping the Elastic agentic brilliance.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-caveman-ai-token-reduction</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-caveman-ai-token-reduction</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sri Kolagani]]></dc:creator>
            <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b162c28623b9070fd1980bcd891b9dd1e868f2f0-720x421.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>When querying Elasticsearch through an AI assistant, you need facts: index names, field mappings, Elasticsearch Query Language (ES|QL) queries, case IDs, sentiment scores. But current large language model (LLM) interfaces wrap every response in conversational padding:</p><p><em>&quot;Of course! I&#x27;d be happy to help you...&quot;</em></p><p><em>&quot;This should give you a good overview...&quot;</em></p><p><em>&quot;Feel free to let me know if you need anything else!&quot;</em></p><p>This isn&#x27;t just annoying; it&#x27;s expensive. Every token costs money and adds latency. For production Elasticsearch queries, that overhead compounds fast. In this post, we introduce elastic-caveman and share the results of a controlled experiment across eight live Model Context Protocol (MCP) scenarios against an Elasticsearch cluster. The findings: 63.6% average token reduction, 817 tokens saved, and zero loss of technical accuracy.</p><h2><strong>Enter elastic-caveman</strong></h2><p><a href="https://github.com/srikolag/elastic-caveman">elastic-caveman</a> tests a simple hypothesis: Strip AI responses to pure signal, and measure the impact. The approach:</p><ul><li>Normal mode: Full conversational AI with greetings, explanations, and sign-offs.</li><li>Caveman mode: Raw data with minimal structural labels only.</li></ul><p>We tested both modes against a live Elasticsearch instance using MCP with real support ticket and Salesforce case data across eight production scenarios.</p><h2><strong>Results: 64% token reduction, zero accuracy loss</strong></h2><p>Here&#x27;s what we found across eight live MCP tool calls: The Elastic-Caveman initiative has successfully optimized AI response size without compromising quality or functionality.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric</td><td style="border: 1px solid #ccc; padding: 4px;">Result</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Scenarios tested</td><td style="border: 1px solid #ccc; padding: 4px;">8</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Success rate</td><td style="border: 1px solid #ccc; padding: 4px;">88%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Token reduction</td><td style="border: 1px solid #ccc; padding: 4px;">63.6% average</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total normal tokens</td><td style="border: 1px solid #ccc; padding: 4px;">1,284</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total Caveman tokens</td><td style="border: 1px solid #ccc; padding: 4px;">467</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tokens saved</td><td style="border: 1px solid #ccc; padding: 4px;">817</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Max reduction (single scenario)</td><td style="border: 1px solid #ccc; padding: 4px;">91.5%</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Key preservations (0% loss):</strong></p><ul><li>Technical accuracy</li><li>API paths</li><li>ES|QL syntax</li><li>Field names</li></ul><p>The critical finding: Every field name, case ID, ES|QL query, account name, and sentiment score was preserved exactly. Not approximately. Exactly.</p><h2><strong>Real examples: Before and after</strong></h2><p><strong>Example 1. List indices: 87% reduction</strong></p><p>User: <em>Show me my indices</em></p><p><strong>Normal mode (107 tokens):</strong></p><pre><code>Of course! I'd be happy to help you see your indices. Here's a comprehensive list of all the indices in your Elasticsearch cluster. Each entry shows the index name along with relevant metadata. This should give you a good overview of what's stored in your cluster:-- salesforce-cases-- support-ticketsThese are all the indices I found. If you'd like to dive deeper into any specific index, just let me know and I'll be glad to assist!</code></pre><p><strong>Caveman mode (14 tokens):</strong></p><pre><code>Indices: salesforce-cases, support-tickets</code></pre><p><strong>Saved: 93 tokens (86.9%)</strong></p><p><strong>Example 2. Generate ES|QL query: 75% reduction</strong></p><p>User: <em>Show me open critical tickets grouped by product area</em></p><p><strong>Normal mode (208 tokens):</strong></p><pre><code>Sure thing! I'd be happy to help you write that ES|QL query. What you're looking for is a query that will filter for open tickets with critical priority and group them by product area. Here's how you can accomplish this...</code></pre><p><em>[followed by the actual query, plus 150+ tokens of step-by-step explanation]</em></p><p><strong>Caveman mode (52 tokens):</strong></p><pre><code>FROM support-tickets
WHERE status = "Open" AND priority = "Critical"
| STATS count = COUNT(*) BY product_area
| SORT count DESC
| LIMIT 100

Filters open+critical, groups by product_area, counts, sorts desc.</code></pre><p><strong>Saved: 156 tokens (75.0%). ES|QL syntax is character-for-character identical in both modes.</strong><br/><strong>Example 3. Search recent support tickets: 35% reduction</strong></p><p>User: <em>Show me 5 recent support tickets</em></p><p><strong>Caveman mode (143 tokens):</strong></p><pre><code>5 recent tickets, newest first:
Case     | Priority | Status    | Account               | Subject                                    | Days | Sentiment
CASE-0012 | Critical | Open      | Pinnacle Financial    | Regulatory reporting: incorrect tax calcs  | 2    | -0.94
CASE-0021 | Critical | Escalated | Riverstone Capital    | Security audit blocking renewal            | 2    | -0.93
CASE-0046 | Medium   | Open      | Blackrock Partners    | Pen test findings need vendor response     | 2    | -0.70
CASE-0006 | High     | Open      | United Oil & Gas      | Unauthorized access -- urgent               | 3    | -0.91
CASE-0017 | High     | Open      | Riverstone Capital    | Renewal paperwork stalled                  | 4    | -0.86

All negative sentiment. Two critical cases from April 7. All case IDs, priorities, account names, and sentiment scores preserved verbatim.</code></pre><h2><strong>What gets removed vs. what stays</strong></h2><p>When we clean up the output, we strip out conversational filler, like <em>“Of course! I’d be happy to help you…”</em>, <em>“This should give you a good overview…”</em>, or <em>“Would you like me to help you prioritize these?”</em>, and we keep every piece of factual content, such as ES|QL snippets, like FROM support-tickets WHERE status = &quot;Open&quot;; field names like sentiment_score, product_area, and resolution_hours; and index names, like support-tickets and salesforce-cases. We also preserve concrete identifiers and business entities, such as case IDs CASE-0012 and CASE-0002; account names, like Pinnacle Financial and United Oil Gas Corp; along with all numeric values, for example, a sentiment_score of -0.94, counts like 47 duplicates, durations such as 18 days, or metrics like 27.0 average hours, so the edited text is tightly focused on query syntax, entities, and numbers while discarding only the polite scaffolding.</p><p>Results varied by operation type:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query type</td><td style="border: 1px solid #ccc; padding: 4px;">Token reduction</td><td style="border: 1px solid #ccc; padding: 4px;">Why</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metadata listings</td><td style="border: 1px solid #ccc; padding: 4px;">85–92%</td><td style="border: 1px solid #ccc; padding: 4px;">Small payload, maximum filler in normal mode</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ES|QL generation</td><td style="border: 1px solid #ccc; padding: 4px;">70–75%</td><td style="border: 1px solid #ccc; padding: 4px;">Query is identical; explanation is eliminated</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Data-heavy searches</td><td style="border: 1px solid #ccc; padding: 4px;">35–40%</td><td style="border: 1px solid #ccc; padding: 4px;">Actual data dominates, leaving less room for fluff</td>
                    </tr>
              </tbody>
            </table>
          <h2><strong>Complete evaluation breakdown</strong></h2><p>Token savings by query type across all eight scenarios against live MCP data:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Scenario</td><td style="border: 1px solid #ccc; padding: 4px;">Normal tokens</td><td style="border: 1px solid #ccc; padding: 4px;">Caveman tokens</td><td style="border: 1px solid #ccc; padding: 4px;">Reduction</td><td style="border: 1px solid #ccc; padding: 4px;">Tokens saved</td><td style="border: 1px solid #ccc; padding: 4px;">MCP tool</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T1: List all streams</td><td style="border: 1px solid #ccc; padding: 4px;">118</td><td style="border: 1px solid #ccc; padding: 4px;">10</td><td style="border: 1px solid #ccc; padding: 4px;">91.5%</td><td style="border: 1px solid #ccc; padding: 4px;">108</td><td style="border: 1px solid #ccc; padding: 4px;">platform.streams.list_streams</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T2: List indices</td><td style="border: 1px solid #ccc; padding: 4px;">107</td><td style="border: 1px solid #ccc; padding: 4px;">14</td><td style="border: 1px solid #ccc; padding: 4px;">86.9%</td><td style="border: 1px solid #ccc; padding: 4px;">93</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.list_indices</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T3: Get index mapping</td><td style="border: 1px solid #ccc; padding: 4px;">143</td><td style="border: 1px solid #ccc; padding: 4px;">40</td><td style="border: 1px solid #ccc; padding: 4px;">72.0%</td><td style="border: 1px solid #ccc; padding: 4px;">103</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.get_index_mapping</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T4: Generate ES|QL query</td><td style="border: 1px solid #ccc; padding: 4px;">208</td><td style="border: 1px solid #ccc; padding: 4px;">52</td><td style="border: 1px solid #ccc; padding: 4px;">75.0%</td><td style="border: 1px solid #ccc; padding: 4px;">156</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.generate_esql</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T5: Execute ES|QL aggregation</td><td style="border: 1px solid #ccc; padding: 4px;">149</td><td style="border: 1px solid #ccc; padding: 4px;">44</td><td style="border: 1px solid #ccc; padding: 4px;">70.5%</td><td style="border: 1px solid #ccc; padding: 4px;">105</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.execute_esql</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T6: Search recent tickets</td><td style="border: 1px solid #ccc; padding: 4px;">221</td><td style="border: 1px solid #ccc; padding: 4px;">143</td><td style="border: 1px solid #ccc; padding: 4px;">35.3%</td><td style="border: 1px solid #ccc; padding: 4px;">78</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.search</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T7: Search escalated cases</td><td style="border: 1px solid #ccc; padding: 4px;">198</td><td style="border: 1px solid #ccc; padding: 4px;">128</td><td style="border: 1px solid #ccc; padding: 4px;">35.4%</td><td style="border: 1px solid #ccc; padding: 4px;">70</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.search</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">T8: ES|QL stats by priority</td><td style="border: 1px solid #ccc; padding: 4px;">140</td><td style="border: 1px solid #ccc; padding: 4px;">36</td><td style="border: 1px solid #ccc; padding: 4px;">74.3%</td><td style="border: 1px solid #ccc; padding: 4px;">104</td><td style="border: 1px solid #ccc; padding: 4px;">platform.core.execute_esql</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">TOTALS</td><td style="border: 1px solid #ccc; padding: 4px;">1,284</td><td style="border: 1px solid #ccc; padding: 4px;">467</td><td style="border: 1px solid #ccc; padding: 4px;">63.6%</td><td style="border: 1px solid #ccc; padding: 4px;">817</td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
              </tbody>
            </table>
          <p>Technical accuracy verification:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Accuracy check</td><td style="border: 1px solid #ccc; padding: 4px;">Result</td><td style="border: 1px solid #ccc; padding: 4px;">Details</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ES|QL syntax preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">FROM, WHERE, STATS, SORT, LIMIT identical</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Field names preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">account_id, sentiment_score, product_area verbatim</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Index names preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">support-tickets, salesforce-cases unchanged</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Case IDs preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">CASE-0012, CASE-0002 exact</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Account names preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">Pinnacle Financial, United Oil Gas Corp exact</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Numeric values preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">Sentiment scores -0.94, -0.88; days open 18, 7 exact</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Priority/status labels</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">Critical, Escalated, Open verbatim</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Null values preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">null for low priority resolution hours retained</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Error messages preserved</td><td style="border: 1px solid #ccc; padding: 4px;">PASS</td><td style="border: 1px solid #ccc; padding: 4px;">Tool validation errors quoted verbatim</td>
                    </tr>
              </tbody>
            </table>
          <p><em>Zero information loss. 64% fewer tokens.</em></p><h2><strong>Why this matters for Elastic users</strong></h2><p>For teams building AI assistants on Elasticsearch, 64% token reduction means 64% savings on output costs at scale, faster streaming responses, and more context window space for actual data rather than fillers. When you&#x27;re debugging an ES|QL query at 2 a.m., you don&#x27;t need an AI telling you it&#x27;s delighted to help; you just need the query response!</p><h2><strong>The bigger picture: Rethinking AI interfaces</strong></h2><p>This experiment reveals something fundamental: Conversational AI interfaces optimize for the wrong metric. They optimize for sounding human when users often just want accurate data, fast.</p><p>For technical workflows, especially data queries, there&#x27;s a strong case for mode-switching:</p><ul><li>Conversational mode: When exploring or learning.</li><li>Caveman mode: When you know what you want and need it now.</li></ul><p>The Elastic MCP server makes this possible by returning structured, accurate responses that work in both modes without modification.</p><h2><strong>How elastic-caveman works</strong></h2><p>elastic-caveman is an Agent Skill, that is, a markdown file with YAML front matter that any compatible AI agent reads and follows. No runtime. No binary. No API calls. Just instructions that reshape how your agent talks when working with Elasticsearch.</p><p>Install with:</p><pre><code>npx skills add srikolag/elastic-caveman</code></pre><p><strong>Supported agents: </strong>Claude Code, Cursor, Codex, Windsurf, GitHub Copilot, Gemini CLI, Roo</p><p><strong>Trigger with:<code>/elastic-caveman</code></strong></p><p><strong>Disable with:</strong>&quot;normal mode&quot; or &quot;verbose&quot;</p><h2><strong>Live in action</strong></h2><p>We tested elastic-caveman with the Claude model to measure its impact on token usage and cost:</p><ul><li><strong>With elastic-caveman:</strong> Token usage was 368 tokens (in) and 1.6k tokens (out), resulting in a cost of <strong>$0.11</strong>.</li><li><strong>Without elastic-caveman:</strong> Token usage was 367 tokens (in) and 1.8k tokens (out), resulting in a cost of <strong>$0.12</strong>.</li></ul><blockquote>Prompt: <em>Get me the critical support tickets from the support-tickets index in kibana for Pinnacle Financial</em></blockquote><p>This test demonstrates the efficiency of elastic-caveman.</p><a href="https://videos.elastic.co/watch/pRHKth67Mv5pGgqSvCdXiF">Watch video on Vidyard</a><h2><strong>What&#x27;s next</strong></h2><p>Caveman mode is just the beginning. Consider dynamic mode switching: Flip between concise and conversational mid-session. Or a hybrid approach: Lean on success, explanatory on errors. Or custom verbosity levels for teams that want something in between. The goal isn&#x27;t to make AI assistants robotic; it&#x27;s to give users control over the signal-to-noise ratio.</p><h2><strong>Try it yourself</strong></h2><p>Test caveman mode with your Elasticsearch data:</p><ol><li>Set up the Elastic MCP server.</li><li> Install elastic-caveman.</li><li>Run queries in both normal and caveman modes.</li><li>Compare token counts and accuracy.</li></ol><p>Full evaluation methodology and scripts available in the <a href="https://github.com/srikolag/elastic-caveman">GitHub repo</a>.</p><h2><strong>The bottom line</strong></h2><p>Across eight real scenarios with live Elasticsearch data, elastic-caveman delivered 64% average token reduction with zero accuracy loss and 100% preservation of ES|QL syntax, field names, and technical values. Sometimes the best AI response isn&#x27;t the chattiest one. Sometimes you just need the data; and with elastic-caveman, you can get it 64% faster. Ready to optimize your Elasticsearch AI workflows? Check out Elasticsearch Labs for more tutorials, integrations, and research on building with Elasticsearch and AI, or start building with Elasticsearch today.</p><p>Want to optimize your Elasticsearch AI workflows? Check out <a href="https://www.elastic.co/search-labs">Elasticsearch Labs</a> for more tutorials, integrations, and research on building with Elasticsearch and AI. Ready to try it yourself? <a href="https://cloud.elastic.co/registration">Start building</a> with Elasticsearch today.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your Elasticsearch field mappings are lying to you — METRICS_INFO and TS_INFO tell the truth]]></title>
            <description><![CDATA[Two new Elasticsearch ES|QL commands that turn time series discovery into a single-line query: METRICS_INFO and TS_INFO tell you which metrics and series actually exist in your data, not just what the mapping declares.]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-metrics-info-ts-info-time-series-catalog</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-metrics-info-ts-info-time-series-catalog</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Dmitry Leontyev]]></dc:creator>
            <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/96a2ffa07e5b1b89999b3c3d466c113a0a7afc8b-1376x768.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Two new Elasticsearch ES|QL processing commands, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/metrics-info"><code>METRICS_INFO</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts-info"><code>TS_INFO</code></a>, tell you which metrics and time series actually have data for your current query context, not just what the mapping declares. Field mappings enumerate every field ever written; these commands return what&#x27;s actively ingested, typed, and queryable right now, scoped to your time window and filters. A single-line query against 1.84 billion documents and 1.4 million time series returns in ~4 seconds. Available GA in Elasticsearch 9.4 and Elastic Cloud Serverless.</p><h2>Why time series discovery matters</h2><p>Elasticsearch uses <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time series data streams</a> (TSDS) to efficiently store metrics. Backed by a fully columnar store, metrics stored in TSDS in Elasticsearch 9.4 require up to 17x less storage compared to using a standard index. Starting with Elasticsearch 9.2, we&#x27;ve also added <a href="https://www.elastic.co/search-labs/blog/esql-elasticsearch-9-2-multi-field-joins-ts-command#time-series-support-in-es%7Cql-(tech-preview)">time-series support in Elasticsearch Query Language (ES|QL) as a fully supported capability</a> when querying data stored in TSDS.</p><p>If you operate TSDS in Elasticsearch, you already know the pattern: <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-dimension">dimensions</a> identify a series, <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-metric">metrics</a> carry typed values like <code>gauge</code> or <code>counter</code>, and the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts">source command</a> in ES|QL enables <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">time series aggregation functions</a> such as <code>RATE</code> and <code>AVG_OVER_TIME</code>.</p><p>What that pipeline can&#x27;t tell you (but you need to know just as often) is <strong>which metrics and time series actually exist right now, for the slice of data you care about</strong>. Field mappings enumerate every field that was ever declared; they don&#x27;t show what&#x27;s actively being ingested in a specific cluster, environment, or time window. That gap shows up across very different workflows:</p><ul><li><strong>Dashboard building.</strong> Metric and dimension pickers should reflect what the cluster currently holds for the user&#x27;s filters, not every field that has ever been mapped. Otherwise, dropdowns stay cluttered with stale options and panels render empty.</li><li><strong>Onboarding to an unfamiliar TSDS.</strong> A new cluster, a new integration, a customer&#x27;s data. A quick list of the metrics being ingested, with their types, units, and applicable dimensions, replaces hours of mapping spelunking and ad hoc probe queries.</li><li><strong>Data quality investigations.</strong> Mapping drift (the same metric declared <code>gauge</code> in one backing index and <code>counter</code> in another) and dimension-cardinality explosions both surface immediately in the catalog output.</li><li><strong>Query validation.</strong> Before running an expensive <code>TS ... | STATS</code> aggregation, confirm that the metric and dimensions you&#x27;re about to use really have data in your window.</li></ul><p>Kibana already relies on this internally. The <a href="https://www.elastic.co/docs/solutions/observability/infra-and-hosts/discover-metrics">dynamic metrics catalog</a> in the observability experience appends <code>METRICS_INFO</code> to the user&#x27;s active <code>TS</code> query so the UI only offers metrics that truly exist for the current filters, rather than every field in the mapping.</p><h2>The problem: Mappings are an inventory of fields, not time series</h2><p>Operations teams routinely need answers to questions that mapping APIs alone cannot answer:</p><ul><li>Which metrics actually have data <strong>in this environment</strong>, <strong>for this cluster</strong>, <strong>in this time range</strong>?</li><li>How are those metrics typed, and which dimensions apply when building or validating a query?</li><li>How many distinct time series exist per metric?</li></ul><p>Until now, answering these questions meant piecing together mapping APIs, ad hoc queries, and guesswork. <code>METRICS_INFO</code> and <code>TS_INFO</code> turn those questions into single-line ES|QL queries that fit naturally into the same pipeline you use for <code>STATS</code>:</p><pre><code>TS k8s
| WHERE cluster == "prod"
| METRICS_INFO
| SORT metric_name</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_name</td><td style="border: 1px solid #ccc; padding: 4px;">data_stream</td><td style="border: 1px solid #ccc; padding: 4px;">unit</td><td style="border: 1px solid #ccc; padding: 4px;">metric_type</td><td style="border: 1px solid #ccc; padding: 4px;">field_type</td><td style="border: 1px solid #ccc; padding: 4px;">dimension_fields</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">bytes</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">long</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">usd</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">double</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
              </tbody>
            </table>
          <h2>How these commands integrate with ES|QL pipelined queries</h2><p>Both commands are <strong>processing commands</strong>. Once you run one, the table is replaced: Downstream commands, like <code>KEEP</code>, <code>WHERE</code>, or <code>STATS</code>, operate on <strong>metadata rows</strong>, not the original time series documents.</p><p>A few rules to keep in mind:</p><ul><li>They apply only after a <strong><code>TS</code></strong> source. Using them after <code>FROM</code> or without a preceding <code>TS</code> source produces an error.</li><li>They must appear <strong>before</strong> <code>STATS</code>, <code>SORT</code>, or <code>LIMIT</code> run on the <strong>time series rows</strong> returned by <code>TS</code>. For example, <code>TS ... | STATS ... | METRICS_INFO</code> is invalid; <code>TS ... | METRICS_INFO | STATS ...</code> is valid because <code>STATS</code> then runs on the metadata table.</li><li>You can filter and aggregate <em>after</em> <code>METRICS_INFO</code> or <code>TS_INFO</code> on the metadata columns with the usual processing commands.</li><li>You can include filters <strong>before</strong> them, for example, narrowing by <code>@timestamp</code> or dimensions, so that the produced metadata reflects series that match your query context, not the entire index.</li></ul><p>Conceptually, the pipeline looks like this:</p><pre><code>TS + filters  →  METRICS_INFO or TS_INFO  →  KEEP / WHERE on metadata  →  STATS / SORT / LIMIT</code></pre><p>This design means you can scope a catalog to exactly the slice of data you care about and then post-process the result with more ES|QL commands as desired.</p><h2>How to use METRICS_INFO and TS_INFO in practice</h2><p><strong><code>METRICS_INFO</code></strong> retrieves information about the metrics available in your time series data streams, together with applicable dimensions and other metadata, all scoped to the current <code>TS</code> query. <strong><code>TS_INFO</code></strong> does the same for <strong>individual time series</strong>. Each row is one metric plus the dimension values that identify one series.</p><p>Each command offers a different view to time series metadata: <strong><code>METRICS_INFO</code></strong> collapses what you see into <strong>one row per distinct metric signature</strong>: the metric name plus how it&#x27;s declared (type, unit, field type, which dimension fields apply) as observed across backing indices. <strong><code>TS_INFO</code></strong> adds <strong>one row per metric and time series</strong>, with a <code>dimensions</code> column that holds the concrete label set for each series, formatted as a JSON object (for instance, <code>{&quot;job&quot;:&quot;elasticsearch&quot;,&quot;instance&quot;:&quot;instance_1&quot;}</code>).</p><p>If the same logical metric name shows up with incompatible metadata in different places, you get multiple rows or multi-valued cells. That&#x27;s a useful signal when you&#x27;re tracking down mapping drift.</p><p>Both commands expose the same core columns; only <code>TS_INFO</code> adds <code>dimensions</code>.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Column</td><td style="border: 1px solid #ccc; padding: 4px;">Meaning</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_name</td><td style="border: 1px solid #ccc; padding: 4px;">Name of the metric.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">data_stream</td><td style="border: 1px solid #ccc; padding: 4px;">Data stream(s) that contain this metric; multi-valued when it spans multiple data streams.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">unit</td><td style="border: 1px solid #ccc; padding: 4px;">Unit
        
        declared in the mapping (e.g. bytes); multi-valued when definitions differ across backing indices; may be null.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_type</td><td style="border: 1px solid #ccc; padding: 4px;">Types such as gauge or counter; multi-valued when definitions differ across backing indices.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">field_type</td><td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch field type (long, double, ...); multi-valued when definitions differ across backing indices.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">dimension_fields</td><td style="border: 1px solid #ccc; padding: 4px;">Dimension field names for this metric (multi-valued): the union of dimension keys across all time series for that metric.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">dimensions</td><td style="border: 1px solid #ccc; padding: 4px;">TS_INFO only. JSON-encoded dimension key/value pairs that identify one time series.</td>
                    </tr>
              </tbody>
            </table>
          <p>Start with a catalog of names and types. The smallest useful query is a <code>TS</code> source, <code>METRICS_INFO</code>, and a sort so the table is easy to scan:</p><pre><code>TS k8s
| METRICS_INFO
| SORT metric_name</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_name</td><td style="border: 1px solid #ccc; padding: 4px;">data_stream</td><td style="border: 1px solid #ccc; padding: 4px;">unit</td><td style="border: 1px solid #ccc; padding: 4px;">metric_type</td><td style="border: 1px solid #ccc; padding: 4px;">field_type</td><td style="border: 1px solid #ccc; padding: 4px;">dimension_fields</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">bytes</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">long</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">usd</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">double</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
              </tbody>
            </table>
          <p>You can post-process the result as usual in ES|QL. For instance, you can trim columns or filter on metadata before aggregating:</p><pre><code>TS k8s
| WHERE cluster == "prod" AND TRANGE(1d)
| METRICS_INFO
| KEEP metric_name, metric_type
| SORT metric_name</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_name</td><td style="border: 1px solid #ccc; padding: 4px;">metric_type</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td>
                    </tr>
              </tbody>
            </table>
          <p>To find <strong>how many</strong> distinct metric names match a pattern (not <strong>which</strong> series), combine <code>METRICS_INFO</code> with <code>STATS</code>:</p><pre><code>TS k8s
| METRICS_INFO
| WHERE metric_name LIKE "network.total*"
| STATS matching_metrics = COUNT_DISTINCT(metric_name)</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">matching_metrics</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td>
                    </tr>
              </tbody>
            </table>
          <p>Document predicates <strong>before</strong> the catalog command narrow the processed time series to data samples that actually exist in your window. The metrics listed are those with matching data, not every field that has ever been mapped:</p><pre><code>TS k8s
| WHERE cluster == "prod" AND TRANGE(1d)
| METRICS_INFO
| SORT metric_name</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_name</td><td style="border: 1px solid #ccc; padding: 4px;">data_stream</td><td style="border: 1px solid #ccc; padding: 4px;">unit</td><td style="border: 1px solid #ccc; padding: 4px;">metric_type</td><td style="border: 1px solid #ccc; padding: 4px;">field_type</td><td style="border: 1px solid #ccc; padding: 4px;">dimension_fields</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">bytes</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">long</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">usd</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">double</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td>
                    </tr>
              </tbody>
            </table>
          <p>Run the <strong>same</strong> scoped pipeline, but swap the middle command for <code>TS_INFO</code>, and the question shifts from “which metrics match” to “which time series identities match”. Each row is one metric plus one combination of dimension values; sort on <code>metric_name</code> and <code>dimensions</code> so related series group together:</p><pre><code>TS k8s
| WHERE cluster == "prod" AND TRANGE(1d)
| TS_INFO
| SORT metric_name, dimensions</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">metric_name</td><td style="border: 1px solid #ccc; padding: 4px;">data_stream</td><td style="border: 1px solid #ccc; padding: 4px;">unit</td><td style="border: 1px solid #ccc; padding: 4px;">metric_type</td><td style="border: 1px solid #ccc; padding: 4px;">field_type</td><td style="border: 1px solid #ccc; padding: 4px;">dimension_fields</td><td style="border: 1px solid #ccc; padding: 4px;">dimensions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"one","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"three","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"two","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"one","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"three","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">packets</td><td style="border: 1px solid #ccc; padding: 4px;">gauge</td><td style="border: 1px solid #ccc; padding: 4px;">integer</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"two","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">bytes</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">long</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"one","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">bytes</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">long</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"three","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">bytes</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">long</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"two","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">usd</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">double</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"one","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">usd</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">double</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"three","region":"[eu, us]"}</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td><td style="border: 1px solid #ccc; padding: 4px;">k8s</td><td style="border: 1px solid #ccc; padding: 4px;">usd</td><td style="border: 1px solid #ccc; padding: 4px;">counter</td><td style="border: 1px solid #ccc; padding: 4px;">double</td><td style="border: 1px solid #ccc; padding: 4px;">[cluster, pod, region]</td><td style="border: 1px solid #ccc; padding: 4px;">{"cluster":"prod","pod":"two","region":"[eu, us]"}</td>
                    </tr>
              </tbody>
            </table>
          <p>That extra column can be used to deduce metric cardinality. Each <code>TS_INFO</code> row is one time series for a given metric, so grouping with <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS</code></a> counts <strong>how many distinct time series exist per metric</strong>:</p><pre><code>TS k8s
| TS_INFO
| STATS series_count = COUNT(*) BY metric_name
| SORT metric_name</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">series_count</td><td style="border: 1px solid #ccc; padding: 4px;">metric_name</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Choosing between them:</strong> Use <strong><code>METRICS_INFO</code></strong> when you want a compact inventory of metric names and types in the filtered <code>TS</code> context. Use <strong><code>TS_INFO</code></strong> when you need label combinations, per-metric series counts. In practice, skim with <code>METRICS_INFO</code> and then switch to <code>TS_INFO</code> when the answer depends on <strong>which</strong> dimensions apply, not just <strong>what</strong> metrics exist.</p><h2>Under the hood: How the commands are executed</h2><p>Both <code>METRICS_INFO</code> and <code>TS_INFO</code> run inside the same distributed ES|QL execution that powers any <code>TS</code> query. In addition to standard features, like shard-level parallelism, Lucene filter pushdown, and coordinator-side merging, special care has been taken during implementation so that the cost scales with the number of matching time series, not the number of documents. Here&#x27;s how each output row gets produced:</p><p><strong>1. The</strong> <strong><code>TS</code></strong> <strong>command defines the scope.</strong> <code>TS</code> resolves your data stream pattern to its TSDS <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-bound-tsds">backing indices</a> and turns any filters you place <strong>before</strong> the catalog command, such as a time range on <code>@timestamp</code> or dimension predicates in <code>WHERE</code>, into a Lucene query that runs on every shard that can match. Shards in backing indices outside the time window are pruned up front and never touched.</p><p><strong>2. Each shard iterates over matching documents and tracks one per series.</strong> A TSDS index is physically sorted by <code>_tsid</code> first, then by <code>@timestamp</code> (descending). That sort matters here: All documents belonging to the same time series sit next to each other on disk, so as a shard processes documents in order, it only needs to keep the first document it sees for each new <code>_tsid</code> and skip the rest. The result is one representative document per time series that has at least one document matching your filters.</p><p><strong>3. The mapping tells us what each field is.</strong> The backing index mapping is the source of truth for the metadata that describes each field:</p><ul><li>Fields declared with <code>time_series_metric</code> are metrics, and the mapping carries each metric&#x27;s <code>metric_type</code>, <code>field_type</code>, and (if declared) <code>meta.unit</code>.</li></ul><p>4. <strong>Synthetic source fills in the actual dimension and metric presence.</strong> For the one representative document per series, the shard reads a subset of _source containing only the dimension (and metric) paths the mapping declares. TSDS uses <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#synthetic-source">synthetic</a> <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#synthetic-source"><code>_source</code></a>, so that subset is reconstructed primarily from doc values — no stored _source is needed. From that reconstructed sliver of JSON, the shard learns two things:</p><ul><li>The dimension key/value pairs for this series (the <code>dimensions</code> JSON for <code>TS_INFO</code>, and the set of dimension keys that feed <code>dimension_fields</code> for both commands).</li><li>Which metric fields actually have data for this series in this backing index.</li></ul><p><strong>5. Partial aggregation happens inside each shard.</strong> Shards don&#x27;t ship raw per-series rows upstream. They partially aggregate first, which is a big part of why catalog queries stay cheap.</p><p><strong>6. The coordinator merges across shards and data streams.</strong> Each data node first reduces its own shards&#x27; partial results and streams them to the coordinator, which applies the same merge logic one more time.</p><p><strong>7. The rest of the pipeline runs as usual.</strong> Everything after the catalog command (<code>KEEP</code>, <code>WHERE</code>, <code>STATS</code>, <code>SORT</code>, <code>LIMIT</code>) runs against this consolidated metadata table on the coordinator, exactly like any other ES|QL stage.</p><p>The net effect is that catalog queries do just enough work to identify one representative document per series, read a small reconstructed slice of that document, classify its fields against the mapping, and fold the results into a handful of metadata rows. Because the output cardinality is bounded by the number of matching series (for <code>TS_INFO</code>) or by the number of distinct metric signatures (for <code>METRICS_INFO</code>), not by the number of documents in the window, these commands stay responsive even against long retention windows and high-ingest data streams.</p><p>Running these commands against the full high cardinality TSDB benchmark corpus without a time range filter (1.84 B documents / 1.4 M time series / 2.77 TB uncompressed) on a single-node Elasticsearch (AWS c8gd.8xlarge, 24 cores, 24 GiB heap, NVMe SSD, 3 primary shards, force-merged), <code>METRICS_INFO</code> returns in ~4 seconds.</p><h2>Beyond ad hoc queries</h2><p>These commands also support product workflows inside Kibana. The UI appends <code>METRICS_INFO</code> to a user&#x27;s <code>TS</code> query (when the query doesn&#x27;t already include <code>STATS</code>) <a href="https://www.elastic.co/docs/solutions/observability/infra-and-hosts/discover-metrics">to build a metric catalog</a> aligned with the user&#x27;s filters, rather than relying solely on mappings.</p><p>These new commands are also the foundation of Prometheus-compatible metadata APIs we&#x27;re adding to Elasticsearch, which Prometheus-based tools can use. Stay tuned for a dedicated blog post that goes into more detail.</p><h2>Data quality</h2><p>Multi-valued <code>unit</code>, <code>metric_type</code>, or <code>field_type</code> in the <code>METRICS_INFO</code> output, is a concise warning that backing indices aren&#x27;t aligned on a metric&#x27;s definition. <code>TS_INFO</code> makes it easier to see whether an explosion in cardinality comes from a handful of metrics or from dimension cardinality you should account for in alerts and aggregations. For example, ranking metrics by series count surfaces outliers at a glance:</p><pre><code>TS k8s
| TS_INFO
| STATS series_count = COUNT(*) BY metric_name
| SORT series_count DESC</code></pre>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">series_count</td><td style="border: 1px solid #ccc; padding: 4px;">metric_name</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">12000</td><td style="border: 1px solid #ccc; padding: 4px;">network.eth0.rx</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.eth0.tx</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.total_bytes_in</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">network.total_cost</td>
                    </tr>
              </tbody>
            </table>
          <p>When a single metric dwarfs the rest, as <code>network.eth0.rx</code> does above, the explosion is concentrated in <strong>a handful of metrics</strong>, and inspecting that metric&#x27;s <code>dimensions</code> pinpoints which label is growing. Comparable counts across metrics instead point at <strong>shared dimension cardinality</strong>, such as a newly introduced <code>pod</code> or <code>instance</code> value propagating through every time series.</p><h2>Availability</h2><p><strong><code>METRICS_INFO</code></strong> and <strong><code>TS_INFO</code></strong> are <strong>generally available</strong> in <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> and in Elasticsearch basic starting with the 9.4.0 release.</p><p>For command pages (syntax, restrictions, and examples), see <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/metrics-info"><code>METRICS_INFO</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts-info"><code>TS_INFO</code></a>.</p><p>For background on TSDS and the <code>TS</code> command itself, start with the official documentation on <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time series data streams</a> and the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts">source command</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Stop guessing which query is burning your cluster: Query activity in Kibana]]></title>
            <description><![CDATA[Pinpoint long-running Elasticsearch searches from Kibana: live tasks, origin context, and cancel when the cluster allows without living in low-level APIs.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-query-activity-long-running-searches</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-query-activity-long-running-searches</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Valentin Crettaz]]></dc:creator>
            <pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3515b0a19116ad940210e4fe43026c8e6350b0d7-1536x1024.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Query activity in <a href="https://www.elastic.co/docs/deploy-manage/monitor/query-activity">Kibana</a> gives you a live list of <strong>in-flight search tasks</strong> in Elasticsearch (ES|QL, DSL, SQL, EQL), so <strong>in about a minute</strong> you can move from “something’s slow” to which query, how long it’s been running, and whether you can cancel it. It builds on the Tasks Management API, but it’s the operational view you can show in a war room. If you’re on Elastic Cloud Serverless, it’s available now; on Elastic Cloud Hosted and self-managed, it ships with Kibana 9.4.</p><h2>When “something is slow” finally has an answer</h2><p>Query activity is already available in your Elastic Cloud Serverless project today. For Elastic Cloud Hosted and Elastic Self-Managed clusters, it ships with Kibana 9.4 and is available across all deployments and clusters on that version. Query activity is the Kibana view on top of the Tasks Management API in Elasticsearch. It’s purpose-built for search-related tasks in any query language, including Elasticsearch Query Language (ES|QL), DSL, SQL, and Event Query Language (EQL).</p><p>It always starts the same way. Someone pings you on a Friday: <em>Discover feels stuck.</em> <em>The exec dashboard won’t load.</em> <em>Did we change something?</em> You open your monitoring tabs, squint at CPU, maybe tail a log, and you’re still guessing. Is it one giant ES|QL pipeline? A dashboard nobody remembers? A background rule doing honest work at the worst possible time? The cluster isn’t mysterious on purpose. In-flight work is simply invisible unless you enjoy living in Dev Tools and reconstructing life stories from task IDs and snippets of JSON.</p><p>We built Query activity for everyone who has ever muttered, <em>Just tell me what’s running.</em> It’s a new screen in Kibana that lists active search work in ES|QL, DSL, SQL, or EQL. It shows the queries that are consuming your cluster right now, with enough context to move from <strong>panic to diagnosis</strong> without a scavenger hunt.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/33ab7fc484605af4caa2c21913364159a2f23014-1999x775.png" style="width: 100%;" /><h2>The play you know and the one-minute rewrite</h2><p>If you’ve operated Elasticsearch for more than a week, you’ve lived the old script. In <strong>Act I</strong>, someone says the cluster feels slow. In <strong>Act II</strong>, you scatter across shards, heap, slow logs, and sticky-note task IDs. Hours pass, and you still cannot name the query. In <strong>Act III</strong>, maybe you find the culprit before dinner, or maybe next month Act I opens again with the same villain in a fake mustache.</p><p>Query activity replaces that meandering Act II with one default sequence. It’s the same story, compressed from symptom to evidence to origin to action in about a minute. Paste this into your runbook or send it to your on-call channel. It’s the whole innovation in practice.</p><p>1. <strong>Open Query activity</strong> as soon as Act I hits. On Elastic Cloud Hosted and Elastic Self-Managed clusters, go to <strong>Stack Management</strong> and then <strong>Cluster performance</strong>. On Elastic Cloud Serverless, go to <strong>Admin and Settings</strong> and then <strong>Project performance</strong>. Do this before you fork into guesswork.</p><p>2. <strong>Refresh the list of queries</strong> once so you’re looking at <strong>right now</strong>, not five minutes ago.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bbfe0ba1e09916331c148ca791f1f7d9b0b40985-1999x331.png" style="width: 100%;" /><p>3. <strong>Surface the pressure.</strong> Sort by run time, or tighten the “Run time” filter, until the expensive work floats to the top.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b7d487d2ce4496f7fe306d4c7e2527ac60699d05-764x448.png" style="width: 100%;" /><p>4. <strong>Open the flyout</strong> on the worst offender. You’ll see duration, query type, index breadth, and full query text. That’s your evidence without opening Dev Tools.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7e62154a314a53e705b0faf9057a941e108fd610-1326x1582.png" style="width: 100%;" /><p>5.<strong> Name the owner.</strong> Use <code>trace.id</code> to jump into <strong>Discover</strong> and filter on the audit or query logs for that trace, or use <code>X-Opaque-Id</code> to figure out which dashboard, saved search, or rule this query originates from.</p><p>6. <strong>Resolve Act III.</strong> Let the query finish, fix the upstream, or <strong>cancel</strong> when it’s appropriate and Elasticsearch says the task is cancelable.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3830fdbe460fb947323f35f0626a0fd76beacf41-1434x383.png" style="width: 100%;" /><p>That’s one pass through what used to be three acts. You get attribution instead of folklore and decisions instead of theater.</p><h2>Query activity deep dive</h2><p>The one-minute sequence above is the habit. What follows is the machinery: the concrete controls and signals in Kibana that make that rewrite possible. You get what’s executing, how long it’s been running, where it came from, and what to do next, without stitching clues across tabs.</p><p>Under the hood, this view is powered by Elasticsearch’s Tasks Management API for long-running search tasks. It’s translated into an operational UI that’s built for speed. You can find the outlier quickly, inspect rich details, and act with confidence.</p><p>Here’s how the UI backs each beat of the runbook.</p><p>The main view is a filterable list of running queries. It includes a search bar so you can match anything in the table, including task ID. You also get filters for run time, query language, and source (for example Discover, Dashboard, and similar surfaces). You stay in control of what “noisy” means.</p><p>Refresh is manual on purpose. The table does not auto-refresh. You click <strong>Refresh </strong>when you’re ready, and the UI shows when the last refresh happened. You shouldn’t have to wonder whether the list is stale.</p><p>When you click a task ID, a detail flyout opens. It shows start time, run time, how many indices the query touches, and full query text. When <code>X-Opaque-Id</code> is present, it can help you <a href="https://www.elastic.co/docs/troubleshoot/kibana/trace-elasticsearch-query-to-the-origin-in-kibana">trace an Elasticsearch query to its origin in Kibana</a> so you can turn “mystery load” into “that dashboard, that version.” Previous and next navigation let you walk the queue without jumping back to the list. When <code>trace.id</code> is available, you can open Discover with that trace prefiltered. That helps when the incident channel is already busy.</p><p>Where work is cancelable, you can request cancellation from the list or the flyout. There’s a deliberate confirmation step. After you confirm, the cancel control shows a spinner until Elasticsearch reports that the task actually stopped. The goal is oops-proof, not oops-fast.</p><p>Viewing and managing active query work requires appropriate cluster privileges. The UI states clearly when something is missing. For example, users without <code>cluster:manage</code> may not be able to take destructive actions. Users without <code>cluster:monitoring</code> may not see task details. You shouldn’t get a blank screen that feels like the stack is playing hide-and-seek.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4c5a8f7aa5187029ed705f4a7affa9424f33c983-1999x1027.png" style="width: 100%;" /><p>If you’ve been following our broader story around query observability, this is the live side of the house. It’s what is happening now, in the product, with controls you can use. Over time, pair it with historical views, such as query logs and <a href="https://www.elastic.co/search-labs/blog/slow-search-elasticsearch-query-autoops">AutoOps long-running search tasks insights</a> when you need to ask whether this has happened before. When you need to answer what’s eating your cluster in this minute, start with the new Query activity UI in Kibana.</p><h2>Who this is for (and who becomes the hero)</h2><p>Cluster and platform admins get the obvious win: <strong>faster incident response</strong> and less time translating APIs into narratives for stakeholders.</p><p>Centers of excellence and internal search champions get something equally valuable: a teachable moment you can screenshot. This is the query pattern that blew up shared capacity. This is what “interactive” versus “background” pressure looks like when everyone is busy.</p><p>Anyone on the hook for Service Level Agreements (SLAs) gets a cleaner bridge between user experience (“the app is slow”) and search reality (“these three requests are still running, and one of them is huge”).</p><p>You don’t have to be the person who wrote the query to be the person who explains the cluster calmly. That’s the whole point.</p><p>Not every task is cancelable, and deep tuning work still has its place. Query activity doesn’t fix your queries for you. It surfaces in seconds which ones might need attention, and it gives you faster evidence, clearer attribution, and better decisions before you reach for heavier tools.</p><h2>Where to find it</h2><p>You’ll find Query activity in the performance area of each deployment model. In Elastic Cloud Hosted and Elastic Self-Managed clusters, open <code>Stack Management</code> and then <code>Cluster performance</code>. In Serverless projects, open <code>Admin and Settings</code> and then <code>Project performance</code>.</p><p>Threshold hygiene: Open <code>Stack Management</code> and then <code>Advanced Settings</code>. The <code>running_queries:minRunningTime</code> setting defaults to <code>100 ms</code>. Only tasks that have been running longer than that appear. That way you can dial through noise without drowning in instantaneous work.</p><h2>What to do next</h2><p>Walk through the six-step sequence once when the cluster is calm. When Act I hits, you won’t be learning a new UI under pressure. Then repeat it during the next slow moment. The gap between assuming and seeing is the whole product story.</p><p>If you aren’t on Elastic Cloud yet, you can still get hands-on with the stack at <a href="https://cloud.elastic.co/registration">elastic.cloud/registration</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Accelerating merchandising improvements with a governed control plane]]></title>
            <description><![CDATA[Search behavior changes shouldn't require an engineering ticket. Learn how a governed control plane lets business teams update search policies in hours, without deployments, without risk.]]></description>
            <link>https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dc802d2ca828ba41d6ff2a0ea1ba67eb0e3bcd10-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval">Part 1</a> in this blog series established why ecommerce search needs a governance layer between the user&#x27;s query and the retrieval engine that classifies intent, enforces business constraints, and routes to the appropriate retrieval strategy. The natural next questions are: Who operates that layer, and how fast can they move?</p><p>This post answers those questions. A governed control plane doesn&#x27;t just improve search relevance; it changes the operating model. It moves search behavior changes from engineering deployment cycles to business-driven workflows, without sacrificing safety or accountability.</p><h2>The scenario that exposes the operating model</h2><p>Imagine that you’re in the weeks leading up to Christmas, and your merchandising team has identified three urgent changes that must immediately be made to search behavior:</p><ul><li><strong>Campaign launch.</strong> Due to an ordering error, there’s an oversupply of in-house branded turkeys. Therefore any query for &quot;turkey&quot; must boost the in-house brand.</li><li><strong>Product recall.</strong> A supplier has recalled a product line. Queries that would surface those products shouldn’t be shown.</li><li><strong>Seasonal reinterpretation.</strong> Queries for &quot;stocking&quot; are returning women&#x27;s hosiery and tights. During the holiday season, &quot;stocking&quot; should resolve to Christmas stockings and stocking stuffers. Once the season ends, the policy can be reverted in minutes.</li></ul><p>Under the traditional operating model, where search logic is embedded in application code, each of these changes requires an engineering ticket, a code change, a review cycle, a staging deployment, and a production release. In organizations with conservative release processes, that&#x27;s a timeline measured in weeks, not hours or minutes. The Christmas shopping window closes before engineering can ship the necessary modifications.</p><p>The bottleneck isn’t the retrieval engine; it’s the operating model. The core challenge is that business intent cannot be translated into search behavior without engineering acting as a constant intermediary, turning every strategic pivot into a technical ticket.</p><h2>The anti-pattern: Search logic in application code</h2><p><a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval">Part 1</a> described how search logic embedded in application code can turn into a &quot;spaghetti&quot; implementation, which creates operational friction. Here’s what that friction looks like at scale. What starts as a few targeted overrides, a filter here, a boost there, grows over time into tens of thousands of lines of if/else branching, regex patterns, and conditional query modifications. This creates problems beyond just technical debt:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fc4d7ea5545512667552af429023fcd7fb316e82-1408x768.png" style="width: 100%;" /><p>This model introduces four systemic frictions that hinder both organizational speed and system scalability:</p><p><strong>Coupling.</strong> Business strategy changes daily. Application infrastructure should remain highly stable. When both live in the same codebase, a merchandiser&#x27;s request to boost a seasonal product becomes a deployment risk, and a scoring function refactor can silently break a campaign.</p><p><strong>Latency (organizational and computational).</strong> A single query behavior change can require a six-week deployment cycle: ticket, investigation, code change, review, staging, release. Furthermore, the application layer lacks any indexing mechanism to efficiently determine which policies apply to a given query, so policy evaluation often adds meaningful latency at query time as the system walks through sequential if/else checks.</p><p><strong>Accountability gaps.</strong> When results change unexpectedly, nobody can quickly answer <em>why</em>. Was it a synonym update? A scoring change? A new filter added three releases ago? When business logic is distributed across thousands of lines of application code, shipped by different teams across different releases, tracing a relevance change back to its root cause becomes an archaeology project.</p><p><strong>Misallocated engineering.</strong> This model turns skilled software engineers into full-time relevance mechanics. Instead of building platform capabilities, they spend their cycles translating merchandising requests into code changes and debugging interactions and conflicts between hard-coded business policies.</p><h2>The paradigm shift: Policies as data</h2><p>The solution is to decouple business policies from application code entirely. Instead of hard-coding query modifications in middleware, store governed policies as structured documents, each one expressing a discrete business intent, and evaluate them at query time in a dedicated governed control plane layer.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/13d47992fb1d1f3f3887f3800f5ddc83742e9c9c-1408x768.png" style="width: 100%;" /><p>A policy is a first-class data object. It has match criteria (when should this policy fire?), an action (what should it do?), a priority (how does it interact with other policies?), and metadata (a title and a description). The control plane evaluates matching policies, resolves conflicts deterministically, and produces an execution plan including constraints, boosts, and routing decisions that Elasticsearch executes against a product catalog.</p><p>For each additional search requirement, the application code doesn&#x27;t change. The retrieval engine doesn&#x27;t change. What changes is that business decisions are no longer encoded in code. They live in a policy index as data that can be updated without a deployment.</p><p>This changes your org chart, not just your query.</p><h2>Policies vs. triggers vs. rules</h2><p>A note on terminology used in this series: a <em>policy</em> refers to this complete governed document, including a trigger (match criteria), rule (action), priority, enabled/disabled, and metadata. A <em>trigger</em> refers to the matching criteria that determines when this policy fires, and a <em>rule</em> refers specifically to the action inside the policy, such as applying a filter or changing the retrieval strategy.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/281e0fb915a7723a5619b5bd08f855abb4e2c530-966x1412.png" style="width: 100%;" /><h2>The workflow: Author → Test → Promote</h2><p>Moving policies out of code and into data opens the door for business-driven search management. But enabling non-technical teams to alter search behavior requires strict operational guardrails. The goal is fast and safe iteration with governance.</p><p>To empower non-technical teams to modify search behavior with confidence, we suggest a three-stage workflow: Author, Test, and Promote. Let’s examine the components of this workflow in detail.</p><p><strong>Author.</strong> A merchandiser creates a policy using structured fields: what the policy should match, what action it should take, and at what priority. The interface guides the business user through what’s expressible.</p><p><a href="https://www.elastic.co/consulting">Elastic Services</a> has built and deployed a governed framework for enterprise ecommerce customers, which has an admin UI that looks as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3c8cd24f24320f5661800e34686719bc7d6c78e2-1005x959.png" style="width: 100%;" /><p><strong>Test.</strong> The policy is validated in a non-production environment where the merchandiser can run representative queries and verify that the policy produces the expected behavior, including how it interacts with other active policies. Because the control plane infrastructure is identical across environments, what works in the test environment will work in production.</p><p><strong>Review.</strong> Before a policy is promoted to production, it passes through review. Depending on the organization&#x27;s risk tolerance, this might be a peer review from another merchandiser, an approval from a search lead, or an automated validation that checks for conflicts with existing policies.</p><p><strong>Promote.</strong> Once approved, the policy is promoted to the production policy index. It takes effect on the next query: no code deployment, no engineering release, no staging build. The entire promotion is a data operation: the same JSON document, moved to a different index.</p><p><strong>Disable.</strong> If a production policy produces unexpected behavior, it can be disabled immediately without engineering involvement. Disabling removes the policy from query evaluation instantly, without affecting any other policy in the system.</p><p>This is the &quot;zero-deploy&quot; promise. It doesn&#x27;t mean &quot;no process.&quot; It means the process operates on <em>policy data</em>, not application code. This distinction compresses the change cycle from weeks to hours or minutes.</p><h2>Why &quot;zero-deploy&quot; matters for revenue-critical queries</h2><p>The economics of ecommerce search are asymmetric. A small number of high-volume queries (&quot;milk,&quot; &quot;bread,&quot; &quot;oranges,&quot; &quot;diapers&quot;) drive a disproportionate share of revenue. When one of these queries returns unexpected results, the cost is immediate and measurable: Conversion drops, customer complaints spike, and the merchandising team opens an urgent ticket.</p><p>Under the traditional model, the response cycle is:</p><ol><li>The merchant notices the problem.</li><li>The merchandiser files a ticket with engineering.</li><li>Engineering investigates, identifies the cause, and writes a fix.</li><li>The fix goes through code review, staging, and release.</li><li>Production is updated.</li></ol><p>Depending on the organization, steps 2 through 5 may take weeks. For a revenue-critical query during a peak sales period, that latency costs money.</p><p>Under a governed control plane, the response cycle compresses:</p><ol><li>The merchant notices the problem.</li><li>The merchandiser drafts a policy fix (or modifies an existing policy).</li><li>The policy goes through review and is published.</li><li>The fix is live.</li></ol><p>The difference isn&#x27;t just speed. It&#x27;s ownership. The person closest to the business context (the merchandiser who understands why &quot;oranges&quot; should resolve to produce, not beverages) is the person making the change. Engineering is freed from the daily merchandising loop to focus on the platform. This shift also unlocks something that&#x27;s nearly impossible under the traditional model: attributing search performance changes to specific business decisions.</p><h2>Measurability: Which policy moved conversion</h2><p>When policies are discrete, versioned documents that are stored in an Elasticsearch index, each one becomes independently deployable and therefore its impact can be more easily measured. You can answer questions that are nearly impossible to answer when business logic is scattered across application code:</p><ul><li>Did the &quot;cheap laptops&quot; price threshold policy improve conversion for that query class, or did it suppress it?</li><li>What was the click-through rate impact of the holiday campaign boost?</li><li>When we rolled back the &quot;oranges&quot; category constraint last Thursday, what happened to add-to-cart rates?</li></ul><p>This turns search governance into a data-driven discipline. Instead of vague &quot;relevance tuning,&quot; where a release contains a dozen changes and nobody can attribute the outcome, you get measurable, attributable impact per policy. Merchandisers can iterate with evidence. Engineers can evaluate whether a policy schema change produced the expected downstream effect. Leadership can see which policies are driving revenue and which are inert.</p><h2>What this means for each role</h2><h3>For merchandisers and business users</h3><p>Search behavior becomes something you can directly influence through structured policies without understanding Elasticsearch syntax or scoring algorithms. You can see what policies are triggered for a given query to understand why it produces specific results, and make changes within hours instead of weeks. The same policy mechanism also supports sponsored product placement: A merchandiser can create a boost policy that elevates a product or brand and flags it for a &#x27;Sponsored&#x27; indicator in the UI, without requiring engineering involvement or additional infrastructure.</p><h3>For search engineers</h3><p>The control plane separates two concerns that are currently entangled: retrieval optimization and business logic. Instead of maintaining tens of thousands of lines of application code that encodes business decisions, you maintain the retrieval engine and the control plane infrastructure. When a merchandiser needs a new campaign boost, they don&#x27;t need engineering to write it.</p><p>This doesn&#x27;t eliminate engineering involvement. Engineers design the policy schema, maintain the control plane, set guardrails on what policies can express, add new capabilities as required, and handle edge cases that fall outside the policy framework. But the day-to-day operational cadence of modifying query behavior shifts to the people who own the business context.</p><h3>For site reliability engineers and platform teams</h3><p>Because policies are structured documents rather than application code, they fit naturally into existing operational workflows. Policies can be stored in version control, reviewed through pull requests, and deployed through the same continuous integration and continuous deployment (CI/CD) pipelines the team already uses. Conflicts between policies are detected and resolved deterministically at query time through the control plane&#x27;s priority system, not through unpredictable interactions between code branches shipped in different releases.</p><p>When something does go wrong, diagnosing the cause is straightforward: Policies are discrete, named, and individually toggleable. A problematic policy can be disabled or deleted immediately without affecting anything else in the system. Compare that to debugging a relevance regression caused by an interaction between a synonym update, a scoring function change, and a new analyzer, all shipped in the same release with no clear attribution.</p><h2>Beyond manual authoring: Large language model–assisted (LLM-assisted) policy suggestions</h2><p>The policies described so far are authored by humans (a merchandiser identifying a gap and drafting a fix). But the same governed workflow supports a second mode: LLM-assisted policy suggestion.</p><p>An LLM can run offline or in the background, analyzing query logs, identifying patterns where search results underperform, such as queries with high exit rates, low click-through, or frequent reformulations. An LLM can then suggest new policies that enter the same Author → Test → Promote pipeline, where a human evaluates each one before it reaches production.</p><h2>Governance is the enabler, not the constraint</h2><p>It might seem counterintuitive: Adding a governance layer makes the system <em>faster</em> to change, not slower. This is the same pattern that works in other domains. CI/CD pipelines don&#x27;t slow down software delivery; they make it safe to ship frequently. Access control doesn&#x27;t slow down collaboration; it makes it safe to share broadly.</p><p>A governed control plane works the same way. The reason a query behavior change takes six weeks isn&#x27;t that the code change is complex; it&#x27;s that nobody is confident enough to ship it faster, because the blast radius is unclear and the rollback path is uncertain.</p><p>Governance provides that confidence. When every policy is explicit, every conflict is resolved deterministically, and every change can be instantly disabled and then rolled back (because policies are structured JSON documents that can be version controlled using existing workflows), the cost of iteration drops dramatically. Business teams move at the speed of the market. Engineering focuses on the platform.</p><h2>From operating model to architecture</h2><p>The shift from business logic in code to business policies as data is more than a technical refactoring; it&#x27;s an organizational change that puts relevance ownership with the teams closest to the business context. But it raises an architectural question: How do you evaluate policies at query time without adding latency or turning the control plane itself into a new form of spaghetti?</p><p>The next post will dig into exactly that: the design pattern that enables fast, deterministic policy evaluation at query time.</p><h2>Put governed ecommerce search into practice</h2><p>The workflow described here, merchandisers authoring, testing, and promoting search policies without engineering deployments, is already available. Elastic Services Engineering designed and built it, and Elastic Services has the skills to deploy it for enterprise ecommerce teams.</p><p>If your organization is ready to move from deployment-gated relevance tuning to business-editable search with governance and auditability, we can accelerate your implementation. Contact <a href="https://www.elastic.co/consulting">Elastic Professional Services</a>.</p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Preconditioning Vectors: Making Elasticsearch VectorDB Better Binary Quantization work for every vector]]></title>
            <description><![CDATA[Modern quantization techniques can hurt recall when using older models or embeddings that aren’t normally distributed. Learn how preconditioning fixes these vectors through random orthogonal projection, making BBQ more effective and recovering recall.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-bbq-preconditioning-vectors</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-bbq-preconditioning-vectors</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[John Wagster]]></dc:creator>
            <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2f5809208179f0040ba699b2c8731533ef845bc7-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch as a vector database offers comprehensive quantization techniques like <a href="https://www.elastic.co/search-labs/blog/optimized-scalar-quantization-elasticsearch">Better Binary Quantization (BBQ)</a>. BBQ and other similarly modern quantization techniques compress vectors down to as little as a single bit per dimension, reducing memory use while retaining impressively accurate distance approximation. For vectors generated from deep learning models, such as Cohere models, this works really well; however, for other kinds of vectors, such as image data or histogram features, recall can be impacted heavily. Preconditioning fixes this by applying a random orthogonal rotation to your vectors before quantization, redistributing variance evenly across dimensions so every bit captures meaningful signal, in some cases improving recall by almost 75%.</p><p>Here, we’ll provide some intuition about the problem and how preconditioning solves it.</p><h2>The problem</h2><p>BBQ quantizes each dimension of a vector independently: Values above the mean become 1, values below it become 0. This works well when every dimension carries roughly the same amount of information. Transformer-based embeddings tend to have this property naturally such that their dimensions are learned representations that distribute variance evenly.</p><p>But there are lots of real-world vectors that aren’t like this. Consider a 784-dimension vector representing a grayscale image, like in the Fashion-MNIST dataset. Some pixels near the center of the image, where the clothing actually appears, vary a lot across the dataset. However, other pixels, such as those near the corners, are mostly one color and barely vary at all. When BBQ quantizes these vectors, the high-variance dimensions lose precision because a single bit can&#x27;t capture their range, while the low-variance dimensions become useless. The resulting quantized vectors are poor approximations of the originals, and recall suffers.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d7230d6d1067eb8b40f1f3e2e514388dc846473f-767x789.png" style="width: 100%;" /><p>Picture of a representation of Fashion-MNIST images. (credit: <a href="https://www.geeksforgeeks.org/deep-learning/how-to-load-fashion-mnist-dataset-using-pytorch/">geeksforgeeks.org</a>)</p><h2>Precondition</h2><p>To fix the problem, we want to spread the information more evenly across dimensions so that each bit captures roughly the same amount of information.</p><p>Preconditioning applies a linear transformation to every vector before quantization. The transformation is an orthogonal rotation that reshuffles how information is distributed across dimensions without changing the distances between vectors. If you want to dig into the math, take a look at this in-depth analysis on <a href="https://www.elastic.co/search-labs/blog/robust-optimized-scalar-quantization">optimized scalar quantization (OSQ) with preconditioners</a>.</p><p>Here’s a graphic to help illustrate how preconditioning can help when applying quantization. This simplified two-dimensional diagram illustrates the idea that the orthogonal rotation helps to increase the spread, or range, of information that was previously quite compressed. While this two-dimensional animation is not an exact representation of preconditioning, it gives a good intuition for what roughly happens in higher dimensions where buckets of dimensions are transformed independently and a random projection can greatly improve the distribution. Imagine that the y-axis represents one pixel of our Fashion-MNIST corners that are primarily one shade with very low variance and the x-axis represents a pixel of clothing at the center of the image with very high variance. Without preconditioning, quantizing vectors to a single representative point is not a particularly good discriminator.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/40a3b20218bfd440f9d8f4aca991b31b83df759e-1080x608.gif" style="width: 100%;" /><h3>Let’s look at the data</h3><p>Today, preconditioning is supported in <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a>. Here’s a benchmark showing the impact when visiting different percentages of the total vector dataset.</p><p>Fashion-MNIST Recall (784 dimensions, 60K docs, 5x oversample, k: 10)</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Vectors visited</td><td style="border: 1px solid #ccc; padding: 4px;">Baseline recall</td><td style="border: 1px solid #ccc; padding: 4px;">Preconditioned recall</td><td style="border: 1px solid #ccc; padding: 4px;">% Improvement</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.5%</td><td style="border: 1px solid #ccc; padding: 4px;">0.45</td><td style="border: 1px solid #ccc; padding: 4px;">0.77</td><td style="border: 1px solid #ccc; padding: 4px;">71%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3%</td><td style="border: 1px solid #ccc; padding: 4px;">0.49</td><td style="border: 1px solid #ccc; padding: 4px;">0.77</td><td style="border: 1px solid #ccc; padding: 4px;">57%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5%</td><td style="border: 1px solid #ccc; padding: 4px;">0.50</td><td style="border: 1px solid #ccc; padding: 4px;">0.87</td><td style="border: 1px solid #ccc; padding: 4px;">74%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">10%</td><td style="border: 1px solid #ccc; padding: 4px;">0.55</td><td style="border: 1px solid #ccc; padding: 4px;">0.91</td><td style="border: 1px solid #ccc; padding: 4px;">65%</td>
                    </tr>
              </tbody>
            </table>
          <p>GIST (960 dimensions, 1M docs, 5x oversample, k: 10)</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Vectors visited</td><td style="border: 1px solid #ccc; padding: 4px;">Baseline recall</td><td style="border: 1px solid #ccc; padding: 4px;">Preconditioned recall</td><td style="border: 1px solid #ccc; padding: 4px;">% Improvement</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.1%</td><td style="border: 1px solid #ccc; padding: 4px;">0.49</td><td style="border: 1px solid #ccc; padding: 4px;">0.69</td><td style="border: 1px solid #ccc; padding: 4px;">41%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.2%</td><td style="border: 1px solid #ccc; padding: 4px;">0.70</td><td style="border: 1px solid #ccc; padding: 4px;">0.77</td><td style="border: 1px solid #ccc; padding: 4px;">10%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.3%</td><td style="border: 1px solid #ccc; padding: 4px;">0.73</td><td style="border: 1px solid #ccc; padding: 4px;">0.85</td><td style="border: 1px solid #ccc; padding: 4px;">16%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.5%</td><td style="border: 1px solid #ccc; padding: 4px;">0.78</td><td style="border: 1px solid #ccc; padding: 4px;">0.88</td><td style="border: 1px solid #ccc; padding: 4px;">13%</td>
                    </tr>
              </tbody>
            </table>
          <p>SIFT (128 dimensions, 1M documents, 5x oversample, k: 10)</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Vectors visited</td><td style="border: 1px solid #ccc; padding: 4px;">Baseline recall</td><td style="border: 1px solid #ccc; padding: 4px;">Preconditioned recall</td><td style="border: 1px solid #ccc; padding: 4px;">% Improvement</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0.5%</td><td style="border: 1px solid #ccc; padding: 4px;">0.48</td><td style="border: 1px solid #ccc; padding: 4px;">0.60</td><td style="border: 1px solid #ccc; padding: 4px;">25%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1%</td><td style="border: 1px solid #ccc; padding: 4px;">0.59</td><td style="border: 1px solid #ccc; padding: 4px;">0.71</td><td style="border: 1px solid #ccc; padding: 4px;">20%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3%</td><td style="border: 1px solid #ccc; padding: 4px;">0.71</td><td style="border: 1px solid #ccc; padding: 4px;">0.87</td><td style="border: 1px solid #ccc; padding: 4px;">23%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">7%</td><td style="border: 1px solid #ccc; padding: 4px;">0.72</td><td style="border: 1px solid #ccc; padding: 4px;">0.90</td><td style="border: 1px solid #ccc; padding: 4px;">25%</td>
                    </tr>
              </tbody>
            </table>
          <p>That’s a nice boost in recall; however, this boost comes with a cost. Applying it to all embeddings blindly is inefficient, causing ~2–4% overhead in query latencies with no improvement in recall for datasets that don’t need to be preconditioned. And upwards of 20% additional overhead at index time. For production use cases where you see initially low recall, you may want to evaluate the impact of preconditioning with your specific model and dataset.</p><h2>Here’s the how</h2><p>Preconditioning is available for the <code>bbq_disk</code> index type. Simply set <code>precondition</code> to <code>true</code> in the <code>index_options</code>, like so:</p><pre><code>{
  "mappings": {
    "properties": {
      "my_vector": {
        "type": "dense_vector",
        "dims": 784,
        "index_options": {
          "type": "bbq_disk",
          "precondition": true
        }
      }
    }
  }
}</code></pre><p>Take a look at the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-params">dense vector mapping</a> docs for more details.</p><h2>Conclusion</h2><p>BBQ is highly effective for deep learning embeddings, but it can be less effective with embeddings that have uneven variance across dimensions, as can occur in feature-engineered vectors. Preconditioning redistributes that variance so quantization can be more effective. On some datasets, like Fashion-MNIST, we see as much as a 74% improvement in recall!</p><p>For now, we’ve made preconditioning optional. Hopefully, you feel more capable of knowing when it may be beneficial so you try it out yourself. In the future, we plan to iterate on performance and automatically detect when to apply preconditioning.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Reindexing data streams due to mapping conflicts]]></title>
            <description><![CDATA[Learn how to fix Elasticsearch mapping conflicts by reindexing data streams. This blog explains the reindexing process and how to ensure new data is correctly mapped.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-mapping-conflicts-reindex-data-streams</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-mapping-conflicts-reindex-data-streams</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Lisa Larribas]]></dc:creator>
            <pubDate>Fri, 24 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2f2573aa3d29b3a628e4fce606c803add2641501-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>When mapping conflicts arise in fields, whether they’re Elastic Common Schema–standard (ECS-standard) or specific to the data source, reindexing your data using Dev Tools becomes necessary. These conflicts can negatively impact any downstream function following ingestion, potentially causing inaccurate results or preventing the use of the complete dataset in features like visualizations, dashboards, the Security app, and aggregations. This blog post details the steps for this reindexing process.</p><p>This blog&#x27;s content was developed and verified using Elastic versions 9.2.8 and 8.19.14, along with Filestream Integration versions 2.3.0 and 1.2.0.</p><p><strong>Important note:</strong> Depending on your environment, some steps may require specific modifications. Furthermore, be aware that dynamic templates were removed from the <code>@package</code> component template starting with Filestream Integration version 2.3.3.</p><p>Before starting the reindexing process, it’s important to consider the current storage allocation in your environment. The steps outlined below involve creating a copy of the existing backing index, which will temporarily reside in the <a href="https://www.elastic.co/docs/manage-data/lifecycle/data-tiers">hot tier</a>.</p><p><strong><span style="text-decoration:underline">Elasticsearch data tiers</span></strong></p><ul><li><strong>Hot: </strong>The hot tier is the Elasticsearch entry point for time series data, storing the most recent, frequently searched data. Hot tier nodes require fast reads and writes, necessitating more resources and faster storage (SSDs). This tier is mandatory, and new data stream indices are automatically allocated here.</li><li><strong>Warm: </strong>Time series data can move to the warm tier once it’s being queried less frequently than the recently indexed data in the hot tier. The warm tier typically holds data from recent weeks. Updates are still allowed but are likely infrequent. Nodes in the warm tier generally don’t need to be as fast as those in the hot tier. For resiliency, indices in the warm tier should be configured to use one or more replicas.</li><li><strong>Cold: </strong>Data that’s infrequently searched can move from the warm to the cold tier. The cold tier, while still searchable, prioritizes lower storage costs over search speed. Alternatively, the cold tier can store regular indices with replicas instead of searchable snapshots, allowing use of less expensive hardware for older data without reducing disk space requirements compared to the warm tier.</li><li><strong>Frozen: </strong>Data that’s queried infrequently or no longer queried moves from the cold to the frozen tier for its remaining lifecycle. This tier uses a snapshot repository and partially mounted indices to store and load data, reducing local storage and costs while still allowing search. Searches on the frozen tier are generally slower than on the cold tier because Elasticsearch may need to fetch frozen data from the snapshot repository. We recommend dedicated frozen tier nodes.</li></ul><h2>Prerequisites: Determine which fields have conflicts</h2><p>To determine which fields have mapping conflicts, navigate to <strong>Stack Management -&gt; Data Views -&gt; logs-*</strong> (using the logs-* data view is the highest hierarchy of data present with the <em>logs-</em> prefix.) If there are any conflicts, there will be a yellow box stating that. You may either click <strong>View conflicts</strong> or, under the <strong>Field type</strong> box next to the <strong>Search </strong>box, select <strong>conflict</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7d41594682b601a30a9544b8db678f118b0146ab-2048x720.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/41ad800daa6fc244a1123ba7538820bff5de6788-747x182.png" style="width: 100%;" /><p>Clicking the yellow <strong>Conflict</strong> button will reveal which indices are associated with which mapping types.</p><p>This situation (where the field is mapped as both a <code>keyword</code> and a <code>long</code>) typically occurs because data was ingested before a specific mapping type was defined in the <a href="https://www.elastic.co/docs/manage-data/data-store/templates#component-templates">component template</a> for the relevant <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams">data stream</a>. In such cases, Elasticsearch attempts to set the mapping based on its dynamic templates.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9973c1935aa52292c1ace09a8e9c0b31ad99e7a2-2048x1085.png" style="width: 100%;" /><p>In order to determine which mapping is appropriate for the field, and if the field is an ECS field, verification with <a href="https://www.elastic.co/docs/reference/ecs/ecs-field-reference">ECS field reference</a> is needed. If the field in question is not an ECS field, its value must be reviewed to determine the correct mapping.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3c77d0a1520bd1ad17e7ffa1480ecf5e224953e1-418x360.png" style="width: 100%;" /><p>If a field, such as <code>log.offset</code> in this example, isn’t documented in the ECS, the next steps are to investigate the field&#x27;s value, determine which conflicting mapping type has the most backing indices, and examine the component templates of the other indices.</p><p>Typically, the mapping type associated with the highest number of indices is the correct one, but we recommend you verify the value of the field in question to validate this. To confirm the validity of a mapping type (for example, <code>long</code>), you must also verify that the field&#x27;s value is appropriate for that type. This verification can be done by using <strong>Discover </strong>to search for the field in question. Reviewing other data streams that contain the same field can provide additional confirmation also.</p><p>To review the values present for the field with the mapping issue, navigate back to the yellow <strong>Conflict </strong>button stated earlier, click the <strong>Conflict</strong> button, highlight one of the backing indices, and paste into a <strong>Discover </strong>session. Your Kibana Query Language (KQL) statement should look like the following screenshot, to include the <strong><code>_index</code>:</strong> field delimiter.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/781f63b34a9abd427ceb896484da29af446e3326-2048x1063.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b7b0cb847c1694ab605c61a538722f5be004ec86-2048x909.png" style="width: 100%;" /><h2>Prepare the new backing index custom component template</h2><p>To address the mapping conflict in the data stream, first examine the relevant <code>@package</code> component template. You can find this under <strong>Stack Management -&gt; Index Management -&gt; Component Template</strong>. Search for the data stream and select the corresponding <code>@package</code> link. This template contains mappings for the fields out of the box and, while it isn’t common to have a mapping mismatch, it’s possible for the more appropriate type to be overlooked.</p><p>Review the template to confirm it contains the necessary field nesting and mapping for the field in question. For example, if the template incorrectly lists <code>log.offset</code> as a <code>keyword</code>, this is the source of the issue.</p><p><strong>Important:</strong> Because modifying <code>@package</code>/managed templates isn’t recommended, you must use or create an <code>@custom</code> component template to correct the mapping type (for example, for <code>log.offset</code>) for all future data.</p><ul><li>We don’t recommend modifying the <code>@package</code>/managed templates, since when you update the integration to a more recent version, any changes you make to the <code>@package</code> template will be overwritten. This is why we recommend using the <code>@custom</code> templates.</li><li>If a data stream is experiencing mapping conflicts, you need to add any missing field (ECS and non-ECS) nestings or mappings to the data stream&#x27;s <code>@custom</code> component template. Create this template if it doesn&#x27;t exist yet, and make sure to specify the correct mapping type for the field.</li><li>If you have multiple conflicts in your data view, apply all the necessary missing mappings for the data stream simultaneously so that the reindex is performed once versus multiple times. Having entries for proper data typing in the <code>@custom</code> component template will ensure any future data ingestion will follow the same mapping guideline.</li></ul><p>To create the <code>@custom</code> component template (or verify it’s in use and populated), navigate to <strong>Index Templates</strong>, type in the name of the data stream in question, and click the appropriate <code>@custom</code> template being used by the data stream. If the template is not yet created, a yellow box will appear, allowing you to create the template through the UI.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bea7cb172227bebc28146e3f2f016e112f34cba5-2048x720.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e8f63a2e396efbe7f1e62dc08a137a22700be484-2048x296.png" style="width: 100%;" /><p>The screenshot below shows the next page once <strong>Create component template</strong> is selected. Leave the defaults as is on the first page and click <strong>Mappings</strong> or <strong>Next</strong> until you reach the <strong>Mappings</strong> page.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/822f1d864302aa4be438c13756b8372f43fa1b0d-2048x1275.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/affa2f1214af516a5a6b571ab813628ed7649275-2048x1235.png" style="width: 100%;" /><p>To explicitly set the mapping for a new field coming in or to update a field that has a mapping conflict, when the data stream rolls over due to configuration set in the index lifecycle policy, an entry is needed for the field that the conflict exists in.</p><p>The below will set the mapping for the <code>log.offset</code> field in the <code>@custom</code> component template for the filestream data stream. Repeat the steps to add any custom fields or update necessary fields from the <code>@package</code> with the appropriate mappings, if needed, for this dataset. In this example, when setting offset to <code>Long</code>, the field type will be <code>Numeric</code> and the Numeric type will be <code>Long</code>. Click <strong>Add field</strong> and then outside of the area to continue.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/affa2f1214af516a5a6b571ab813628ed7649275-2048x1235.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ea2975ee4e40ac0e10c4170d2a23125101f7f8da-2048x1136.png" style="width: 100%;" /><p>Once all needed fields have been added, click through to review, and select <strong>Create component template</strong> when ready. All new data being ingested from this step forward will have <code>log.offset</code> set to <code>long</code>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bdf8b8290b0c064c9d88990194b15232ffe85709-2048x1027.png" style="width: 100%;" /><h2>Creating the new backing index structure</h2><p>The new backing index needs to have the existing mappings from the data stream’s&nbsp; component template, as well as the ECS <code>ecs@mappings</code> component template. The <code>ecs@mappings</code> component template is applied after the data stream’s component as a catchall for additional mappings that potentially weren’t captured in the previous component templates.</p><p>Navigate to the browser tab for the data stream&#x27;s <code>@package</code> mappings. (Go to <strong>Stack Management -&gt; Index Management -&gt; Component Template -&gt; <code>logs-filestream.generic@package</code> -&gt; Manage -&gt; Edit</strong>.) Once there, click on the <strong>Review</strong> section, then <strong>Request</strong>, and finally the <strong>Copy</strong> button on the right. The JSON contents of the component template copied will ensure the remaining field mappings and settings are retained while we update the <code>log.offset</code> field mapping. The JSON will form the backing structure for the newly reindexed backing index.</p><p><strong>Important: </strong>If the template’s JSON was not copied and work was continued on with the reindex, the <code>log.offset</code> conflict would be resolved but there would be new conflicts with the integration, as the integrity of the current mappings were not upheld, creating double work to resolve the original issue.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/402b0431b0e19374e9b28a4374ed51dfa5fa44ba-2048x897.png" style="width: 100%;" /><p>Open a second browser tab, navigate to Dev Tools, and paste the copied content. Now, to clean up what was pasted:</p><p><strong>Modifications to the request</strong></p><p><strong>1. Index name:</strong> Replace <code>_component_template/logs-filestream.generic@package</code> with the name of the backing index you intend to reindex, appending <code>-1</code> to the end. For example, use <code>PUT &lt;backing index to reindex&gt;-1</code>.</p><ul><li>The appended <code>-1</code> signifies a reindex and won’t conflict with the default ILM rollover settings, which are based on the index&#x27;s creation date.</li></ul><p><strong>2. Settings:</strong> Remove the line <code>&quot;template&quot;</code> (line 3), as well as the very last closing brace for the entire JSON payload; Line 3 should start with <code>&quot;settings&quot;: {</code>.</p><ul><li>Replace the inner contents of the settings section with <code>&quot;index.codec&quot;: &quot;best_compression&quot;</code>. This action will apply Elastic&#x27;s best compression to the index upon creation.</li><li>Add in <code>&quot;index.lifecycle.name&quot;: &quot;logs&quot;</code>, as well as a line for <code>&quot;index.lifecycle.rollover_alias&quot;: &quot;&quot;</code>.<ol><li>The <code>&quot;index.lifecycle.name&quot;: &quot;logs&quot;</code> entry will apply the logs ILM policy to the new backing index. Modify the ILM policy name if you aren’t using logs.</li><li>The <code>&quot;index.lifecycle.rollover_alias&quot;: &quot;&quot;</code> is blank, since this backing index won’t be rolled over, yet the setting is required to avoid ILM rollover errors into the next ILM phase after hot.</li></ol></li></ul><p><strong>3. Structure:</strong> The request should now include both a <code>Settings</code> section and a <code>Mappings</code> section. Inside <code>&quot;mappings&quot;: {</code>, you should find <code>&quot;dynamic_templates&quot;</code> and a <code>&quot;properties&quot;</code> section containing hard-coded fields and their mappings.</p><p><strong>4. Dynamic templates modification: </strong>The current dynamic templates section contains entries for fields that may be overwritten when the <code>ecs@mappings </code>dynamic templates are added next, causing redundancy and extra lines that aren’t needed.</p><ul><li>Remove all sections in <code>&quot;dynamic_templates&quot;</code> except for the second section titled <code>&quot;_embedded_ecs-data_stream_to_constant&quot;: {</code>.</li><li>Repeat the same process as described above, gathering the dynamic mappings for the <code>@package</code> component template, but this time the dynamic mappings for <code>ecs@mappings</code> component template.<ul><li>It may be easier to copy the entire contents of the mappings from the UI for the <code>ecs@mappings</code> component template, paste into the working Dev Tools <code>dynamic_templates</code> section, and remove duplicate and unnecessary lines where appropriate. Include these dynamic template setting contents after the<code>&quot;_embedded_ecs-data_stream_to_constant&quot;: {</code> entry. The <code>dynamic_templates</code> section should look very similar to the below sample contents in Dev Tools.</li></ul></li><li><strong>If <code>dynamic_templates</code> are not included/removed altogether</strong>, other fields (review the screenshot below) will have double mappings: <code>text</code> and <code>keyword</code> versus the appropriate mappings, if the <code>dynamic_templates</code> section was left included. What’s left should be the <code>&quot;properties&quot;</code> section under <code>&quot;mappings&quot;</code>. This will also create issues in the data view by having the fields be double mapped (if not already mapped this way) and will cause additional mapping conflicts.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/24e972cd0fc8eadf943b21cfdd80a5d435e705aa-2048x994.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/aac13fbe882516e5ed5b5b1b5271c0ae34e80b04-1890x2048.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c43d6f61c8ece4de2d51657f239a0c34ced07cdb-1928x1452.png" style="width: 100%;" /><p><strong>5. Metadata removal:</strong> Delete the last section labeled <code>&quot;_meta&quot;</code>, as well as the section labeled <code>&quot;version&quot;</code>, if present.</p><p><strong>6. Formatting:</strong> Auto-indent the remaining sections, and adjust or remove any unnecessary curly braces that would prevent a successful execution.<br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ebe1573647500de75315e7655256a0db9604c40d-2048x1402.png" style="width: 100%;" /><p><strong>7. Mapping change:</strong> Navigate to the <code>&quot;properties&quot;</code> section, find <code>&quot;log&quot;</code>, and then locate <code>&quot;offset&quot;</code> nested underneath. Change the type from <code>keyword</code> to <code>long</code>, and remove the line entry (comma included) labeled <code>&quot;ignore_above&quot;: 1024,</code>. If more than one entry was added to the <code>@custom</code> component template created earlier, include them here.</p><p>Your Dev Tools console view should now be similar to the example provided below.</p><pre><code>PUT .ds-logs-filestream.generic-default-2026.04.14-000001-1
{
  "settings": {
    "index.codec": "best_compression",
    "index.lifecycle.name": "logs",
    "index.lifecycle.rollover_alias": ""
  },
  "mappings": {
    "dynamic_templates": [
      {
        "_embedded_ecs-data_stream_to_constant": {
          "path_match": "data_stream.*",
          "mapping": {
            "type": "constant_keyword"
          }
        }
      },
      {
        "ecs_timestamp": {
          "mapping": {
            "ignore_malformed": false,
            "type": "date"
          },
          "match": "@timestamp"
        }
      },
      {
        "ecs_message_match_only_text": {
          "path_match": [
            "message",
            "*.message"
          ],
          "mapping": {
            "type": "match_only_text"
          },
          "unmatch_mapping_type": "object"
        }
      },
      {
        "ecs_non_indexed_keyword": {
          "path_match": [
            "*event.original"
          ],
          "mapping": {
            "index": false,
            "type": "keyword",
            "doc_values": false
          }
        }
      },
      {
        "ecs_non_indexed_long": {
          "path_match": [
            "*.x509.public_key_exponent"
          ],
          "mapping": {
            "index": false,
            "type": "long",
            "doc_values": false
          }
        }
      },
      {
        "ecs_ip": {
          "path_match": [
            "ip",
            "*.ip",
            "*_ip"
          ],
          "mapping": {
            "type": "ip"
          },
          "match_mapping_type": "string"
        }
      },
      {
        "ecs_wildcard": {
          "path_match": [
            "*.io.text",
            "*.message_id",
            "*registry.data.strings",
            "*url.path"
          ],
          "mapping": {
            "type": "wildcard"
          },
          "unmatch_mapping_type": "object"
        }
      },
      {
        "ecs_path_match_wildcard_and_match_only_text": {
          "path_match": [
            "*.body.content",
            "*url.full",
            "*url.original"
          ],
          "mapping": {
            "fields": {
              "text": {
                "type": "match_only_text"
              }
            },
            "type": "wildcard"
          },
          "unmatch_mapping_type": "object"
        }
      },
      {
        "ecs_match_wildcard_and_match_only_text": {
          "mapping": {
            "fields": {
              "text": {
                "type": "match_only_text"
              }
            },
            "type": "wildcard"
          },
          "unmatch_mapping_type": "object",
          "match": [
            "*command_line",
            "*stack_trace"
          ]
        }
      },
      {
        "ecs_path_match_keyword_and_match_only_text": {
          "path_match": [
            "*.title",
            "*.executable",
            "*.name",
            "*.working_directory",
            "*.full_name",
            "*file.path",
            "*file.target_path",
            "*os.full",
            "*email.subject",
            "*vulnerability.description",
            "*user_agent.original"
          ],
          "mapping": {
            "fields": {
              "text": {
                "type": "match_only_text"
              }
            },
            "type": "keyword"
          },
          "unmatch_mapping_type": "object"
        }
      },
      {
        "ecs_date": {
          "path_match": [
            "*.timestamp",
            "*_timestamp",
            "*.not_after",
            "*.not_before",
            "*.accessed",
            "created",
            "*.created",
            "*.installed",
            "*.creation_date",
            "*.ctime",
            "*.mtime",
            "ingested",
            "*.ingested",
            "*.start",
            "*.end",
            "*.indicator.first_seen",
            "*.indicator.last_seen",
            "*.indicator.modified_at",
            "*threat.enrichments.matched.occurred"
          ],
          "mapping": {
            "type": "date"
          },
          "unmatch_mapping_type": "object"
        }
      },
      {
        "ecs_path_match_float": {
          "path_match": [
            "*.score.*",
            "*_score*"
          ],
          "mapping": {
            "type": "float"
          },
          "path_unmatch": "*.version",
          "unmatch_mapping_type": "object"
        }
      },
      {
        "ecs_usage_double_scaled_float": {
          "path_match": "*.usage",
          "mapping": {
            "scaling_factor": 1000,
            "type": "scaled_float"
          },
          "match_mapping_type": [
            "double",
            "long",
            "string"
          ]
        }
      },
      {
        "ecs_geo_point": {
          "path_match": [
            "*.geo.location"
          ],
          "mapping": {
            "type": "geo_point"
          }
        }
      },
      {
        "ecs_flattened": {
          "path_match": [
            "*structured_data",
            "*exports",
            "*imports"
          ],
          "mapping": {
            "type": "flattened"
          },
          "match_mapping_type": "object"
        }
      },
      {
        "all_strings_to_keywords": {
          "mapping": {
            "ignore_above": 1024,
            "type": "keyword"
          },
          "match_mapping_type": "string"
        }
      }
    ],
    "properties": {
      "input": {
        "properties": {
          "type": {
            "ignore_above": 1024,
            "type": "keyword"
          }
        }
      },
      "@timestamp": {
        "ignore_malformed": false,
        "type": "date"
      },
      "ecs": {
        "properties": {
          "version": {
            "ignore_above": 1024,
            "type": "keyword"
          }
        }
      },
      "log": {
        "properties": {
          "file": {
            "properties": {
              "inode": {
                "ignore_above": 1024,
                "type": "keyword"
              },
              "path": {
                "ignore_above": 1024,
                "type": "keyword"
              },
              "device_id": {
                "ignore_above": 1024,
                "type": "keyword"
              },
              "fingerprint": {
                "index": false,
                "type": "keyword"
              }
            }
          },
          "offset": {
            "type": "long"
          },
          "level": {
            "ignore_above": 1024,
            "type": "keyword"
          }
        }
      },
      "data_stream": {
        "properties": {
          "namespace": {
            "type": "constant_keyword"
          },
          "type": {
            "type": "constant_keyword"
          },
          "dataset": {
            "type": "constant_keyword"
          }
        }
      },
      "event": {
        "properties": {
          "original": {
            "index": false,
            "type": "keyword",
            "doc_values": false
          },
          "module": {
            "type": "constant_keyword",
            "value": "filestream"
          },
          "dataset": {
            "type": "constant_keyword",
            "value": "filestream.generic"
          }
        }
      },
      "message": {
        "type": "match_only_text"
      },
      "tags": {
        "ignore_above": 1024,
        "type": "keyword"
      }
    }
  }
}</code></pre><p>After your console resembles the example (with any additional custom fields included and custom values specific to your environment), execute the command to create the shell of the new backing index, pausing to resolve any errors that arise.</p><h2>Begin reindex process</h2><p>With the shell of the new backing index successfully created, the next step is to reindex and resolve the mapping conflicts.</p><p><strong>Important:</strong> If the backing index that has the mapping conflict is the most recent index and is the current write index (for example, the ending number for the backing index is -000001), the data stream needs to be rolled over. Rolling over the data stream is needed since the current write index, which is having documents fed into it, is a live backing index and cannot be modified.</p><p>With the correct field mapping now applied to the newer write index via the previously created <code>@custom</code> component template, all new documents will reflect this change.</p><p>This is performed by executing the following: </p><pre><code>POST <full data stream name>/_rollover</code></pre><p>For example: </p><pre><code>POST logs-filestream.generic-default/_rollover</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/22abc1a2f6de0420aa0d56ac498894111df7f4fd-2048x330.png" style="width: 100%;" /><p>Reindexing involves copying the data from an existing backing index to a new one within the same naming convention, typically to apply necessary changes. These modifications could include updates to a component template or the addition of a new ingest pipeline for the data to be processed through.</p><p>Next, the data will be copied from the backing index that has the incorrect mappings into a new backing index. The original backing index has been rolled over, meaning no new documents can be added to it. The new backing index will follow the same naming convention, which preserves data visibility and integrity while applying the correct ILM policy, but will include a <code>-1</code> suffix to indicate that it has been reindexed.</p><p>Adjust the index names as needed and paste the following code into the console. By including <code>wait_for_completion=false</code>, you can track the progress of document copying, which helps estimate the remaining reindexing time. Without this setting, you cannot track the status using the <code>GET _tasks</code> command below and will only be able to check the document count in the newer backing index using <code>GET &lt;backing index name&gt;-1/_count</code>.</p><p><strong>Important: </strong>If issues arise during the reindex process, don’t rerun the reindex command; doing so will restart the process and create duplicate records in the index ending with <code>-1</code>. If a restart is necessary, first delete the index with the trailing <code>-1</code>, and then execute the preceding <code>PUT</code> command to recreate the new backing index shell.</p><pre><code>POST _reindex?wait_for_completion=false
{
  "source": {
    "index": "<source backing index>"
  },
  "dest": {
    "index": "<new backing index>-1"
  }
}

i.e.
POST _reindex?wait_for_completion=false
{
  "source": {
    "index": ".ds-logs-filestream.generic-default-2026.04.13-000001"
  },
  "dest": {
    "index": ".ds-logs-filestream.generic-default-2026.04.13-000001-1"
  }
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/22f9b1f762802ecd20faa7c7c1f76c9d1444aba5-2048x530.png" style="width: 100%;" /><p>Upon execution, the response will include a task ID. You can monitor the reindex progress using this ID with the command: <code>GET _tasks/&lt;task ID&gt;</code>.</p><p>The duration of the reindex depends on the volume of data in the original index. The completion can be tracked by looking for <code>&quot;completed&quot;: true</code> when executing the <code>GET</code> command, which should yield a similar output.</p><p><code>GET _tasks/&lt;task ID&gt;</code></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dbf0fb0a560a78236440b8c3de68cdf5c83e6d7a-2048x824.png" style="width: 100%;" /><p>With the reindexing process now finished for the document count, the next step is to verify that the mappings for the new backing index and the specific field in question are correct.</p><pre><code>GET <backing index>-1/_mapping</code></pre><p>For example:</p><pre><code>GET .ds-logs-filestream.generic-default-2026.04.13-000001-1/_mapping</code></pre><p>You can verify that the mapping for <code>log.offset</code> is as shown below. To confirm that other fields have only a single mapping entry (not both <code>text</code> and <code>keyword</code>), compare them to a field that was not part of the dynamic template section in the preceding <code>PUT</code> command.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/db5c12c0a651e804a916d517e6e260e49a8b835a-2048x1121.png" style="width: 100%;" /><p>If the backing index that’s being reindexed has a large number of documents, it’s helpful to check the status of those documents being copied to the new backing index; this can be done by the following two Dev Tools commands to compare the counts.</p><p><code>GET .ds-logs-filestream.generic-default-2026.04.14-000001/_count</code></p><p><code>GET .ds-logs-filestream.generic-default-2026.04.14-000001-1/_count</code></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a0e49ac79edb0abf9fe99d0e6fd35e96d0e3e0e5-2048x880.png" style="width: 100%;" /><p>Once the counts are verified to match and the correct mappings are present, update the data stream to include the new backing index, preventing an orphaned backing index in index management, where the ILM policy will never occur on the backing index.</p><ul><li>The return should be an acknowledgment of true, if successful.</li></ul><pre><code>POST _data_stream/_modify
{
  "actions": [
    {
      "add_backing_index": {
        "data_stream": "logs-filestream.generic-default",
        "index": ".ds-logs-filestream.generic-default-2026.04.14-000001-1"
      }
    }
  ]
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0aa3233377c0175258d37eaa661d56cf9f310d5e-2048x1288.png" style="width: 100%;" /><p>Verify the new backing index is added with the following command, making sure the <code>ilm_policy</code> is correct:</p><pre><code>GET _data_stream/logs-filestream.generic-default</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/af8b75cf260f6f088c28a78da86ad31527e0bfd5-2048x839.png" style="width: 100%;" /><p>Check the ILM status of the backing index next with the following command:</p><ul><li>It’s normal to see that the index is in hot, as it was created very recently (review line 8 or 10).</li></ul><pre><code>GET .ds-logs-filestream.generic-default-2026.04.14-000001-1/_ilm/explain</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cd181a31001c7a3ee2b0599a7388909ce5b50baf-2048x972.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/20f6e7632804f173533e655f0292c3c540f26597-2048x894.png" style="width: 100%;" /><p>Execute the following to transition the backing index from the hot tier to the next appropriate tier that’s after the hot phase for the ILM policy for this data stream. The specific values for <code>phase</code>, <code>action</code>, and <code>name</code> in the <code>current_step</code> below can be referenced from lines 11, 13, and 15, respectively, in the provided screenshot above.</p><p>The <code>next_step</code> value indicates the subsequent ILM phase or data tier to which the index will transition to.</p><p>For example:</p><pre><code>POST _ilm/move/.ds-logs-filestream.generic-default-2026.04.14-000001-1
{
  "current_step": {
    "phase": "hot",
    "action": "rollover", 
    "name": "check-rollover-ready"
  },
  "next_step": {
    "phase": "warm" 
  }
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ae60b900ce1d0b46ce26ec301901bc8a9ef750c-2048x1249.png" style="width: 100%;" /><ul><li>It isn’t necessary, but as a safety measure, you may execute the <code>_ilm/explain</code> command again to ensure the backing index has moved to the next phase and is no longer in hot.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/58a460cf2ec443223ea68ba7e7166a7cf9d8c97a-2048x915.png" style="width: 100%;" /><p>Once the following conditions are met, you can safely delete the original backing index that had mapping conflicts:</p><ol><li>A new backing index has been successfully created.</li><li>Documents have been moved to the new index, and the document counts match.</li><li>Mappings have been corrected (both data stream specific and ECS).</li><li>The data stream incorporates the new backing index.</li><li>The ILM policy has been applied and has moved the index out of the hot phase.</li></ol><p><strong>Important:</strong> Alternatively, before deleting the original index, you can check the <strong>Data Views</strong> page. Select <code>logs-*</code> and verify that the reindexed backing index (which ends in <code>-1</code>) now appears in the <strong><code>long</code></strong> section. The original backing index should still be present under <strong><code>keyword</code></strong>. If the reindexed backing index is not in the <strong><code>long</code></strong> section, go back and review the preceding steps and make any necessary corrections.</p><p>For example:</p><pre><code>DELETE .ds-logs-filestream.generic-default-2026.04.14-000001</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/09d661b20a44929b4736a43eaa3df84180b25f30-2048x1295.png" style="width: 100%;" /><p>After resolving the conflicts, return to the <strong>Data Views</strong> page and select <code>logs-*</code>. If the conflict was solely related to <code>log.offset</code>, you should no longer see any conflicts listed. If there were other conflicts, the original backing index should no longer appear in the conflict list; instead, the new backing index should now be listed in the <code>long</code> section.</p><p>You can also verify in <strong>Discover</strong> that the <code>log.offset</code> field now displays the appropriate icons.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1c3bb7029c99aa4bc6b0931f39f5648654b35ccd-2048x1204.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0af1b1aa3a031c207aa5eb083696dd081d941e67-2048x1001.png" style="width: 100%;" /><p>Continue this process, repeating the above steps for every backing index that has a mapping conflict until all are successfully resolved.</p><p>References:</p><ul><li><a href="https://www.elastic.co/docs/reference/ecs/ecs-field-reference">ECS field reference</a></li><li><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-reindex">Reindex documents</a></li></ul><h2>Final thoughts</h2><p>By following the steps in this blog, you will resolve mapping conflicts and ensure that all new data is correctly mapped. This is achieved by linking the necessary component templates to your data source. This workflow not only fixes the immediate issues but also establishes a secure and repeatable process for managing schema changes as your data and requirements evolve.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How we built Elasticsearch simdvec to make vector search one of the fastest in the world]]></title>
            <description><![CDATA[How we built Elasticsearch simdvec, the hand-tuned SIMD kernel library behind every vector search query in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-simdvec-engine</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Chris Hegarty, Lorenzo Dematte, Simon Cooper]]></dc:creator>
            <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/df9646091bafbbf0a6dfd212ff8a6bd1e8589708-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch simdvec is the engine behind every vector distance computation in Elasticsearch. It provides hand-tuned AVX-512 and NEON kernels for every vector type Elasticsearch supports. Its bulk scoring architecture hides memory latency through explicit prefetching on x86 and interleaved loading on ARM, outperforming libraries like FAISS and jvector by up to 4x when data exceeds CPU cache. In this post, we explain why we built it, what’s inside, and how it makes Elasticsearch vector search one of the fastest in the world.</p><h2>How we built Elasticsearch simdvec</h2><p>Every vector search query in Elasticsearch, whether <a href="https://arxiv.org/abs/1603.09320">Hierarchical Navigable Small World (HNSW)</a> traversal, inverted file (IVF) scan, or reranking pass, reduces to the same problem: computing distances between vectors, millions of times per query. Elasticsearch supports a wide range of data types and quantization strategies, from float32 to int8, bfloat16, binary, and Better Binary Quantization (BBQ). Each comes with different trade-offs between memory, throughput, and recall. Behind all of it is a single engine: simdvec.</p><p>We built simdvec to make every distance computation as fast as the hardware allows. In this post, we explain why we built it, what’s inside, and where it delivers the most impact.</p><h3>Built like a race car</h3><p>As Formula 1 enthusiasts, with one of us having previously worked with the Ferrari Formula 1 Team, we see a clear parallel. A Formula 1 car is designed with a single purpose: to achieve the best lap time. Engine power, aerodynamics, and chassis design only matter insofar as they contribute to that outcome. The same is true of a vector database, where indexing throughput, query latency, and recall define success.</p><p>While the end result is what matters, reaching the highest levels of performance requires each component to be at its best. It can’t just be <em>good enough</em>, it has to be the <em>best </em>in its category. Simdvec is built with that mindset, focusing on a critical part of the system: the engine. It’s a purpose-built, <a href="https://en.wikipedia.org/wiki/Single_instruction,_multiple_data">single instruction multiple data</a> (SIMD) optimized kernel library that provides hand-tuned native C++ distance functions, called from Java via the <a href="https://openjdk.org/projects/panama/">Panama</a> foreign function interface (FFI). It supports bulk scoring, cache line prefetching, and all vector types and layouts used in Elasticsearch.</p><p>That’s the engine behind every query.</p><h3>Why we built our own</h3><p>We started in 2023 with the Panama Vector API in Apache Lucene. It worked well for float32 dot products, but Elasticsearch&#x27;s needs quickly outgrew what it could provide. Elasticsearch supports a wide range of quantized vector types: int8, int4, bfloat16, single-bit, and asymmetric BBQ. Each has different SIMD strategies, packing layouts, and accumulator requirements. Beyond type coverage, Elasticsearch&#x27;s scoring paths demand more than single-pair throughput: HNSW needs to score several graph neighbors in one pass, IVF needs bulk scoring of thousands of candidates with prefetching, and disk-based scoring needs to work directly on mmap&#x27;d memory without copying. We looked at what was available, and nothing covered the full set.</p><p>So we built simdvec: hand-tuned native C++ kernels called from Java via FFI, with bulk scoring, prefetching, and support for every vector type Elasticsearch uses. By owning the library, we control the full stack. When we add a new quantization type like BBQ, it gets a tuned SIMD kernel wired all the way through the system. We don&#x27;t wait for an upstream library to support it, and we don&#x27;t compromise on performance for any type. Every vector query in Elasticsearch, whether HNSW, IVF, reranking, or hybrid, runs on this engine, built around the operations and types we actually use.</p><p>Simdvec has separate native libraries for x86 and ARM, each with multiple instruction set architecture (ISA) tiers selected at startup. The call overhead from Java via FFI is very low at <a href="https://github.com/ldematte/simsimd-benchmarks/blob/main/COMPARISON.md#ffm-downcall-overhead-measurements">single-digit nanoseconds</a>.</p><h3>The landscape</h3><p>We&#x27;re not the only ones building SIMD-optimized vector distance kernels. The ecosystem is rich, and we wanted to understand how simdvec performs. Not to rank projects, but to provide context and explain where Elasticsearch&#x27;s engine sits. We selected three projects as reference points, each representing a different approach:</p><ul><li><strong>jvector:</strong> A Java approximate nearest neighbor (ANN) library that uses the Panama Vector API for vectorized distance computation, with optional native C acceleration on x86.</li><li><strong>FAISS:</strong> A widely deployed open source vector search framework, with hand-tuned AVX2/AVX-512 kernels.</li><li><strong>NumKong</strong> (formerly SimSIMD): A comprehensive suite of over 2,000 hand-tuned SIMD kernels spanning distance functions, matrix operations, and geospatial computation.</li></ul><p>Each project serves a different purpose and makes different trade-offs. We include reference numbers from them to give context for simdvec&#x27;s performance on the specific operations that Elasticsearch needs.</p><h3>How we measure</h3><p>The simdvec and <a href="https://github.com/ChrisHegarty/jvector-kernel-benchmarks">jvector benchmarks</a> are written in Java with JMH, the standard JVM microbenchmark harness, with FFI overhead included. For <a href="https://github.com/ldematte/simsimd-benchmarks">NumKong benchmarks</a> and <a href="https://github.com/ChrisHegarty/faiss-kernel-benchmarks">FAISS benchmarks</a>, we wrote small C/C++ harnesses using Google Benchmark, which is the standard C++ microbenchmark framework. Both frameworks report nanoseconds per operation with warmup and iteration calibration. We verified via hardware performance counters that all libraries are using SIMD on both platforms. All the benchmark code is publicly available in the linked GitHub repositories (and, in the case of simdvec, in the <a href="https://github.com/elastic/elasticsearch">elasticsearch</a> repository).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/aee116772df161cf86b7668f575ac34c733a23c5-1580x238.png" style="width: 100%;" /><p><strong>Software:</strong> JDK 25.0.2, JMH 1.37, GCC 14, Google Benchmark (latest).</p><h2>One vector at a time</h2><p>The most fundamental operation in vector search is computing the distance between two vectors. Every HNSW neighbor evaluation, every IVF candidate score, every reranking comparison reduces to this inner loop.</p><p>We measured single-pair throughput at 1024 dimensions on both platforms, starting with float32, the baseline type and the one where the ecosystem is most competitive. We compare simdvec against FAISS and jvector; we excluded NumKong as it uses float64 accumulators for float32, making it 3.2x-5.3x slower (depending on platform), prioritizing numerical precision over throughput. To keep the comparison like-for-like, we benchmark NumKong on int8 instead, where it uses the same accumulator strategy as simdvec.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/352cfa0fc18f123140f746d843404f80127fb1b7-1500x675.png" style="width: 100%;" /><p>On x86, FAISS AVX-512 is the fastest single-pair kernel at 23 ns. Simdvec AVX-512 follows at 28 ns, a gap that reflects the FFI call overhead. Both use 512-bit FMA with multi-accumulator unrolling. At the AVX2 level, the two are much closer, 36 ns and 39 ns respectively, both constrained by the 256-bit register and memory load widths. jvector lands at 44 ns using the Java Panama Vector API. Panama generates good SIMD code, but hand-tuned C++ intrinsics retain an edge.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/95e44d9767f21ec4bccaed835e3c99aa180431ee-1500x495.png" style="width: 100%;" /><p>On ARM, simdvec leads at 70 ns, well ahead of jvector at 110 ns and FAISS at 156 ns. Simdvec has hand-tuned NEON kernels for aarch64. Jvector has no native ARM code and relies on Panama. FAISS relies on compiler auto-vectorization rather than explicit NEON intrinsics, which accounts for the wider gap. This reflects a practical advantage of owning the kernel library: when Elasticsearch expanded to Graviton, we added purpose-built NEON kernels. Neither jvector nor FAISS have prioritized ARM native code to the same degree.</p><p>But Elasticsearch doesn&#x27;t only score float32. <strong>Int8</strong> quantization reduces memory by 4x, bfloat16 by 2x, and BBQ by 32x. Each type needs its own SIMD strategy, and simdvec provides hand-tuned native kernels for all of them.</p><p>Of the libraries we compared, only NumKong has comparable kernels for int8. We measured int8 dot product, squared Euclidean, and cosine at 1024 dimensions.</p><p><strong>Int8 single-pair scoring(1024 dimensions, ns/vec op – lower is better)</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a0b852fd5f51d57bd2488886600472ee65ab64da-1594x378.png" style="width: 100%;" /><p>On both architectures, NumKong is equal or faster at small-to-medium dimensions, where the difference is largely due to lower call overhead (direct C call vs Java FFI). At larger dimensions simdvec catches up, where the more efficient kernel implementation (which uses cascade unrolling) amortizes the call cost: As dimension increases, <a href="https://github.com/ldematte/simsimd-benchmarks/blob/main/COMPARISON.md#single-pair-i8-nsop-2">this gap closes and eventually reverses</a>. Crossover is at dimensions between 768 and 1536, depending on function and architecture.</p><p>Despite the slightly higher overhead of Java FFI, simdvec is on par with highly optimized C/C++ libraries. Not only is it the only library with optimized kernels for both float32 <em>and</em> int8, but it also leads on ARM and only slightly behind FAISS on x86 (for float32), and very close to NumKong on both architectures (for int8). And for bfloat16, int4, binary, and BBQ, while alternatives exist, simdvec distinguishes itself through hand-tuned SIMD tailored to each type&#x27;s data layout.</p><p>But a production search engine doesn’t score one vector at a time; it scores thousands per query. The next question is what happens at that scale.</p><h3>Thousands at a time</h3><p>Single-pair performance is only part of the picture. What matters in practice is how systems behave under load. A single HNSW query may score hundreds of graph neighbors. An IVF scan may score thousands of posting list entries. A reranking pass may score tens of thousands of candidates. Single-pair throughput matters, but what matters more is how fast you can score many vectors, and how gracefully performance degrades as the working set spills out of CPU caches.</p><p>Simdvec provides bulk scoring for every data type. These aren&#x27;t just loops over single-pair kernels; they use multi-accumulator inner loops that load the query vector once per dimension stride and share it across multiple document vectors, with explicit cache-line prefetching for the next batch. Neither jvector nor FAISS offer an equivalent (at the time of writing). Jvector has no bulk API, so callers score one pair at a time in a loop. FAISS exposes <code>fvec_inner_products_ny</code>, which, at the time of writing, is implemented as a loop over its single-pair distance function with no query amortization or prefetching.</p><p><strong>Float32.</strong> To measure the impact at the kernel level, we scored a single query against increasing numbers of 1024 dimension float32 document vectors using random access patterns that simulate HNSW-like scattered graph neighbor lookups. The three dataset sizes, 32, 625, and 32,500 vectors, are chosen so the working set exceeds L1, L2, and L3 cache, respectively.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1d90267be1c63ac82b8ba588617ebedb0be0d1b6-1334x558.png" style="width: 100%;" /><p>When the data fits in cache, simdvec is the fastest on both platforms, but the margins are modest since kernel arithmetic dominates. The real separation appears as the working set grows beyond L3. On x86, simdvec scores at 95 ns per vector, while FAISS needs 165 ns and jvector 412 ns. On ARM, the pattern is the same: simdvec holds at 162 ns, while FAISS climbs to 347 ns, and jvector to 476 ns. The prefetching and query amortization in simdvec keeps memory latency hidden in a way that a simple loop over single-pair kernels cannot match, and the advantage widens precisely where real search workloads operate, deep in main memory.</p><p><strong>Int8.</strong> The same pattern holds for quantized types. We measured int8 dot product bulk scoring at 1024 dimensions with dataset sizes chosen to exceed the same L1, L2, and L3 cache boundaries, comparing simdvec&#x27;s bulk scoring against NumKong single-pair scoring in a loop.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9ee97d98b40d13b19370b76b33e67ea66bfb3250-1580x338.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/601f50a03fa3e6263cbac9700281dfe3e511de60-1580x338.png" style="width: 100%;" /><p>On x86, simdvec is between 1.2x and 1.9x faster, driven by the combination of explicit prefetching and batch processing. On ARM, simdvec wins again (1.7x to 1.9x faster) across all dataset sizes. The advantage comes from batch processing four vectors at a time, providing memory-level parallelism via an interleaved access pattern. In both cases, the most striking result is what happens at the largest dataset size, where it matters the most.</p><p>Results for squared distance and cosine show a similar pattern, with speedups of 1.4x to 1.8x for ARM, and of 1.3x to 3.0x for x86 (details <a href="https://github.com/ldematte/simsimd-benchmarks/blob/main/COMPARISON.md">here</a>).</p><h3>When memory matters</h3><p>Production vector indices typically don&#x27;t fit in CPU cache. A 10M-vector int8 index at 1024 dimensions is 10GB. Scoring candidates means streaming data from DRAM, and that&#x27;s where bulk scoring architecture makes the difference.</p><p>We used hardware performance counters to measure what happens inside the CPU during bulk scoring and found that hiding memory latency requires two fundamentally different strategies, one per architecture.</p><p><strong>On x86, explicit prefetching eliminates cache misses. </strong>The bulk kernel processes vectors sequentially, one fully computed before the next, while issuing prefetch instructions for the next batch. Future data is pulled into L1 before the CPU needs it.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a39548f8060c2a4a5154521a4047dd92d8cd77be-1580x309.png" style="width: 100%;" /><p>On ARM, the same sequential approach performed poorly, even with prefetching. Instead, <strong>the bulk kernel interleaves loads</strong> from four vectors at every stride position, giving the out-of-order engine four independent memory streams. The CPU is not fetching data any faster, but rather waiting less by always having something else to compute while memory requests are in flight. Detailed analysis can be found in <a href="https://github.com/elastic/elasticsearch/issues/145412">this GitHub issue</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/832de3bc0d556a493b7bf3f250196018acfc1585-1580x238.png" style="width: 100%;" /><p>The numbers tell two different stories:</p><ol><li>On x86, prefetching turns 139K cache misses into 19K, and instructions per cycle (IPC) more than doubles. The bulk advantage grows with dataset size, from 1.2x in L2 to 2.8x beyond L3, because prefetching hides progressively more expensive DRAM round trips.</li><li>On ARM, cache misses barely change. What changes is utilization: Backend stalls drop 40% because the interleaved access pattern keeps the pipeline fed. This advantage stays a consistent 1.8x regardless of dataset size, because memory-level parallelism applies whether data comes from cache or DRAM.</li></ol><p>Two architectures, two strategies, one result: At production scale, simdvec keeps the CPU pipeline busy even when vectors are scattered across main memory.</p><h2>What this means for Elasticsearch users</h2><p>These kernel-level capabilities compound. A single vector search query may compute millions of distance operations: HNSW graph traversal, candidate scoring, reranking. Across thousands of concurrent queries, nanoseconds per operation translate directly to query latency and cluster throughput. Whether you use float32, int8, bfloat16, or BBQ, whether your index is in memory or on disk, simdvec is the engine underneath, and every one of those operations runs through the same engine, tuned down to the last nanosecond.</p><p>The key takeaway is that at production scale, vector search performance isn’t primarily determined by raw SIMD throughput. It’s dominated by how efficiently the system hides memory latency while sustaining compute across millions of small operations.</p><p>The simdvec kernels improve with almost every Elasticsearch release. When new quantization types and hardware platforms emerge, they get tuned kernels from day one. And existing types continue to get faster as we refine the implementations that are already shipping.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Jina embeddings v3 now available on Gemini Enterprise Agent Platform Model Garden]]></title>
            <description><![CDATA[Jina search foundation model, jina-embeddings-v3, is now self-deployable on Gemini Enterprise Agent Platform Model Garden, with more to follow. Run jina-embeddings-v3 on a single L4 GPU inside your own VPC.]]></description>
            <link>https://www.elastic.co/search-labs/blog/jina-embeddings-v3-gemini-enterprise-model-garden</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-embeddings-v3-gemini-enterprise-model-garden</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sa Zhang]]></dc:creator>
            <pubDate>Wed, 22 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/42f72633f1e5453dbfd47730b5f776429f9f633e-721x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Today we’re launching <code>jina-embeddings-v3</code>, the first Jina search foundation model to be available on <a href="https://console.cloud.google.com/vertex-ai/publishers/jinaai/model-garden/jina-embeddings-v3">Gemini Enterprise Agent Platform Model</a><a href="https://console.cloud.google.com/vertex-ai/publishers/jinaai/model-garden/jina-embeddings-v3"> Garden</a> as a self-deployable partner model. <em>Self-deployment</em> means the model runs on GPU instances inside your Google Cloud project and Virtual Private Cloud (VPC). No external API calls, no per-token metering, no rate limits.</p><p>With this integration, Elasticsearch users gain a new deployment option that keeps data inside their security perimeter, delivers predictable infrastructure costs, and runs natively on Google Cloud. At the same time, the broader Google Cloud ecosystem gains access to Jina&#x27;s purpose-built, state-of-the-art search and retrieval models.</p><p>This is the first stage of a broader rollout. Together with the models coming next, the lineup will form a complete retrieval stack: Embed your data, embed queries, retrieve and rerank candidates, and extend search to images with multimodal embeddings, all on infrastructure you control. You can start today with <code>jina-embeddings-v3</code>, the model already powering production search pipelines across the Elasticsearch ecosystem via Elastic Inference Service (EIS).</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model</td><td style="border: 1px solid #ccc; padding: 4px;">Type</td><td style="border: 1px solid #ccc; padding: 4px;">Parameters</td><td style="border: 1px solid #ccc; padding: 4px;">Key capability</td><td style="border: 1px solid #ccc; padding: 4px;">Status on Model Garden</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`jina-embeddings-v3`</td><td style="border: 1px solid #ccc; padding: 4px;">Text embedding</td><td style="border: 1px solid #ccc; padding: 4px;">572M</td><td style="border: 1px solid #ccc; padding: 4px;">Proven multilingual workhorse, 8K context, 1024 dim output, truncatable to 32</td><td style="border: 1px solid #ccc; padding: 4px;">Available now</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`jina-embeddings-v5-text-small`</td><td style="border: 1px solid #ccc; padding: 4px;">Text embedding</td><td style="border: 1px solid #ccc; padding: 4px;">677M</td><td style="border: 1px solid #ccc; padding: 4px;">State-of-the-art sub-1B multilingual, 32K context, 1024 dim output, truncatable to 32</td><td style="border: 1px solid #ccc; padding: 4px;">Coming soon</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`jina-embeddings-v5-text-nano`</td><td style="border: 1px solid #ccc; padding: 4px;">Text embedding</td><td style="border: 1px solid #ccc; padding: 4px;">239M</td><td style="border: 1px solid #ccc; padding: 4px;">Best-in-class under 500M params, 8K context, 768 dim output, truncatable to 32</td><td style="border: 1px solid #ccc; padding: 4px;">Coming soon</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`jina-reranker-v3`</td><td style="border: 1px solid #ccc; padding: 4px;">Reranker</td><td style="border: 1px solid #ccc; padding: 4px;">600M</td><td style="border: 1px solid #ccc; padding: 4px;">Listwise reranker, 131K context, up to 64 documents</td><td style="border: 1px solid #ccc; padding: 4px;">Coming soon</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`jina-clip-v2`</td><td style="border: 1px solid #ccc; padding: 4px;">Multimodal embedding</td><td style="border: 1px solid #ccc; padding: 4px;">900M</td><td style="border: 1px solid #ccc; padding: 4px;">Text + image in shared space, 89 languages, and 8K text context, 512×512 images</td><td style="border: 1px solid #ccc; padding: 4px;">Coming soon</td>
                    </tr>
              </tbody>
            </table>
          <p>Every model runs on a single NVIDIA L4 (24 GB), the most cost-efficient GPU tier on Google Cloud. Most other embedding models on Google Cloud Model Garden require an A100 80 GB or H100, roughly three times the per-hour instance cost before you even start counting tokens.</p><p>No additional commercial license is required when deployed through Vertex AI.</p><h2><strong>Why Model Garden?</strong></h2><p>Why deploy through Model Garden instead of hitting an API? It comes down to three things: control, cost, and context.</p><h3>Your data never leaves the house</h3><p>The biggest draw for most developers is the self-deploy architecture. When you deploy a Jina model through Model Garden, the weights run on GPU instances inside your own Google Cloud project and your own VPC. This is a game-changer for anyone working in industries with data security concerns, like finance or healthcare. Because there are no external API calls, your sensitive data stays within your security perimeter.</p><h3>Scaling with prediction</h3><p>Instead of paying every time you embed a sentence or rerank a document, you pay a flat hourly instance cost. And because every Jina model can run on a single NVIDIA L4, the most affordable GPU tier on Google Cloud, the barrier to entry is low. Whether you process a thousand requests or a billion, your infrastructure bill stays predictable. This is a setup that actually rewards you for growing your traffic rather than taxing you for it.</p><h3>Everything under one roof</h3><p>If your data is already sitting in Elasticsearch on Google Cloud, BigQuery, or Cloud Storage, it makes sense to keep your inference engines nearby. By deploying through Model Garden, Jina search foundation models inherit all the enterprise features you are already using: identity and access management (IAM) for access control, unified billing on your existing Google Cloud invoice, and the ability to plug into Vertex AI Pipelines for machine learning operations (MLOps) workflows.</p><p>While the Jina AI Cloud API and Elastic Cloud offer the fastest path for bursty traffic or existing search workflows, Model Garden is ideal for enterprise applications requiring strict data security and predictable costs at scale. Elastic wants to meet you where you are.</p><h2><strong>Jina AI models</strong></h2><h3><strong>jina-embeddings-v3</strong></h3><p>Our proven multilingual embedding model with 572M parameters and 8K token context. Scores 65.5 on Massive Text Embedding Benchmark (MTEB) English. Supports five task-specific Low-Rank Adaptation (LoRA) adapters (retrieval query/passage, text-matching, classification, clustering) and Matryoshka truncation from 1024 to 64 dimensions. Already widely adopted across the Elasticsearch ecosystem via EIS.</p><p>We’re leading with v3 because many production systems already depend on it. If you’re migrating a v3-based pipeline to Google Cloud, you can now run the same model natively without changing your embedding dimensions or reindexing.</p><h3><strong>jina-embeddings-v5-text (small and nano)</strong></h3><p>Our fifth-generation text embedding models, released February 2026, achieve top-tier performance, competing with models many times their size.</p><p><code>v5-text-small</code> (677M) scores 67.0 on the Multilingual MTEB (MMTEB) benchmark suite, encompassing 131 tasks of nine task types, and 71.7 on the MTEB English benchmark. It’s the strongest sub-1B multilingual embedding model on the MTEB Leaderboard.</p><p><code>v5-text-nano</code> (239M) scores 65.5 on MMTEB. No other model under 500M parameters reaches this level. At less than half the size of most comparable models, it’s the natural choice for edge and latency-sensitive deployments.</p><p>Both models support:</p><ul><li><strong>Four task-specific LoRA adapters:</strong> Retrieval, text-matching, classification, clustering. Selecting an appropriate adapter via <code>task</code> parameter at inference time.</li><li><strong>Matryoshka dimension truncation:</strong> Reduce embedding dimensions from 1024 (or 768 for nano) down to 32. Quality loss is minimal at moderate truncation (for example, 256 dims). Halving dimensions roughly halves storage.</li><li><strong>Binary quantization:</strong> Compress 1024-dim embeddings from 2KB to 128 bytes with binarization. Special training makes this compression minimal losses.</li><li><strong>Multilingual: </strong>119 languages (small) and 93 (nano).</li></ul><h3><strong>jina-reranker-v3</strong></h3><p>A 0.6B parameter multilingual listwise reranker built using a <em>last but not late interaction</em> architecture. The query and up to 64 candidate matches are entered into a single 131K-token context window, and the model performs cross-document comparison before scoring. Jina Reranker v3 achieves 61.94 nDCG@10 on BEIR, outperforming the model being 6× smaller in size.This is fundamentally different from pointwise rerankers that score each document in isolation, producing better results, especially for passage retrieval from single documents.</p><h3><strong>jina-clip-v2</strong></h3><p>A 0.9B multimodal, multilingual embedding model that maps text and images into a shared 1024-dimensional space. It supports:</p><ul><li><strong>89 languages</strong> for text-image retrieval.</li><li><strong>512×512 image resolution.</strong></li><li><strong>8K token text input.</strong></li><li><strong>Matryoshka truncation</strong> from 1024 to 64 dimensions for both modalities.</li></ul><p>Highly competitive on image-to-text benchmarks, including multilingual tasks.</p><h2><strong>Getting started</strong></h2><p>Jina Embeddings v3 is live on Model Garden today. Here’s how to get it running.</p><p>You need a Google Cloud project with the Vertex AI API enabled and enough GPU quota for at least one g2-standard-8 instance (NVIDIA L4). If you’re new to Google Cloud, <a href="https://cloud.google.com/vertex-ai/docs/start/cloud-environment">start with the setup guide.</a></p><p><a href="https://console.cloud.google.com/vertex-ai/publishers/jinaai/model-garden/jina-embeddings-v3">The Model Garden page for Jina Embeddings v3</a> walks you through the full flow: Upload the model, create an endpoint, pick your machine type, and deploy. Open it in your own project, and follow the guided steps. A100 and H100 machines are also available where region and quota allow, but L4 is all you need to start.</p><p>From click to first embedding, the whole process takes a few minutes.</p><h2><strong>What comes next</strong></h2><p>Jina Embeddings v3 is the starting point. In the coming weeks, we’ll bring the rest of the Jina retrieval stack to Model Garden: v5 text embeddings (small and nano), jina-reranker-v3, and jina-clip-v2 for multimodal search. All will run on a single L4 GPU with the same self-deploy model.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elastic Security, Observability, and Search now offer interactive UI in your AI tools]]></title>
            <description><![CDATA[Elastic is the first security vendor to ship an interactive UI in AI Conversations, built on the MCP App standard that Anthropic and OpenAI co-authored. It launches alongside two other Elastic MCP Apps for Observability and Search.]]></description>
            <link>https://www.elastic.co/search-labs/blog/mcp-apps-elastic</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/mcp-apps-elastic</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[David Elgut, Graham Hudgins, Jesse Miller]]></dc:creator>
            <pubDate>Tue, 21 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/55cea79fd5c554656e8605525a61d4ad3ae0ee1b-2048x1143.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>AI agents are only as good as the surfaces they return. A text-only tool result flattens anything that is naturally visual or interactive: a dashboard, an alert list, an investigation graph, a distributed trace. The conversation becomes a place where you ask and the agent answers, but the moment the work gets dense, you leave for a separate tab, a separate product, a separate authentication boundary.</p><p><a href="https://modelcontextprotocol.io/extensions/apps/overview">MCP Apps</a> change the shape of that answer. A tool can now return an interactive UI alongside its text summary, and the host (Claude Desktop, Claude.ai, VS Code Copilot, Cursor) renders it inline in the conversation. The model keeps the compact text for reasoning. The human gets a live, clickable interface right next to the chat.</p><p>Three properties make this a different kind of integration than &quot;a webhook that returns a URL&quot;:</p><ul><li><strong>Context preservation.</strong> The UI lives inside the conversation. No tab switching, no hand-offs.</li><li><strong>Bidirectional data flow.</strong> The UI can call tools on the MCP server for fresh data, and the host can push new results from the agent back into the UI. No separate API layer or authentication plumbing.</li><li><strong>Sandboxed trust boundary.</strong> MCP Apps run in a host-controlled iframe. They cannot access the parent page, read cookies, or escape their container.</li></ul><p>Security operations run on triage, investigation graphs, and Attack Discovery, where an AI agent correlates hundreds of alerts into a handful of attack chains. Observability means distributed traces and time-series drill-downs. Building in Kibana means a dashboard grid. Flatten any of that to text and you lose the thing that makes it useful. We built MCP Apps for all three and are open-sourcing them together, so the same conversation can move from a triage queue to a dependency graph to a live dashboard without ever leaving the chat.</p><p>Each of the three reference apps is one MCP server serving many interactive views, not a bundle of separate products. The security app alone surfaces six dashboards that share the same server shell, the same tool-visibility model, and the same host bridge. The pattern is small; the surface area is where the value compounds.</p><h2>Elastic Security MCP App</h2><h3>Why it matters for the SOC</h3><p>When an agent tells a SOC analyst, &quot;<em>There are 47 alerts on host-314, here&#x27;s a summary,</em>&quot; it hasn&#x27;t done any work. It&#x27;s just pointed at where the work starts. The actual work lives in the alert list, the process tree, the investigation graph, and the case file. You can&#x27;t do it from a paragraph of text.</p><p>The security MCP App returns the workflow itself. The analyst prompts the agent, and the agent returns an interactive dashboard in the chat where the analyst can drill into alerts, run threat hunts, correlate attack chains, and open cases, all without losing the thread of the conversation. And because the findings, queries, and cases all land back in Elasticsearch, the same investigation is waiting in Kibana where the analyst can pick back up after the conversation has been closed.</p><h3>Six interactive dashboards</h3><p>The <a href="https://github.com/elastic/example-mcp-app-security">Elastic Security MCP App</a> ships six interactive elements, one per major SOC workflow. Each one is a React UI that renders inline when the agent calls the corresponding tool:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tool</td><td style="border: 1px solid #ccc; padding: 4px;">What it does</td><td style="border: 1px solid #ccc; padding: 4px;">Interactive UI</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Alert triage</td><td style="border: 1px solid #ccc; padding: 4px;">Fetch, filter, and classify security alerts</td><td style="border: 1px solid #ccc; padding: 4px;">Severity grouping, AI verdict cards, process tree, network events</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Attack Discovery</td><td style="border: 1px solid #ccc; padding: 4px;">AI-correlated attack chain analysis with on-demand generation</td><td style="border: 1px solid #ccc; padding: 4px;">Attack narrative cards with confidence scoring, entity risk, MITRE mapping</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Case management</td><td style="border: 1px solid #ccc; padding: 4px;">Create, search, and manage investigation cases</td><td style="border: 1px solid #ccc; padding: 4px;">Case list with alerts, observables, comments tabs, AI actions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Detection rules</td><td style="border: 1px solid #ccc; padding: 4px;">Browse, tune, and manage detection rules</td><td style="border: 1px solid #ccc; padding: 4px;">Rule browser with KQL search, query validation, noisy-rule analysis</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Threat hunt</td><td style="border: 1px solid #ccc; padding: 4px;">ES|QL workbench with entity investigation</td><td style="border: 1px solid #ccc; padding: 4px;">Query editor, clickable entities and investigation graph</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Sample data</td><td style="border: 1px solid #ccc; padding: 4px;">Generate ECS security events for common attack scenarios</td><td style="border: 1px solid #ccc; padding: 4px;">Scenario picker with four pre-built attack chains</td>
                    </tr>
              </tbody>
            </table>
          <p>Each tool returns a compact text summary that the model can reason over, alongside the interactive UI the analyst acts on. The UI can also fetch fresh data behind the scenes through the MCP host bridge. The full tool model and bridge API live in the <a href="https://github.com/elastic/example-mcp-app-security/blob/main/docs/architecture.md">repo&#x27;s architecture doc</a>.</p><p>The app also ships with <a href="https://github.com/elastic/example-mcp-app-security/tree/main/skills">Claude Desktop skills</a>, <code>SKILL.md</code> files that teach the agent when and how to use each tool. Download pre-built skill zips from the <a href="https://github.com/elastic/example-mcp-app-security/releases/latest">latest release</a>.</p><h3>From Alert to Case</h3><p>Four skills cover the core SOC loop. Each one picks up a prompt, calls a tool, and returns an interactive dashboard alongside a text summary that the model reasons over. An analyst&#x27;s day usually starts with an alert queue.</p><p><strong>Triage alerts. </strong>Ask the agent to triage by host, rule, user, or time window. The Alert Triage skill returns a dashboard of AI verdicts above the raw alert list, with one verdict per detection rule classifying that rule&#x27;s activity as benign, suspicious, or malicious, each with a confidence score and a recommended action. Click any alert to open a detailed view with a process tree, network events, related alerts, and MITRE ATT&amp;CK tags. No need to context switch between AI conversation and your alerts dashboard inside Kibana, everything is happening in real time inside your conversation.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b0239ea916a828ee0bfd0cc046af17330568fe09-2048x1313.png" style="width: 100%;" /><p><strong>Hunt for threats. </strong>Ask the agent to hunt across your indices. The Threat Hunt skill returns an ES|QL workbench with the query pre-populated and auto-executed, with every entity in the results clickable for drill-down. The model writes a short read-out below the table: what&#x27;s unusual, what&#x27;s connected, what&#x27;s worth a closer look. It then offers the next pivot: either go deeper into the threat hunt, or start a new skill within the MCP app that complements the work done so far. What ties this really well is launching an Attack Discovery to gather more context on the alerts you’ve gone deep with and the threats you have hunted so far.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9f0bde828bcedb24e2b45995cfa306f03e57d300-2048x1313.png" style="width: 100%;" /><p><strong>Run Attack Discovery.</strong> The Attack Discovery skill triggers the Attack Discovery API and returns a ranked list of findings. Each finding is a set of related alerts stitched into one attack chain, with MITRE tactics, a risk score, a confidence label, and the impacted hosts and users surfaced up front. The agent&#x27;s summary lands below the findings in the same rank order, and the conversation now holds everything needed to act: hunt queries, triage decisions, correlated chains, all staged for the next step.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e52af22dbaa070192170280d519dfb219ad807f5-2048x1313.png" style="width: 100%;" /><p><strong>Open cases without leaving the chat.</strong> Approve findings in bulk or ask the agent to open cases for specific alerts. The Case Management skill creates one case per approved finding (source alerts attached, MITRE tactics inherited from the attack chain) and renders the live case list inline. Click a case for its detail view, which includes a row of AI action buttons: Summarize case, Suggest next steps, Extract IOCs, and Generate timeline. Each one drops a structured prompt back into the chat, so the agent picks up the case context without needing a reintroduction. The agent&#x27;s summary sits below the case list and covers the full IR queue, including the cases just opened and earlier findings that still need one.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2057310b450c2917c5434c713f2b34732067429a-2048x1283.png" style="width: 100%;" /><p>Every step in this walkthrough runs the same loop: a prompt comes in, the skill picks it up, the tool returns a compact text summary for the model to reason over, alongside an interactive UI that the analyst acts on. Chain the skills together, and they compose into an end-to-end SOC flow — hunt, triage, correlate, open cases, and drive the next pivot, all with the model carrying the session context across every step. Invoke any one on its own and it&#x27;s still the full dashboard, pointed at whatever slice of your data you name. Either way, the work accumulates inside the conversation; no tab switching, no copy-paste, no hand-offs.</p><p>Two more skills round out the app: a detection-rule browser for tuning noisy rules, and a sample-data generator for spinning up realistic ECS events against a fresh cluster. A follow-up post will go deep on all six: investigation graph, attack-flow canvas, and end-to-end walkthrough.<br/></p><blockquote><em>&quot;The MCP App for Elastic Security bridges the gap between automated detection and manual hunting. By bringing our security data directly into a single interface within Claude Desktop, we surfaced &#x27;silent&#x27; threats in under an hour, risks that didn&#x27;t trigger standard alerts but required immediate action. It&#x27;s a force multiplier for our analysts.&quot; Mandy Andress: Chief Information Security Officer (CISO), Elastic.</em></blockquote><h3>How it works</h3><p>Each MCP App is a small Node.js server whose tools return both a compact text summary for the model and a React UI that the host renders inline. Because it&#x27;s built on the open MCP App spec, the same server runs on any compatible host - see the <a href="https://github.com/elastic/example-mcp-app-security/blob/main/docs/architecture.md">repo&#x27;s architecture doc</a> for the full design.</p><h3>Try it</h3><p>Requires Elasticsearch 9.x with Security enabled, plus Kibana for cases, rules, and Attack Discovery. The fastest path is the one-click <code>.mcpb</code> bundle from the <a href="https://github.com/elastic/example-mcp-app-security/releases/latest">latest release</a> - double-click it in Claude Desktop, and you&#x27;ll be prompted for your Elasticsearch URL and API key. Setup guides for <a href="https://github.com/elastic/example-mcp-app-security/blob/main/docs/setup-cursor.md">Cursor</a>, <a href="https://github.com/elastic/example-mcp-app-security/blob/main/docs/setup-vscode.md">VS Code</a>, <a href="https://github.com/elastic/example-mcp-app-security/blob/main/docs/setup-claude-code.md">Claude Code</a>, <a href="https://github.com/elastic/example-mcp-app-security/blob/main/docs/setup-claude-ai.md">Claude.ai</a>, and building from source are in the <a href="https://github.com/elastic/example-mcp-app-security">repo</a>.</p><a href="https://videos.elastic.co/watch/voDxV9RWWpY9HeBYTWTS93">Watch video on Vidyard</a><h2>Elastic Search MCP App: Dashboards built from conversation</h2><p>Every Kibana user knows the dashboard detour: leave what you&#x27;re working on, open Kibana, pick an index, pick fields, pick a visualization, tweak, and save. That&#x27;s five context switches before a single chart is on screen.</p><p>The new <a href="https://github.com/elastic/example-mcp-dashbuilder">example-mcp-dashbuilder</a> reference app collapses that into a prompt. Ask the agent to &quot;<strong>build me a dashboard with revenue metrics, order trends, and category breakdowns</strong>&quot; and the dashboard comes back inside the conversation without any tab-switching required.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b096b493726488f53040b08b5c8bc53ca0e44f9d-2048x1182.png" style="width: 100%;" /><p>Behind that prompt, the agent explores your Elasticsearch data via ES|QL and selects chart types to match the data: bars for comparisons, lines for trends, metric cards for KPIs, and heatmaps for two-dimensional patterns. It lays panels out on Kibana&#x27;s 48-column grid using the Elastic UI Borealis theme, and the result is fully interactive: you can drag, resize, and group panels into collapsible sections right in the chat. When the dashboard looks right, a single tool call exports it to Kibana, preserving ES|QL queries and custom colors. You can also import existing Kibana dashboards back into the chat for AI-assisted editing.</p><p>The principle is the same one behind the Security app: when the artifact is the product, returning it inside the conversation closes the loop between describing what you want and seeing it.</p><p>Under the hood, it follows the same MCP App pattern. A Node.js server registers a <code>view_dashboard</code> model-facing tool alongside a set of app-only tools the UI calls directly (data fetching, layout persistence, time-field detection, export/import). The dashboard view itself is a single self-contained HTML file bundled with <code>vite-plugin-singlefile</code> and served as an MCP App resource. Builders forking the repo get the same server shell and host bridge they see in the Security app, pointed at a different job. The example-mcp-dashbuilder README has the full architecture and chart-type reference.</p><a href="https://videos.elastic.co/watch/wep6PxPiEDyyvsYTKq6v5B">Watch video on Vidyard</a><h2>Elastic Observability MCP App</h2><p>The third reference app, <a href="https://github.com/elastic/example-mcp-app-observability">Elastic Observability MCP App</a>, tackles the SRE version of the same shape problem. When something breaks in production, the answer the on-call engineer needs is not a chart, it is a diagnosis stitched together from K8s metrics, APM topology, ML anomalies, and risk assessment. The shape of the answer is a causal story: what failed, why, what depends on it, and what to do next.</p><p>Six tools supporting the observability investigation workflow</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tool</td><td style="border: 1px solid #ccc; padding: 4px;">Dependency</td><td style="border: 1px solid #ccc; padding: 4px;">What It Does</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">observe</td><td style="border: 1px solid #ccc; padding: 4px;">Universal</td><td style="border: 1px solid #ccc; padding: 4px;">Transient ES|QL + ML-anomaly access primitive — run a query once, live-sample a metric, or block until a threshold or anomaly fires.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">manage-alerts</td><td style="border: 1px solid #ccc; padding: 4px;">Universal</td><td style="border: 1px solid #ccc; padding: 4px;">Create, list, get, and delete Kibana custom-threshold alerting rules. Omit the Kibana URL to run read-only.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ml-anomalies</td><td style="border: 1px solid #ccc; padding: 4px;">ML jobs</td><td style="border: 1px solid #ccc; padding: 4px;">Query ML anomaly records and open an inline anomaly-explainer view.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">apm-health-summary</td><td style="border: 1px solid #ccc; padding: 4px;">Elastic APM</td><td style="border: 1px solid #ccc; padding: 4px;">Cluster-level health rollup from APM telemetry; layers in K8s and ML context when available.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">apm-service-dependencies</td><td style="border: 1px solid #ccc; padding: 4px;">Elastic APM</td><td style="border: 1px solid #ccc; padding: 4px;">Service dependency graph — upstream/downstream, protocols, call volume.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">k8s-blast-radius</td><td style="border: 1px solid #ccc; padding: 4px;">Kubernetes metrics</td><td style="border: 1px solid #ccc; padding: 4px;">Node-outage impact — full outage, degraded, unaffected, reschedule feasibility.</td>
                    </tr>
              </tbody>
            </table>
          <h3><strong>Cluster health rollup</strong></h3><p>Ask &quot;what&#x27;s broken?&quot; or &quot;give me a status report&quot; and get a one-shot orientation: overall health badge, degraded services with reasons, top pod memory consumers, anomaly severity breakdown, and service throughput — all in one inline view. This is the starting point when something feels off, but you don&#x27;t know where to look. The view adapts based on what your deployment supports. APM gives you service health. Kubernetes metrics add pod and node context. ML jobs layer in anomalies.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f31be8bbcffd54623fcec4e51fd914dae666c026-1510x1810.png" style="width: 100%;" /><h3><strong>Service dependency graph</strong></h3><p>Ask &quot;what calls checkout?&quot; or &quot;show me the topology&quot; and get a layered dependency graph — upstream callers, downstream dependencies, protocols, call volume, and latency per edge. Let’s ask Claude to “Show me the service dependencies of the frontend”:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2afec7dbd2153bc3a9f05068f323911af766a258-2048x1184.png" style="width: 100%;" /><p>Zoom, pan, and hover to get all the details you need to understand the complex service relationships:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ff99d2fd0a54c22c9f7b975e9d8d7073c4311e7a-2048x970.png" style="width: 100%;" /><h3><strong>Assess risk with a blast radius</strong></h3><p>Ask &quot;what happens if my k8s node goes down?&quot; and get a radial impact diagram: the target node at center, full-outage deployments in red, degraded in amber, unaffected in gray. A floating summary card shows pods at risk and rescheduling feasibility. Single-replica deployments are flagged as single points of failure. </p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7736bef9609e45a3f0c7f9f3090a314b0834315d-1272x1838.png" style="width: 100%;" /><h3><strong>Observe</strong></h3><p>The agent&#x27;s primary access primitive for Elastic — one tool, three modes for three different needs. Say &quot;what&#x27;s CPU doing right now?&quot; and it runs an ES|QL query once and returns a table. Say &quot;show me frontend latency for the next 60 seconds&quot; and it live-samples the metric, updating the chart in-place. Say &quot;tell me when memory drops below 80MB&quot; or &quot;watch for anything unusual for the next 10 minutes&quot; and it blocks until the condition fires or the window expires. The view adapts to the mode: a results table for one-shot queries, a live trend chart with current/peak/baseline stats for sampling and threshold conditions, and a severity-scored trigger card for anomaly mode.</p><h3>How it works</h3><p>Same MCP App pattern as the Security and Search apps: a Node.js server, six model-facing tools wired to six single-file view resources. Tools are grouped by deployment backend (Universal, APM-dependent, K8s-dependent, ML-dependent), so the agent and the user both know up front which tools apply to a given deployment instead of discovering capability gaps at call time. The MCP App also includes an example Agent Builder workflow: <code>k8s-crashloop-investigation-otel</code> that can trigger on a Kubernetes alert and return a structured root cause summary before you&#x27;ve opened a single dashboard.</p><a href="https://videos.elastic.co/watch/xcF3nKDziVgphc5bPAkv9R">Watch video on Vidyard</a><h2>The Agentic Stack, Interactive</h2><p>Three properties about this pattern are worth stating directly. First, the tool result is no longer the end of the work, it is the start of it: the conversation returns an interface you can act on, not a summary you have to act from. Second, the same agent, the same model context, and the same conversation thread can now move across Security, Search, and Observability surfaces without leaving the conversation. Third, this only works because Elasticsearch and Kibana already expose the APIs. The MCP App is a thin interactive layer over the product capabilities we already ship.</p><p>Attack Discovery already powers the correlated findings view inside this app. Inside the stack, the same agentic pattern goes further: Elastic Workflows automate the deterministic steps (enrich entities, create cases, isolate hosts), while Agent Builder reasons over the data and invokes those workflows as tools. The MCP App brings that same security surface into the external conversation; Workflows and Agent Builder deepen it inside the stack. Different entry points, same Elastic APIs underneath.</p><p><strong>Try it:</strong></p><ul><li><strong>Security:</strong> <a href="https://github.com/elastic/example-mcp-app-security">example-mcp-app-security</a></li><li><strong>Search and dashboards:</strong> <a href="https://github.com/elastic/example-mcp-dashbuilder">example-mcp-dashbuilder</a></li><li><strong>Observability: </strong><a href="https://github.com/elastic/example-mcp-app-observability">example-mcp-observability</a></li></ul><p>Don&#x27;t have an Elasticsearch cluster yet? Start a free <a href="https://cloud.elastic.co/registration">Elastic Cloud trial</a>. For more on the building blocks behind the security app, see the related Security Labs posts on <a href="https://www.elastic.co/security-labs/from-alert-fatigue-to-agentic-response">Elastic Workflows and Agent Builder</a>, <a href="https://www.elastic.co/security-labs/agent-skills-elastic-security">Agent Skills</a>, and <a href="https://www.elastic.co/security-labs/speeding-apt-attack-discovery-confirmation-with-attack-discovery-workflows-and-agent-builder">Attack Discovery</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing unified API keys for Elastic Cloud Serverless and Elasticsearch]]></title>
            <description><![CDATA[Learn how Elastic unified control plane and data plane authentication in Serverless with a globally distributed IAM architecture. Use one API key for Cloud and Elasticsearch APIs.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-cloud-api-keys-unified-serverless</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-cloud-api-keys-unified-serverless</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[ Alex Chalkias]]></dc:creator>
            <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/864e229f00eb2018084f13dd7f0e390e18383ed4-1980x1188.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Imagine you&#x27;re a site reliability engineer (SRE) responsible for a growing fleet of Elastic Cloud Serverless projects: Elastic Observability for your production infrastructure, Elastic Security for your security operations center (SOC) team, and Elasticsearch for your customer-facing application. Each project has its own Elasticsearch API key. Your continuous integration and continuous delivery (CI/CD) pipeline needs a separate Cloud API key to provision and manage those projects. Rotation day arrives every quarter: You walk through each project, mint fresh keys, update your Terraform state, redeploy your pipelines, and hope nothing falls through the cracks. When an incident hits at 2 a.m. and you need to revoke access fast, you&#x27;re cross-referencing a spreadsheet of credentials to figure out which key belongs to which project and which service.</p><p>Today, that story gets a lot simpler. <strong>Elastic Cloud API keys</strong> can now be used to authenticate directly against <strong>Elasticsearch</strong> and <strong>Kibana</strong> APIs on <strong>Elastic Cloud Serverless</strong>. You can now use a single credential to manage your organization&#x27;s resources <em>and</em> run data operations, like Elasticsearch Query Language (ES|QL) queries, data ingestion, and alerting.</p><p>Let’s look at why we built this, how we engineered a globally distributed identity layer to make it possible, and how it lays the foundation for cross-project search.</p><h2>The secret management burden</h2><p>Building reliable CI/CD pipelines, GitOps workflows, or Terraform automation around data platforms comes with a hidden cost: secret sprawl.</p><p>In the previous model, developers faced a disjointed authentication story:</p><ul><li><strong>Control plane (Elastic Cloud API keys):</strong> Organization-scoped keys used to create projects, invite users, and manage billing via the <a href="https://www.elastic.co/docs/api/doc/cloud/">Elastic Cloud API</a>.</li><li><strong>Data plane (Elasticsearch API keys):</strong> Project-scoped keys created <em>inside</em> a specific Serverless project to interact with <a href="https://www.elastic.co/docs/api/doc/elasticsearch-serverless/">Elasticsearch</a> and <a href="https://www.elastic.co/docs/api/doc/serverless">Kibana</a> APIs.</li></ul><p>This meant that your deployment script had to authenticate to Elastic Cloud, provision a Serverless project, extract a newly minted Elasticsearch API key from that specific project, and then inject <em>that</em> second key into the downstream application or automation tool, resulting in complex pipelines, fragmented audit logs, and a higher risk of credential leaks.</p><h2>Unified authentication in Elastic Cloud Serverless</h2><p>With this release, the split is gone for Serverless projects. You can now create an Elastic Cloud API key that’s explicitly authorized for <strong>Cloud, Elasticsearch, and Kibana APIs</strong>.</p><ul><li><strong>Before:</strong> An Elastic Cloud API key was strictly a control plane token. It could create projects, manage billing, and invite users, but it had a hard boundary; it couldn’t be used to call the Elasticsearch or Kibana APIs inside those projects. You always needed a second, project-specific key for data operations.</li><li><strong>Now:</strong> By opting into <strong>Cloud, Elasticsearch, and Kibana API</strong> access when creating an Elastic Cloud API key, the hard boundary is removed for Serverless. That API key becomes a truly unified credential. It retains its ability to manage your organization&#x27;s infrastructure, while simultaneously gaining native access to query, ingest, and analyze data across any authorized Serverless project.</li></ul><p>By unifying this under a single Elastic Cloud API key, you gain a single identity that can be scoped, audited, rotated, and revoked as one unit. Every API call, whether it provisions a new project or runs an ES|QL query, appears under the same credential in your audit logs, giving you a single trail to follow during incident investigations or compliance reviews. Credential rotation becomes a one-step operation instead of a coordinated update across separate control-plane and data-plane secrets. And because role assignments are per-project, a single key can span several projects, managing ingestion in your observability project and running queries in your security project, without juggling separate credentials for each.</p><p>Importantly, <em>unified</em> does not mean <em>all-powerful</em>. By using the <code>role_assignments</code> payload, you can scope a unified key strictly to a single project and a specific role (such as read-only), ensuring the blast radius remains completely contained if a credential is ever exposed. If a developer leaves or an application is decommissioned, you can revoke a single key from the Elastic Cloud Console, immediately terminating access across both the control plane and all associated Elasticsearch projects.</p><p><em>(Note: For Elastic Cloud Hosted/managed deployments, Cloud API keys still only manage the control plane. Support for extending this to hosted stack APIs is planned for a future release.)</em></p><h2>Automating your workflows</h2><p>Getting started is simple. You can configure this entirely through the Elastic Cloud console or automate it using the <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elastic-cloud-api-keys">Elastic Cloud API</a>.</p><p>The UI process remains the same, but now you can select <strong>Cloud, Elasticsearch, and Kibana API</strong> access under the project role assignment.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6da7949fc93a52b080ba4d5c0aebbd750273a43a-2700x2699.webp" style="width: 100%;" /><p>Here’s how you create a unified key programmatically using the Elastic Cloud API. Notice the <code>application_roles</code> array, as this is what grants the key native access to the Elasticsearch data plane:</p><pre><code>curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: ApiKey $EC_API_KEY" \
  "https://api.elastic-cloud.com/api/v1/users/auth/keys" \
  -d '{
    "description": "unified-automation-key",
    "expiration": "90d",
    "role_assignments": {
      "project": {
        "elasticsearch": [
          {
            "role_id": "elasticsearch-admin",
            "organization_id": "YOUR_ORG_ID",
            "all": false,
            "project_ids": ["YOUR_PROJECT_ID"],
            "application_roles": ["admin"]
          }
        ]
      }
    }
  }'</code></pre><p>Once created, you simply pass this exact same key in the <code>Authorization: ApiKey</code> header to both <code>api.elastic-cloud.com</code> and your specific Serverless Elasticsearch endpoints.</p><h2>Under the hood: Building a distributed identity layer</h2><p>Making a Cloud API key work across both the control plane and the data plane isn&#x27;t as simple as passing a token. It requires solving a fundamental distributed systems challenge.</p><p>Historically, Cloud API keys lived in a centralized global security cluster. That works fine for control plane operations where a higher latency is acceptable. However, Elasticsearch data requests require ultra-low latency. We cannot afford a round trip across the globe to a central control plane to validate every single search query or ingest request.</p><p>To solve this, we introduced a new authentication architecture backed by a globally distributed datastore. The following sequence diagram shows a client sending an Elasticsearch query using an Elastic Cloud API key, illustrating how authentication happens entirely within the local region, without a round trip to the global control plane. Elasticsearch delegates authentication to the Regional IAM Service, which validates the key and resolves its role assignments against a local replica of the globally distributed database. Once authorized, Elasticsearch executes the query and returns results to the client.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3e38d7a862b9981523c5393c441b92eae13aeb90-2401x1351.webp" style="width: 100%;" /><h3>Globally distributed persistence</h3><p>Instead of relying solely on a centralized security cluster, Elastic Cloud API keys and their associated role definitions are now persisted in a globally distributed, highly available database. This database synchronizes identity and access management (IAM) data across the global control plane and the regional data planes where your Serverless projects actually run.</p><h3>Local validation with regional IAM</h3><p>When your client sends a request to Elasticsearch using an Elastic Cloud API key, the request doesn&#x27;t go back to the global control plane. Instead, it gets routed to the new regional IAM service. It validates the key against the local database replica, ensuring that authentication happens with near-zero latency and is completely insulated from global control plane outages.</p><h3>Dynamic role mapping</h3><p>Authentication is only half the battle; the system also needs to authorize the request. The regional IAM service instantly translates your Cloud-level role assignments for example, <code>application_roles</code>) into native Elasticsearch privileges. Elasticsearch can then authorize and execute the request locally, without ever needing a local <code>.security</code> index.</p><h2>The foundation for Cross-Project Search</h2><p>This distributed identity architecture is a foundational building block for the future of the Elastic platform.</p><p>Because identity and access are now unified and globally synchronized, we have the framework required to securely pass your identity between different projects. This enables the upcoming <strong>Cross-Project Search (CPS)</strong> capabilities for Serverless.</p><p>With CPS, you&#x27;ll be able to query data spanning multiple remote Serverless projects, such as combining security and observability workloads, as easily as if they were a single dataset. By relying on unified API keys, the system can automatically evaluate your permissions across all projects simultaneously without requiring you to configure complex trust relationships, certificates, or duplicate credentials on every target project.</p><h2>Learn more</h2><p>Ready to simplify your stack?</p><ul><li>Read the <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elastic-cloud-api-keys">Elastic Cloud API keys documentation</a> to learn how to assign stack access.</li><li>Check out the <a href="https://www.elastic.co/docs/api/doc/cloud/operation/operation-create-api-key">Create API key (Elastic Cloud API)</a> reference to automate key generation.</li><li>Review <a href="https://www.elastic.co/docs/deploy-manage/api-keys">Elastic API keys</a> for a full comparison of key types across the Elastic platform.</li></ul><p>Start or continue building in <a href="https://cloud.elastic.co/registration">Elastic Cloud</a> today.</p><h2>Disclaimer</h2><p>The release and timing of any features or functionality described in this post remain at Elastic&#x27;s sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fast approximate Elasticsearch ES|QL - part II]]></title>
            <description><![CDATA[Explaining the approach we use to obtain fast approximate Elasticsearch ES|QL queries and the testing we did of error estimation.]]></description>
            <link>https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-2</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-2</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Thomas Veasey, Jan Kuipers]]></dc:creator>
            <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c0cd6d42f5d061aac15767539209a7c443ed1acd-1000x600.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>As we discussed in our <a href="https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-1">previous blog</a>, we’re introducing fast approximate <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql">ES|QL</a> <code>STATS</code> queries, which will be available in version 9.4 of Elasticsearch and the Elastic Stack. This feature allows users to estimate an expensive analytics query, often orders of magnitude faster than running the full query, by relaxing the constraint that it returns the exact value. We believe this has many uses; for example, we’re planning to integrate it into Kibana to obtain fast chart previews where possible.</p><p>In order for you to be able to trust our estimates, we provide error estimates. Furthermore, since there are edge cases in error estimation, we certify when the estimated value and error are trustworthy. In this blog post, we’ll dive into the theory for approximating and estimating the error in such queries, as well as discuss the testing we’ve done.</p><h3>Background</h3><p>In order to estimate ES|QL <code>STATS</code> queries efficiently, we make use of a property that’s shared by many statistics: Their estimates computed from a large number of independent samples from a dataset approach their true value. In the case of an index with some field $X=\{x_i\},$ we can think of the true value of a statistic as its value computed for a random variable with uniform discrete distribution on $X$. In the following we denote this quantity $\theta$; it can be things like <code>AVG</code>, <code>MEDIAN</code>, and so on. If we make $n$ independent draws from $X$, denoted $S$, such that each value is selected with probability $\frac{1}{|X|}$, we have $n$ independent copies of this random variable. The property we rely on means that a sample statistic value $\hat{\theta}$ computed from $S$ approaches $\theta$ as $n$ becomes large. For example, if $\theta$ is the mean of some metric values then $\hat{\theta} = \frac{1}{|S|}\sum_{x\in S} x \rightarrow \frac{1}{|X|}\sum_{x\in X} x=\theta$ as $|S|=n$ becomes large. Indeed, for many statistics the limiting error distribution is known to be normal. Furthermore, it only depends on the distribution of $X$, the size of the sample $n$ and the type of the aggregation $\theta$. This means supported <code>STATS</code> queries can be approximated with fixed accuracy independent of the index size $|X|$.</p><p>It is easy to pick values at random from a Lucene index: create a filter that takes exponentially distributed jumps through the dataset, where the expected jump size is controlled by the desired sample probability. The AND of this filter and any other Lucene query can be performed extremely efficiently, since AND’ing filter queries is one of the things for which it is well optimized. In our other post, we discussed some real-world query examples to give a sense of the speedup we obtain for different levels of accuracy.</p><p>So far, we&#x27;ve only discussed obtaining an estimate of a query. While such a point estimator can be useful, without knowing anything about its error those uses are limited. We found that ES|QL has existing capabilities that make it relatively easy to incorporate cheap, flexible, and accurate error estimation at the same time. We&#x27;ll discuss this next.</p><h3>Error estimates</h3><p>We view providing an accurate understanding of the uncertainty in our estimates as crucial for users to be able to trust the approximation. While having the option to quickly estimate an ES|QL query alone can be useful in certain situations, we wanted to provide a richer feature that allows clients to make intelligent choices. For example, if an approximate query is being used to preview a chart and the error is only a couple of pixels, there’s little point in running another expensive query to redraw it.</p><p>The way we&#x27;ve chosen to represent error is by a confidence interval: the $\alpha$-central confidence interval, to be precise. This can be expressed in terms of the <a href="https://en.wikipedia.org/wiki/Cumulative_distribution_function">cumulative density</a>, $F$, of the statistic being estimated. Specifically, it&#x27;s the interval which contains the true value of the statistic with probability $\alpha$ whose endpoints are $F^{-1}\left(\frac{1-\alpha}{2}\right)$ and $F^{-1}\left(\frac{1+\alpha}{2}\right)$. Confidence interval calculations are surprisingly subtle. There are also important constraints for our use case that make standard approaches undesirable. Next, we’ll take a look in more detail at the motivation and the design for the approach we’ve adopted.</p><p>A key requirement of the whole project is to dramatically accelerate expensive analytics queries. It’s therefore vital that the overhead of estimating uncertainty isn’t too large compared to estimating the query result itself. We also want the feature to be as general as possible, but “isolated” within the language. In other words, ES|QL is a flexible language, and we want estimation to work with as much of it as possible. At the same time, we don’t want to introduce a cross-cutting feature that incurs development costs on every new feature we ship.</p><p>With these considerations in mind, we chose to estimate confidence intervals by partitioning the sample set and computing the query output on each subsample. This is reminiscent of bootstrap; however, since we ensure that each partition receives a disjoint random subset of the sample data, we know that they comprise true estimates of the statistic distribution. To achieve the best possible estimate of the statistic itself, we still compute its value on the full sample. For example, to estimate the mean and its distribution the process can be expressed as follows:</p><pre><code>FROM data | SAMPLE probability
          | EVAL bucketId = RANDOM(B) // B is the number of buckets
          | STATS avg     = AVG(x)
                  avg_0   = AVG(x) WHERE bucketId==0  
                  (...)
                  avg_B-1 = AVG(x) WHERE bucketId==B-1
            BY grouping
          | EVAL confidence_interval = CONFIDENCE_INTERVAL(avg, avg_0, ..., avg_B-1)
</code></pre><p>This introduces a complication to account for the discrepancy between the count of values used to estimate a query statistic and used to sample its distribution. This is a downside; however, there are some significant advantages.</p><p>Most of the work in analytic queries resides in computing the aggregate statistics: post-processing after a <code>STATS</code> reduction acts on a far smaller table, and the cost is often relatively small. In this scheme, every row in the input data to the <code>STATS</code> command is processed exactly twice compared to just estimating the statistic. Therefore, roughly speaking we pay a fixed overhead that&#x27;s the same order of magnitude as the cost of estimating the query in order to estimate its uncertainty. Since we often achieve multiple orders of magnitude speedup on the exact query, this is acceptable.</p><p>Because this process uses a plain old table, with extra columns for the distribution samples, we can pass the whole table through any ES|QL pipeline and compute confidence intervals on the final results. For example, if we include <code>EVAL square_avg = avg * avg</code> in the pipeline above, we&#x27;d have exactly the same <code>square_avg</code>, <code>square_avg_0</code>, …, <code>square_avg_B-1</code> extra values. At the end of the pipeline, we have samples from the distribution of the original statistics and all quantities that are computed using them. Therefore, we can apply our standard confidence interval machinery to reduce the table and convert samples into confidence intervals for derived quantities as well. This whole process is essentially transparent to the rest of the ES|QL language, and as we showed above, can be achieved by query rewriting.</p><h3>The confidence interval calculation</h3><p>We have independent samples of the statistic distribution $\{\hat{\theta}_i\}$. However, they&#x27;re computed with fewer values than our estimate $\bar{\theta}$. We also have a relatively small number of distribution samples, to avoid the count discrepancy being too large, and so we don’t inflate the table too much. We therefore prefer a parametric approach for estimating confidence intervals.</p><p>The errors in the statistics for which we support estimation tend to normal distributions in the limit they&#x27;re computed from many values. So a natural choice, the standard interval, is to estimate the mean and standard deviation from the samples and report the corresponding normal confidence intervals $\left[ m+\sigma \Phi^{-1}\left(\frac{1-\alpha}{2}\right), m+\sigma \Phi^{-1}\left(\frac{1+\alpha}{2}\right) \right]$. Here, $\Phi$ denotes the standard normal distribution function. For heavy-tailed data and statistical functions that are sensitive to outliers, such as <code>STD_DEV</code>, convergence to normality can be slow, resulting in poorly calibrated intervals.</p><p>Briefly, in order to assess the quality of the intervals, one can examine their calibration. Specifically, one computes a quantity called the <a href="https://en.wikipedia.org/wiki/Coverage_probability">coverage</a>. For a central confidence interval, it should contain the true statistic value roughly $(1-\alpha)n$ times for $n$ trials. In fact, since we seek the central confidence interval, we can make the stronger statement that the true value should be above, or below, the confidence interval endpoints in roughly $\frac{1-\alpha}{2} n$ out $n$ trials. The empirical coverage is this fraction computed for a large number of trials. It allows us to compare alternative approaches by simulation. We return to this when we report our test results.</p><p>In order to obtain better confidence intervals, we tried a couple of different approaches: the <a href="https://en.wikipedia.org/wiki/Cornish%E2%80%93Fisher_expansion">Cornish-Fisher</a> correction of quantiles and an adaptation of <a href="https://en.wikipedia.org/wiki/Bootstrapping_(statistics)#Deriving_confidence_intervals_from_the_bootstrap_distribution">bias-corrected accelerated</a> (BCa) confidence intervals. Simulation showed BCa provided more robust calibration across a range of confidences, so this is the approach we selected. The basic idea, which was introduced by Efron, is to assume that there exists a monotonic transformation of the underlying statistic $g=g(\theta)$ which, when applied to a distribution sample normalizes its distribution:</p><div class="katex">\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\hat{\phi}=\phi+(1+a \phi) \left(N(0,1) - z_0 \right)\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;(1)</div><p>Here, $\hat{\phi}=g(\hat{\theta})$, $\phi=g(\theta)$ and $N(0,1)$ is the standard normal random variable. This is clearly a relaxation of the assumption that the statistic itself is normally distributed, which is used to derive the standard interval. In fact, this family includes many distributions, since $g$ is only constrained to be monotonic. (You can think of $1+a\phi=1+a g(\theta)$ as a first-order Taylor expansion of the case that the variance is an arbitrary function of the true parameter value. This further relaxes the assumption that the normalizing transformation also stabilizes the variance.) The nice thing about this ansatz is that $g$ never needs to be explicitly computed, and there exist standard approaches for estimating the parameters $a$ and $z_0$ from the distribution samples.</p><p>To handle $z_0$ one simply arranges for the estimate to land at the median of transformed distribution. If we assume the cumulative distribution function in theta space is $F_{\theta}$ then $z_0=\Phi^{-1}(F_{\theta}(\bar{\theta}))$, where $\bar{\theta}$ is the estimated statistic value, and as before $\Phi$ is the standard normal distribution function. Typically, $F_{\theta}$ is approximated by the empirical distribution function, computed indirectly by bootstrap. However, somewhat surprisingly, extensive simulation showed that we obtained better calibrated intervals using a normal approximation to our sample values, i.e. $F_{\theta}(\bar{\theta})=\Phi \left( \frac{\bar{\theta}-\hat{\theta}}{\hat{\sigma}}\right)$ with $\hat{\theta}$ and $\hat{\sigma}$ their empirical mean and standard deviation, respectively.</p><p>To complete the procedure, one can rearrange (1) to derive $\alpha_{\theta}$ quantiles for $\theta$ as follows:</p><div class="katex">\alpha_{\theta}=\Phi\left( z_0 + \frac{z_0+z_{\alpha}}{1 - a (z_0+z_{\alpha})}\right)</div><p>where $z_{\alpha}$ is the standard normal z-score for quantile $\alpha$. Typically, one uses the inverse empirical cumulative density estimate of $F_{\theta}^{-1}$ to convert quantiles back to a confidence interval. However, because we have a mismatch between the count of values used to compute distribution samples and the query estimate, we need to do some sort of scaling. Exploring options by simulation, we again found it best to use a normal approximation, $F_{\theta}^{-1}(\alpha_{\theta})=\hat{m}+\frac{\hat{\sigma}}{\sqrt{s}}\Phi^{-1}(\alpha_{\theta})=\hat{m}+\frac{\hat{\sigma}}{\sqrt{s}}\left( z_0 + \frac{z_0+z_{\alpha}}{1 - a (z_0+z_{\alpha})}\right)$, where $s$ is the number of distribution samples we use. This is just applying the usual scaling of variance by $\frac{1}{\sqrt{\text{sample size}}}$.</p><p>Efron showed that in the case $\hat{\theta}$ is distributed as $f_{\theta}$, i.e. that it depends only on the true value $\theta$, then the acceleration $a$ can be estimated without any knowledge of $g$. In particular, $a=\frac{1}{6}\text{SKEW}\left(\frac{\partial f_{\theta}}{\partial \theta}\right)$. By assumption, our statistics tend to normal distributions with mean $\theta$. Since skew is translation and scale invariant, this gives that $a \approx \frac{1}{6}\text{SKEW}(\{\hat{\theta}\})$, i.e. one sixth of the skew of our distribution samples. One thing this glosses over is the dependence of skew, and therefore acceleration, on sample size. We know it tends to zero as the count increases. In fact, skew also asymptotes to zero as $\frac{1}{\sqrt{\text{sample size}}}$ and so we also adjust acceleration to be $\frac{1}{6\sqrt{s}}\text{SKEW}(\{\hat{\theta}\})$ to account for the count mismatch between the samples $\{\hat{\theta}\}$ and estimate $\bar{\theta}$.</p><p>Although we significantly improve the calibration of confidence intervals by using a better methodology, we still see issues in the case that the underlying distribution has very heavy tails for some of the supported <code>STATS</code> functions. Therefore, we introduce some additional guard rails we discuss next.</p><h3>Guard rails</h3><p>To avoid the user having to understand too much about edge cases, we provide additional safeguards that surface when we&#x27;ve been unable to confirm&nbsp; that the distribution samples behave as we expect. This typically happens when the statistic isn’t computed from a sufficient number of values given the metric distribution. It&#x27;s exacerbated by very skewed metric data and certain aggregation functions, such as the <code>STD_DEV</code>, which are sensitive to outliers.</p><p>We have some global constraints on the minimum count of values used to estimate a statistic for which we&#x27;ll certify it. For example, if any bucket is empty, then we can’t rely on the distribution samples. This is because ES|QL allows mixing approximate statistics, which treat empty buckets differently. For example, consider the following query:</p><pre><code>SET approximation=true;
FROM data | STATS avg = AVG(x), sum = SUM(y) | EVAL mix = avg + sum
</code></pre><p>There is no self-contained way of correctly assigning a value to <code>mix</code> for empty buckets, since summing requires that we treat them as zero, in which case we bias our estimate of <code>avg</code>. Alternatively, ignoring empty buckets introduces bias in the <code>sum</code>. There is also a global minimum count of values for which we’ve verified our certification method is sufficiently reliable; this is 10.</p><p>We explored a variety of additional tests to certify the results. These were based on both tests of the underlying data distribution, specifically <a href="https://en.wikipedia.org/wiki/Heavy-tailed_distribution#Hill.27s_tail-index_estimator">Hill’s estimator</a>, as well as the statistic’s distribution properties. If the true distribution of the statistic is sufficiently normal, then our estimate and confidence interval calculation behaves as we expect: The interval is well calibrated and the interval width is representative of the actual error. Therefore, in the end, we chose to use a test based on the p-value for distribution samples’ <a href="https://en.wikipedia.org/wiki/Skewness">skewness</a> and <a href="https://en.wikipedia.org/wiki/Kurtosis">kurtosis</a> versus a normal distribution null hypothesis. To certify a result, we require that the two tail p-values are greater than 0.05 for both tests. As we show below, we found this test was well aligned to our actual needs: to distinguish results for which the estimate and its confidence interval are more and less reliable.</p><p>There&#x27;s a simple trick we can use to boost the accuracy of the accuracy of the test: Create multiple independent distribution samples and use a vote. Given a test to certify results with a failure rate $f$, the distribution of the count of $k$ failures for $t$ tests is $\frac{t!}{k!(t-k)!}f^k(1-f)^{t-k}$ for the case the null hypothesis, that the estimate is trustworthy, is true. For example, for the majority vote assuming $f=0.05$ and $t=3,$ then the significance of the test is $1-(3\times 0.95^2\times 0.05+0.95^3)=0.007$, i.e. we fail to certify fewer than 1% of trustworthy results. Note that we can compute multiple trials relatively easily using different seeds for the <code>RANDOM</code> bucket identifier.</p><p>This additional check allows us to certify that we trust our estimates and their errors. We surface this information in the approximate query results. When we can’t certify results, they won’t necessarily be inaccurate, but they should be treated with more caution.</p><h3>Testing</h3><p>The two main aims of the testing we discuss here were to understand the calibration of the confidence intervals and to see how well they characterize the statistics&#x27; estimation errors. The count function is particularly well behaved, its error distribution is binomial, so the majority of our testing focused on metric aggregations. We study smooth distributions but make sure we cover a range of tail behaviors. The presence of outliers is the key factor that reduces the accuracy of estimated statistics. For example, if an outlier isn’t sampled at all, it can significantly affect the value of some statistics.</p><p>We explored a range of light-tailed distributions, such as uniform and normal, and skewed and heavy-tailed distributions, such as exponential, log-normal, Cauchy, and Pareto. For each family of distribution, we used multiple parameterizations, focusing primarily on varying the scale parameter. In total, we had 24 distinct data distributions. Figure 1 shows some example sample distributions from this set. Note that we’ve truncated the charts to remove extreme outliers, which are present for both the Cauchy and log-normal distributions.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fb088c17f9755c0d1b3173fb917f0af2c0f83847-1712x950.png" style="width: 100%;" /><p>For each data distribution, we evaluated 14 different sample sizes, ranging from 1000 to 500000. Then, for each sample set, we evaluated <code>AVG</code>, <code>COUNT</code>, <code>MEDIAN_ABSOLUTE_DEVIATION</code>, <code>MEDIAN</code>, <code>PERCENTILE([25, 75, 90, 95, 99])</code>, <code>SUM</code> and <code>STD_DEV</code> at two levels of confidence, 50% and 90%. In total, we have around 7500 distinct experiments. For each experiment, we assessed the interval calibration using 100 runs and counting the number of times the true statistic lands in the confidence interval. This gives us a binomially distributed estimate for the true confidence interval coverage. The variation we expect in the estimated coverage changes slightly with the level of confidence; for example, at 50% we expect to see values mainly between 0.44 and 0.56, and for 90% we expect to see values mainly between 0.86 and 0.94 using 100 trials.</p><p>Figure 2 shows <a href="https://en.wikipedia.org/wiki/Box_plot">box plots</a> for the empirical coverage for the two confidence levels computed from all experiments. In all cases, the confidence intervals are reasonably well calibrated. Extreme percentiles are biased for small sample sizes, which leads to increased outlier counts for small sample sizes. As a rule of thumb, you’d want roughly $\frac{10}{p(1-p)}$ samples to ensure that you have enough samples in the appropriate tail.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/02a5375025e811ba18c4e823e1d984261bbf6f42-631x763.png" style="width: 100%;" /><p>Next, we examine the degree to which the confidence intervals capture the typical size of the estimate error. To do this, we examine the distribution of the ratio of the estimated statistics&#x27; error and half the confidence interval width for all certified results. The higher the confidence, the wider the interval, so different confidence levels shift the mean of this distribution. Figure 3 shows this distribution computed for the 90% confidence interval. As expected, the distribution is roughly normal, albeit with a tail of some larger errors. We see in all cases the confidence interval width gives the order of magnitude of the estimated statistics&#x27; actual errors.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2d4ab88a07910edac7e8406ae4942694751f0090-1000x600.png" style="width: 100%;" /><p>We’ve shown that certified results are nearly always reliable; however, we’d also like some insight into the proportion of results which we fail to certify that are actually reliable, to confirm that the test aligns with our objective. We use <em>reliable</em> here in the fairly strong sense that the confidence interval is well calibrated. Specifically, for the 50% and 90% confidence intervals, we count the proportion of uncertified results for which the confidence interval empirical calibration has an acceptable margin of error, given the number of trials used to estimate it. Using this procedure, the false positive rate across all experiments is around 1%. This agrees well with the failure rate we expect by chance, given our test parameters, and confirms the assumption underlying the test.</p><p>Finally, to better understand the difference between certified and uncertified results, Figure 4 shows the error distribution of the ratio of the estimated statistics&#x27; errors and half the 90% confidence interval for the reliable and unreliable results separately. Note that we truncated the range for uncertified intervals.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c0cd6d42f5d061aac15767539209a7c443ed1acd-1000x600.png" style="width: 100%;" /><h3>Wrapping up</h3><p>In this post, we present the background behind our approach for quickly estimating ES|QL queries and providing an indication of their errors. To do this, we developed an effective confidence interval mechanism that allows us to provide error estimates. Our approach also allows us to estimate confidence intervals for quantities derived from sampled statistics via other pipeline operations. Quantifying the error comes with a relatively small overhead compared to just estimating the query. Finally, we developed a statistical test to certify results we return. Values that aren’t certified can still be accurate, but we’re less confident in them.</p><p>As well as testing the feature on a range of real-world use cases, which we discuss in <a href="https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-1">our companion post</a>, we tested the error estimation by extensive simulation across a range of data characteristics, sample sizes, aggregation functions, and confidence levels. This showed confidence intervals are well calibrated, and the interval itself provides a good approximation of the actual error we observe in the estimates. Finally, we showed that we were able to certify intervals with a low false negative rate.</p><p>We’re planning to integrate this feature into other stack capabilities in the future, so stay tuned.<br/><br/></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fast approximate Elasticsearch ES|QL - part I]]></title>
            <description><![CDATA[Introducing the work we've done on a fast approximate querying mode for Elasticsearch ES|QL. In many cases, it allows us to achieve orders of magnitude latency reductions while providing accurate estimates.]]></description>
            <link>https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-1</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-1</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jan Kuipers, Thomas Veasey]]></dc:creator>
            <pubDate>Thu, 16 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/17afc59be8a46957a341faec1f44c9cb0a221894-1918x1176.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Analytics workloads typically involve summarizing large volumes of data into a much smaller number of statistics. The Elasticsearch Query Language (ES|QL) implements this capability using the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by">STATS command</a>. This allows you to select various aggregation functions and apply them to the previous query results, as well as grouping the results by one or more ES|QL expressions. This is a flexible operation that, coupled with ES|QL querying capabilities, allows one to perform <a href="https://en.wikipedia.org/wiki/MapReduce">MapReduce</a> on data stored in collections of Elasticsearch indices.</p><p>One of the key requirements for a pleasant user experience is that these operations are performed quickly. Large language model–based (LLM) agents also introduce new <a href="https://arxiv.org/pdf/2509.00997">higher bandwidth and speculative query patterns</a> that can potentially benefit from different optimization strategies.</p><p>In this two-part blog series, we discuss an optimization approach we’re introducing to ES|QL in version 9.4 of Elasticsearch and the Elastic Stack, which exploits a relaxation of the problem. Rather than trying to get exact values for aggregates, we allow ourselves to return approximate values, together with some characterization of their error. A key benefit of approximation is that it breaks the dependency between performance and dataset size: The accuracy with which one can approximate a query doesn’t depend on the original dataset size but, principally, its data characteristics and the query itself. As we’ll see later, this allows us to achieve some dramatic performance improvements.</p><p>In our<a href="https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-2"> next blog post</a>, we will discuss the theory behind our approach and the validation we’ve done of its statistical properties. Here, we introduce the syntax and give a sense of how it’s achieved using standard ES|QL and query rewriting. You can explore its performance on a subset of the popular <a href="https://github.com/ClickHouse/ClickBench">ClickBench</a> benchmark. Finally, we discuss some limitations and gotchas that are worth understanding when you use query approximation.</p><h3>Syntax and behavior</h3><p>So how do you actually use it?</p><pre><code>SET approximation=true;
// The query you want to approximate
FROM data | commands | STATS x=agg(...) | commands</code></pre><p>That’s it. You simply introduce the new line <code>SET approximation=true;</code> and write your <code>STATS</code> query pipeline as usual. Below, we discuss some advanced configuration options and some limitations around the <code>agg(...)</code> and <code>commands</code>. However, essentially, we choose defaults so that this will typically provide useful approximations while achieving significant speedups.</p><p>With this change, you’ll see some differences in the query results. Let’s look at a concrete example to illustrate this. Suppose the raw query is as follows:</p><pre><code>FROM sales | WHERE @timestamp >= NOW()-1w
           | STATS count = COUNT() BY item_category
           | SORT count DESC
           | LIMIT 5</code></pre><p>The results might look something like this:</p><pre><code>item_category        | count
---------------------+------
Household Essentials | 5165
Kitchen              | 2132
Storage              | 1121
Home Decor           | 877
Furniture            | 357</code></pre><p>Approximating this query introduces some extra columns for each quantity that’s estimated:</p><pre><code>item_category | count | _approximation_confidence_interval(count) | _approximation_certified(count)
--------------+-------+-------------------------------------------+--------------------------------
Essentials    | 5150  | [5100, 5250]                              | true
Kitchen       | 2150  | [2100, 2200]                              | true
Storage       | 1120  | [1100, 1150]                              | true
Home Decor    | 880   | [860, 900]                                | true
Furniture     | 330   | [310, 350]                                | true</code></pre><p>The count column now contains an estimate, and you’ll see it’s somewhat different from the exact values above. The <code>_approximation_confidence_interval(count)</code> column defaults to the central 90% confidence interval for the <code>count</code> estimate and the <code>_approximation_certified(count)</code> column indicates if we’re highly confident that the results and their confidence interval are trustworthy. In outline, the <em>confidence interval</em> is an interval we expect has a high probability (0.9) of containing the true value for the quantity being estimated. The <em>certified column</em> indicates the distribution of the approximation is behaving as we expect. When the result isn’t certified, it’s often still accurate, but our test of the properties of its distribution hasn’t been able to confirm this. These quantities are discussed in more detail in our second post.</p><h3>Implementation</h3><p>An approximate query is rewritten before query execution using random sampling and extrapolation. Let’s take a look at the query of the previous section. The part of the rewritten query responsible for obtaining the best estimate looks like:</p><pre><code>FROM sales | SAMPLE probability
           | WHERE @timestamp >= NOW()-1w
           | STATS count = TO_LONG(COUNT() / probability) BY item_category
           | SORT count DESC
           | LIMIT 5</code></pre><p>The query samples a fraction of the data, and therefore the final count has to be extrapolated by scaling up with the inverse of the sample probability. Extrapolation clearly depends on the underlying aggregation function, and we handle this appropriately for all functions we support.</p><p>To obtain the sample probability, we&#x27;re setting a fixed <code>number_of_rows</code> to be processed by the <code>STATS</code> command. In this case, the probability is calculated as follows:</p><pre><code>FROM sales | WHERE @timestamp >= NOW()-1w
           | STATS total_row_count = COUNT()
           | EVAL probability = number_of_rows / total_row_count</code></pre><p>This query is executed before the final approximate query is executed.</p><p>As well as this best estimate, confidence intervals and a statistical test used to certify that the value distribution is behaving as we expect also need to be computed. The intervals are computed using a variant of the <a href="https://blogs.sas.com/content/iml/2017/07/12/bootstrap-bca-interval.html">bias-corrected and accelerated bootstrap confidence interval</a> (BCa) method. Therefore, the data needs to be partitioned into B buckets, which are used in turn to compute the intervals. Omitting some implementation details, this approximate query looks like:</p><pre><code>FROM sales | SAMPLE p
           | WHERE @timestamp >= NOW()-1w
           | EVAL bucketId = RANDOM(B) // B is the number of buckets
           | STATS count     = TO_LONG(COUNT() / p) 
                   count_0   = TO_LONG(COUNT() / p) WHERE bucketId==0  
                   (...)
                   count_B-1 = TO_LONG(COUNT() / p) WHERE bucketId==B-1  
             BY item_category
           | WHERE count >= 10
           | SORT count DESC
           | LIMIT 5
           | EVAL ci = CONFIDENCE_INTERVAL(count, count_0, ..., count_B-1),
                  certified = CERTIFIED(count, count_0, ..., count_B-1)
           | DROP bucketId, count_0, ..., count_B-1</code></pre><p>To certify the estimate and confidence interval, there should be enough data, and the distribution of the bucket values should tend to normality.</p><p>Some queries can be efficiently computed using only summary statistics maintained in the index. To handle these correctly, where sampling is both slower and inaccurate, we updated the physical query planner, since detecting this case requires information that’s only available where the data resides. When the planner detects this is possible, it simply executes the query as normal. Such queries are typically fast anyway, and there’s no real side effect, so you don’t need to worry about this when using approximation; however, you’ll see that confidence intervals for such queries always have zero length, indicating the results are exact.</p><h3>Results</h3><p>To explore the performance improvements, we use <a href="https://github.com/ClickHouse/ClickBench">ClickBench</a>. This is a benchmark for analytics workloads for database management systems (DBMS). It comprises approximately 100 million rows, with a focus on clickstream and traffic analysis, web analytics, machine-generated data, structured logs, and events data. The benchmark also defines 43 queries that are typical of ad-hoc analytics and real-time dashboards.</p><p>Some of the queries aren’t suitable for approximation. For example, we don’t support approximating the unique count of a categorical value or computing the minimum and maximum of a metric value. We also don’t care about queries targeting search alone, for which Elasticsearch has excellent performance in any case. We therefore exclude these types of query from our evaluation. Finally, we also want to test a few additional aggregation functions, such as percentiles, which are not well represented in the original query set, so add some variants of the original metric queries to this end.</p><p>Queries in the benchmark are written using standard SQL and so need porting to use ES|QL syntax. This translation is fairly straightforward. Here’s an example:</p><pre><code>SELECT SUM(AdvEngineID), COUNT(*), AVG(ResolutionWidth) FROM hits</code></pre><p>becomes:</p><pre><code>FROM hits | STATS s = SUM(AdvEngineID),
                  c = COUNT(*),
                  a = AVG(ResolutionWidth)</code></pre><p>when rewritten in ES|QL.</p><p>For running all benchmarks, we use an Elastic Cloud Hosted instance with 870GB disk, 29GB Ram, and 4 vCPUs, in effect, an Amazon Elastic Compute Cloud (EC2) i3.xlarge instance. In the following results, we simply compare ES|QL with and without query approximation. Extensive results on a range of different hardware setups and datastores can be found <a href="https://benchmark.clickhouse.com/">here</a>. Even with significantly constrained test hardware (matching the vCPUs of the smallest setup), our approximation approach achieves competitive results against much larger systems.</p><p>We run each query and its approximation five times in a random order, clearing the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-clear-cache">query cache</a> between each run. We report the average run time over all five runs. While clearing the cache should be sufficient to avoid most of the advantage of running second, we wanted to avoid any possible accidental prewarming effects, which is why we alternate.</p><p>The results break down into four categories:</p><ol><li>Queries which are rewritten to use index summary statistics (three queries).</li><li>Queries that perform well (13 queries).</li><li>Queries with high cardinality partitioning (seven queries).</li><li>Queries with restrictive filters (12 queries).</li></ol><p>Roughly speaking, for these four categories, approximate querying is: equivalent (1); faster and accurate (2); faster but unreliable (3); and slightly slower (4), compared to exact querying, respectively.</p><p>For category 1, the planner automatically detects that we’re able to perform the query using summary statistics, and we end up executing the queries in the same way. To do this, we need information that’s only available on the data nodes, so we perform the rewrite only after we&#x27;ve estimated the sample probability. Because we&#x27;re able to do this very efficiently, the overhead is small (around 10–15%). In both cases, the results are exact.</p><p>Queries in category 2 run on average 23$\times$ faster if estimating the values and computing confidence intervals and 72$\times$ faster if just estimating the values, which you can select as follows: <code>SET approximation={&quot;confidence_level&quot;:null}</code>. These headline figures hide quite some variation in the impact of approximation on performance. The table below shows some queries sampled from the range of speedups we see:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query</td><td style="border: 1px solid #ccc; padding: 4px;">Baseline / ms</td><td style="border: 1px solid #ccc; padding: 4px;">Approximate with CI / ms</td><td style="border: 1px solid #ccc; padding: 4px;">Approximate without CI / ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">1725</td><td style="border: 1px solid #ccc; padding: 4px;">145</td><td style="border: 1px solid #ccc; padding: 4px;">15</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">10</td><td style="border: 1px solid #ccc; padding: 4px;">4340</td><td style="border: 1px solid #ccc; padding: 4px;">1721</td><td style="border: 1px solid #ccc; padding: 4px;">56</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">13</td><td style="border: 1px solid #ccc; padding: 4px;">32912</td><td style="border: 1px solid #ccc; padding: 4px;">6106</td><td style="border: 1px solid #ccc; padding: 4px;">3821</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">21</td><td style="border: 1px solid #ccc; padding: 4px;">46739</td><td style="border: 1px solid #ccc; padding: 4px;">3284</td><td style="border: 1px solid #ccc; padding: 4px;">2139</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">22</td><td style="border: 1px solid #ccc; padding: 4px;">252505</td><td style="border: 1px solid #ccc; padding: 4px;">6478</td><td style="border: 1px solid #ccc; padding: 4px;">5019</td>
                    </tr>
              </tbody>
            </table>
          <p>Here are the corresponding queries:</p><pre><code>// Query 3
FROM hits | STATS s = SUM(AdvEngineID),
                  c = COUNT(*),
                  a = AVG(ResolutionWidth)

// Query 10
FROM hits | STATS s = SUM(AdvEngineID),
                  c = COUNT(*),
                  a = AVG(ResolutionWidth) BY RegionID
          | SORT c DESC
          | LIMIT 10

// Query 13
FROM hits | WHERE SearchPhrase != ""
          | STATS c = COUNT(*) BY SearchPhrase
          | SORT c DESC
          | LIMIT 10

// Query 21
FROM hits | WHERE URL != ""
          | STATS l = AVG(LENGTH(URL)), c = COUNT(*) BY CounterID
          | WHERE c > 100000
          | SORT l DESC
          | LIMIT 25

// Query 22
FROM hits | WHERE Referer != ""
          | GROK Referer """%{URIPROTO}://(?:www\.)?%{URIHOST:k}"""
          | WHERE k IS NOT NULL
          | STATS l = AVG(LENGTH(Referer)), c = COUNT(*) BY k
          | WHERE c > 100000
          | SORT l DESC
          | LIMIT 25</code></pre><p>We&#x27;ll return to the accuracy of the approximation in the next blog post, but to give a sense of this, we plot below the exact and approximate values for a sample run for query 13:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9ab83c13f42f88253a242d78339356f4a7c48700-2094x1358.png" style="width: 100%;" /><p>For category 3, we get an average speedup of $11\times$. However, the results of queries in this category can miss some partitions and often have large estimation errors. Approximation can still be valuable for such queries, particularly in the context of agentic workflows, but requires larger sample sizes than out default if accuracy is important. As we discuss in the next section, we provide an API to explicitly control the sample size. If the source dataset is sufficiently large, this can be increased and approximation will still yield significant performance improvements. The table below shows a couple of query examples for this category:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query</td><td style="border: 1px solid #ccc; padding: 4px;">Baseline / ms</td><td style="border: 1px solid #ccc; padding: 4px;">Approximate with CI / ms</td><td style="border: 1px solid #ccc; padding: 4px;">Approximate without CI / ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">15</td><td style="border: 1px solid #ccc; padding: 4px;">8256</td><td style="border: 1px solid #ccc; padding: 4px;">1187</td><td style="border: 1px solid #ccc; padding: 4px;">124</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">17</td><td style="border: 1px solid #ccc; padding: 4px;">70641</td><td style="border: 1px solid #ccc; padding: 4px;">2109</td><td style="border: 1px solid #ccc; padding: 4px;">982</td>
                    </tr>
              </tbody>
            </table>
          <p>Here are the corresponding queries:</p><pre><code>// Query 15
FROM hits | STATS c = COUNT(*) BY UserID, SearchPhrase
          | SORT c DESC
          | LIMIT 10

// Query 17
FROM hits | EVAL m = DATE_EXTRACT("minute_of_hour", EventTime)
          | STATS c = COUNT(*) BY UserID, m, SearchPhrase 
          | SORT c DESC
          | LIMIT 10</code></pre><p>Finally, category 4 queries use selective filters and end up being executed exactly, but they run slightly slower because of the work done in the query rewrite stage. Typically, all these queries run fast anyway, so the absolute slowdown is small. On average, they run approximately 14% or 370ms slower than the “without” sampling for our test setup.</p><h3>Limitations and best practices</h3><p>It’s worth explicitly mentioning some limitations. In particular, the following queries are not currently supported:</p><ol><li>Queries using the <code>TS</code> source command.</li><li>Queries using the <code>FORK</code> or <code>JOIN</code> processing command.</li><li>Pipelines which use two or more <code>STATS</code> commands.</li><li>The <code>ABSENT</code>, <code>PRESENT</code>, <code>DISTINCT_COUNT</code>, <code>MIN</code>, <code>MAX</code>, <code>TOP</code>, <code>ST_CENTROID_AGG</code> and <code>ST_EXTENT_AGG</code> aggregation functions.</li></ol><p>We plan to lift some of these restrictions in future releases, such as approximating queries using <code>TS</code>, <code>FORK</code> and <code>JOIN</code>; however, some are intrinsic. For example, while there’s prior art for estimating the <a href="https://en.wikipedia.org/wiki/Generalized_extreme_value_distribution">minimum and maximum</a> of a metric dataset or the count of unique values of a categorical dataset (see, for example, <a href="https://arxiv.org/pdf/2202.02800">this</a> paper), they require making certain distributional assumptions, either explicitly or implicitly. In summary, we view trying to automatically provide estimates of these statistics as being too open to accidental misuse.</p><p>For the expert user, we provide another route: ES|QL supports using the <code>SAMPLE</code> command directly. This allows one to obtain “point estimates” of any query, albeit with no attempt to correct for the impact of sampling or quantify error. For example:</p><pre><code>FROM data | SAMPLE 0.01 | STATS DISTINCT_COUNT(value)</code></pre><p>computes the unique count of the value field on a sample of roughly 1/100th of the dataset. The sample probability can be adjusted to get a sense of how this is asymptoting, or more sophisticated estimation procedures can use <code>STATS COUNT() BY value</code> to estimate the frequency profile of the data.</p><p>There are a couple of cases that are more problematic for sampling. If a very restrictive filter is applied in the query, then sampling is of little value, since few rows match anyway. In such cases, we discover that we’d have to sample too large a proportion of the rows to estimate the query in the rewrite phase. In this case, we revert to running the query without sampling and its result is exact. However, the search procedure to determine the fraction of rows to sample comes with some overhead. One therefore pays a penalty, albeit less than the original query cost, for no benefit. If you know in advance that the query is expected to match relatively few rows, it&#x27;s best to run it without approximation.</p><p>The second case only applies when computing <code>STATS</code> partitioned by some expression. If the cardinality of this expression is very high, then even if many rows are searched, individual statistics may be computed from a small number of rows. Some cases are more problematic than others. Sorting by ascending count, that is, finding the rarest partitions, can be impossible to estimate in a single query if heavy hitters would require us to sample most of the dataset to find them. For this particular case, heavy hitting partitions can be estimated first and sometimes efficiently excluded by updating the query. In general, infrequent partitions may be lost in the sampling process, and their statistics&#x27; estimation errors can be high. It’s worth noting that we won’t attempt to estimate any statistic for which we have fewer than 10 samples, and we simply drop them from the result set. In the case of very high cardinality <code>BY</code> clause, for example, a field whose value is unique for every row, this means the query can return no results. If you find approximate query results are too inaccurate, you have the option to increase the sample size, which by default is 1,000,000 for <code>STATS</code>, which uses grouping and 100,000 otherwise. Currently, this needs to be done manually, and we provide the following API for this:</p><pre><code>SET approximation={"rows":12345678};
FROM data | commands | STATS x=agg(...) | commands</code></pre><p>Occasionally, functions significantly alter the distribution characteristics of the quantities they act on. A contrived example is the following:</p><pre><code>FROM data | STATS sl = SUM(length) | EVAL csl = COS(sl)</code></pre><p>If the variation in the estimate <code>sl</code> is much larger than $2\pi,$ we expect the distribution of <code>csl</code> to be mainly flat in the interval $[-1,1]$ with peaks near both endpoints. In this particular case, it’s not clear that the central confidence interval is a particularly useful concept, since the modes of the distribution lie outside almost all central confidence intervals. In any case, just observing the samples of <code>csl</code>, our standard confidence interval machinery won’t reliably characterize this distribution and it will underestimate the variability of <code>csl</code>. However, our statistical test should detect this problem, and the result won’t be certified.</p><p>Finally, we note that Elasticsearch implements some query optimization strategies that ideally <a href="https://github.com/elastic/elasticsearch/issues/138151">need to account for the fact that sampling is taking place</a>. These rewrite the query at the Lucene level and the preprocessing involved in this rewrite can be relatively expensive. Accelerating an expensive string matching operation by first building a suitable data structure makes sense if the query needs to process every row, but if it processes only a small fraction of them, the trade-off is different. This is something we plan to enhance in future.</p><h3>Conclusions</h3><p>In this blog post, we introduced a new form of query optimization we’re bringing to ES|QL that enables dramatically faster querying by relaxing the constraint that the results are exact. We found on ClickBench that we were able to accurately estimate query values and their confidence intervals up to 100 times faster and values alone up to 250 times faster than we can compute them exactly. Furthermore, we expect this advantage to grow as the dataset size increases, because the approximation accuracy is independent of the dataset size. This feature works with many features of the ES|QL language and is enabled by simply prepending <code>SET approximation=true;</code> to the query to estimate.</p><p>As well as providing a point estimate, we also estimate confidence intervals and indicate whether we think that the underlying assumptions used to compute these are satisfied. This allows us to certify the results if the results are reliable. We explain the theory behind this feature and discuss the testing of its accuracy in our <a href="https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-2">next post</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Is your ML job's datafeed losing a race it cannot win?]]></title>
            <description><![CDATA[Learn how switching from scroll-based to aggregation-based datafeeds optimizes machine learning jobs for large-scale deployments.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-machine-leaning-jobs-aggregation-datafeeds</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-machine-leaning-jobs-aggregation-datafeeds</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Valeriy Khakhutskyy]]></dc:creator>
            <pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/023e3e6cb25891f789129d496c181113cc570f1f-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>On almost every large Elastic deployment I’ve worked with, there’s an Elastic Security or Elastic Observability anomaly detection (AD) job that looks healthy but is perpetually behind. Six hours behind. Twelve. And the gap never closes.</p><p>The datafeed isn’t broken. It’s doing exactly what it was built to do: reading every raw document, across every shard, every run. On a large cluster with cross-cluster search (CCS) and a broad index pattern, like <code>logs-*</code>, that means scanning billions of documents per bucket. There’s no hardware that makes that sustainable. The datafeed will always be chasing live data and never reaching it.</p><p>The fix is to switch from the default <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-scroll"><strong>scroll-based</strong></a> datafeed configuration to an <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/anomaly-detection/ml-configuring-aggregation"><strong>aggregation-based</strong> datafeed configuration</a>: Let the data nodes summarize locally, and ship only compact bucket results to the ML node. Same detections, a fraction of the load. The speedup can be dramatic. More than you might expect. The numbers are in the next section. The explanation for <em>why</em> the gap is so large is at the end of the post, for those who want to understand the mechanics.</p><p>One catch worth knowing now: Switching requires creating a new job. The old model doesn’t transfer; weeks of learned baseline are lost. <strong>The right time to make this switch is before the job has been running for months, not after.</strong> That’s the main reason to read this before you deploy.</p><h2><strong>How much faster? Scroll vs. aggregation datafeeds for ML jobs</strong></h2><p>We ran the same job two ways on production data: first scroll-based, and then aggregation-based. The job covered 13 months of history, monitoring 836,000 log events per hour in 15-minute buckets across multiple clusters.</p><p>Training on historical data with scroll-based configuration: <strong>five days of wall-clock time</strong>, 7.9 million sequential requests, and 3.5 TB transferred; with aggregations: <strong>2.3 minutes</strong>, 23 requests, and 34 MB (a 3,374× speedup). Think of it this way: If you start the scroll backfill at 9 a.m. Monday, it will finish Saturday morning. The aggregation version is done by 9:02 a.m.</p><p>On live data, the difference is less dramatic but still meaningful: around <strong>20×</strong> fewer requests per tick. That adds up quickly when the datafeed runs every few minutes around the clock.</p><h2><strong>Before you start</strong></h2><p>Three things worth knowing before diving into the configuration.</p><p><strong>This isn&#x27;t wizard territory.</strong> The standard Kibana job wizards (Single Metric, Multi-Metric, Population) don&#x27;t expose aggregation configuration. To create an aggregation-based job, you need either the Elasticsearch API or Kibana&#x27;s Advanced Job Wizard, with JSON edited by hand. The worked example below shows the most practical path: Configure the job in the Multi-Metric Wizard, and then click <strong>Convert to advanced job</strong> before creating it. That gets you a prefilled JSON starting point instead of a blank editor.</p><p><strong>The configuration is unforgiving and mostly silent about it.</strong> There&#x27;s no schema validation that catches a misnamed aggregation key or a <code>fixed_interval</code> that doesn&#x27;t match <code>bucket_span</code>. The job will run, anomalies will fire, and nothing will indicate that the results are based on the wrong data. This is why the five-step pattern exists and why the <strong>Preview </strong>tab is worth using every time: Catching a misconfiguration before the job trains is a 30-second check; catching it a week later is a much worse afternoon.</p><p><strong>The Single Metric Viewer has a known limitation with aggregated jobs.</strong> That viewer reconstructs the &quot;actual&quot; data curve by re-querying the index, but it can&#x27;t reproduce an arbitrary, user-defined aggregation, so the actual-value line is typically missing or approximate. The Anomaly Explorer is unaffected: Anomaly scores, swim lanes, and influencer attribution all work normally. Just don&#x27;t rely on the Single Metric Viewer&#x27;s chart for visual validation of what the model saw.</p><h2><strong>What we can and can’t aggregate</strong></h2><p>Almost every <a href="https://www.elastic.co/docs/reference/machine-learning/machine-learning-functions">ML function</a> works with aggregated datafeeds, but the right aggregation pattern depends on the function.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Function</td><td style="border: 1px solid #ccc; padding: 4px;">Pattern</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`count`, `mean`, `high_mean`, `low_mean`, `sum`, `max`, `min`, `varp`</td><td style="border: 1px solid #ccc; padding: 4px;">Standard: `date_histogram` → `terms` → metric aggregation</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`time_of_day`, `time_of_week`</td><td style="border: 1px solid #ccc; padding: 4px;">Minimal: plain `date_histogram`, no `terms` or metric needed</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`rare`, `freq_rare`, `info_content`</td><td style="border: 1px solid #ccc; padding: 4px;">Composite: top-level composite with `date_histogram` as a source</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`categorization`</td><td style="border: 1px solid #ccc; padding: 4px;">`terms` on the `.keyword` subfield of the categorization field</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`lat_long`</td><td style="border: 1px solid #ccc; padding: 4px;">Scroll only</td>
                    </tr>
              </tbody>
            </table>
          <p><code>lat_long</code> is the one genuine exception. The configuration is accepted, but <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-geocentroid-aggregation"><code>geo_centroid</code></a> computes the arithmetic mean of all coordinates in a bucket: If the same entity appears in New York and London within the same bucket, the centroid ends up in the Atlantic Ocean, which probably doesn&#x27;t make sense for the use case. Keep <code>lat_long</code> jobs on scroll-based datafeeds.</p><p>The five-step pattern in the next section covers the standard case. We’ll walk through the remaining patterns at the end of the post.</p><h2><strong>The standard five-step pattern: Scroll-based to aggregation datafeed</strong></h2><p>Converting any scroll-based job to an aggregation-based datafeed follows the same five steps. Once you understand the pattern, applying it to any compatible job takes about 10 minutes.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/77cd16165133374a04dbcf71210ea8d36f66b54f-1999x924.png" style="width: 100%;" /><p><strong>Step 1: Add <code>summary_count_field_name: <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-doc-count-field">&quot;doc_count&quot;</a></code> to the analysis config.</strong> This tells the ML engine that incoming data is pre-summarized. Without it, the engine treats each aggregated bucket as a single raw document and produces wrong anomaly scores.</p><p><strong>Step 2: Choose the bucket wrapper topology.</strong> For most functions (<code>count</code>, <code>mean</code>, <code>sum</code>, <code>max</code>, <code>min</code>, <code>varp</code>, <code>time_of_day</code>, <code>time_of_week</code>, and <code>categorization</code>) use a <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-datehistogram-aggregation"><code>date_histogram</code></a> at the top level whose <code>fixed_interval</code> matches your <code>bucket_span</code> exactly to ensure accurate analysis. For <code>rare</code>, <code>freq_rare</code>, and <code>info_content</code>, use a <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-composite-aggregation">composite</a> at the top level with a <code>date_histogram</code> as one of its sources. This routes the datafeed to the composite extractor, which paginates through all field-value combinations rather than truncating to a top-N.</p><p><strong>Step 3: Add a <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-max-aggregation"><code>max</code></a> aggregation on <code>@timestamp</code>.</strong> The ML engine needs this to determine the precise end time of each bucket. In the standard topology (Step 2, <code>date_histogram</code> outer), it goes inside the histogram’s <code>aggregations</code>. In the composite topology, it sits as a sibling of the <code>composite</code> aggregation.</p><p><strong>Step 4: Map each analysis field to a <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-terms-aggregation"><code>terms</code></a><span style="text-decoration:underline"> aggregation</span></strong>, named exactly after the corresponding field in the analysis config. One categorical field → a single nested <code>terms</code>. Two or more categorical fields → a <code>composite</code> aggregation nested inside the <code>date_histogram</code>, with one <code>terms</code> source per field. For categorization jobs, use a <code>terms</code> aggregation on the <code>.keyword</code> subfield of the <code>categorization_field_name</code>. The naming rule is strict: The aggregation key must exactly match the field name in the analysis config; the ML engine uses the aggregation name, not the <code>field</code> parameter, to look up values. A mismatch produces silently wrong results; no error, just a job that appears to run while missing everything meaningful.</p><p><strong>Step 5: Map each detector’s metric field</strong> to its Elasticsearch aggregation equivalent:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ML function</td><td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch aggregation</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`mean` / `high_mean` / `low_mean`</td><td style="border: 1px solid #ccc; padding: 4px;">`avg`</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`sum`</td><td style="border: 1px solid #ccc; padding: 4px;">`sum`</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`max`</td><td style="border: 1px solid #ccc; padding: 4px;">`max`</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`min`</td><td style="border: 1px solid #ccc; padding: 4px;">`min`</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`varp`</td><td style="border: 1px solid #ccc; padding: 4px;">`extended_stats`</td>
                    </tr>
              </tbody>
            </table>
          <p>For <code>count</code>, <code>rare</code>, <code>freq_rare</code>, <code>info_content</code>, <code>time_of_day</code>, <code>time_of_week</code>, and categorization jobs, the ML engine works from <code>doc_count</code> alone; no metric aggregation is needed, and this step can be skipped.</p><h2><strong>Step-by-step example: Building an aggregation-based ML job in Kibana</strong></h2><p>Let’s build this end to end using Kibana’s sample web logs. If you haven’t loaded them yet, go to the Kibana home page and click <strong>Integrations → Sample data → Sample web logs → Add data</strong>. This gives us a data view called <code>Kibana Sample Data Logs</code> and an index called <code>kibana_sample_data_logs</code> with fields including <code>@timestamp</code>, <code>bytes</code> (response size), and <code>geo.dest</code> (destination country).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/75b692b9f38017cd7e4e221d2e89a14f75d3b9dc-1999x1905.png" style="width: 100%;" /><p>We’ll build a job that detects unusually large response sizes: <code>high_mean of bytes</code>, partitioned by destination country (<code>geo.dest</code>), with a 1-hour bucket span.</p><h3><strong>Creating the job with the Multi-Metric Wizard</strong></h3><p>This is how most jobs get created in practice. Navigate to <strong>Machine Learning → Anomaly Detection → Manage Jobs → Create job</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b5bb2f2770a76fd535db22b97fc4f72471c43ca7-1999x587.png" style="width: 100%;" /><p>Select the “Kibana Sample Data Logs” data view, and set the time range to cover the full sample dataset. On the job type screen, choose <strong>Multi-metric</strong>.</p><p>In the Multi-Metric Wizard, configure the detector:</p><ul><li><strong>High mean</strong> of <code>bytes</code>.</li><li><strong>Split data by</strong> <code>geo.dest</code>.</li><li><strong>Bucket span:</strong> <code>1h</code>.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b3055c91c881e4011521ba0c17cc36c6138595ee-1999x1540.png" style="width: 100%;" /><p>Give the job an ID, and leave everything else at its defaults, but <strong>don’t click Create yet</strong>. On this last configuration step, click on <strong>Preview JSON</strong> and look at the datafeed section. What you’ll see is a plain scroll-based datafeed with no aggregations, just an index pattern and a <code>match_all</code> query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/32a1650958525b03a6052d480152933341acdd41-1999x1392.png" style="width: 100%;" /><p>This is the default every wizard produces. On a small cluster, it works fine. On a large cluster with CCS and a broad index pattern, this datafeed will scan every raw document on every run and never catch up with live data.</p><p>Instead of clicking <strong>Create</strong>, click <strong>Convert to advanced job</strong>. This keeps everything you just configured (the detector, the partition field, the bucket span) and drops you directly into the Advanced Wizard, where we can apply the five-step pattern.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/388d279820a639c08b119753f064b3a948ace8c6-1999x1591.png" style="width: 100%;" /><h3><strong>Analysis configuration</strong></h3><p>The conversion prefills the detector, partition field, and bucket span. The only change needed here is <strong>Step 1</strong> of the pattern: Open the <strong>Edit JSON</strong> view, and add <code>summary_count_field_name</code> to tell the ML engine that incoming data will be pre-summarized:</p><pre><code>{
  "bucket_span": "1h",
  "summary_count_field_name": "doc_count", // Step 1
  "detectors": [
    {
      "function": "high_mean",
      "field_name": "bytes",
      "partition_field_name": "geo.dest"
    }
  ],
  "influencers": ["geo.dest"]
}</code></pre><h3><strong>Datafeed configuration</strong></h3><p>Switch to the <strong>Datafeed</strong> tab. This is where Steps 2 through 5 of the pattern come together. Remove <code>scroll_size</code> if it’s present, and then enter the aggregations:</p><pre><code>{
  "buckets": {
    "date_histogram": {               // Step 2: bucket wrapper, interval = bucket_span
      "field": "@timestamp",
      "fixed_interval": "1h"
    },
    "aggregations": {
      "@timestamp": {                 // Step 3: max timestamp anchor
        "max": { "field": "@timestamp" }
      },
      "geo.dest": {                   // Step 4: partition field, name must match exactly
        "terms": {
          "field": "geo.dest",
          "size": 1000
        },
        "aggregations": {
          "bytes": {                  // Step 5: metric field → avg aggregation
            "avg": { "field": "bytes" }
          }
        }
      }
    }
  }
}</code></pre><p>A few notes on this config:</p><ul><li><strong>Step 2:</strong> The <code>date_histogram</code> uses <code>fixed_interval</code>: <code>&quot;1h&quot;</code>, matching <code>bucket_span</code> exactly. A mismatch produces incorrect bucket timing.</li><li><strong>Step 3:</strong> The <code>max</code> aggregation on <code>@timestamp</code> must be named <code>@timestamp</code> and placed inside the histogram’s <code>aggregations</code>; without it, the ML node can’t determine the precise end of each bucket.</li><li><strong>Step 4:</strong> The <code>terms</code> aggregation for the partition field must be named <strong>exactly</strong> after the partition field: <code>geo.dest</code>, not <code>geo.dest_grouping</code> or any alias. The ML engine uses the aggregation name, not the <code>field</code> parameter, to identify which partition value each bucket belongs to. A mismatch silently drops the partition field from results entirely.</li><li><strong>Step 5:</strong> The metric aggregation key <code>bytes</code> matches <code>field_name</code> in the detector exactly. Any mismatch here produces silently wrong anomaly scores.</li></ul><h3><strong>Validate with the preview</strong></h3><p>Before we create the job, let’s use the <strong>Preview</strong> tab. This runs the aggregation against real data and shows exactly what the ML node will receive, a very useful sanity check before committing.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/638eeb2ae5b854195dec0e468887300b9afd2c58-1999x1254.png" style="width: 100%;" /><p>Three things to verify in the preview output: <code>doc_count</code> should be present on every bucket and greater than 1. The <code>bytes</code> values should look like average response sizes: numbers in the hundreds to hundreds of thousands for web traffic. And each row should correspond to a distinct (<code>timestamp</code>, <code>geo.dest</code>) pair. If anything looks off, fix it in the JSON editor and rerun the preview.</p><h2><strong>Adding influencer fields</strong></h2><p>In the example above, <code>geo.dest</code> is the partition field. The ML model learns a separate baseline for each destination country, and anomalies are reported per country. But you might also want <code>machine.os</code> to appear as an <strong>influencer</strong> in anomaly results: When the detector fires, you want to see “this looks anomalous for <code>geo.dest: CN</code> and <code>machine.os: win</code> is a contributing factor.” <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/anomaly-detection/ml-ad-run-jobs#ml-ad-influencers">Influencers</a> don’t drive anomaly detection; they provide context for the anomalies that are found.</p><p>To support an influencer alongside a partition field, the analysis config gains an <code>influencers</code> array:</p><pre><code>{
  "bucket_span": "1h",
  "summary_count_field_name": "doc_count",
  "detectors": [
    {
      "function": "high_mean",
      "field_name": "bytes",
      "partition_field_name": "geo.dest"
    }
  ],
  "influencers": ["geo.dest", "machine.os"]
}</code></pre><p>And now the datafeed needs to aggregate on both fields simultaneously. One <code>terms</code> nested inside another <code>terms</code> won’t work; a nested <code>terms</code> surfaces only the top-N values of the inner field per outer bucket, so you’d silently lose combinations. Instead, use a <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-composite-aggregation">composite aggregation</a> with one <code>terms</code> source per field, nested inside the <code>date_histogram</code>:</p><pre><code>{
  "buckets": {
    "date_histogram": {
      "field": "@timestamp",
      "fixed_interval": "1h"
    },
    "aggregations": {
      "@timestamp": {
        "max": { "field": "@timestamp" }
      },
      "group_by_fields": {
        "composite": {
          "size": 1000,
          "sources": [
            { "geo.dest":   { "terms": { "field": "geo.dest" } } },
            { "machine.os": { "terms": { "field": "machine.os" } } }
          ]
        },
        "aggregations": {
          "bytes": {
            "avg": { "field": "bytes" }
          }
        }
      }
    }
  }
}</code></pre><p><code>composite</code> generates one bucket per unique (<code>geo.dest</code>, <code>machine.os</code>) combination. The ML node sees every pair and can correctly attribute which operating system was contributing when a country’s response sizes spiked. Use the preview to confirm distinct pairs appear. If you only see a handful of rows where you’d expect many, the <code>size</code> parameter on the composite may need to be raised.</p><p>Note that this <code>composite</code> is nested <em>inside</em> the <code>date_histogram</code>, a different structure from the top-level composite used for <code>rare</code>, <code>freq_rare</code>, and <code>info_content</code> below. The distinction matters: Composite nested inside <code>date_histogram</code> routes the datafeed to the standard extractor; composite at the top level routes it to the composite extractor, which paginates through all value combinations across time.</p><h2><strong>Categorization</strong></h2><p>Categorization works with aggregated datafeeds: <code>summary_count_field_name</code> and <code>categorization_field_name</code> can coexist in the same job. The five-step pattern applies directly. Step 2 uses the standard <code>date_histogram</code> topology. Step 4 has one adjustment: Instead of a partition field, we aggregate the text field itself using a <code>terms</code> aggregation on its <code>.keyword</code> subfield, named to match <code>categorization_field_name</code> exactly. Step 5 is skipped. The <code>count</code> detector works from <code>doc_count</code> alone.<br/><strong>Analysis config:</strong></p><pre><code>{
  "bucket_span": "1h",
  "summary_count_field_name": "doc_count",
  "categorization_field_name": "message",
  "detectors": [
    {
      "function": "count",
      "by_field_name": "mlcategory"
    }
  ],
  "influencers": ["mlcategory"]
}</code></pre><p><strong>Datafeed aggregations:</strong></p><pre><code>{
  "buckets": {
    "date_histogram": {
      "field": "@timestamp",
      "fixed_interval": "1h"
    },
    "aggregations": {
      "@timestamp": {
        "max": { "field": "@timestamp" }
      },
      "message": {
        "terms": {
          "field": "message.keyword",
          "size": 1000
        }
      }
    }
  }
}</code></pre><p>The datafeed sends one bucket per unique <code>message.keyword</code> value with a <code>doc_count</code> for each. The ML node receives those strings, runs categorization on them, assigning an <code>mlcategory</code> to each, and the <code>count</code> detector tracks how many documents fall into each category per bucket. The naming rule from Step 4 applies: The <code>terms</code> aggregation must be named <code>message</code>, matching <code>categorization_field_name</code> in the analysis config exactly.</p><p>One thing to watch: Keyword fields have a default <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/ignore-above"><code>ignore_above: 256</code></a> limit. Log messages longer than 256 characters won’t be indexed as <code>.keyword</code> and will be silently excluded from the aggregation. If your log messages are long, check the field mapping before using this approach. You may need to raise the limit in your index template.</p><h2><strong>The minimal pattern for <code>time_of_day</code> and <code>time_of_week</code></strong></h2><p><a href="https://www.elastic.co/docs/reference/machine-learning/ml-time-functions"><code>time_of_day</code> and <code>time_of_week</code></a> are the easiest functions to aggregate: They only need a timestamp and a document count. The C++ process extracts the time component from the bucket timestamp and builds a cyclical model of normal activity; <code>doc_count</code> tells it how many events fell in each bucket. No <code>terms</code> sources, no metric aggregation, no composite.<br/><strong>Analysis config:</strong></p><pre><code>{
  "bucket_span": "15m",
  "summary_count_field_name": "doc_count",
  "detectors": [
    { "function": "time_of_day" }
  ]
}</code></pre><p><strong>Datafeed aggregations:</strong></p><pre><code>{
  "time": {
    "date_histogram": {
      "field": "@timestamp",
      "fixed_interval": "15m"
    },
    "aggregations": {
      "@timestamp": { "max": { "field": "@timestamp" } }
    }
  }
}</code></pre><p>A plain <code>date_histogram</code> is enough; no composite needed. This makes <code>time_of_day</code> and <code>time_of_week</code> particularly CCS-friendly: one request per time chunk, minimal data over the wire. Use the same structure for <code>time_of_week</code>; only the function name changes.</p><p>If you want to add a <code>partition_field_name</code> (for example, to model time-of-day patterns per service), add a <code>terms</code> aggregation inside the histogram’s aggregations following the standard Step 4 pattern.</p><h2><strong>The composite pattern for <code>rare</code>, <code>freq_rare</code>, and <code>info_content</code></strong></h2><p><a href="https://www.elastic.co/docs/reference/machine-learning/ml-rare-functions"><code>rare</code>, <code>freq_rare</code></a>, and <a href="https://www.elastic.co/docs/reference/machine-learning/ml-info-functions"><code>info_content</code></a> all need the composite extractor, the one that paginates through all unique value combinations rather than truncating to top-N. The five-step pattern applies here with a different topology in Step 2: <code>composite</code> goes at the top level (not <code>date_histogram</code>), with <code>date_histogram</code> as a source inside it. Step 3 places the <code>max</code> <code>@timestamp</code> aggregation as a sibling of the <code>composite</code>, and Step 5 is skipped since all three functions work from <code>doc_count</code> alone.</p><p>The datafeed structure is the same for all three functions: a composite at the top level, a <code>date_histogram</code> as one of its sources, and one <code>terms</code> source per analysis field. The only thing that varies is which fields you include as <code>terms</code> sources: <code>rare</code> needs one source for <code>by_field_name</code>; <code>freq_rare</code> needs sources for both <code>by_field_name</code> and <code>over_field_name</code>; <code>info_content</code> needs a source for <code>field_name</code> plus any <code>by_field_name</code> or <code>over_field_name</code> fields. None of the three require a metric aggregation.</p><pre><code>{
  "buckets": {
    "composite": {
      "size": 10000,
      "sources": [
        { "@timestamp":   { "date_histogram": { "field": "@timestamp", "fixed_interval": "5m" } } },
        { "by_field":     { "terms": { "field": "by_field" } } },
        { "over_field":   { "terms": { "field": "over_field" } } }
      ]
    },
    "aggregations": {
      "@timestamp": { "max": { "field": "@timestamp" } }
    }
  }
}</code></pre><p>A few notes:</p><ul><li>The composite aggregation must be the top-level aggregation, not nested inside a <code>date_histogram</code>. This is what routes the datafeed to the composite extractor.</li><li>The <code>date_histogram</code> is a source inside the composite, not the outer wrapper. Its <code>fixed_interval</code> must divide evenly into <code>bucket_span</code>.</li><li>The <code>max</code> aggregation on <code>@timestamp</code> sits as a sibling of the <code>composite</code> (inside <code>aggregations</code>), not nested inside it.</li><li><code>composite.size</code> controls the page size per round trip. Setting it high (10000) reduces round trips, which matters with CCS latency. With three sources and high-cardinality fields, the total combination count can be large; the extractor paginates automatically.</li></ul><h2><strong>Why aggregation-based datafeeds outperform scroll at scale</strong></h2><p>The gap is structural, not incidental. A scroll-based datafeed reads raw documents one page at a time: Every 1,000 documents is one request, and each waits for the previous one to complete before issuing the next. The number of requests is therefore proportional to the total document count in the time range being backfilled. At 836,000 events per hour over 13 months, that&#x27;s roughly 7.9 billion events, or 7.9 million sequential round trips. Each round trip crosses the CCS boundary, waits for shard responses, and transfers matching documents in full. There’s no parallelism: The datafeed holds a <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-scroll">scroll context</a> open on the remote cluster and processes one page at a time.</p><p>An aggregation-based datafeed works differently. The data nodes summarize data locally, grouping by time bucket and categorical fields, and ship only the bucket results to the ML node. The number of requests is proportional to field cardinalities, not document count. In our example, two influencer fields with six unique combinations produce six result rows per time bucket; the datafeed pages through those in a handful of requests regardless of how many raw events fall in each bucket. Double the ingestion rate and the scroll request count doubles; the aggregation request count stays the same. This is why the gap widens at scale: The more data you have, the worse scroll looks by comparison, and the better aggregations look.</p><p>On live data, the picture is different because each real-time tick covers only one fresh bucket: Scroll issues however many pages fit in that bucket&#x27;s worth of data, while aggregations issue one request. The 20× figure for live data reflects that ratio at 836,000 events per hour with a 15-minute bucket span. The practical threshold where aggregations stop being optional is when <code>(ingestion rate × bucket span) &gt; scroll_size</code>; once a single bucket contains more than <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/anomaly-detection/anomaly-detection-scale#set-scroll-size">one scroll page</a> of documents, the datafeed can&#x27;t keep pace with live data regardless of hardware. Below that threshold, scroll is fine and aggregations are a nice-to-have. Above it, aggregations are the only sustainable option.</p><p>Scroll-based datafeeds are the right default, and the wizards make the right call for most deployments. At scale (more shards, broader index patterns, CCS across tiers), switching to an aggregation-based datafeed is the natural next step: The data nodes summarize where the data lives, the ML node processes compact results, and the detections stay the same. The one cost to know up front is model state: Switching requires a new job, so the earlier you make the move, the less you give up.</p><p>If you hit a case not covered here, an aggregation type that doesn’t map cleanly or a composite that behaves unexpectedly, the <a href="https://discuss.elastic.co/">Elastic Discuss forums</a> are a good place to continue.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How big is too big? Elasticsearch sizing best practices]]></title>
            <description><![CDATA[There’s no hard size limit in Elasticsearch, but there are clear signals you've outgrown your setup. Learn how to size shards, manage node limits, choose storage by tier, and use AutoOps to catch problems before they happen.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-node-shard-size-best-practices</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-node-shard-size-best-practices</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
            <pubDate>Tue, 14 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2576886b8938e8265c6fafc9dcee6a4442f6a2fa-1408x768.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>There’s no hard size limit in Elasticsearch. Production clusters run at petabyte scale. But &quot;too big&quot; shows up in three ways: queries slow past your Service Level Agreement (SLA), a node hits its shard ceiling, or storage costs spiral because everything lives on the same expensive tier. This guide walks through each signal, the numbers that matter, and what to do about them.</p><h2>The three limits that actually matter</h2><p>There’s no hard storage ceiling at the node level. Elastic has demonstrated <a href="https://www.elastic.co/blog/querying-a-petabyte-of-cloud-storage-in-10-minutes">a single node querying 1 PiB of data</a>. In earlier versions, the per-shard overhead was high enough that the old rule of thumb was <a href="https://github.com/elastic/elasticsearch/pull/86223">no more than 20 shards per GB of heap</a>. Exceeding that limit meant garbage collection pressure, slow cluster state updates, and unstable nodes. Over 7.x and 8.x, a <a href="https://github.com/elastic/elasticsearch/issues/77466">series of optimizations</a> (more compact metadata serialization, efficient caching, off-heap data structures, and compressed cluster state) reduced per-shard overhead to the point where that rule was retired in 8.3, replaced by <a href="https://www.elastic.co/blog/whats-new-elasticsearch-8-3-0">field-density-based sizing</a>.</p><p>What defines your actual ceiling is workload type. Cold nodes at 20 TB with 31 GB of heap handle audit and retention workloads comfortably, because the access pattern is infrequent and aggregation-based. The same spec on a high-concurrency document search workload would struggle.</p><p>The three things to watch operationally:</p><ul><li><strong>Shard size:</strong> Individual shards that are too large slow queries and recovery.</li><li><strong>Shards per node:</strong> Every node has a ceiling, and <a href="https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management">index lifecycle management</a> (ILM) creates shards automatically whether you track them or not.</li><li><strong>Storage tier mismatch:</strong> Keeping data on expensive fast storage longer than needed.</li></ul><h2>Shard size</h2><p>Target between 10 GB and 50 GB per shard. The <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards">official guidance</a> sets the ILM rollover trigger at 50 GB per primary shard, with 10 GB as the suggested floor. Keep each shard under 200 million documents.</p><p>Shards that are too small create unnecessary overhead: more metadata for master nodes, more heap consumed, more network traffic. Shards that are too large slow query execution and make recovery after node failure slow, since Elasticsearch recovers one shard at a time.</p><p>One rule you can stop using: The &quot;20 shards per GB of heap&quot; guideline was deprecated in Elasticsearch 8.3. The replacement is simpler: Watch the 1,000 shard-per-node limit below, and keep shard sizes in the <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards#shard-size-recommendation">10–50 GB range (or 200M documents)</a>.</p><p><strong>How to monitor:</strong></p><pre><code># size per shard
GET _cat/shards?&h=index,store&v</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e4820733cc108ee5a78c7acafcbdf882f09ef661-1290x150.png" style="width: 100%;" /><h2>The shard budget</h2><p>Each non-frozen data node supports up to <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards#shard-count-per-node-recommendation">1,000 shards</a>. ILM creates shards on your behalf. If your policy rolls over daily with five primary shards and one replica, that’s 10 shards per day. One node fills up in about 100 days without you changing anything.</p><p>Options when you’re approaching the ceiling:</p><ul><li><strong>Wider rollover intervals:</strong> Roll weekly or monthly if shards are not reaching 50 GB before the time trigger fires.</li><li><strong>Fewer shards per index:</strong> For smaller daily volumes, one or two primary shards is often enough. See <a href="https://www.elastic.co/search-labs/blog/elasticsearch-increase-primary-shard-count">how to increase primary shard count</a> if you need to rebalance an existing index.</li><li><strong>More nodes:</strong> If volume genuinely requires daily rollover at full shard count, distribute across more nodes.</li></ul><p>For master nodes, plan for <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards#shard-count-recommendation">1 GB of heap per 3,000 indices</a>.</p><p><strong>How to monitor:</strong></p><pre><code># shards per node
GET _cat/allocation?h=node,shards&v</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a0060038cbe02e0dc35c2a24112c82f50e2da6ee-474x98.png" style="width: 100%;" /><h2>Storage</h2><p>The <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.html">search speed guide</a> recommends allocating at least half of system memory to the OS filesystem cache and using directly attached storage. Remote storage generally performs worse. The <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html">indexing speed guide</a> echoes this, recommending RAID 0 across multiple local SSDs for write-heavy workloads.</p><p>For hot data: Don’t use network-attached storage (NAS). NAS adds latency on every read, and some NAS systems don’t correctly implement POSIX filesystem semantics, which can cause data corruption. Use local SSDs.</p><p>What works at each tier:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tier</td><td style="border: 1px solid #ccc; padding: 4px;">Storage</td><td style="border: 1px solid #ccc; padding: 4px;">Why</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hot</td><td style="border: 1px solid #ccc; padding: 4px;">Local SSD (DAS)</td><td style="border: 1px solid #ccc; padding: 4px;">High I/O, low latency, safe filesystem semantics</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Warm</td><td style="border: 1px solid #ccc; padding: 4px;">HDD acceptable</td><td style="border: 1px solid #ccc; padding: 4px;">Lower query pressure, no active indexing</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Cold</td><td style="border: 1px solid #ccc; padding: 4px;">Searchable snapshots</td><td style="border: 1px solid #ccc; padding: 4px;">No replicas needed, ~50% storage savings</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Frozen</td><td style="border: 1px solid #ccc; padding: 4px;">Searchable snapshots</td><td style="border: 1px solid #ccc; padding: 4px;">Up to 20x reduction vs warm (Enterprise license)</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>How to monitor:</strong></p><pre><code># disk usage per node and role
GET _cat/allocation?h=node,node.role,disk.used,disk.avail,disk.percent&v</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8fab2b7d44f4b5b11b89920e9b5ba49eeb48906c-1100x106.png" style="width: 100%;" /><p><em>On Elastic Cloud, skip this section. You select a <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/ec-change-hardware-profile">hardware profile</a> per tier, and Elastic handles storage provisioning.</em></p><h2>Data tiers and ILM</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f8e5dbbc0bbef7cb35abc0f0e82367024cb68730-1999x301.png" style="width: 100%;" /><p>Index lifecycle management moves data through <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/data-tiers.html">tiers</a> automatically: hot, warm, cold, frozen, delete. The further data moves from hot, the cheaper the storage.</p><p>The cold and frozen tiers use <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/searchable-snapshots.html">searchable snapshots</a>:</p><ul><li><strong>Cold</strong> (fully mounted): Performance comparable to a regular index, no replicas required, roughly 50% cheaper than warm.</li><li><strong>Frozen</strong> (partially mounted): Up to 20x storage reduction compared to warm, slower queries, requires an Enterprise license.</li></ul><p>The cost difference at scale is significant. A <a href="https://www.elastic.co/search-labs/blog/searchable-snapshots-benchmark">Search Labs benchmark</a> measured 90 TB of data: all-hot cost $28,222 per month. A hot+frozen architecture brought that to $3,290 per month.</p><p>A typical ILM policy for time-series data with a 14-day hot window:</p><pre><code>{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": { "max_primary_shard_size": "50gb" }
        }
      },
      "warm": {
        "min_age": "14d",
        "actions": {
          "shrink": { "number_of_shards": 1 }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "searchable_snapshot": {
            "snapshot_repository": "my_repository"
          }
        }
      },
      "frozen": {
        "min_age": "90d",
        "actions": {
          "searchable_snapshot": {
            "snapshot_repository": "my_repository"
          }
        }
      },
      "delete": {
        "min_age": "365d",
        "actions": { "delete": {} }
      }
    }
  }
}</code></pre><p>Adjust <code>min_age</code> values to match your query patterns. Data queried weekly can move to cold sooner than data queried daily.</p><h2>AutoOps</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0dd56a74b148bcd95d1cb47eaaa45d99239455a9-1999x670.png" style="width: 100%;" /><p>As of February 2026, <a href="https://www.elastic.co/blog/autoops-free">AutoOps is free for all Elasticsearch users</a> regardless of license tier. On Elastic Cloud, it’s already enabled. For Elastic Self-Managed, Elastic Cloud Enterprise (ECE), and Elastic Cloud on Kubernetes (ECK) deployments, a lightweight Elastic Agent connects your cluster in about five minutes via <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops">Cloud Connect</a>. Internet connectivity is required; air-gapped deployments are not supported.</p><p>AutoOps samples hundreds of metrics every 10 seconds and surfaces issues with root cause analysis and remediation commands. It does not apply fixes automatically.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/16ec78498b40709c38eab2fa78128d11d5a59a6c-1340x546.png" style="width: 100%;" /><p>For large deployments it detects:</p><p></p><ul><li>Shards growing past the recommended size range.</li><li>Indices without ILM policies that have grown too large.</li><li>Shard imbalance across nodes.</li><li>Disk watermark violations before they cause allocation failures.</li><li>Indexing rejections and ingestion bottlenecks.</li><li>Slow queries and circuit breaker trips from large aggregations.</li></ul><p>It ships with 100+ customizable alerts and routes notifications to PagerDuty, Slack, Teams, or any webhook.</p><h2>Conclusion</h2><p>Watch shard size (10–50 GB), track your per-node shard budget as ILM rolls, put hot data on local SSDs, and use cold and frozen tiers for data that is rarely queried.</p><p>On Elastic Cloud, hardware profiles and AutoOps handle most of this for you. For self-managed deployments, this is your checklist, and AutoOps via Cloud Connect is your early warning system. If you’re unsure how much data your nodes can handle for your specific workload, use <a href="https://github.com/elastic/rally">Rally</a> to benchmark against your own data before committing to a hardware spec.</p><h2>Sources</h2><ul><li><a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards">Size your shards</a></li><li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/data-tiers.html">Data tiers</a></li><li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-shards-and-replicas-guide">Elasticsearch shards and replicas guide</a></li><li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-shards-reduce-change-count">How to reduce shard count</a></li><li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-increase-primary-shard-count">How to increase primary shard count</a></li><li><a href="https://www.elastic.co/search-labs/blog/optimize-elasticsearch-disk-space-and-usage">Optimize disk space and usage</a></li><li><a href="https://www.elastic.co/search-labs/blog/searchable-snapshots-benchmark">Searchable snapshots benchmark</a></li><li><a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops">AutoOps documentation</a></li><li><a href="https://github.com/elastic/rally">Rally</a>: Elastic&#x27;s benchmarking framework for testing cluster sizing against your own data</li><li><a href="https://www.elastic.co/virtual-events/optimizing-storage-efficiency-elasticsearch">Optimizing storage efficiency in Elasticsearch</a> webinar by Christian Dahlqvist and Alan Woodward</li><li><a href="https://www.elastic.co/virtual-events/using-rally-to-get-elasticsearch-cluster-size-right">Using Rally to get your cluster size right</a>: webinar by Christian Dahlqvist and Daniel Mitterdorfer on benchmarking methodology</li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[New Elasticsearch ES|QL plugin for IntelliJ IDEA]]></title>
            <description><![CDATA[Build and run Elasticsearch ES|QL queries in your IDE with the new plugin for IntelliJ IDEA.]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-plugin-intellij-idea</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-plugin-intellij-idea</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Laura Trotta]]></dc:creator>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/91366de35a1b66860ce0d126c8a83e5b25b678f0-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> is Elasticsearch’s piped query language, designed for intuitive data querying and manipulation. Refer to our <a href="https://www.elastic.co/blog/getting-started-elasticsearch-query-language">getting started guide</a> to learn more.</p><p>The Elasticsearch Java client <a href="https://www.elastic.co/search-labs/blog/esql-queries-to-java-objects">supports ES|QL queries</a> through the DSL, but currently it treats queries as simple strings, with no dedicated helper; and while <a href="https://www.elastic.co/kibana">Kibana</a> offers an excellent <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql-kibana">UI to build ES|QL queries</a>, we’re aware that sometimes having everything needed to write applications in the integrated development environment (IDE) offers a better experience. So, until the Java client extends its type support to ES|QL, we wrote an Intellij IDEA plugin that autocompletes, syntax checks, shows documentation, and executes ES|QL queries.</p><p>The plugin currently supports Java, Kotlin, and plain text files, in case the Java Virtual Machine (JVM) isn’t your thing.</p><p>Check it out in the <a href="https://plugins.jetbrains.com/plugin/28898-elasticsearch-es-ql">JetBrains Marketplace page</a> and in the <a href="https://github.com/elastic/esql-idea-plugin">GitHub repository</a>, for more information.</p><h2>Prerequisites</h2><ul><li>IDE: Intellij IDEA version &gt;= 253 (community or ultimate)</li></ul><h2>Usage</h2><p>Install the plugin in Intellij IDEA like you would with every other plugin, so either from the <a href="https://plugins.jetbrains.com/plugin/28898-elasticsearch-es-ql">JetBrains marketplace</a> or by going to Settings -&gt; Plugins -&gt; Marketplace and searching “esql”.</p><p>The following examples are written using Java, but Kotlin is also supported and the usage is pretty much the same.</p><p>Create a text block string, write “ES|QL” in a simple comment above it, and you’re done.</p><pre><code>// ES|QL
String query = """
""";</code></pre><p>If you see the Elastic logo appearing on the left:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/620d47b0c241271ab9bf727c37d3ab5f4137ca44-417x55.png" style="width: 100%;" /><p>then everything is working, and you’re ready to write your queries.</p><p>Why text blocks and not simple strings? The ES|QL syntax accepts quotes in various contexts, and escaping them would trigger other errors in the syntax checker, so we decided on text blocks to keep things simple.</p><p>It’s even simpler for txt files, as you can just add the comment and start writing the query right below:</p><pre><code>// ES|QL</code></pre><h3>Connecting to a server instance</h3><p>The plugin can be connected to an Elasticsearch server instance to fetch indices and field names, which will then be added to the autocompletion options. Look for the Elastic logo on the bottom left of of the screen (or wherever you keep your tools), and configure your connection to any server instance:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9f5109542c921ad523458b9156551bf1fca7d41a-418x269.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9fe37a53d97f67cbdd8623793bce768e8e2f9ced-577x298.png" style="width: 100%;" /><h3>Autocomplete</h3><p>Start typing while in the text block to automatically open the autocompletion popup, which will return a list of acceptable commands/values to continue writing the query correctly. If you want to manually trigger autocompletion, <code>ctlr+space</code> is the IDE’s shortcut to use:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/987a927ab0e682bb1f9d07c934dd4254a769db20-584x252.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5f8e379ea1cca345b38d0b7a0c2a873db6de624f-584x252.png" style="width: 100%;" /><h3>Syntax check</h3><p>The plugin will highlight errors in queries, explaining what to fix:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/15e91eab6c00fb49cfa5c1c6e270beeba534afc3-812x252.png" style="width: 100%;" /><h3>Documentation</h3><p>Hovering with the cursor over commands will display documentation describing what the command can be used for and its correct syntax:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c78295103c2188a3615edb2e001acbaf17523656-1072x627.png" style="width: 100%;" /><h3>Running the query</h3><p>Once connected to a server instance, you can run queries by clicking on the green button beside the Elastic icon: The results will be displayed in the tool window:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bff17bee7269dfb6f30140c9cd78dc203990f21c-1130x441.png" style="width: 100%;" /><p>Or if you’re writing an application, you can use the Java client like so:</p><pre><code>// ES|QL
String query = """
	FROM my-index
| SORT year DESC
| LIMIT 10
""";

try (ElasticsearchClient client = ElasticsearchClient.of(e -> e
                .host(serverUrl)
                .apiKey(apiKey))) {

client.esql().query(QueryRequest.of(qr -> qr.query(query)));

}</code></pre><p>Check our previous <a href="https://www.elastic.co/search-labs/blog/esql-queries-to-java-objects">ES|QL Java Client article</a> for a complete example of mapping ES|QL results to Java objects.</p><h2>How does it work?</h2><p>There’s no AI involved; the plugin is based on the ES|QL <a href="https://www.antlr.org/">ANTLR</a> grammar for autocompletion and syntax check, and it uses the <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch docs</a> to show documentation.</p><h2>Conclusion</h2><p>The plugin is still experimental, so feel free to report any bug or feature request on the <a href="https://github.com/elastic/esql-idea-plugin">Github repository</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Unsupervised document clustering with Elasticsearch + Jina embeddings]]></title>
            <description><![CDATA[A practical, reproducible approach to unsupervised document clustering with Elasticsearch and Jina embeddings.]]></description>
            <link>https://www.elastic.co/search-labs/blog/unsupervised-document-clustering-elasticsearch-jina-embeddings</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/unsupervised-document-clustering-elasticsearch-jina-embeddings</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
            <pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/662c00694c3e0c2fb2128098bdb6813df9e86a72-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Vector search starts with a query, but what if you don&#x27;t have one?</p><p>Organizations accumulate large document collections, like support tickets, legal filings, news feeds, research papers, and need to understand what&#x27;s in them before they can ask the right questions. Without labels or training data, manually reviewing thousands of documents is impractical. Traditional search doesn&#x27;t help when you don&#x27;t know what to search for.</p><p>This post walks through an Elasticsearch-native approach to unsupervised document clustering and temporal story tracking that addresses this discovery problem. By the end, you can trace story arcs like this across days:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/100a60a7fb85da8ab3813fd071a82c93f2c3f318-1300x650.png" style="width: 100%;" /><p><strong>What you&#x27;ll discover:</strong></p><ul><li>Why <strong>clustering embeddings</strong> (not retrieval embeddings) matter when you want topic discovery without a query.</li><li>How density-probed centroid classification groups documents by topic using Elasticsearch k-nearest neighbor (kNN) and batched <code>msearch</code>.</li><li>How <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-significanttext-aggregation"><code>significant_text</code></a> can auto-label clusters so themes are readable without training a model.</li><li>How temporal story chains link daily clusters to show how themes evolve from day to day.</li></ul><div style="display:none">Unknown block type "infoPanel", specify a component for it in the `components.types` option</div><p>The pipeline uses ~8,500 February 2025 articles from BBC News and The Guardian as a test corpus. News is convenient because it has clear temporal behavior, but the pattern applies anywhere document discovery matters: legal review, compliance monitoring, research synthesis, customer support triage.</p><p><strong>Stack:</strong></p><ul><li><a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text"><strong>Jina v5</strong></a> <strong>clustering embeddings:</strong> Task-specific Low-Rank Adaptation (LoRA) adapters for topic grouping. <a href="https://www.elastic.co/blog/elastic-jina-ai">Jina has joined Elastic</a>, and its models are available natively through <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a>.</li><li><strong>Elasticsearch:</strong> Scalable <a href="https://www.elastic.co/docs/solutions/search/vector/knn">kNN</a>, <code>significant_text</code> labeling, and vector storage.</li><li><a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction"><strong>DiskBBQ:</strong></a> A disk-based vector index format that combines <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/bbq">Better Binary Quantization (BBQ)</a> with hierarchical k-means partitioning for approximate nearest neighbors (ANN) acceleration. This index partitioning is internal to vector search and separate from the density-probed clustering algorithm used in this post. <code>bbq_disk</code> stores quantized vectors on disk and keeps only partition metadata in heap, dramatically reducing resource requirements, compared to <code>bbq_hnsw</code>, while maintaining high recall.</li><li><strong>Global clustering + daily temporal linking:</strong> Discovery and story evolution.</li></ul><p><strong>What you&#x27;ll need:</strong></p><ul><li>An Elasticsearch deployment (Elastic Cloud, Elasticsearch Serverless, or Elastic Self-Managed 8.18+/9.0+): <code>bbq_disk</code> requires 8.18 or later. The optional diversify retriever section requires 9.3+ or serverless.</li><li>A <a href="https://jina.ai/embeddings/">Jina API key</a>: The free tier includes 10 million tokens, which covers the core clustering pipeline (~4.25 million tokens). The optional retrieval-versus-clustering comparison uses a second embedding pass.</li><li>A <a href="https://bonobo.capi.gutools.co.uk/register/developer">Guardian API key</a> (free).</li></ul><h2>Setup</h2><p>Install required packages:</p><pre><code>pip install elasticsearch pandas numpy plotly umap-learn python-dotenv pydantic-settings datasets requests</code></pre><p>Optional (only if you run scraping helpers from this repo):</p><pre><code>pip install beautifulsoup4</code></pre><p>Then configure API keys in a <code>.env</code> file at the project root:</p><pre><code>ELASTIC_CLOUD_ID=your-cloud-id        # or ELASTIC_HOST=https://...
ELASTIC_API_KEY=your-api-key
JINA_API_KEY=your-jina-key
GUARDIAN_API_KEY=your-guardian-key</code></pre><p>This notebook calls <code>load_dotenv(override=True)</code>, so local <code>.env</code> values take precedence.</p><pre><code>Connected to Elasticsearch</code></pre><h2>Part 1: Discovery clustering - Why clustering embeddings?</h2><p>Most vector search uses <strong>retrieval embeddings</strong> trained to match a <em>query</em> to relevant <em>documents</em>. That&#x27;s perfect for search, but not for discovery. When you want to find what topics exist in a corpus without any query at all, you need embeddings that group similar documents together.</p><p>Jina v5 solves this with <strong>task-specific Low-Rank Adaptation (LoRA) adapters</strong>. LoRA adds small low-rank updates to targeted internal layers while keeping most base-model weights frozen, so the model behavior shifts toward a specific task without full retraining. The same base model produces different embeddings depending on the <code>task</code> parameter:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Task</td><td style="border: 1px solid #ccc; padding: 4px;">Trained for</td><td style="border: 1px solid #ccc; padding: 4px;">Use case</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">retrieval.passage</td><td style="border: 1px solid #ccc; padding: 4px;">Query-document matching</td><td style="border: 1px solid #ccc; padding: 4px;">Search, retrieval augmented generation (RAG)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">clustering</td><td style="border: 1px solid #ccc; padding: 4px;">Topic grouping (optimized for tight clusters)</td><td style="border: 1px solid #ccc; padding: 4px;">Discovery, categorization</td>
                    </tr>
              </tbody>
            </table>
          <p>The clustering adapter is trained to make documents about the same topic <em>closer</em> in embedding space and documents about different topics <em>further apart</em>. The visual comparison below makes the difference concrete.</p><h3>Retrieval vs. clustering: A visual comparison</h3><p>To see the difference, a sample of documents is embedded with both task types. Clustering is performed in the original 1024-dimensional embedding space; Uniform Manifold Approximation and Projection (UMAP) is used only to project those embeddings into 2D for visualization. UMAP preserves local neighborhood structure, making it useful for comparing cluster separation.</p><p>Below, the same 480-document sample is embedded with both task types and projected to 2D with UMAP. Look for tighter, more separated color groups in the clustering panel.</p><pre><code>    Full dataset: 8,495 articles
    Sources: guardian: 5749, bbc: 2746
    Date range: 2025-02-01 to 2025-02-28


    Sample: 480 docs across 8 sections
    section
    Film              60
    World news        60
    Australia news    60
    Opinion           60
    Football          60
    US news           60
    Sport             60
    Business          60


    Clustering embeddings: 480
    Retrieval embeddings:  480


    UMAP projection complete</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9bcf7a744900560c1c6c63a2dc3af2f9bfd33e11-1100x500.png" style="width: 100%;" /><p><em>Retrieval embeddings (left) spread topics broadly; clustering embeddings (right) produce tighter, more separated groups from the same documents.</em></p><p>The clustering embeddings produce tighter, more visually distinct groups. The retrieval embeddings spread topics out more evenly, ideal for search (fine-grained similarity); but for discovery, tight topical clusters are what matters.</p><p>This is why <code>task=&quot;clustering&quot;</code> is used for the rest of this walkthrough.</p><h3>Loading the dataset</h3><p>The corpus combines two news sources for February 2025:</p><ul><li><strong>BBC News</strong> via the <a href="https://huggingface.co/datasets/RealTimeData/bbc_news_alltime">RealTimeData/bbc_news_alltime</a> HuggingFace dataset.</li><li><strong>The Guardian</strong> via the <a href="https://open-platform.theguardian.com/">Guardian Open Platform API</a>.</li></ul><p>Having multiple sources helps validate that clustering finds <em>topics</em> rather than <em>source-specific style</em>.</p><pre><code>    Total articles:  8,495
    
    Source breakdown:
    source
    guardian    5749
    bbc         2746
    
    Date range: 2025-02-01 → 2025-02-28
    Days covered: 28
    
    Sample article:
      Source:  guardian
      Title:   Carbon monoxide poisoning ruled out in death of Gene Hackman and wife, police sa
      Section: Film
      Text:    Authorities have ruled out that Gene Hackman and his wife, Betsy Arakawa, died from carbon monoxide poisoning earlier this week in their home in Santa Fe, New Mexico. The Santa Fe county sheriff, Adan...</code></pre><h3>Embedding with the clustering task</h3><p>The Jina v5 API is called with <code>task=&quot;clustering&quot;</code> for all documents. Embeddings are cached to disk, so subsequent runs skip the API entirely.</p><p>The API call is straightforward. The <code>task</code> parameter is the key difference from typical embedding usage:</p><pre><code>payload = {
    "model": "jina-embeddings-v5-text-small",
    "input": texts,
    "task": "clustering",  # ← This selects the clustering LoRA adapter
}</code></pre><p>The timing below reflects a cache hit. First run against the API takes longer, depending on corpus size.</p><pre><code>    Embeddings ready: 8,495 vectors of dimension 1024
    Time: 0.6s</code></pre><h3>Indexing into a single Elasticsearch index</h3><p>For discovery clustering, the full month goes into one index (<code>docs-clustering-all</code>). Daily partitioning comes later for temporal story linking.</p><p>The index mapping uses <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/bbq"><code>bbq_disk</code></a> for the vector field:</p><pre><code>{
  "embedding": {
    "type": "dense_vector",
    "dims": 1024,
    "index": true,
    "similarity": "cosine",
    "index_options": {
      "type": "bbq_disk"        // hierarchical k-means partitioning for ANN index lookup; separate from this post's clustering algorithm
    }
  }
}</code></pre><p>A 1024-dimensional float32 vector is 4 KB. <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction"><code>bbq_disk</code></a> uses hierarchical k-means to partition vectors into small clusters, binary-quantizes them, and stores the full-precision vectors on disk for rescoring. Only partition metadata lives in heap, so memory requirements stay low even for large corpora. For workloads that can afford more heap, <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/bbq"><code>bbq_hnsw</code></a> builds a Hierarchical Navigable Small World (HNSW) graph for faster lookups at higher resource cost.</p><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a> field type supports multiple quantization strategies: <code>bbq_disk</code> and <code>bbq_hnsw</code> are the best fits for high-dimensional embeddings like the 1024-dim vectors used here.</p><pre><code>    Indexed 8,495 documents into docs-clustering-all
    Time: 57.5s</code></pre><h3>Clustering: Density-probed centroid classification</h3><p>Traditional clustering algorithms like HDBSCAN assume you can hold the full N×d vector matrix in memory and run repeated full-pass updates. For 8,495 documents at 1024 dimensions, that&#x27;s manageable (~35 MB), but the approach doesn&#x27;t scale to millions of documents without additional infrastructure.</p><p>This algorithm is conceptually similar to KMeans++ initialization with Voronoi assignment and a noise floor, but it uses Elasticsearch <a href="https://www.elastic.co/docs/solutions/search/vector/knn">kNN search</a> as the compute primitive, keeping almost all work server-side:</p><ol><li><strong>Sample 5% of documents</strong> as density probes (random sample, minimum 50).</li><li><strong>Probe density via batched</strong> <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-msearch"><strong><code>msearch</code></strong></a> <strong>kNN</strong>. Each probe fires a kNN query and records the mean similarity of its neighbours. High mean similarity = dense region of embedding space. <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-msearch"><code>msearch</code></a> sends multiple search requests in a single HTTP call, which is critical here: Density probing generates hundreds of kNN queries, and batching them avoids per-request overhead.</li><li><strong>Select high-density seeds with diversification</strong>: Candidates above median density are sorted by density descending and greedily accepted only when their cosine similarity to every existing seed is below a separation threshold. This is the only client-side compute (~0.01s for 8k docs).</li><li><strong>Classify all docs against centroids via</strong> <strong><code>msearch</code></strong> <strong>kNN</strong>: Each seed acts as a centroid; a kNN search retrieves nearby documents above a similarity threshold. Each document is assigned to whichever centroid returned it with the highest score. Small clusters are dissolved to noise.</li></ol><p>Elasticsearch handles the heavy lifting: <code>msearch</code> for density probes, <code>msearch</code> for classification, and <code>significant_text</code> for labeling. For this corpus (8,495 docs), the 5% density-probe sample launches 425 kNN probe queries, which <code>msearch</code> batches into nine HTTP calls (at batch size 50), avoiding one-request-per-probe overhead. Combined with <code>bbq_disk</code> ANN lookup, this keeps the clustering stage fast and scalable. The kNN queries use a minimal <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/approximate-knn-search"><code>num_candidates</code></a> value for speed during the clustering pass; production search queries should use higher <code>num_candidates</code> values to improve recall at the cost of latency.</p><p>Clusters have natural sizes determined by the embedding space density around each centroid, not by a hard <code>k</code> cap. Dense topic regions produce larger clusters; niche topics produce smaller ones.</p><h4>Why not KMeans or HDBSCAN?</h4><p>KMeans assumes spherical clusters and requires the full N×d matrix in memory. For corpora that fit in memory, <a href="https://scikit-learn.org/stable/modules/generated/sklearn.cluster.HDBSCAN.html">HDBSCAN</a> is a strong alternative. It handles arbitrary cluster shapes and has well-understood density semantics.</p><p>The density-probed centroid approach targets a different niche: corpora where you want storage, retrieval, and clustering in one system, or where scale makes client-side matrix operations impractical. It uses Elasticsearch kNN as the compute primitive, handles arbitrary cluster sizes, and keeps nearly all computation server-side.</p><pre><code>    Clustered global index in 31.6s
      Total clusters: 82
      Total noise:    2420 (28.5%)
      Density probes: 425 kNN queries via 9 _msearch HTTP calls</code></pre><h4>Understanding the noise rate</h4><p>The ~28% noise rate is by design, not a failure mode. Documents that don&#x27;t fit any dense cluster at the configured <code>similarity_threshold</code> are left unassigned rather than forced into a poor match. This acts as a quality gate: Opinion columns, short articles, and one-off stories naturally resist clustering because they lack the thematic density that defines a coherent group.</p><p>The threshold is tunable: Lowering <code>similarity_threshold</code> produces more aggressive clustering (more documents assigned, but looser clusters), while raising it tightens clusters and increases the noise fraction. For this corpus of mixed news content, ~30% noise is a reasonable operating point. Production deployments should tune the threshold against domain-specific quality criteria.</p><h3>Automatic labels with significant_text</h3><p>Now each cluster needs a human-readable label. Elasticsearch&#x27;s <code>significant_text</code> aggregation finds terms that appear unusually often in a foreground set (the cluster) compared to a background set (the full corpus).</p><p>Under the hood, it uses a statistical heuristic (JLH score by default) that balances absolute and relative frequency shifts, no machine learning, no large language model (LLM) calls. A cluster about UK politics might surface terms like <code>starmer</code>, <code>labour</code>, <code>downing</code> because those terms are disproportionately common in that cluster compared to the overall news corpus.</p><p>For this global pass, labels are computed directly against <code>docs-clustering-all</code>, so both foreground and background are drawn from the full month. In Part 2, labeling uses the daily index pattern (<code>docs-clustering-*</code>), a wildcard that lets queries span all matching indices simultaneously, to give <code>significant_text</code> a broader background for better contrast.</p><p>A minimal query shape looks like this:</p><pre><code>{
  "size": 0,
  "query": { "term": { "cluster_id": "72" } },
  "aggs": {
    "label_terms": {
      "significant_text": {
        "field": "text",
        "size": 5,
        "filter_duplicate_text": true
      }
    }
  }
}</code></pre><p><code>significant_text</code> also serves as a quality gate: Clusters that produce no significant terms have no distinguishing vocabulary. They&#x27;re incoherent groupings that should be dissolved back to noise rather than given a misleading label.</p><p>A lightweight deterministic cleanup step removes noisy label terms (numeric tokens, generic words) and falls back to a representative headline when needed. This keeps labels Elasticsearch native while improving readability.</p><pre><code>    Sample cluster labels:
      cluster   3  (200 docs)  arsenal | mikel | villa
      cluster   1  (198 docs)  volodymyr | ukrainian | kyiv
      cluster   0  (196 docs)  hostages | hamas | israeli
      cluster   4  (187 docs)  scrum | rugby | borthwick
      cluster  52  (185 docs)  fossil | renewable | renewables
      cluster  10  (156 docs)  labour | gwynne | mps
      cluster  40  (151 docs)  novel | novels | literary
      cluster  11  (149 docs)  mewis | sarina | wiegman
      cluster  44  (143 docs)  flooding | rainfall | rain
      cluster  13  (131 docs)  doge | musk | elon
      cluster  12  (128 docs)  murder | insp | knockholt
      cluster   5  (124 docs)  putin | backstop | starmer


    Reassigned 35 docs from incoherent clusters to noise
    Total docs: 8,495
    Clustered:  6,040 (71.1%)
    Noise:      2,455 (28.9%)</code></pre><h3>Visualizing the clusters</h3><p>The visualizations below show what the global clustering pass discovered: a date-wise breakdown of clustered versus noise documents, a UMAP projection of the full month, and a source-mix chart confirming that clusters reflect topics rather than sources.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/99099f5adaa945ae4097c50b0d7151c7dd28872e-1000x400.png" style="width: 100%;" /><p>Daily distribution of clustered versus noise documents across February 2025.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/477c6c7177942955a942f85f5c881da50e517915-1100x700.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4f4302556c8974c416842452cf33bca06e90b966-1100x700.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/327f96a79e382ef30614cb0570aa7fccd822b8f8-1100x700.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/acfb1524a10e24d6ff2412e7c3ec0f2b3ac75193-900x600.png" style="width: 100%;" /><p>Each colored island in the UMAP represents a cluster: a group of articles about the same topic discovered purely from embedding similarity. The gray noise points are articles that didn&#x27;t fit cleanly into any cluster (often short articles, opinion pieces, or one-off stories).</p><p>The source breakdown chart confirms that clusters contain articles from <strong>both</strong> BBC News and The Guardian. The clustering is finding <em>topics</em>, not <em>sources</em>, exactly what unsupervised discovery should produce.</p><h3>Exploring cluster breadth with the diversify retriever</h3><p>Plain kNN returns the documents most similar to a cluster&#x27;s centroid (the dense core). But real clusters cover subtopics. The <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/diversify-retriever"><strong>diversify retriever</strong></a> uses Maximal Marginal Relevance (MMR) to surface documents that are relevant to the centroid but also <em>different from each other</em>.</p><p>The key parameter is <strong>λ (lambda)</strong>:</p><ul><li>λ = 1.0 → pure relevance (same as plain kNN).</li><li>λ = 0.0 → pure diversity (maximally spread results).</li><li>λ = 0.5 → balanced: that is relevant to the topic, but covering different angles.</li></ul><div style="display:none">Unknown block type "infoPanel", specify a component for it in the `components.types` option</div><p>A minimal retriever request shape looks like this:</p><pre><code>{
  "size": 8,
  "retriever": {
    "diversify": {
      "type": "mmr",
      "field": "embedding",
      "lambda": 0.5,
      "query_vector": "<cluster-centroid-vector>",
      "retriever": {
        "knn": {
          "field": "embedding",
          "query_vector": "<cluster-centroid-vector>",
          "k": 50,
          "num_candidates": 100
        }
      }
    }
  }
}</code></pre><p>The <code>type</code>, <code>field</code>, and <code>query_vector</code> parameters are required at the diversify level: <code>field</code> tells MMR which dense_vector field to use for inter-result similarity, and <code>query_vector</code> provides the reference point for relevance scoring.</p><p>This lets you answer: &quot;What does this cluster actually cover?&quot; rather than just &quot;What&#x27;s at its center?&quot;</p><pre><code>    Exploring cluster 52 (185 docs)
    Label: fossil | renewable | renewables
    Centroid computed (dim=1024)


    ========================================================================
    Plain kNN (closest to centroid)
    ========================================================================
      1. [0.9738] Green campaigners fear ministers are poised to award billions of pounds in fresh subsidies to Drax power station, despite strong concerns...
      2. [0.9710] Thirteen more oil and gas licences could be cancelled as ministers decide new guidance for fossil fuel extraction after a landmark court...
      3. [0.9699] Experts have accused the fossil fuel industry of seeking special treatment after lobbyists argued greenhouse gas emissions from oilfields...
      4. [0.9681] Burning wood is a terrible way of producing electricity . Chopping down trees destroys habitats for wildlife, and growing new trees cannot...
      5. [0.9649] Keir Starmer will do huge damage to the global fight against climate change if he gives in to political pressure and allows the development...
      6. [0.9641] Labour will next week be confronted with stark policy choices that threaten to expose the fault lines between the Treasury and the...
      7. [0.9638] The Drax power station near Selby in north Yorkshire burns imported wood pellets  The government has agreed a new funding arrangement with...
      8. [0.9581] If you care about the world we are handing on to future generations, the news on Thursday morning was dramatic. This January was the...
    
    ========================================================================
    Diversify retriever (MMR, lambda=0.5)
    ========================================================================
      1. [0.9738] Green campaigners fear ministers are poised to award billions of pounds in fresh subsidies to Drax power station, despite strong concerns...
      2. [0.9434] Oil and gas interests have waged a coordinated campaign to kill pro-electrification policies that ban gas connections in new buildings ,...
      3. [0.9303] It was interesting to read that new licences for oil and gas production in the North Sea are being delayed by legal action ( Thirteen more...
      4. [0.9139] The US energy secretary, Chris Wright, has said he “would love to see Australia get in the game of supplying uranium and maybe going down...
      5. [0.9077] Rachel Reeves was facing criticism on Saturday night as it was confirmed that a report she cited as evidence that a third ­runway at...
      6. [0.8996] When Margaret Thatcher opened the Hadley Centre for Climate Change in 1990 journalists suggested she was attempting to appear to be doing...
      7. [0.8993] The vast majority of governments are likely to miss a looming deadline to file vital plans that will determine whether or not the world has...
      8. [0.8987] European imports of seaborne gas shipments fell by a fifth last year to their lowest level since the pandemic, according to a new report,...
    
    Overlap: 1/8 documents appear in both result sets
    
    Avg pairwise similarity (lower = more diverse):
      Plain kNN:          0.9057
      Diversify retriever: 0.6965</code></pre><p>The plain kNN results cluster around one angle of the topic: the documents most similar to the centroid and to each other. The diversify retriever surfaces different facets of the same cluster: subtopics, different sources, and varied perspectives.</p><p>The diversity metric confirms this quantitatively: the average pairwise similarity is lower for the diversify retriever results, meaning that the returned documents cover more ground.</p><p>This is useful for:</p><ul><li><strong>Understanding what a cluster actually covers</strong>, not just its center but also its edges.</li><li><strong>Generating summaries</strong>. Diverse representative docs give an LLM better material.</li><li><strong>Finding representative examples</strong> for human review or downstream labeling.</li><li><strong>Quality checks</strong>. If the diverse results look incoherent, the cluster may need splitting.</li></ul><h2>Part 2: Temporal story chains</h2><h3>Tracking stories across days</h3><p>Part 1 clustered the full month globally for topic discovery. For temporal flow, the same density-probed centroid classification runs independently per day on <strong>daily indices</strong>, and then clusters are linked across adjacent days. Note that the daily clusters are independent of the global clusters from Part 1; each day produces its own cluster assignments and labels tuned to that day&#x27;s content.</p><h4><strong>The linking approach: sample-and-query</strong></h4><p>For each cluster on day A:</p><ol><li>Sample a few representative documents.</li><li>Run kNN against day B&#x27;s index.</li><li>Count how many hits land in each day B cluster.</li><li>If the hit fraction exceeds a threshold (kNN fraction ≥ 0.4), record a link.</li></ol><p>This is fast (only a few docs per cluster are queried, not all of them) and uses Elasticsearch&#x27;s native kNN, no external tools needed.</p><pre><code>Preparing daily indices for temporal linkage...


Indexed 8,495 docs into 28 daily indices


Temporal links found: 808 in 145.4s

Strongest links:
  2025.02.01 'league | arsenal | premier' -> 2025.02.02 'league | season | striker'  (100%)
  2025.02.03 'league | striker | loan' -> 2025.02.04 'league | striker | season'  (100%)
  2025.02.03 'score | operator | gedling' -> 2025.02.04 'league | striker | season'  (100%)
  2025.02.12 'playoff | leg | bayern' -> 2025.02.13 'league | players | injury'  (100%)
  2025.02.14 'league | injury | football' -> 2025.02.15 'league | premier | football'  (100%)
  2025.02.18 'russia | ukraine | talks' -> 2025.02.19 'saudi | russia | arabia'  (100%)
  2025.02.18 'football | league | bayern' -> 2025.02.19 'league | manchester | players'  (100%)
  2025.02.21 'league | premier | manchester' -> 2025.02.22 'game | players | defeat'  (100%)
  2025.02.21 'rugby | calcutta | brilliant' -> 2025.02.22 'game | players | defeat'  (100%)
  2025.02.26 'metals | kyiv | ukrainian' -> 2025.02.27 'ukraine | russia | talks'  (100%)</code></pre><p>A kNN fraction of 100% means every sampled document from the source cluster landed in the same target cluster, the strongest possible cross-day link. Most links above are football-related, which makes sense: Premier League coverage runs daily with high topical consistency.</p><p>The <code>score | operator | gedling</code> → <code>league | striker | season</code> link is an example of a niche local football cluster (Gedling is a non-league club) being absorbed into the broader Premier League cluster on the next day, a natural effect of daily reclustering at different granularity.</p><h3>Building story chains</h3><p>A story chain is a sequence of linked clusters across consecutive days.</p><p>Individual pairwise links tell you that Monday&#x27;s &quot;UK politics&quot; cluster connects to Tuesday&#x27;s. Chains reveal the full arc: a story that starts Monday, evolves through the week, and fades by Friday.</p><p>Chains are built greedily from links with a kNN fraction ≥ 0.4, meaning that at least 40% of sampled documents from the source cluster landed in a single target cluster. Starting from the earliest cluster, the algorithm always follows the strongest outgoing link.<br/></p><pre><code>    Strong links (kNN fraction >= 0.4): 244
    Story chains spanning 3+ days: 18
      Chain 1: 'ukrainian | kyiv | eastern' (19 days: Feb 3 → Feb 21)
      Chain 2: 'playing | opposition' (19 days: Feb 10 → Feb 28)
      Chain 3: 'tadhg | maro | cadan' (10 days: Feb 1 → Feb 10)
      Chain 4: 'invade | china | putin' (8 days: Feb 21 → Feb 28)
      Chain 5: 'elected | labour | leader' (7 days: Feb 12 → Feb 18)
      Chain 6: 'film | swift | awards' (6 days: Feb 2 → Feb 7)
      Chain 7: 'amendment | termination | reporting' (6 days: Feb 12 → Feb 17)
      Chain 8: 'officers | scene | police' (5 days: Feb 1 → Feb 5)</code></pre><p>The longest chain tracks Ukraine–Russia coverage for 19 consecutive days, unsurprising given the sustained geopolitical intensity in February 2025. The second-longest follows Premier League football across 19 days of the month. Shorter chains capture award season (film/awards, six days), Six Nations rugby (10 days), and UK political leadership coverage (seven days). Each chain represents a story arc that the algorithm discovered purely from embedding similarity across daily indices.</p><h3>Sankey: Visualizing story flow</h3><p>A Sankey diagram is a flow visualization where link width represents connection strength. Here, each vertical band is a day, each node is a daily cluster (sized by document count), and each colored path traces one story chain across time. Link width encodes kNN overlap strength: Thicker links mean more sampled documents landed in the target cluster. Colors are consistent per chain, so a single color path from left to right reads as one story&#x27;s progression.</p><p>For example, the Ukraine–Russia chain (visible as one of the longer paths) flows continuously from early February through the third week, with consistently thick links indicating strong topical continuity across days.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/100a60a7fb85da8ab3813fd071a82c93f2c3f318-1300x650.png" style="width: 100%;" /><p><em>Temporal story chains flowing across February 2025. Each colored path is a story persisting across days; link width indicates kNN overlap strength.</em></p><h2>What this approach delivers</h2><p>This walkthrough covered a complete unsupervised document clustering pipeline built on Elasticsearch:</p><ol><li><strong>Clustering embeddings</strong>: Jina v5&#x27;s task-specific adapters produce embeddings optimized for topic grouping, not just query-document matching.</li><li><strong>Global discovery clustering</strong>: Clustering the full month in one index maximizes cross-day topical discovery.</li><li><strong>Density-probed centroid classification</strong>: Sample 5%, probe density via <code>msearch</code> kNN, select diverse high-density seeds, classify all docs against centroids. Elasticsearch handles the heavy compute; only seed selection runs client-side (~0.01s).</li><li><a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-significanttext-aggregation"><strong><code>significant_text</code></strong></a> <strong>labeling</strong>: Significance testing produces meaningful cluster labels without any ML model or manual annotation. Clusters that produce no significant terms are incoherent and get demoted to noise — a built-in quality gate.</li><li><strong>Temporal story linking</strong>: Daily indices and sample-and-query cross-index kNN trace how stories evolve over time.</li></ol><p><strong>Key takeaways:</strong></p><ul><li>The embedding task type matters: Clustering embeddings produce measurably tighter topical groups.</li><li>Elasticsearch can serve as both the storage layer <em>and</em> the clustering engine via <a href="https://www.elastic.co/docs/solutions/search/vector/knn">kNN search</a>.</li><li>Density-probed centroid classification keeps nearly all compute server-side and produces clusters with natural sizes determined by embedding space density.</li><li><code>significant_text</code> is fast, interpretable, and effective for both auto-labeling and quality gating.</li></ul><p><strong>When this approach is useful:</strong></p><ul><li>You have timestamped text and want topic discovery without labeled training data.</li><li>You want one stack for storage, vector search, labeling, and temporal linkage.</li></ul><p><strong>Extensions to explore:</strong></p><ul><li>Multi-period clustering (weekly, monthly rollups).</li><li>Real-time ingestion with incremental cluster assignment.</li><li>LLM-generated cluster summaries using the significant_text terms as seeds.</li><li>At larger scale, sampled KMeans centroids can serve as warm-start seeds for density-based clustering, reducing the probe phase cost.</li></ul><h2>Try it yourself</h2><p>Swap in your own timestamped document corpus; any collection of text with dates works with this pipeline. The full notebook and supporting code are available in the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/unsupervised-document-clustering-elasticsearch-jina-embeddings">companion repository</a>.</p><ul><li><a href="https://cloud.elastic.co/registration?onboarding_token=vectorsearch&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs"><strong>Start a free Elastic Cloud trial</strong></a>: Spin up a managed cluster with <code>bbq_disk</code> support in minutes.</li><li><a href="https://www.elastic.co/elasticsearch/serverless"><strong>Try Elasticsearch Serverless</strong></a>: No cluster management, scales automatically, and supports everything in this walkthrough.</li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why ecommerce search needs governance]]></title>
            <description><![CDATA[Learn why ecommerce search falls short without governance and how a control layer ensures predictable and intent-driven results, thus improving retrieval.]]></description>
            <link>https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ecommerce-search-governance-improve-retrieval</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt, Honza Král, Taylor Roy]]></dc:creator>
            <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3793259b01a5653a7520393a2f006610de0d21e7-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Ecommerce retailers need to handle various fundamentally different query types within the same system. A shopper searching for “oranges” expects the fruit, not products containing the word “orange”, such as orange juice or orange marmalade, and not semantically related citrus products. A shopper searching for a “gift for grandpa who has a sweet tooth” needs semantic discovery, not literal keyword matching.</p><p><em>Lexical retrieval</em> (text matching), <em>semantic retrieval</em> (matching concepts), and <em>hybrid retrieval</em> (combining lexical and semantic signals) don’t solve these issues on their own. Lexical retrieval may return anything containing the word “oranges”, while pure semantic retrieval on a high-intent query like “oranges” may broaden toward related items, such as lemons or grapefruits. Hybrid retrieval blends these lexical and semantic signals, but it still doesn’t determine if this query should be treated as navigational, which constraints should be enforced, or which business policies should apply. The gap isn’t the retrieval technology itself; it’s the absence of a governance layer that understands what kind of query this is and what constraints should be enforced before retrieval begins.</p><p>In this blog, we explore ecommerce search governance, why it matters, and how a control layer ensures predictable, accurate retrieval.</p><h2>What governance means in ecommerce search</h2><p><em>Governance</em>, in this context, means introducing a decision layer between the user&#x27;s query and the retrieval engine. This layer performs the following functions:</p><ul><li>Classifies query intent: Is this navigation (&quot;oranges&quot;) or discovery (&quot;gift for grandpa&quot;)?</li><li>Applies business constraints: What category boundaries, eligibility rules, availability constraints, or merchandising policies apply?</li><li>Routes to the appropriate strategy: Should this use lexical retrieval, semantic retrieval, or hybrid?</li></ul><p>A governance layer determines which retrieval approach should be used for each query, which constraints must be enforced, and which business policies should apply before retrieval begins. It’s important not to conflate governance with hybrid retrieval: hybrid is one retrieval strategy that combines lexical and semantic signals, while governance is the upstream decision layer that determines whether lexical, semantic, or hybrid should be used.</p><h2>The status quo: The application layer &quot;spaghetti&quot; implementation</h2><p>Currently, many retailers attempt to solve this by adding logic directly into the application layer. This often results in <em>spaghetti code</em>, that is, thousands of lines of hard-coded if-then statements, regex, and complex search templates.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f532b099ee103458e15563a711dae92952f8df02-1024x765.png" style="width: 100%;" /><p>This approach can provide desired search results as shown above; however, it creates significant operational friction:</p><ul><li><strong>Engineering dependency:</strong> Business users and merchandisers cannot modify search behavior without engineering tickets and long deployment cycles that often span several weeks.</li><li><strong>Fragmentation:</strong> Search logic becomes scattered between application code and search templates, and is difficult to explain or audit, making it risky to evolve.</li></ul><p>Even when teams recognize the need for routing, the debate often focuses on the wrong question: which retrieval method to pick.</p><h2>The false choice: Lexical vs. semantic vs. hybrid</h2><p>Search teams often frame the challenge as a retrieval strategy choice: lexical/BM25 versus semantic/vectors versus hybrid. That framing is understandable (retrieval methods matter), but it misses the most common failure mode in real deployments, which is that using a single retrieval approach for all queries will give suboptimal results.</p><p>Commerce search is a mix of fundamentally different intents:</p><ul><li><strong>Deterministic, high-intent navigation</strong> ( &quot;oranges&quot;, “milk”, “chocolate without peanuts”, “cheap olive oil”).</li><li><strong>Exploratory discovery</strong> (&quot;jacket for hiking in the mountains&quot;, &quot;gift for a 12-year-old who likes robotics&quot;).</li><li><strong>Operational constraints</strong> (availability, size, price, color).</li><li><strong>Merchandising and campaigns</strong> (boost, bury, seasonal campaigns).</li></ul><p>When the system routes all of these through the same retrieval strategy, the results are often systematically wrong in predictable ways because the operating model lacks governance. When teams don&#x27;t recognize this as a governance gap, they respond with the only lever they have: more tuning.</p><h2>Why &quot;relevance tuning&quot; can become cyclical</h2><p>Without a routing layer, “relevance” often turns into a never-ending backlog:</p><ul><li>Why is this query showing accessories above the core product?</li><li>Why did this head query suddenly start surfacing related items?</li><li>Why did results change after we added synonyms, adjusted analyzers, or enabled hybrid?</li><li>Why does the business team need an engineering release to fix a single query?</li></ul><p>Teams respond with more tuning: more synonyms, more boosts, more reranking experiments, more exceptions in application code. This can work for a while, but it often produces brittle behavior because the system still lacks an explicit decision layer for determining query type and enforcing the right constraints before retrieval.</p><h2>The anatomy of ecommerce intent: Head and tail</h2><p>In this section, we use “head” and “tail” as practical shorthand for common navigational and exploratory query patterns in ecommerce. In the real world, many queries contain aspects of both:</p><h3>Head queries (deterministic intent)</h3><p>These are direct, navigational queries where the user knows exactly what they want:</p><ul><li>Single-item intent (&quot;oranges&quot;, &quot;milk&quot;, &quot;bread&quot;).</li><li>Exact brands or product families (&quot;iPhone 15 Pro&quot;, &quot;Diet Coke&quot;).</li><li>SKUs, model numbers, sizes (&quot;ABC123&quot;, &quot;air max 270&quot;).</li></ul><p>For these queries, lexical retrieval can handle token correspondence (matching words), but the business also expects to respect constraints, return predictable rankings, and have controllable outcomes. A merchandiser needs to ensure that a query resolves within the correct category boundaries, respects eligibility, and surfaces specific business priorities.</p><p>Governance is required to enforce the intended resolution. For example, “oranges” should map to the produce category, not to orange juice, orange marmalade, or orange soda.</p><h3>Tail queries (exploratory discovery)</h3><p>These are descriptive, intent-rich queries where shoppers are exploring:</p><ul><li>&quot;Gift for grandpa who has a sweet tooth&quot;</li><li>&quot;Jacket for hiking in the mountains&quot;</li><li>&quot;Shoes for standing all day&quot;</li></ul><p>Lexical retrieval often struggles here. Semantic retrieval excels because it can connect the query concept to the product, even when wording does not match. But semantic retrieval alone is rarely sufficient either. Real queries often require constraints to be enforced, regardless of which retrieval method is used.</p><h2>Constraints are orthogonal to retrieval method</h2><p>Applying constraints to semantic retrieval doesn’t mean <em>hybrid search</em>. These are orthogonal concepts. Constraints, such as filters and boosts in Elasticsearch, can be applied to any lexical, semantic, or hybrid retrieval. The challenge is deciding how the query should be interpreted, which constraints must be enforced, and which retrieval strategy should be used.</p><p>Below are some examples of queries that combine retrieval with hard constraints:</p><ul><li><strong>Oranges:</strong> Lexical retrieval for “oranges” plus a category constraint, such as “Fruits” or “Produce”, eliminating orange marmalade, orange juice, and orange soda.</li><li><strong>Fruits high in vitamin C under $4:</strong> Semantic retrieval for nutritional intent plus constraints limiting results to the fruit category and products under $4.</li><li><strong>Comfortable shoes for work:</strong> Semantic retrieval for contextual intent plus a category constraint limiting results to shoes.</li></ul><p>These queries can&#x27;t be handled by a single approach:</p><ul><li><strong>Pure lexical retrieval</strong> is often insufficient here because phrases like “high in vitamin C” or “comfortable” may not exist as clean, structured attributes. They may need to be inferred from product descriptions, reviews, or specifications.</li><li><strong>Pure semantic retrieval</strong> is also not always sufficient because, without explicit constraints, a query like “fruits high in vitamin C” might broaden toward vitamin supplements, fruit-flavored drinks, or high-vitamin vegetables outside the intended category and price range.</li></ul><p>A governance layer determines whether a query needs lexical retrieval, semantic understanding, constraint enforcement, or some combination of these. Without this layer, ecommerce teams may end up:</p><ul><li><strong>Over-constraining:</strong> Using lexical retrieval for semantic requests (for example, &quot;gift for grandpa&quot;).</li><li><strong>Under-constraining: </strong>Using semantic queries for high-intent head queries (for example, “oranges”).</li></ul><p>The governance challenge is to build a system that can make the right judgment call for each class of query.</p><h2>What happens without governance</h2><p>The most common failure mode is straightforward: Teams take the raw user query and pass it directly into a single retrieval strategy (lexical, semantic, or hybrid), without an intermediate governance layer.</p><h3>Lexical retrieval misses intended resolution</h3><p>When a user searches for “oranges”, a lexical retrieval strategy may return anything containing that token: orange juice, orange marmalade, or orange soda. The system matched the term correctly, but without governance it may not resolve the intended shopping context (the fruit).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/99abc7a46f9c56a26a68d0a089d7ab830b9b5568-1560x814.png" style="width: 100%;" /><h3>Semantic retrieval broadens beyond intended constraints</h3><p>When a user searches for “oranges”, a semantic system may retrieve conceptually related items across nearby product concepts. The system may correctly understand the broader domain (fruit or produce), but without explicit governance it can still over-broaden beyond the user’s intended constraint (specifically oranges).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c9de86363ecbed499fe48259f47b3c5b2c26bc43-1568x796.png" style="width: 100%;" /><h3>The gap is governance</h3><p>What’s required is an upstream decision layer that determines query intent and enforces the right constraints before retrieval begins. This fixes issues such as the following:</p><ul><li>Similar or related items appearing alongside what the user actually wanted.</li><li>Blurred category boundaries (&quot;beverages&quot; versus. &quot;produce&quot;).</li><li>Inability to implement seasonal boosts or campaigns.</li><li>Unpredictable and unexplainable results.</li></ul><h2>Intent understanding and routing: The necessary control plane</h2><p>A governed search system introduces a lightweight control plane in front of retrieval (prior to executing a query in Elasticsearch). The control will be discussed in detail in parts <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-control-plane-architecture">3</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">4</a> of this blog series; for now, we just discuss what it can do but not how it works:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/88c3d0f9731a128d73a765dcdffed897308110a6-2680x766.png" style="width: 100%;" /><p>A control plane can detect intent, apply business policies, and ensure the appropriate retrieval strategy as follows:</p><p><strong>1. Detect intent signals</strong></p><ul><li>Is this query likely navigation versus discovery?</li><li>Is it a known head query (milk, bread, bananas)?</li><li>Is there a known product, brand, or category interpretation (for example, “oranges” should resolve to produce).</li><li>Is the query an SKU-like pattern?</li><li>Does the query fall under an active campaign or seasonal policy (for example, during Christmas, boost turkey-related results)?</li><li>Does the query imply constraints (category, attributes, exclusions, price/size/color)?</li></ul><p><strong>2. Apply governance and business policies</strong></p><ul><li>Enforce deterministic constraints first (category/attribute/negation/availability).</li><li>Apply active merchandising policies (boost/bury/pin/override).</li><li>Resolve conflicts with precedence rules (for example, campaign overrides versus global policies).</li></ul><p><strong>3. Route to the appropriate retrieval strategy</strong></p><ul><li>Lexical (fast, deterministic) for navigational/high-intent head queries.</li><li>Semantic retrieval for true discovery queries.</li><li>Hybrid where combined lexical and semantic signals add value under explicit business constraints.</li></ul><p>In practice, the output of the control plane is not simply “use hybrid” or “use semantic.” It’s a governed retrieval plan: an interpretation of the shopper’s intent, the constraints and policies that should apply, and the retrieval strategy that should be executed. A few simple examples make this concrete:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Shopper query</td><td style="border: 1px solid #ccc; padding: 4px;">Governed interpretation</td><td style="border: 1px solid #ccc; padding: 4px;">Example retrieval plan</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">“chocolate without peanuts”</td><td style="border: 1px solid #ccc; padding: 4px;">Product-oriented query with a hard exclusion constraint</td><td style="border: 1px solid #ccc; padding: 4px;">Lexical retrieval for chocolate plus an exclusion filter for products containing peanuts</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">“cheap olive oil”</td><td style="border: 1px solid #ccc; padding: 4px;">Product/category query with a price constraint</td><td style="border: 1px solid #ccc; padding: 4px;">Lexical retrieval for olive oil plus a price filter capped at the retailer’s threshold for cheap</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">“fruit high in vitamin C under $4”</td><td style="border: 1px solid #ccc; padding: 4px;">Discovery query requiring semantic understanding plus hard constraints</td><td style="border: 1px solid #ccc; padding: 4px;">Semantic retrieval for nutritional intent, constrained to the fruit category and filtered to products priced under $4</td>
                    </tr>
              </tbody>
            </table>
          <p>A control plane selects the right policy and retrieval strategy for each query consistently, predictably, and at scale. This makes advanced retrieval methods more predictable in production because intent-aligned constraints are enforced first and routing decisions are explicit rather than implicit.</p><h2>How this relates to other approaches</h2><p>Some teams use improved embedding models to better capture product semantics, which can materially improve semantic retrieval quality. Others use reranking approaches, such as <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr">Learning To Rank (LTR)</a>, to optimize result ordering based on engagement or business signals after retrieval. Both are valuable and often complementary. Better embeddings improve similarity matching. Reranking improves ordering among retrieved candidates.</p><p>Governance addresses a different layer of the problem: It sits upstream of retrieval. It decides which retrieval strategy to use (for example, lexical, semantic, or hybrid), what deterministic constraints are required, and which queries should combine multiple business policies.</p><h2>What a governed control plane enables</h2><p>Once a governance layer is in place, the operating model changes fundamentally. Revenue-critical queries become predictable. Business teams can update search behavior without waiting on engineering release cycles. And advanced retrieval methods, like semantic and hybrid, can be adopted incrementally, behind routing and guardrails, instead of as a global on/off switch.</p><p>The <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">next post</a> in this series explores what that operating model looks like in practice and why it may matter as much as the retrieval technology underneath it.</p><p>If a merchandiser has to open a Jira ticket and wait for a deploy to fix a revenue-critical query, the bottleneck isn&#x27;t the engine; it&#x27;s the operating model. Modern ecommerce search needs a way to translate business intent into controlled, auditable search behavior quickly and safely, while still using advanced retrieval where it adds measurable value.</p><h2>What’s next in this series</h2><p>The patterns explored in this series operate upstream of retrieval: translating business intent into the right query strategy before query generation begins. In the <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-governance-zero-deploy">next post</a>, we shift from the technical problem to the operational one: what happens when business teams can change search behavior without an engineering deployment, and why governance makes that safe.</p><h2>Put governed ecommerce search into practice</h2><p>Engineering bottlenecks, brittle application-layer logic, and unpredictable search results are problems that Elastic Services can help you solve in enterprise ecommerce services engagements. The governed control plane architecture described in this series was built by Elastic Services Engineering.</p><p>If your team is spending engineering cycles translating merchandising requests into code changes, or if your search relevance backlog never seems to shrink, we can help you assess your current architecture and build a path to governed, business-editable search. Contact <a href="https://www.elastic.co/consulting">Elastic Services</a>.&nbsp; </p><h2>Join the discussion</h2><p>Have questions about search governance, retrieval strategies, or ecommerce search architecture? Join the broader <a href="https://discuss.elastic.co/">Elastic community conversation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to build agentic AI applications with Mastra and Elasticsearch]]></title>
            <description><![CDATA[Learn how to build agentic AI applications using Mastra and Elasticsearch through a practical example.]]></description>
            <link>https://www.elastic.co/search-labs/blog/build-agentic-ai-applications-mastra-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/build-agentic-ai-applications-mastra-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Enrico Zimuel]]></dc:creator>
            <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ab30baf2f908534840c5d71a46705773807baf54-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we’ll walk through how to use the <a href="https://mastra.ai/">Mastra</a> TypeScript framework to build agentic applications that interact with <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a>.</p><p>We recently contributed to the <a href="https://github.com/mastra-ai/mastra">mastra-ai/mastra</a> open source project by adding support for Elasticsearch as a vector database. With this new feature, you can use Elasticsearch natively in Mastra to store embeddings. In addition to vectors, Elasticsearch provides a suite of advanced features to address all your context engineering requirements. (for example, <a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-evolution-agentic-ai">hybrid search and reranking</a>).</p><p>This article details the creation of an agent to implement a retrieval augmented generation (RAG) architecture using Elasticsearch. We’ll showcase a demo project where an agentic approach is used to interact with a corpus of sci-fi movie data stored within Elasticsearch. The project is available at <a href="https://github.com/elastic/mastra-elasticsearch-example">elastic/mastra-elasticsearch-example</a>.</p><h2>Mastra</h2><p>Mastra is a TypeScript framework to create agentic AI applications.</p><p>A project structure in Mastra looks as follows:</p><pre><code>src/
├── mastra/
│   ├── agents/
│   │   └── weather-agent.ts
│   ├── tools/
│   │   └── weather-tool.ts
│   ├── workflows/
│   │   └── weather-workflow.ts
│   ├── scorers/
│   │   └── weather-scorer.ts
│   └── index.ts
├── .env.example
├── package.json
└── tsconfig.json</code></pre><p>In Mastra, you can build <a href="https://mastra.ai/docs/agents/overview">agents</a>, <a href="https://mastra.ai/docs/agents/using-tools">tools</a>, <a href="https://mastra.ai/docs/workflows/overview">workflows</a>, and <a href="https://mastra.ai/docs/evals/overview">scores</a>.</p><p>An <strong>agent</strong> is a class that accepts a message in input and produces a response as output. An agent can use tools, large language models (LLMs), and a memory (figure 1).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f6aca2dcc7fcc45d25e06681649be1b2b7eb6781-706x721.png" style="width: 100%;" /><p>An agent&#x27;s <strong>tools</strong> allow it to interact with the &quot;external world,&quot; such as communicating with a web API or performing an internal operation, like querying Elasticsearch. The <strong>memory</strong> component is crucial for storing the history of conversations, including past inputs and outputs. This stored context enables the agent to provide more informed and relevant responses to future questions by using its past interactions.</p><p><strong>Workflows</strong> let you define complex sequences of tasks using clear, structured steps rather than relying on the reasoning of a single agent (figure 2). They give you full control over how tasks are broken down, how data moves between them, and what gets executed when. Workflows run using the built-in execution engine by default or can be deployed to <a href="https://mastra.ai/docs/deployment/workflow-runners">workflow runners</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0dce161cf7891207015dc87532b5b90df1822432-880x252.png" style="width: 100%;" /><p>In Mastra, you can also define scores, which are automated tests that evaluate agent outputs using model-graded, rule-based, and statistical methods. Scorers return <em>scores</em>: numerical values (typically between 0 and 1) that quantify how well an output meets your evaluation criteria. These scores enable you to objectively track performance, compare different approaches, and identify areas for improvement in your AI systems. Scorers can be customized with your own prompts and scoring functions.</p><h2>Elasticsearch</h2><p>For running the demo project, we need to have an Elasticsearch instance running. You can activate a free trial on <a href="https://www.elastic.co/cloud">Elastic Cloud</a> or install it locally using the <a href="https://github.com/elastic/start-local"><code>start-local</code></a> script:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>This will install Elasticsearch and Kibana on your computer and generate an API key to be used for configuring the Mastra integration.</p><p>The API key will be shown as output of the previous command and stored in a <strong>.env</strong> file in the elastic-start-local folder.</p><h2>Install and configure the demo</h2><p>We created an <a href="https://github.com/elastic/mastra-elasticsearch-example">elastic/mastra-elasticsearch-example</a> repository containing the source code of the demo project. The example reported in the repository illustrates how to create an agent in Mastra that implements a RAG architecture for retrieving documents from Elasticsearch.</p><p>We provided a dataset for the demo about sci-fi movies. We extracted 500 movies from the IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>.</p><p>The first step is to install the dependencies of the project with npm, using the following command:</p><pre><code>npm install</code></pre><p>Then we need to configure the <strong>.env</strong> file that will contain the settings. We can generate this file copying the structure from the <strong>.env.example</strong> file, using the following command:</p><pre><code>cp .env.example .env</code></pre><p>Now we can edit the .env, adding the missing information:</p><pre><code>OPENAI_API_KEY=
ELASTICSEARCH_URL=
ELASTICSEARCH_API_KEY=
ELASTICSEARCH_INDEX_NAME=scifi-movies</code></pre><p>The name of the Elasticsearch index is <strong><code>scifi-movies</code></strong>. If you want, you can change it using the env variable <code>ELASTICSEARCH_INDEX_NAME</code>.</p><p>We used OpenAI as embedding service, which means that you need to provide an API key for OpenAI in the <code>OPENAI_API_KEY</code> env variable.</p><p>The embedding model used in the example is <a href="https://developers.openai.com/api/docs/models/text-embedding-3-small">openai/text-embedding-3-small</a>, with an embedding dimension of 1536.</p><p>To generate the final answer, we used the <a href="https://developers.openai.com/api/docs/models/gpt-5-nano">openai/gpt-5-nano</a> model to reduce the costs.</p><p>The RAG architecture allows you to use a less powerful (and typically less expensive) final LLM model because the heavy lifting of grounding the answer is done by the retrieval component (Elasticsearch in this case).</p><p>The smaller LLM is only responsible for two main tasks:</p><ul><li><strong>Rephrasing/embedding the query:</strong> Converting the user&#x27;s natural language question into a vector embedding for semantic search.</li><li><strong>Synthesizing the answer:</strong> Taking the highly relevant, retrieved context chunks (documents/movies) and synthesizing them into a coherent, final, human-readable answer, following the provided prompt instructions.</li></ul><p>Since the RAG process <strong>provides the exact factual context</strong> needed for the answer, the final LLM doesn&#x27;t need to be massive or highly complex and it doesn’t need to possess all the required knowledge within its own parameters (which is where large, expensive models excel). It essentially acts as a sophisticated text summarizer and formatter for the context provided by Elasticsearch, rather than as a full-fledged knowledge base itself. This enables the use of models like <code>gpt-5-nano</code> for cost and latency optimization.</p><p>After the configuration of the .env file, you can ingest the movies to Elasticsearch using the following command:</p><pre><code>npx tsx src/utility/store.ts</code></pre><p>You should see an output as follows:</p><pre><code>🚀 Starting ingestion of 500 movies from 500_scifi_movies.jsonl...
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 1/500 (0%) | ok:1 | fail:0 | chunks:1 | eta:19m 33s | current:Capricorn One
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 2/500 (0%) | ok:2 | fail:0 | chunks:2 | eta:10m 32s | current:Doghouse
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 3/500 (1%) | ok:3 | fail:0 | chunks:3 | eta:7m 33s | current:Dinocroc
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 4/500 (1%) | ok:4 | fail:0 | chunks:7 | eta:6m 10s | current:Back to the Future           
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 5/500 (1%) | ok:5 | fail:0 | chunks:9 | eta:5m 14s | current:The Projected Man            
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 6/500 (1%) | ok:6 | fail:0 | chunks:11 | eta:4m 41s | current:I, Robot
...
✅ Ingestion complete in 1m 46s. Success: 500, Failed: 0, Chunks: 693.</code></pre><p>The mapping of the scifi-movies index contains the following fields:</p><ul><li><strong>embedding</strong>, dense_vector with 1536 dimension, cosine similarity.</li><li><strong>description</strong>, text containing the description of the movie.</li><li><strong>director</strong>, text containing the name of the director.</li><li><strong>title</strong>, text containing the title of the movie.</li></ul><p>We generated the embeddings using the title + description. Since the title and the description are two separate fields, the concatenation of both ensures that the resulting embedding vector captures both the specific, unique identity (title) and the rich, descriptive context (description) of the movie, leading to more accurate and comprehensive semantic search results. This combined input gives the embedding model a better single representation of the document&#x27;s content for similarity matching.</p><h2>Run the demo</h2><p>You can run the demo with the following command:</p><pre><code>npm run dev</code></pre><p>This command will start a web application at <strong>localhost:4111</strong> to access Mastra Studio (figure 3).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1567e309df21a12bcf1dfef4429f82342549956c-1705x1079.png" style="width: 100%;" /><p><a href="https://mastra.ai/docs/getting-started/studio">Mastra Studio</a> offers an interactive UI for building and testing your agents, along with a REST API that exposes your Mastra application as a local service. This lets you start building right away without worrying about integration.</p><p>We provided an <strong>Elasticsearch Agent</strong> that uses the <a href="https://mastra.ai/reference/tools/vector-query-tool">createVectorQueryTool</a> by Mastra as a tool for executing semantic search using Elasticsearch. This agent uses the RAG approach to search for relevant documents (that is, movies) to answer the user’s question.</p><p>This agent uses the following prompt:</p><pre><code>You are a helpful assistant that answers questions based on the provided context.
Follow these steps for each response:

1. First, carefully analyze the retrieved context chunks and identify key information.
2. Break down your thinking process about how the retrieved information relates to the query.
3. Draw conclusions based only on the evidence in the retrieved context.
4. If the retrieved chunks don't contain enough information, explicitly state what's missing.

Format your response as:
THOUGHT PROCESS:
- Step 1: [Initial analysis of retrieved chunks]
- Step 2: [Reasoning based on chunks]

FINAL ANSWER:
[Your concise answer based on the retrieved context]

Important: When asked to answer a question, please base your answer only on the context provided in the tool. 
If the context doesn't contain enough information to fully answer the question, please state that explicitly and stop it.
Do not add more information than what is present in the retrieved chunks.
Remember: Explain how you're using the retrieved information to reach your conclusions.</code></pre><p>If you click on the <code>Mastra Studio &gt; Agents</code> menu and select <strong>Elasticsearch Agent</strong>, you can test the agent using a chat system. For instance, you can ask information regarding sci-fi movies with a question as follows:</p><p><em>Find 5 movies or TV series about UFOs</em>.</p><p>You’ll notice that the agent will execute the vectorQueryTool. You can click on the invoked tool to have a look at the input and the output. At the end of execution, the LLM will reply to your question, given the context coming from the scifi-movies index of Elasticsearch (figure 4).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d9998d4f687984de98845dae52d1288166abf448-1344x1071.png" style="width: 100%;" /><p>Mastra executes the following steps internally:</p><ol><li><strong>Vector conversion:</strong> The user&#x27;s question, <em>Find 5 movies or TV series about UFOs,</em> is converted into a vector embedding using OpenAI&#x27;s <code>openai/text-embedding-3-small</code> model.</li><li><strong>Vector search:</strong> This embedding is then used to query Elasticsearch via a vector search.</li><li><strong>Result retrieval:</strong> Elasticsearch returns a set of 10 movies highly relevant to the query (that is, those with vectors closest to the user&#x27;s query vector).</li><li><strong>Answer generation:</strong> The retrieved movies and the original user question are sent to the LLM, specifically <code>openai/gpt-5-nano</code>. The LLM processes this information and generates a final answer, ensuring that the user&#x27;s request for five results is met.</li></ol><h2>The Elasticsearch Agent</h2><p>Here we reported the source code of Elasticsearch Agent.</p><pre><code>import { Agent } from "@mastra/core/agent";
import { ElasticSearchVector } from '@mastra/elasticsearch';
import { createVectorQueryTool } from '@mastra/rag';
import { ModelRouterEmbeddingModel } from "@mastra/core/llm";
import { Memory } from "@mastra/memory";

const es_url = process.env.ELASTICSEARCH_URL;
const es_apikey = process.env.ELASTICSEARCH_API_KEY;
const es_index_name = process.env.ELASTICSEARCH_INDEX_NAME;
const prompt = 'insert here the previous prompt';

const esVector = new ElasticSearchVector({
  id: 'elasticsearch-vector',
  url: es_url,
  auth: {
    apiKey : es_apikey
  }
});

const vectorQueryTool = createVectorQueryTool({
  vectorStore: esVector,
  indexName: es_index_name,
  model: new ModelRouterEmbeddingModel("openai/text-embedding-3-small")
});

export const elasticsearchAgent = new Agent({
  id: "elasticsearch-agent",
  name: "Elasticsearch Agent",
  instructions: prompt,
  model: 'openai/gpt-5-nano',
  tools: { vectorQueryTool },
  memory: new Memory(),
});</code></pre><p>The <strong>vectorQueryTool</strong> is the tool that’s invoked to implement the retrieval part of the RAG example. It uses the <a href="https://mastra.ai/reference/vectors/elasticsearch">ElasticSearchVector</a> implementation that Elastic contributed to Mastra.</p><p>The agent is an object of the agent class that consumes the vectorQueryTool, the prompt, and a memory. As you can see, the code that we need to put in place for connecting Elasticsearch to an agent is very minimal.</p><h2>Conclusion</h2><p>This article demonstrated the simplicity and power of integrating Elasticsearch with the Mastra framework to build sophisticated agentic AI applications. Specifically, we walked through creating a RAG agent capable of performing semantic search over a corpus of sci-fi movie data indexed in Elasticsearch.</p><p>A key takeaway is the direct contribution by Elastic to the Mastra open source project, providing native support for Elasticsearch as a vector store. This integration significantly lowers the barrier to entry, as evidenced by the <strong>Elasticsearch Agent</strong> source code. Using the <code>ElasticSearchVector</code> and <code>createVectorQueryTool</code>, the complete setup for connecting Elasticsearch to your agent requires only a minimal number of lines of configuration code.</p><p>Elasticsearch provides several advanced features to enhance result relevance. For example, <a href="https://www.elastic.co/elasticsearch/hybrid-search">hybrid search</a> significantly boosts accuracy by combining lexical search with vector search. Another interesting feature is reranking using the latest <a href="https://www.elastic.co/search-labs/tutorials/jina-tutorial/jina-reranker-v3">Jina models</a> that can be applied at the end of hybrid search. To learn more about these techniques, consult the following articles from Elasticsearch Labs:</p><ul><li><a href="https://www.elastic.co/search-labs/blog/hybrid-search-elasticsearch">Elasticsearch hybrid search</a> by Valentin Crettaz</li><li><a href="https://www.elastic.co/search-labs/blog/jina-models-elasticsearch-guide">An introduction to Jina models, their functionality, and uses in Elasticsearch</a> by Scott Martens</li></ul><p>We also encourage you to explore the provided example and begin building your own data-powered agents with Mastra and Elasticsearch. For more information about Mastra, you can have a look at the official documentation <a href="https://mastra.ai/docs">here</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Migrating your OpenShift Elasticsearch 6.x cluster to Elastic Cloud on Kubernetes (ECK)]]></title>
            <description><![CDATA[A step-by-step guide on how to migrate from the legacy OpenShift Elasticsearch Operator (ES 6.x) to the modern Elastic Cloud on Kubernetes (ECK).]]></description>
            <link>https://www.elastic.co/search-labs/blog/openshift-elastic-cloud-kubernetes-migration</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/openshift-elastic-cloud-kubernetes-migration</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Omer Kushmaro, Jamie Parker]]></dc:creator>
            <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2f35bf231e4c8ad4f9a79d4ac4d188bfc2e385a3-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Red Hat&#x27;s OpenShift platform has long been a trusted foundation for enterprise Kubernetes workloads, and for years, its built-in Elasticsearch Operator has made log management simple. But all good things evolve: The OpenShift Elasticsearch Operator reached the end of its supported lifecycle with Red Hat OpenShift Container Platform (OCP) 4.13, and the Elasticsearch 6.x clusters it managed are long out of support. Working closely with Red Hat, we&#x27;ve put together this step-by-step guide to help you move from that legacy setup to <strong>Elastic Cloud on Kubernetes</strong> (ECK), the modern, full-featured operator maintained directly by Elastic. The migration path we&#x27;ve designed respects the OpenShift-native tooling you already rely on, keeps disruption to a minimum, and leaves you on a solid foundation for future upgrades to 8.x, 9.x, and beyond.</p><h2>Why this matters</h2><ul><li><strong>Security and support:</strong> The last Elasticsearch 6.x patch release was on January 13, 2022. ECK lets you upgrade at your own pace, with a supported operator from the creators of Elasticsearch. Remaining on an old Elasticsearch version exposes you to supportability risks or well-known security issues.</li><li><strong>Features you’ve been missing:</strong> Autoscaling, data tiers, machine learning (ML) jobs, searchable snapshots. None of these are available in the legacy operator.</li></ul><p><strong>Future-proof operations:</strong> ECK ships <em>day-and-date</em> with every new Elastic release, so you’re never stuck waiting again.</p><h2>High-level plan</h2>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Phase</td><td style="border: 1px solid #ccc; padding: 4px;">Goal</td><td style="border: 1px solid #ccc; padding: 4px;">Outcome</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">0</td><td style="border: 1px solid #ccc; padding: 4px;">Snapshot and sanity-check your 6.x cluster.</td><td style="border: 1px solid #ccc; padding: 4px;">You have a backup in case you need it.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1</td><td style="border: 1px solid #ccc; padding: 4px;">Install ECK 2.16.1 alongside the Red Hat operator.</td><td style="border: 1px solid #ccc; padding: 4px;">Both operators coexist safely.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td><td style="border: 1px solid #ccc; padding: 4px;">Spin up a fresh, production-ready ES 6.8.23 cluster managed by ECK.</td><td style="border: 1px solid #ccc; padding: 4px;">Empty ECK-managed cluster.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">Restore data into that new cluster.</td><td style="border: 1px solid #ccc; padding: 4px;">All indices now live under ECK.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4</td><td style="border: 1px solid #ccc; padding: 4px;">Point openshift-logging to the new service and retire the old operator.</td><td style="border: 1px solid #ccc; padding: 4px;">Single source of truth.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5</td><td style="border: 1px solid #ccc; padding: 4px;">Rolling-upgrade Elasticsearch to 7.17.28.</td><td style="border: 1px solid #ccc; padding: 4px;">Latest long-term 7.x.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">6</td><td style="border: 1px solid #ccc; padding: 4px;">Upgrade ECK to 3.3.1.</td><td style="border: 1px solid #ccc; padding: 4px;">Operator on current release.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">7</td><td style="border: 1px solid #ccc; padding: 4px;">Schedule your own jump to 8.x & 9.x.</td><td style="border: 1px solid #ccc; padding: 4px;">You control the timing.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">8</td><td style="border: 1px solid #ccc; padding: 4px;">Clean up</td><td style="border: 1px solid #ccc; padding: 4px;">Removing old operator</td>
                    </tr>
              </tbody>
            </table>
          <p>Feel free to bookmark this list. Each milestone is small, reversible, and validated before you move on.</p><h3>0. Preflight checks</h3><p><strong>A. Health first: </strong>Run <code>/_cat/health</code> and make sure you’re green.</p><p><strong>B. Disk watermarks:</strong> Keep at least 20% free before starting a migration.</p><p><strong>C. Final snapshot:</strong> S3, GCS, NFS: It doesn’t matter, as long as you can mount the same repo in the new cluster.</p><ol><li>If you don’t have object storage handy in your environment, you can use <a href="https://access.redhat.com/solutions/6408341">this solution-post by Red Hat</a> to snapshot your data to local storage on the OpenShift cluster.</li></ol><p><strong>D. Review the documentation:</strong> Elastic provides thorough documentation for <a href="https://www.elastic.co/docs/manage-data/migrate/migrate-data-between-elasticsearch-clusters-with-minimal-downtime">migrating data between Elasticsearch clusters</a>.</p><h3>1. Installing ECK 2.16.1 (your “bridge” operator)</h3><p>ECK 2.16.1 is the last release that still accepts <code>spec.version: 6.8.x</code>, which makes it the ideal bridge between past and future Elasticsearch versions.</p><pre><code>helm repo add elastic https://helm.elastic.co
helm repo update
oc create namespace elastic-system 

helm install elastic-operator elastic/eck-operator --version=2.16.1 -n elastic-system --create-namespace</code></pre><p>You can keep the Red Hat operator in place; the two watch <em>different</em> Custom Resource Definitions (CRDs), so they won’t step on each other’s toes.</p><p>Keep in mind that with OpenShift, ECK might display some Transport Layer Security (TLS) errors in its logs as OpenShift tries to connect to its healthcheck webhook endpoint via HTTP, but ECK allows TLS communication only. This is a well-known issue and shouldn’t pose a problem.</p><p>You can refer further to <a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s/install-using-helm-chart">Elastic documentation</a>, in case you need to make a local namespaced installation.</p><h3>2. Launching a 6.x cluster under ECK</h3><p>Below is a starter Kubernetes manifest that balances resiliency (separate masters) with cost (three hot-tier data nodes). Swap storage class names, resources, and snapshot credentials to match your environment.</p><p><strong>Note:</strong> The syntax used below is a bit different than what it would be for newer Elasticsearch versions on ECK.</p><pre><code>apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: es-logs
  namespace: elastic # Create this namespace prior, or use another namespace
spec:
  version: 6.8.23
  nodeSets:
    - name: hot
      count: 3
      volumeClaimTemplates:
        - metadata:
            name: elasticsearch-data
          spec:
            accessModes:
              - ReadWriteOnce
            storageClassName: gp3-csi   # adjust if needed
            resources:
              requests:
                storage: 100Gi # Storage may vary depending on  
      config:
        node.master: true
        node.data: true
        node.ingest: true
        node.attr.data: hot
        cluster.routing.allocation.awareness.attributes: data
      podTemplate:
        spec:
          containers:
            - name: elasticsearch
              resources:
                requests:
                  memory: 16Gi
                  cpu: 2
                limits:
                  memory: 16Gi
---
apiVersion: kibana.k8s.elastic.co/v1
kind: Kibana
metadata:
  name: kibana
  namespace: elastic
spec:
  version: 6.8.23
  count: 1
  elasticsearchRef:
    name: es-logs
  podTemplate:
    spec:
      containers:
        - name: kibana
          resources:
            requests:
              memory: 1Gi
              cpu: 0.5
            limits:
              memory: 4Gi</code></pre><p>Deploy it, watch pods come up, and you’re ready for data.</p><h3>3. Moving the data</h3><p>To move data from one Elasticsearch cluster to another, you can also further consult <a href="https://www.elastic.co/docs/manage-data/migrate/migrate-data-between-elasticsearch-clusters-with-minimal-downtime">this guide</a> in the Elastic documentation. For the purpose of this post, we’re assuming that snapshot and restore are used.</p><p><strong>Snapshot and restore</strong> are quickest:</p><pre><code># on the old cluster, take a snapshot
PUT _snapshot/log-backups
{
  "type": "s3",
  "settings": { ... }
}

PUT _snapshot/log-backups/final-snap-2025-08-07

# on the new cluster (readonly!)
PUT _snapshot/log-backups
{
  "type": "s3",
  "settings": {
    "readonly": true,
    ...
  }
}

# Perform the restore operation
POST _snapshot/log-backups/final-snap-2025-08-07/_restore</code></pre><p>Can’t share an object store? Use <a href="https://www.elastic.co/docs/manage-data/migrate/migrate-from-a-self-managed-cluster-with-a-self-signed-certificate-using-remote-reindex"><strong>remote re-index</strong></a> (slower, but works everywhere; has drawbacks in terms of not migrating index templates, component templates, and more) or pump logs through a one-off Logstash job.</p><h3>4. Configuring ClusterLogging operator</h3><p>First, we’ll need to decommission our Red Hat operator–managed Elasticsearch cluster. We’ll modify our ClusterLogging like so:</p><pre><code>oc edit clusterlogging instance -n openshift-logging 
---------
 logStore:
    elasticsearch:
      nodeCount: 0 # scale down node count, previously > 0 
      redundancyPolicy: ZeroRedundancy
    type: elasticsearch
  managementState: Managed # this needs to be kept, as it will manage the fluentd instance for us.
  visualization:
    kibana:
      replicas: 0 # scale down kibana as well 
    type: kibana</code></pre><p>Then we’ll define a ClusterLogForwarder to direct the logs from fluentd to our newly built Elasticsearch 6.x cluster managed by ECK. We’ll need to create a secret with the Elasticsearch credentials:</p><pre><code>oc create secret generic eck-es-credentials \
  -n openshift-logging \
  --from-literal=username=elastic \
  --from-literal=password=$(oc get secret es-logs-es-elastic-user -n elastic -o jsonpath='{.data.elastic}' | base64 -d)</code></pre><p>For configuring TLS (as recommended), you’ll need to create a ConfigMap for ClusterLogForwarder to trust the ECK ca certificates. Further <a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.16/html/logging/log-collection-and-forwarding?utm_source=chatgpt.com">guidance can be found here</a>. We’ll run the command: </p><pre><code>oc -n elastic get secret es-logs-es-http-certs-public \
-o go-template='{{index .data "tls.crt" | base64decode}}' > ca.crt

oc -n openshift-logging create configmap eck-es-ca \
--from-file=ca-bundle.crt=ca.crt</code></pre><p>To create the certificate secret, and then we’ll reference it in the ClusterLogging CRD:</p><pre><code>apiVersion: logging.openshift.io/v1
kind: ClusterLogForwarder
metadata:
  name: instance
  namespace: openshift-logging
spec:
  outputs:
    - name: eck-es
      type: elasticsearch
      url: https://es-logs-es-http.elastic.svc:9200
      secret:
        name: eck-es-credentials # this secret needs to be created first
      tls:
        # insecureSkipVerify: true # can be used for lab testing purposes
        ca:
          name: eck-es-ca
  pipelines:
    - name: send-to-eck
      inputRefs:
        - application
        - infrastructure
        - audit
      outputRefs:
        - eck-es</code></pre><p>⚠️ <em>If you’re troubleshooting connectivity issues, you can temporarily set <code>tls.insecureSkipVerify: true</code>, but this shouldn’t be used long term.</em></p><p>Because we’re restoring legacy indices into a fresh ECK-managed cluster, OpenShift Logging will not automatically recreate the legacy index layout or aliases. You must ensure that write aliases exist and point to writable indices. In my case, I needed to verify that I have proper aliases, set up as:</p><ul><li>app-write</li><li><code>infra-write</code></li><li><code>audit-write</code></li></ul><p>Pointing to indices with dynamic mappings (not recommended) for minimizing errors and troubleshooting steps. </p><pre><code># Forward ES port to local machine
oc -n elastic port-forward svc/es-logs-es-http 9200:9200

PASS="$(oc -n elastic get secret es-logs-es-elastic-user -o jsonpath='{.data.elastic}' | base64 -d)"

# Make sure the write alias points to the correct backing index
curl -s -k -u "elastic:${PASS}" -XPOST "https://localhost:9200/_aliases" \
  -H 'Content-Type: application/json' \
  -d '{
    "actions": [
      { "add": { "index": "infra-000002", "alias": "infra-write", "is_write_index": true } }
    ]
  }'</code></pre><p>Repeat for <code>app-write</code> and <code>audit-write</code> with their respective backing indices.We should see data start flowing now toward our new ECK managed cluster.</p><h3>5. Rolling upgrade to 7.17.29, and verify</h3><p>Now you can finally leave 6.x behind.</p><p>A. Check <code>_xpack/migration/deprecations?pretty</code> using curl against Elasticsearch, to tackle deprecations. This API will return either warnings or critical things to attend to before you upgrade.</p><p>B. Patch the CRD to upgrade it to the latest 7.x version. I’m using 7.17.29.</p><pre><code>oc -n elastic patch elasticsearch es-logs --type=merge -p '{"spec":{"version":"7.17.29"}}'</code></pre><p>C. ECK restarts nodes <em>one at a time</em>. Your cluster should be online throughout.</p><p>D. Give cluster tasks and shard recoveries time to settle before pressing on.</p><p>E. Don’t forget to upgrade Kibana in the same way.</p><pre><code>oc -n elastic patch kibana kibana --type=merge -p '{"spec":{"version":"7.17.29"}}'</code></pre><p>Once complete, check your Elasticsearch version and Kibana version, as well as the health state:</p><pre><code>oc -n elastic get elasticsearch es-logs
oc -n elastic get kibana kibana</code></pre><h3>6. Operator upgrade: ECK 2.16.1 → 3.3.1</h3><p>ECK upgrades are pleasantly boring:</p><pre><code>helm upgrade elastic-operator elastic/eck-operator -n elastic-system --version 3.3.1</code></pre><p>Watch the operator pod roll. Your Elasticsearch cluster keeps running; only the controller restarts.</p><p>Verify that the upgrade is successful by looking at the operator logs and ensuring that no major errors appear:</p><pre><code>oc logs -n elastic-system sts/elastic-operator</code></pre><p>And then verifying the new version of the operator (will now be 3.3.1):</p><pre><code>helm -n elastic-system list</code></pre><h3>7. Your roadmap to 8.x and 9.x (when you’re ready)</h3><p>You’re now on:</p><ul><li><strong>ECK Operator:</strong> 3.3.1</li><li><strong>Elastic Stack:</strong> 7.17.29</li></ul><p>That pair is fully supported and serves as the official launchpad for 8.x. It’s important to first go through the <a href="https://www.elastic.co/guide/en/security/8.19/upgrade-7.17-8x.html">Elastic upgrade documentation</a>.</p><p>We’ll again go through the procedure of checking for any hard-breaking changes between our 7.17.29 and the latest 8 version (8.19.9):</p><pre><code>GET _migration/deprecations?pretty</code></pre><p>It&#x27;s important to look through the result of this query carefully and to go through necessary steps, like re-indexing indices and changing mappings, among others.</p><p>Once you’ve addressed all required changes from 7.17.29 to 8.x:</p><pre><code>oc -n elastic patch elasticsearch es-logs --type=merge -p '{"spec":{"version":"8.19.9"}}'
oc -n elastic patch kibana kibana --type=merge -p '{"spec":{"version":"8.19.9"}}'</code></pre><p>ECK will handle the rest. Just remember to upgrade Beats, Logstash pipelines, and client libraries in lockstep to avoid wire-protocol surprises.</p><p>Repeat the process again to migrate to the <strong>latest 9.x</strong> version.</p><h3>8. Cleanup</h3><ul><li>Remove the Red Hat Elasticsearch operator.</li></ul><p>Now that you’re no longer using the Red Hat Elasticsearch operator, you can remove it from your cluster. You can do that via the following steps:</p><p>A. In the OpenShift Console, go to <strong>Operators</strong> and then to <strong>Installed Operators</strong>.</p><p>B. In the <strong>Filter By Name</strong> field, enter “Elasticsearch” to find the installed Red Hat Elasticsearch operator.</p><p>C. On the <strong>Operator Details</strong> page, select <strong>Uninstall Operator</strong> from the <strong>Actions</strong> list.</p><p>D. On the <strong>Uninstall Operator?</strong> dialog box, select <strong>Uninstall</strong>. This removes the operator, the operator deployments, and the pods. After this step, the operator stops running and will no longer receive updates.</p><p>All of these steps can be found in <a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.16/html-single/operators/index#olm-deleting-operators-from-a-cluster-using-web-console_olm-deleting-operators-from-a-cluster">this link</a> from Red Hat OpenShift documentation.</p><h2>Wrapping up</h2><p>By installing <strong>ECK 2.16.1</strong> as a bridge, snapshot-restoring into a new cluster, and stepping cleanly through 7.x before landing on <strong>ECK 3.3</strong>, you’ve transformed an aging, unsupported logging back end into a modern, secure, first-class Elastic deployment, without surprises or downtime.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Semantic search, now multilingual by default]]></title>
            <description><![CDATA[semantic_text now defaults to jina-embeddings-v5-text on Elastic Inference Service, enabling multilingual semantic search in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/multilingual-semantic-search-jina-embeddings-v5-text</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multilingual-semantic-search-jina-embeddings-v5-text</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Kapil Jadhav, Brendan Jugan, Ranjana Devaji]]></dc:creator>
            <pubDate>Tue, 07 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/205fc7de02c5d84b99624bffc0488a46df2d3b07-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Today, we’re pleased to announce that <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> now defaults to the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text"><code>jina-embeddings-v5-text</code></a> family on <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a>, bringing built-in multilingual inference with no additional configuration required.</p><p>EIS provides managed, GPU-accelerated inference tightly integrated with Elasticsearch. With EIS, you don’t need to host, scale, or maintain infrastructure for embedding models.</p><p><a href="https://www.elastic.co/docs/solutions/search/semantic-search">Semantic search</a> retrieves results based on meaning. Text is converted into vector embeddings so queries can match related concepts, even when the exact words differ.</p><p>The <code>semantic_text</code> field type simplifies this entire workflow, with <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">automatic chunking</a>, embedding generation at index time, and seamless querying via the <code>semantic</code> query, without building custom pipelines or managing separate model inference.</p><p>The <code>jina-embeddings-v5-text</code> model family <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">just launched on EIS</a>, giving developers powerful multilingual embeddings accessible as part of the core <code>semantic_text</code> workflow. So now your semantic search works across languages out of the box, and global datasets, such as support articles, product descriptions, user reviews, and multilingual websites, work without extra configuration.</p><p>This default opens up broad, globe-spanning semantic retrieval with no operational overhead.</p><h2>jina-embeddings-v5-text</h2><p>The <strong><code>jina-embeddings-v5-text</code></strong> models represent the latest generation of compact, high-performance multilingual embedding models on EIS.</p><ul><li><strong>State-of-the-art multilingual performance:</strong> Top scores on <a href="https://huggingface.co/spaces/mteb/leaderboard">MMTEB benchmarks</a> across hundreds of languages. <code>jina-embeddings-v5-text-nano</code> leads models under 500M parameters, and <code>jina-embeddings-v5-text-small</code> outperforms significantly larger alternatives.</li><li><strong>Multiple task capabilities:</strong> Spanning across retrieval, semantic matching, clustering, and classification.</li><li><strong>Flexible choices to fit your use case:</strong> Two model sizes (<code>small</code>, <code>nano</code>) let you balance speed, cost, and quality.</li><li><strong>Long-context support:</strong> Embed long texts efficiently, ideal for document collections with extended context.</li></ul><h2>Get started</h2><h3>1. Create index</h3><p>Define a <code>semantic_text</code> field with no additional configuration. Embeddings will be generated automatically at index time using the default model. For production workloads, explicitly specify the model to ensure consistent behavior and results.</p><pre><code>PUT /multilingual-reviews
{
  "mappings": {
    "properties": {
      "product": { "type": "keyword" },
      "review": { "type": "semantic_text" },
      "language": { "type": "keyword" }
    }
  }
}</code></pre><h3>2. Index multilingual documents</h3><p>Add product reviews in six different languages. Each document’s review field is automatically embedded at ingest time, with no separate pipeline or preprocessing needed.</p><pre><code>POST /multilingual-reviews/_bulk?refresh=wait_for
{ "index": { "_id": "1" } }
{ "product": "wireless-headphones", "review": "Amazing noise cancellation and the battery lasts all day. Perfect for long flights.", "language": "en" }
{ "index": { "_id": "2" } }
{ "product": "wireless-headphones", "review": "La cancelación de ruido es impresionante. Muy cómodos incluso después de horas de uso.", "language": "es" }
{ "index": { "_id": "3" } }
{ "product": "wireless-headphones", "review": "ノイズキャンセリングが素晴らしく、長時間つけていても耳が痛くなりません。", "language": "ja" }
{ "index": { "_id": "4" } }
{ "product": "wireless-headphones", "review": "Réduction de bruit excellente et très confortable pour les longs trajets en avion.", "language": "fr" }
{ "index": { "_id": "5" } }
{ "product": "wireless-headphones", "review": "Hervorragende Geräuschunterdrückung. Ideal für Pendler und Vielflieger.", "language": "de" }
{ "index": { "_id": "6" } }
{ "product": "wireless-headphones", "review": "O cancelamento de ruído é excelente e a bateria dura o dia todo.", "language": "pt" }</code></pre><h3>3. Search across languages with a query in English</h3><pre><code>GET /multilingual-reviews/_search
{
  "query": {
    "match": {
      "review": "comfortable for long flights"
    }
  }
}</code></pre><p>The results show all six reviews ranked by semantic relevance to the English query:</p><pre><code>{
  "took": 83,
  "timed_out": false,
  "_shards": {
    "total": 6,
    "successful": 6,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 0.8275735,
    "hits": [
      {
        "_index": "multilingual-reviews",
        "_id": "4",
        "_score": 0.8275735,
        "_source": {
          "product": "wireless-headphones",
          "review": "Réduction de bruit excellente et très confortable pour les longs trajets en avion.",
          "language": "fr"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "1",
        "_score": 0.7616198,
        "_source": {
          "product": "wireless-headphones",
          "review": "Amazing noise cancellation and the battery lasts all day. Perfect for long flights.",
          "language": "en"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "5",
        "_score": 0.72122526,
        "_source": {
          "product": "wireless-headphones",
          "review": "Hervorragende Geräuschunterdrückung. Ideal für Pendler und Vielflieger.",
          "language": "de"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "2",
        "_score": 0.6867013,
        "_source": {
          "product": "wireless-headphones",
          "review": "La cancelación de ruido es impresionante. Muy cómodos incluso después de horas de uso.",
          "language": "es"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "3",
        "_score": 0.66513836,
        "_source": {
          "product": "wireless-headphones",
          "review": "ノイズキャンセリングが素晴らしく、長時間つけていても耳が痛くなりません。",
          "language": "ja"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "6",
        "_score": 0.61658823,
        "_source": {
          "product": "wireless-headphones",
          "review": "O cancelamento de ruído é excelente e a bateria dura o dia todo.",
          "language": "pt"
        }
      }
    ]
  }
}</code></pre><p>Notice that the French review ranks first, even above the English one. That&#x27;s because &quot;<em>très confortable pour les longs trajets en avion</em>&quot; (&quot;very comfortable for long trips by plane&quot;) is a closer semantic match to the query than the English review, which splits its focus across noise cancellation, battery life, and flights. This demonstrates the jina-embeddings-v5-text-small ability to rank by meaning, not language.</p><h3>4. Search across languages with a Japanese query</h3><pre><code>GET /multilingual-reviews/_search
{
  "query": {
    "match": {
      "review": "長時間のフライトに最適"
    }
  }
}</code></pre><p>The results show all six reviews ranked by semantic relevance to the Japanese query (“Ideal for long-haul flights”):</p><pre><code>{
  "took": 89,
  "timed_out": false,
  "_shards": {
    "total": 6,
    "successful": 6,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 0.7556782,
    "hits": [
      {
        "_index": "multilingual-reviews",
        "_id": "4",
        "_score": 0.7556782,
        "_source": {
          "product": "wireless-headphones",
          "review": "Réduction de bruit excellente et très confortable pour les longs trajets en avion.",
          "language": "fr"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "1",
        "_score": 0.7395687,
        "_source": {
          "product": "wireless-headphones",
          "review": "Amazing noise cancellation and the battery lasts all day. Perfect for long flights.",
          "language": "en"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "5",
        "_score": 0.68835545,
        "_source": {
          "product": "wireless-headphones",
          "review": "Hervorragende Geräuschunterdrückung. Ideal für Pendler und Vielflieger.",
          "language": "de"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "3",
        "_score": 0.6487931,
        "_source": {
          "product": "wireless-headphones",
          "review": "ノイズキャンセリングが素晴らしく、長時間つけていても耳が痛くなりません。",
          "language": "ja"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "6",
        "_score": 0.6241487,
        "_source": {
          "product": "wireless-headphones",
          "review": "O cancelamento de ruído é excelente e a bateria dura o dia todo.",
          "language": "pt"
        }
      },
      {
        "_index": "multilingual-reviews",
        "_id": "2",
        "_score": 0.6183049,
        "_source": {
          "product": "wireless-headphones",
          "review": "La cancelación de ruido es impresionante. Muy cómodos incluso después de horas de uso.",
          "language": "es"
        }
      }
    ]
  }
}</code></pre><p>The ranking is nearly identical to the English query: French and English still lead because they&#x27;re the most semantically relevant to &quot;perfect for long flights,&quot; regardless of query language. The Japanese review didn&#x27;t get artificially boosted just because the query was in Japanese. It ranks fourth because it discusses wearing comfort, not flights. Semantic relevance takes priority over language matching.</p><h2>Note: For English-only use cases</h2><p>If you prefer a sparse representation or would like to continue to use Elastic Learned Sparse EncodeR (ELSER) for English workloads, ELSER remains available and fully supported as an option for <code>semantic_text</code>.</p><p>You can explicitly choose ELSER by specifying <code>inference_id: &quot;.elser-2-elastic</code> in your mappings when creating an index.</p><h2>Conclusion: Semantic search without borders</h2><p>With <code>semantic_text</code> now defaulting to the <code>jina-embeddings-v5-text</code> family on Elastic Inference Service, multilingual semantic search becomes the standard developer experience in Elasticsearch. This means developers can build search, retrieval augmented generation (RAG), and AI applications that work across global datasets without stitching pipelines together.</p><p>Create a <code>semantic_text</code> field, index your data, and start searching. All Elastic Cloud trials have access to Elastic Inference Service. Try it now on <a href="https://cloud.elastic.co/serverless-registration">Elastic Cloud Serverless</a> or Elastic Cloud Hosted, or use <a href="https://www.elastic.co/search-labs/blog/cloud-connect-elastic-inference-service">EIS via Cloud Connect</a> with your self-managed cluster.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to compare two Elasticsearch indices and find missing documents]]></title>
            <description><![CDATA[Exploring approaches for comparing two Elasticsearch indices and finding missing documents.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-index-comparison</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-index-comparison</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[David Pilato]]></dc:creator>
            <pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/099abf465250360ab741a5aa13931fa8884ded34-1376x768.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>When managing Elasticsearch indices, you may need to verify that all documents present in one index also exist in another, such as after a reindex operation, a migration, or a data pipeline. Elasticsearch doesn&#x27;t provide a built-in &quot;diff&quot; command for this, but the right approach depends on one key question: <strong>Are your document IDs stable between the two indices?</strong></p><h2>The problem</h2><p>Imagine you have two indices, <code>index-a</code> (source) and <code>index-b</code> (target), and you want to find all documents that exist in <code>index-a</code> but are missing from <code>index-b</code>.</p><p>A naive approach, querying both indices and comparing results in memory, won&#x27;t scale. Elasticsearch is designed to handle millions of documents, and loading them all at once isn’t practical.</p><p>There are two scenarios:</p><ol><li><strong>IDs are stable</strong>: Both indices use the same <code>_id</code> for the same document (for example, <code>emp_no</code> as the document ID). This is the easy case.</li><li><strong>IDs are generated</strong>: Documents were ingested through different pipelines that assigned random or sequential IDs. You can&#x27;t compare by <code>_id</code>; you need to match on content.</li></ol><p>Let&#x27;s walk through both.</p><h2>Step 0 — A lighter CLI for Elasticsearch</h2><p>All the examples in this post use <a href="https://github.com/Anaethelion/escli-rs">escli</a>, a small Rust command line interface (CLI) that wraps the Elasticsearch REST API. It reads your cluster URL and credentials from environment variables, so you don’t have to repeat authentication headers on every command.</p><p>To see why that matters, here&#x27;s a typical <code>_search</code> call with raw <code>curl</code>:</p><pre><code>curl -X GET \
  -H "Authorization: ApiKey $ELASTIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":{"term":{"user.id":"kimchy"}}}' \
  "$ELASTICSEARCH_URL/my-index-000001/_search"</code></pre><p>With <code>escli</code>, the same request becomes:</p><pre><code>./escli search --index my-index-000001 <<< '{"query":{"term":{"user.id":"kimchy"}}}'</code></pre><p>The credentials live in a <code>.env</code> file that escli sources automatically — no <code>-H &quot;Authorization: ...&quot;</code> on every call, no risk of leaking secrets in shell history. The request body is passed via stdin (<code>&lt;&lt;&lt;</code>), which makes it easy to pipe in multi-line JSON built dynamically with <code>jq</code>.</p><h2>Step 1 — Count documents in both indices</h2><p>Before doing a full scan, get a quick count of each index. If the counts match, the indices are likely in sync, and there’s no need to scan at all.</p><pre><code>./escli count --index index-a
./escli count --index index-b</code></pre><p>The <code>_count</code> API returns:</p><pre><code>{ "count": 1000000 }</code></pre><p>If the counts differ, proceed to the full comparison.</p><h2>Step 2 — When IDs mean something: Use op_type=create</h2><p>If both indices use the same <code>_id</code> for the same document, for example, because you indexed documents using a functional business key like <code>emp_no</code> rather than a generated UUID, you can find and fix missing documents in a single <code>_reindex</code> call.</p><h3>Why functional IDs matter</h3><p>Using a meaningful field as <code>_id</code> (instead of a random UUID) is a best practice when the data has a natural key. It means:</p><ul><li>The same document always gets the same <code>_id</code>, regardless of which pipeline ingested it.</li><li>You can easily update or delete documents by ID.</li><li>You can use <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-index#operation-index-op_type"><code>op_type=create</code></a> to skip documents that already exist in the target.</li><li>No client-side scanning or comparison is needed.</li></ul><h3>The op_type=create trick</h3><p><code>_reindex</code> with <code>op_type=create</code> tries to create each document from the source in the target. If a document with the same <code>_id</code> already exists, Elasticsearch reports it as a <code>version_conflict</code> and moves on. It <strong>doesn’t</strong> overwrite the existing document. Setting <code>conflicts=proceed</code> tells the API to continue instead of aborting on the first conflict.</p><pre><code>./escli reindex <<< '{
  "source": { "index": "index-a" },
  "dest":   { "index": "index-b", "op_type": "create" },
  "conflicts": "proceed"
}'</code></pre><p>The response tells you exactly what happened:</p><pre><code>{
  "total": 1000000,
  "created": 49594,
  "version_conflicts": 950406,
  "failures": []
}</code></pre><ul><li><code>created</code>: Documents that were missing from <code>index-b</code> and have now been added.</li><li><code>version_conflicts</code>: Documents that already existed in <code>index-b</code> and were left untouched.</li></ul><p><strong>No scanning, no client-side comparison, no intermediate file.</strong> Everything happens server-side in about six seconds on a 1M-document dataset.</p><h2>Step 3 — When IDs are not stable: Business-key comparison</h2><p>Sometimes you can&#x27;t rely on <code>_id</code>. A document pipeline that generates IDs at ingestion time will assign a different <code>_id</code> each time the same record is processed. If <code>index-a</code> and <code>index-b</code> were populated by two such pipelines, the same employee record might have <code>_id: &quot;abc123&quot;</code> in one index and <code>_id: &quot;xyz789&quot;</code> in the other, even though the underlying data is identical.</p><p>In this case, you need to match documents by content rather than by ID. The key is to identify a set of fields that together form a unique business key.</p><p>For an employee dataset, a reasonable business key is <code>(first_name, last_name, birth_date)</code>. A document in <code>index-a</code> is &quot;missing&quot; from <code>index-b</code> if no document in <code>index-b</code> has the same combination of those three fields.</p><h3>3a — Scan the source with PIT + search_after</h3><p>Open a <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-open-point-in-time">point in time (PIT)</a> on the source index to get a consistent snapshot, and then paginate through it, fetching only the business-key fields:</p><pre><code>./escli open_point_in_time index-a 5m
# → { "id": "46ToAwMDaWR..." }</code></pre><pre><code>./escli search <<< '{
  "size": 10000,
  "_source": ["first_name", "last_name", "birth_date"],
  "pit": { "id": "46ToAwMDaWR...", "keep_alive": "5m" },
  "sort": [{ "_shard_doc": "asc" }]
}'</code></pre><p>The sort key <code>_shard_doc</code> is the most efficient sort for full-index pagination: it uses the internal Lucene document order with no overhead. Repeat with <code>search_after</code> until the response contains zero hits. Always close the PIT when done:</p><pre><code>./escli close_point_in_time <<< '{"id": "46ToAwMDaWR..."}'</code></pre><h3>3b — Check each page against the target via _msearch</h3><p>For each page of source documents, build one <code>_msearch</code> request with one subquery per document. Each subquery uses a <code>bool/must</code> on the three business-key fields and requests <code>size: 0</code>; we only need to know whether a match exists, we don’t need to retrieve the document itself.</p><pre><code>./escli msearch << 'EOF'
{"index": "index-b"}
{"size":0,"query":{"bool":{"must":[{"term":{"first_name.keyword":"Alice1"}},{"term":{"last_name.keyword":"Smith"}},{"term":{"birth_date":"1985-03-12"}}]}}}
{"index": "index-b"}
{"size":0,"query":{"bool":{"must":[{"term":{"first_name.keyword":"Bob2"}},{"term":{"last_name.keyword":"Jones"}},{"term":{"birth_date":"1990-07-24"}}]}}}
EOF</code></pre><p>The response contains one entry per subquery, in the same order:</p><pre><code>{
  "responses": [
    { "hits": { "total": { "value": 1 } } },
    { "hits": { "total": { "value": 0 } } }
  ]
}</code></pre><p><code>total.value == 0</code> means no document in <code>index-b</code> matches that business key; the document is missing. Collect the corresponding <code>_id</code> from the source page.</p><blockquote><strong>Note on</strong> <strong><code>.keyword</code></strong> <strong>subfields</strong>: <code>term</code> queries require exact (keyword) matching. The <code>first_name</code> and <code>last_name</code> fields must have a <code>.keyword</code> subfield in the index mapping. The demo&#x27;s <code>mapping.json</code> includes this.</blockquote><h3>3c — Speed it up with split-by-date</h3><p>If the business key includes a date field, you can partition the source into date slices and run each slice as an independent job. Each slice opens its own PIT with a <code>range</code> filter on <code>birth_date</code>, runs its own msearch loop, and writes its results to a separate file. The parent script launches all slices in parallel and aggregates the results when they’re all done.</p><p>But depending on your use case, you might want to partition by a different field; for example, if you have a <code>team</code> field, you could run one slice per team. The key is to find a field that allows you to split the data into reasonably even chunks that can be processed in parallel.</p><pre><code>[compare] Launching 5 slices in parallel...

  → Slice 1: 1960-01-01 → 1969-12-31 ✅ — 244408 checked, 12207 missing
  → Slice 2: 1970-01-01 → 1979-12-31 ✅ — 243624 checked, 12212 missing
  → Slice 3: 1980-01-01 → 1989-12-31 ✅ — 243551 checked, 11921 missing
  → Slice 4: 1990-01-01 → 1999-12-31 ✅ — 243895 checked, 11991 missing
  → Slice 5: 2000-01-01 → 2009-12-31 ✅ — 24522 checked, 1263 missing</code></pre><h2>Performance on a 1M dataset</h2><p>To validate the approaches, the demo generates 1,000,000 documents in <code>index-a</code> and deliberately skips ~5% in <code>index-b</code> (49,594 missing documents), and then runs the full compare → reindex cycle.</p><p>Results on a MacBook M3 Pro:</p><p><strong>Comparison</strong> (<code>compare-indices.sh</code>):</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Strategy</td><td style="border: 1px solid #ccc; padding: 4px;">Compare</td><td style="border: 1px solid #ccc; padding: 4px;">Reindex</td><td style="border: 1px solid #ccc; padding: 4px;">Total</td><td style="border: 1px solid #ccc; padding: 4px;">How it works</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">op_type</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">6s</td><td style="border: 1px solid #ccc; padding: 4px;">6s</td><td style="border: 1px solid #ccc; padding: 4px;">Full _reindex server-side, skips existing</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">business-key</td><td style="border: 1px solid #ccc; padding: 4px;">1m 38s</td><td style="border: 1px solid #ccc; padding: 4px;">4s</td><td style="border: 1px solid #ccc; padding: 4px;">1m 42s</td><td style="border: 1px solid #ccc; padding: 4px;">PIT scan + _msearch by business key</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">split-by-date</td><td style="border: 1px solid #ccc; padding: 4px;">32s</td><td style="border: 1px solid #ccc; padding: 4px;">4s</td><td style="border: 1px solid #ccc; padding: 4px;">36s</td><td style="border: 1px solid #ccc; padding: 4px;">Same as business-key, 5 slices in parallel</td>
                    </tr>
              </tbody>
            </table>
          <p>The <code>op_type=create</code> approach is fastest because everything is server-side and requires no client-side scanning. The <code>split-by-date</code> strategy cuts the <code>business-key</code> duration from 1m 38s down to 36s through parallelism: not bad for a comparison across two 1M-document indices.</p><h2>Decision tree</h2><pre><code>Are _id values stable between both indices?
├── Yes → _reindex with op_type=create          (6s, server-side)
└── No  → Do you have a reliable business key?
          ├── Yes, simple scan is fast enough → business-key   (1m 42s)
          └── Yes, and you need more speed    → split-by-date  (36s, parallel)</code></pre><h2>Conclusion</h2><p>Elasticsearch doesn&#x27;t offer a native index diff command, but the right strategy depends on your data model:</p><ul><li><strong>Use functional</strong> <strong><code>_id</code>s</strong> (a natural business key like <code>emp_no</code>) whenever possible. It unlocks the simplest and fastest approach: <code>_reindex</code> with <code>op_type=create</code> finds and fills gaps in one server-side call.</li><li><strong>When IDs are unstable</strong>, match by business key using PIT + <code>_msearch</code>. Partition by a field and run slices in parallel to recover most of the performance. If you find yourself doing this regularly, consider computing a hash of your business key fields and using it as <code>_id</code> at ingestion time. You get the best of both worlds: stable IDs and efficient lookups.</li></ul><p>The complete demo, including dataset generation, comparison scripts, and reindex scripts, is available at <a href="https://github.com/dadoonet/blog-compare-indices/">https://github.com/dadoonet/blog-compare-indices/</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Monitoring Kibana dashboard views with Elastic Workflows]]></title>
            <description><![CDATA[Learn how to use Elastic Workflows to collect Kibana dashboard view metrics every 30 minutes and index them into Elasticsearch, so you can build custom analytics and visualizations on top of your own data.]]></description>
            <link>https://www.elastic.co/search-labs/blog/monitor-kibana-dashboard-views-elastic-workflows</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/monitor-kibana-dashboard-views-elastic-workflows</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
            <pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0ed94ce00962287b5507f45c92ecb60fdcbf2718-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/kibana">Kibana</a> tracks how many times each dashboard is viewed, but that data isn’t exposed natively in any built-in dashboard. In this article, we’ll use <strong>Elastic Workflows</strong> to automatically collect that data every 30 minutes and index it into Elasticsearch, so we can build our own analytics on top of it.</p><p><a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a> is a built-in automation engine inside Kibana that lets you define multistep processes using a simple YAML configuration. Each workflow can be triggered on a schedule or event or as a tool in <a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder">Elastic Agent Builder</a>, and each step can call Kibana APIs, query Elasticsearch, or transform data.</p><p>We’ll use dashboard view counts as a concrete example, but the same pattern applies to any metric exposed through the Kibana saved objects API.</p><h2>Prerequisites</h2><ul><li><a href="https://www.elastic.co/cloud">Elastic Cloud</a> or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed">self-managed </a>cluster running 9.3</li><li><a href="https://www.elastic.co/docs/explore-analyze/workflows/get-started#workflows-prerequisites">Workflows enabled</a> (Advanced settings)</li></ul><h2>Step 1: Explore the raw data in <a href="https://www.elastic.co/docs/explore-analyze/query-filter/tools/console">Dev Tools</a></h2><p>Before building anything, let&#x27;s understand what data we have. Kibana stores most of its configuration and metadata as <a href="https://www.elastic.co/docs/api/doc/kibana/group/endpoint-saved-objects">saved objects</a> in a dedicated internal index. One of the things Kibana tracks this way is dashboard view counts, using a special saved object type called usage counters. You can query them directly from Dev Tools:</p><pre><code>GET kbn:/api/saved_objects/_find?type=usage-counter&filter=usage-counter.attributes.domainId:"dashboard"%20and%20usage-counter.attributes.counterType:"viewed"&per_page=10000</code></pre><p>The response looks like this:</p><pre><code>{
  "page": 1,
  "per_page": 10000,
  "total": 1,
  "saved_objects": [
    {
      "type": "usage-counter",
      "id": "dashboard:346f3c64-ebca-484d-9d57-ec600067d596:viewed:server:20260310",
      "attributes": {
        "domainId": "dashboard",
        "counterName": "346f3c64-ebca-484d-9d57-ec600067d596",
        "counterType": "viewed",
        "source": "server",
        "count": 1
      },
      ...
    }
  ]</code></pre><p>The <code>counterName</code> field is the dashboard ID, and <code>count</code> is the cumulative view count for that dashboard on that specific day. Kibana creates one counter object per dashboard per day; you can see the date suffix in the object ID (...viewed:server:20260310). The count grows throughout the day as users open the dashboard.</p><p>Rather than replicating this daily-document model in our index, we’ll create one document per workflow execution. Each document records how many views that dashboard had accumulated for the day at the moment of capture.</p><h2>Step 2: Create the destination index</h2><p>We need an index to store our dashboard view snapshots. The following command creates it with explicit mappings so we can aggregate and visualize later. Run this in Dev Tools:</p><pre><code>PUT dashboard-views
{
  "mappings": {
    "properties": {
      "captured_at": {
        "type": "date"
      },
      "dashboard_id": {
        "type": "keyword"
      },
      "dashboard_name": {
        "type": "keyword"
      },
      "view_count": {
        "type": "integer"
      }
    }
  }
}</code></pre><p>Using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword"><code>keyword</code></a> mappings for IDs and names allows <a href="https://www.elastic.co/docs/explore-analyze/query-filter/aggregations">aggregations</a>. Using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/number"><code>integer</code></a> for <code>view_count</code> is a safe default, since Kibana resets the counter daily, reaching the 32-bit limit (more than 2 billion views in a single day) isn’t a realistic concern. It still supports numeric operations, like <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-max-aggregation"><code>max</code></a>, <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-avg-aggregation"><code>avg</code></a>, and <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-min-aggregation"><code>min</code></a> among others.</p><h2>Step 3: Create the workflow</h2><p>Go to <strong>Stack Management &gt; Workflows &gt; New Workflow</strong>, and paste the following workflow YAML configuration:</p><pre><code>name: dashboard-views-ingestion
triggers:
  - type: scheduled
    with:
      every: 30m

steps:
  - name: fetch_dashboard_views
    type: kibana.request
    with:
      method: GET
      path: >-
        /api/saved_objects/_find?type=usage-counter&per_page=10000&filter=usage-counter.attributes.domainId:"dashboard"%20and%20usage-counter.attributes.counterType:"viewed"

  - name: index_each_dashboard
    type: foreach
    foreach: "{{ steps.fetch_dashboard_views.output.saved_objects }}"
    steps:
      - name: fetch_dashboard_name
        type: kibana.request
        with:
          method: GET
          path: /api/saved_objects/dashboard/{{ foreach.item.attributes.counterName }}
        on-failure:
          continue: true

      - name: index_doc
        type: elasticsearch.request
        with:
          method: POST
          path: /dashboard-views/_doc
          body:
            dashboard_id: "{{ foreach.item.attributes.counterName }}"
            dashboard_name: "{{ steps.fetch_dashboard_name.output.attributes.title }}"
            view_count: "${{ foreach.item.attributes.count | plus: 0 }}"
            captured_at: "{{ execution.startedAt | date: '%Y-%m-%dT%H:%M:%SZ' }}"</code></pre><p>In the next section, let&#x27;s break down the workflow step by step.</p><h3>How the workflow works</h3><h4>Triggers</h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5670991d65c64ee833924225c2d375a1be868b13-325x162.png" style="width: 100%;" /><p>The workflow runs on a scheduled trigger every 30 minutes. This gives us time-series data without hammering the API.</p><h4>fetch_dashboard_views</h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/66eaec147c3d01c524c67cf1c7f663ac56a3259d-812x215.png" style="width: 100%;" /><p>Uses <code>kibana.request</code> to call the Kibana saved objects API. No authentication setup is needed: The workflow engine automatically attaches the correct headers based on the execution context.</p><h4>index_each_dashboard (foreach)</h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/aad45e8aed8dc81ded6260cd6199ff78dcffe3b4-1892x290.png" style="width: 100%;" /><p>Iterates over the <a href="https://www.elastic.co/docs/api/doc/kibana/group/endpoint-saved-objects"><code>saved_objects</code></a> array returned by the previous step. The current item in each iteration is available as <code>foreach.item</code>. Inside the loop, we run two nested steps for each dashboard.</p><p><strong>1. <code>fetch_dashboard_name</code>:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/db21ec5094b743018b9cd66c5052681f14c7d7e3-1999x431.png" style="width: 100%;" /><p>Resolves the human-readable dashboard title by calling <code>GET /api/saved_objects/dashboard/{id}</code>. We add <code>on-failure: continue: true</code> so that if a dashboard was deleted but still has view counters, the loop continues instead of failing the whole execution.</p><p><strong>2. <code>index_doc</code>:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f49dd0c9f0817bb1e1e5d9f4a2b05d13ef331054-1999x626.png" style="width: 100%;" /><p>Indexes each document using <code>POST /dashboard-views/_doc</code> (without an explicit ID), which lets Elasticsearch auto-generate IDs. This creates a new document on every run, building a history of view counts over time rather than overwriting the previous snapshot.</p><p>Two things worth noting:</p><ul><li>The <code>captured_at</code> field uses the date filter to format the timestamp as <a href="https://www.iso.org/iso-8601-date-and-time-format.html">ISO 8601</a>. Without it, the value comes out as a JavaScript date string, like <code>Tue Mar 10 2026 05:03:47 GMT+0000</code>, which Elasticsearch won&#x27;t map as a date.</li><li>The <code>view_count</code> uses <code>${{ }}</code> syntax with <code>| plus: 0</code> to preserve the numeric type. Using <code>{{ }}</code> would render it as a string, which would prevent math operations in the dashboard.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6d48c8784d5df6192e8b5175e69dbab5098194bc-919x774.png" style="width: 100%;" /><p><em>The UI allows you to nicely debug each of the workflow steps.</em></p><h2>Step 4: Build the stats dashboard</h2><p>Once the workflow has run a few times and data is collected, create a new dashboard in Kibana using the dashboard-views data view.</p><p>Some panels to start with:</p><ul><li><strong>Top dashboards by views:</strong> Use a <a href="https://www.elastic.co/docs/explore-analyze/visualize/charts/bar-charts"><strong>Bar chart</strong></a> with <code>dashboard_name</code> on the X axis and <code>last_value(view_count)</code> on the Y axis. This shows the current daily view count per dashboard.</li><li><strong>Views over time:</strong> Use a <a href="https://www.elastic.co/docs/explore-analyze/visualize/charts/line-charts"><strong>Line chart</strong></a> with <code>captured_at</code> on the X axis and <code>last_value(view_count)</code> on the Y axis, broken down by <code>dashboard_name</code>. Since each run appends a new document, use last value to get the peak count per time bucket rather than summing duplicates.</li><li><strong>Current snapshot:</strong> Use a <a href="https://www.elastic.co/docs/explore-analyze/visualize/charts/tables"><strong>Data table</strong></a> with the latest <code>captured_at</code> to show the most recent view counts across all dashboards.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e245b95f67daf76a2aaf4cb9df2c75ef4cfef582-1462x747.png" style="width: 100%;" /><p>Since each workflow creates a new document, you can filter by time range to analyze activity in specific periods, compare week over week, or build alerts when a dashboard drops below a view threshold.</p><h2><strong>Conclusion</strong></h2><p>Elastic Workflows is a good fit for this kind of periodic data collection because both the source (Kibana API) and the destination (Elasticsearch) are native, which means zero credential management. The workflow engine handles authentication automatically for <code>kibana.request</code> and <code>elasticsearch.request</code> steps, so the only thing you write is the logic.</p><h2><strong>Resources</strong></h2><ul><li><a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a></li><li><a href="https://www.elastic.co/docs/api/doc/kibana/">Kibana API</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[When TSDS meets ILM: Designing time series data streams that don't reject late data]]></title>
            <description><![CDATA[How TSDS time bounds interact with ILM phases; and how to design policies that tolerate late-arriving metrics.]]></description>
            <link>https://www.elastic.co/search-labs/blog/tsds-ilm-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/tsds-ilm-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Bret Wortman]]></dc:creator>
            <pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e4c46e4a6f746d9c845857e80de036f5d51cd4e7-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Recently, I migrated a customer&#x27;s metrics cluster from &quot;everything in the hot tier&quot; to a hot/cold/frozen architecture. It was a change I’d performed dozens of times before. Within minutes, Logstash stopped advancing data entirely.</p><p>Elasticsearch was rejecting late-arriving metrics. Those rejections caused the pipeline to fall behind, resulting in more late data, which triggered even more rejections. Eventually, the pipeline stalled completely.</p><p>We had to restore from snapshot, reindex the data, and redesign the ingestion pipeline to recover.</p><p>The root cause wasn&#x27;t index lifecycle management (ILM) itself. It was time series data streams (TSDS) and how they enforce time‑bound backing indices.</p><p>TSDS can reduce storage requirements for metrics by 40–70%, but the architectural changes that make TSDS efficient also alter how indices behave over time. Those changes matter when designing ILM policies or when your ingestion pipelines may produce late‑arriving data.</p><h2>TL;DR</h2><p>When using TSDS:</p><ul><li>Backing indices only accept documents within a specific time window.</li><li>If late data arrives after an index moves to cold or frozen, Elasticsearch rejects those documents or routes them to the failure store, if configured.</li></ul><p>Design rule:</p><pre><code>warm_min_age > rollover_max_age + maximum_expected_lateness</code></pre><h2>What is a time series data stream?</h2><p>A<em> time series data stream</em> (TSDS) is a specialized data stream optimized for metrics data. Data is routed so that related documents are located within the same shards, optimizing them for query and retrieval. Here’s how Elasticsearch does it:</p><p>Each document contains:</p><ul><li>A timestamp.</li><li>Dimension fields identifying the time series.</li><li>Metric fields representing measured values.</li></ul><p>Examples include:</p><ul><li>CPU usage per host.</li><li>Request latency per service.</li><li>Temperature readings per sensor.</li></ul><p><em>Dimensions </em>identify what we want to measure, while <em>metrics </em>represent values that change over time.</p><h3>Dimensions</h3><p>Dimensions describe the measured entity.</p><p>Examples:</p><pre><code>host.name
service.name
container.id</code></pre><p>We define them in mappings with:</p><pre><code>time_series_dimension: true</code></pre><h3>Metrics</h3><p>Metrics represent numeric values and are defined using:</p><pre><code>time_series_metric</code></pre><p>Common metric types:</p><ul><li>Gauge: Values that rise and fall.</li><li>Counter: Values that increase until reset.</li></ul><p>Elastic Agent primarily collects metrics and logs data, so even if you haven’t enabled any TSDS indices by hand, you may still have them in your cluster.</p><h3>The _tsid field</h3><p>Elasticsearch internally generates a <code>_tsid</code> value from dimension fields. This allows documents with identical dimensions to be routed to the same shard, improving:</p><ul><li>Compression.</li><li>Query locality.</li><li>Aggregation performance.</li></ul><h2>The key difference: Time‑bound backing indices</h2><p>Traditional data streams always write to the most recent backing index, called the <em>write index</em>, but TSDS behaves differently.</p><p>Each TSDS backing index has a defined time window and only accepts documents with <code>@timestamp</code> values that fall in that window:</p><pre><code>GET _data_stream/my-metrics-data-stream
{
  "index_mode": "time_series",
  "time_series": {
    "temporal_ranges": [
      {
        "start": "2026-01-15T14:35:50.000Z",
        "end": "2026-03-16T11:34:40.000Z"
      }
    ]
  }
}</code></pre><p>When a document is indexed, Elasticsearch routes it to the backing index responsible for that timestamp, meaning that, unlike traditional indices, a TSDS may write to multiple backing indices simultaneously.</p><p>For example:</p><ul><li>Real‑time data → newest index.</li><li>Late data → earlier index covering that time range.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/31af2bb3b3dc24db8342e791e1db77a44659ba7a-1589x502.png" style="width: 100%;" /><h2>Designing for late‑arriving data</h2><p>Real ingestion pipelines rarely deliver metrics perfectly on time. Metrics can be delayed by network outages, backlogs along the way, batch ingestion, and loss of edge devices, which reconnect and start to catch up.</p><p>Traditional indices quietly absorb those delays. TSDS does not.</p><p>If a document&#x27;s timestamp falls outside the range of writable backing indices, Elasticsearch rejects it, meaning your ILM policy must account for late data.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/32a103b95b20e31615c214271e27811a7ee315ae-1999x691.png" style="width: 100%;" /><h2>The critical constraint</h2><p>Backing indices must remain writable long enough to accept delayed data.</p><p>In practical terms:</p><pre><code>time_until_readonly > maximum_expected_lateness</code></pre><p>Because ILM measures ages from rollover, the operational rule becomes:</p><pre><code>warm_or_cold_min_age > rollover_max_age + maximum_expected_lateness</code></pre><p></p><p>For example, if metrics may arrive up to six hours late, indices must remain writable at least six hours after rollover.</p><p></p><p>Failing to account for this constraint was exactly what caused the ingestion failure described earlier. Late-arriving data was directed to an earlier index, which was already in the cold tier and therefore write-blocked.</p><p></p><h2>Handling rejected documents</h2><p>When TSDS rejects a document, Elasticsearch returns an error, indicating that the timestamp doesn’t fall within the range of writable indices. How your ingestion pipeline handles that error determines whether you lose data or stall ingestion.</p><p>The primary mechanism for handling rejected documents is the failure store.</p><h3>Failure store (recommended in Elasticsearch 9.1+)</h3><p>Elasticsearch 9.1 introduced the failure store, which automatically captures rejected documents. Instead of returning errors to clients, Elasticsearch writes failed documents to a dedicated failure index inside the data stream.</p><p>You can inspect failures using:</p><pre><code>GET metrics-myapp::failures/_search</code></pre><p>Using the failure store prevents ingestion pipelines from choking on rejection errors while preserving failed data for analysis or <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/reindex-tsds">reindexing</a>.</p><h2>Monitoring for rejection issues</h2><p>Late‑arrival problems usually appear first as ingestion anomalies. You may notice them first as:</p><ul><li>Sudden drops in indexing rate.</li><li>Spikes in rejected documents.</li><li>A growing number of failure store entries.</li><li>Mismatches between pipeline input and output counts.</li></ul><p>Alerting on these signals allows operators to detect issues before pipelines stall. Workflows, machine learning jobs, and other mechanisms can be used to automate detection and notification.</p><h2>Migration checklist for TSDS + ILM</h2><p>If you&#x27;re migrating a metrics cluster to TSDS, introducing ILM tiering, or upgrading to an Elasticsearch version where metrics are TSDS by default, review these items first.</p><h3><strong>1. Measure ingestion latency</strong></h3><p>Before changing ILM policies, determine:</p><ul><li>Normal ingestion delay.</li><li>Worst-case delay during incidents.</li><li>Delays caused by batch pipelines.</li></ul><p>Your ILM design must accommodate the maximum realistic delay.</p><h3><strong>2. Verify index time windows</strong></h3><p>Inspect your TSDS backing indices:</p><pre><code>GET _data_stream/<your-stream></code></pre><p>Look for:</p><ul><li><code>time_series.start_time</code></li><li><code>time_series.end_time</code></li></ul><p>These bounds determine which indices can accept documents. Understanding these windows can help you determine how late data can be before it’s rejected.</p><h3><strong>3. Size the hot tier for late arrivals</strong></h3><p>Ensure backing indices remain writable long enough for delayed data.</p><p>Operational rule:</p><ul><li><code>warm_min_age &gt; rollover_max_age + maximum_expected_lateness</code></li></ul><p>Remember, indices must remain writable for at least six hours if metrics may arrive six hours late.</p><h3><strong>4. Decide how to handle rejected documents</strong></h3><p>Choose a strategy before enabling TSDS:</p><ul><li>Failure store (recommended in Elasticsearch 9.1+).</li><li>Logstash dead letter queue.</li><li>Fallback index for late arrivals.</li><li>Accepting limited data loss.</li></ul><h3><strong>5. Monitor ingestion health</strong></h3><p>Add alerts for:</p><ul><li>Indexing rate drops.</li><li>Rejected documents.</li><li>Failure store growth.</li><li>Pipeline input/output mismatches.</li></ul><p>Late data issues often appear first as ingestion anomalies.</p><h2>Summary</h2><p>Time series data streams provide major storage and performance improvements for metrics workloads, but they introduce an important architectural change: Backing indices are time‑bound, which affects how ILM behaves.</p><p>When using TSDS:</p><ul><li>Indices must remain writable long enough to accept delayed data.</li><li>Ingestion pipelines should handle rejected documents safely.</li></ul><p>The key rule to remember is:</p><pre><code>warm_min_age > rollover_max_age + maximum_expected_lateness</code></pre><p>If you design ILM policies around that constraint, TSDS works extremely well for metrics workloads.</p><p>Ignore it, though, and your ingestion pipeline may discover those time boundaries the hard way.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[LINQ to Elasticsearch ES|QL: Write C#, query Elasticsearch]]></title>
            <description><![CDATA[Exploring the new LINQ to Elasticsearch ES|QL provider in the Elasticsearch .NET client, which allows you to write C# code that’s automatically translated to ES|QL queries.]]></description>
            <link>https://www.elastic.co/search-labs/blog/linq-esql-c-elasticsearch-net-client</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/linq-esql-c-elasticsearch-net-client</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Florian Bernd, Martijn Laarman]]></dc:creator>
            <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e54132e915217063e9ed0ec45059c6cfc38e31dd-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Starting with <strong>v9.3.4</strong> and <strong>v8.19.18</strong>, the Elasticsearch .NET client includes a <a href="https://learn.microsoft.com/en-us/dotnet/csharp/linq/">Language Integrated Query (LINQ) </a>provider that translates C# LINQ expressions into <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">Elasticsearch Query Language (ES|QL)</a> queries at runtime. Instead of writing ES|QL strings by hand, you compose queries using <code>Where</code>, <code>Select</code>, <code>OrderBy</code>, <code>GroupBy</code>, and other standard operators. The provider takes care of translation, parameterization, and result deserialization, including per-row streaming that keeps memory usage constant, regardless of result set size.</p><h2>Your first query</h2><p>Start by defining a plain old CLR object (POCO) that maps to your Elasticsearch index. Property names are resolved to ES|QL column names through standard <code>System.Text.Json</code> attributes, like <code>[JsonPropertyName]</code>, or through a configured <code>JsonNamingPolicy</code>. The same <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/source-serialization">source serialization</a> rules that apply across the rest of the client apply here as well.</p><pre><code>using System.Text.Json.Serialization;

public class Product
{
    [JsonPropertyName("product_id")]
    public string Id { get; set; }

    public string Name { get; set; }

    public string Brand { get; set; }

    [JsonPropertyName("price_usd")]
    public double Price { get; set; }

    [JsonPropertyName("in_stock")]
    public bool InStock { get; set; }
}</code></pre><p>With the type in place, a query looks like this:</p><pre><code>var minPrice = 100.0;
var brand = "TechCorp";

await foreach (var product in client.Esql.QueryAsync<Product>(q => q
    .From("products")
    .Where(p => p.InStock && p.Price >= minPrice && p.Brand == brand)
    .OrderByDescending(p => p.Price)
    .Take(10)))
{
    Console.WriteLine($"{product.Name}: ${product.Price}");
}</code></pre><p>The provider translates this into the following ES|QL:</p><pre><code>FROM products
| WHERE (in_stock == true AND price_usd >= ?minPrice AND brand == ?brand)
| SORT price_usd DESC
| LIMIT 10</code></pre><p>A few details to note:</p><ul><li><strong>Property name resolution:</strong> <code>p.Price</code> becomes <code>price_usd</code> because of the <code>[JsonPropertyName]</code> attribute, and <code>p.Brand</code> becomes <code>brand</code> following the default camelCase naming policy.</li><li><strong>Parameter capturing:</strong> The C# variables <code>minPrice</code> and <code>brand</code> are captured as named parameters (<code>?minPrice</code>, <code>?brand</code>). They’re sent separately from the query string in the JSON payload, which prevents injection and enables server-side query plan caching.</li><li><strong>Streaming:</strong> <code>QueryAsync&lt;T&gt;</code> returns <code>IAsyncEnumerable&lt;T&gt;</code>. Rows are materialized one at a time as they arrive from Elasticsearch.</li></ul><p>You can also inspect the generated query and its parameters without executing it:</p><pre><code>var query = client.Esql.CreateQuery<Product>()
    .Where(p => p.InStock && p.Price >= minPrice && p.Brand == brand)
    .OrderByDescending(p => p.Price)
    .Take(10);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE (in_stock == true AND price_usd >= 100) | SORT price_usd DESC | LIMIT 10

Console.WriteLine(query.ToEsqlString(inlineParameters: false));
// FROM products | WHERE (in_stock == true AND price_usd >= ?minPrice AND brand == ?brand) | SORT price_usd DESC | LIMIT 10

var parameters = query.GetParameters();
// { "minPrice": 100.0, "brand": "TechCorp" }</code></pre><h2>How does this work? A quick LINQ refresher</h2><p>The mechanism that makes LINQ providers possible is the distinction between <code>IEnumerable&lt;T&gt;</code> and <code>IQueryable&lt;T&gt;</code>.</p><p>When you call <code>.Where(p =&gt; p.Price &gt; 100)</code> on an <code>IEnumerable&lt;T&gt;</code>, the lambda compiles to a <code>Func&lt;Product, bool&gt;</code>, a regular delegate that the runtime executes in-process. This is LINQ-to-Objects.</p><p>When you call the same method on an <code>IQueryable&lt;T&gt;</code>, the C# compiler wraps the lambda in an <code>Expression&lt;Func&lt;Product, bool&gt;&gt;</code> instead. This is a data structure that represents the <em>structure</em> of the code rather than its executable form. The expression tree can be inspected, analyzed, and translated into another language at runtime.</p><pre><code>// IEnumerable: the lambda is a compiled delegate
IEnumerable<Product> local = products.Where(p => p.Price > 100);

// IQueryable: the lambda is an expression tree, a data structure
IQueryable<Product> remote = queryable.Where(p => p.Price > 100);</code></pre><p>The <code>IQueryProvider</code> interface is the extension point. Any provider can implement <code>CreateQuery&lt;T&gt;</code> and <code>Execute&lt;T&gt;</code> to translate these expression trees into a target language. Entity Framework uses this to emit SQL. The LINQ to ES|QL provider uses it to emit ES|QL.</p><p>The expression tree for the query above looks like this:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f864cd18a390831f8d28503a29b5835efb1842f7-1000x720.png" style="width: 100%;" /><p><em>Expression tree for the example query.</em></p><p>The tree is nested inside out: <code>Take</code> wraps <code>OrderByDescending</code>, which wraps <code>Where</code>, which wraps <code>From</code>, which wraps the root <code>EsqlQueryable&lt;Product&gt;</code> constant. The <code>Where</code> predicate is itself a subtree of <code>BinaryExpression</code> nodes for the <code>&amp;&amp;</code>, <code>&gt;=</code>, and <code>==</code> operators, with <code>MemberExpression</code> leaves for property accesses and closure captures for the <code>minPrice</code> and <code>brand</code> variables. This is the data structure that the provider walks to produce the final ES|QL.</p><h2>Under the hood: The translation pipeline</h2><p>The path from a LINQ expression to query results follows a six-stage pipeline:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2a2c772b63d720f61fc9a28b2f85668fa2db8d38-1999x1036.png" style="width: 100%;" /><p><em>Translation pipeline overview.</em></p><h3>1. Expression tree capture</h3><p>When you chain <code>.Where()</code>, <code>.OrderBy()</code>, <code>.Take()</code> and other operators on an <code>IQueryable&lt;T&gt;</code>, the standard LINQ infrastructure builds an expression tree. <code>EsqlQueryable&lt;T&gt;</code> implements <code>IQueryable&lt;T&gt;</code> and delegates to <code>EsqlQueryProvider</code>.</p><h3>2. Translation</h3><p>When the query is executed (by enumerating, calling <code>ToList()</code>, or using <code>await foreach)</code>, the <code>EsqlExpressionVisitor</code> walks the expression tree inside out. It dispatches each LINQ method call to a specialized visitor:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Visitor</td><td style="border: 1px solid #ccc; padding: 4px;">Translates</td><td style="border: 1px solid #ccc; padding: 4px;">Into</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">WhereClauseVisitor</td><td style="border: 1px solid #ccc; padding: 4px;">.Where(predicate)</td><td style="border: 1px solid #ccc; padding: 4px;">WHERE condition</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">SelectProjectionVisitor</td><td style="border: 1px solid #ccc; padding: 4px;">.Select(selector)</td><td style="border: 1px solid #ccc; padding: 4px;">EVAL + KEEP + RENAME</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">GroupByVisitor</td><td style="border: 1px solid #ccc; padding: 4px;">.GroupBy().Select()</td><td style="border: 1px solid #ccc; padding: 4px;">STATS ... BY</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OrderByVisitor</td><td style="border: 1px solid #ccc; padding: 4px;">.OrderBy() / .ThenBy()</td><td style="border: 1px solid #ccc; padding: 4px;">SORT field [ASC\|DESC]</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">EsqlFunctionTranslator</td><td style="border: 1px solid #ccc; padding: 4px;">EsqlFunctions.*, Math.*, string methods</td><td style="border: 1px solid #ccc; padding: 4px;">80+ ES|QL functions</td>
                    </tr>
              </tbody>
            </table>
          <p>During translation, C# variables referenced in expressions are captured as named parameters.</p><h3>3. Query model</h3><p>The visitors don’t produce strings directly. Instead, they produce <code>QueryCommand</code> objects, an immutable intermediate representation. A <code>FromCommand</code>, a <code>WhereCommand</code>, a <code>SortCommand</code>, and a <code>LimitCommand</code>, each representing one ES|QL processing command. These are collected into an <code>EsqlQuery</code> model.</p><p><em></em></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2adc349b6cf655b96b7b3e826a134e8a17fe42fd-1999x1036.png" style="width: 100%;" /><p><em>Query model and command pattern.</em></p><p>This intermediate model is decoupled from both the expression tree and the output format. It can be inspected, intercepted (via <code>IEsqlQueryInterceptor</code>), or modified before formatting.</p><h3>4. Formatting</h3><p><code>EsqlFormatter</code> visits each <code>QueryCommand</code> in order and produces the final ES|QL string. Each command becomes one line, separated by the pipe (|) operator that ES|QL uses to chain processing commands. Identifiers containing special characters are automatically escaped with backticks.</p><h3>5. Execution</h3><p>The formatted ES|QL string and captured parameters are sent to Elasticsearch’s <code>/_query</code> endpoint as a JSON payload. The <code>IEsqlQueryExecutor</code> interface abstracts the transport layer, which is where the layered package architecture comes into play.</p><h3>6. Materialization</h3><p><code>EsqlResponseReader</code> streams the JSON response without buffering the entire result set into memory. A <code>ColumnLayout</code> tree, precomputed once per query, maps flat ES|QL column names (like <code>address.street</code>, <code>address.city</code>) to nested POCO properties. Each row is assembled into a <code>T</code> instance and yielded one at a time via <code>IEnumerable&lt;T&gt;</code> or <code>IAsyncEnumerable&lt;T&gt;</code>.</p><h2>The layered architecture</h2><p>The LINQ to ES|QL functionality is split across three packages:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/41b8aae860ecdc2480edcb1c1d4cc9b03cfb78c9-1999x1036.png" style="width: 100%;" /><p><em>Package architecture.</em><br/><a href="https://www.nuget.org/packages/Elastic.Esql"><strong><code>Elastic.Esql</code></strong></a> is the pure translation engine. It has zero HTTP dependencies and contains the expression visitors, query model, formatter, and response reader. You can use it stand alone to build and inspect ES|QL queries without an Elasticsearch connection, which is useful for testing, query logging, or building your own execution layer.</p><pre><code>// Translation-only: no Elasticsearch connection needed
var provider = new EsqlQueryProvider();
var query = new EsqlQueryable<Product>(provider)
    .From("products")
    .Where(p => p.InStock)
    .OrderByDescending(p => p.Price);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE in_stock == true | SORT price_usd DESC</code></pre><p><a href="https://www.nuget.org/packages/Elastic.Clients.Esql"><strong><code>Elastic.Clients.Esql</code></strong></a> is a lightweight stand-alone ES|QL client. It adds HTTP execution on top of <code>Elastic.Esql</code> via <code>Elastic.Transport</code>. If your application only needs ES|QL and none of the other Elasticsearch APIs, this is the minimal dependency option.</p><p><a href="https://www.nuget.org/packages/Elastic.Clients.Elasticsearch"><strong><code>Elastic.Clients.Elasticsearch</code></strong></a> is the full Elasticsearch .NET client. It also builds on <code>Elastic.Esql</code> and exposes the LINQ provider through the <code>client.Esql</code> namespace. This is the recommended entry point for most applications.</p><p>Both execution-layer packages provide their own implementation of <code>IEsqlQueryExecutor</code>, the strategy interface that bridges translation and transport.</p><p>All three packages are compatible with Native AOT when used with a source-generated <code>JsonSerializerContext</code>. For the full client, see the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/source-serialization#native-aot">Native AOT documentation</a>.</p><h2>Beyond the basics</h2><p>The example above covered filtering, sorting, and pagination. The provider supports a broader set of operations.</p><h3>Aggregations</h3><p><code>GroupBy</code>, combined with aggregate functions in <code>Select</code>, translates to ES|QL <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS ... BY</code></a>:</p><pre><code>var stats = client.Esql.Query<Product, object>(q => q
    .GroupBy(p => p.Brand)
    .Select(g => new
    {
        Brand = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p => p.Price),
        MaxPrice = g.Max(p => p.Price)
    }));

// -> FROM products | STATS COUNT(*), AVG(price_usd), MAX(price_usd) BY brand</code></pre><h3>Projections</h3><p><code>Select</code>, with anonymous types generates <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval"><code>EVAL</code></a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/keep"><code>KEEP</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/rename"><code>RENAME</code></a> commands:</p><pre><code>var query = client.Esql.CreateQuery<Product>()
    .Select(p => new { ProductName = p.Name, p.Price, p.InStock });

// -> FROM products | KEEP name, price_usd, in_stock | RENAME name AS ProductName</code></pre><h3>Rich function library</h3><p>Over 80 ES|QL functions are available through the <code>EsqlFunctions</code> class, covering date/time, string, math, IP, pattern matching, and scoring. Standard <code>Math.*</code> and <code>string.*</code> methods are also translated:</p><pre><code>.Where(p => p.Name.Contains("Pro"))       // -> WHERE name LIKE "*Pro*"
.Where(p => EsqlFunctions.CidrMatch(      // -> WHERE CIDR_MATCH(ip, "10.0.0.0/8")
    p.IpAddress, "10.0.0.0/8"))</code></pre><h3>LOOKUP JOIN</h3><p>Cross-index lookups translate to ES|QL <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a>:</p><pre><code>var enriched = client.Esql.Query<Product, object>(q => q
    .LookupJoin<Product, CategoryLookup, string, object>(
        "category-lookup-index",
        product => product.Id,
        category => category.CategoryId,
        (product, category) => new { product.Name, category!.CategoryLabel }));</code></pre><h3>Raw ES|QL escape hatch</h3><p>For ES|QL features not yet covered by the LINQ provider, you can append raw fragments:</p><pre><code>var results = client.Esql.Query<Product>(q => q
    .Where(p => p.InStock)
    .RawEsql("| EVAL discounted = price_usd * 0.9"));</code></pre><h3>Server-side async queries</h3><p>For long-running queries, submit them for background processing on the server:</p><pre><code>await using var asyncQuery = await client.Esql.SubmitAsyncQueryAsync<Product>(
    q => q.Where(p => p.InStock),
    asyncQueryOptions: new EsqlAsyncQueryOptions
    {
        WaitForCompletionTimeout = TimeSpan.FromSeconds(5),
        KeepAlive = TimeSpan.FromMinutes(10)
    });

await asyncQuery.WaitForCompletionAsync();
await foreach (var product in asyncQuery.AsAsyncEnumerable())
    Console.WriteLine(product.Name);</code></pre><p>Server-side async queries are especially useful for long-running analytical queries / large dataset processing that might exceed typical timeout thresholds, or in timeout-sensitive environments with load balancers, API gateways, or proxies that enforce strict HTTP timeouts. Async queries avoid connection drops by decoupling submission from result retrieval.</p><h2>Getting started</h2><p>LINQ to ES|QL is available starting from:</p><ul><li><strong>Elastic.Clients.Elasticsearch v9.3.4</strong> (9.x branch)</li><li><strong>Elastic.Clients.Elasticsearch v8.19.18</strong> (8.x branch)</li></ul><p>Install from NuGet:</p><p><code>dotnet add package Elastic.Clients.Elasticsearch</code></p><p>The entry points are on <code>client.Esql</code>:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Method</td><td style="border: 1px solid #ccc; padding: 4px;">Returns</td><td style="border: 1px solid #ccc; padding: 4px;">Use case</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query<T>(...)</td><td style="border: 1px solid #ccc; padding: 4px;">IEnumerable<T></td><td style="border: 1px solid #ccc; padding: 4px;">Synchronous execution</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">QueryAsync<T>(...)</td><td style="border: 1px solid #ccc; padding: 4px;">IAsyncEnumerable<T></td><td style="border: 1px solid #ccc; padding: 4px;">Async streaming</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">CreateQuery<T>()</td><td style="border: 1px solid #ccc; padding: 4px;">IEsqlQueryable<T></td><td style="border: 1px solid #ccc; padding: 4px;">Advanced composition and inspection</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">SubmitAsyncQueryAsync<T>(...)</td><td style="border: 1px solid #ccc; padding: 4px;">EsqlAsyncQuery<T></td><td style="border: 1px solid #ccc; padding: 4px;">Long-running server-side queries</td>
                    </tr>
              </tbody>
            </table>
          <p>For the full feature reference, including query options, multifield access, nested objects, and multivalue field handling, see the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/linq-to-esql">LINQ to ES|QL documentation</a>.</p><h2>Conclusion</h2><p>LINQ to ES|QL brings the full expressiveness of C# LINQ to Elasticsearch&#x27;s ES|QL query language, letting you write strongly typed, composable queries without handcrafting query strings. With automatic parameter capturing, streaming materialization, and a layered package architecture that scales from stand-alone translation to the full Elasticsearch client, it fits naturally into .NET applications of any size. Install the latest client, point your LINQ expressions at an index, and let the provider handle the rest.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From judgment lists to trained Learning to Rank (LTR) models]]></title>
            <description><![CDATA[Learn how to transform judgment lists into training data for Learning To Rank (LTR), design effective features, and interpret what your model learned.]]></description>
            <link>https://www.elastic.co/search-labs/blog/learning-to-rank-models-judgment-lists</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/learning-to-rank-models-judgment-lists</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b43ac8c8f6dd519b28a6ed2b89328b6e76c71e02-1999x1125.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In <a href="https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch">Evaluating search query relevance with judgment lists</a>, we built lists and used the <code>_rank_eval</code> API to measure search quality. Though this approach gave us an objective way to evaluate changes, improving relevance still requires manual query tuning.</p><p>If judgment lists answer the question,<strong> “How good is my ranking?,”</strong> Learning To Rank (LTR) answers, <strong>“How do I systematically make it better?”</strong></p><p>In this article, we take the next step: using those judgment lists to train an LTR model using <a href="https://xgboost.ai/">XGBoost</a>, <a href="https://eland.readthedocs.io/en/latest/">Eland</a>, and Elasticsearch. We’ll focus on understanding the process rather than on implementation details. For the complete code, refer to the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb">companion notebook</a>.</p><h2>What is LTR?</h2><p><a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr">LTR</a> uses machine learning (ML) to build a ranking function for your search engine. Instead of manually tuning query weights, you provide examples of proper rankings (your<a href="https://www.elastic.co/search-labs/blog/judgment-lists"> judgment list</a>) and let the model learn what makes documents relevant. In Elasticsearch, LTR works as a second-stage reranker following retrieval of documents from Elasticsearch:</p><ul><li><strong>First stage:</strong> A standard query (<a href="https://www.elastic.co/blog/practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch">BM25</a>, vector, or <a href="https://www.elastic.co/search-labs/blog/hybrid-search-elasticsearch">hybrid</a>) retrieves candidate documents quickly.</li><li><strong>Second stage:</strong> The LTR model reranks the top results using multiple signals it learned to combine.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/926c037f84e211b6fb5a44a0849823151ab0fa0a-1712x1852.png" style="width: 100%;" /><p>For a deeper introduction, see <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">Introducing Learning To Rank (LTR)</a> in Elasticsearch.</p><h2>The journey from judgment list to model</h2><p>A judgment list tells us which documents should rank highly for a given query. But the model cannot learn directly from document IDs. It needs numerical signals that explain <em>why</em> certain documents are potentially relevant.</p><p>The process works like this:</p><ol><li><strong>Start with judgments.</strong> Query-document pairs with relevance grades, so you define that doc1 is a good match for “DiCaprio performance” search terms.</li><li><strong>Extract features.</strong> For each query-document pair, compute numerical signals, some about the document alone (for example, popularity), and others about how the query and document interact (for example, BM25 score).</li><li><strong>Train the model.</strong> The model learns which feature patterns predict high grades.</li><li><strong>Deploy. </strong>Deploy the trained model to your Elasticsearch cluster.</li><li><strong>Query.</strong> Use the model to rerank search results.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b43ac8c8f6dd519b28a6ed2b89328b6e76c71e02-1999x1125.png" style="width: 100%;" /><p>The key insight is that features must capture what your judgments are measuring. If your judgment list rewards popular thriller movies but your features only include text-matching scores, the model has no way to learn what makes those documents relevant.</p><h2>What are features?</h2><p><a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr#learning-to-rank-feature-extraction"><em>Features</em></a> are numerical values that describe a query-document pair. In Elasticsearch, we define features using queries that return <a href="https://www.elastic.co/search-labs/blog/elasticsearch-scoring-and-explain-api">scores</a>. There are three types:</p><ul><li><strong><em>Query-document features</em></strong> measure how well a query matches a document. Eland provides the <code>QueryFeatureExtractor</code> utility to define these features, which computes the BM25 relevance score for each query-document pair:</li></ul><pre><code>QueryFeatureExtractor(
    feature_name="title_bm25",
    query={"match": {"title": "{{query}}"}}
)</code></pre><p>This extracts the BM25 score from the title field for each document relative to the query.</p><ul><li><strong><em>Document features</em></strong> are properties of the document that don’t depend on the query. You can extract these using <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-script-score-query"><code>script_score</code></a> or <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query"><code>function_score</code></a>:</li></ul><pre><code>QueryFeatureExtractor(
    feature_name="popularity",
    query={
        "script_score": {
            "query": {"exists": {"field": "popularity"}},
            "script": {"source": "return doc['popularity'].value;"}
        }
    }
)</code></pre><ul><li><strong><em>Query features</em></strong> describe the query itself, like the number of terms. These are less common but can help the model handle different query types.</li></ul><h2>Designing your feature set</h2><p>Choosing features isn’t random. Each feature should capture a signal that might explain why users prefer certain documents. Let&#x27;s look at the features from the LTR notebook and understand the reasoning:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Feature</td><td style="border: 1px solid #ccc; padding: 4px;">Type</td><td style="border: 1px solid #ccc; padding: 4px;">Purpose</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`title_bm25`</td><td style="border: 1px solid #ccc; padding: 4px;">Query-document</td><td style="border: 1px solid #ccc; padding: 4px;">Title matches are strong relevance signals. For example, a movie titled Star Wars should rank highly for the query "star wars".</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`actors_bm25`</td><td style="border: 1px solid #ccc; padding: 4px;">Query-document</td><td style="border: 1px solid #ccc; padding: 4px;">Some users search by actor name. If they search for "leonardo dicaprio movies", they should get films starring Leonardo DiCaprio.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`title_all_terms_bm25`</td><td style="border: 1px solid #ccc; padding: 4px;">Query-document</td><td style="border: 1px solid #ccc; padding: 4px;">This is a stricter version of title matching where all query terms must be present. It helps distinguish between exact matches and partial ones.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`actors_all_terms_bm25`</td><td style="border: 1px solid #ccc; padding: 4px;">Query-document</td><td style="border: 1px solid #ccc; padding: 4px;">This is the same stricter matching logic as described above but applied specifically to actors.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`popularity`</td><td style="border: 1px solid #ccc; padding: 4px;">Document</td><td style="border: 1px solid #ccc; padding: 4px;">Users generally prefer well-known movies over obscure ones when relevance is similar. A popular Star Wars film should rank above a low-budget parody with "Star Wars" in the title.</td>
                    </tr>
              </tbody>
            </table>
          <p>Notice the strategy here:</p><ul><li><strong>Multiple signals for the same concept. </strong>We have both <code>title_bm25</code> (lenient) and <code>title_all_terms_bm25</code> (strict). The lenient version scores any document where at least one query term matches the title, and the strict version requires all the terms to be present. For short queries, the lenient match might be enough; whereas for longer, more specific queries, strict matching might be more important. The model can learn when to rely on each.</li><li><strong>Text features plus quality features. </strong>Text matching alone can return irrelevant documents that happen to contain the right words. The popularity feature lets the model boost well-known, quality content when text scores are similar.</li><li><strong>Coverage for different query types.</strong> Some queries target titles (&quot;star wars&quot;), and others target actors (&quot;dicaprio movies&quot;). Having features for both means that the model can handle diverse searches.</li></ul><p>When designing your own features, ask yourself, &quot;What signals would a human use to decide if this document is relevant?&quot; Those are your candidate features.</p><h2>Building the training dataset</h2><p>Once features are defined, we extract them for every query-document pair in our judgment list. The result is a training dataset where each row contains:</p><ul><li>The query identifier.</li><li>The document identifier.</li><li>The relevance grade (from our judgment list).</li><li>All feature values.</li></ul><p>Here’s a simplified example:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`query_id`</td><td style="border: 1px solid #ccc; padding: 4px;">`query`</td><td style="border: 1px solid #ccc; padding: 4px;">`doc_id`</td><td style="border: 1px solid #ccc; padding: 4px;">`grade`</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">qid:1</td><td style="border: 1px solid #ccc; padding: 4px;">star wars</td><td style="border: 1px solid #ccc; padding: 4px;">11</td><td style="border: 1px solid #ccc; padding: 4px;">4</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">qid:1</td><td style="border: 1px solid #ccc; padding: 4px;">star wars</td><td style="border: 1px solid #ccc; padding: 4px;">12180</td><td style="border: 1px solid #ccc; padding: 4px;">3</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">qid:1</td><td style="border: 1px solid #ccc; padding: 4px;">star wars</td><td style="border: 1px solid #ccc; padding: 4px;">278427</td><td style="border: 1px solid #ccc; padding: 4px;">1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">qid:2</td><td style="border: 1px solid #ccc; padding: 4px;">tom hanks movies</td><td style="border: 1px solid #ccc; padding: 4px;">857</td><td style="border: 1px solid #ccc; padding: 4px;">4</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">qid:2</td><td style="border: 1px solid #ccc; padding: 4px;">tom hanks movies</td><td style="border: 1px solid #ccc; padding: 4px;">13</td><td style="border: 1px solid #ccc; padding: 4px;">3</td>
                    </tr>
              </tbody>
            </table>
          <p>A few things to notice:</p><p><strong>NaN values are normal.</strong> When a query doesn’t match a field, the feature returns no score. The movie <em>Star Wars</em> has a high <code>title_bm25</code> but no <code>actors_bm25</code> because the query &quot;star wars&quot; doesn’t match any actor names.</p><p><strong>Queries are grouped during training.</strong> The <code>query_id</code> column tells the model which documents to compare against each other. For &quot;star wars&quot;, it learns that document 11 (grade 4) should rank above document 278427 (grade 1).</p><p>But here’s the important part: The model doesn’t memorize these specific queries. Instead, it learns general patterns, like &quot;documents with high <code>title_bm25</code> AND high <code>popularity</code> tend to have high grades.&quot; When presented with a new query, the model applies these learned patterns to rank the results.</p><p><strong>Features must explain grade differences.</strong> Look at qid:1: The grade 4 document has a higher <code>title_bm25</code> and higher <code>popularity</code> than the grade 1 document. These patterns are what the model learns.</p><h2>Training the LTR model</h2><p>With the training dataset prepared, we train an <a href="https://xgboost.readthedocs.io/en/stable/">XGBoost</a> model with a ranking objective. The model builds decision trees that learn patterns like:</p><ul><li>&quot;If <code>title_bm25</code> &gt; 10 and <code>popularity</code> &gt; 50, predict high relevance.&quot;</li><li>&quot;If <code>title_bm25</code> is missing but <code>actors_bm25</code> &gt; 12, still predict moderate relevance.&quot;</li></ul><p>Here&#x27;s how the training process works in practice:</p><pre><code>from xgboost import XGBRanker
from sklearn.model_selection import GroupShuffleSplit

# Create the ranker model:
ranker = XGBRanker(
    objective="rank:ndcg",
    eval_metric=["ndcg@10"],
    early_stopping_rounds=20,
)

# Shaping training and eval data in the expected format.
X = judgments_with_features[ltr_config.feature_names]
y = judgments_with_features["grade"]
groups = judgments_with_features["query_id"]

# Split the dataset in two parts respectively used for training and evaluation of the model.
group_preserving_splitter = GroupShuffleSplit(n_splits=1, train_size=0.7).split(
    X, y, groups
)
train_idx, eval_idx = next(group_preserving_splitter)

train_features, eval_features = X.loc[train_idx], X.loc[eval_idx]
train_target, eval_target = y.loc[train_idx], y.loc[eval_idx]
train_query_groups, eval_query_groups = groups.loc[train_idx], groups.loc[eval_idx]

# Training the model
ranker.fit(
    X=train_features,
    y=train_target,
    group=train_query_groups.value_counts().sort_index().values,
    eval_set=[(eval_features, eval_target)],
    eval_group=[eval_query_groups.value_counts().sort_index().values],
    verbose=True,
)</code></pre><p>During training, the model tries different combinations of these rules and measures how well the resulting rankings match your judgment grades. It uses a metric called <a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">Normalized Discounted Cumulative Gain (NDCG)</a> to score itself. A perfect NDCG of 1.0 means that the model&#x27;s ranking exactly matches your judgments. Lower scores mean that some relevant documents are ranking below where they should be.</p><p>The training also uses a technique called <a href="https://xgboosting.com/early-stopping/">early stopping</a>. If the model&#x27;s score stops improving for several rounds, training halts automatically. This prevents the model from memorizing the training data too closely, which would hurt its ability to generalize to new queries.</p><p>The <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb">companion notebook</a> contains the complete training code.</p><h2>Understanding what your LTR model learned</h2><p>After training, XGBoost can show you which features the model relied on most. You can generate a feature importance chart using XGBoost&#x27;s built-in visualization:</p><pre><code>from xgboost import plot_importance

plot_importance(ranker, importance_type="weight")</code></pre><p>The <code>importance_type=&quot;weight&quot;</code> parameter shows how often each feature was used in tree splits. Here’s the resulting chart:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/67608c24c50a8775b6e70fa7f522d7b58cefc764-734x460.png" style="width: 100%;" /><p>The F score counts how many times each feature was used to make split decisions across all trees in the model. Higher values mean that the model relied on that feature more often.</p><p>In this example:</p><ul><li><strong><code>popularity</code> (2178):</strong> The most important feature. The model frequently uses popularity to separate relevant from nonrelevant documents.</li><li><strong><code>title_bm25</code> (1642):</strong> Second-most important. Title matches matter a lot for movie searches.</li><li><strong><code>actors_bm25</code> (565):</strong> Moderately important. This is useful for queries that mention actors.</li><li><strong><code>title_all_terms_bm25</code> (211):</strong> Occasionally useful. The stricter matching helps for some queries.</li><li><strong><code>actors_all_terms_bm25</code> (63):</strong> Rarely used. The model found this feature less predictive.</li></ul><p>This chart helps you iterate on your feature set. If a feature that you expected to be important shows near-zero importance, investigate why. Maybe the feature extraction is not working as intended, or maybe that signal doesn’t actually predict relevance in your judgment data.</p><h2>Deploying and using the LTR model</h2><p>Once trained, upload the model to Elasticsearch using Eland:</p><pre><code>MLModel.import_ltr_model(
    es_client=es_client,
    model=ranker,
    model_id="ltr-model-xgboost",
    ltr_model_config=ltr_config,
    es_if_exists="replace",
)</code></pre><p>Once uploaded, the model can be used as a <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rescorer-retriever">rescorer retriever</a> to be combined with other <a href="https://www.elastic.co/docs/solutions/search/retrievers-overview">retrievers</a> for multistage search pipelines:</p><pre><code>GET movies/_search
{
  "retriever": {
    "rescorer": {
      "rescore": {
        "window_size": 50,
        "learning_to_rank": {
          "model_id": "ltr-model-xgboost",
          "params": {
            "query": "star wars"
          }
        }
      },
      "retriever": {
        "standard": {
          "query": {
            "multi_match": {
              "fields": ["title", "overview", "actors", "director", "tags", "characters"],
              "query": "star wars"
            }
          }
        }
      }
    }
  }
}</code></pre><p>Response (simplified):</p><pre><code> "hits": {
    "total": {
      "value": 852,
      "relation": "eq"
    },
    "max_score": 25.165691,
    "hits": [
      {
        "_index": "movies",
        "_id": "11",
        "_score": 25.165691,
        "_source": {
          "title": "Star Wars"
        }
      },
      {
        "_index": "movies",
        "_id": "12180",
        "_score": 25.092865,
        "_source": {
          "title": "Star Wars: The Clone Wars"
        }
      },
      {
        "_index": "movies",
        "_id": "181812",
        "_score": 23.456198,
        "_source": {
          "title": "Star Wars: The Rise of Skywalker"
        }
      },
      {
        "_index": "movies",
        "_id": "140607",
        "_score": 23.320757,
        "_source": {
          "title": "Star Wars: The Force Awakens"
        }
      },
...</code></pre><p>The first-stage query retrieves candidates using BM25. The LTR model then reranks the top 50 results using all the features it learned to weight.</p><p>For the sake of the example, the <code>multi_match</code> query alone would return some less relevant results on the first positions that LTR helped to fix:</p><pre><code>{
  "hits": [
    {
      "_index": "movies",
      "_id": "11",
      "_score": 10.971989,
      "_source": {
        "title": "Star Wars"
      }
    },
    {
      "_index": "movies",
      "_id": "12180",
      "_score": 9.923633,
      "_source": {
        "title": "Star Wars: The Clone Wars"
      }
    },
    {
      "_index": "movies",
      "_id": "1022100",
      "_score": 8.9880295,
      "_source": {
        "title": "Andor: A Disney+ Day Special Look"
      }
    },
    {
      "_index": "movies",
      "_id": "278427",
      "_score": 8.845748,
      "_source": {
        "title": "Family Guy Presents: It's a Trap!"
      }
    },
    ...
  ]
}</code></pre><h2>Conclusion</h2><p>The path from judgment lists to a working LTR model involves three key steps: designing features that capture relevance signals, building a training dataset that pairs those features with your judgment grades, and training a model that learns the patterns.</p><p>Our <a href="https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch">previous article</a> becomes the starting point for this process. Your grades define what &quot;relevant&quot; means and how to measure it, and your features give the model the signals to predict it.</p><p>For the complete implementation with a dataset of 9,750 movies and 384,755 judgment rows, see the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb">LTR notebook</a>. For advanced use cases, like personalized search, see <a href="https://www.elastic.co/search-labs/blog/personalized-search-elasticsearch-ltr">Personalized search with LTR</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Elasticsearch runtime fields to ES|QL: Adapting legacy tools to current techniques]]></title>
            <description><![CDATA[Learn how to migrate five common Elasticsearch runtime field patterns to their ES|QL equivalents, with side-by-side code comparisons and guidance on when each approach makes sense.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-runtime-fields-to-esql</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-runtime-fields-to-esql</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/657ec44d182de78e6ddabb6632c6844b5a36774d-720x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields">runtime fields</a> solve the problem of computing values at query time without <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-reindex">reindexing</a>. But they come with <a href="https://www.elastic.co/docs/reference/scripting-languages/painless/painless">Painless scripting</a> complexity and performance costs that scale with document count. <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> offers a more powerful alternative with a dedicated execution engine, pipeline processing, and no scripting required. In this article, you’ll learn how to map five common runtime field patterns to their ES|QL equivalents, so you can modernize your queries and understand when each approach makes sense.</p><h2>Prerequisites</h2><ul><li>Elasticsearch 8.15+ (for <code>::</code> cast operator support; core ES|QL features available from 8.11)</li></ul><h2>Runtime fields versus ES|QL</h2><p>Runtime fields were introduced in Elasticsearch 7.11 as a way to define fields at query time. Instead of reindexing data, you could write a Painless script that computes values on the fly:</p><pre><code>PUT my-index/_mapping
{
  "runtime": {
    "full_address": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['address'].value + ':' + doc['port'].value)"
      }
    }
  }
}</code></pre><p>This works, but comes with trade-offs:</p><ul><li><strong>Painless scripting overhead:</strong> Every runtime field requires scripting knowledge, and the <a href="https://www.elastic.co/docs/reference/scripting-languages/painless/painless-language-specification">syntax</a> is Java-like, not query-like.</li><li><strong>Performance cost:</strong> Runtime fields evaluate per document at query time. Elasticsearch classifies them as &quot;expensive queries&quot; that <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields#runtime-compromises">can be rejected</a> by cluster settings.</li><li><strong>Isolated computation:</strong> Each runtime field computes independently. There’s no way to chain transforms or use the output of one field in another within the same query.</li></ul><p>ES|QL changes the equation. It has its own execution engine (not translated to Query DSL), runs queries concurrently across nodes, and provides a complete toolkit for field computation: <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval"><code>EVAL</code></a>, <a href="http://elastic.co/docs/reference/query-languages/esql/commands/grok"><code>GROK</code></a>, <a href="http://elastic.co/docs/reference/query-languages/esql/commands/dissect"><code>DISSECT</code></a>, type casting, and pipeline chaining.</p><p>Let&#x27;s see how each runtime field pattern maps to ES|QL.</p><h2>Setting up the example data</h2><p>All the code snippets in this article can be executed in the Kibana <a href="https://www.elastic.co/docs/explore-analyze/query-filter/tools/console">Dev Tools console</a>.</p><p>To follow along, create a sample index with data that exercises all five patterns. This simulates a server logs scenario with mixed field types, raw messages, and some intentional data quality issues:</p><pre><code>PUT server-logs
{
  "mappings": {
    "properties": {
      "host": { "type": "keyword" },
      "port": { "type": "keyword" },
      "raw_message": { "type": "text" },
      "response_time": { "type": "keyword" },
      "status_code": { "type": "keyword" },
      "region": { "type": "keyword" }
    }
  }
}</code></pre><p>Now index some sample documents:</p><pre><code>POST _bulk
{ "index": { "_index": "server-logs" } }
{ "host": "web-01", "port": "8080", "raw_message": "2024-01-15 INFO user=alice action=login duration=230ms", "response_time": "145", "status_code": "200", "region": "us-east" }
{ "index": { "_index": "server-logs" } }
{ "host": "web-02", "port": "443", "raw_message": "2024-01-15 ERROR user=bob action=upload duration=1200ms", "response_time": "not_available", "status_code": "500", "region": "eu-west" }
{ "index": { "_index": "server-logs" } }
{ "host": "api-01", "port": "3000", "raw_message": "2024-01-15 WARN user=charlie action=query duration=890ms", "response_time": "890", "status_code": "200", "region": "us-east" }
{ "index": { "_index": "server-logs" } }
{ "host": "api-02", "port": "3000", "raw_message": "2024-01-16 INFO user=diana action=export duration=3400ms", "response_time": "3400", "status_code": "200", "region": "ap-south" }
{ "index": { "_index": "server-logs" } }
{ "host": "web-01", "port": "8080", "raw_message": "2024-01-16 ERROR user=eve action=login duration=50ms", "response_time": "50", "status_code": "401", "region": "US-EAST" }
</code></pre><p>Notice that <code>response_time</code> is stored as a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword">keyword</a> (a common real-world mistake), and the last document has <code>&quot;US-EAST&quot;</code> instead of <code>&quot;us-east&quot;</code> (a data quality issue we’ll fix later).</p><h2>Pattern 1: Field concatenation</h2><p>A common runtime field use case is combining two fields into one. For example, creating a <code>host:port</code> identifier.</p><h3>The runtime field approach</h3><p>You can define it inline at query time. Query-time approach avoids modifying the mapping, but you still need Painless scripting, scoping it to a single search request:</p><pre><code>GET server-logs/_search
{
  "runtime_mappings": {
    "endpoint": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['host'].value + ':' + doc['port'].value)"
      }
    }
  },
  "fields": ["endpoint"],
  "_source": false
}</code></pre><h3>The ES|QL approach</h3><p>You can run ES|QL queries using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-esql-query"><code>_query API</code></a> endpoint:</p><pre><code>POST _query
{
  "query": """
    FROM server-logs
    | EVAL endpoint = CONCAT(host, ":", port)
    | KEEP host, port, endpoint
    | LIMIT 1
  """
}</code></pre><p>Response:</p><pre><code>{
  "columns": [
    { "name": "host", "type": "keyword" },
    { "name": "port", "type": "keyword" },
    { "name": "endpoint", "type": "keyword" }
  ],
  "values": [
    ["web-01", "8080", "web-01:8080"]
  ]
}</code></pre><p><code>CONCAT</code> accepts two or more arguments and always returns a <code>keyword</code>.</p><p><em>Note: For brevity, the remaining ES|QL examples in this article show just the query. Wrap them in <code>POST _query { &quot;query&quot;: &quot;...&quot; }</code> to run them in Kibana Dev Tools.</em></p><h4>When to use</h4><p>If you need <code>endpoint</code> to persist across all queries and be available in Kibana dashboards, use a mapping-level runtime field. If you need it for a single search request within Query DSL, use a query-time runtime field. If you need it for ad-hoc analysis or exploratory work, ES|QL is simpler.</p><h2>Pattern 2: Data extraction from unstructured text</h2><p>Extracting structured data from raw log messages is another classic runtime field pattern.</p><h3>The runtime field approach</h3><p>Painless uses Java&#x27;s regex <a href="https://docs.oracle.com/javase/8/docs/api/java/util/regex/Matcher.html">Matcher</a> class:</p><pre><code>GET server-logs/_search
{
  "runtime_mappings": {
    "log_user": {
      "type": "keyword",
      "script": {
        "source": "def matcher = /user=(\\w+)/.matcher(params._source['raw_message']); if (matcher.find()) { emit(matcher.group(1)); }"
      }
    }
  },
  "fields": ["log_user"],
  "_source": false
}</code></pre><p>This is verbose. You need to know <a href="https://www.elastic.co/docs/explore-analyze/scripting/modules-scripting-regular-expressions-tutorial">Painless regex syntax</a>, handle the <code>Matcher</code> object, and call <code>emit()</code> correctly.</p><h3>The ES|QL approach: GROK</h3><p>ES|QL provides two purpose-built commands for text extraction. <code>GROK</code> uses regex-based patterns:</p><pre><code>FROM server-logs
| GROK raw_message "%{WORD:timestamp_date} %{WORD:log_level} user=%{WORD:user} action=%{WORD:action} duration=%{WORD:duration}"
| KEEP user, log_level, action, duration</code></pre><p>Response:</p><pre><code>{
  "columns": [
    { "name": "user", "type": "keyword" },
    { "name": "log_level", "type": "keyword" },
    { "name": "action", "type": "keyword" },
    { "name": "duration", "type": "keyword" }
  ],
  "values": [
    ["alice", "INFO", "login", "230ms"], ...
  ]
}</code></pre><p><code>GROK</code> uses the <code>%{SYNTAX:SEMANTIC}</code> pattern format. It extracts multiple fields in a single and readable command.</p><h3>The ES|QL approach: DISSECT</h3><p>For structured data with consistent delimiters, <code>DISSECT</code> is faster because it doesn’t use regular expressions:</p><pre><code>FROM server-logs
| DISSECT raw_message "%{timestamp_date} %{log_level} user=%{user} action=%{action} duration=%{duration}"
| KEEP user, log_level, action, duration</code></pre><p>The syntax is nearly identical to <code>GROK</code>, but <code>DISSECT</code> works by splitting on delimiters rather than matching regex patterns. This makes it faster for data that follows a consistent format.</p><h4>When to use GROK vs DISSECT</h4><p>Use <code>DISSECT</code> when your data has a predictable structure (same delimiters, same field order). Use <code>GROK</code> when you need regex flexibility, for example when fields may be optional or formats vary.</p><h2>Pattern 3: Dynamic type conversion</h2><p>When a field is mapped as <code>keyword</code> but contains numeric data (a surprisingly common scenario), runtime fields can cast it at query time.</p><h3>The runtime field approach</h3><pre><code>GET server-logs/_search
{
  "runtime_mappings": {
    "response_time_long": {
      "type": "long",
      "script": {
        "source": """
          def val = doc['response_time'].value;
          if (val != 'not_available') {
            emit(Long.parseLong(val));
          }
        """
      }
    }
  },
  "fields": ["response_time_long"],
  "_source": false
}</code></pre><p>You need to handle parsing exceptions manually. If <a href="https://docs.oracle.com/javase/8/docs/api/java/lang/Long.html#parseLong-java.lang.String-"><code>Long.parseLong</code></a> fails on an unexpected value, the script throws an error.</p><h3>The ES|QL approach</h3><p>ES|QL provides explicit conversion functions and a shorthand cast operator:</p><pre><code>FROM server-logs
| EVAL response_ms = TO_LONG(response_time)
| KEEP host, response_time, response_ms</code></pre><p>Or with the <code>::</code> cast operator (<a href="https://www.elastic.co/search-labs/blog/esql-timeline-of-improvements">available since 8.15</a>):</p><pre><code>FROM server-logs
| EVAL response_ms = response_time::long
| KEEP host, response_time, response_ms</code></pre><p>Response:</p><pre><code>{
  "columns": [
    { "name": "host", "type": "keyword" },
    { "name": "response_time", "type": "keyword" },
    { "name": "response_ms", "type": "long" }
  ],
  "values": [
    ["web-01", "145", 145]
  ]
}</code></pre><p>Both produce the same result. The key difference from Painless: <strong>Failed conversions return <code>null</code> instead of throwing exceptions</strong>. The document with <code>&quot;not_available&quot;</code> simply gets <code>null</code> for <code>response_ms</code>, and ES|QL emits a warning.</p><p>Common conversion functions include:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Function</td><td style="border: 1px solid #ccc; padding: 4px;">Converts to</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_LONG()`</td><td style="border: 1px solid #ccc; padding: 4px;">Long integer</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_INTEGER()`</td><td style="border: 1px solid #ccc; padding: 4px;">Integer</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_DOUBLE()`</td><td style="border: 1px solid #ccc; padding: 4px;">Double</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_DATETIME()`</td><td style="border: 1px solid #ccc; padding: 4px;">Date</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_BOOLEAN()`</td><td style="border: 1px solid #ccc; padding: 4px;">Boolean</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_IP()`</td><td style="border: 1px solid #ccc; padding: 4px;">IP address</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`TO_VERSION()`</td><td style="border: 1px solid #ccc; padding: 4px;">Version</td>
                    </tr>
              </tbody>
            </table>
          <p>The <code>::</code> operator works with all these types (for example, <code>field::double</code>, <code>field::datetime</code>).</p><h4>When to use</h4><p>ES|QL&#x27;s graceful null handling makes it safer for dirty data. Runtime fields with Painless give you fine-grained control over error handling but require more code. For type conversion specifically, ES|QL is almost always the better choice.</p><h2>Pattern 4: <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/dynamic-field-mapping">Dynamic field</a> handling</h2><p>Runtime fields support <code>&quot;dynamic&quot;: &quot;runtime&quot;</code> in mappings, which prevents <a href="https://www.elastic.co/docs/troubleshoot/elasticsearch/mapping-explosion">mapping explosion</a> by creating all new fields as runtime fields instead of indexed fields:</p><pre><code>{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": { "type": "date" }
    }
  }
}</code></pre><p>Any new field sent to this index becomes a runtime field automatically. This is useful when you ingest semi-structured data with unpredictable field names.</p><h3>Where ES|QL fits</h3><p>ES|QL provides query-time flexibility, but it still needs fields to be visible in the mapping. This is where runtime fields and ES|QL complement each other rather than compete.</p><p>If a field exists in <code>_source</code> but isn’t mapped, ES|QL cannot access it directly. The current workaround is to define a runtime field to make the unmapped field visible:</p><pre><code>PUT dynamic-logs/_mapping
{
  "runtime": {
    "custom_field": {
      "type": "keyword",
      "script": {
        "source": "emit(params._source['custom_field'])"
      }
    }
  }
}</code></pre><p>Once defined, ES|QL can query it:</p><pre><code>FROM dynamic-logs
| WHERE custom_field == "some_value"
| KEEP timestamp, custom_field</code></pre><p>This is one scenario where runtime fields remain essential. They act as a bridge, making unmapped data accessible to ES|QL.</p><h2>Pattern 5: Field shadowing for error correction</h2><p>Runtime fields can shadow (override) indexed fields by defining a runtime field with the same name as an existing field. This is useful for correcting data without reindexing.</p><h3>The runtime field approach</h3><p>Remember our data quality issue, where <code>region</code> has inconsistent casing (<code>&quot;US-EAST&quot;</code> versus <code>&quot;us-east&quot;</code>)?</p><pre><code>GET server-logs/_search
{
  "runtime_mappings": {
    "region": {
      "type": "keyword",
      "script": {
        "source": "emit(params._source['region'].toLowerCase())"
      }
    }
  },
  "fields": ["region"],
  "_source": false
}</code></pre><p>This overrides the indexed <code>region</code> field for all queries. Every search, aggregation, and Kibana visualization will see the lowercase version.</p><pre><code>FROM server-logs
| EVAL region = TO_LOWER(region)
| KEEP host, port, region</code></pre><p>When you use <code>EVAL</code> with an existing column name, ES|QL drops the original column and replaces it with the computed value. This is the exact equivalent of field shadowing, but scoped to the current query.</p><p>You can also chain multiple corrections in a pipeline:</p><pre><code>FROM server-logs
| EVAL region = TO_LOWER(region)
| EVAL region = CASE(region == "us-east", "US East", region == "eu-west", "EU West", region == "ap-south", "AP South", region)
| KEEP host, region</code></pre><h4>When to use</h4><p>If the correction should apply to all queries and <a href="https://www.elastic.co/kibana/kibana-dashboard">Kibana dashboards</a>, use runtime field shadowing. If you need to correct data for a specific analysis, ES|QL is more flexible since you can apply different transformations in different queries without modifying the mapping.</p><h2>The ES|QL pipeline advantage: Going beyond runtime fields</h2><p>This is where ES|QL fundamentally surpasses runtime fields. Runtime fields are isolated: each one computes independently, and you cannot use the output of one runtime field as input for another in the same query.</p><p>ES|QL pipelines chain transforms. Here’s a single query that combines multiple patterns:</p><pre><code>FROM server-logs
| GROK raw_message "%{WORD:log_date} %{WORD:log_level} user=%{WORD:user} action=%{WORD:action} duration=%{INT:duration_raw}ms"
| EVAL duration_ms = duration_raw::long
| EVAL region = TO_LOWER(region)
| WHERE log_level == "ERROR" AND duration_ms > 100
| STATS avg_duration = AVG(duration_ms), error_count = COUNT(*) BY region</code></pre><p>This single query:</p><ul><li><strong>Extracts</strong> fields from raw text (<code>GROK</code>).</li><li><strong>Converts</strong> the duration to a number (<code>EVAL</code> with cast).</li><li><strong>Normalizes</strong> region casing (<code>EVAL</code> with <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions/to_lower"><code>TO_LOWER</code></a>).</li><li><strong>Filters</strong> for errors with high duration (<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/where"><code>WHERE</code></a>).</li><li><strong>Aggregates</strong> by region (<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS</code></a>).</li></ul><p>To achieve the same result with runtime fields, you would need to define at least three separate runtime fields (for extraction, conversion, and normalization) and then write a Query DSL query with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/filter-search-results">filters</a> and <a href="https://www.elastic.co/docs/explore-analyze/query-filter/aggregations">aggregations</a>. The ES|QL version is a single, readable pipeline.</p><p>You can even use expressions directly inside aggregations:</p><pre><code>FROM server-logs
| EVAL response_ms = response_time::long
| STATS
    avg_response = AVG(response_ms),
    p95_response = PERCENTILE(response_ms, 95),
    slow_count = COUNT(CASE(response_ms > 1000, 1, null))
  BY host</code></pre><h2>Conclusion</h2><p>What we covered:</p><ul><li>ES|QL provides a full toolkit (<code>EVAL</code>, <code>GROK</code>, <code>DISSECT</code>, type casting with <code>::</code>) that replaces most runtime field patterns without any Painless scripting.</li><li>Failed type conversions in ES|QL return <code>null</code> instead of throwing exceptions, making it safer for real-world data.</li><li>Pipeline processing (chaining <code>GROK</code> into <code>EVAL</code> into <code>WHERE</code> into <code>STATS</code>) goes beyond what runtime fields can do in isolation.</li><li>Runtime fields remain valuable for persistent computed fields, field shadowing across all queries, and as a bridge for unmapped data in ES|QL.</li></ul><p>One important caveat: Both runtime fields and ES|QL compute values at query time, which means they pay the cost on every query. If you find yourself applying the same transformation repeatedly (type corrections, field extraction, data normalization), consider using <a href="https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines">ingest pipelines</a> to fix the data at index time instead. Ingest pipelines let you parse, enrich, and transform documents before they’re stored, so queries can work with clean, properly typed fields directly. Runtime fields and ES|QL are great for exploration and ad-hoc analysis, but for production workloads, indexing the right data from the start is almost always the better choice.</p><p><strong>The key takeaway: </strong>Runtime fields aren’t deprecated, and they aren’t going away. But for most query-time computation patterns, ES|QL offers a simpler, more powerful, and more performant approach. And when the transformation is known up front, an ingest pipeline is the most efficient option of all.</p><h2>Next steps</h2><ul><li><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL documentation</a></li><li><a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields">Runtime fields reference</a></li><li><a href="https://www.elastic.co/search-labs/blog/esql-timeline-of-improvements">ES|QL timeline of improvements</a></li><li><a href="https://www.elastic.co/blog/getting-started-with-elasticsearch-runtime-fields">Getting started with runtime fields</a></li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-process-data-with-dissect-grok">ES|QL processing data with DISSECT and GROK</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating an Elasticsearch MCP server with TypeScript]]></title>
            <description><![CDATA[Learn how to create an Elasticsearch MCP server with TypeScript and Claude Desktop.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0bb24c05fff391f42070c2883182ea6fe9cb9680-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>When working with large knowledge bases in Elasticsearch, finding information is only half the battle. Engineers often need to synthesize results from multiple documents, generate summaries, and trace answers back to their sources. Model Context Protocol (MCP) provides a standardized way to connect Elasticsearch with large language model–powered (LLM-powered) applications to accomplish this. While Elastic offers official solutions, like Elastic Agent Builder (which includes an <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">MCP endpoint</a> among its features), building a custom MCP server gives you full control over search logic, result formatting, and how retrieved content is passed to an LLM for synthesis, summaries, and citations.</p><p>In this article, we’ll explore the benefits of building a custom Elasticsearch MCP server and show how to create one in TypeScript that connects Elasticsearch to LLM-powered applications.</p><h2>Why build a custom Elasticsearch MCP server?</h2><p>Elastic provides some alternatives for <a href="https://www.elastic.co/docs/solutions/search/mcp">MCP servers</a>:</p><ul><li><a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">Elastic Agent Builder MCP server for Elasticsearch 9.2+</a></li><li><a href="https://github.com/elastic/mcp-server-elasticsearch?tab=readme-ov-file#elasticsearch-mcp-server">Elasticsearch MCP server for older versions (Python)</a></li></ul><p>If you need more control over how your MCP server interacts with Elasticsearch, building your own custom server gives you the flexibility to tailor it exactly to your needs. For example, Agent Builder&#x27;s MCP endpoint is limited to Elasticsearch Query Language (ES|QL) queries, while a custom server allows you to use the full Query DSL. You also gain control over how results are formatted before being passed to the LLM and can integrate additional processing steps, like the OpenAI-powered summarization we&#x27;ll implement in this tutorial.</p><p>By the end of this article, you’ll have an MCP server in TypeScript that searches for information stored in an Elasticsearch index, summarizes it, and provides citations. We&#x27;ll use Elasticsearch for retrieval, OpenAI&#x27;s <code>gpt-4o-mini</code> model to summarize and generate citations, and Claude Desktop as the MCP client and UI to take in user queries and give responses. The end result is an internal knowledge assistant that helps engineers discover and synthesize best practices across their organization’s technical docs.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ec5771a874cf9740d4cac6888622cbe8cd6aede7-1999x1133.png" style="width: 100%;" /><h2>Prerequisites:</h2><ul><li>Node.js 20 +</li><li>Elasticsearch</li><li>OpenAI API key</li><li>Claude Desktop</li></ul><h3>What is MCP?</h3><p><a href="https://www.elastic.co/what-is/mcp">MCP</a> is an open standard, created by <a href="https://www.anthropic.com/news/model-context-protocol">Anthropic</a>, that provides secure, bidirectional connections between LLMs and external systems, like Elasticsearch. You can read more about the current state of MCP in <a href="https://www.elastic.co/search-labs/blog/mcp-current-state">this article</a>.</p><p>The MCP landscape is <a href="https://www.elastic.co/search-labs/blog/mcp-current-state#mcp-project-updates:-transport,-elicitation,-and-structured-tooling">evolving every day</a>, with servers available for a wide range of use cases. On top of that, it’s easy to build your own custom MCP server, as we’ll show in this article.</p><h3>MCP clients</h3><p>There’s a long <a href="https://modelcontextprotocol.io/clients">list of available MCP clients</a>, each with its own characteristics and limitations. For simplicity and popularity, we’ll use <a href="https://claude.ai/download">Claude Desktop</a> as our MCP client. It will serve as the chat interface where users can ask questions in natural language, and it will automatically invoke the tools exposed by our MCP server to search documents and generate summaries.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/66eb0b11473347b6cf2d85718251eeac38d6249d-1999x1491.png" style="width: 100%;" /><h2>Creating an Elasticsearch MCP server</h2><p>Using the <a href="https://github.com/modelcontextprotocol/typescript-sdk">TypeScript SDK</a>, we can easily create a server that understands how to query our Elasticsearch data based on a user query input.</p><p>Here are the steps in this article to integrate the Elasticsearch MCP server with the Claude Desktop client:</p><ol><li><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#configure-mcp-server-for-elasticsearch">Configure MCP server for Elasticsearch.</a></li><li><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#load-the-mcp-server-into-claude-desktop">Load the MCP server into Claude Desktop.</a></li><li><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#test-it-out">Test it out.</a></li></ol><h3>Configure MCP server for Elasticsearch</h3><p>To begin, let&#x27;s initialize a node application:</p><pre><code>npm init -y</code></pre><p>This will create a <code>package.json</code> file, and with it, we can start installing the necessary dependencies for this application.</p><pre><code>npm install @elastic/elasticsearch @modelcontextprotocol/sdk openai zod && npm install --save-dev ts-node @types/node typescript</code></pre><ul><li><strong>@elastic/elasticsearch</strong> will give us access to the Elasticsearch Node.js library.</li><li><strong>@modelcontextprotocol/sdk</strong> provides the core tools to create and manage an MCP server, register tools, and handle communication with MCP clients.</li><li><strong>openai</strong> allows interaction with OpenAI models to generate summaries or natural language responses.</li><li><strong><a href="https://zod.dev/">zod</a> </strong>helps define and validate structured schemas for input and output data in each tool.</li></ul><p><code>ts-node</code>, <code>@types/node</code>, and <code>typescript</code> will be used during development to type the code and compile the scripts.</p><h4>Set up the dataset</h4><p>To provide the data that Claude Desktop can query using our MCP server, we’ll use a mock <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/dataset.json">internal knowledge base dataset</a>. Here’s what a document from this dataset will look like:</p><pre><code>{
    "id": 5,
    "title": "Logging Standards for Microservices",
    "content": "Consistent logging across microservices helps with debugging and tracing. Use structured JSON logs and include request IDs and timestamps. Avoid logging sensitive information. Centralize logs in Elasticsearch or a similar system. Configure log rotation to prevent storage issues and ensure logs are searchable for at least 30 days.",
    "tags": ["logging", "microservices", "standards"]
}</code></pre><p>To ingest the data, we prepared a script that creates an index in Elasticsearch and loads the dataset into it. You can find it <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/setup.ts">here</a>.</p><h4>MCP server</h4><p>Create a file named <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/index.ts"><code>index.ts</code></a> and add the following code to import the dependencies and handle environment variables:</p><pre><code>// index.ts
import { z } from "zod";
import { Client } from "@elastic/elasticsearch";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import OpenAI from "openai";

const ELASTICSEARCH_ENDPOINT =
  process.env.ELASTICSEARCH_ENDPOINT ?? "http://localhost:9200";
const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY ?? "";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
const INDEX = "documents";</code></pre><p>Also, let’s initialize the clients to handle the Elasticsearch and OpenAI calls:</p><pre><code>const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
});

const _client = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: {
    apiKey: ELASTICSEARCH_API_KEY,
  },
});</code></pre><p>To make our implementation more robust and ensure structured input and output, we&#x27;ll define schemas using <a href="https://zod.dev/"><code>zod</code></a>. This allows us to validate data at runtime, catch errors early, and make the tool responses easier to process programmatically:</p><pre><code>const DocumentSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
});

const SearchResultSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
  score: z.number(),
});

type Document = z.infer<typeof DocumentSchema>;
type SearchResult = z.infer<typeof SearchResultSchema>;</code></pre><p>Learn more about structured outputs <a href="https://www.elastic.co/search-labs/blog/structured-outputs-elasticsearch-guide">here</a>.</p><p>Now let’s initialize the MCP server:</p><pre><code>const server = new McpServer({
  name: "Elasticsearch RAG MCP",
  description:
    "A RAG server using Elasticsearch. Provides tools for document search, result summarization, and source citation.",
  version: "1.0.0",
});</code></pre><h4>Defining the MCP tools</h4><p>With everything configured, we can start writing the tools that will be exposed by our MCP server. This server exposes two tools:</p><ul><li><strong><code>search_docs</code>: </strong>Searches for documents in Elasticsearch using full-text search.</li><li><strong><code>summarize_and_cite</code>:</strong> Summarizes and synthesizes information from previously retrieved documents to answer a user question. This tool also adds citations referencing the source documents.</li></ul><p>Together, these tools form a simple “retrieve-then-summarize” workflow, where one tool fetches relevant documents and the other uses those documents to generate a summarized, cited response.</p><h4>Tool response format</h4><p>Each tool can accept arbitrary input parameters, but it must respond with the following structure:</p><ul><li><strong>Content:</strong> This is the response of the tool in an unstructured format. This field is usually used to return text, images, audio, links, or embeddings. For this application, it will be used to return formatted text with the information generated by the tools.</li><li><strong>structuredContent: </strong>This is an optional return used to provide the results of each tool in a structured format. This is useful for programmatic purposes. Although it isn&#x27;t used in this MCP server, it can be useful if you want to develop other tools or process the results programmatically.</li></ul><p>With that structure in mind, let’s dive into each tool in detail.</p><h4>Search_docs tool</h4><p>This tool performs a <a href="https://www.elastic.co/docs/solutions/search/full-text">full-text search</a> in the Elasticsearch index to retrieve the most relevant documents based on the user query. It highlights key matches and provides a quick overview with relevance scores.</p><pre><code>server.registerTool(
  "search_docs",
  {
    title: "Search Documents",
    description:
      "Search for documents in Elasticsearch using full-text search. Returns the most relevant documents with their content, title, tags, and relevance score.",
    inputSchema: {
      query: z
        .string()
        .describe("The search query terms to find relevant documents"),
      max_results: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of results to return"),
    },
    outputSchema: {
      results: z.array(SearchResultSchema),
      total: z.number(),
    },
  },
  async ({ query, max_results }) => {
    if (!query) {
      return {
        content: [
          {
            type: "text",
            text: "Query parameter is required",
          },
        ],
        isError: true,
      };
    }

    try {
      const response = await _client.search({
        index: INDEX,
        size: max_results,
        query: {
          bool: {
            must: [
              {
                multi_match: {
                  query: query,
                  fields: ["title^2", "content", "tags"],
                  fuzziness: "AUTO",
                },
              },
            ],
            should: [
              {
                match_phrase: {
                  title: {
                    query: query,
                    boost: 2,
                  },
                },
              },
            ],
          },
        },
        highlight: {
          fields: {
            title: {},
            content: {},
          },
        },
      });

      const results: SearchResult[] = response.hits.hits.map((hit: any) => {
        const source = hit._source as Document;

        return {
          id: source.id,
          title: source.title,
          content: source.content,
          tags: source.tags,
          score: hit._score ?? 0,
        };
      });

      const contentText = results
        .map(
          (r, i) =>
            `[${i + 1}] ${r.title} (score: ${r.score.toFixed(
              2,
            )})\n${r.content.substring(0, 200)}...`,
        )
        .join("\n\n");

      const totalHits =
        typeof response.hits.total === "number"
          ? response.hits.total
          : (response.hits.total?.value ?? 0);

      return {
        content: [
          {
            type: "text",
            text: `Found ${results.length} relevant documents:\n\n${contentText}`,
          },
        ],
        structuredContent: {
          results: results,
          total: totalHits,
        },
      };
    } catch (error: any) {
      console.log("Error during search:", error);

      return {
        content: [
          {
            type: "text",
            text: `Error searching documents: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);</code></pre><p><em>We configure <code><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-fuzzy-query">fuzziness</a>: “AUTO”</code> to have a variable typo tolerance based on the length of the token that’s being analyzed. We also set <code>title^2</code> to increase the score of the documents where the match happens on the title field.</em></p><h4>summarize_and_cite tool</h4><p>This tool generates a summary based on documents retrieved in the previous search. It uses OpenAI’s <code>gpt-4o-mini</code> model to synthesize the most relevant information to answer the user’s question, providing responses derived directly from the search results. In addition to the summary, it also returns citation metadata for the source documents used.</p><pre><code>server.registerTool(
  "summarize_and_cite",
  {
    title: "Summarize and Cite",
    description:
      "Summarize the provided search results to answer a question and return citation metadata for the sources used.",
    inputSchema: {
      results: z
        .array(SearchResultSchema)
        .describe("Array of search results from search_docs"),
      question: z.string().describe("The question to answer"),
      max_length: z
        .number()
        .optional()
        .default(500)
        .describe("Maximum length of the summary in characters"),
      max_docs: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of documents to include in the context"),
    },
    outputSchema: {
      summary: z.string(),
      sources_used: z.number(),
      citations: z.array(
        z.object({
          id: z.number(),
          title: z.string(),
          tags: z.array(z.string()),
          relevance_score: z.number(),
        })
      ),
    },
  },
  async ({ results, question, max_length, max_docs }) => {
    if (!results || results.length === 0 || !question) {
      return {
        content: [
          {
            type: "text",
            text: "Both results and question parameters are required, and results must not be empty",
          },
        ],
        isError: true,
      };
    }

    try {
      const used = results.slice(0, max_docs);

      const context = used
        .map(
          (r: SearchResult, i: number) =>
            `[Document ${i + 1}: ${r.title}]\\n${r.content}`
        )
        .join("\n\n---\n\n");

      // Generate summary with OpenAI
      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [
          {
            role: "system",
            content:
              "You are a helpful assistant that answers questions based on provided documents. Synthesize information from the documents to answer the user's question accurately and concisely. If the documents don't contain relevant information, say so.",
          },
          {
            role: "user",
            content: `Question: ${question}\\n\\nRelevant Documents:\\n${context}`,
          },
        ],
        max_tokens: Math.min(Math.ceil(max_length / 4), 1000),
        temperature: 0.3,
      });

      const summaryText =
        completion.choices[0]?.message?.content ?? "No summary generated.";

      const citations = used.map((r: SearchResult) => ({
        id: r.id,
        title: r.title,
        tags: r.tags,
        relevance_score: r.score,
      }));

      const citationText = citations
        .map(
          (c: any, i: number) =>
            `[${i + 1}] ID: ${c.id}, Title: "${c.title}", Tags: ${c.tags.join(
              ", ",
            )}, Score: ${c.relevance_score.toFixed(2)}`,
        )
        .join("\n");

      const combinedText = `Summary:\\n\\n${summaryText}\\n\\nSources used (${citations.length}):\\n\\n${citationText}`;

      return {
        content: [
          {
            type: "text",
            text: combinedText,
          },
        ],
        structuredContent: {
          summary: summaryText,
          sources_used: citations.length,
          citations: citations,
        },
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error generating summary and citations: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);</code></pre><p>Finally, we need to start the server using <a href="https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#stdio">stdio</a>. This means the MCP client will communicate with our server by reading and writing to its standard input and output streams. stdio is the simplest transport option and works well for local MCP servers launched as subprocesses by the client. Add the following code at the end of the file:</p><pre><code>const transport = new StdioServerTransport();
server.connect(transport);</code></pre><p>Now compile the project using the following command:</p><pre><code>npx tsc index.ts --target ES2022 --module node16 --moduleResolution node16 --outDir ./dist --strict --esModuleInterop</code></pre><p>This will create a <code>dist</code> folder, and inside it, an <code>index.js</code> file.</p><h3>Load the MCP server into Claude Desktop</h3><p>Follow <a href="https://modelcontextprotocol.io/docs/develop/connect-local-servers">this guide</a> to configure the MCP server with Claude Desktop. In the Claude configuration file, we need to set the following values:</p><pre><code>{
  "mcpServers": {
    "elasticsearch-rag-mcp": {
      "command": "node",
      "args": [   "/Users/user-name/app-dir/dist/index.js"
      ],
      "env": {
        "ELASTICSEARCH_ENDPOINT": "your-endpoint-here",
        "ELASTICSEARCH_API_KEY": "your-api-key-here",
        "OPENAI_API_KEY": "your-openai-key-here"
      }
    }
  }
}</code></pre><p>The <code>args</code> value should point to the compiled file in the <code>dist</code> folder. You also need to set the environment variables in the configuration file with the exact same names defined in the code.</p><h3>Test it out</h3><p>Before executing each tool, click on <strong>Search and Tools</strong> to make sure that the tools are enabled. Here you can also enable or disable each one:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/172981c2a54adabc70d5819013c3007670935605-1999x1002.png" style="width: 100%;" /><p>Finally, let’s test the MCP server from the Claude Desktop chat and start asking questions:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/03654c0f8c53c714f801fba8b25747071179209b-1999x1353.png" style="width: 100%;" /><p>For the question “<strong>Search for documents about authentication methods and role-based access control</strong>”, the <code>search_docs</code> tool is executed and returns the following results:</p><pre><code>Most Relevant Documents:
Access Control and Role Management (highest relevance) - This document covers role-based access control (RBAC) principles, including ensuring users only have necessary permissions, regular auditing of user roles, revoking inactive accounts, and implementing just-in-time access for sensitive operations.
User Authentication with OAuth 2.0 - This document explains OAuth 2.0 authentication, which enables secure delegated access without credential sharing. It covers configuring identity providers, token management with limited scope and lifetime, and secure storage of refresh tokens.
Container Security Guidelines - While primarily about container security, this document touches on access control aspects like running containers as non-root users and avoiding embedded credentials.
Incident Response Playbook - This mentions role assignment during incidents (incident commander, communications lead, etc.), which relates to access control in emergency scenarios.
Logging Standards for Microservices - This document includes guidance on avoiding logging sensitive information, which is relevant to authentication security.</code></pre><p>The response is, “Great! I found 5 relevant documents about authentication methods and role-based access control. Here&#x27;s what was found:”</p><p>The tool call returns the source documents as part of its response payload, which are later used to generate citations.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/25ff4c7e9563d99752f95540dafdc7fd211a66e3-800x530.gif" style="width: 100%;" /><p>It’s also possible to chain multiple tools in a single interaction. In this case, Claude Desktop analyzes the user&#x27;s question and determines that it needs to first call <code>search_docs</code> to retrieve relevant documents and to then pass those results to <code>summarize_and_cite</code> to generate the final answer, all without requiring separate prompts from the user:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e6a8a46acb4236e77058f18bcd2f0737b5882c05-1999x1101.png" style="width: 100%;" /><p>In this case, for the query “<strong>What are the main recommendations to improve authentication and access control across our systems? Include references.</strong>”, we obtained the following results:</p><pre><code>Based on the documentation, here are the main recommendations to improve authentication and access control across your systems:

Key Recommendations

1. Implement Role-Based Access Control (RBAC)
Ensure users have only the permissions necessary for their job functions. This principle of least privilege minimizes security risks by limiting access to what's truly needed. [1]

2. Regular Access Audits
Conduct regular audits of user roles and promptly revoke access for inactive accounts. This helps prevent unauthorized access from dormant or compromised accounts. [1]

3. Just-in-Time (JIT) Access
Implement just-in-time access for sensitive operations, where permissions are granted only when needed and for a limited duration. This reduces the attack surface by minimizing standing privileges. [1]

4. OAuth 2.0 for Secure Authentication
Use OAuth 2.0 to enable secure delegated access without sharing user credentials. This protocol allows third-party applications to access resources on behalf of users without exposing passwords. [2]

5. Token Security and Management
Configure your identity provider to issue tokens with limited scope and lifetime

Store refresh tokens securely
Validate access tokens consistently to maintain security [2]
References

Access Control and Role Management (Tags: security, access-control)
User Authentication with OAuth 2.0 (Tags: authentication, oauth)
These recommendations work together to create a defense-in-depth approach, where multiple security layers protect your systems from unauthorized access.</code></pre><p>As in the previous step, we can see the response from each tool for this question:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cb606d356b2f7d5e4878a5eff71bc881869ac0ee-800x585.gif" style="width: 100%;" /><p><em>Note: If a submenu appears asking whether you approve the use of each tool, select <strong>Always allow</strong> or <strong>Allow once</strong>.</em></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/aea942ba9b0037526ea215bec65690f1a5c3099c-1522x250.png" style="width: 100%;" /><h2>Conclusion</h2><p>MCP servers represent a significant step toward standardizing LLM tools for both local and remote applications. Though full compatibility is still in the works, we’re moving fast in that direction.</p><p>In this article, we learned how to build a custom MCP server in TypeScript that connects Elasticsearch to LLM-powered applications. Our server exposes two tools: <code>search_docs</code> for retrieving relevant documents using Query DSL; and <code>summarize_and_cite</code> for generating summaries with citations via OpenAI models and Claude Desktop as client UI.</p><p>The future of compatibility between different client and server providers looks promising. Next steps include adding more functionalities and flexibility to your agent. There’s a practical <a href="https://www.elastic.co/search-labs/blog/llm-functions-elasticsearch-intelligent-query">article</a> on how you can parameterize your queries using search templates to gain precision and flexibility.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Announcing read-only permissions for Kibana dashboards]]></title>
            <description><![CDATA[Introducing read-only dashboards in Kibana, giving dashboard creators granular sharing controls to keep results accurate and protected from unwanted changes.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-dashboards-read-only-permissions</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboards-read-only-permissions</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Fabio Busatto, Teresa Alvarez Soler]]></dc:creator>
            <pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/11e578bc317aea30d2e10ccc0334a532f6af2ef9-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>You’ve been there. You spend an hour building the perfect dashboard to monitor your logs: every chart, every filter, and every label. You share it with your team. A few days later, you open it and something’s off. A colleague tweaked a query. Or someone changed the date range. Maybe they thought they were helping. Now you’re digging through revisions and second-guessing every number. Sound familiar?</p><p>That’s exactly why we built <strong>read-only dashboards</strong>. It’s the control you’ve been asking for. Share dashboards with confidence, without worrying that the next person with edit access will change or break them.</p><p>Note: Read-only permissions are available in Elastic Cloud Serverless and from version 9.3 for Elastic Cloud Hosted and Elastic Self-Μanaged.</p><h2>When “everyone can edit” gets in the way</h2><p>In Kibana, <em>sharing </em>has usually meant space-level permissions. If someone can create dashboards in a space, they can also edit or delete anyone else’s. That’s great for collaboration until it isn’t. One accidental edit can ripple into wrong decisions, lost trust, and a lot of cleanup.</p><p>We’ve heard the workarounds: <strong>“We put ‘read-only’ in the dashboard name and hope people notice.”</strong> Or: <strong>“We tag them and cross our fingers.”</strong> Hope isn’t a permission model. You needed a real way to lock a dashboard without locking everyone out of the space.</p><h2>What actually goes wrong</h2><p>Deb and Kevin both have edit access to the log monitoring dashboard within the Operations space. Kevin makes some changes to the charts. When Deb comes back, the numbers don’t match what she presented. She has to track down what changed (often from memory), fix it, and wonder how many reports went out with bad data.</p><h2>Read-only dashboards: Ownership and control that make sense</h2><p>Read-only dashboards fix this by giving you control to decide whether other users can edit the dashboard. When you share a dashboard, you choose: <strong>edit</strong> (default, same as today) or <strong>view</strong>. In <strong>view </strong>mode, only you (and Kibana admins) can change or delete it. Everyone else can open it, use it, and trust it, but they can’t modify it.</p><h3>What you get</h3><ul><li><strong>Dashboard integrity:</strong> In <strong>view</strong> mode, other users with edit access in the space cannot modify or delete the dashboard. If they try, they’re told it’s locked. Your charts and logic stay as you left them.</li><li><strong>You stay in control:</strong> You’re the owner. You can always edit, refine, and update. Sharing as view-only doesn’t lock you out; it locks in the version everyone else sees.</li><li><strong>Flexible lifecycle:</strong> You can switch a dashboard back to “can edit” anytime. And Kibana admins can still manage all dashboards (for example, if the owner leaves). No dead ends.</li></ul><p>You can share finalized, mission-critical dashboards widely and know they’ll stay consistent. This is available in <strong>all Elastic tiers and offerings</strong>, including Serverless.</p><h3>Who can do what?</h3><p>Quick reference by role:</p><ul><li><strong>Dashboard owner:</strong> You created it; you have full edit access.</li><li><strong>Kibana admin:</strong> Can manage all dashboards.</li><li><strong>User with space edit:</strong> Can create and edit their dashboards; can’t edit or delete view-only dashboards.</li><li><strong>User with space view:</strong> Can only view (and list) dashboards.</li></ul>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Action</td><td style="border: 1px solid #ccc; padding: 4px;">Dashboard owner</td><td style="border: 1px solid #ccc; padding: 4px;">Kibana admin</td><td style="border: 1px solid #ccc; padding: 4px;">User with space edit</td><td style="border: 1px solid #ccc; padding: 4px;">User with space view</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">List and view dashboards</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Create new dashboards</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✘</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Modify/delete editable dashboards</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✘</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Modify/delete read-only dashboards</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✔</td><td style="border: 1px solid #ccc; padding: 4px;">✘</td><td style="border: 1px solid #ccc; padding: 4px;">✘</td>
                    </tr>
              </tbody>
            </table>
          <h2>How to turn on read-only</h2><p>You can set view-only when you save a new dashboard or later from the share menu.</p><h3>When saving a new dashboard</h3><ul><li>Build your dashboard, and click <strong>Save</strong>.</li><li>In the “Save as new dashboard” modal, find <strong>Permissions</strong>.</li><li>Change from <strong>Can edit</strong> to <strong>Can view</strong>.</li><li>Click <strong>Save</strong>. Done. It’s read-only for everyone else.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/42a71d1bb55f9d50bd079f53bf45a0e1999b27f7-1214x1306.png" style="width: 100%;" /><h2>For a dashboard you already own</h2><ul><li>Open the dashboard.</li><li>Open the <strong>Share dashboard</strong> menu.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e8405938557c879b1d4c262b98cf5a7f66408c04-1246x264.png" style="width: 100%;" /><ul><li>In the sharing modal, find <strong>Permissions</strong> and switch to <strong>Can view</strong>. The change applies immediately; other users in the space can no longer edit or delete it.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/849405bc32701f3ebe0def012d8ae3cf3813ea0a-996x750.png" style="width: 100%;" /><ul><li>You can mouse over the <strong>Share</strong> action to see what type of permissions a given dashboard has.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/80541ddb1b1bc567b0aeff693944ea8b6871d6a7-1270x320.png" style="width: 100%;" /><h3>Seeing which dashboards are locked</h3><p>On the main Dashboards list, dashboards you can’t edit or delete have a disabled selection checkbox. This provides an easy way to spot what’s view-only.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/24b2eba08df86174db949c662e7886c5aea1b460-1999x876.png" style="width: 100%;" /><p>In the dashboard, you will also find that the Edit action is disabled and a tooltip will appear, explaining that the dashboard has been set as view-only.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e0a2f9da6dc854e876fc6dc2a7c3ef8b313b52ef-1358x330.png" style="width: 100%;" /><h2>Try it</h2><p>Read-only dashboards are available now. Create a dashboard, flip it to <strong>Can view</strong>, and share it. Your team gets a single source of truth, and you get peace of mind. No more “please don’t edit” in the title.</p><p>We’d love to hear how you use read-only dashboards. Share your feedback in our <a href="https://discuss.elastic.co">community forum</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The shell tool is not a silver bullet for context engineering]]></title>
            <description><![CDATA[Learn what context-retrieval tools exist for context engineering, how they work, and their trade-offs.]]></description>
            <link>https://www.elastic.co/search-labs/blog/search-tools-context-engineering</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-tools-context-engineering</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Leonie Monigatti]]></dc:creator>
            <pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f91e4d07915ba7bf3b7abf15fac8fab3350f7df2-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>The most important tools an agent has are the search tools it can use to build its own context. Recent posts by <a href="https://www.llamaindex.ai/blog/files-are-all-you-need">LlamaIndex</a> and <a href="https://x.com/hwchase17/status/2011814697889316930">LangChain</a> have sparked a discussion: <em>Are a shell tool and a filesystem all an agent needs for context engineering? </em>Unfortunately, the discussion quickly drifted to the wrong focus: filesystem versus database.</p><p>This post refocuses on the question,<em>What are the right search interfaces an agent needs to build its own context?</em> It first covers the trade-offs between shell tools and dedicated database tools. From there, it offers a practical framework for finding the right interfaces for your agent&#x27;s needs.</p><h2>What does &quot;building context&quot; actually mean for an agent?</h2><p>In early <a href="https://www.elastic.co/what-is/retrieval-augmented-generation">retrieval augmented generation (RAG) pipelines</a>, the developer engineered a fixed retrieval pipeline, and the large language model (LLM) was a passive recipient of the context. This was a fundamental limitation: Context was retrieved on every query, whether or not it was needed, with no check that it actually helped.</p><p>With the shift to agentic RAG, the agents now have access to a set of search tools to build their own context. For example, both Claude Code [1] and Cursor [2] let the agent choose between different search tools and even combine them for chained queries, depending on what the task actually requires.</p><h2>What search interfaces exist for context engineering?</h2><p>Context can live in different locations, such as on the web, in a local filesystem, or in a database. An agent can interact with each of these out-of-context data sources through different tools:</p><ul><li><strong>Shell tools</strong> can execute shell commands and have access to the local filesystem. Some examples of built-in shell tools are <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/bash-tool">Claude API&#x27;s bash tool</a>, <a href="https://docs.openclaw.ai/tools/exec">OpenClaw&#x27;s exec tool</a>, and <a href="https://docs.langchain.com/oss/python/integrations/tools/bash">LangChain&#x27;s shell tool</a>.</li><li><strong>Dedicated database tools,</strong> such as tools from a Model Context Protocol (MCP) server (for example, the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic Agent Builder MCP server)</a> or custom tools (for example, <code>run_esql(query)</code> or <code>db_list_index()</code>), can query databases.</li><li><strong>Dedicated file search tools</strong> can search and read local (or uploaded) files (without full shell access). Some examples of built-in file search tools are <a href="https://ai.google.dev/gemini-api/docs/file-search">Gemini API’s File Search Tool</a> or <a href="https://developers.openai.com/api/docs/guides/tools-file-search">OpenAI’s File Search Tool</a>.</li><li><strong>Web search tools</strong> can retrieve information from the web.</li><li><strong>Memory tools</strong> store and recall from long-term memory (regardless of how it’s stored).</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/115f20c8ded259e508f51524b2c06bdc702d70ab-1999x1050.png" style="width: 100%;" /><p>As you can see, the shell tool is versatile and can be used to retrieve context from different data sources, including:</p><ul><li><strong>Filesystem:</strong> The agent explores the directory structure (ls, find), searches for relevant content (grep, cat), and repeats until it has built sufficient context.</li><li><strong>Database:</strong> The agent can use database command line interface (CLI) tools (for example, <a href="https://www.elastic.co/docs/reference/query-languages/sql/sql-cli"><code>elasticsearch-sql-cli</code></a>), call HTTP APIs via curl, or run scripts, which is especially useful in combination with agent skills, which are reusable, documented examples injected into the agent&#x27;s context to guide correct tool usage (for example, <a href="https://github.com/elastic/agent-skills">Elastic Agent Skills for Elasticsearch</a>).</li><li><strong>Web: </strong>The agent can execute web searches via a curl command through a search provider’s API.</li></ul><p>However, the shell tool provides direct system access and therefore requires safety measures, such as running in an isolated sandbox environment and logging all executed commands.</p><h2>When to use which search interfaces</h2><p>The right search interface depends on your data, your query patterns, and your use case. This section serves as a practical starting point.</p><h3>Filesystems aren’t making databases obsolete</h3><p>The filesystems-versus-databases discussion is not about the storage layer. For example, LangChain explains that <a href="https://x.com/hwchase17/status/2011814697889316930">its memory system</a> doesn’t actually store memory in a real filesystem. Instead, it stores memory in a database and <em>represents</em> it as a set of files to the agent [3].</p><p>Filesystems are a natural fit for file-native use cases, such as coding agents. They also work well as a temporary scratch pad or working memory and for single-user or single-agent scenarios where concurrency isn&#x27;t a concern. In these cases, a physical filesystem or representing the data as a filesystem gives you flexibility before committing to a purpose-built interface.</p><p>But filesystem storage has real downsides, such as weak concurrency, manual schema enforcement, and atomic transactions. These become more apparent when your application needs to scale or move to a multi-agent scenario. Anyone who ignores these downsides is doomed to <a href="https://dx.tips/oops-database">painfully reinvent worse databases</a> without the decades of engineering behind transaction safety or access control that production databases already provide. Additionally, in most enterprise contexts, you don&#x27;t choose whether to use a database since it&#x27;s already there, storing business-critical data.</p><h3>Shell tool + filesystem</h3><p>A shell tool is the natural starting point for filesystem search. Currently, coding agents are driving a lot of progress in the field. Because they work with code in local files, they’re naturally file-heavy use cases. Therefore, LLMs are fine-tuned in the post-training stage for coding tasks. That’s why many LLMs are not only good at writing code but also at using shell commands and navigating filesystems.</p><p>Using a shell tool with built-in CLIs, like <code>ls</code> and <code>grep</code>, to find files is effective. With grep, a query like &quot;Find all files that import <code>matplotlib</code>&quot; is fast, precise, and cheap. But when the agent needs to handle conceptual queries, such as &quot;How does our app handle failed authentication?&quot;, pattern matching with grep can hit a ceiling quickly. Several alternatives that bring semantic search capabilities to the command line have emerged to fill this gap, including <a href="https://github.com/jina-ai/jina-grep-cli"><code>jina-grep</code></a>.</p><p>However, grep and many of its semantic search alternatives run in O(n) over the corpus. For use cases over codebases, this might be fine. However, if your data grows, latency will become noticeable. In this case, an indexed datastore becomes necessary to maintain performance.</p><h3>Shell tool + database</h3><p>Another way to add more search capabilities, such as semantic or hybrid search, over your data is to store it in a database, as Cursor does, for example. Additionally, when data requires complex relational joins or aggregations, a database interface is nonnegotiable.</p><p>When the data lives in a database rather than on the filesystem, a shell tool can serve as a lightweight database interface for certain use cases. If your queries are simple enough for a CLI or a curl call, a dedicated database tool may add unnecessary complexity.</p><p>This approach is also suitable in early exploration stages, when you don&#x27;t yet know what query patterns your agent will actually develop. In this case, Agent Skills can give the agent enough structure to query correctly without committing to a purpose-built tool. However, when the agent requires many iterations to figure out the right way to query the database for repeated tasks, the token overhead of using a shell tool as the interface no longer justifies the simplicity benefit of avoiding an extra tool.</p><h3>Dedicated database tool</h3><p>Especially when repeated query patterns are structured or analytical, dedicated database tools become necessary. A <a href="https://vercel.com/blog/testing-if-bash-is-all-you-need">blog post from Vercel and Braintrust</a> compared agents with different sets of search tools for real-world retrieval tasks over semi-structured data, such as customer support tickets and sales call transcripts (for example, “How many open issues mention &#x27;security&#x27;?&quot; or &quot;Find issues where someone reported a bug and later someone submitted a PR claiming to fix it?&quot;) [4].</p><p>Agents with dedicated database tools used fewer tokens, were faster, and made fewer mistakes than agents with only a shell tool and filesystem. The lesson is that direct database tools are the right choice when the query requires analytical reasoning over semi-structured data.</p><h3>Combining search interfaces</h3><p>No single search interface handles every query well. For example, Cursor combines shell tools (for searches via grep) and semantic search tools and lets the agent select the right tool based on the user’s prompt. They report that the agent chooses grep for matching specific symbols or strings, semantic search for conceptual or behavior questions, and both for exploratory tasks.</p><p>The Vercel experiment reports the same: Its hybrid agent with access to both a shell tool and a dedicated database tool achieved the best performance out of all tested agents by first using the dedicated database tools and then verifying the results by grepping through the filesystem. However, this approach uses more tokens and time for reasoning about tool choice and verification.</p><p>The pattern across both examples is the same: Composition beats any single interface, but composition comes at the trade-off of added cost and latency.</p><h2>Practical recommendations for finding the right set of tools</h2><p>The right set of search interfaces is small, purposeful, and specific to your agent&#x27;s actual query patterns. The current best practice is to have an agent with as few tools as possible instead of having an agent with hundreds of MCP tools. This is because the downside of exposing all possible tools up front is that it bloats the context window and confuses the agent about which tool to actually use. For example, Claude Code reportedly only has about 20 tools.</p><p>Instead, the idea of progressive disclosure is to start with a minimal set of tools and let the agent discover additional capabilities only when needed. Research from Anthropic [5] and Cursor [6] has shown that this approach yields a token savings between 47%–85%. Claude Code, for example, implements this directly, allowing the agent to incrementally discover how to query an API or a database, without that knowledge consuming context on every LLM call.</p><p>Once you’re familiar with the agent&#x27;s query patterns, you can revisit the set of search tools that the agent has access to by default. A useful way to think about this trade-off is the <a href="https://www.elastic.co/search-labs/blog/database-retrieval-tools-context-engineering#building-the-right-database-retrieval-tools-%5C(%E2%80%9Clow-floor,-high-ceiling%E2%80%9D%5C">&quot;low floor, high ceiling&quot; principle</a> for deciding which tools make the cut. High-ceiling tools don&#x27;t limit the agent&#x27;s potential. For example, a versatile shell tool lets the agent write full database queries, including ambiguous ones, but at the cost of reasoning overhead, higher latency, and lower reliability.</p><p>Low-floor tools are the opposite. They’re specialized tools that wrap specific queries and are immediately accessible to the agent with minimal reasoning overhead, producing lower cost and higher reliability. But they need upfront engineering, can&#x27;t cover every possible query, and can make it harder for the agent to choose the right tool.</p><p>Think of each tool on a spectrum: Low-floor tools are easy for the agent to use correctly but narrow in scope. High-ceiling tools are versatile but demand more reasoning to use well.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e6d1b973be4b0a0a25c99c74f02a47e98395a3f7-1200x630.png" style="width: 100%;" /><p>Most agents need a mix of different search tools. But each tool needs to earn its addition. We recommend starting with an all-purpose search tool (for example a <code>search_database()</code> tool or a shell tool). Then reuse the command logs you&#x27;re already keeping for security purposes to track what your agent actually does, including tool calls, retries, and number of calls per user query. And, when you see a query pattern repeating or failing, that&#x27;s the signal to build a purpose-built tool for it.</p><h2>Summary</h2><p>The filesystem-versus-database debate is distracting from the actual question that engineers need to be asking: <em>What are the right search interfaces an agent needs to build its own context?</em> The answer is most likely, <em>Not a single one</em>.</p><p>A shell tool is a versatile tool to interact with different out-of-context sources and thus a good starting point. But it’s less efficient and accurate for use cases with structured analytical queries than dedicated database tools.</p><p>The goal is to find the minimal set of search tools that handles your agent&#x27;s actual query patterns well. Start with a shell tool, and log what your agent actually does. When you see a query pattern repeating and failing, it’s time to engineer specialized tools.</p><h2>References</h2><p>1. Thariq (Anthropic). <a href="https://x.com/trq212/status/2027463795355095314">Lessons from Building Claude Code: Seeing like an Agent</a> (2026).</p><p>2. Cursor: Documentation. <a href="https://cursor.com/docs/agent/tools/search">Semantic &amp; agentic search</a> (2026).</p><p>3. Harrison Chase (LangChain). <a href="https://x.com/hwchase17/status/2011814697889316930">How we built Agent Builder’s memory system</a> (2026).</p><p>4. Ankur Goyal (Braintrust) and Andrew Qu (Vercel). <a href="https://vercel.com/blog/testing-if-bash-is-all-you-need">Testing if &quot;bash is all you need&quot;</a> (2026).</p><p>5. Anthropic. <a href="https://www.anthropic.com/engineering/advanced-tool-use">Introducing advanced tool use on the Claude Developer Platform</a> (2025).</p><p>6. Cursor. <a href="https://cursor.com/blog/dynamic-context-discovery">Dynamic context discovery</a> (2026).</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch replicas for load balancing in Serverless]]></title>
            <description><![CDATA[Learn how Elastic Cloud Serverless automatically adjusts index replicas based on search load, ensuring optimal query performance without manual configuration.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-replicas-load-balancing-serverless</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-replicas-load-balancing-serverless</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Andrei Dan]]></dc:creator>
            <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3c4c1e99b892f026b7aba098973593f8298e2ea6-1280x717.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In Elastic Cloud Serverless, we automatically adjust the number of replicas for your indices based on search load, ensuring optimal query performance without any manual configuration. In this blog, we’ll explain how replicas are scaled, when the system adds or removes them, and what this means for your indices.</p><h2>The party is getting crowded</h2><p>You&#x27;re hosting a pizza party. You&#x27;ve got a few friends helping you serve, each stationed at different spots around the room. You give each friend a pizza, and they start handing out slices to hungry guests as they arrive.</p><p>At first, things run smoothly. A few guests trickle in, your friends serve slices, everyone&#x27;s happy. But then word spreads about your sourdough pizzas. The doorbell keeps ringing. Guests pour in. Soon, there&#x27;s a crowd forming around one of your friends, the one holding the pepperoni pizza, which everyone seems to want.</p><p>Your friend with the pepperoni pizza is overwhelmed. Guests are waiting, getting impatient, and a large queue has formed. Meanwhile, your friend holding the margherita pizza is standing around with barely anyone asking for a slice.</p><p>What do you do?</p><p>You order a couple more pepperoni pizzas and hand them to other friends. Now three friends are holding pepperoni instead of one. The crowd spreads out, and suddenly you can serve three times as many guests at once.</p><p>A few things become clear as you host more parties:</p><ul><li><strong>Not all pizzas are equally popular.</strong> Some are in high demand, others have fewer takers. You don&#x27;t need extra &quot;copies&quot; of the unpopular ones. You need extras of the ones with queues.</li><li><strong>Order more pizzas before the queue gets too long.</strong> If you wait until your friend is completely overwhelmed and guests are leaving angry, you&#x27;ve waited too long. Better to get an extra pizza when you see a crowd forming.</li><li><strong>Don&#x27;t throw away pizzas too quickly.</strong> Just because the crowd around the pepperoni thinned out for five minutes doesn&#x27;t mean the rush is over. Maybe they&#x27;re just refilling drinks, or even talking among themselves (is that still a thing?). Keep the extra pizzas ready. If the lull continues for a while, then you can put them away.</li><li><strong>You can only hand out as many pizzas as you have friends who are helping.</strong> If you&#x27;ve only got four friends helping, ten pizzas won’t change the outcome. Only four can be served at once. Match your pizza count to your available hands.</li><li><strong>When a friend leaves, take their pizza.</strong> If one of your friends needs to head out, grab their pizza immediately. You can&#x27;t have pizzas sitting unattended. Hand it to someone else, or put it away.</li></ul><h2>From pizzas to replicas</h2><p>Let&#x27;s map this back to Elasticsearch.</p><p>In our analogy, pizzas are replicas (copies of your index shards), your friends helping serve are search nodes, hungry guests are search queries, and that popular pizza with a crowd around it is a hot index with high search load.</p><p>When search traffic increases on a particular index, we create additional replicas and distribute them across your search nodes. Any replica can serve any query for that index, just like any friend holding pepperoni can hand out pepperoni slices. More replicas means higher throughput: Three replicas can handle three times the queries per second of a single replica.</p><h2>Measuring the hunger</h2><p>Before we decide how many pizzas to order, we need to know how hungry the crowd is.</p><p>Elasticsearch tracks the <strong>search load</strong> for every shard. It&#x27;s a metric that captures how much search activity a shard is handling. We aggregate this across all shards of an index to understand the total search demand.</p><p>What matters most is the <strong>relative search load</strong>: What proportion of your project&#x27;s total search traffic is hitting each index? If one index is receiving 60% of all searches while another gets 5%, we know where to add capacity.</p><h2>The math behind the pizzas</h2><p>We calculate the optimal number of replicas following this formula:</p><pre><code>desired_replicas = min(ceil(L × N / (S × X)), N)</code></pre><p>Where:</p><ul><li><strong>L</strong> = the index&#x27;s relative search load (between 0 and 1).</li><li><strong>N</strong> = the number of desired search nodes in your project.</li><li><strong>S</strong> = the number of shards in the index.</li><li><strong>X</strong> = a threshold to avoid hot spots (default: 0.5).</li></ul><p>An example: four search nodes, one index with two primary shards receiving 80% of search traffic:</p><pre><code>desired_replicas = min(ceil(0.8 × 4 / (2 × 0.5)), 4)
                 = min(4, 4)
                 = 4</code></pre><p>This hot index gets four replicas distributed across the search nodes.</p><p>The threshold X (defaulting to 0.5) is important. We don&#x27;t wait until a replica is completely overwhelmed; we scale up when it&#x27;s at half capacity. Hand out the extra pizza when you see the crowd forming, not when guests are already leaving.</p><h2>Scale up fast, scale down slow</h2><p>When search load increases, we add replicas immediately. No reason to make users wait.</p><p>When search load drops, we wait a bit before taking any action. We need to see consistent low demand for about 30 minutes before reducing replicas. (This is to deal with spiky traffic where a quiet moment doesn&#x27;t mean the party is over.)</p><p>This matters because adding a replica has a cost. The new replica copies data and warms its caches before serving queries efficiently. Removing replicas too eagerly means constantly paying this startup cost as traffic naturally fluctuates.</p><h2>Respecting topology bounds</h2><p>Replicas can never exceed the number of search nodes. Having more replicas than nodes provides no benefit (you can only serve as many pizzas as you have friends who are helping to serve slices).</p><p>When nodes are removed from your project, we reduce replicas immediately to match. No waiting for the cooldown, as you can&#x27;t have unassigned replicas. The moment a friend leaves, we remove their pizza.</p><h2>The bigger Serverless picture</h2><p>Replicas for search load balancing works alongside other autoscaling systems:</p><ul><li><strong>Search autoscaling</strong> adjusts the number of search nodes (how many friends are helping).</li><li><strong>Replicas for search load balancing</strong> distribute traffic by adjusting replica counts per index (how many pizzas of each kind we need).</li><li><strong>Data stream autosharding</strong> optimizes shard counts for writes (how to slice each pizza, covered in the <a href="https://www.elastic.co/search-labs/blog/datastream-autosharding-serverless">previous post</a>).</li></ul><p>An important design principle: Replicas for load balancing don&#x27;t directly trigger search autoscaling. Instead, by distributing search requests across more replicas, it enables increasing resource utilization across your search nodes. This higher utilization then triggers our existing autoscaling logic to add capacity if needed. Replicas for load balancing enables autoscaling to do its job, making sure your search nodes are actually being used, rather than having all traffic bottlenecked on a single replica while other nodes sit idle.</p><h2>What this means for you</h2><p>You don&#x27;t need to predict which indices will be popular. You don&#x27;t need to manually adjust replicas when traffic patterns change. You don&#x27;t need to wake up at 3 a.m. because a surge overwhelmed your busiest index.</p><p>The system watches where queues are forming and orders more pizzas for those spots. Cold indices don&#x27;t waste resources on unnecessary replicas. Hot indices get the capacity they need. Your budget goes where it matters.</p><h2>Conclusion</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/datastream-autosharding-serverless">autosharding post</a>, we made sure your pizzas are sliced right. Now, with replicas for search load balancing, we make sure you have enough pizzas, in the right hands, when the hungry crowds arrive.</p><p>Try <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> and let us handle the pizza logistics.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Elasticsearch Inference API along with Hugging Face models]]></title>
            <description><![CDATA[Learn how to connect Elasticsearch to Hugging Face models using inference endpoints, and build a multilingual blog recommendation system with semantic search and chat completions.]]></description>
            <link>https://www.elastic.co/search-labs/blog/hugging-face-elasticsearch-inference-api</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hugging-face-elasticsearch-inference-api</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1417d6ff033712206c9bd4bcc22074ee3437ce96-1999x1125.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In recent updates, Elasticsearch introduced a native integration to connect to models hosted on the <a href="https://endpoints.huggingface.co/">Hugging Face Inference Service</a>. In this post, we’ll explore how to configure this integration and perform inference through simple API calls using a large language model (LLM). We’ll use <a href="https://huggingface.co/HuggingFaceTB/SmolLM3-3B">SmolLM3-3B</a>, a lightweight general-purpose model with a good balance between resource usage and answer quality.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ddadf1976421a860a7d62087239adb9150d808b-1999x1388.png" style="width: 100%;" /><h2>Prerequisites</h2><ul><li><strong>Elasticsearch 9.3 or Elastic Cloud Serverless: </strong>You can create a cloud deployment following <a href="https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud">these instructions</a>, or you can use the <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart#local-dev-quick-start"><code>start-local</code></a> quickstart instead.</li><li><strong>Python 3.12: </strong>Download Python <a href="https://www.python.org/">here</a>.</li><li><strong>Hugging Face </strong><a href="https://huggingface.co/docs/hub/en/security-tokens">access token</a>.</li></ul><h2>Chat completions using a Hugging Face inference endpoint</h2><p>First, we’ll build a practical example that connects Elasticsearch to a Hugging Face <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put">inference endpoint</a> to generate AI-powered recommendations from a collection of blog posts. For the app knowledge base, we’ll use a dataset of company blog articles, which contains valuable but often hard-to-navigate information.</p><p>With this endpoint, <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> retrieves the most relevant articles for a given query, and a Hugging Face LLM generates short, contextual recommendations based on those results.</p><p>Let’s take a look at a high-level overview of the information flow we’re going to build:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1dfbc2323438feaaa42e13ab242dd1f7166f74aa-1200x676.png" style="width: 100%;" /><p>In this article, we’ll test <strong>SmolLM3-3B </strong>capacity to<strong> </strong>combine its compact size with strong multilingual reasoning and tool-calling capabilities. Based on a search query, we’ll send all the matching content (in English and Spanish) to the LLM to generate a list of recommended articles with a custom-made description based on the search query and results.</p><p>Here’s what the UI of an article site with an AI recommendations generation system could look like.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8d3b86b212f28ff279f2da67a33e6134039f0e4e-1999x949.png" style="width: 100%;" /><p>You can find the full implementation of this application in the linked <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/notebook.ipynb">notebook</a>.</p><h3>Configuring Elasticsearch inference endpoints</h3><p>To use the Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-hugging-face">Hugging Face inference endpoint</a>, we need two important elements: a Hugging Face API key and a running Hugging Face endpoint URL. It should look like this:</p><pre><code>PUT _inference/chat_completions/hugging-face-smollm3-3b
{
    "service": "hugging_face",
    "service_settings": {
        "api_key": "hugging-face-access-token", 
        "url": "url-endpoint" 
    }
}</code></pre><p>The Hugging Face inference endpoint in Elasticsearch supports different task types: <code>text_embedding</code>, <code>completion</code>, <code>chat_completion</code>, and <code>rerank</code>. In this blog post, we use <code>chat_completion</code> because we need the model to generate conversational recommendations based on the search results and a system prompt.This endpoint allows us to perform chat completions directly from Elasticsearch in a simple way using the Elasticsearch API:</p><pre><code>POST _inference/chat_completion/hugging-face-smollm3-3b/_stream
{
  "messages": [
      { "role": "user", "content": "<user prompt>" }
  ]
}</code></pre><p>This will serve as the core of the application, receiving the prompt and the search results that will pass through the model. With the theory covered, let’s start implementing the application.</p><h4>Setting up ​​inference endpoint on Hugging Face</h4><p>To deploy the Hugging Face model, we’re going to use <a href="https://huggingface.co/inference-endpoints/dedicated">Hugging Face one-click deployments</a>, an easy and fast service for deploying model endpoints. Keep in mind that this is a paid service, and using it may incur additional costs. This step will create the model instance that will be used to generate the recommendations of the articles.</p><p>You can pick a model from the one-click catalog:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b816e9fba1fe172687bf58f5143fb1f838c1077f-549x331.png" style="width: 100%;" /><p>Let’s pick the <strong>SmolLM3-3B</strong> model:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/610d3aba0429f3666c2df3616d513eb6a4397c0c-502x478.png" style="width: 100%;" /><p>From here, grab the Hugging Face endpoint URL:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/025094ddb2cfbd1f0f216a5ec4e119b0f4fa2c42-646x328.png" style="width: 100%;" /><p>As mentioned in the Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-hugging-face">Hugging Face inference endpoints documentation</a>, text generation requires a model that’s compatible with the OpenAI API. For that reason, we need to append the <code>/v1/chat/completions</code> subpath to the Hugging Face endpoint URL. The final result will look like this:</p><pre><code>https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions</code></pre><p>With this in place, we can start coding in a Python notebook.</p><h4>Generating Hugging Face API key</h4><p>Create a <a href="https://huggingface.co/join">Hugging Face account</a>, and obtain an API token by following <a href="https://huggingface.co/docs/hub/en/security-tokens#user-access-tokens">these instructions</a>. You can choose between three token types: <em>fine-grained</em> (recommended for production, as it provides access only to specific resources); <em>read</em> (for read-only access); or <em>write</em> (for read and write access). For this tutorial, a read token is sufficient, since we only need to call the inference endpoint. Save this key for the next step.</p><h4>Setting up Elasticsearch inference endpoint</h4><p>First, let’s declare an Elasticsearch Python client:</p><pre><code>os.environ["ELASTICSEARCH_API_KEY"] = "your-elasticsearch-api-key"
os.environ["ELASTICSEARCH_URL"] = "https://xxxx.us-central1.gcp.cloud.es.io:443"

es_client = Elasticsearch(
    os.environ["ELASTICSEARCH_URL"], api_key=os.environ["ELASTICSEARCH_API_KEY"]
)</code></pre><p>Next, let’s create an Elasticsearch inference endpoint that uses the Hugging Face model. This endpoint will allow us to generate responses based on the blog posts and the prompt passed to the model.</p><pre><code>INFERENCE_ENDPOINT_ID = "smollm3-3b-pnz"

os.environ["HUGGING_FACE_INFERENCE_ENDPOINT_URL"] = (
 "https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions"
)
os.environ["HUGGING_FACE_API_KEY"] = "hf_xxxxx"

resp = es_client.inference.put(
        task_type="chat_completion",
        inference_id=INFERENCE_ENDPOINT_ID,
        body={
            "service": "hugging_face",
            "service_settings": {
                "api_key": os.environ["HUGGING_FACE_API_KEY"],
                "url": os.environ["HUGGING_FACE_INFERENCE_ENDPOINT_URL"],
            },
        },
    )</code></pre><h3>Dataset</h3><p>The dataset contains the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/dataset.json">blog posts</a> that will be queried, representing a multilingual content set used throughout the workflow:</p><pre><code>// Articles dataset document example: 
{
    "id": "6",
    "title": "Complete guide to the new API: Endpoints and examples",
    "author": "Tomas Hernandez",
    "date": "2025-11-06",
    "category": "tutorial",
    "content": "This guide describes in detail all endpoints of the new API v2. It includes code examples in Python, JavaScript, and cURL for each endpoint. We cover authentication, resource creation, queries, updates, and deletion. We also explain error handling, rate limiting, and best practices. Complete documentation is available on our developer portal."
  }</code></pre><h4>Elasticsearch mappings</h4><p>With the dataset defined, we need to create a data schema that properly fits the blog post structure. The following <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">index mappings</a> will be used to store the data in Elasticsearch:</p><pre><code>INDEX_NAME = "blog-posts"

mapping = {
    "mappings": {
        "properties": {
            "id": {"type": "keyword"},
            "title": {
                "type": "object",
                "properties": {
                    "original": {
                        "type": "text",
                        "copy_to": "semantic_field",
                        "fields": {"keyword": {"type": "keyword"}},
                    },
                    "translated_title": {
                        "type": "text",
                        "fields": {"keyword": {"type": "keyword"}},
                    },
                },
            },
            "author": {"type": "keyword", "copy_to": "semantic_field"},
            "category": {"type": "keyword", "copy_to": "semantic_field"},
            "content": {"type": "text", "copy_to": "semantic_field"},
            "date": {"type": "date"},
            "semantic_field": {"type": "semantic_text"},
        }
    }
}


es_client.indices.create(index=INDEX_NAME, body=mapping)</code></pre><p>Here, we can see more clearly how the data is structured. We’ll use semantic search to retrieve results based on natural language, along with the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> property to copy the field contents into the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> field. Additionally, the <code>title</code> field contains two subfields: the <code>original</code> subfield stores the title in either English or Spanish, depending on the original language of the article; and the <code>translated_title</code> subfield is present only for Spanish articles and contains the English translation of the original title.</p><h3>Ingesting data</h3><p>The following code snippet ingests the blog posts dataset into Elasticsearch using the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/javascript/bulk_examples">bulk API</a>:</p><pre><code>def build_data(json_file, index_name):
    with open(json_file, "r") as f:
        data = json.load(f)

    for doc in data:
        action = {"_index": index_name, "_source": doc}
        yield action


try:
    success, failed = helpers.bulk(
        es_client,
        build_data("dataset.json", INDEX_NAME),
    )
    print(f"{success} documents indexed successfully")

    if failed:
        print(f"Errors: {failed}")
except Exception as e:
    print(f"Error: {str(e)}")</code></pre><p>Now that we have the articles ingested into Elasticsearch, we need to create a function capable of searching against the <code>semantic_text</code> field:</p><pre><code>def perform_semantic_search(query_text, index_name=INDEX_NAME, size=5):
    try:
        query = {
            "query": {
                "match": {
                    "semantic_field": {
                        "query": query_text,
                    }
                }
            },
            "size": size,
        }

        response = es_client.search(index=index_name, body=query)
        hits = response["hits"]["hits"]

        return hits
    except Exception as e:
        print(f"Semantic search error: {str(e)}")
        return []</code></pre><p>We also need a function that calls the inference endpoint. In this case, we’ll call the endpoint using the <strong><code>chat_completion</code> </strong>task type to get streaming responses:</p><pre><code>def stream_chat_completion(messages: list, inference_id: str = INFERENCE_ENDPOINT_ID):
    url = f"{ELASTICSEARCH_URL}/_inference/chat_completion/{inference_id}/_stream"
    payload = {"messages": messages}
    headers = {
        "Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.post(url, json=payload, headers=headers, stream=True)
        response.raise_for_status()

        for line in response.iter_lines(decode_unicode=True):
            if line:
                line = line.strip()

                if line.startswith("event:"):
                    continue

                if line.startswith("data: "):
                    data_content = line[6:]

                    if not data_content.strip() or data_content.strip() == "[DONE]":
                        continue

                    try:
                        chunk_data = json.loads(data_content)

                        if "choices" in chunk_data and len(chunk_data["choices"]) > 0:
                            choice = chunk_data["choices"][0]
                            if "delta" in choice and "content" in choice["delta"]:
                                content = choice["delta"]["content"]
                                if content:
                                    yield content

                    except json.JSONDecodeError as json_err:
                        print(f"\nJSON decode error: {json_err}")
                        print(f"Problematic data: {data_content}")
                        continue

    except requests.exceptions.RequestException as e:
        yield f"Error: {str(e)}"</code></pre><p>Now we can write a function that calls the semantic search function, along with the <code>chat_completions</code> inference endpoint and the recommendations endpoint, to generate the data that will be allocated in the cards:</p><pre><code>def recommend_articles(search_query, index_name=INDEX_NAME, max_articles=5):
    print(f"\n{'='*80}")
    print(f"🔍 Search Query: {search_query}")
    print(f"{'='*80}\n")

    articles = perform_semantic_search(search_query, index_name, size=max_articles)

    if not articles:
        print("❌ No relevant articles found.")
        return None, None

    print(f"✅ Found {len(articles)} relevant articles\n")

    # Build context with found articles
    context = "Available blog articles:\n\n"
    for i, article in enumerate(articles, 1):
        source = article.get("_source", article)
        context += f"Article {i}:\n"
        context += f"- Title: {source.get('title', 'N/A')}\n"
        context += f"- Author: {source.get('author', 'N/A')}\n"
        context += f"- Category: {source.get('category', 'N/A')}\n"
        context += f"- Date: {source.get('date', 'N/A')}\n"
        context += f"- Content: {source.get('content', 'N/A')}\n\n"

    system_prompt = """You are an expert content curator that recommends blog articles.

    Write recommendations in a conversational style starting with phrases like:
    - "If you're interested in [topic], this article..."
    - "This post complements your search with..."
    - "For those looking into [topic], this article provides..."


    FORMAT REQUIREMENTS:
    - Return ONLY a JSON array
    - Each element must have EXACTLY these three fields: "article_number", "title", "recommendation"
    - If the original title is in spanish, use the "translated_title" subfield in the "title" field

    Keep each recommendation concise (2-3 sentences max) and focused on VALUE to the reader.

    EXAMPLE OF CORRECT FORMAT:
    [
        {"article_number": 1, "title": "Article title in english", "recommendation": "If you are interested in [topic], this article provides..."},
        {"article_number": 2, "title": "Article title in english", "recommendation": " for those looking into [topic], this article provides..."}
    ]

    Return ONLY the JSON array following this exact structure."""

    user_prompt = f"""Search query: "{search_query}"

    Generate recommendations for the following articles: {context}
    """

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]

    # LLM generation
    print(f"{'='*80}")
    print("🤖 Generating personalized recommendations...\n")

    full_response = ""

    for chunk in stream_chat_completion(messages):
        print(chunk, end="", flush=True)
        full_response += chunk

    return context, articles, full_response</code></pre><p>Finally, we need to extract the information and format it to be printed:</p><pre><code>def display_recommendation_cards(articles, recommendations_text):
    print("\n" + "=" * 100)
    print("📇 RECOMMENDED ARTICLES".center(100))
    print("=" * 100 + "\n")

    # Parse JSON recommendations - clean tags and extract JSON
    recommendations_list = []
    try:

        # Clean up <think> tags
        cleaned_text = re.sub(
            r"<think>.*?</think>", "", recommendations_text, flags=re.DOTALL
        )
        # Remove markdown code blocks ( ... ``` or ``` ... ```)
        cleaned_text = re.sub(r"```(?:json)?", "", cleaned_text)
        cleaned_text = cleaned_text.strip()

        parsed = json.loads(cleaned_text)

        # Extract recommendations from list format
        for item in parsed:
            article_number = item.get("article_number")
            title = item.get("title", "")
            rec_text = item.get("recommendation", "")

            if article_number and rec_text:
                recommendations_list.append(
                    {
                        "article_number": article_number,
                        "title": title,
                        "recommendation": rec_text,
                    }
                )
    except json.JSONDecodeError as e:
        print(f"⚠️  Could not parse recommendations as JSON: {e}")
        return

    for i, article in enumerate(articles, 1):
        source = article.get("_source", article)

        # Card border
        print("┌" + "─" * 98 + "┐")

        # Find recommendation and title for this article number
        recommendation = None
        title = None
        for rec in recommendations_list:
            if rec.get("article_number") == i:
                recommendation = rec.get("recommendation")
                title = rec.get("title")
                break

        # Print title
        title_lines = textwrap.wrap(f"📌 {title}", width=94)
        for line in title_lines:
            print(f"│  {line}".ljust(99) + "│")

        # Card border
        print("├" + "─" * 98 + "┤")

        # Print recommendation
        if recommendation:
            recommendation_lines = textwrap.wrap(recommendation, width=94)
            for line in recommendation_lines:
                print(f"│  {line}".ljust(99) + "│")

        # Card bottom
        print("└" + "─" * 98 + "┘")</code></pre><p>Let’s test this by asking a question about the security blog posts:</p><pre><code>search_query = "Security and vulnerabilities"

context, articles, recommendations = recommend_articles(search_query)

print("\nElasticsearch context:\n", context)

# Display visual cards
display_recommendation_cards(articles, recommendations)</code></pre><p>Here we can see the cards in the console generated by the workflow:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/730d35212594bb3db30447c3ea7e2a92857287b7-1999x1515.png" style="width: 100%;" /><p>You can see the full results, including all hits and the LLM response, in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/results.md">this file</a>.</p><p>We’re asking for articles related to: “Security and vulnerabilities.” This question is used as the search query against the documents stored in Elasticsearch. The retrieved results are then passed to the model, which generates recommendations based on their content. As we can see, the model did a great job generating engaging short text that can motivate the reader to click on it.</p><h2>Conclusion</h2><p>This example shows how Elasticsearch and Hugging Face can be combined to create a fast and efficient centralized system for AI applications. This approach reduces manual effort and provides flexibility, thanks to Hugging Face’s extensive model catalog. Using SmolLM3-3B, in particular, shows how compact, multilingual models can still deliver meaningful reasoning and content generation when paired with semantic search. Together, these tools offer a scalable and effective foundation for building intelligent content analysis and multilingual applications.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fast vs. accurate: Measuring the recall of quantized vector search]]></title>
            <description><![CDATA[Explaining how to measure recall for vector search in Elasticsearch with minimal setup.]]></description>
            <link>https://www.elastic.co/search-labs/blog/recall-vector-search-quantization</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/recall-vector-search-quantization</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeff Vestal]]></dc:creator>
            <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/09f03b9239d66c36763cdab3fafcdac207ff6d83-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Everyone wants vector search to be instant. But high-dimensional vectors are heavy. A single 1,024-dimension float-32 vector takes up significant memory, and comparing it against millions of others is computationally expensive.</p><p>To solve this, search engines like Elasticsearch use two main optimization strategies:</p><ol><li><strong>Approximate search (hierarchical navigable small world [HNSW]):</strong> Instead of scanning every document, we build a navigation graph to jump quickly to the likely neighborhood of the answer.</li><li><strong>Quantization:</strong> We compress the vectors (for example, from 32-bit floats to 8-bit integers or even 1-bit binary values) to reduce memory usage and speed up calculations.</li></ol><p>But optimization often comes with a tax: <strong>accuracy</strong>.</p><p>The fear is valid: &quot;If I compress my data and take shortcuts during the search, will I miss the best results?&quot; &quot;Does this optimization degrade the relevance of my search engine?&quot;</p><p>To prove that Elastic’s quantization doesn’t degrade results, we built a repeatable test harness using the <a href="https://huggingface.co/datasets/fancyzhx/dbpedia_14"><strong>DBPedia-14</strong> dataset</a> to calculate exactly how much accuracy (specifically, <strong>recall)</strong> we trade for speed when using default optimizations in Elasticsearch.</p><p>tldr: It’s likely much less than you think. Check out the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/fast_vs_accurate_measuring_the_recall_of_quantized_vector_search/vector_recall_notebook.ipynb">notebook here</a>, and try it yourself</p><h2><strong>The definitions (for the non-experts)</strong></h2><p>Before we look at the code, let’s level-set on some terms.</p><ul><li><strong>Relevance versus recall:</strong> <strong>Relevance</strong> is subjective (did I find good stuff?). <strong>Recall</strong> is mathematical. If there are 10 documents in the database that are the <em>perfect</em> mathematical matches for your query, and the search engine finds nine of them, your recall is 90% (or 0.9).</li><li><strong>Exact search (flat):</strong> Sometimes called the &quot;brute force&quot; method. The search engine scans every single document in an index and calculates the distance.<ul><li><em>Pros:</em> 100% perfect recall.</li><li><em>Cons:</em> Computationally expensive and slow at scale.</li></ul></li><li><strong>Approximate search (HNSW):</strong> The &quot;shortcut&quot; method. The search engine builds an <a href="https://www.elastic.co/search-labs/blog/hnsw-graph">HNSW</a> graph. It traverses the graph to find the nearest neighbors.<ul><li><em>Pros:</em> Extremely fast and scalable.</li><li><em>Cons:</em> You might miss a neighbor if the graph traversal stops too early.</li></ul></li></ul><h2><strong>The experiment: Exact versus approximate</strong></h2><p>To test recall, we used the <strong>DBPedia-14</strong> dataset, a large dataset of titles and abstracts across 14 ontology classes, commonly used for training and evaluating text categorization models. Specifically, we’ll focus on the &quot;Film&quot; category. We wanted to compare the optimized production settings against a mathematically perfect ground truth.</p><p>For this experiment, we are using the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">jina-embeddings-v5-text-small</a> model, a state-of-the-art multilingual model that leads industry benchmarks for text representation. We chose this model because it defines the current standard for high-performance embeddings. By combining Jina v5’s elite accuracy with Elasticsearch’s native quantization, we can demonstrate a search architecture that is both computationally efficient and uncompromising on retrieval quality.</p><p>We set up an index with dual mapping. We ingested the same text into two different fields simultaneously:</p><ol><li><strong><code>content.raw</code> </strong>with type: <code>flat</code>. This forces Elasticsearch to perform a brute-force scan of the full Float32 vectors. This returns exact match results and will be used for our baseline.</li><li><strong><code>content</code> </strong>with type <code>semantic_text</code>. With defaults using HNSW + Better Binary Quantization (BBQ). This is the standard, optimized production setting for approximate match.</li></ol><h3><strong>The Recall@10 test</strong></h3><p>For our metric, we used Recall@10.</p><p>We picked 50 random movies and ran the same query against both fields.</p><ul><li>If the <strong>exact (flat)</strong> search says the top 10 neighbors are IDs [1, 2, 3... 10].</li><li>And the <strong>approximate (HNSW)</strong> search returns IDs [1, 2, 3... 9, 99].</li><li>We found nine out of the top 10 correctly. The score is <strong>0.9</strong>.</li></ul><p>Here’s the mapping we used:</p><pre><code># The "Control Group": Forces exact brute-force scan
"raw": {
    "type": "semantic_text",
    "inference_id": ".jina-embeddings-v5-text-small",
    "index_options": {
        "dense_vector": {
            "type": "flat"
        }
    }
}</code></pre><p><strong>The results: The &quot;flat line&quot; of success</strong></p><p>We ran a scale test, reloading the full dataset and testing against index sizes of 1,000 to 40,000 documents.</p><p>Here’s what happened to the recall score:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Documents</td><td style="border: 1px solid #ccc; padding: 4px;">Recall@10 score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1,000</td><td style="border: 1px solid #ccc; padding: 4px;">1.000 (100%)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5,000</td><td style="border: 1px solid #ccc; padding: 4px;">0.998 (100%)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">10,000</td><td style="border: 1px solid #ccc; padding: 4px;">0.992 (99.4%)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">20,000</td><td style="border: 1px solid #ccc; padding: 4px;">0.999 (99.0%)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">40,000</td><td style="border: 1px solid #ccc; padding: 4px;">0.992 (98.8%)</td>
                    </tr>
              </tbody>
            </table>
          <p>The results were incredibly stable. Even as we scaled up, the approximate search matched the brute-force exact search <strong>&gt;99% of the time</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a4bfacb1d0cce6fdf6df0e1a9d4fc5d4007a66da-1999x1209.png" style="width: 100%;" /><h2><strong>Why did it work so well?</strong></h2><p>You might expect that compressing vectors to binary values would hurt accuracy more than this. The reason it doesn&#x27;t lies in how Elasticsearch handles the retrieval.</p><p>Most embedding models today output Float32 vectors, which are large. To make search efficient, Elasticsearch uses quantization for high-dimensional vectors. Specifically, since 9.2, it uses <a href="https://www.elastic.co/search-labs/blog/elasticsearch-9-1-bbq-acorn-vector-search">BBQ</a> by default.</p><p>BBQ uses a <strong>rescoring</strong> mechanism:</p><ol><li><strong>Traversal:</strong> The search engine uses the compressed (quantized) vectors to traverse the HNSW graph quickly. Because the vectors are small, it can efficiently over-sample, gathering a larger list of candidates (for example, the top 100 roughly similar docs) without a performance penalty.</li><li><strong>Rescore:</strong> Once it has those candidates, it retrieves the full-precision values for just those few documents to calculate the final, precise ranking.</li></ol><p>This gives you the best of both worlds, the speed of quantization for the heavy lifting, and the precision of floats for the final sort.</p><h2><strong>Can we do better?</strong></h2><p>It’s worth noting that the results we’re seeing here are using default settings and a random sampling of data. Think of this as a high-performance starting point. While Jina v5 is a beast, these recall scores aren&#x27;t a &quot;one size fits all&quot; guarantee for every dataset. Every data collection has its own quirks, and while you can definitely tune things further to squeeze out even more performance, you should always benchmark against your own specific data to see where your ceiling is.</p><h2><strong>Conclusion</strong></h2><p>This is a very small-scale test. But the point of the exercise is not to measure the embedding model or BBQ specifically, it’s to demonstrate how you can easily measure the recall of your dataset with minimal setup.</p><p>If you want to run this test on your own data, you can check out the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/fast_vs_accurate_measuring_the_recall_of_quantized_vector_search/vector_recall_notebook.ipynb">notebook here</a> and try it yourself.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Testing Elasticsearch. It just got simpler.]]></title>
            <description><![CDATA[Explaining how Elasticsearch integration tests have become simpler thanks to improvements in Elasticsearch 9.x, the modern Java client, and Testcontainers 2.x.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-integration-tests</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-integration-tests</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Piotr Przybyl]]></dc:creator>
            <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3ae41b1f2876d2ad11c8e2b79bbf79955d6902aa-1440x840.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>When I first wrote about <a href="https://www.elastic.co/search-labs/blog/series/integration-tests-using-elasticsearch">testing Elasticsearch</a> with Testcontainers for Java, the focus was very pragmatic: if you care about correctness, you should test against a real node; if you care about confidence, your integration tests should resemble production as closely as possible; and if you care about maintainability, your setup shouldn’t turn into a maze of mocks and assumptions.</p><p>That philosophy hasn’t changed.</p><p>What has changed, however, is how little effort it now takes to achieve that goal. With Elasticsearch 9.x, the modern Java client, and Testcontainers 2.x, the experience of writing integration tests feels noticeably smoother, as if a layer of incidental complexity has quietly been removed.</p><p>The example accompanying this article is intentionally modest and can be found <a href="https://github.com/pioorg/elasticsearch9-testcontainers2/blob/main/src/test/java/testing_elasticsearch/ES9TC2DemoTest.java">here</a>.</p><p>It doesn’t attempt to demonstrate sophisticated indexing strategies or elaborate data pipelines; instead, it concentrates on the essentials, because the essentials are precisely where the improvements are most visible.</p><h2>When the tooling stops getting in the way</h2><p>Anyone who has maintained a test suite for a few years will recognize the pattern: You introduce a new library, a transitive dependency pulls something unexpected, and before long, you’re negotiating between versions of testing engines rather than writing tests.</p><p>With Testcontainers 2.x, that negotiation largely disappears. The dependency structure is clearer, the modules are more explicit, and the accidental coupling to older testing frameworks no longer sneaks in behind your back. In practical terms, adding Elasticsearch support to your tests is now as straightforward as declaring:</p><pre><code><dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-elasticsearch</artifactId>
  <version>2.0.3</version>
  <scope>test</scope>
</dependency></code></pre><p>And, if you’re using JUnit Jupiter integration:</p><pre><code><dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-junit-jupiter</artifactId>
  <version>2.0.3</version>
  <scope>test</scope>
</dependency></code></pre><p>There are no exclusions to sprinkle in, no legacy engines to silence, and no uneasy feeling that something hidden might surface during the next upgrade. The configuration becomes almost unremarkable, which, in the context of build tooling, is a compliment.</p><h2>A real Elasticsearch node, with security intact</h2><p>In the demo test, we use the official Elasticsearch 9.3.1 Docker image:</p><pre><code>var container =
    new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:9.3.1");

container.start();</code></pre><p>At first glance, this may look similar to older examples, yet the subtle difference lies in what we no longer need to do. <strong>We don’t disable security.</strong> <strong>We don’t bypass SSL.</strong> We don’t simplify the environment just to make the test convenient.</p><p>Instead, once the container is started, we construct a client that uses the REST API and authenticates properly:</p><pre><code>try (var client = ElasticsearchClient.of(c -> c
     .host("https://" + container.getHttpHostAddress())
     .usernameAndPassword("elastic", ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD)
     .sslContext(container.createSslContextFromCa())
)) {</code></pre><p>What deserves special mention here is how neat the client construction itself has become. In earlier iterations, creating an Elasticsearch client often meant juggling multiple intermediate objects, configuring transport layers explicitly, wrapping low-level clients, and dedicating some amount of code to what was essentially plumbing. Now, the signal-to-noise ratio is refreshingly high. The builder encapsulates the necessary details, the container provides what the client needs, and the resulting configuration fits comfortably within a few readable lines.</p><p>Just as importantly, the <code>ElasticsearchClient</code> is <code>AutoCloseable</code>, which means it integrates naturally with try-with-resources, ensuring proper cleanup without additional ceremony. The lifecycle is explicit, concise, and self-contained, which is exactly what you want in integration tests that should focus on behavior rather than infrastructure management.</p><p>The container exposes everything required to build a legitimate, secure connection, and the client integrates with it naturally, which means the test environment mirrors production in all the aspects that matter, without imposing additional mental overhead from the developer.</p><p>This alignment between realism and simplicity is, perhaps, one of the most meaningful improvements.</p><h2>Typed APIs change the character of tests</h2><p>The evolution of the Elasticsearch Java client has also reshaped how integration tests read and feel. Where older approaches often involved parsing JSON responses or navigating loosely typed structures, the modern client offers a builder-based, strongly typed API that guides you through valid request shapes at compile time.</p><p>In the demo, we perform a simple cluster health check:</p><pre><code>var health = client.cluster().health();

Assertions.assertEquals("docker-cluster", health.clusterName());
Assertions.assertEquals(HealthStatus.Green, health.status());</code></pre><p>What’s striking here is not the complexity of the operation, but the absence of friction. There’s no manual extraction from maps, no assertions built on untyped string values, and no detour into low-level response handling. The test code looks indistinguishable from application code, which subtly reinforces the idea that integration tests aren’t a special category of code with different rules, but simply another consumer of the same APIs.</p><p>When the boundary between production code and test code becomes thinner, confidence increases almost by default.</p><h2>Reading the test as a story</h2><p>If you take a look at the full test case:</p><pre><code>@Test
void newClientTest() throws IOException {
    try (var container =
             new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:9.3.1")) {
        
        container.start();
        
        try (
            var client = ElasticsearchClient.of(c ->
                c.host("https://" + container.getHttpHostAddress())
                    .usernameAndPassword("elastic", ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD)
                    .sslContext(container.createSslContextFromCa()))) {

            HealthResponse health = client.cluster().health();

            Assertions.assertEquals("docker-cluster", health.clusterName());
            Assertions.assertEquals(HealthStatus.Green, health.status());
        }
    }
}</code></pre><p>you’ll notice that it reads less like a configuration script and more like a short narrative:</p><ul><li>We define the container.</li><li>We start the container.</li><li>We build a client.</li><li>We call a real API.</li><li>We assert the outcome.</li></ul><p>The supporting infrastructure fades into the background, leaving the intent of the test clearly visible. That clarity isn’t accidental; it’s the cumulative effect of incremental improvements across Testcontainers and the Elasticsearch client.</p><h2>The advanced patterns still apply</h2><p>None of the more advanced techniques discussed in earlier articles, <a href="https://www.elastic.co/search-labs/blog/elasticsearch-integration-tests-faster">Faster integration tests with real Elasticsearch</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-improve-performance-integration-tests">Advanced integration tests with real Elasticsearch</a>, have become obsolete. Reusing containers to speed up large test suites, customizing cluster settings, preloading indices, or testing role-based access scenarios remain entirely valid and, in many cases, essential.</p><p>What has improved is the baseline experience. The simplest possible integration test, the one that merely needs a real node and a real client, no longer requires defensive configuration or dependency gymnastics. It’s concise, expressive, and production-like by default.</p><h2>Progress without drama</h2><p>There was no dramatic rewrite of the ecosystem, no disruptive migration guide that forced a rethinking of everything. Instead, there has been a steady refinement of APIs and dependencies, each release smoothing a rough edge here and removing a surprise there.</p><p>The result isn’t flashy, yet it’s tangible. Writing integration tests against Elasticsearch now feels less like assembling a test harness and more like exercising a real system in miniature.</p><p>Sometimes progress announces itself loudly. Sometimes it arrives quietly, in the form of code that simply reads better and requires less explanation. In this case, it’s the latter, and for those of us who care about clean, reliable integration tests, that’s more than enough.</p><p>And what if we could do something similar with Kibana? Sounds appealing? Stay tuned!</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI agent memory: Creating smart agents with Elasticsearch managed memory]]></title>
            <description><![CDATA[ Learn how to create smarter and more efficient AI agents by managing memory using Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/ai-agent-memory-management-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-agent-memory-management-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Gustavo Llermaly, Jeffrey Rengifo]]></dc:creator>
            <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e0172bf1bf25f10001de046531d7dfae816c1f80-1200x676.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we’ll learn about using memory techniques to make agents smarter using Elasticsearch as the database for memories and knowledge.</p><h2>Understanding memory in large language models (LLMs)</h2><p>Here&#x27;s something that trips people up: The conversations with LLMs are completely <a href="https://www.geeksforgeeks.org/computer-networks/difference-between-stateless-and-stateful-protocol/">stateless</a>. Every time you send a message, you need to include the entire chat history to &quot;remind&quot; the model what happened before. The ability to keep track of what was asked and answered within a single conversation session is what we call <strong><em>short-term memory</em></strong>.</p><p>But here&#x27;s where it gets interesting: Nothing stops us from manipulating this chat history beyond simple storage. For example, when we want to persist memories like user preferences across different conversations, we inject those into fresh conversations when needed and call it <strong><em>long-term memory</em></strong>.</p><h2>Why mess with chat history?</h2><p>There are three compelling reasons to go beyond simply appending each new message and response to a growing list that gets sent to the LLM with every request:</p><ul><li><strong>Inject useful context:</strong> Add information about previous interactions, like user preferences, without cluttering the current conversation.</li><li><strong>Summarize and remove data:</strong> Clean up information the model has already used to avoid confusion (<em>context poisoning</em>) and keep the model focused.</li><li><strong>Save tokens:</strong> Remove unnecessary data to prevent filling the context window, enabling longer, more meaningful conversations.</li></ul><p>This opens up some sci-fi possibilities. Imagine an agent that selectively remembers things based on its environment or who it&#x27;s talking to, like the TV show <strong><em>Severance</em></strong>, where the main character, Mark, has a chip implanted in his brain that creates two separate identities with distinct memories depending on whether he’s in the office (&quot;innie&quot;) or outside of it (&quot;outie&quot;), switching based on location.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b3c08fe6139cae5571288d713e890b7c8ca3f428-1000x1500.png" style="width: 100%;" /><h2>Memory types and selective retrieval in agents: Creating smart agents with Elasticsearch managed memory</h2><p>Not all memories serve the same purpose, and treating them as interchangeable chat history limits how far agents can scale. Modern agent architectures, including frameworks like <a href="https://arxiv.org/abs/2309.02427">Cognitive Architectures for Language Agents (CoALA)</a>, distinguish between <strong>procedural</strong>, <strong>episodic</strong>, and <strong>semantic</strong> memory. Rather than treating all context as a single growing buffer, these architectures recognize that each memory type requires distinct storage, retrieval, and consolidation strategies.</p><h3>Procedural memory: How the agent operates</h3><p><em>Procedural memory</em> defines how an agent behaves, not what it knows or remembers.</p><p>In practice, this includes:</p><ul><li>When to store a memory.</li><li>When to retrieve one.</li><li>How to summarize conversations.</li><li>How to use tools.</li></ul><p>In our system, procedural memory lives primarily in the application code and prompts and isn’t stored in Elasticsearch. Instead, Elasticsearch is used by procedural memory.</p><p><strong><em>Procedural memory determines how memory is used, not what’s stored.</em></strong></p><h3>Episodic memory: What happened</h3><p><em>Episodic memory</em> captures specific experiences tied to an entity and a context.</p><p>Examples:</p><ul><li>“Peter’s birthday is tomorrow and he wants steak.”</li><li>“Janice has a report due at 9 am.”</li></ul><p>This is the most dynamic and personal form of memory and the one most prone to context pollution if handled incorrectly.</p><p>In our architecture:</p><ul><li>Episodic memories are stored as documents in Elasticsearch.</li><li>Each memory includes metadata (user, role, timestamp, innie or outie).</li><li>Retrieval is selective, based on who’s asking and in what context.</li></ul><p>This is where the innie/outie model applies as an example of episodic memory isolation.</p><h3>Semantic memory: Ground truth</h3><p><em>Semantic memory</em> represents<strong> abstracted, generalized knowledge about the world</strong>, independent of any single interaction or personal context. Unlike episodic memory, which is tied to who said what and when, semantic memory captures what is true in general.</p><p>In our analogy, the knowledge about <strong>Lumon</strong>, which is the company where Mark works in the show <em>Severance</em>, is world truth shared between innies and outies.</p><p>Things like company handbooks and rules are part of the knowledge being used as semantic memory.</p><p>While episodic memory retrieval prioritizes precision and strong contextual filters (such as identity, role, and time), semantic memory favors high-recall, concept-level retrieval. It’s designed to surface generally true information that can ground reasoning, rather than personal experiences tied to a specific situation.</p><p>Let’s move to architecture and see how these ideas translate into a memory system for our agent.</p><h2>Prerequisites</h2><ul><li>Elasticsearch Elastic Cloud Hosted (ECH) or self-hosted 9.1+ instance.</li><li>Python 3.x.</li><li><a href="https://platform.openai.com/docs/api-reference/authentication">OpenAI API Key</a>.</li></ul><p>The full Python notebook for this application can be found <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/smarter-agents-with-memory/notebook.ipynb">here</a>.</p><h2>Why Elasticsearch?</h2><p>Elasticsearch is an ideal solution for storing both knowledge and memory because it&#x27;s a native vector database ready to scale. It gives us everything we need to manage selective memory:</p><ul><li><strong>Vector database</strong> with <a href="https://www.elastic.co/docs/solutions/search/hybrid-semantic-text">hybrid search</a> to find memories by context, not only by keywords.</li><li><strong>Multiple data types</strong>, including text, numbers, dates, and geolocation.</li><li><strong>Metadata filters</strong> for complex queries across different fields.</li><li><a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/controlling-access-at-document-field-level"><strong>Document level security</strong></a> to filter memories based on who&#x27;s asking.</li></ul><h3>Why selective memory improves latency and reasoning</h3><p>Selective memory is not only about correctness and isolation; it also has a direct impact on latency and model performance. By narrowing the search space using structured filters (such as memory type, user, or time) before running semantic retrieval, Elasticsearch reduces the number of vectors that need to be scored and the amount of context that must be injected into the LLM. This results in faster retrieval, smaller prompts, and more focused attention for the model, which in practice translates into lower latency, lower token usage, and more accurate responses.</p><p>Episodic memory is inherently temporal: Recent experiences are usually more relevant than older ones, and not all memories should be kept with the same level of detail forever. In human cognition, experiences are gradually forgotten, summarized, or consolidated into more abstract knowledge.</p><p>Memory compression is a whole different topic, but you can implement strategies to summarize and store old memories while retrieving the fresh ones entirely.</p><h2>The setup</h2><p>Following the <strong><em>Severance</em></strong> concept, we&#x27;re creating an agent named Mark with two distinct memory sets:</p><ul><li><strong>Innie memories</strong>: Work-related conversations with colleagues.</li><li><strong>Outie memories</strong>: Personal conversations with friends and family.</li></ul><p>When Mark talks to an innie, he shouldn&#x27;t remember conversations with outies, and vice versa.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e0172bf1bf25f10001de046531d7dfae816c1f80-1200x676.png" style="width: 100%;" /><h2>Building the memory system</h2><h3>Memory index structure</h3><p>First, we define our memory schema:</p><pre><code>mappings = {
    "properties": {
        "user_id": {"type": "keyword"},
        "memory_type": {"type": "keyword"},
        "created_at": {"type": "date"},
        "memory_text": {
            "type": "text",
            "fields": {
                "semantic": {
                    "type": "semantic_text"
                }
            }
        }
    }
}</code></pre><p><em>Note that we use <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/multi-fields">multi-field</a> for <code>memory_text </code>so we can do both full-text search, and <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">semantic search</a> using the <a href="https://www.elastic.co/search-labs/tutorials/search-tutorial/semantic-search/elser-model">Elastic Learned Sparse EncodeR (ELSER) model</a> (default) against the same field content.</em></p><p>This gives us semantic search capabilities while maintaining structured metadata for filtering.</p><h3>Setting up document level security</h3><p>This is the key piece that makes selective memory work. We create two separate roles: one for innies, one for outies, each with query-level filters built in. When a user with the innie role queries the memories index, Elasticsearch automatically applies a filter that only returns memories where <code>memory_type</code> equals &quot;innie&quot;.</p><p><em>You can find more illustrative examples about access control <a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/controlling-access-at-document-field-level#basic-examples">here</a> and about role management <a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/kibana-role-management">here</a>.</em></p><p>Here&#x27;s the innie role:</p><pre><code>innie_role_descriptor = {
    "indices": [
        {
            "names": ["memories"],
            "privileges": ["read", "write"],
            "query": {
                "bool": {
                    "filter": [
                        {"term": {"memory_type": "innie"}}
                    ]
                }
            }
        }
    ]
}</code></pre><p>We create a similar role for outies, just filtering by <code>&quot;memory_type&quot;: &quot;outie&quot;</code> instead.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9e675da210be223200e1d5519b5bc62b6f97b3b1-1999x1046.png" style="width: 100%;" /><p>Then we create users and assign them to these roles. For example:</p><ul><li><strong>Peter (outie):</strong> Can only access memories marked as <code>&quot;outie&quot;</code>.</li><li><strong>Janice (innie):</strong> Can only access memories marked as <code>&quot;innie&quot;</code>.</li></ul><p>When Mark (our agent) receives a query, he uses the credentials of whoever is asking. If Peter asks something, Mark uses Peter&#x27;s credentials, which means Elasticsearch automatically filters to only show outie memories. If Janice asks, only innie memories are visible.</p><p>The application code doesn&#x27;t need to filter the user management and is completely decoupled from the application logic. Elasticsearch handles all the security automatically.</p><h3>Creating the agent tools</h3><p>We define three key functions for our agent:</p><ul><li><strong><code>GetKnowledge</code>:</strong> Searches the knowledge base for relevant context (<a href="https://www.elastic.co/search-labs/blog/retrieval-augmented-generation-rag">traditional retrieval augmented generation [RAG]</a>).</li><li><strong><code>GetMemories</code>:</strong> Retrieves memories using <a href="https://www.elastic.co/what-is/hybrid-search">hybrid search</a> (semantic + keyword):</li></ul><pre><code>def get_memory(query: str):
    es_query = {
        "retriever": {
            "rrf": {
                "retrievers": [
                    {
                        "standard": {
                            "query": {
                                "semantic": {
                                    "field": "semantic_field",
                                    "query": query
                                }
                            }
                        }
                    },
                    {
                        "standard": {
                            "query": {
                                "multi_match": {
                                    "query": query,
                                    "fields": ["memory_text"]
                                }
                            }
                        }
                    }
                ],
                "rank_window_size": 50,
                "rank_constant": 20
            }
        }
    }
    
    response = user_es_client.search(index="memories", body=es_query)
    return response</code></pre><p><em>Notice that we don&#x27;t apply security filters in the query; Elasticsearch handles that automatically based on the user&#x27;s credentials.</em></p><ul><li><strong><code>SetMemory</code>:</strong> Stores new memories (implementation uses LLM to convert conversations into structured memory records).</li></ul><h3>How the agent uses these tools</h3><p>When a user asks Mark a question, the flow works like this:</p><p>1.<strong> User asks:</strong> &quot;What&#x27;s my favorite family destination?&quot;</p><p>2.<strong> LLM decides to use tools:</strong> OpenAI&#x27;s Response API with function calling lets the LLM decide it needs to call <code>GetMemories</code> with the query <code>&quot;favorite family destination&quot;</code>.</p><p>3.<strong> We execute the function:</strong> Our code calls <code>get_memory(&quot;favorite family destination&quot;)</code> using the user&#x27;s credentials (Peter&#x27;s in this case).</p><p>4. <strong>Elasticsearch filters automatically:</strong> Because we&#x27;re using Peter&#x27;s credentials, only outie memories are returned:<br/></p><pre><code>Memories
peter125: (User name is Peter Johnson. His favorite family destination is Disneyland.)</code></pre><p>5. <strong>We send results back to LLM:</strong> The memory gets added to the conversation context.</p><p>6.<strong> LLM generates an answer:</strong> &quot;Your favorite family destination is Disneyland.&quot;</p><p>Here&#x27;s the actual code that handles this loop:</p><pre><code># Initial call with tools available
response = client.responses.create(
    model="gpt-4.1-mini",
    input=messages,
    tools=tools,
    parallel_tool_calls=True
)

# Execute any tool calls the LLM requested
for tool_call in response.output:
    if tool_call.name == "GetMemories":
        result = get_memory(tool_call.arguments["query"])
        # Add result to messages
        
# Call LLM again with tool results to generate final answer
final_response = client.responses.create(
    model="gpt-4.1-mini",
    input=messages  # Now includes tool results
)</code></pre><p>The key insight: The application doesn&#x27;t decide which memories to retrieve or when. The LLM decides based on the user&#x27;s question, and Elasticsearch ensures that only the right memories are accessible.</p><h2>Testing selective memory</h2><p>Let&#x27;s see it in action:</p><p>Outie conversation (Peter):</p><pre><code>Peter: Hey Mark, my birthday is tomorrow! I'd like to have a steak for dinner.
Mark: That's great! (memory stored)</code></pre><p>Mark stores this as an outie memory associated with Peter. Here&#x27;s what that memory looks like in Elasticsearch:</p><pre><code>{
    "user_id": "peter125",
    "memory_type": "outie",
    "created_at": "2025-10-11T18:02:52.182780",
    "memory_text": "Peter's birthday is tomorrow. He wants steak for dinner."
}</code></pre><p>Innie conversation (Janice):</p><pre><code>Janice: Hey Mark, remember we have to finish the end of year report tomorrow at 9am.
Mark: Thanks for reminding me! (memory stored)</code></pre><p>This creates a separate innie memory:</p><pre><code>{
    "user_id": "janice456",
    "memory_type": "innie", 
    "created_at": "2025-10-11T19:15:33.445821",
    "memory_text": "End of year report deadline tomorrow at 9am with Janice."
}</code></pre><p>Imagine Peter also works at Lumon. A colleague stores a work-related memory about him:</p><pre><code>{
    "user_id": "innie-peter",
    "memory_type": "innie",
    "created_at": "2025-10-11T20:30:00.000000",
    "memory_text": "Peter needs to review the Q4 budget spreadsheet before Friday."
}</code></pre><p>This memory exists in Elasticsearch, but Peter&#x27;s current credentials only grant him the outie role. When he asks Mark about work tasks, this memory is invisible to him; Elasticsearch&#x27;s document level security ensures that it’s never returned.</p><p><em>Note: To allow interaction with these memories, you would need to create a separate user (or assign an additional role) with &quot;innie&quot; access for Peter. This is left as an exercise, but it demonstrates that the same person can have isolated memory contexts, and access is controlled entirely at the security layer.</em></p><h3>Memory isolation test</h3><p>Now Peter starts a new conversation:</p><pre><code>Peter: Hey Mark, do you remember what I want for my birthday?
Mark: Yes! You want steak.

Peter: When do you have to finish the end of year report?
Mark: What are you talking about?</code></pre><p>Perfect! Mark only accesses outie memories when talking to Peter. The agent&#x27;s &quot;brain&quot; is genuinely split, just like in the show.</p><h2>The full implementation</h2><p>The complete working implementation is available in this <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/smarter-agents-with-memory/notebook.ipynb">notebook</a>, where you can:</p><ul><li>Set up the Elasticsearch indices.</li><li>Create roles and users with document level security.</li><li>Build the agent with OpenAI&#x27;s Response API.</li><li>Test the selective memory system.</li></ul><h2>Conclusion</h2><p>Memory isn’t just a place to store past conversations. It’s part of the agent’s architecture. By going beyond raw chat history and separating procedural, episodic, and semantic memory, we can build agents that reason more clearly, scale better, and stay focused over long interactions.</p><p>Selective retrieval reduces context pollution, lowers latency, and improves the quality of the information sent to the LLM. Episodic memory can be filtered by user and time, semantic memory can be used to ground answers in shared knowledge, and procedural memory controls how and when all of this is used.</p><p>Elasticsearch provides the building blocks to implement this in practice through hybrid search, rich metadata, security, and temporal filtering. Just like in <em>Severance</em>, we can create agents with isolated experiences and shared world knowledge. The difference is that here the split is intentional and useful, not a mystery.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Gemini CLI extension for Elasticsearch with tools and skills]]></title>
            <description><![CDATA[Introducing Elastic’s extension for Google's Gemini CLI to search, retrieve, and analyze Elasticsearch data in developer and agentic workflows.
]]></description>
            <link>https://www.elastic.co/search-labs/blog/gemini-cli-extension-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/gemini-cli-extension-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Walter Rafelsberger, Enrico Zimuel]]></dc:creator>
            <pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6dfcec4a77b3dc83bf0d974417bf2e211abb1f4f-876x468.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We&#x27;re glad to announce the release of our Elastic extension for Google&#x27;s Gemini CLI, bringing the full power of <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a> and <a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a> directly into your AI development workflow. This extension also offers several recently developed agent skills for interacting with Elasticsearch.</p><p>The extension is available as an open source project <a href="https://github.com/elastic/gemini-cli-elasticsearch">here</a>.</p><h2>What is Gemini CLI, and how do you install it?</h2><p><a href="https://geminicli.com/">Gemini CLI</a> is an open source AI agent that brings Google’s Gemini models directly into the command line. It allows developers to interact with AI from the terminal to perform tasks such as generating code, editing files, running shell commands, and retrieving information from the web.</p><p>Unlike typical chat interfaces, Gemini CLI integrates with your local development environment, meaning that it can understand project context, modify files, run builds or tests, and automate workflows directly within the terminal. This makes it useful for developers, site reliability engineers (SREs), and engineers who want AI-assisted coding and automation without leaving their command-line workflow.</p><p>Gemini CLI can be installed using several package managers. The most common method is via npm:</p><pre><code>npm install -g @google/gemini-cli</code></pre><p>If you want to know alternative installation options, refer to the <a href="https://geminicli.com/docs/get-started/installation/">official installation page</a>.</p><p>After installation, start the CLI by running:</p><pre><code>gemini</code></pre><p>You see a screen, as shown in Figure 1:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6dfcec4a77b3dc83bf0d974417bf2e211abb1f4f-876x468.png" style="width: 100%;" /><h2>Configure Elasticsearch</h2><p>We need to have an Elasticsearch instance running. If you want to use the Model Context Protocol (MCP) server, you also need Kibana 9.3+ installed. To use the Elasticsearch Query Language (ES|QL) skill (<code>esql</code>) described below, Kibana is not required.</p><p>You can activate a free trial on <a href="https://www.elastic.co/cloud">Elastic Cloud</a> or install it locally using the <a href="https://github.com/elastic/start-local"><code>start-local</code></a> script:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>This will install Elasticsearch and Kibana on your computer and will generate an API key to be used for configuring Gemini CLI.</p><p>The API key will be shown as output of the previous command and stored in a <strong>.env</strong> file in the <strong><code>elastic-start-local</code></strong> folder.</p><p>If you’re using on-premises Elasticsearch (for example, using <code>start-local</code>), and you want to use Elastic Agent Builder with MCP, you also need to connect a large language model (LLM). You can read <a href="https://www.elastic.co/docs/explore-analyze/ai-features/llm-guides/llm-connectors">this documentation page</a> to understand the different options.</p><p>If you’re using Elastic Cloud (or serverless), you already have a prebuilt LLM connection in place.</p><h2>Install the Elasticsearch extension</h2><p>You can install the Elasticsearch extension for Gemini CLI with the following command:</p><pre><code>gemini extensions install https://github.com/elastic/gemini-cli-elasticsearch</code></pre><p>You can check that the extensions have been installed successfully by opening Gemini and executing the following command:</p><pre><code>/extensions list</code></pre><p>You should see the Elasticsearch extension available.</p><p>If you want to use the MCP integration, you need to have an Elasticsearch 9.3+ version installed. You need your MCP server URL from <a href="https://www.elastic.co/kibana">Kibana</a>:</p><ul><li>Get your MCP server URL from Agents &gt; View all tools &gt; Manage MCP &gt; Copy MCP Server URL.</li><li>The URL will look like this: https://your-kibana-instance/api/agent_builder/mcp</li></ul><p>You need the Elasticsearch endpoint URL. This is typically reported at the top of the Kibana Elasticsearch page. If you’re running Elasticsearch with <code>start-local</code>, you already have the endpoint in the <code>ES_LOCAL_URL</code> key in the <code>start-local</code> .env file.</p><p>You also need an API key. If you’re running Elasticsearch with <code>start-local</code>, you already have the <code>ES_LOCAL_API_KEY</code> in the <code>start-local</code> .env file. Otherwise, you can create an API key using the Kibana interface, as reported <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys">here</a>:</p><ul><li>In Kibana: Stack Management &gt; Security &gt; API Keys &gt; Create API key.</li><li>We suggest setting only the read privileges for the API key, enabling the <code>feature_agentBuilder.read</code> privilege as reported <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/permissions#grant-access-with-roles">here</a>.</li><li>Copy the encoded API key value.</li></ul><p>Set the required environment variables in your shell:</p><pre><code>export ELASTIC_URL="your-elasticsearch-url"
export ELASTIC_MCP_URL="your-elasticsearch-mcp-url"
export ELASTIC_API_KEY="your-encoded-api-key"</code></pre><h2>Install the example dataset</h2><p>You can install the <strong>eCommerce orders </strong>dataset available from Kibana. It includes a single index named <strong><code>kibana_sample_data_ecommerce</code></strong>, containing information for 4,675 orders from an ecommerce website. For each order, we have the following information:</p><ul><li>Customer information (name, ID, birth date, email, and more).</li><li>Order date.</li><li>Order ID.</li><li>Products (list of all the products with price, quantity, ID, category, discount, and other details).</li><li>SKU.</li><li>Total price (taxless, taxed).</li><li>Total quantity.</li><li>Geo information (city, country, continent, location, region).</li></ul><p>To install the sample data, open the <strong>Integrations</strong> page in Kibana (search for “Integration” in the top search bar) and install the <strong>Sample Data</strong>. For more details, refer to the documentation <a href="https://www.elastic.co/docs/explore-analyze/#gs-get-data-into-kibana">here</a>.</p><p>The goal of this article is to show how easy it is to configure Gemini CLI to connect to Elasticsearch and interact with the <strong><code>kibana_sample_data_ecommerce</code></strong> index.</p><h2>How to use the Elasticsearch MCP</h2><p>You can check the connection using the following command in Gemini:</p><pre><code>/mcp list</code></pre><p>You should see the <strong><code>elastic-agent-builder</code></strong> enabled, as shown in Figure 2:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1508423bc1d1bc3c04a1cb01e2d59495a3516ed1-1465x844.png" style="width: 100%;" /><p>Elasticsearch provides a default set of tools. See the description <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/builtin-tools-reference">here</a>.</p><p>Using these tools, you can interact with Elasticsearch, asking questions like:</p><ul><li><code>Give me the list of all the indexes available in Elasticsearch.</code></li><li><code>How many customers are based in the USA in the kibana_sample_data_ecommerce index of Elasticsearch?</code></li></ul><p>Depending on the question, Gemini will use one or more of the available tools to try to answer it.</p><h2>The /elastic commands</h2><p>In the Elasticsearch extension for Gemini CLI, we also added<strong> <code>/elastic</code></strong> commands.</p><p>If you execute the <strong><code>/help</code></strong> command, you see all the available <code>/elastic</code> options (Figure 3):</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5b2a0727ce7a04354878dd048253d3f4d062324b-1983x230.png" style="width: 100%;" /><p>These commands can be useful if you want to directly execute a specific tool of the <code>elastic-agent-builder</code> MCP server. For instance, using the following command, you can get the mapping of the <code>kibana_sample_data_ecommerce</code>:</p><pre><code>/elastic:get-mapping kibana_sample_data_ecommerce</code></pre><p>These commands are essentially shortcuts for executing specific tools, rather than relying on the Gemini model to determine which tool should be invoked.</p><h2>How to use the Elasticsearch skills</h2><p>This extension also comes with an <a href="https://github.com/elastic/gemini-cli-elasticsearch/tree/main/skills/esql">agent skill for ES|QL</a>, the <a href="https://www.elastic.co/docs/explore-analyze/discover/try-esql">Elasticsearch Query Language</a> available in Elasticsearch. <a href="https://agentskills.io/home">Agent Skills</a> is an open format that gives AI coding agents, like Gemini CLI, custom instructions for specific tasks. They use a concept called <em>progressive disclosure</em>, meaning that only a brief description of the skill is added to the initial system prompt. When you ask the agent to perform a task, like querying Elasticsearch, it matches the request to the relevant skill and dynamically loads the detailed instructions. This is an efficient way to manage token budgets while providing the AI with exactly the context it needs.</p><p>The <strong><code>esql</code> skill</strong> is designed to let Gemini CLI write and execute ES|QL queries directly against your cluster. ES|QL is a powerful piped query language that makes data exploration, log analysis, and aggregations highly intuitive. With this skill enabled, you don&#x27;t need to look up ES|QL syntax; you can simply ask the Gemini CLI natural language questions about your data, and the agent will handle the rest.</p><p>The executions are performed using simple <a href="https://curl.se/">curl</a> commands run in a terminal. This is possible because Elasticsearch provides a rich set of REST APIs that can be easily used to integrate the system into any architecture.</p><p><strong>What the <code>esql</code> skill offers:</strong></p><ul><li><strong>Index and schema discovery:</strong> The agent can use the skill&#x27;s built-in tools to list available indices and fetch field mappings. For instance, before writing a query for the eCommerce dataset, the agent can run a schema check on <strong><code>kibana_sample_data_ecommerce</code></strong> to understand the available fields, like <strong><code>taxful_total_price</code></strong> or <strong><code>category</code></strong>.</li><li><strong>Seamless natural language translation:</strong> The skill gives the agent more than just a plain reference manual; it provides a specific guide for interpreting user intent. When you type natural language requests, like &quot;Show average response time grouped by service,&quot; the agent uses the skill&#x27;s bundled pattern matching to instantly translate your words into the correct ES|QL aggregations, filters, and commands.</li><li><strong>Self-correct:</strong> If a query fails (for example, due to a type mismatch or syntax error), the skill returns the generated query alongside the exact Elasticsearch error message, allowing the agent to instantly fix the query and try again without you having to intervene.</li></ul><p>Since the <code>esql</code> skill is also available as a tool in the <code>elastic-agent-builder</code> MCP server, we need to disable this server momentarily. You can use the following command to disable it:</p><pre><code>/mcp disable elastic-agent-builder</code></pre><p>Then you can simply type a prompt like this into your Gemini CLI:</p><pre><code>Find the top 5 product categories by total sales revenue in the kibana_sample_data_ecommerce index</code></pre><p>The agent will:</p><ul><li>Recognize the need for the <code>esql</code> skill.</li><li>Check the schema of <strong><code>kibana_sample_data_ecommerce</code></strong>.</li><li>Construct an ES|QL query, like: <code>FROM kibana_sample_data_ecommerce | STATS total_revenue = SUM(taxful_total_price) BY category.keyword | SORT total_revenue DESC | LIMIT 5</code>.</li><li>Execute the query against the Elasticsearch API.</li><li>Present the final answer to you right in the terminal.</li></ul><p>Here, we reported an example of response from Gemini to the previous prompt:</p><pre><code>───────────────────────────────────────────────────────────
> Find the top 5 product categories by total sales revenue in the kibana_sample_data_ecommerce index   ───────────────────────────────────────────────────────────
✓  Activate Skill "esql": Interact with Elasticsearch using ES|QL and curl. Use when querying, managing indices,  …
Skill esql activated. Resources loaded from /home/user/.gemini/extensions/elasticsearch/skills/esql: 
Showing up to 200 items (files + folders).                                                                     /home/user/.gemini/extensions/elasticsearch/skills/esql/                                                            ├───SKILL.md 
└───references/
├───esql-reference.md 
├───generation-tips.md 
└───query-patterns.md

✦ I will start by exploring the mapping of the kibana_sample_data_ecommerce index to identify the fields for product category and sales revenue. Then, I will construct and execute an ES|QL query to determine the top 5 product categories by total sales revenue.

✓  Shell curl -s "${ELASTIC_URL%/}/kibana_sample_data_ecommerce/_mapping" -H "Authorization: ApiKey $(printenv ELAS…                                                                                                                       {  
   "kibana_sample_data_ecommerce": { 
     "mappings": { 
       "properties": {
         "category": { 
           "type": "text", 
           "fields": {
             "keyword": {
               "type": "keyword"
             }
           }
         }, 
         "currency": {
           "type": "keyword"
         }, 

✦ Okay, I have the mapping. The category is products.category.keyword and sales revenue is taxful_total_price. I'll now execute the ES|QL query to get the top 5 categories by revenue.

✓  Shell curl -s -X POST "${ELASTIC_URL%/}/_query" \  -H "Authorization: ApiKey $(printenv ELASTIC_API_KEY)" \  -H …
{ 
   "took": 62,
   "is_partial": false, 
   "completion_time_in_millis": 1772805644164,
   "documents_found": 4675,
   "values_loaded": 12084, 
   "start_time_in_millis": 1772805644102, 
   "expiration_time_in_millis": 1773237644087,  
   "columns": [ 
     { 
       "name": "total_revenue",    
       "type": "double"  
     }, 
     {   
       "name": "products.category.keyword",

✦ The query for top 5 product categories by revenue was successful. I'll format the returned JSON values into a   user-friendly list.

 ✓  Shell 
                             
 Top 5 product categories by total sales revenue: 
 1. Men's Clothing: 149393.91
 2. Women's Clothing: 135099.91
 3. Women's Shoes: 105479.17
 4. Men's Shoes: 91797.92
 5. Women's Accessories: 60830.31 

✦ I've found the answer; now I'll format it for clarity.

 ✓  Shell 

 Here are the top 5 product categories by total sales revenue:                                                        
                                                                      
 | Rank | Category              | Total Revenue | 
 |------|-----------------------|---------------| 
 | 1    | Men's Clothing        | 149,393.91    |
 | 2    | Women's Clothing      | 135,099.91    |
 | 3    | Women's Shoes         | 105,479.17    |
 | 4    | Men's Shoes           | 91,797.92     | 
 | 5    | Women's Accessories   | 60,830.31     | 

✦ I've formatted and presented the answer. I'm finished.
 ───────────────────────────────────────────────────────────</code></pre><p>It’s interesting to note how the Gemini model generates the final response by showing all the steps it follows. Here, you can clearly see the influence of the skill in the model’s reasoning process. The first time the model recognizes that it needs to use a skill or execute a shell command, it requests permission using the human-in-the-loop approach.</p><p>By handling the heavy lifting of schema discovery, query generation, and execution, the <code>esql</code> skill lets you focus entirely on the answers rather than the mechanics of getting them. You’ll get the data you need, properly formatted and right in your terminal, all without ever writing a single line of syntax or context-switching to a different application.</p><h2>Conclusion</h2><p>In this article, we presented the Elasticsearch extension for Gemini CLI that we recently released. This extension gives you the ability to interact with your Elasticsearch instance using Gemini and the Elasticsearch MCP server provided by Elastic Agent Builder, available starting from version 9.3.0, as well as the <code>/elastic</code> command.</p><p>Moreover, the extension also includes an <code>esql</code> skill that converts a user’s request from natural language into an ES|QL query. This skill can be particularly useful when the MCP server cannot be used, since the underlying communication is driven by simple curl commands executed in a terminal. Elasticsearch offers a rich set of REST APIs that can be easily integrated into any project. This is especially useful when developing agentic AI applications.</p><p>For more information about our Gemini CLI extension, visit the project repository <a href="https://github.com/elastic/gemini-cli-elasticsearch">here</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Agent Skills for Elastic: Turn your AI agent into an Elastic expert]]></title>
            <description><![CDATA[Give your AI coding agent the knowledge to query, visualize, secure, and automate with Elastic Agent Skills.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-skills-elastic</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-skills-elastic</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Graham Hudgins, Matt Ryan]]></dc:creator>
            <pubDate>Mon, 16 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/09e64953819083168a9ecef0888c7f8bde1a43bd-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Every developer, site reliability engineer (SRE), or analyst who has tried to use an AI coding agent with a specialized platform has hit the same wall. You ask the agent to write a query, configure an alert, or investigate something, and it gets close but not right. Elastic has an advantage here: over a decade of documentation, blog posts, and community answers means AI agents already know Elastic better than most data platforms. But that depth comes with noise. Deprecated APIs sit alongside current ones. Outdated patterns rank as highly as best practices. The agent confidently reproduces an approach that worked three versions ago, because in its training data, it did. The result is a correction tax: users manually feed documentation into context, fix hallucinated syntax, and work around the agent instead of with it. Worse, advanced capabilities go unused entirely, not because users don&#x27;t need them, but because the agent doesn&#x27;t know they exist.</p><p>That&#x27;s why we’re open-sourcing <a href="https://github.com/elastic/agent-skills">Elastic Agent Skills</a>: native platform expertise for Elasticsearch, Kibana, Elastic Observability, and Elastic Security. Drop them into the agent runtime you already use, and improve your agent from being a ‘generalist’ that guesses at a lot of syntax to giving it expertise, such as being able to use many of the architectural standards as Elastic’s own engineering teams. This initial technical preview release focuses on skills with maximum compatibility for <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a>, but will evolve quickly to include improved support for older stack releases.</p><p>In addition, Elastic is solving this problem from both sides. For agents on the Elastic platform, <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Elastic Agent Builder</a> (now generally available) lets you create and chat with AI agents that inherit your data&#x27;s access controls, use built-in search and analysis tools, and work in context alongside your dashboards, alerts, and investigations. We&#x27;re working hard to ensure amazing Agentic experiences in the Elastic platform. But not every agent lives inside Elastic. Your team already uses Cursor, Claude Code, or other runtimes, and those agents need to get Elastic right, too. That&#x27;s where Agent Skills come in.</p><h2>Why agents struggle with specialized platforms</h2><p>Large language models (LLMs) are remarkably capable generalists. They can write Python, explain Kubernetes manifests, and refactor React components because their training data is rich with examples. But when it comes to platform-specific work, the kind that involves proprietary query languages, deep API surfaces, and domain-specific best practices, they fall short in predictable ways.</p><p>For Elasticsearch, the gap shows up concretely:</p><ul><li><strong>Elasticsearch Query Language (ES|QL) is new territory.</strong> LLMs are trained heavily on SQL, but ES|QL is a piped query language with different syntax, different functions, and different semantics. Agents frequently write queries that look plausible but don&#x27;t parse. They confuse <code>WHERE</code> with <code>| WHERE</code>, invent functions that don&#x27;t exist, and miss the pipe-based composition model entirely.</li><li><strong>API surfaces are wide and deep.</strong> Elasticsearch, Kibana, and Elastic Security expose hundreds of APIs across search, ingestion, alerting, detection rules, case management, dashboards, and more. An agent armed with nothing but general training data has to guess which endpoint to call, what the request body looks like, and how to handle the response. It guesses wrong often enough to erode trust.</li><li><strong>Best practices aren&#x27;t in the training data.</strong> When should you use <code>semantic_text</code> versus a custom embedding pipeline? How should you structure an ingest pipeline for a 10GB CSV? What&#x27;s the right detection rule syntax for a <a href="https://www.elastic.co/docs/solutions/security/detect-and-alert/mitre-attandckr-coverage">MITRE ATT&amp;CK</a> technique? General-purpose agents don&#x27;t have curated, reliably structured Elastic-specific knowledge loaded by default. They&#x27;d have to go find it, and even if they did, raw docs don&#x27;t always encode the judgment calls and best practices that skilled practitioners carry.</li></ul><p>The result: Developers spend more time fixing agent output than they would have spent writing the code themselves. That&#x27;s not the experience anyone signed up for.</p><h2>Agent Skills: Platform knowledge, packaged for agents</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8d16ec00d16e70a916c5eef0aaa23fcc735b7186-1067x1280.png" style="width: 100%;" /><p>Agent Skills are self-contained directories of instructions, scripts, and reference material that agent runtimes can load dynamically. When a skill is active, the agent has access to the right context at the right time: query syntax, API patterns, validation logic, worked examples, so it can complete tasks correctly on the first try.</p><p>Each skill follows the open <a href="https://agentskills.io">agentskills.io</a> specification: a folder with a <code>SKILL.md</code> file containing metadata and structured instructions. No proprietary format, no lock-in. Skills work across agent runtimes, including Cursor, Claude Code, GitHub Copilot, Windsurf, Gemini CLI, Cline, Codex, and <a href="https://agentskills.io">many more</a>.</p><h3>What&#x27;s in the initial v0.1.0 release</h3><p>The first set of skills spans five areas of the Elastic Stack:</p><ul><li>Interacting with Elasticsearch APIs (search, indexing, cluster management)</li><li>Building and managing Kibana content such as dashboards, alerts, connectors, and more</li><li>Domain expertise for Elastic Observability</li><li>Domain expertise for Elastic Security</li><li>Making effective agents in Agent Builder</li></ul><h3>Skills are composable</h3><p>Skills aren&#x27;t monolithic. They&#x27;re modular by design. Your agent loads only the skills relevant to the task at hand. Working on an ES|QL query? The ES|QL skill activates. Need to build a dashboard from those results? The dashboards skill picks up. Assessing the health of your application? The service health skill comes into play. Investigating a security alert? The triage skill chains into case management and response skills as the investigation progresses.</p><p>This composability means that you don&#x27;t need a single, massive prompt that tries to cover everything. Each skill carries exactly the context its domain requires, nothing more, nothing less.</p><h2>For developers building search and AI applications</h2><p>If you&#x27;re loading data into Elasticsearch, writing queries, or migrating indices, skills reduce the cycle of generating code, hitting errors, and searching docs for what went wrong.</p><p>Ask your agent to load a CSV file, and it uses a streaming ingestion tool that handles backpressure and infers mappings from the data. It’s not a hand-rolled _bulk loop that runs out of memory on the first large file. Ask it to query with ES|QL, and it discovers your actual index names and field schemas, then writes valid piped queries with correct syntax, appropriate aggregations, and version-aware feature selection, not a SQL-flavored guess that requires three rounds of debugging. Ask it to reindex across clusters, and it follows the full operational workflow: creates the destination with explicit mappings, tunes settings for throughput, runs the job async, and restores production settings when it finishes, not a bare _reindex call that skips half the steps an experienced operator would follow.</p><p>Instead of an agent that gives you a plausible starting point you have to fix, you get one that encodes the operational discipline that makes the output actually work.</p><p><strong>Example impacts of using Elastic Agent Skills</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Eval</td><td style="border: 1px solid #ccc; padding: 4px;">What the skill changed</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">es-audit-query-failed-logins</td><td style="border: 1px solid #ccc; padding: 4px;">Used the audit log query patterns from the skill instead of generic search</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">es-authz-role-mapping-ldap</td><td style="border: 1px solid #ccc; padding: 4px;">Emitted the correct role mapping API call structure</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">esql-basic-query</td><td style="border: 1px solid #ccc; padding: 4px;">Wrote ES|QL pipe syntax over Query DSL</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">esql-error-handling</td><td style="border: 1px solid #ccc; padding: 4px;">Schema first instead of guessing field names</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">esql-schema-discovery</td><td style="border: 1px solid #ccc; padding: 4px;">Never guessed an index name</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">es-ingest-csv-with-infer</td><td style="border: 1px solid #ccc; padding: 4px;">Used --infer-mappings alone, avoided combining with --source-format csv which causes an empty index</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">es-ingest-json-file</td><td style="border: 1px solid #ccc; padding: 4px;">Used robust ingestion approach capable of handling large files</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">es-reindex-local-async</td><td style="border: 1px solid #ccc; padding: 4px;">Created destination index first with replicas: 0 and refresh_interval: "-1", then async reindex. Baseline skipped any prep</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">es-security-403-privileges</td><td style="border: 1px solid #ccc; padding: 4px;">Followed the skill's diagnostic workflow for privilege errors instead of generic advice</td>
                    </tr>
              </tbody>
            </table>
          <h2>For security teams</h2><p>Security teams repeat the same operational workflows daily: triaging alerts, tuning detection rules, managing cases. Agent Skills encode that procedural knowledge so your AI agent can execute these workflows correctly, calling the right APIs in the right order with the right field names. For a hands-on walkthrough that takes you from zero to a fully populated Elastic Security environment without leaving your IDE, see <a href="https://www.elastic.co/security-labs/agent-skills-elastic-security">Get started with Elastic Security from your AI agent</a>.</p><h2>For observability and operations teams</h2><p>The new Agent Skills for Elastic Observability reduce the operational toil of instrumenting complex systems, managing SLOs, sifting through complex data, and assessing service health. Embedding native Elastic expertise directly into AI agents allows teams to execute complex observability workflows using simple natural language. This empowers SREs and Ops teams to resolve incidents faster and maintain reliable systems more easily. Learn more in <a href="https://www.elastic.co/observability-labs/blog/elastic-agent-skills-observability-workflows">this blog</a>.</p><h2>Open source, open spec, community-driven</h2><p>We&#x27;re releasing Agent Skills under the Apache 2.0 license because we believe that agent knowledge should be open. The <a href="https://agentskills.io">agentskills.io</a> specification that skills follow is an open standard, not an Elastic proprietary format. We want skills to be a community effort, not a walled garden.</p><h2>Part of a bigger picture</h2><p>Agent Skills is one piece of a broader initiative to make Elasticsearch the most agent-friendly data platform available. For agents that live on the Elasticsearch platform, <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Agent Builder</a> goes further by inheriting your data&#x27;s access controls and permissions, providing built-in and custom tools for search and analysis, and letting users interact with agents in context alongside their dashboards, alerts, and investigations. Finally, support for skills is coming soon to Agent Builder, allowing developer flexibility to leverage Elastic Agent Skills as well as skills from any other source to enable secure, context enhanced chat and automation on the Elasticsearch platform.</p><p>For agents that live everywhere else, we&#x27;re investing in the open ecosystem:</p><ul><li><strong>Model Context Protocol (MCP) server expansion:</strong> Extending the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">MCP endpoint</a> in Agent Builder with more tools beyond the current search, ES|QL, and index operations.</li><li><strong>Authentication improvements:</strong> Making it easier for agents to connect securely, with the goal of eliminating manual API key copy-paste.</li><li><strong>LLM-readable documentation:</strong> Publishing <code>llms.txt</code> and <code>AGENTS.md</code> files so agents can discover and understand Elastic APIs on their own.</li><li><strong>A command line interface (CLI) for agent workflows:</strong> Command-line tooling that makes connection management and common operations agent-friendly.</li></ul><p>Skills are the layer you can use today. The rest is coming.</p><h2>Get started</h2><p><strong>Before you get started: </strong>AI coding agents operate with real credentials, real shell access, and often the full permissions of the user running them. When those agents are pointed at security workflows, the stakes are higher: you&#x27;re handing an automated system access to detection logic, response actions, and sensitive telemetry. Every organization&#x27;s risk profile is different. Before enabling AI-driven security workflows, <strong>evaluate what data the agent can access, what actions it can take, and what happens if it behaves unexpectedly</strong>.</p><p>Install Elastic Agent Skills into your agent runtime:</p><p><code>npx skills add elastic/agent-skills</code></p><p>This auto-detects your installed agent runtimes and places skills in the correct configuration directory. From there, your agent picks them up automatically.</p><p>You can also browse the <a href="https://github.com/elastic/agent-skills">skills catalog</a> directly and install individual skills manually by copying the skill folder into your agent&#x27;s configuration directory.</p><p>Don&#x27;t have an Elasticsearch cluster yet? Start an <a href="https://cloud.elastic.co/registration">Elastic Cloud free trial</a>. It takes about a minute to get a fully configured environment.</p><p><strong>Explore the project:</strong></p><ul><li><a href="https://github.com/elastic/agent-skills">Agent Skills repository</a></li><li><a href="https://agentskills.io">agentskills.io specification</a></li><li><a href="https://www.elastic.co/docs">Elasticsearch documentation</a></li><li><a href="https://cloud.elastic.co/registration">Elastic Cloud free trial</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Entity resolution with Elasticsearch, part 4: The ultimate challenge]]></title>
            <description><![CDATA[Solving and evaluating entity resolution challenges in a highly diverse “ultimate challenge” dataset designed to prevent shortcuts.]]></description>
            <link>https://www.elastic.co/search-labs/blog/entity-resolution-elasticsearch-llm-challenges</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/entity-resolution-elasticsearch-llm-challenges</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
            <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/70fb0ff949f6db9ac9b8a28ecb4329ab915ebf46-720x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We’ve now seen intelligent entity resolution implemented in two ways. Both approaches begin the same way: entity preparation and extraction, followed by candidate retrieval with Elasticsearch. From there, we evaluate those candidates using a large language model (LLM), either through prompt-based JSON generation or through function calling, and require the model to provide a transparent explanation for its judgment.</p><p>As we saw in the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-function-calling">previous post</a>, the consistency provided by function calling is not just a nice optimization; it’s essential. Once we removed structural errors from the evaluation loop, results on standard scenarios (such as those in the tier 4 dataset) improved dramatically.</p><p>Yet there’s an obvious question left to answer:</p><p><em>Does this approach still work when things get genuinely messy?</em></p><p>Real-world entity resolution rarely fails because of simple cases. It fails when names cross languages, cultures, writing systems, time periods, and organizational boundaries. It fails when people are referenced by titles instead of names, when companies change names, when transliterations aren’t consistent, and when context (not spelling) is the only thing tying a mention to a real-world entity.</p><p>So, for the final post in this series, we put the system through what we called <strong>the ultimate challenge</strong>.</p><h2>What makes this the ultimate challenge?</h2><p>In earlier evaluations, we tested the system using increasingly complex datasets. By the time we reached tier 4, discussed in the previous post, we were already dealing with a mix of nicknames, titles, multilingual names, and semantic references. Those tests showed that the architecture itself was sound, but that reliability issues, especially malformed JSON, were suppressing recall.</p><p>With function calling in place, we finally had a stable foundation. That gave us the opportunity to ask a more interesting question:</p><p><em>Can one unified pipeline handle <strong>many different kinds</strong> of entity resolution problems at once?</em></p><p>The ultimate challenge dataset was designed to push precisely on that dimension.</p><p>Instead of focusing on a single difficulty (like nicknames or transliteration), this dataset combines <strong>50+ distinct challenge types</strong>, including:</p><ul><li>Cultural naming conventions.</li><li>Title-based references.</li><li>Business relationships and historical name changes.</li><li>Multilingual and cross-script mentions.</li><li>Compound challenges that mix several of the above.</li></ul><p>Crucially, this isn’t about optimizing for any one narrow use case. It’s about testing whether the <em>design pattern</em> holds up when the rules change from entity to entity.</p><h2>The dataset at a glance</h2><p>The ultimate challenge dataset consists of:</p><ul><li><strong>50 entities</strong>, spanning people, organizations, and institutions.</li><li><strong>~60 articles</strong>, with varying structure and linguistic complexity.</li><li><strong>51 distinct challenge categories</strong>, grouped broadly into:<ul><li>Cultural naming conventions.</li><li>Titles and professional context.</li><li>Business and organizational relationships.</li><li>Multilingual and transliteration challenges.</li><li>Combined and edge‑case scenarios.</li></ul></li></ul><p>Earlier in the series, we saw that using generative AI (GenAI) to create datasets can be a mixed blessing. Without it, assembling sufficiently large and diverse test data would be extremely difficult. But left unchecked, the model has a tendency to make things too easy.</p><p>On an early generation pass, for example, we discovered that the model had included phrases like “the Russian president” as explicit aliases for Vladimir Putin. That might seem reasonable today, but it defeats the purpose of testing contextual resolution. What happens if the article is discussing Russia in the 1990s? The system should infer the correct entity from context, not rely on a hard-coded alias.</p><p>For that reason, this dataset was deliberately designed so that <strong>shortcuts don’t work</strong>. Aliases are not explicitly listed when the system is expected to infer meaning. Descriptive phrases are not prelinked to entities. Correct matches often depend on article-level context, not just local text.</p><p><strong>Important note:</strong> Although we demonstrate the system’s capabilities across diverse scenarios, this is still an educational prototype. Production systems handling real-world sanctioned-entity monitoring would require additional validation, compliance checks, audit trails, and specialized handling for sensitive use cases.</p><h2>Why these scenarios are hard</h2><p>Back in the first post in this series, we introduced a simple but ambiguous example: “The new Swift update is here!” The challenge is that “Swift” can resolve to multiple real-world entities, depending on context. That example captures a broader truth: Natural language is inherently ambiguous.</p><p>Entity resolution, therefore, is not just a string-matching problem. Humans routinely rely on shared knowledge, cultural norms, and situational context to resolve references, and we rarely even notice we’re doing it.</p><p>Consider a few common cases:</p><ul><li>A title like “the president” is meaningless without geopolitical and temporal context.</li><li>A company name may refer to a parent, a subsidiary, or a former brand depending on when the article was written.</li><li>A person’s name may appear in different orders, scripts, or transliterations, depending on language and culture.</li><li>The same phrase can legitimately refer to different entities in different contexts, and the system must be able to <em>reject</em> matches just as confidently as it accepts them.</li></ul><p>There is no single rule set that handles all of this cleanly. That’s why this prototype separates concerns so aggressively:</p><ul><li>Elasticsearch narrows the candidate space efficiently and transparently.</li><li>The LLM is used only where judgment is required and is forced to explain itself.</li><li>Retrieval and reasoning remain distinct steps.</li></ul><p>This separation becomes even more important as the diversity of challenge types increases.</p><h2>How the system handles diversity without special cases</h2><p>One of the most interesting outcomes of this evaluation is what <em>didn’t</em> change:</p><ul><li>We did <strong>not</strong> add special logic for Japanese names.</li><li>We did <strong>not</strong> add custom rules for Arabic patronymics.</li><li>We did <strong>not</strong> add hard-coded mappings for historical company names.</li></ul><p>Instead, the system relied on the same core ingredients introduced earlier in the series:</p><ul><li>Context-enriched entities indexed for semantic search.</li><li>Hybrid retrieval (exact, alias, and semantic) in Elasticsearch.</li><li>A small, well-defined set of candidate matches.</li><li>LLM judgment constrained by function calling and minimal schemas.</li></ul><p>This suggests that the system’s flexibility comes from <strong>representation and architecture</strong>, not from an ever-growing collection of rules.</p><p>When the system succeeds, it’s because the right candidates are retrieved and the LLM has enough context to explain why a reference does (or does not) map to a specific entity.</p><h2>Results: How did it perform?</h2><p>On the ultimate challenge dataset, the system produced the following overall results:</p><ul><li><strong>Precision:</strong> ~91%</li><li><strong>Recall:</strong> ~86%</li><li><strong>F1 Score:</strong> ~89%</li><li><strong>LLM acceptance rate:</strong> ~72%</li></ul><h3>Performance across challenge types</h3><p>Breaking down results by challenge type reveals strengths and limitations:</p><p><strong>Strongest performance (100% F1 score)</strong> was observed in areas such as:</p><ul><li>Cross-script matching (Cyrillic, Korean, Chinese business entities).</li><li>Hebrew scenarios (patronymics, professional titles, religious titles, transliteration).</li><li>Business hierarchies (aerospace, diversified manufacturing, multidivision corporations).</li><li>Professional titles (academic, military, political, religious).</li><li>Combined Japanese scenarios involving multiple writing systems.</li></ul><p><strong>Strong performance (80–99% F1 score)</strong> included:</p><ul><li>International political figures (98%).</li><li>Historical name changes (90%).</li><li>Complex business hierarchies (89%).</li><li>Japanese company names (93%).</li><li>Cross-script transliteration (86%).</li><li>Arabic patronymics (86%).</li></ul><p><strong>More challenging areas</strong> included:</p><ul><li>Advanced transliteration (Chinese, Korean): 0% F1.</li><li>Certain Japanese scenarios (honorifics, name order, writing system variation): ~67% F1.</li><li>Some Arabic scenarios (company names, institutional references): ~40% F1.</li></ul><p>What’s important here is <em>why</em> the system struggled in these cases. The failures were not due to the overall approach breaking down, but to limitations in specific components, most notably the dense vector model used for semantic search in certain multilingual scenarios.</p><p>Because retrieval and judgment are cleanly separated, improving performance does not require rewriting the system. Swapping in a more capable multilingual embedding model, enriching entity context, or refining retrieval strategies would improve results across these categories without changing the core architecture.</p><p>From an architectural standpoint, that’s the real success metric.</p><h2>What this tells us about the design</h2><p>Looking back across the series, a few patterns stand out:</p><ul><li><strong>Preparation matters more than clever matching. </strong>Enriching entities with context up front dramatically reduces ambiguity later.</li><li><strong>LLMs are most valuable as judges, not retrievers. </strong>Asking them to explain <em>why</em> a match makes sense is far more powerful than asking them to search.</li><li><strong>Reliability enables accuracy. </strong>Function calling didn’t just clean up JSON; it unlocked recall that was already latent in the retrieval step.</li><li><strong>Generalization beats specialization. </strong>A small number of well-chosen abstractions handled dozens of challenge types without custom logic.</li></ul><p>This is why the prototype is intentionally Elasticsearch-native and intentionally conservative in how it uses LLMs. The goal isn’t to replace search; it’s to make search explainable in situations where meaning matters.</p><h2>Final thoughts</h2><p>The ultimate challenge wasn’t about chasing perfect metrics; it was about answering a more fundamental question:</p><p><em>Can a transparent, search-first, LLM-assisted architecture handle real-world entity ambiguity without collapsing into rules or black boxes?</em></p><p>For this educational prototype, the answer is yes, with clear caveats around production hardening, compliance, monitoring, and data quality. If you’re building systems that need to justify <em>why</em> an entity match was made, this pattern is worth serious consideration. I hope this series has shown that entity resolution doesn’t have to be mysterious. With the right separation of concerns, it becomes something you can reason about, measure, and improve.</p><p>This work also suggests a broader architectural pattern. What emerges is a slight but important evolution of classic retrieval augmented generation (RAG). Instead of allowing retrieval to feed generation directly, we introduce an explicit evaluation step. The LLM is first used to judge and sanity-check retrieved candidates, and only those approved results are allowed to augment generation. You can think of this as Generation-Augmented Retrieval-Augmented Generation with Evaluation, or GARAGE, because who doesn’t love a good acronym.</p><p>What other use cases could benefit from this pattern? Systems that require trust, transparency, and defensible reasoning are natural candidates. Future work in this area should prove as compelling as the results we’ve seen here, and I’m excited to see where the community takes it next.</p><h2>Next steps: Try it yourself</h2><p>Want to see the ultimate challenge in action? Check out the <a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=5%20minutes%20ago-,05_ultimate_challenge_v3.ipynb,-Initial%20public%20lab"><strong>Ultimate Challenge notebook</strong></a> for a complete walkthrough, with real implementations, detailed explanations, and hands-on examples.</p><p>The complete entity resolution pipeline demonstrates the core concepts and architecture needed for production use. You can use it as a foundation to build systems that monitor news articles, track entity mentions, and answer questions about which entities appear in which articles, all while retaining transparency and explainability.<br/></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The stateless architecture of Elasticsearch Serverless]]></title>
            <description><![CDATA[Exploring the stateless architecture of Elasticsearch Serverless. Learn how the stateful architecture was transformed into stateless for Serverless.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-serverless-stateless-architecture</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-serverless-stateless-architecture</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Iraklis Psaroudakis]]></dc:creator>
            <pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5ce3d804dd4652ce2114ce6a20f53cb41e5826c5-1396x812.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><strong>We&#x27;re thrilled to announce</strong> the publication of our new peer-reviewed paper, <strong><em><a href="https://doi.org/10.1145/3772052.3772245">Serverless Elasticsearch: the Architecture Transformation from Stateful to Stateless</a>,</em></strong> which was accepted and presented at the industrial track of the 2025 Association for Computing Machinery (ACM) Symposium on Cloud Computing (SoCC). The paper gives a concise view of our recent innovations in Elasticsearch. At Elastic, we’re relentlessly focused on the future of search. From optimizing performance to simplifying operations, our teams are always exploring what&#x27;s next. </p><p>This paper isn&#x27;t just an academic exercise. It&#x27;s a foundational exploration of how the core of a search engine could be reimagined for a purely serverless world. We <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">decouple</a> storage from compute: Data lives in a cloud blob store with virtually infinite storage and scalability. That vision is the main driver behind our <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> offering: seamless search over massive datasets, with the economics and operational simplicity of serverless.</p><h2>The challenge: Rethinking stateful search for the cloud</h2><p>For decades, search engines have been powerful, stateful systems. Deploying a production-grade cluster like Elasticsearch has meant:</p><ul><li>Provisioning servers and managing storage.</li><li>Carefully tuning configurations for cost, performance, and reliability.</li><li>Paying for idle capacity when workloads are spiky or unpredictable.</li><li>Significant operational effort to scale up and down.</li></ul><p>Modern cloud platforms have made some of this easier, but the fundamental tension remains:</p><p><em>Can we build a search engine that delivers the power and rich query capabilities of Elasticsearch with the economics and operational simplicity of a serverless architecture?</em></p><p>That question drove our research.</p><h2>Our key contributions</h2><p>The paper presents concrete innovations that make Elasticsearch Serverless possible:</p><ul><li><strong>Object store as single source of truth: </strong>We offload index data, the transaction log (translog), and cluster state to a cloud object store. That eliminates replica shards for durability and makes the object store the sync point between indexing and search.</li><li><strong>&quot;Thin&quot; (stateless) shards: </strong>Shards recover and relocate quickly across nodes without copying large amounts of data. Disks are used only for caching, not for persistent storage.</li><li><strong>Batched compound commits (BCC): </strong>We wrap index commits in a custom format, cutting upload costs, while keeping the same read-after-write semantics as Elasticsearch.</li><li><strong>Batched translog uploads: </strong>Translog uploads are batched at the node level, cutting upload costs.</li><li><strong>Smart garbage collection:</strong> We track the usage of BCCs and translogs we’ve uploaded, and we delete them once they’re unused, to reduce storage footprint and retention costs.</li><li><strong>Autoscaling: </strong>We scale automatically with ingestion and search load so clients can call APIs without managing cluster size.</li></ul><p><strong>The bottom line:</strong> In our experiments, Elasticsearch Serverless achieves up to <strong>twice</strong> the indexing throughput of stateful Elasticsearch on comparable hardware and scales linearly with autoscaling to match ingestion load.</p><h2>Visualizing the architecture</h2><p>Figure 1 in the paper gives a clear side-by-side view: stateful Elasticsearch versus the new stateless architecture Elasticsearch Serverless.</p><p><strong>Stateful Elasticsearch (top):</strong> Familiar data tiers: hot, warm, cold, frozen. Data lives on local disks; primaries and replicas are spread across nodes; colder tiers may use searchable snapshots on an object store.</p><p><strong>Elasticsearch Serverless</strong> <strong>(bottom):</strong> Just two tiers: <strong>indexing</strong> and <strong>search</strong>. All durable data (Lucene commits, translogs, cluster state) lives in the object store. Indexing nodes write and upload; search nodes read from the object store and a shared cache, with no local persistence of index data.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c4ca8c725cc60c06625cee3b5d1045c42367c166-1080x1116.png" style="width: 100%;" /><p>The takeaway: <em>a complete separation between the resources used for indexing and those used for querying.</em></p><h2>A tale of two data flow paths</h2><p>Figures 2 and 3 in the paper contrast how data flows in stateful versus Elasticsearch Serverless.</p><p><strong>Stateful Elasticsearch </strong>(figure 2):</p><ul><li>Documents go to the primary shard&#x27;s Lucene buffers and translog and then to replica shards.</li><li>After refresh, the documents go to new searchable segments.</li><li>After flush, they’re committed to disk.</li><li>Thus, durability is given by the disk and the replicas.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/eed45de365b8475b8f258342d344ad71491d39e4-1088x1022.png" style="width: 100%;" /><p><strong>Elasticsearch Serverless </strong>(figure 3):</p><ul><li>Documents go to Lucene and the translog on an indexing node.</li><li><em>Before</em> acknowledging the client, the translog is uploaded to the object store.</li><li>After refresh, the documents go to new searchable segments and are committed to disk in the indexing nodes.</li><li>After flush, they go into BCCs and are uploaded to the blob object store.</li><li>Search nodes serve queries from the object store (and, for recent data not yet uploaded, directly from the indexing node).</li><li>Thus, durability comes from the object store, not from disk or replicas.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5374213dbac9c84e0d672045f663582b25f57e7a-1090x986.png" style="width: 100%;" /><p>Result: <em>The indexing and search paths are fully decoupled</em>.</p><h2>Autoscaling</h2><p>Section 7 of the paper describes the autoscaler. Because data lives in the object store, relocating shards doesn&#x27;t mean copying full segment data; only metadata and, when needed, cache warming. So the cluster can scale up and down <em>much</em> faster than in stateful Elasticsearch.</p><p>How it works:</p><ul><li>The autoscaler is an external component that monitors metrics from Elasticsearch Serverless.</li><li><strong>Indexing tier:</strong> Scale-up is driven by memory usage and ingestion load (including queued work).</li><li><strong>Search tier:</strong> Scale-up is driven by memory, search load, and the user-configurable &quot;search power&quot; (how much of the dataset is cached locally).</li><li>It polls every few seconds and adjusts each tier independently.</li></ul><p>Outcome: <em>automatic, workload-driven scaling so clients can focus on their applications instead of on capacity planning</em>.</p><h2>The experimental results</h2><p>Section 8 of the paper presents our experimental evaluation.</p><p><strong>Microbenchmarks</strong> show the impact of batching: fewer object store operations for both commits and translogs, with some trade-offs.</p><p><strong>Autoscaling experiments:</strong> As we increase the number of indexing clients, throughput scales linearly while P50 and P99 latency stay stable. A real-world example shows bulk response times improving and stabilizing as the indexing tier scales up with demand.</p><p><strong>Head-to-head comparison of stateful Elasticsearch versus Elasticsearch </strong>Serverless<strong>:</strong></p><ul><li>Elasticsearch Serverless achieves roughly <strong>twice</strong> the indexing throughput of stateful Elasticsearch at the 50th percentile.</li><li>The gain comes largely from using the object store for durability instead of replicating every operation to replica shards.</li><li>Latency stays competitive.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/353ca1e865535e7211d22547a0d9eaa60e47b4aa-1084x578.png" style="width: 100%;" /><p>The takeaway: <em>The stateless design delivers both better peak performance and more efficient, automatic scaling.</em></p><h2>Why this matters for the future of Elastic</h2><p>The stateless architecture isn&#x27;t just a technical achievement; it&#x27;s the foundation for how we want search to work in the cloud.</p><ul><li><strong>Pay-as-you-go:</strong> Customers can index and search over practically limitless data without provisioning clusters, tuning tiers, or managing replicas and snapshots.</li><li><strong>Automatic scaling: </strong>Each tier scales on its own automatically; no capacity planning required.</li><li><strong>Frequent, automated upgrades:</strong> Better security and time-to-value, without the operational cost of rolling upgrades over stateful data.</li></ul><p>This work is a step toward making powerful search more accessible, cost-effective, and scalable for everyone.</p><h2>Read the full paper, and join the conversation</h2><p>We believe in the power of open research and collaboration to move technology forward. We encourage you to dive into the details. We provide a <a href="https://cdn.sanity.io/files/me0ej585/search-labs-import-testing/af199341cc57431238d62c7d4dbc31791bfa806e.pdf">preprint of this paper</a> for your information, which details in depth the architecture transformation.</p><h2>Dive deeper: Explore related blog posts</h2><p>While our paper offers a concise overview of the Elasticsearch Serverless architecture, the details and underlying innovations are explored more fully in a collection of in-depth blog posts written by our engineering team. These articles provide the background, nuance, and specific technical deep dives that make the stateless transformation possible.</p><p>We encourage you to delve into the following resources to gain a richer understanding of the components and concepts presented in the paper:</p><ul><li><a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">Stateless — your new state of find with Elasticsearch (2022)</a> and <a href="https://www.elastic.co/blog/elastic-serverless-architecture">Serve more with Serverless (2023)</a>. Read the foundational posts introducing the concept of decoupling storage and compute.</li><li><a href="https://www.elastic.co/search-labs/blog/data-safety-stateless-elasticsearch">Stateless: Data safety in a stateless world (2024).</a> Learn how data durability is achieved in the absence of local replicas.</li><li><a href="https://www.elastic.co/search-labs/blog/datastream-autosharding-serverless">Autosharding of data streams in Elasticsearch Serverless (2024).</a> Discover the logic behind automatic and dynamic data stream sharding.</li><li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-refresh-costs-serverless">How we optimized refresh costs in Elasticsearch Serverless (2024).</a> Understand the specific optimizations applied to reduce the cost of making data searchable.</li><li><a href="https://www.elastic.co/search-labs/blog/thin-indexing-shards-elasticsearch-serverless">Introducing Serverless Thin Indexing Shards (2024).</a> Explore the innovation of &quot;thin&quot; shards that enable rapid relocation and recovery.</li><li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-serverless-tier-autoscaling">Search tier autoscaling in Elasticsearch Serverless (2024).</a> Gain insight into the mechanisms driving the automatic scaling of search resources.</li><li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-ingest-autoscaling">Ingest autoscaling in Elasticsearch (2024).</a> Learn how the ingestion tier scales automatically to meet fluctuating indexing load.</li><li><a href="https://www.elastic.co/blog/elastic-cloud-serverless-pricing-packaging">Elastic Cloud Serverless pricing and packaging (2025)</a>. Learn how the pricing and packaging was initially structured for Elastic Cloud&#x27;s Serverless offering.</li><li><a href="https://www.elastic.co/blog/elasticsearch-opensearch-performance-gap">Elasticsearch vs. OpenSearch: Unraveling the performance gap (2023)</a>. Learn about the performance differences and key optimizations that distinguish Elasticsearch from OpenSearch, as observed in 2023.</li></ul><h2>Acknowledgments</h2><p>We would like to thank all the co-authors of the paper: Iraklis Psaroudakis, Pooya Salehi, Jason Bryan, Francisco Fernández Castaño, Brendan Cully, Ankita Kumar, Henning Andersen, and Thomas Repantis. We would also like to thank the Elasticsearch Distributed Systems team for their contributions, and also the entire Elasticsearch engineering team.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hybrid search with Java: LangChain4j Elasticsearch integration]]></title>
            <description><![CDATA[Learn how to use hybrid search in LangChain4j via its Elasticsearch integrations, with a complete Java example.]]></description>
            <link>https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Laura Trotta]]></dc:creator>
            <pubDate>Wed, 11 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/93d8e0d84fb4cfbf5e51df85df7ec2e600d9dcc7-1088x607.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In our <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a> on hybrid search with Elasticsearch in LangChain, we explained why hybrid search can help retrieve better results than simple vector search, along with how it works. We recommend reading that article first.</p><p>In addition to Python and JavaScript, the LangChain ecosystem also has a community-driven Java project called <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>, which will be the focus of this article, showing how powerful hybrid search can be by writing a complete application using LangChain4j, Elasticsearch, and Ollama.</p><h2>Setting up the environment</h2><h3>Running a local Elasticsearch instance</h3><p>Before running the examples, you&#x27;ll need Elasticsearch running locally. The easiest way is using the <a href="https://github.com/elastic/start-local?tab=readme-ov-file"><code>start-local</code></a> script:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>After starting, you&#x27;ll have:</p><ul><li>Elasticsearch at http://localhost:9200.</li><li>Kibana at http://localhost:5601.</li></ul><p>Your API key is stored in the .env file (under the elastic-start-local folder) as <code>ES_LOCAL_API_KEY</code>.</p><p>&gt; <strong>Note: This script is for local testing only. Do not use it in production. For production installations, refer to the <a href="https://www.elastic.co/downloads/elasticsearch">official documentation</a> for Elasticsearch.</strong></p><h3>Running a local Ollama instance</h3><p>You’ll also need to connect your application to an embedding model. Although you can choose between any provider supported by LangChain4j (check the <a href="https://docs.langchain4j.dev/integrations/language-models/">complete list</a>), for this example we’ll be using Ollama, which can be easily set up locally following the <a href="https://docs.ollama.com/quickstart">quickstart</a>.</p><h2>Let’s start coding</h2><p>The idea for the application is simple: Given a dataset of movies (taken from an IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>), we want to be able to find movies whose descriptions are relevant to our queries. This demo uses a subset of the data, which has been cleaned. You can download the dataset used for this article from our <a href="https://github.com/elastic/hybrid-search-elastic-langchain4j">GitHub repo</a>, along with the full code for this demo.</p><h2>Step 1: Dependencies and environment</h2><p>Open your favorite integrated development environment (IDE), create a new blank project, preferably with a modern Java version (we’re using Java24) and a gradle/maven version to match (in our case, Gradle 9.0).</p><p>We only need three dependencies:</p><pre><code>dependencies {
    implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.17.0")
    implementation("dev.langchain4j:langchain4j-elasticsearch:1.11.0-beta19")
    implementation("dev.langchain4j:langchain4j-ollama:1.11.0")
}</code></pre><p>The first one is needed to ingest the data that we’ll embed and query; the other two are the necessary LangChain4j dependencies to connect and manage our Elasticsearch vector store and Ollama embedding model.</p><p>The best way to connect to the external services is to set up environment variables and set them at the start of our main function:</p><pre><code>String elasticsearchServerUrl = System.getenv("ES_LOCAL_URL");
String elasticsearchApiKey = System.getenv("ES_LOCAL_API_KEY");

String ollamaUrl = System.getenv("ollama-url");
String ollamaModelName = System.getenv("model-name");</code></pre><h2>Step 2: Ingesting the dataset</h2><p>Since the dataset is a CSV, we’ll be using <a href="https://github.com/FasterXML/jackson-dataformats-text">Jackson dataformat</a>’s <code>jackson-dataformat-csv</code> to easily read the data and map it to a Java class, defined as:</p><pre><code>public record Movie(
    String movie_id,
    String movie_name,
    Integer year,
    String genre,
    String description,
    String director
) {
}</code></pre><p>Now we can create an instance of <code>CsvSchema</code> mapping the CSV structure and read the file into an iterator:</p><pre><code>CsvSchema schema = CsvSchema.builder()                    
    .addColumn("movie_id") // same order as in the csv    
    .addColumn("movie_name")                              
    .addColumn("year")                                    
    .addColumn("genre")                                   
    .addColumn("description")                             
    .addColumn("director")                                
    .setColumnSeparator(',')                              
    .setSkipFirstDataRow(true)                            
    .build();                                             
                                                          
CsvMapper csvMapper = new CsvMapper();                    
                                                          
File initialFile = new File("src/main/resources/scifi_1000.csv");
InputStream csvContentStream = new FileInputStream(initialFile);
                                                          
MappingIterator<Movie> it = csvMapper                     
    .readerFor(Movie.class)                               
    .with(schema)                                         
    .readValues(new InputStreamReader(csvContentStream)); </code></pre><p>Each row needs to be embedded first, and then both the embedded content and the text representation will be ingested by Elasticsearch.</p><p>Let’s start by creating an instance of the Ollama embedding model class:</p><pre><code>EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
    .baseUrl(ollamaUrl)
    .modelName(ollamaModelName)
    .build(); </code></pre><p>And then the Elasticsearch vector store, which needs an instance of the Elasticsearch Java RestClient:</p><pre><code>RestClient restClient = RestClient
    .builder(HttpHost.create(elasticsearchServerUrl))
    .setDefaultHeaders(new Header[]{
        new BasicHeader("Authorization", "ApiKey " + elasticsearchApiKey)
    })
    .build(); 

EmbeddingStore<TextSegment> embeddingStore = ElasticsearchEmbeddingStore.builder()
    .restClient(restClient)
    .build(); </code></pre><p>For the ingestion loop, the LangChain4j library requires the data to be split in two lists for ingestion, one for the vector representation and one for the original text, so we’ll set up two lists which will be filled by the loop:</p><pre><code>List<Embedding> embeddings = new ArrayList<>();
List<TextSegment> embedded = new ArrayList<>();</code></pre><p>Where <code>Embedding</code> and <code>TextSegment</code> are both library specific classes.</p><p>We’ll iterate on the movie dataset iterator, use the embedding model to retrieve the vector representation for each movie information (a text representation of all the fields merged), and add the name separately as metadata so that the result will be easier to read.</p><pre><code>boolean hasNext = true;

while (hasNext) {
    try {
        Movie movie = it.nextValue();
        String text = movie.toString();

        Embedding embedding = embeddingModel.embed(text).content();
        embeddings.add(embedding);

        Metadata metadata = new Metadata();
        metadata.put("movie_name", movie.movie_name());
        embedded.add(new TextSegment(text, metadata));

        hasNext = it.hasNextValue();
    } catch (JsonParseException | InvalidFormatException e) {
        // ignore malformed data
    }
}</code></pre><p>Finally, the vector list and text list are passed to the vector store method <code>addAll()</code>, which will handle asynchronously sending the data to the vector store:</p><pre><code>embeddingStore.addAll(embeddings, embedded);</code></pre><h2>Step 3: Querying</h2><p>Our goal is to find movies with time loops in the plot, so our prompt will be:</p><pre><code>String query = "Find movies where the main character is stuck in a time loop and reliving the same day.";</code></pre><p>Let’s try a simple vector search first, by creating a content retriever with a <a href="https://www.elastic.co/docs/solutions/search/vector/knn">k-nearest neighbor (kNN) query</a> default configuration and then running the query and printing the results:</p><pre><code>ElasticsearchContentRetriever contentRetrieverVector = ElasticsearchContentRetriever.builder()
                .restClient(restClient)
                .configuration(ElasticsearchConfigurationKnn.builder().build())
                .maxResults(5)
                .embeddingModel(embeddingModel)
                .build();

List<Content> vectorSearchResult = contentRetrieverVector.retrieve(Query.from(query));

System.out.println("Vector search results:");
vectorSearchResult.forEach(v -> System.out.println(v.textSegment().metadata().getString(
                "movie_name")));</code></pre><p>This outputs:</p><pre><code>Vector search results:
The Witch: Part 1 - The Subversion
Divinity
The Maze Runner
Spider-Man
Spider-Man: Into the Spider-Verse</code></pre><p>Now let’s see how hybrid search performs:</p><pre><code>ElasticsearchContentRetriever contentRetrieverHybrid = ElasticsearchContentRetriever.builder()
    .restClient(restClient)
    .configuration(ElasticsearchConfigurationHybrid.builder().build())
    .maxResults(5)
    .embeddingModel(embeddingModel)
    .build();

List<Content> hybridSearchResult = contentRetrieverHybrid.retrieve(Query.from(query));

System.out.println("Hybrid search results:");
hybridSearchResult.forEach(v -> System.out.println(v.textSegment().metadata().getString(
            "movie_name")));</code></pre><pre><code>Hybrid search results:
Edge of Tomorrow
The Witch: Part 1 - The Subversion
Boss Level
Divinity
The Maze Runner</code></pre><h2>Why these results?</h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped / escape) can appear.</li><li>Hybrid (BM25 + kNN + reciprocal rank fusion [RRF]) rewards documents that match keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</li></ul><p>Hybrid search doesn’t guarantee that every result is perfect; it balances lexical and semantic signals, so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords. Check the <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a> for more information on how hybrid search works.</p><h2>Full code example</h2><p>You can find the full demo code on <a href="https://github.com/elastic/hybrid-search-elastic-langchain4j">GitHub</a>.</p><h2>Conclusion</h2><p>In this article, we demonstrated how to use hybrid search in LangChain4j through its Elasticsearch integrations, with a complete Java example. This article is an extension of a <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a>, which presents the LangChain integrations for Python and JavaScript and introduces and explains hybrid search. We’re planning to continue our collaboration with LangChain4j in the future by contributing to the embedding models with our Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">Inference API</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[SearchClaw: Bring Elasticsearch to OpenClaw with composable skills]]></title>
            <description><![CDATA[Give your local AI agent access to Elasticsearch data using OpenClaw, composable skills, and agents, no custom code required.]]></description>
            <link>https://www.elastic.co/search-labs/blog/openclaw-elasticsearch-ai-agents</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/openclaw-elasticsearch-ai-agents</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alex Salgado]]></dc:creator>
            <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ce3248c3cb7a352e3fdafef4ac8116ab998ab4f4-1950x1137.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In recent weeks, <a href="https://openclaw.ai/">OpenClaw</a> has been appearing frequently in AI community discussions, particularly among developers interested in agents, automation, and local runtimes. The project gained traction quickly, which naturally raised a technical question:</p><p><em>What real problem does it solve for engineers?</em></p><p><strong>OpenClaw</strong> is a self-hosted gateway for AI agents: a single runtime that coordinates execution, treats agents as isolated processes, and uses skills (structured instructions in markdown files) as the unit of integration. Conceptually, this isn’t entirely different from what we already do with command line interfaces (CLIs) and scripts, but it’s now formalized around agent-driven workflows.</p><p>This led to a practical exploration within the Elastic Stack:</p><p><em>If we treat OpenClaw as an orchestration runtime, how does it behave when Elasticsearch is the back end? And how straightforward is integration using OpenClaw skills?</em></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ec61e65f54b96b83975b52b2d88305170001d9bd-1999x1445.png" style="width: 100%;" /><p>Let&#x27;s build an integration using composable skills.</p><h2><strong>Solution architecture</strong></h2><p>In this tutorial, we’ll teach OpenClaw how to access and query Elasticsearch data through a custom read-only skill, and we’ll then demonstrate how it composes multiple skills together; for example, combining Elasticsearch queries with real-time weather data to generate dynamic reports.</p><p>Before diving into the hands-on steps, let’s look at what we’re building. The solution is composed of three integrated layers that work together through OpenClaw orchestration.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ca8dc124a7410ba036ddf887eee011c42125cdf3-1270x680.png" style="width: 100%;" /><h3>Layer 1: Storage and search (Elasticsearch)</h3><p>The data layer runs on Elasticsearch via <a href="https://github.com/elastic/start-local"><code>start-local</code></a>, a single command that spins up Elasticsearch and Kibana locally with Docker.</p><p>Two sample indices demonstrate different use cases:</p><ul><li><strong><code>fresh_produce</code>:</strong> 10 products with semantic search (ecommerce scenario)</li><li><strong><code>app-logs-synthetic</code>:</strong> 30 log entries across four services (observability scenario)</li></ul><p>The same read-only skill works with both indices without any reconfiguration; the agent inspects the mapping and adapts its queries accordingly.</p><h3>Layer 2: Orchestration (OpenClaw Gateway)</h3><p>The gateway receives natural language requests and loads the Elasticsearch skill, and the large language model (LLM) decides which queries to construct. The skill is a pure <strong><code>SKILL.md</code></strong> with reference docs, meaning that its operations require no custom code.</p><p>To understand how the gateway organizes this, two core OpenClaw concepts are worth knowing:</p><ul><li><strong>Agents:</strong> Independent AI instances, each with its own configuration, workspace, and set of skills. You can run multiple agents for different purposes.</li><li><strong>Workspace:</strong> A folder that defines an agent’s context:<strong> <code>AGENTS.md</code></strong> (the agent’s permanent briefing), <strong><code>.env</code> </strong>(credentials), and a <strong><code>skills/</code></strong> directory. Think of it as the agent’s working environment.</li></ul><h3>Layer 3: Skills (composable capabilities)</h3><p>Skills are structured instructions in markdown files (<code>SKILL.md</code>) that teach the agent how to use specific tools or APIs. They can be global (available to all agents), workspace-specific, or bundled with OpenClaw. The agent selectively loads only the skills relevant to each request.</p><p>This tutorial uses two skills:</p><ul><li><strong><code>Elasticsearch-openclaw</code> (custom, built for this tutorial):</strong> A read-only skill that teaches the agent how to search, filter, aggregate, and explore Elasticsearch indices using curl.</li><li><strong><code>Weather</code> (community skill, used for composition demo):</strong> A skill that fetches current weather conditions from external APIs.</li></ul><p>Later in the tutorial, we&#x27;ll demonstrate how OpenClaw composes both skills in a single request, querying Elasticsearch products based on real-time weather data without any custom integration code.</p><h4>Read-only by design</h4><p>The <code>elasticsearch-openclaw</code> skill is <strong>read-only by design</strong>. It provides patterns for searching, filtering, and aggregating data, but it never writes, updates, or deletes. This minimizes the security footprint when giving AI agents access to your Elasticsearch cluster.</p><p>Even if the agent environment is compromised, your data remains safe from modification or deletion. This is enforced through:</p><ul><li><strong>Skill design:</strong> No write operation patterns in <code>SKILL.md</code> or reference files.</li><li><strong>API key permissions:</strong> The tutorial uses a read-only API key with only <code>read</code> and <code>view_index_metadata</code> privileges.</li><li><strong>Agent instructions:</strong> <code>AGENTS.md</code> explicitly states &quot;You can SEARCH, FILTER, and AGGREGATE data, but you can NEVER write, update, or delete.&quot;</li></ul><p>This security-first approach is why infrastructure setup (index creation, data loading) must be done manually; by design, the agent cannot do it for you.</p><h2><strong>Prerequisites</strong></h2><p>To follow this tutorial, you’ll need:</p><p><strong>Software and tools:</strong></p><ul><li>Docker Desktop installed and running (Docker Engine with Compose V2).</li><li>Elasticsearch running locally via <code>start-local</code>. (We’ll set this up in the next section.)</li><li>Jina API key (free): <a href="https://jina.ai/embeddings">https://jina.ai/embeddings</a>.</li><li>OpenClaw installed: <a href="https://openclaw.ai">https://openclaw.ai</a>.</li></ul><h3><strong>Setting up the environment</strong></h3><p>Start by cloning the starter project, which contains the skill, workspace configuration, and Dev Tools scripts:</p><pre><code>git clone https://github.com/salgado/elasticsearch-openclaw-start-blog
cd elasticsearch-openclaw-start-blog</code></pre><p>The repository contains:</p><pre><code>elasticsearch-openclaw-start-blog/
├── devtools_fresh_produce.md         ← Creates fresh_produce index (10 products)
├── devtools_app_logs_synthetic.md    ← Creates app-logs-synthetic index (30 logs)
└── openclaw-workspace-elastic-blog/
    ├── AGENTS.md                      ← Agent briefing
    ├── .env.example                   ← Credentials template</code></pre><p><em><strong>Note:</strong> The <code>devtools*.md</code> files contain Kibana Dev Tools commands formatted as reference documentation.</em></p><h4>Installing OpenClaw</h4><p>OpenClaw is a self-hosted gateway. This means you maintain full control over execution and data, but you need to prepare your local environment or server.</p><p>I installed OpenClaw on a separate machine, which is why I included the disclaimer below.</p><p><strong>** Security and responsibility disclaimer **</strong></p><p>Since OpenClaw is an early-stage, rapidly evolving open-source project, the community has raised important discussions about potential security vulnerabilities, especially around token handling and third-party script execution.</p><p><strong>Deployment recommendations:</strong></p><ul><li><strong>Isolated environments:</strong> If you’re not an advanced infrastructure security user, we recommend installing OpenClaw strictly in isolated, controlled environments (such as a dedicated virtual machine [VM], a rootless Docker container, or a test machine).</li><li><strong>Do not use in production:</strong> Avoid running the gateway on servers containing sensitive data or with unrestricted access to your corporate network until the project reaches a more stable, audited version.</li><li><strong>Least privilege:</strong> We reinforce the need to use Elasticsearch API keys with restricted permissions (read-only) to mitigate risks, in case the environment is compromised.</li><li><strong>Network segmentation:</strong> Both Elasticsearch and OpenClaw bind to <code>localhost</code> by default. Keep it that way, unless you have a specific reason to expose them.</li><li><strong>Credential rotation:</strong> Rotate API keys periodically. OpenClaw stores credentials locally, so treat the machine’s security as the perimeter.</li><li><strong>Audit logging:</strong> Enable Elasticsearch audit logging to track all API calls made by OpenClaw. This creates a full trail of what the agent accessed and when.</li><li><strong>Keep the installation up to date.</strong></li></ul><p>For a deeper analysis of the security architecture and deployment options, consult the <a href="https://docs.openclaw.ai">official OpenClaw documentation</a>.</p><h4>Runtime installation</h4><p>OpenClaw manages daemons and skill isolation via CLI. Since it’s a recent project that has undergone naming changes, we recommend strictly following the <a href="https://docs.openclaw.ai/install">official documentation</a> to ensure installation compatibility.</p><pre><code># Global gateway installation
curl -fsSL https://openclaw.ai/install.sh | bash</code></pre><h2><strong>Preparing the Elasticsearch back end</strong></h2><p>Before connecting any agent runtime, we need a working Elasticsearch environment with data to query and a secure, <strong>read-only access layer</strong>. In the next two sections, we’ll spin up Elasticsearch locally using <code>start-local</code>, create an index with <code>semantic_text</code> and Jina v5 embeddings, load sample data, validate that semantic search works, and generate a read-only API key. Once this foundation is in place, the Elasticsearch side is complete and we can focus entirely on teaching the agent how to use it.</p><h3>Part 1: Setting up Elasticsearch locally</h3><p>Start a local Elasticsearch and Kibana instance with a single command:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>Once complete: Elasticsearch at <code>http://localhost:9200</code>, Kibana at <code>http://localhost:5601</code>, and credentials in <code>elastic-start-local/.env</code>.</p><h3>Part 2: Configuring the index in Kibana Dev Tools</h3><p>Open <code>http://localhost:5601</code> → Dev Tools and run <code>devtools_fresh_produce.md</code> in order.</p><ul><li><strong>Step 1:</strong> Replace <code>YOUR_JINA_API_KEY</code> with your actual Jina API key (free).</li><li><strong>Step 2:</strong> Save the encoded field immediately; it cannot be retrieved later.</li></ul><p>The key commands in the Dev Tools file are:</p><p><strong>Create the Jina inference endpoint:</strong></p><pre><code>PUT _inference/text_embedding/jina-embeddings-v5
{
  "service": "jinaai",
  "service_settings": {
    "api_key": "YOUR_JINA_API_KEY",
    "model_id": "jina-embeddings-v5-text-small"
  }
}</code></pre><p><strong>Create the index with <code>semantic_text</code>:</strong></p><pre><code>PUT /fresh_produce
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "fields": { "keyword": { "type": "keyword" } }
      },
      "description": { "type": "text" },
      "category": { "type": "keyword" },
      "price": { "type": "float" },
      "stock_kg": { "type": "float" },
      "on_sale": { "type": "boolean" },
      "image_url": { "type": "keyword" },
      "semantic_content": {
        "type": "semantic_text",
        "inference_id": "jina-embeddings-v5"
      }
    }
  }
}</code></pre><p>The <code>semantic_text</code> field type handles embedding generation automatically at index time.</p><p><strong>Index sample products</strong> using the bulk API (see <code>devtools_fresh_produce.md</code> for the full dataset of 10 products).</p><p><strong>Validate semantic search:</strong></p><pre><code>GET /fresh_produce/_search
{
  "query": {
    "semantic": {
      "field": "semantic_content",
      "query": "healthy colorful meals"
    }
  },
  "size": 3,
  "_source": ["name", "description", "category"]
}</code></pre><p>The semantic query type handles inference on the query side automatically; no need to specify model IDs or embedding details.</p><p><strong>Create a read-only API key:</strong></p><pre><code>POST /_security/api_key
{
  "name": "openclaw-readonly",
  "role_descriptors": {
    "reader": {
      "cluster": ["monitor"],
      "indices": [
        {
          "names": ["fresh_produce", "app-logs-synthetic"],
          "privileges": ["read", "view_index_metadata"]
        }
      ]
    }
  }
}</code></pre><p>Save the encoded value from the response. This is your API key for the OpenClaw configuration.</p><h2>Connecting to OpenClaw</h2><p>With the Elasticsearch back end ready, we can now wire it into OpenClaw. Several Elasticsearch integrations already exist in the ecosystem, from <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic’s own Model Context Protocol (MCP) server</a> to community-built MCP servers. However, most of these offer full CRUD access or are designed for different agent runtimes. Given that the technology is still in its early stages and security remains a primary concern, I chose to build a dedicated skill, simple, read-only, and purpose-built for OpenClaw. This approach ensures that the agent can search, filter, and aggregate data but never modify it, keeping the blast radius minimal even if the environment is compromised.</p><p>In the next sections, we’ll configure credentials, install the skill, create a dedicated agent, and explore how the workspace ties everything together.</p><h3>Install the skill and create the agent</h3><h4>Step 1: Configure credentials</h4><p>From the cloned repository, configure the credentials by copying the environment template and filling in your Elasticsearch URL and the read-only API key:</p><pre><code>cp openclaw-workspace-elastic-blog/.env.example 
openclaw-workspace-elastic-blog/.env</code></pre><p>Edit the .env file with these two values:</p><pre><code>ELASTICSEARCH_URL: http://localhost:9200 (from start-local)
ELASTICSEARCH_API_KEY: The encoded value from the read-only API key you created in Part 2 (the POST /_security/api_key response)</code></pre><p>Example .env file:</p><pre><code>ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_API_KEY=VnVaRmxLSDRCQxxxxxxxxbGVfa2V5</code></pre><h4>Step 2: Install the skill from ClawHub</h4><p><a href="https://clawhub.ai/">ClawHub</a> is OpenClaw&#x27;s public skill registry. Think of it as npm for AI agent skills. At the time of this writing, ClawHub hosts over 3,200 skills, covering everything from Slack and GitHub integrations to Internet of Things (IoT) device automation. For this tutorial, we created <code>elasticsearch-openclaw</code>, a custom skill focused on read-only queries using <code>semantic_text</code>, aggregations, and observability on Elasticsearch 9.x. It’s published on ClawHub so you can install it directly. As a best practice, only install skills from trusted sources with known provenance; as with any package manager, review the content before granting access to your agent.</p><p>The <code>elasticsearch-openclaw</code> skill is published on ClawHub.</p><p><strong>Recommended:</strong> Open the OpenClaw Web UI (http://127.0.0.1:18789/) and ask:</p><pre><code>Install the elasticsearch-openclaw skill from https://clawhub.ai/salgado/elasticsearch-openclaw</code></pre><p>OpenClaw will:</p><ul><li>Fetch the skill from ClawHub.</li><li>Install it in the appropriate directory.</li><li>Confirm when ready to use.</li></ul><h4>Step 3: Create the agent</h4><p>Do this by registering a dedicated agent with its own workspace, and then restart the gateway to load the new configuration:</p><pre><code>openclaw agents add elasticsearch-agent \
  --workspace ~/path/to/elasticsearch-openclaw-start-blog/openclaw-workspace-elastic-blog \
  --non-interactive

openclaw gateway restart</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/abc051a513b0cc7dff4a7f02493d51e220c72ad4-1999x1095.png" style="width: 100%;" /><h3>Understanding the workspace</h3><p>Now that the agent is running, let’s look at what makes it tick.</p><h4><code>AGENTS.md</code></h4><p>The <code>AGENTS.md</code> file is the agent’s permanent briefing. It defines who the agent is, what it can do, and how it should behave. For our Elasticsearch agent, this file instructs the agent about the available indices, the read-only constraint, and the preferred query patterns.</p><h4>Skills: When they make a difference</h4>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Without skill</td><td style="border: 1px solid #ccc; padding: 4px;">With `elasticsearch-openclaw` skill</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Agent has no knowledge of Elasticsearch query syntax.</td><td style="border: 1px solid #ccc; padding: 4px;">Agent knows semantic, full-text, filtered, and aggregation patterns.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Agent might attempt write operations.</td><td style="border: 1px solid #ccc; padding: 4px;">Agent is instructed to never write, update, or delete.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Agent guesses field names and types.</td><td style="border: 1px solid #ccc; padding: 4px;">Agent inspects mappings first and then constructs appropriate queries.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Generic curl commands with trial and error.</td><td style="border: 1px solid #ccc; padding: 4px;">Structured query templates with best practices for Elasticsearch 9.x.</td>
                    </tr>
              </tbody>
            </table>
          <h2><strong>Exploring with the agent</strong></h2><p>With the Elasticsearch back end configured and the OpenClaw agent connected, it’s time to see what the agent can actually do. In the next sections, we’ll test natural language queries, explore observability data, and compose multiple skills together.</p><h3><strong>Testing in OpenClaw</strong></h3><p>Open the OpenClaw web UI, and try some natural language queries. The agent will inspect the index mapping, choose the appropriate query type, and return results.</p><p>Type:</p><pre><code>“Find products that would be good for a healthy summer salad.”</code></pre><p>Result:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5b4bbe71ad56af5462bccc1475bd10d5233abd9-1011x557.png" style="width: 100%;" /><p>Others ideas to explore:</p><ul><li><strong>Index exploration:</strong> &gt; “What indices do I have in Elasticsearch? Show me the fields of <code>fresh_produce</code>.”</li><li><strong>Filtered search:</strong> &gt; “Show me all products on sale under $15.”</li><li><strong>Aggregations:</strong> &gt; “What’s the average price by category?”</li></ul><h3>Observability</h3><p>To demonstrate that the skill works beyond a single use case, the repository includes a second index: <code>app-logs-synthetic</code>, with 30 synthetic log entries across four fictional services, created from <code>devtools_app_logs_synthetic.md</code>.</p><h4>Setting up the log data</h4><p>Since the skill is read-only, you need to populate the index first. The <code>devtools_app_logs_synthetic.md</code> file contains <strong>five commands</strong> (three for setup and two for verification):</p><ul><li><strong><code>Create ingest pipeline</code>:</strong> Adds @timestamp to log entries automatically.</li><li><strong><code>Create index mapping</code>:</strong> Defines the <code>app-logs-synthetic</code> structure (classic fields only, no <code>semantic_text</code>).</li><li><strong><code>Bulk insert logs</code>:</strong> Loads 30 synthetic log entries across four services.</li><li><strong><code>Count query</code>:</strong> Verify 30 documents were indexed.</li><li><strong><code>Sample search</code>:</strong> Quick test to confirm that data is queryable.</li></ul><h4>How to run:</h4><ol><li>Open Kibana Dev Tools: http://localhost:5601 → Dev Tools.</li><li>Copy each numbered block from the .md file.</li><li>Paste into the Dev Tools console.</li><li>Press <strong><em>Ctrl/Cmd+Enter</em></strong> to execute.</li><li>Wait for a successful response before continuing to the next block.</li></ol><p>This creates the <code>app-logs-synthetic</code> index with sample data ready for querying.</p><p>Try this query in the OpenClaw web UI:</p><pre><code>Show me the distribution of HTTP status codes across all services.</code></pre><p>Result:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d35fc1c0ea6d647f1c85163eb0ab8e268c6c4f89-1002x565.png" style="width: 100%;" /><p>Other ideas to explore:</p><ul><li>“How many 500 errors do I have in <code>app-logs-synthetic</code>? Which services are failing?”</li><li>“Which endpoints have the slowest response times?”</li><li>“What happened with the <code>payment-service</code> in the last 24 hours?”</li></ul><p>This is the same skill, same agent, same setup, just pointed at different data. The agent inspects the new index mapping, adapts its queries, and returns relevant results without any reconfiguration.</p><h2><strong>Composing skills in action</strong></h2><p>This is where composable skills truly shine. Start by asking the agent:</p><pre><code>Install the weather skill.</code></pre><p>OpenClaw will search for the weather skill, automatically attempt the installation, and guide you through the process. Just follow the on-screen instructions; no new API key is required for the weather skill. Afterward, try this:</p><pre><code>“Find the products on sale in the fresh_produce index that match today’s weather in São Paulo. Generate a nice HTML report with product cards using the image_url field from each document, price, description, and stock. Save it to ~/Desktop/report.html and open it in the browser.”</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/14fa4303bb2f1eb19530d8844f09c99948b3c752-1965x1079.png" style="width: 100%;" /><p>In a single request, the agent chains multiple skills: the <strong>weather skill</strong> to check current conditions, the <strong>Elasticsearch skill </strong>to run a hybrid search on products that match the context, and its built-in file and browser tools to generate an HTML report and open it. No custom integration code, no glue scripts, just skills composed by the LLM at runtime.</p><p>This is what makes OpenClaw different from a traditional automation framework. You don’t preprogram the workflow. You describe the outcome, and the agent figures out the composition.</p><h2><strong>Conclusion</strong></h2><p>SearchClaw started as a simple experiment and ended up demonstrating what composable, LLM-driven integration looks like in practice. The key takeaway is not the individual tools (all are familiar) but the approach. Instead of writing a specific application with hardcoded queries, we gave the agent capabilities and let it compose solutions dynamically. This is what makes OpenClaw native: composable, LLM-driven, and local-first.</p><p>As with any early-stage project, OpenClaw should be used thoughtfully, especially regarding security and environment isolation. The read-only skill approach demonstrated here is one way to limit risk while still unlocking the value of your Elasticsearch data.</p><p>The full code is available in the repository and can serve as a starting point for your own integrations: <a href="https://github.com/salgado/elasticsearch-openclaw-start-blog">https://github.com/salgado/elasticsearch-openclaw-start-blog</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building effective database retrieval tools for context engineering]]></title>
            <description><![CDATA[Best practices for writing database retrieval tools for context engineering. Learn how to design and evaluate agent tools for interacting with Elasticsearch data.]]></description>
            <link>https://www.elastic.co/search-labs/blog/database-retrieval-tools-context-engineering</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/database-retrieval-tools-context-engineering</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Leonie Monigatti]]></dc:creator>
            <pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/06d3502988707f6f88f9b18243072cbbed3eb8af-1200x630.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Good search is incredibly difficult. That&#x27;s why we have an ecosystem full of search algorithms, tooling, and even dedicated stacks like Elastic’s, not to mention an entire field called <em>information retrieval</em>. In modern <a href="https://www.elastic.co/search-labs/blog/context-engineering-overview">context engineering</a>, AI agents search and retrieve context from many different sources, including local files, the web, or memory files. Giving agents access to tools to interact with data stored in a database allows them to ground their answers in proprietary information or even complete analytical tasks.</p><p>However, if these interfaces aren’t carefully engineered, agents can search the wrong index, generate invalid SQL/Elasticsearch Query Language (ES|QL) queries, or return large amounts of irrelevant data. During the development of <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Elastic Agent Builder</a>, we’ve seen these failure modes multiple times. While collaborating with dozens of internal teams to build tools for interacting with Elasticsearch data and integrating them to improve our internal processes with agentic workflows, such as our <a href="https://www.elastic.co/search-labs/blog/agent-builder-one-workflow">internal laptop refresh process</a>, we found that the most successful teams carefully design database retrieval tools as curated interfaces to their data.</p><p>In this blog, we share the best practices we follow when building database retrieval tools. In fact, the principles we share are based on common patterns we saw during iteratively improving our prebuilt tools and helping internal teams build custom tools.</p><h2>Key challenges of agentic retrieval</h2><p>Coding and search are among the best use cases of agents. Even though coding agents have recently made substantial progress with new concepts, such as filesystem-oriented tools and code-specific embeddings, search agents (specifically for database retrieval) lack reported breakthroughs.</p><p>Agent use cases are challenging for multiple reasons: They can ignore the available tools to accomplish a task; they can call the wrong tools; and they can call the right tools with the wrong parameters. In addition to these general challenges, we believe that database retrieval use cases are challenging for the following three key reasons:</p><ul><li><strong>Identifying the right index of data</strong> requires the large language model (LLM) to understand what it contains. But sometimes, the number of <a href="https://www.elastic.co/blog/what-is-an-elasticsearch-index">indices</a> can already be so large that even representing those to select may cause context length problems.</li><li><strong>Generating efficient queries</strong> that balance retrieving relevant information with minimizing latency and resource usage can be challenging.</li><li><strong>Avoiding context bloat with tool responses </strong>requires the tool response to be optimized for contextual relevance and<strong> </strong>token efficiency. This isn’t always easy, especially when the agent generates the query from scratch. Once the context is no longer relevant to answer a user’s query, offloading the data for later reference is another challenge.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6736afbe774da63ef9534a64f65a7de1f7d075c0-1280x720.png" style="width: 100%;" /><p>During the development of Agent Builder and integrating it into our own processes, we faced these challenges multiple times. In fact, the principles we share in the following sections are based on common patterns we saw during iteratively improving our built-in and custom tools and our internal workflows built on top of them.</p><h2>Principles for building effective database retrieval tools</h2><p>In this section, we translate our learnings into guiding principles for building effective database retrieval tools: deciding which tools to build, making sure the agent finds the right index to search and calls the right tool with appropriate parameters, optimizing the tool responses, handling errors, and safeguarding the data.</p><h3>Building the right database retrieval tools (“low floor, high ceiling”)</h3><p>When deciding on what database retrieval tools to build, we follow the principle of “low floor, high ceiling” for good <a href="https://biilmann.blog/articles/introducing-ax/">agent experience</a>:</p><ul><li><strong>High ceiling: </strong>Tools that don’t limit the agent&#x27;s potential to handle ambiguous user queries in the worst case. In the context of database retrieval, these are <strong>general-purpose</strong> tools that, for example, allow the agent to write full SQL/ES|QL queries from scratch. These come with the trade-off of reasoning overhead of the agent and result in higher latency, higher cost, and lower reliability.</li><li><strong>Low floor:</strong> Tools with high accessibility that the agent can use successfully on the first try with minimal reasoning overhead for repeating user queries. In the context of database retrieval, these are <strong>specialized tools</strong> that, for example, wrap specific queries. These have the benefit of lower latency, lower cost, and higher reliability than general-purpose tools. However, they require engineering effort, and realistically, it may not be possible for engineers to anticipate every possible user query.</li></ul><p>For example, in our experience, a generic search tool is mandatory to allow the agent to handle unique and ambiguous user queries in the worst case. However, we found it necessary to reduce reasoning overhead and increase efficiency by creating specialized tools (for example, <code>get_top_performing_products(category)</code>).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e6d1b973be4b0a0a25c99c74f02a47e98395a3f7-1200x630.png" style="width: 100%;" /><p>Another lesson we learned is to consider the level of abstraction of a tool. During our preview phase, the agent had access to a large number of atomic general-purpose tools (for example, <code>get_index_mappings</code>, <code>generate_esql</code>, <code>execute_query</code>, and others). In practice, this had two downsides: When faced with a complex, open-ended question, the agent would confuse tools and their expected order, despite guiding instructions. Combining multiple tools in an agentic workflow also requires hand-off of information via the context window, which leads to filling up the context window with information that’s only temporarily important. To overcome this inefficiency, we wrapped the functionality of multiple atomic tools into one self-contained search tool.</p><h3>Finding the right index</h3><p>While the majority of tools that interact with a database will focus on querying the database, in some use cases, like for index selection, the tool will allow the agent to interact with the database’s metadata to decide which indices to search against based on a user’s query and intent.</p><p>Initially, our naive index selection relied on the index names and a sample of their schema definitions. This worked well in our internal testing, but when internal teams experimented with it, we realized real use cases often don’t have index names that are distinct and descriptive but are instead vague (for example, <code>users</code>, <code>logs</code>, <code>flight_travels</code> versus <code>web-logs-2026.01</code>, <code>web-logs-2026.02</code>).</p><p>To overcome this, we started exposing each index’s metadata and schema definitions in the tool. This significantly improved the selection by allowing engineers to add descriptions to translate technical names into natural language on two levels:</p><ul><li><strong>Index-level descriptions: </strong>What data is stored in the index and how documents relate to one another.</li><li><strong>Field-level hints:</strong> Specific guidance on a field&#x27;s format, expected values, or business meaning (for example, <code>”Use this field for exact ID matches only”</code>).</li></ul><p>In another iteration, we decided to add data sampling. For each index, we sample some of the data present in each field to let the agent have a clearer understanding of the type of data in the index. While it significantly improved the efficiency of index selection, it comes at the cost of increased tool response data.</p><h3>Calling the right database retrieval tool</h3><p>Guiding an agent to call the right tool is already challenging enough in general. This section discusses what helped us make sure the agent calls a tool to ground their response, as well as calling the right database retrieval tool.</p><h4>Naming and namespacing: Standardizing identifiers for reliable selection</h4><p>A tool’s name acts as a skimmable header that agents use to decide which one to investigate further. In practice, this means using descriptive and distinct tool names. Additionally, tool selection is more reliable when their names are consistent in formatting (for example, standardize on snake_case) and wording. Using action-oriented verbs helps the LLM map the user’s intent to the tool’s purpose, although the exact wording of the tool names is less critical in practice (for example, <code>search</code> versus <code>find</code>).</p><p>Namespacing tools to group related tools under common prefixes or suffixes is similarly helpful. In the case of databases, namespacing tools by index or domain helps the LLM understand tool relationships and prevents naming collisions (for example, <code>finance.search_ticker</code> or <code>support.get_ticket_details</code>).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ae64cafe7a3aade3985780d7981a853e90387945-1200x630.png" style="width: 100%;" /><h4>Tool description: Instructing the agent on proper usage</h4><p>The description is the most crucial component of any tool definition because it instructs the agent on <em>when</em> and <em>how</em> to use it, especially when tools have similar names (for example, <code>search_logs</code> and <code>find_errors</code>).</p><p>Consider this template for complex tools:</p><ul><li><strong>Core purpose: </strong>A high-level summary of what the tool does.</li><li><strong>Trigger: </strong>When the tool should be used (and when it should not).</li><li><strong>Action:</strong> Which specific data the tool retrieves or modifies, and what type of questions it can answer.</li><li><strong>Limitations: </strong>What important limitations and constraints exist, such as specific query languages or formats.</li><li><strong>Relationships with other tools: </strong>Does one tool affect another tool, or are there any preconditions?</li><li><strong>Examples:</strong> Specific few-shot examples of user queries and how to use the tool for them, such as how to determine the optimal search strategy or when to use which operator.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b3e19d6472cbc62fe2be0e4efcb918e52c10d2f8-1200x630.png" style="width: 100%;" /><p>A note on model sensitivity: While top-tier models like Claude 4.5 Sonnet are forgiving, smaller models often need clearer and more elaborate descriptions to select the right tool.</p><h4>Adding reasoning parameters</h4><p>Inspired by the <a href="https://arxiv.org/pdf/2601.18282v1">paper on think-augmented function calling</a>, we added a reasoning parameter. This approach improves the parameter accuracy by providing a scratchpad for the LLM to process its thoughts and facilitates a more transparent user experience.</p><p>This works well for complex tool calls or when a large number of tools are exposed to the agent. However, it can lead to regression in simple scenarios, and the benefits are further diminished for thinking-based LLMs. In our Agent Builder implementation, the reasoning parameter is often optional and stripped before execution and is only used for tool selection and parameter filling.</p><pre><code>"properties": {
   "reasoning": {
      "type": "string",
      "description": "Brief explanation of why you're calling this tool"
   }
}</code></pre><h4>Support: Reinforcing instructions in the agent prompt</h4><p>A common error we observed is that the LLM would sometimes ignore the available tools and instead use its innate knowledge to generate a (hallucinated) response. For example, when asked ”Can you tell me more information about Elasticsearch’s ES|QL language?”, it would assume it was fine to answer by itself instead of calling the tool, which was specifically designed to fetch documentation about Elastic products.</p><p>To mitigate this problem, we added repeated, explicit instructions in the system prompt of the agent itself to guide the agent to find the right balance between using its innate knowledge and grounding its answer in a tool response. Our testing indicates that this is especially effective when multiple tools with similar purposes are exposed to the agent.</p><h4>Forcing tool usage</h4><p>Beyond reinforcing the instructions in the agent prompt, we found it helpful to force tool usage when mandatory by explicitly binding tools using <code>tool_choice: ‘any’</code>.</p><h3>Calling database retrieval tools with appropriate values and writing queries</h3><p>Another challenge is to enable the agent to call a tool with appropriate parameter values. We’ve observed a consistent pattern where a strong definition, the number of parameters, and their complexity play an important role in reducing errors.</p><h4>Parameter definition</h4><p>A strong parameter definition significantly improves the parameter accuracy. General best practices for parameter definitions in agent tools are:</p><ul><li><strong>Unambiguous name:</strong> Clearly identifies the purpose (for example, <code>user_id</code> versus <code>user</code>).</li><li><strong>Strong typing:</strong> Uses integer, string, or enums, among others, for finite sets of valid values.</li><li><strong>Detailed description:</strong> Explains what the parameter means and when and how to use it. Specifies default values for missing values, document formats (for example, for dates), hidden rules (for example, <code>”at least one of agent_id | user_id is required”</code>), and includes small examples.</li></ul><pre><code># Weak parameter description
"properties": {
   "index": {
      "description": "Name of the index",
   },
}

# Strong parameter description
"properties": 
{
   "index": {
      "type": "string","description": "The specific index, alias, or datastream to search. Defaults to 'main-alias' if unknown. ",
   },
}</code></pre><h4>Number of parameters</h4><p>Agents struggle to call a tool with a large number of parameters with appropriate values, especially mandatory ones. As a general rule of thumb, we try to keep the mandatory parameters below five and the total parameters below 10.</p><h4>Parameter complexity</h4><p>Reducing the complexity of the input parameter when possible leads to fewer mistakes. For example, it requires reasoning overhead to let an LLM generate a search term than generating full SQL or ES|QL queries from scratch. Especially for repeating user queries, “pre-canning” search queries reduces latency, cost, and error rates (although modern LLMs are good at using well-known languages, such as SQL).</p><p>To follow the principles of “low floor, high ceiling,” we gravitated toward wrapping a specific query inside a tool and letting the agent only provide the search term. Below is an example of input parameters with varying complexity for the same user query, &quot;Find the 5 most relevant &#x27;resolved&#x27; support tickets based on a new problem description to find previous solutions.&quot;</p><pre><code># Complex parameter
search_support_tickets("FROM support_tickets | WHERE status = 'resolved' AND customer_email = ?email | MATCH(issue_title, issue_description, ?problem_description) | KEEP ticket_id, issue_title, resolution | LIMIT 5",
)

# Simple parameter
find_similar_customer_resolved_tickets(problem_description)</code></pre><h4>Model sensitivity</h4><p>Models have a strong impact on parameter accuracy and query generation. Our internal benchmarking showed that switching from Claude 3.7 Sonnet to Claude 4.5 Sonnet reduced the syntax errors of the generated ES|QL queries from ~28% to ~4%.</p><h4>Input validation</h4><p>Although the above techniques increase parameter accuracy, they don’t eliminate the possibility of errors. Instead of trusting the LLM&#x27;s input, we gravitated toward always validating and sanitizing it to ensure queries adhere to the expected schema.</p><h3>Optimizing database retrieval tool responses</h3><p>One common mistake is neglecting the size of the tool’s output. Because the tool’s output is what goes into the agent’s context window, not optimizing it for <strong>contextual relevance</strong> (quality) can distract the agent, and not optimizing it for <strong>token efficiency</strong> (quantity) can increase cost and risk exceeding the LLM’s context window limit. Working alongside internal teams, we’ve identified three dimensions for reviewing the return values:</p><p><strong>The first dimension is length:</strong> The number of search results. A recurring pitfall we’ve observed among teams is the failure to limit search results, which can lead to overflowing the context window. While vector search queries inherently require a limiting parameter, other search methods often do not. We recommend including <code>limit </code>clauses (for example, 10 - 20) in all query types. This prevents returning low-signal results and ensures token efficiency.</p><p><strong>The second dimension is width: </strong>The number of fields per data object. Instead of returning all properties, including cluttering ones (for example, timestamps and internal IDs, among others), curating a set of relevant fields can help improve both the user experience and the agent behavior.</p><p>Consider the following examples:</p><ul><li><strong>Returning identifiers </strong>with a concise set of properties of a data object allows the agent to retrieve full information about a data object later when it needs it (“just-in-time context engineering”), rather than cluttering the context window.</li><li><strong>Returning metadata for citations</strong> (for example, page numbers in large PDF documents) can help build trust for the user.</li><li><strong>Returning the number of search results and status messages</strong> can help the agent reason the status of the search query.</li></ul><p><strong>The third dimension is depth:</strong> The size of a single field. Consider a case where the documents themselves are large (for example, in the 10s of MB scale). Those can’t just be passed back to the agent in full, as context length will instantly reach its limit. To mitigate this, we recommend truncating long text fields when an excerpt is sufficient. We found features such as <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/highlighting">Elasticsearch’s highlighting</a> are helpful for this without the need for another LLM API call.</p><p>But even when working with smaller documents, letting the tool format the data into an easily digestible format for the LLM, such as sanitizing the content (for example, stripping HTML tags), formatting for readability (for example, tables to Markdown tables or links as “[Title](url)”), improved downstream performance.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/74a92e1102e235ecd7fd9868d9c3a81a59809c9c-1200x630.png" style="width: 100%;" /><p>While using only one of these techniques is often not sufficient for improving the contextual relevance, combining all of them might reduce the retrieval recall (for example, limiting the number of search results might risk not retrieving all of the relevant documents). In practice, this requires evaluating different combinations of these approaches to find the best balance.</p><h3>Handling errors and enabling self-correction</h3><p>We’ve observed that agents can get stuck in infinite loops or hallucinate responses when they encounter an error. Even if an agent follows its instructions perfectly, if a tool doesn’t provide any error message, only returns an error code, or at best provides a short, nondescriptive error message, the agent has no chance to self-correct from an error it doesn’t understand.</p><p>An <strong>informative error message</strong> enables the agent to understand why the error is happening and how to recover from it. For this, engineers need to think about the “not so happy” paths and the anticipated edge cases, such as the following examples:</p><p>If an error occurred because of a wrongly formulated search query, the agent should be able to reason over the failure and reformulate the query. In this example, returning the number of search results and the generated query can help the agent self-correct.</p><p>In general, engineers need to consider whether “zero results” is an expected behavior or an error for a given tool. In cases where an empty result likely indicates an error, both an error message and agent instructions can be helpful.</p><pre><code># Example error message from tool
"No product data found for product ID [XYZ]. 

Review the search query [insert used search query here].

Ask the customer to confirm the product name, and look up the product ID by name to confirm you have the correct ID."

---
# Example agent instruction
"If the product_search tool returns no results, do not state that the product does not exist. 

First, check that the `:` operator was used for multi-value fields.


Second, ask the user to provide and confirm the product ID or provide an alternative identifier like product name."</code></pre><p>When encountering an API failure due to an expired API key, strictly limit retries (for example, a maximum of two or three) to prevent the agent from repeatedly trying a failing deterministic flow.</p><h3>Safeguarding data</h3><p>The primary engineering challenge for production-grade agent applications with different tools lies in identity propagation, specifically the distinct requirements of <em>authentication</em> (verifying who the user is) and <em>authorization</em> (verifying what they can access). While an initial layer (for example, Okta) can provide the base authentication, downstream systems (for example, ServiceNow, Elasticsearch, and others) maintain their own nonuniform authorization architectures with varying levels of granularity.</p><p>The most successful implementations we’ve seen enforce identity verification at every touchpoint within the tool&#x27;s logic. This prevents the agent from accidentally accessing data that the end user isn&#x27;t allowed to see. By verifying identity at every system level, we ensure that the agent respects privacy even when navigating complex, multisystem workflows. Be aware of the downside; this introduces intentional, security-mandated latency.</p><p>Beyond verifying the identity at every touchpoint, managing credentials securely is critical. Exposing sensitive API keys in tool definitions or hardcoding database credentials in YAML configuration files is a high-risk vulnerability. Instead, we recommend that engineers use secure credential management systems.</p><h2>Evaluating database retrieval tools</h2><p>The development of database retrieval tools for agentic systems is an iterative, evaluation-driven process. To evaluate the effectiveness of our database retrieval tools and uncover issues, our internal teams maintain evaluation datasets with realistic user queries and the expected tool calls (for example, ”Am I eligible for a laptop refresh?” expects the <code>check_eligibility</code> tool). We’ve used the following metrics for evaluation of our database retrieval tools and for benchmarking different LLMs for model selection:</p><ul><li><strong>Tool selection accuracy: </strong>How often the correct tool was selected for a specific query type (for example, retrieval, analytical, hybrid, adversarial).</li><li><strong>First-pass success rate: </strong>A critical metric for us was distinguishing between eventual success and immediate success. Does the agent pick the right tool on the first try, or does it require a self-correction loop? (High self-correction indicates poor tool descriptions.)</li><li><strong>Average tool calls per answer:</strong> We track the efficiency of the agent. If the average number of tool calls to answer a simple question jumps from 1.5 to 4, it usually indicates that the agent is getting lost or that the tools are too granular.</li><li><strong>Tool-specific recall/precision:</strong> For dedicated database retrieval tools, we measure standard retrieval metrics to ensure that the documents returned are relevant to the arguments passed by the LLM.</li><li><strong>Failure rate:</strong> We strictly monitor the rate of malformed tool calls (for example, missing parameters) to identify which models need more &quot;hand-holding&quot; in the prompt instructions.</li></ul><p>Once live, our teams at Elastic continue to <strong>monitor</strong> agentic health and log telemetry data (for example, every success and failure and the time taken for the agent to complete the task) in Kibana. This allows our ITOps teams to build dashboards to answer meta-questions like, &quot;What is the failure rate this week?,&quot; &quot;How many laptop requests came from California?,&quot; &quot;How many requests were fulfilled?,&quot; without building a custom analytics engine.</p><h2>Summary</h2><p>During the iterative, evaluation-driven development process of <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Elastic Agent Builder</a>, we identified consistent patterns in what makes database retrieval tools effective for context engineering. When implementing tools to search, retrieve, and manipulate data, we try to follow the following core principles:</p><ol><li><strong>Building the right database retrieval tools</strong> by following the “low floor, high ceiling” principle.</li><li><strong>Helping the agent call the right database retrieval tool with appropriate parameter values</strong> through careful and reinforced prompting and interface design.</li><li><strong>Avoid context flooding</strong> by optimizing the tool response for context relevancy (quality) and token efficiency (quantity).</li></ol><p>However, there remain open challenges, and we’re actively working on improving these techniques:</p><ul><li>Context bloat is a primary hurdle for agent performance because retrieved data remains in the context window as the conversation progresses. A future direction is to dynamically off-load large chunks of data, such as tool responses or attachments, in a file store and allow the agent to retrieve them on demand.</li><li>Efficient discovery of large volumes of tools and data attachments will be central for building production-grade agents. We plan to introduce agent skills with the functionality of <a href="https://claude.com/blog/equipping-agents-for-the-real-world-with-agent-skills"><em>progressive disclosure</em></a> (loading information only as needed) and implementing a semantic metadata layer.</li></ul><h3>Acknowledgements</h3><p>Written by Leonie Monigatti with valuable contributions from colleagues across Search Solutions Engineering (Sean Story, Pierre Gayvallet, Abhimanyu Anand) and Enterprise Applications (Sri Kolagani).</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build task-aware agents with an expanded model catalog on Elastic Inference Service (EIS)]]></title>
            <description><![CDATA[Elastic Inference Service (EIS) expands its managed model catalog, enabling teams to build production-ready agents with flexible model choice across retrieval, generation, and reasoning, without managing GPUs or infrastructure.]]></description>
            <link>https://www.elastic.co/search-labs/blog/build-ai-agents-elastic-inference-service</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/build-ai-agents-elastic-inference-service</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sean Handley, Anish Mathur, Deepti Dheer, Ranjana Devaji]]></dc:creator>
            <pubDate>Fri, 06 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d8c0ffb8835c49c116da7163edb0cf7f6857817c-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Today, we’re pleased to announce an expanded model catalog for Elastic Inference Service (EIS), making it easy to run fast, high-quality inference on managed GPUs, without setup or hosting complexity.</p><p>EIS already provides access to state-of-the-art large language models (LLMs) that power <a href="https://www.elastic.co/blog/elastic-out-of-box-genai-capabilities">out-of-the-box AI capabilities</a> across <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Elastic Agent Builder</a> and Elastic AI Assistants, including automatic ingest, threat detection, problem investigation, and root cause analysis. We’re now extending this foundation with a <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis#llm-chat-models">broader catalog</a> of managed models, giving developers more control over how agents reason, retrieve, and act.</p><p>In practice, this reflects a broader shift in how enterprises build AI systems. The idea of a single, all-purpose AI model no longer holds up. Real-world agent workflows require multiple models with different strengths, costs, and performance characteristics. With EIS, teams can either choose and switch models directly in Agent Builder, with zero setup, cost, or hosting overhead, or they can mix and match models in an agent <a href="https://www.elastic.co/blog/elastic-workflows-technical-preview">workflow</a> so each step uses the model best suited to the task.</p><p>Developers can use models from OpenAI, Anthropic, and Google directly in Elasticsearch, selecting different models for different agent steps while Elastic fully manages inference, scaling, and GPU execution for production agents.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4eea6b9d6fbe492d75947828bf1c46ae9801387a-1234x892.png" style="width: 100%;" /><h2>An expanded catalog of managed models on EIS</h2><p>The expanded EIS catalog now includes models optimized for different classes of tasks, from lightweight generation to large-context reasoning and embeddings for retrieval.</p><p>For generation, the catalog includes:</p><ul><li>Anthropic Claude Opus 4.5 and 4.6.</li><li>Gemini 2.5 Flash.</li><li>Gemini 2.5 Pro.</li><li>OpenAI GPT-4.1 and GPT-4.1 Mini.</li><li>OpenAI GPT-5.2.</li><li>OpenAI GPT-OSS-120B.</li></ul><p>For retrieval, EIS includes native Jina AI models, <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service"><code>jina-embeddings-v3</code></a> and <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text"><code>jina-embeddings-v5</code></a>, which provide fast, high-quality embeddings for multilingual retrieval. The service also includes embedding models from Microsoft, OpenAI, Google, and Alibaba.</p><h2>Choosing the right models for agent tasks</h2><p>With EIS, model choice becomes a design decision inside the agent, rather than an operational concern. Agents can select models based on the role they play, without changing how inference is deployed or scaled.</p><p>To see how this plays out in practice, consider a few common agent scenarios.</p><h3>Simple informational query</h3><p>Simple interactions, such as answering “What is our holiday policy?,” do not require an expensive frontier model and can be handled by a fast, low-cost option.</p><ul><li>Task: “What is our holiday policy?”</li><li>Pattern: Retrieve and summarize.</li><li>Model choice: Fast, low-cost generation model.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e3c4bb30b433c9026d87d28fc16a5afa93594401-1436x1396.png" style="width: 100%;" /><p>This can also be configured <a href="https://www.elastic.co/docs/api/doc/kibana/operation/operation-post-agent-builder-converse">through the API</a> by selecting the model you want to use:</p><pre><code>POST kbn://api/agent_builder/converse
{
 "input": "What is our holiday policy?",
 "agent_id": "internal-kb-bot",
 "connector_id": "Anthropic-Claude-Sonnet-4-5"
}</code></pre><p>This step relies primarily on retrieval quality. A lightweight model is sufficient to summarize a small set of documents quickly.</p><h3>Moderate capability</h3><p>More complex tasks may benefit from a more capable generation model, without necessarily requiring the most expensive reasoning model available.</p><ul><li>Task: “Compare our holiday policy with new labor laws in France and draft an email.”</li><li>Pattern: Retrieve relevant documents, compare policy details across sources, and generate output such as a draft email.</li><li>Model choice: More capable generation model.</li></ul><a href="https://videos.elastic.co/watch/ajCYrZDZBqSQ1WUhahiShk">Watch video on Vidyard</a><p>Here’s the API example:</p><pre><code>POST kbn://api/agent_builder/converse
{
 "input": "Compare our holiday policy with new labor laws in France and draft an email.",
 "agent_id": "internal-kb-bot",
 "connector_id": "Google-Gemini-2-5-Pro"
}</code></pre><p>This task requires synthesis across multiple sources and structured output but doesn’t need the heaviest frontier reasoning model.</p><h3>Investigation or audit task (high capability)</h3><ul><li>Task: Review a large document set to identify compliance risks.</li><li>Pattern: Multistep reasoning over large context, where the model evaluates information across many documents and synthesizes findings before producing a final judgment.</li><li>Model choice: Frontier or large-context model.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/54a500766139b7c39785eee38184a4479ee76e2b-1080x1080.gif" style="width: 100%;" /><p>Try it out using the API:</p><pre><code>POST kbn://api/agent_builder/converse
{
 "input": "What are the compliance risks associated with Example AI products?",
 "connector_id": "Anthropic-Claude-Opus-4-6"
}</code></pre><p>Because the task requires deeper reasoning and consistent evaluation across many inputs, output quality matters more. A high-capability model is therefore appropriate for this step.</p><p>EIS also enables more advanced orchestration patterns. Enterprises increasingly recognize that using a frontier model for every agent step is inefficient.</p><p>With Agent Builder and Elastic Workflows, teams can design agents where each subtask is executed by the most efficient model for the job, based on cost, complexity, and accuracy requirements.</p><h3>Models-as-judge pattern (quality control)</h3><ul><li>Task: Validate an agent’s output using a second model</li><li>Pattern: Generate and evaluate.</li></ul><p>In this <a href="https://www.elastic.co/blog/elastic-workflows-technical-preview">Elastic Workflow</a> example, the agent uses one model to generate a response and a second model to evaluate its quality, adding a validation layer for the result. Elastic Workflows, the automation engine built into Elasticsearch, let developers combine reliable scripted automation with AI-driven steps for tasks that require reasoning.</p><a href="https://videos.elastic.co/watch/FoSG66tYxtgshaWAUHZFHy">Watch video on Vidyard</a><p>The multimodel approach enables new reliability patterns by separating generation from evaluation, allowing one model to produce a response and another to validate it. Today, teams can implement this by pairing a general-purpose generation model with a lighter-weight evaluation model.</p><p>Over time, this pattern naturally lends itself to specialized judging and safeguard models designed specifically for validation, policy checks, and quality control. As these models become available, EIS makes it straightforward to introduce them into agent workflows without changing how inference is deployed or managed.</p><h2>What’s next</h2><p>EIS is actively evolving, with more models on the way. You can track what’s coming next and what we’re currently building on the <a href="https://github.com/orgs/elastic/projects/2066/views/2">Elastic public roadmap</a>.</p><h2>Get started</h2><p>Elastic Inference Service makes it easy to start with default models and evolve toward sophisticated, multimodel agent workflows over time, all within Elasticsearch. Whether you’re building global retrieval augmented generation (RAG) systems, search, or <a href="https://www.elastic.co/search-labs/blog/ai-agentic-workflows-elastic-ai-agent-builder">agentic workflows</a> that need reliable context, Elastic now gives you high-performance models out of the box, along with the operational simplicity to move from prototype to production with confidence.</p><p>All Elastic Cloud trials have access to Elastic Inference Service. Try it now on <a href="https://cloud.elastic.co/serverless-registration">Elastic Cloud Serverless</a> or Elastic Cloud Hosted, or use <a href="https://www.elastic.co/search-labs/blog/cloud-connect-elastic-inference-service">EIS via Cloud Connect</a> with your self-managed cluster.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Does MCP make search obsolete? Not even close]]></title>
            <description><![CDATA[Explore why search engines and indexed search remain the foundation for scalable, accurate, enterprise-grade AI, even in the age of MCP, federated search, and large context windows.]]></description>
            <link>https://www.elastic.co/search-labs/blog/future-of-search-engines-indexed-search-mcp</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/future-of-search-engines-indexed-search-mcp</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Dayananda Srinivas]]></dc:creator>
            <pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5b8af1c3ca51f2c038406c714eb9a71b696bbc5a-1999x1091.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>With the rise of large language models (LLMs), agent frameworks, and new protocols like Model Context Protocol (MCP), a provocative question is starting to surface:</p><blockquote><strong>Do we still need a search engine at all?</strong></blockquote><p>If agents can call tools on demand and models can reason over massive context windows, why not just fetch data live from every system and let the LLM figure it out?</p><p>It’s a reasonable question. It’s also the wrong conclusion.</p><p>The reality is that MCP and agent tooling don’t eliminate the need for search. They make the quality of search <strong>more critical than ever</strong>. In this blog, we’ll explore why MCP, federated search, and large context windows don’t replace search engines and why indexes remain the foundational layer for scalable, accurate, enterprise-grade AI.</p><h2><strong>What MCP actually is (and what it is not)</strong></h2><p>MCP is a <strong>coordination protocol</strong>. It standardizes how an agent requests information or actions from external systems.</p><p>What MCP <em>doesn’t</em> do:</p><ul><li>Rank results across systems.</li><li>Understand relevance across heterogeneous data.</li><li>Normalize schemas or metadata.</li><li>Data transformations or enrichments at scale.</li><li>Apply consistent security and permissions.</li><li>Optimize for latency, cost, or scale.</li></ul><p>In other words, <strong>MCP tells agents <em>how</em> to ask for data, not <em>which</em> data matters most</strong>.</p><h2><strong>Modern retrieval requires query intelligence, not just data access</strong></h2><p>In modern enterprise search architectures, retrieval quality is determined long before a query reaches an index. Raw queries — especially those generated by agents — may be incomplete, overly literal, schema-driven rather than intent-driven, and at times syntactically invalid.</p><p>This is why mature search platforms introduce a query intelligence layer that performs query rewriting, entity normalization, synonym expansion, and intent disambiguation before retrieval even begins.</p><p>For example, an agent-generated request such as: “Show severity 2 authentication failures from last sprint” may be rewritten to include authentication synonyms (login, SSO, OAuth), normalized severity mappings, and sprint-to-date-range translation. The result is not just more matches — it is more <em>relevant</em> matches.</p><p>In enterprise AI, retrieval is not a single step. It is a controlled pipeline.</p><p>This distinction is crucial because once MCP-based agents start pulling information live from multiple tools, they recreate a familiar pattern under a new name: <strong>federated search</strong>.</p><h2><strong>MCP-based retrieval is federated search in disguise</strong></h2><p>Federated search isn’t new. Enterprises have tried it for decades.</p><p>The model is simple:</p><ul><li>Send the user’s query to multiple systems in parallel (SharePoint, GitHub, Jira, customer relationship management [CRM]).</li><li>Collect the responses.</li><li>Merge and present the results.</li></ul><p>MCP-driven tool calls follow the same pattern, except that the caller is now an agent instead of a user interface.</p><p>And the same problems resurface.</p><h2><strong>Why federated search breaks down at enterprise scale</strong></h2><ul><li><strong>Latency becomes unpredictable:</strong> A federated query is only as fast as its slowest system. Enterprise systems can have wildly different response times and rate limits, so federated queries tend to be <strong>slow and jittery</strong>. Agents must wait for multiple round trips before reasoning can even begin. The result is a laggy experience and unpredictable wait times.</li><li><strong>Relevance is fragmented:</strong> Because each system ranks results on its own, there’s no unified relevance model. Federated search <strong>cannot apply a single ranking or semantic understanding across all content</strong>, so results often seem disjointed or incomplete. Agents may retrieve <em>correct</em> information but not the <em>most useful</em> information.</li><li><strong>Context is shallow and incomplete: </strong>Federated systems typically expose only what’s directly accessible through an API call.They rarely surface:<ul><li>Usage signals, like clicks, dwell time, recency of access, popularity, or authority.</li><li>Relationships between documents across different systems to correlate the insights.</li><li>Organizational knowledge beyond a single silo.<br/><br/>This strips agents of the broader context required for high-quality reasoning.<br/></li></ul></li><li><strong>Limited filtering and features:</strong> In a federated setup, you can only filter on fields that every system supports (the “lowest common denominator”). If one system doesn’t support a particular filter or facet, you lose that functionality entirely. This severely limits rich search features, like date ranges, categories, or tags.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/25bb778f4da9a3cb4f0d4e10af66221b8af73900-1376x768.jpg" style="width: 100%;" /><h2><strong>The power of an indexed search</strong></h2><p>Search engines achieve millisecond-level retrieval at massive scale by using specialized data structures, including inverted indexes for lexical search and k‑dimensional trees (k-d trees) for vector-based retrieval. The approach is to <strong>crawl or ingest every source into search engines</strong>, creating a central place of company knowledge. This brings big advantages:</p><ul><li><strong>Speed by design:</strong> Searching an index is lightning fast. Queries hit inverted indexes and specialized data structures, avoiding the need to poll each backend system.</li><li><strong>Relevance that compounds over time:</strong> Search engines that support <strong>semantic search </strong>are capable of comprehending the intent, and machine learning models can rerank results for enterprise contexts. In one Elastic <a href="https://www.elastic.co/blog/elastic-generative-ai-experiences?">experiment</a>, Elastic users see more accurate results when combining vector search with a question-answering (QA) model to extract answers. It gives better precision than keyword matching.</li><li><strong>Advanced features:</strong> Elastic’s <a href="https://www.elastic.co/search-labs/blog/rag-graph-traversal#:~:text=Retrieval,for%20deeper%2C%20more%20contextual%20retrieval">Graph retrieval augmented generation (RAG) solution</a> shows how structuring an index as a knowledge graph can power more contextual retrieval. In other words, indexes aren’t just backward-looking dumps of text; they can also encode relationships and ontologies that let AI connect the dots across documents.</li><li><strong>Permission-aware search:</strong> Enterprise AI cannot compromise on security. Indexed search allows:<ul><li><a href="https://www.elastic.co/docs/reference/search-connectors/document-level-security">Document-level security.</a></li><li><a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/user-roles#roles">Role-based access control.</a></li><li><a href="https://www.elastic.co/search-labs/blog/rag-and-rbac-integration">Permission-aware retrieval for RAG and agents.</a></li></ul></li></ul><p>Agents see only what users are allowed to see, without leaking data into model prompts or training. Elasticsearch is suitable for the indexed search layer in the diagram below, as it provides the essential components for context engineering.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8785e7806616273d086a90b3540273fb26d045ae-1392x768.jpg" style="width: 100%;" /><h2><strong>Retrieval consistency through search templates and governed execution</strong></h2><p>At scale, retrieval must be predictable, secure, and repeatable. This is where <a href="https://www.elastic.co/docs/solutions/search/search-templates">search templates</a> become critical.</p><p>Search templates act as retrieval contracts between applications, agents, and the search platform. Instead of dynamically constructing queries at runtime, agents invoke pre-defined retrieval patterns that enforce:</p><ul><li>Consistent relevance logic</li><li>Mandatory security filters</li><li>Cost and latency guardrails</li><li>Business-specific ranking rules</li><li>Explicit index and field scope boundaries</li></ul><p>In MCP-driven architectures, this becomes even more important. Agents should not dynamically invent retrieval strategies. Instead, MCP tool calls can map directly to approved search templates, ensuring that every retrieval request adheres to enterprise relevance and governance standards.</p><p>This approach shifts retrieval from ad-hoc query execution to controlled retrieval orchestration.</p><h2><strong>Retrieval is now a multi-layer engineering discipline</strong></h2><p>Modern enterprise retrieval is no longer a simple query-to-index operation. It typically includes multiple coordinated layers:</p><ul><li>Query understanding — rewriting, expansion, entity resolution</li><li>Retrieval strategy selection — hybrid search, vector search, graph retrieval, or synthetic query techniques such as Hypothetical Document Embeddings (HyDE), where the system generates a representative answer or expanded context first and retrieves documents using that richer semantic signal.</li><li>Execution governance — templates, security enforcement, and performance guardrails</li><li>Ranking and re-ranking — blending lexical precision, semantic similarity, and interaction-derived relevance signals such as click-through patterns, dwell time, and document usage frequency.</li></ul><p>When these layers are implemented upstream, agents receive clean, high-confidence context rather than raw, fragmented data.</p><p>This is what makes large-scale agent systems reliable in production environments.</p><h2><strong>Advanced retrieval techniques improve context quality before reasoning begins</strong></h2><p>Modern retrieval systems increasingly use AI-assisted techniques to improve recall and semantic coverage before ranking is applied.</p><p>One example is <a href="https://medium.com/@nirdiamant21/hyde-exploring-hypothetical-document-embeddings-for-ai-retrieval-cc5e5ac085a6">Hypothetical Document Embeddings (HyDE)</a>. Instead of embedding only the original query, the system first generates a hypothetical answer or expanded context, embeds that representation, and retrieves documents based on that richer semantic signal.</p><p>This is particularly useful in enterprise environments where:</p><ul><li>Users or agents may not know the exact terminology</li><li>Knowledge is distributed across silos</li><li>Important context is implied rather than explicitly stated</li></ul><p>Techniques like HyDE improve the probability that relevant documents are retrieved even when the original query is underspecified.</p><p>This reinforces a key principle of enterprise AI: better context retrieval produces better reasoning outcomes.</p><h2><strong>Agents aren’t data engineers; they’re reasoning systems</strong></h2><p>They shouldn’t be responsible for stitching together raw data, reconciling schemas, or compensating for poor retrieval.</p><p>This is where a search platform such as <strong>Elasticsearch</strong> becomes foundational.</p><p>By ingesting data once and normalizing it upstream (through pipelines, mappings, enrichment processors, and prebuilt indexes), Elasticsearch resolves schema mismatches, joins signals across sources, and materializes retrieval-ready views of the data. At query time, the agent receives clean, ranked, semantically enriched results rather than fragmented raw records.</p><p>For example, instead of an agent pulling independently from CRM, ticketing, and documentation systems and attempting to reconcile customer IDs, timestamps, and formats in real time, Elasticsearch can pre-index these sources into a unified customer interaction index with hybrid (keyword + vector) search and relevance ranking. The agent then queries a single, coherent interface and immediately reasons over the most relevant context.</p><p>This separation of concerns, that is, <strong>Elasticsearch handling data integration and retrieval, and agents focusing on reasoning, planning, and decision-making</strong>,<strong> </strong>is what makes agent systems scalable, reliable, and production ready.</p><h2><strong>Elastic’s role in the AI stack</strong></h2><p>Elastic sits at the intersection of search and AI by design.</p><ul><li><strong>Connectors and crawlers</strong> ingest data continuously from enterprise systems.</li><li><strong>Semantic and vector search</strong> enable intent-based retrieval.</li><li><strong>Hybrid search</strong> blends lexical precision with semantic understanding.</li><li><strong>RAG workflows</strong> ground LLMs in authoritative, permission-aware data.</li></ul><p>Elastic does not compete with agents or MCP. It <strong>makes them effective</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/193da239544ce858416db845f9fc34c7c0e9b6f9-1920x1080.png" style="width: 100%;" /><h2><strong>Bigger models don’t eliminate retrieval</strong></h2><p>Some have wondered whether huge new LLMs can bypass traditional search, perhaps by letting the model read <em>everything</em> in one go. Large context windows feel powerful, but they introduce:</p><ul><li>Higher latency.</li><li>Higher cost.</li><li>Lower precision due to noise.</li><li>A higher propensity for confusion, context clash, and context poisoning.</li></ul><p>RAG wins because it filters first and then reasons.In another <a href="https://www.elastic.co/search-labs/blog/rag-vs-long-context-model-llm#:~:text=,context%20approach%20led%20to%20inaccuracies">Elastic Search Labs experiment</a>, RAG achieved answers in about <strong>1 second</strong>, versus 45 seconds for the raw-LM approach, at <strong>1/1250th</strong> the cost, and with far higher accuracy. In other words, giving an LLM a million tokens of documents is slower, more expensive, and actually <em>less precise</em> than filtering through an index first.</p><h2><strong>Conclusion: MCP changes the interface, not the fundamentals</strong></h2><p>MCP is a meaningful step forward in how agents interact with tools. But it doesn’t replace the need for fast, relevant, governed retrieval.</p><p>In enterprise AI:</p><ul><li>Context quality determines answer quality.</li><li>Indexes create that context.</li><li>Search is the foundation, not the legacy.</li></ul><p>Indexes aren’t obsolete in the era of MCP. They’re <strong>the reason that MCP-based agents can work at all</strong>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Entity resolution with Elasticsearch, part 3: Optimizing LLM integration with function calling]]></title>
            <description><![CDATA[Learn how function calling enhances LLM integration, enabling a reliable and cost-efficient entity resolution pipeline in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-function-calling</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-function-calling</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
            <pubDate>Wed, 04 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b2afa90c1b863c716008f3f5bbdd2866fa1c3577-720x420.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In <a href="https://www.elastic.co/search-labs/blog/entity-resolution-llm-elasticsearch">part 1</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-semantic-search">part 2</a> of this series, we built a complete entity resolution pipeline that included preparing entities with context and indexing them for semantic search, extracting entities from articles using hybrid named entity recognition (NER), and matching entities using semantic search and large language model (LLM) judgment. The results were promising, but JSON parsing errors significantly lowered measured accuracy by causing otherwise valid judgments to be discarded. The system wasn’t failing because it made bad judgments; it was failing because it couldn’t reliably express them.</p><p>The root of this problem was our somewhat naive choice to use prompt-based JSON generation in which the LLM generates JSON responses in text format. If we asked the LLM to judge more than a couple of matches at a time, the generated JSON was often ill-formed. To mitigate this, we were forced to reduce the processing batch size, which simply won&#x27;t scale in a production system.</p><p>So the prompt-based JSON generation helped validate our approach to entity resolution, but we need a more systematic and reliable method. OpenAI function calling provides a better path by guaranteeing structure and type safety while reducing errors and costs. We chose OpenAI&#x27;s functions for the educational prototype, but other LLM providers typically provide similar functionality (for example, Claude tools).</p><p><strong>Note:</strong> While we discuss production challenges here, this is still an educational prototype demonstrating optimization techniques. Real production systems would need additional considerations, like monitoring, alerting, fallback strategies, and comprehensive error handling.</p><h2>Key concepts: Function calling, schema design, and cost benefits</h2><p><strong>What is function calling?</strong> <em>Function calling</em> is OpenAI&#x27;s structured output API. With it, we can define schemas for LLM responses, so we always know exactly what we&#x27;re going to get. By enforcing the JSON format rather than trying to define it in the LLM prompt, we should be able to eliminate parsing errors.</p><p><strong>Why is it better than prompt-based JSON?</strong> LLMs generate nondeterministic output. One hopes that they&#x27;ll at least generate content that contains the correct response, but the presentation of that response is unpredictable. With a chatbot, this is often not a problem, but our prototype is trying to programmatically process the output. Computer programs demand consistency, so when the LLM generates what we expect, everything is fine, but as soon as it goes off script, so to speak, the code errors out. We could try to account for the different possibilities, but it would be very difficult to catch everything. We could try to enforce more consistent behavior by adding something like &quot;Always return parsable JSON&quot;. We tried this exact technique in the prototype&#x27;s prompt, but we&#x27;ve seen that prompt-based JSON still goes off the rails pretty quickly, particularly if we try to process a batch of matches.</p><p>Function calling makes the LLM generation controllable and predictable, exactly what we need for entity resolution. To aid in the definition of the functions, we’ll also follow minimal schema design principles.</p><p><strong>What are minimal schema design principles?</strong> <em>Minimal schema design</em> means defining only the fields you need, using simple types, and avoiding nested structures when possible. This reduces token usage (smaller schemas mean fewer tokens), improves reliability (simpler schemas are easier for the LLM to follow), and lowers costs (fewer tokens mean lower API costs).</p><p><strong>What are the cost and reliability benefits?</strong> Since fewer errors means match processing is much more likely to succeed, even with large batch sizes, we don&#x27;t have to retry judging matches. The elimination of retries reduces costs by reducing token usage, but using minimal schemas also keeps our token count down. This all leads to a less expensive and more reliable approach that’s much more suitable to use in production.</p><p>We need to check one more thing, though. While matches may be getting processed without error, are the errorless results actually correct? How does this new approach compare to the promising results we saw with the prompt-based approach?</p><h2>Real-world results: Side-by-side comparison</h2><p>As we did in the previous blog, we ran the function calling approach against the tier 4 dataset, which consists of 206 expected matches across 69 articles. The results demonstrate a dramatic improvement:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric</td><td style="border: 1px solid #ccc; padding: 4px;">Prompt-based</td><td style="border: 1px solid #ccc; padding: 4px;">Function calling</td><td style="border: 1px solid #ccc; padding: 4px;">Improvement</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Error rate</td><td style="border: 1px solid #ccc; padding: 4px;">30.2%</td><td style="border: 1px solid #ccc; padding: 4px;">0.0%</td><td style="border: 1px solid #ccc; padding: 4px;">100% elimination</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Precision</td><td style="border: 1px solid #ccc; padding: 4px;">83.8%</td><td style="border: 1px solid #ccc; padding: 4px;">90.3%</td><td style="border: 1px solid #ccc; padding: 4px;">+6.5pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Recall</td><td style="border: 1px solid #ccc; padding: 4px;">62.6%</td><td style="border: 1px solid #ccc; padding: 4px;">90.8%</td><td style="border: 1px solid #ccc; padding: 4px;">+28.2pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">F1 score</td><td style="border: 1px solid #ccc; padding: 4px;">71.7%</td><td style="border: 1px solid #ccc; padding: 4px;">90.6%</td><td style="border: 1px solid #ccc; padding: 4px;">+18.9pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Acceptance rate</td><td style="border: 1px solid #ccc; padding: 4px;">44.8%</td><td style="border: 1px solid #ccc; padding: 4px;">60.2%</td><td style="border: 1px solid #ccc; padding: 4px;">+15.4pp</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">True positives</td><td style="border: 1px solid #ccc; padding: 4px;">129</td><td style="border: 1px solid #ccc; padding: 4px;">187</td><td style="border: 1px solid #ccc; padding: 4px;">+45.0%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">False negatives</td><td style="border: 1px solid #ccc; padding: 4px;">77</td><td style="border: 1px solid #ccc; padding: 4px;">19</td><td style="border: 1px solid #ccc; padding: 4px;">-75.3%</td>
                    </tr>
              </tbody>
            </table>
          <h3>Error elimination: The key differentiator</h3><p>The most striking difference is the <strong>complete elimination of JSON parsing errors</strong>. This resulted in a modest precision improvement and a far more dramatic recall improvement. The precision metric captures how often the matches the system accepts were expected in the golden document. So the prototype was decent at judging matches correctly in the prompt-based approach, but function calling does that even better.</p><p></p><p>Conversely, recall tells us how many of the expected matches were found. When a batch of matches comes back with malformed JSON, the system loses all of those matches. It&#x27;s likely that Elasticsearch sends many of these matches for judgment, but we lose those matches if judgment fails. The significant recall improvement shows that this hypothesis is correct. Elasticsearch identifies the potential matches and function calling verifies which of those matches are correct.</p><p></p><p><strong>Note:</strong> It’s expected that Elasticsearch will find some incorrect matches because we look at the top two or three results from hybrid search. Most of the time, hybrid search returns the correct match as the top result, but having the LLM judge the top few hits ensures that we see how the LLM handles incorrect matches. If we move from the educational prototype to a production system, we’ll likely tune the Elasticsearch queries more carefully so that we only send promising matches to the LLM, further optimizing our LLM costs.</p><h2>What&#x27;s next: The ultimate challenge</h2><p>Now that we&#x27;ve optimized our LLM integration with function calling, we have a complete entity resolution pipeline with improved reliability and cost efficiency. However, can it handle the ultimate challenge? In the next post, we&#x27;ll explore how the system handles diverse entity resolution scenarios across 50 different challenge types, including cultural naming conventions, business relationships, titles, and multilingual variations.</p><h2>Try it yourself</h2><p>Want to see function calling optimization in action? Check out the <a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=5%20minutes%20ago-,04_function_calling_optimization_v3.ipynb,-Initial%20public%20lab">Function Calling Optimization notebook</a> for a complete walkthrough with real implementations, detailed explanations, and hands-on examples. The notebook shows you exactly how to use function calling for structured output, compare it with prompt-based JSON, and analyze cost and reliability benefits.</p><p><strong>Remember:</strong> This is an educational prototype designed to teach optimization concepts. When building production systems, consider additional factors, like multi-provider support, advanced caching strategies, monitoring and alerting, comprehensive error handling, and compliance requirements that aren&#x27;t covered in this learning-focused prototype.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using subagents and Elastic Agent Builder to bring business context into code planning]]></title>
            <description><![CDATA[Learn about subagents, how to ensure they have the right information, and how to create a specialized subagent that connects Claude Code to your Elasticsearch data.]]></description>
            <link>https://www.elastic.co/search-labs/blog/subagents-with-elastic-agent-builder</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/subagents-with-elastic-agent-builder</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
            <pubDate>Tue, 03 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bfeb78b687bd930371364ee7dd0341ae90004349-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://code.claude.com/docs/en/sub-agents">Subagents in Claude Code</a> let you offload specialized tasks to separate context windows, keeping your main conversation focused. In this article, you&#x27;ll learn what subagents are, when to use them, and how to build a retrieval subagent using Elastic Agent Builder that connects your development workflow to business data in Elasticsearch.</p><h2>What are subagents?</h2><p><em>Subagents </em>are specialized assistants that can be called to execute a specific task, using their own context window. They complete a task and give the results to the main agent, preventing it from saving information that isn’t relevant for the rest of the conversation in the context window.</p><p>Their four core principles are:</p><ul><li><strong>Context preservation:</strong> Each subagent uses its own context window.</li><li><strong>Specialized expertise:</strong> Each subagent is designed for a specific task.</li><li><strong>Reusability:</strong> You can reuse a subagent in different sessions and projects.</li><li><strong>Flexible access:</strong> You can limit the subagent access to specific tools.</li></ul><p>Each subagent can have access to Claude Code tools to work with the terminal, such as glob, read, write, grep, or bash, or to access the internet, like search, fetch, or call external tools with Model Context Protocol (MCP) servers.</p><p>A subagent uses the following schema:</p><pre><code>---
name: your-sub-agent-name
description: Description of when this subagent should be invoked
tools: tool1, tool2, tool3  # Optional - inherits all tools if omitted
model: sonnet  # Optional - specify model alias or 'inherit'
permissionMode: default  # Optional - permission mode for the subagent
skills: skill1, skill2  # Optional - skills to auto-load
---

Your subagent's system prompt goes here. This can be multiple paragraphs
and should clearly define the subagent's role, capabilities, and approach
to solve problems.

Include specific instructions, best practices, and any constraints
the subagent should follow.</code></pre><p>You can call subagents implicitly by talking about the task they run, and Claude will call them automatically. For example, you can say, &quot;I want to plan my new functionality.&quot;</p><p>You can also call them explicitly by directly asking Claude Code to use a subagent and telling it, &quot;Use the planning subagent to plan my new functionality.&quot;</p><p>Another important feature is that subagents are stateful, so once you give one a task, it will generate an ID. This way, when you use it again, you can start from scratch or provide the ID to give it context from its previous tasks.</p><p>You can read the <a href="https://code.claude.com/docs/en/sub-agents">full documentation here</a>.</p><h2>When are subagents used?</h2><p>Subagents are useful when you need to delegate tasks that require specialized context but you don&#x27;t want to clutter the main chat window. Considering our example of coding, the most common subtasks include:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Subtask type</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td><td style="border: 1px solid #ccc; padding: 4px;">Typical tools</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Exploration / research</td><td style="border: 1px solid #ccc; padding: 4px;">Searching and analyzing code without modifying it.</td><td style="border: 1px solid #ccc; padding: 4px;">Read, grep, glob</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Planning</td><td style="border: 1px solid #ccc; padding: 4px;">Running deep analysis to create implementation plans.</td><td style="border: 1px solid #ccc; padding: 4px;">Read, grep, glob, bash</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Code review</td><td style="border: 1px solid #ccc; padding: 4px;">Reviewing quality, safety, and best practices.</td><td style="border: 1px solid #ccc; padding: 4px;">Read, grep, glob, bash</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Code modification</td><td style="border: 1px solid #ccc; padding: 4px;">Writing and editing code.</td><td style="border: 1px solid #ccc; padding: 4px;">Read, edit, write, grep, glob</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Testing / debugging</td><td style="border: 1px solid #ccc; padding: 4px;">Running tests and analyzing issues.</td><td style="border: 1px solid #ccc; padding: 4px;">Bash, read, grep, edit</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Retrieval</td><td style="border: 1px solid #ccc; padding: 4px;">Getting information from external sources (APIs, databases).</td><td style="border: 1px solid #ccc; padding: 4px;">MCP tools, bash</td>
                    </tr>
              </tbody>
            </table>
          <p>Claude Code includes three built-in agents that showcase these use cases:</p><p></p><ul><li><strong>Explore:</strong> Quick agents for read-only search in the codebase. It&#x27;s great for answering questions like, &quot;Where are the client&#x27;s errors handled?&quot;</li><li><strong>Plan:</strong> Research agent that activates in plan mode to analyze the codebase before proposing changes.</li><li><strong>General-purpose:</strong> The most capable agent for complex tasks that require multiple steps and can include modifications.</li></ul><h2>Context management: Ensuring subagents have the right information</h2><p>One of the most important decisions when designing subagents is how to handle context. There are three key considerations:</p><h3><strong>1. Which context the subagent should get</strong></h3><p>The prompt you give to the subagent must contain all of the necessary information to complete the task since the subagent doesn’t have access to the main chat. You need to be specific:</p><ul><li>Do NOT say, &quot;Review the code.&quot;</li><li>SAY, &quot;Review the changes to src/auth/index.ts, focusing on JWT token validation.&quot;</li></ul><p>Providing the exact file name makes a difference between using the read tool against the file directly and making a wide search using grep and thus wasting time and tokens.</p><p>Also consider what not to include. Irrelevant context can distract the subagent or bias results. It’s tempting to ask for multiple things in one pass, but focused tasks yield better results:</p><ul><li>Do NOT say, “Review src/auth/<a href="http://index.ts">index.ts</a>. Here is also the database schema and our API docs for reference, fix bugs and suggest improvements about the architecture decisions.”</li><li>SAY, “Fix the token refresh bug in src/auth/index.ts that&#x27;s throwing AUTH_TOKEN_EXPIRED unexpectedly.”</li></ul><h3><strong>2. What tools to provide</strong></h3><p>Limit the tools to what’s strictly necessary. This improves security, keeps the subagent focused, and reduces unnecessary tool calls and execution costs.</p><pre><code># For just an analysis agent
tools: Read, Grep, Glob

# For an agent that needs to modify the code
tools: Read, Edit, Write, Grep, Glob</code></pre><p>If you don&#x27;t specify a tools field, the subagent inherits all tools from the main agent, including MCP tools.</p><p>You can learn all Claude Code tools <a href="https://code.claude.com/docs/en/how-claude-code-works#tools">here</a>.</p><h3><strong>3. How to keep context between calls</strong></h3><p>Subagents can be resumed using their agentId:</p><pre><code># First call
> Use the code-analyzer agent to review the authentication module
[Agent completes the analysis and returns agentId: "abc123"]

# Continue with previous context
> Resume agent abc123 and now analyze the authorization module
[Agent continues with the context from the previous chat]</code></pre><p></p><p>You can ask Claude for the agent ID or find it in <code>~/.claude/projects/{project}/{sessionId}/subagents/</code></p><p>This is especially useful for long research tasks or multistep workflows.</p><p>Another way to keep context consistent is to ask the agent to write a Markdown checklist with what it&#x27;s doing and its current progress. Then you can execute <code>/clear</code> without losing the initial instruction. In that request, you can define the task granularity or details to retain that make sense for your use case.</p><pre><code># Task: Review authentication module

## Progress
- [x] Analyzed src/auth/index.ts
- [x] Found JWT validation issue
- [ ] Review authorization module
- [ ] Check rate limiting

## Findings
- Token refresh has race condition in line 42</code></pre><p>After you clear the conversation, the next agent can pick it up from here. This is very useful when you want an agent to run a script over a list and watch the output record by record.</p><h2>Orchestration patterns</h2><p>It’s important to see subagents as a context optimization mechanism. The way in which you coordinate them determines the efficiency of the whole system. There are different orchestration patterns.</p><h3><strong>Sequential (chaining)</strong></h3><p>Here, a subagent completes a task, and its results feed the next one in a sequence of tasks, similar to traditional Linux piping.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/74a3a376600cd1b7cdd2dddddfed2f00ab131eed-896x94.png" style="width: 100%;" /><p>Call example:</p><pre><code>> First use the planning agent to design the feature,
> then use the coding agent to implement it,
> finally use the reviewer agent to check the code</code></pre><h3><strong>Parallel</strong></h3><p>In this pattern, multiple subagents run independent tasks simultaneously. The main Claude Code agent invokes them since <strong>subagents cannot spawn other subagents</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/84eca68d29bf79cf978a8089d3c18972738cd2c1-595x272.png" style="width: 100%;" /><p>This approach reduces the execution time for tasks like code review since it allows you to work with the same code from different angles without impacting the running time.</p><h3><strong>Hub-and-spoke (delegation)</strong></h3><p>In this approach, the main agent acts as an orchestrator, delegates tasks to specialized agents, and then consolidates the results.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/93bb2cc55c435f509b426fbcc090a67c53021684-595x272.png" style="width: 100%;" /><p>This is the pattern we’ll implement in our example: The main Claude Code agent will delegate the gathering of business information to a retrieval agent built with Elastic Agent Builder, while the explore agent will look into local files and the planning agent builds a plan.</p><h2>Why use an agent instead of a single query?</h2><p>Before building our retrieval subagent, it&#x27;s worth understanding when an agent adds value versus when a simple Elasticsearch Query Language (ES|QL) query suffices.</p><p>If you need a single aggregation, like &quot;What&#x27;s our most visited page?&quot; just run the query directly. The agent adds value when your question requires:</p><ul><li><strong>Multiple queries that build on each other:</strong> The answer from query 1 informs query 2.</li><li><strong>Cross-index reasoning:</strong> Correlating data from different sources.</li><li><strong>Ambiguity resolution:</strong> The agent interprets and follows leads.</li><li><strong>Synthesis:</strong> Combining quantitative data with qualitative knowledge.</li></ul><p>Our example will demonstrate all of these capabilities.</p><h2>Agent Builder as subagent</h2><p>Generating code using AI is very quick, but the problem is having a good planning phase to set the boundaries for our coding agent. To help with that, Claude created a subagent that <a href="https://code.claude.com/docs/en/common-workflows#use-plan-mode-for-safe-code-analysis">specializes in planning</a> to perform deep analysis and create a to-do list for the main agent to execute.</p><p>With this flow, you can plan based on what Claude Code can see both in local files and on the internet. However, there&#x27;s still knowledge available in Elasticsearch that you cannot access via standard tools.</p><p>To access our internal knowledge during the planning phase, we&#x27;ll create a Claude Code subagent by making a retrieval agent using Agent Builder.</p><p>You can configure the agent using the UI or an API. In this example, we&#x27;ll use the latter.</p><h3><strong>Prerequisites</strong></h3><ul><li><a href="https://code.claude.com/docs/en/setup">Claude Code</a> 2.0.76+</li><li>Elasticsearch 9.2</li><li>Elasticsearch <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys">API key</a></li></ul><h3><strong>The scenario: Technical debt sprint planning</strong></h3><p>You&#x27;re a tech lead. You have two weeks and two developers. Your <code>TECH_DEBT.md</code> lists 12 items. You can tackle maybe three or four. Which ones should you prioritize?</p><p>The complexity is that you need to optimize across multiple dimensions simultaneously:</p><ul><li><strong>User impact:</strong> How many users hit this issue?</li><li><strong>Business impact:</strong> Does it affect paying customers? Enterprise tier?</li><li><strong>Severity:</strong> Errors? Performance? Just ugly code?</li><li><strong>Effort:</strong> Quick win or rabbit hole?</li><li><strong>Dependencies:</strong> Does fixing A unlock fixing B?</li><li><strong>Strategic alignment:</strong> Does it align with Q1 priorities?</li></ul><p>A single query like, &quot;What&#x27;s the most important tech debt item?&quot; fails because this requires:</p><ol><li>Reading <code>TECH_DEBT.md</code> to understand what the 12 items even are.</li><li>For EACH item, querying <code>error_logs</code>to get error frequency.</li><li>Cross-referencing with <code>customer_data</code> to see tier breakdown.</li><li>Checking <code>support_tickets</code>to see complaint volume.</li><li>Reading <code>engineering_standards</code> in the knowledge base to see whether any items violate core principles.</li><li>Reading <code>Q1_roadmap</code> to check strategic alignment.</li><li>Synthesizing all of this into a prioritized recommendation.</li></ol><p>This is where a retrieval agent can be helpful in orchestrating multiple queries across different indices and synthesizing the results.</p><h2>Steps</h2><h3><strong>Preparing the test dataset</strong></h3><p>We&#x27;ll create four indices: a knowledge base with internal documentation, error logs, support tickets, and customer data.</p><p>You can create the indices, index the data, and create the agent using one of the following:</p><ul><li><strong>Kibana Dev Tools:</strong> Using the Elasticsearch requests provided below.</li><li><strong>Jupyter Notebook:</strong> Using the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/notebook.ipynb">complete notebook</a> written for this article.</li></ul><h2>Create the indices</h2><p>Open Kibana Dev Tools, and run the following requests to create each index with its mapping and bulk data. Here&#x27;s an example of the knowledge index structure and data to be indexed:</p><pre><code>PUT customer_data
{
  "mappings": {
    "properties": {
      "user_id": { "type": "keyword" },
      "customer_tier": { "type": "keyword" },
      "company_name": { "type": "text" },
      "mrr": { "type": "float" },
      "joined_at": { "type": "date" }
    }
  }
}

POST customer_data/_bulk
{"index":{}}
{"user_id":"enterprise_user_01","customer_tier":"enterprise","company_name":"Acme Corp","mrr":2500.00,"joined_at":"2023-01-15"}
{"index":{}}
{"user_id":"enterprise_user_02","customer_tier":"enterprise","company_name":"GlobalTech Inc","mrr":4200.00,"joined_at":"2022-08-20"}
{"index":{}}
{"user_id":"enterprise_user_05","customer_tier":"enterprise","company_name":"DataFlow Systems","mrr":3100.00,"joined_at":"2023-06-01"}
{"index":{}}
{"user_id":"user_001","customer_tier":"free","company_name":"","mrr":0,"joined_at":"2024-03-15"}
{"index":{}}
{"user_id":"user_002","customer_tier":"free","company_name":"","mrr":0,"joined_at":"2024-05-20"}
{"index":{}}
{"user_id":"user_045","customer_tier":"pro","company_name":"SmallBiz LLC","mrr":49.00,"joined_at":"2024-01-10"}
{"index":{}}
{"user_id":"user_089","customer_tier":"pro","company_name":"StartupXYZ","mrr":49.00,"joined_at":"2024-02-28"}</code></pre><p>Full requests for all indices:</p><ul><li><strong>Knowledge index:</strong> <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/elasticsearch_requests/knowledge.txt">knowledge.txt</a></li><li><strong>Error logs index:</strong> <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/elasticsearch_requests/error_logs.txt">error_logs.txt</a></li><li><strong>Support tickets index:</strong> <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/elasticsearch_requests/support_tickets.txt">support_tickets.txt</a></li><li><strong>Customer data index:</strong> <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/elasticsearch_requests/customer_data.txt">customer_data.txt</a></li></ul><p>The raw JSON files with the dataset are also available:</p><ul><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/dataset/knowledge.json">knowledge.json</a></li><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/dataset/error_logs.json">error_logs.json</a></li><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/dataset/support_tickets.json">support_tickets.json</a></li><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/dataset/customer_data.json">customer_data.json</a></li></ul><h2>Local project files</h2><p>Create the following Markdown (MD) files in your project. These files look like this:</p><pre><code># Tech Debt Items

## AUTH-001: Token refresh race condition
- **Module**: src/auth/refresh.ts
- **Symptom**: Users randomly logged out
- **Estimate**: 3 days

## EXPORT-002: CSV export timeout on large datasets
- **Module**: src/export/csv.ts
- **Symptom**: Timeout after 30s for >10k rows
- **Estimate**: 2 days

...</code></pre><p>Full files:</p><p></p><ul><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/TECH_DEBT.md">TECH_DEBT.md</a>: Tech debt items list.</li><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/REQUIREMENTS.md">REQUIREMENTS.md</a>: FlowDesk Q1 2025 requirements.</li></ul><p>This ties directly to the tech debt items and gives the agent clear priorities to work with when cross-referencing with the Elasticsearch data.</p><h2>Create an agent with Agent Builder</h2><p>We&#x27;ll now create an agent capable of running analytics queries with ES|QL to provide us with app usage information while also capable of searching to provide us info from Knowledge Base (KB) in unstructured text format.</p><p>We&#x27;re using the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools#built-in-tools">built-in tools</a> since they cover search and analytics on any index. Agent Builder also supports custom tools for more specialized operations, like scoping an index or adding ES|QL dynamic parameters, but that&#x27;s beyond our scope here.</p><p>You can create the agent using the curl request in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/elasticsearch_requests/create_agent.txt">create_agent.txt</a>.</p><pre><code>curl -X POST "https://${KIBANA_URL}/api/agent_builder/agents" \
  -H "Authorization: ApiKey ${API_KEY}" \
  -H "kbn-xsrf: true" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "tech-debt-advisor",
    "name": "Tech Debt Prioritization Agent",
    "description": "I help prioritize technical debt by analyzing error logs, support tickets, customer impact, and aligning with engineering standards and roadmap priorities.",
    "avatar_color": "#BFDBFF",
    "avatar_symbol": "TD",
    "configuration": {
      "instructions": "This agent helps prioritize technical debt items. Use the following indices:\n\n- knowledge: Engineering standards, policies, and roadmap priorities\n- error_logs: Production error frequency by module\n- support_tickets: Customer complaints and their urgency\n- customer_data: Customer tier information (enterprise, pro, free)\n\nWhen analyzing tech debt:\n1. Check error frequency in error_logs\n2. Cross-reference affected users with customer_data to understand tier impact\n3. Count support tickets and note urgency markers\n4. Check knowledge base for relevant policies and Q1 priorities\n5. Synthesize findings into prioritized recommendations",
      "tools": [
        {
          "tool_ids": [
            "platform.core.search",
            "platform.core.list_indices",
            "platform.core.get_index_mapping",
            "platform.core.get_document_by_id",
            "platform.core.execute_esql",
            "platform.core.generate_esql"
          ]
        }
      ]
    }
  }'</code></pre><p>You’ll get this response if everything went OK:</p><pre><code>{
  "id": "tech-debt-advisor",
  "type": "chat",
  "name": "Tech Debt Prioritization Agent",
  "description": "I help prioritize technical debt by analyzing error logs, support tickets, customer impact, and aligning with engineering standards and roadmap priorities.",
  ...
}</code></pre><p>The agent will be available in Kibana, so you can now chat with it if you want:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c655ec6b9b1cc2fa1ab3cc13d289e7b96a543284-815x784.png" style="width: 100%;" /><h3><strong>Configure the agent as Claude Code tool</strong></h3><p>The agent we just created will expose an <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">MCP server.</a> Let&#x27;s add the MCP server to Claude Code using the already-generated API key:</p><pre><code>claude mcp add --transport http agentbuilder https://${KIBANA_URL}/api/agent_builder/mcp --header "Authorization: ApiKey ${API_KEY}"</code></pre><p>We can check the connection status using <code>claude mcp get agentbuilder</code>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ba5fbc144f9e29151b8628dffd33dc74b12deece-499x177.png" style="width: 100%;" /><h3><strong>Create a subagent that uses the tool</strong></h3><p></p><p>Now that we have the Agent Builder available as a set of MCP tools, we can create a subagent in Claude Code that will use all or some of those tools, in combination with Claude Code ones.</p><p></p><p>Claude Code recommends using its agent creator tool for this step:</p><p></p><p>1. Type <code>/agents</code> in Claude Code.</p><p>2. Choose <strong>Create new agent</strong>.</p><p>3. Select <strong>Project scope</strong> so that it&#x27;s only available for this project. (This is the recommended setting to avoid agent overflow.)</p><p>4. Select <strong>Generate with Claude (recommended)</strong>.</p><p>5. Type in the description: &quot;Agent that analyzes technical debt by querying Elasticsearch for error logs, support tickets, customer data, and engineering knowledge base. Use this agent when you need to prioritize tech debt items based on business impact.&quot;</p><p>6. In “Select tools,” choose <strong>Advanced options</strong> and select the tools we defined on the agent creation.</p><pre><code>Individual Tools:
☒ platform.core.search (agentbuilder)
☒ platform.core.list_indices (agentbuilder)
☒ platform.core.get_index_mapping (agentbuilder)
☒ platform.core.get_document_by_id (agentbuilder)
☒ platform.core.execute_esql (agentbuilder)
☒ platform.core.generate_esq (agentbuilder)</code></pre><p>7. Select <strong>[ Continue ]</strong>.</p><p>Now choose the model. For planning tasks, the recommendation is to use Opus due to its significant reasoning capacity. So let&#x27;s select that and continue.</p><p>Finally, choose the background color for our subagent text and confirm.</p><p>Claude automatically names our subagent based on the description (for example, <code>tech-debt-analyzer</code>).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f01ac4c9f30fbcbed7fc69881aae9ff72c4616a0-869x521.png" style="width: 100%;" /><h2>Testing the agent</h2><p>Once the agent has been created, we can test it with a complex prioritization question that requires multistep reasoning:</p><pre><code>> Based on TECH_DEBT.md, which items should we prioritize for our 2-week sprint?
> Use the tech-debt-analyzer agent to check error frequency, customer impact,
> support ticket volume, and alignment with engineering standards.</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d76d972ab5b07e6d35bdf3036cb5ee3c080c7156-749x239.png" style="width: 100%;" /><p>Watch how the agent orchestrates multiple queries:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c49b56c366576406586ba03f694d2bfb09d30895-875x96.png" style="width: 100%;" /><p>And will give you a comprehensive analysis of the local files combined with Elasticsearch data:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/084c532b4c9e993e53810738ae1da1fd4af1f025-1228x693.png" style="width: 100%;" /><p>This demonstrates why a single query fails and an agent succeeds: It orchestrates five or more queries across different indices, correlates the data, and synthesizes a recommendation that contradicts the naive &quot;fix highest error count&quot; approach.</p><p>By typing <code>/context</code>, we can see how much context each of the MCP tool&#x27;s definitions uses and our subagent&#x27;s prompt. Keep an eye on this overhead when creating subagents.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3a6f528a9cae7b7fbf17f1b97e13c51c78c1b8b4-666x391.png" style="width: 100%;" /><h2>Start planning</h2><p>We can now start planning using local files, the internet, and our Elasticsearch knowledge as information sources.</p><p>Ask something like:</p><pre><code>"Based on our requirements defined in REQUIREMENTS.md, use the planning agent
to create a detailed implementation plan, prioritizing tasks according to
business impact. Use the tech-debt-analyzer agent to query about internal
company knowledge and make analytical queries about error patterns and
customer impact."</code></pre><p>Note that Claude decides to run the Elasticsearch data analysis and the local documentation reading in parallel, following the hub-and-spoke orchestration pattern.</p><p>After the analysis, you should get a plan that prioritizes based on actual business data rather than on assumptions. This context will make your AI coding experience much more reliable, as you can feed this plan directly to the agent and execute step by step:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cfa5c6c44425d6e73355116e08082a33699915a3-961x873.png" style="width: 100%;" /><p>The more details you provide and the more focused the instructions are, the better the quality of the plan will be. If you have an existing codebase, it will suggest the code changes.</p><h2>Conclusion</h2><p>Subagents are a great tool to offload specific tasks where we only need the final result for the main chat (without going through how we got there), keeping the chat flow focused.</p><p>By choosing the right orchestration pattern (sequential, parallel, or hub-and-spoke) and handling the context properly, we can build efficient and maintainable agent systems.</p><p>Elastic Agent Builder and its MCP feature allow us to access our data using a retrieval subagent to facilitate planning and coding by combining local (files, source code), external (internet), and internal (Elasticsearch) sources. The key insight is that agents add value not for simple queries but when you need multistep reasoning that builds on previous results and synthesizes information from multiple sources.</p><h2>Resources</h2><ul><li><a href="https://code.claude.com/docs/en/sub-agents">Claude Code Subagents</a></li><li><a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a></li><li><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Agent Builder MCP</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Adaptive early termination for HNSW in Elasticsearch]]></title>
            <description><![CDATA[Introducing a new adaptive early termination strategy for HNSW in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/hnsw-elasticsearch-adaptive-early-termination</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hnsw-elasticsearch-adaptive-early-termination</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tommaso Teofili]]></dc:creator>
            <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e6d3186f609dd56dc5ffe33d70fa9e5cfa05b51f-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch uses the <a href="https://www.elastic.co/search-labs/blog/hnsw-graph">Hierarchical Navigable Small World</a> (HNSW) algorithm to perform vector search over a proximity graph. HNSW is known to provide a nice trade-off between the quality of k-nearest neighbor (KNN) results and the associated cost.</p><p>In HNSW, search proceeds by iteratively expanding candidate nodes in the graph, maintaining a bounded set of nearest neighbors discovered so far. Each expansion has a cost (vector operations, random seeks to disk, and more), and the marginal benefit of that cost tends to decrease as the search progresses.</p><p>One way to optimize HNSW graph traversal is to stop searching when the marginal likelihood of finding new true neighbors doesn’t increase. For this reason, in <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules#index-dense-vector-hnsw-early-termination">Elasticsearch 9.2</a> we introduced a new <a href="https://www.elastic.co/search-labs/blog/hnsw-knn-search-early-termination">early termination mechanism</a>. This stops the search process when visiting graph nodes doesn’t provide enough new nearest neighbors, consecutively, for a fixed number of times.</p><p>This article guides you through how we improved over the mentioned early termination mechanism in HNSW to make it better suited for different datasets and data distributions.</p><h2><strong>Early termination in HNSW</strong></h2><p>In HNSW, search proceeds by iteratively expanding candidate nodes in the proximity graph, maintaining a bounded set of nearest neighbors discovered so far, until it either has visited the whole graph or meets some early stop criteria.</p><p>Early termination is therefore not necessarily always an optimization, it’s <strong>part of the search algorithm itself</strong>. The moment we decide to stop determines the balance between efficiency and recall. In Elasticsearch, there are already a number of ways a query on HNSW can early terminate:</p><ul><li>A fixed maximum number of nodes is visited.</li><li>A fixed timeout is reached.</li></ul><p>While simple and predictable, these rules are largely <strong>agnostic to what the search is actually doing</strong>. Also they’re used mostly to make sure that the query finishes in reasonable time for the end user.</p><p>In a <a href="https://www.elastic.co/search-labs/blog/hnsw-knn-search-early-termination">previous blogpost</a>, we introduced the concept of redundancy in HNSW. In short, redundant computations occur when HNSW continues to evaluate new candidate nodes that don’t result in finding more nearest neighbors.</p><h2><strong>Patience: Measuring progress instead of effort</strong></h2><p>The notion of <em>patience</em> reframes early termination around <strong>progress rather than effort</strong>.</p><p>Instead of asking:</p><p>“How many steps have we taken?”</p><p>The new question becomes:</p><p>“What is the amount of computation we accept to waste, until we lose hope?”</p><p>During HNSW search, early exploration typically produces peak improvements to the top-k candidate set. During first steps of the HNSW graph exploration, the set of neighbors is continuously updated as the algorithm keeps discovering nearer and nearer neighbors to the query vector. Over time, these improvements become rarer as the search converges. <a href="https://cs.uwaterloo.ca/~jimmylin/publications/Teofili_Lin_ECIR2025.pdf">Patience-based termination</a> monitors this pattern and terminates the search once improvements have ceased for a sustained period.</p><p>In practice, while visiting the HNSW graph we also compute the queue saturation ratio as we hop through candidate nodes. This measures the percentage of nearest neighbors that were left unchanged while visiting the most recent graph node (or the inverse of the number of new neighbors introduced during the last iteration). When such a ratio becomes too big for too many consecutive iterations, we stop visiting the graph.</p><p>Conceptually, patience treats HNSW search as a <strong>diminishing returns process</strong>. When returns flatten out, continuing to explore the graph yields little benefit.</p><p>This framing is powerful because it ties termination directly to <em>observable outcomes</em> rather than to arbitrary fixed limits.</p><p>The benefit of using this smart early termination technique is that HNSW graph explorations tend to visit a smaller number of graph nodes while retaining an almost perfect relative recall.</p><p>To visualize this, we can plot the amount of recall per visited node that we got with the patience based early termination (labeled as <em><code>et=static</code></em>), when compared to the default HNSW behavior (labeled as <em><code>et=no</code></em>) on a couple of datasets, FinancialQA and Quora, and models, JinaV3 and E5-small.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a9d07c5153ea64a2426c82487c36846030692bb9-1600x945.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dac56125c4b16d1b596c9876b6ca9ac7b2dc87fa-1600x944.png" style="width: 100%;" /><h2><strong>Static thresholds and HNSW dynamics</strong></h2><p>In practice, in Elasticsearch this is implemented using <strong>static thresholds</strong>. One threshold refers to the <strong>saturation threshold</strong>: that is, the ratio of saturation that we consider suboptimal. The other threshold refers to the number of consecutive graph nodes that we allow to be visited while still having a suboptimal queue saturation: that is, the <strong>patience threshold</strong>.</p><p>When we introduced this early termination strategy in Elasticsearch 9.2, we decided to opt for conservative defaults, so as to let the recall as much as possible, while still gaining in terms of latency and memory consumption. For this reason, we set the saturation threshold to be 100% and the patience threshold to be set as a (bounded) 30% of the <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query#knn-query-top-level-parameters:~:text=search%20request%20size.-,num_candidates,-(Optional%2C%20integer)%20The"><em><code>num_candidates</code></em></a> in the KNN query.</p><p>In many scenarios, these settings resulted to work nicely; however, two queries requesting the same number of neighbors might have radically different convergence behaviors. Some queries encounter dense local neighborhoods and saturate quickly; others must traverse long, sparse paths before finding competitive candidates. The latter resulted to be the most difficult to handle effectively.</p><p>As a result, we sometimes noticed:</p><ul><li>Over-exploration for easy queries.</li><li>Premature termination for hard queries.</li></ul><p>Therefore, we figured that fixed threshold values encode global assumptions about convergence, whereas we could make HNSW better adapt to different dynamics.</p><h2><strong>Making HNSW early termination adaptive</strong></h2><p>Adaptive early termination approaches this problem from a different angle. Instead of enforcing predefined stopping thresholds, the algorithm <strong>infers when to stop from the search dynamics themselves</strong>.</p><p>So instead of comparing the queue saturation ratio between two consecutive candidates, we decided to introduce both an instant smoothed discovery rate&nbsp; $d_{q,i} $ (how many new neighbors were introduced for a query <em>q</em>, in the last visit <em>i</em>) together with rolling mean $\mu_{q,i}$ and standard deviation $\sigma_{q,i}$ of such a discovery rate during the graph visit (using <a href="https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford&#x27;s_online_algorithm">Welford’s algorithm</a>). These statistics about the discovery rate are calculated per query, so that this information can be used to decide different degrees of patience for each query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1958be7ca4425ade66eaf621ada3533173183598-694x118.png" style="width: 100%;" /><p>The previously static thresholds become adaptive to the discovery rate statistics: The saturation threshold becomes the rolling mean plus the standard deviation; whereas we make the patience adapt and scale inversely with the standard deviation.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f7ee4a55c24853b657df26052b275e8bd76cf0f9-654x156.png" style="width: 100%;" /><p>The early exit rules remain the same; the saturation happens when the instant discovery rate is lower than the adaptive saturation threshold. The graph visit stops if the saturation persists for a number of consecutive candidate visits that’s larger than the adaptive patience.</p><p>This way, we obtain a behavior that doesn’t depend on the <em><code>num_candidates</code></em> parameter in the KNN query (which might be always set or left as the default, regardless of early exit) and that better adapts to each query and vector distribution dynamically.</p><p>The recall per visited node on FinancialQA and Quora with the adaptive strategy (labeled as <em><code>et=adaptive</code></em>) reports a higher recall per visited node, when compared to the static strategy (<em><code>et=static</code></em>) and the default HNSW behavior (<em><code>et=no</code></em>).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2a906997d9a25d74c7038bd9661bc97581e7258e-1600x938.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3a114911e232c351dbb814cea20e8b0f1415a717-1600x925.png" style="width: 100%;" /><p>Adaptive early termination is turned on by default in Elasticsearch 9.3 for HNSW dense vector fields (and it can eventually be turned off via the <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules#index-dense-vector-hnsw-early-termination">same index level setting</a>).</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Common Expression Language (CEL): How the CEL input improves data collection in Elastic Agent integrations]]></title>
            <description><![CDATA[Learn how the Common Expression Language differs from other programming languages, how we’ve extended it for Filebeat’s CEL input, and the flexibility it gives you to express data collection logic in Elastic Agent integrations.]]></description>
            <link>https://www.elastic.co/search-labs/blog/common-expression-language-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/common-expression-language-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Chris Berkhout]]></dc:creator>
            <pubDate>Fri, 27 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/985c50bfabee3348494eb4307f0b3375a97a0644-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elastic Agent <a href="https://www.elastic.co/integrations">integrations</a> allow users to ingest data into Elasticsearch from a wide range of sources. They combine collection logic, ingest pipelines, dashboards, and other artifacts into a package that can be installed and managed from the Kibana web interface.</p><p>Integrations configure <a href="https://www.elastic.co/docs/reference/beats/filebeat/configuration-filebeat-options">Filebeat inputs</a> to do the data collection. To collect data from HTTP APIs, we’ve often used the <a href="https://www.elastic.co/docs/reference/beats/filebeat/filebeat-input-httpjson">HTTP JSON input</a>. However, even basic listing APIs can differ greatly in the details, and the HTTP JSON input&#x27;s model of YAML-configured transformations can make it awkward and sometimes impossible to express the required collection logic.</p><p>The <a href="https://www.elastic.co/docs/reference/beats/filebeat/filebeat-input-cel">Common Expression Language (CEL) input</a> was introduced to allow more flexible interaction with HTTP APIs. <a href="https://cel.dev/">CEL</a> is a language designed to be embedded in applications that require a fast, safe, and extensible way to express conditions and data transformations. The CEL input lets an integration builder write one expression that can read settings, keep track of its own state, make requests, process responses, and ultimately return events ready to ingest.</p><p>In this article, we’ll look at how CEL differs from other programming languages, how we’ve extended it for the CEL input, and the flexibility and power that gives you to express your data collection logic.</p><h2>CEL and how it works in the input</h2><p>CEL is an expression language. It has no statements. When you write CEL, you don’t tell it what to do by writing statements, you tell it what value to produce by writing an expression. Every CEL expression produces a value, and smaller expressions can be combined into a larger expression to produce a result according to more complex rules. Later, we’ll see how to use expressions for things that may be written with statements in other languages.</p><p>CEL is intentionally a non-Turing complete language. It doesn’t allow unbounded loops. Later, we’ll see how you can process lists and maps using macros, but by avoiding unbounded loops, the language guarantees predictable and limited execution time for individual expressions.</p><p>The CEL input is configured with a CEL program (an expression) and some initial state. The state will be provided as input to the program. The program is evaluated to produce an output state. If the output state includes a list of events, those will be removed and published. The rest of the output state will be used as the input for the next evaluation. If the output state includes one or more events and the flag <code>want_more: true</code>, the next evaluation will be performed immediately; otherwise, it will sleep for the rest of the configured interval time before continuing. Here’s a simplified diagram of the input’s control flow:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/42671541f97e2dba808fd53969fe12f517917f9a-1600x529.png" style="width: 100%;" /><p>The output of each evaluation will be passed forward as the input to the next evaluation, for as long as the input runs. Output data under the key &quot;<code>cursor</code>&quot; will be persisted to disk and reloaded after the input is restarted, but the rest of the state will not be preserved across restarts.</p><p>The CEL language itself has limited functionality and avoids side effects, but it is extensible. The <a href="https://github.com/google/cel-go">cel-go</a> implementation adds some functionality, such as optional syntax and types. The <a href="https://github.com/elastic/mito">Mito</a> library builds on cel-go and adds more functionality, including the ability to make HTTP requests. The CEL input uses Mito’s version of CEL.</p><h2>Working with Mito</h2><p>To build or debug an integration using the CEL input, the most important thing to understand is what output state your CEL program will produce for a given input state. During development, it can be cumbersome to have your CEL program run by the input, surrounded by the full Elastic stack. One way to achieve a faster feedback loop is to use Mito’s command-line tool, which will let you run a CEL program directly and see the output it produces for a given input.</p><p>Mito is written in Go and can be installed as follows:</p><pre><code>go install github.com/elastic/mito/cmd/mito@latest</code></pre><p>When you run a CEL program with Mito, you typically give it two files: a JSON file with the initial input state, and another file with the source code of your CEL program:</p><pre><code>mito -data state.json src.cel</code></pre><p>For easier copy and pasting, the examples in this article are written as single commands that have the shell create temporary files on the fly, by wrapping the content of each file in <code>&lt;(echo &#x27;...content...&#x27;)</code>. In your own development, working with actual files will be easier.</p><h2>Fetching issues data from GitHub</h2><p>The following example includes a full CEL program that will fetch data about issues from the <a href="https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues">GitHub API</a>. Its initial input state has a URL for the API endpoint, and some information about how it should handle pagination. The CEL program uses the data in the input state to generate a request. It will decode the response, produce events from it, and return them as part of its output state.</p><pre><code>mito -data <(echo '
  {
    "url": "https://api.github.com/repos/elastic/integrations/issues",
    "per_page": 3,
    "max_pages": 3
  }
') <(echo '
  int(state.?cursor.page.orValue(1)).as(page,
    (
      state.url + "?" + {
        "state": ["all"],
        "sort": ["created"],
        "direction": ["asc"],
        "per_page": [string(state.per_page)],
        "page": [string(page)],
      }.format_query()
    ).as(full_url,
      request("GET", full_url).with({
        "Header": {
          "Accept": ["application/vnd.github+json"],
          "X-GitHub-Api-Version": ["2022-11-28"],
        }
      }).do_request().as(resp,
        resp.Body.decode_json().as(data,
          state.with({
            "events": data.map(i, {
              "html_url": i.html_url,
              "title": i.title,
              "created_at": i.created_at,
            }),
            "cursor": { "page": page + 1 },
            "want_more": size(data) == state.per_page && page < state.max_pages,
          })
        )
      )
    )
  )
')</code></pre><p>Its first evaluation produces the following output:</p><pre><code>{
  "cursor": {
    "page": 2
  },
  "events": [
    {
      "created_at": "2018-09-14T09:47:35Z",
      "html_url": "https://github.com/elastic/integrations/issues/3250",
      "title": "Increase support of log formats in haproxy filebeat module"
    },
    {
      "created_at": "2019-02-06T12:37:37Z",
      "html_url": "https://github.com/elastic/integrations/issues/487",
      "title": "ETCD Metricbeat module needs polishing and grooming"
    },
    {
      "created_at": "2019-08-13T11:33:11Z",
      "html_url": "https://github.com/elastic/integrations/pull/1",
      "title": "Initial structure"
    }
  ],
  "max_pages": 3,
  "per_page": 3,
  "url": "https://api.github.com/repos/elastic/integrations/issues",
  "want_more": true
}</code></pre><p>The events will be removed, and when run in the CEL input, they’ll be published for ingestion. The rest of the output will be provided to the next CEL program evaluation as its input state.</p><p></p><p>To understand how that CEL program works, we’ll look at some smaller CEL examples and discuss more details of how the CEL input operates.</p><h2>CEL basics</h2><p>In the CEL language, there are no statements; there are only expressions. Every successful CEL expression evaluates to a final value. Here’s one of the smallest CEL expressions you can write, along with its output:</p><pre><code>mito <(echo '
  "hello" + " " + "world"
')</code></pre><pre><code>"hello world"</code></pre><p>Many simple expressions are intuitive. Mathematical operations are only supported on values of the same type (for example, <code>int</code> with <code>int</code>), so convert types as you need (here from <code>int</code> to <code>double</code>):</p><pre><code>mito <(echo '
  double((1 + 2) * (3 + 4)) / 2.0
')</code></pre><pre><code>10.5</code></pre><p>There are no variables in the CEL language, but an expression can be given a name and used in a larger expression with the help of Mito’s <a href="https://pkg.go.dev/github.com/elastic/mito/lib#hdr-As__Macro_-Collections"><code>as</code></a> macro. In this example, the expression <code>(1 + 1)</code> evaluates to the value <code>2</code>, and <code>.as(n, ...)</code> gives that value the name <code>n</code> for use in the expression <code>&quot;one plus one is &quot;+string(n)</code>:</p><pre><code>mito <(echo '
  (1 + 1).as(n, "one plus one is "+string(n))
')</code></pre><pre><code>"one plus one is 2"</code></pre><p>It&#x27;s also possible to accumulate information in a map and use it later in the expression, as demonstrated here using <a href="https://pkg.go.dev/github.com/elastic/mito/lib#hdr-With-Collections"><code>with</code></a>:</p><pre><code>mito <(echo '
  { "key": "value" }.with({ "key2": "value2" }).as(data,
    {
      "data": data,
      "size": size(data),
    }
  )
')</code></pre><pre><code>{
  "data": {
    "key": "value",
    "key2": "value2"
  },
  "size": 2
}</code></pre><p>Look at that example again. Notice that the nested part, <code>({ &quot;data&quot;: data, &quot;size&quot;: size(data), })</code>, gives us the shape of the final value. It’s a map with the keys <code>&quot;data&quot;</code> and <code>&quot;size&quot;</code>. The values for those keys depend on <code>data</code>, which is defined by the outer part of the expression. Reading CEL expressions from the inside out can help to quickly see what they’ll return.</p><p>CEL has no control flow statements, like <code>if</code>, but conditional branching can be done with the ternary operator:</p><pre><code>mito <(echo '
  1 + 1 < 12 ? "few" : "many"
')</code></pre><pre><code>"few"</code></pre><p>Unbounded loops and recursion are not supported, as CEL is not a Turing complete language. That makes execution time predictable and proportional to the size of the input data and the expression complexity.</p><p>Although unbounded loops are not possible in individual CEL expressions, you can process lists and maps using macros like <a href="https://github.com/google/cel-spec/blob/master/doc/langdef.md#macros"><code>map</code></a>:</p><pre><code>mito <(echo '
  [1, 2, 3].map(x, x * 2)
')</code></pre><pre><code>[2, 4, 6]</code></pre><p>In this section, we’ve covered:</p><ul><li>Strings, numbers, lists, and maps.</li><li>String concatenation.</li><li>Mathematical operations.</li><li>Type casting.</li><li>Conditionals.</li><li>Naming sub-expressions.</li><li>Processing collections.</li></ul><p>Next, we’ll look at how to make HTTP requests.</p><h2>Requests</h2><p>Mito extends CEL with the ability to make <a href="https://pkg.go.dev/github.com/elastic/mito/lib#HTTP">HTTP requests</a>:</p><pre><code>mito <(echo '
  get("https://example.com").as(resp, string(resp.Body))
')</code></pre><pre><code>"<!doctype html><html lang=\"en\"><head><title>Example Domain</title>..."</code></pre><p>Requests can be explicitly constructed before they’re executed. That makes it possible to use different HTTP methods and to add headers and a body.</p><p>In this example, we build a URL with the help of <a href="https://pkg.go.dev/github.com/elastic/mito/lib#hdr-Format_Query-HTTP"><code>format_query</code></a>, add a header to the request, and parse the response body with <a href="https://pkg.go.dev/github.com/elastic/mito/lib#hdr-Decode_JSON-JSON"><code>decode_json</code></a>. When given the <code>-log_requests</code> option, Mito will log detailed information in JSON format about each request and response.</p><pre><code>mito -log_requests <(echo '
  request("GET",
    "https://postman-echo.com/get?" + {
        "q": ["query value"]
     }.format_query()
  ).with({
    "Header": { "Accept": ["application/json"] }
  }).do_request().as(resp, {
    "status": resp.StatusCode,
    "data": resp.Body.decode_json(),
  })
')</code></pre><pre><code>{"time":"...","level":"INFO","msg":"HTTP request",...}
{"time":"...","level":"INFO","msg":"HTTP response",...}
{
  "data": {
    "args": {
      "q": "query value"
    },
    "headers": {
      "accept": "application/json",
      "accept-encoding": "gzip, br",
      "host": "postman-echo.com",
      "user-agent": "Go-http-client/2.0",
      "x-forwarded-proto": "https"
    },
    "url": "https://postman-echo.com/get?q=query+value"
  },
  "status": 200
}</code></pre><h2>Managing state and evaluations</h2><p>Now that we’ve covered how to make requests and the CEL basics required to produce our desired output state, let’s take a closer look at what we should put into the output state and how that lets us direct later processing.</p><p>An integration’s CEL program needs to make sure its output state is suitable for use as the input of the next evaluation. Configuration sets the initial state, and that should be repeated in the output with any appropriate changes. An easy way to do that is to use <code>state.with({ ... })</code>, to repeat the state map with some overrides. A common pattern for small programs is to wrap the whole program in <code>state.with()</code>, so that state propagation doesn’t have to be repeated in each branch that generates output data (for example, success, errors).</p><p>When there are state values that are initialized by an evaluation rather than hard-coded in the initial input state, the program will need to check for an existing value before setting the initial one. That’s something that the support for <a href="https://pkg.go.dev/github.com/google/cel-go/cel#OptionalTypes">optional syntax and types</a> can help with. By using a question mark before the field name in a map key, the access becomes optional: It may or may not resolve to a value, but further optional accesses are possible and it’s easy to supply a default if no value is present:<br/></p><pre><code>mito -data <(echo '{}') <(echo '
  int(state.?counter.orValue(0)).as(counter,
    state.with({
      "counter": counter + 1,
      "want_more": counter + 1 < 3,
    })
  )
')</code></pre><pre><code>{ "counter": 1, "want_more": true }
{ "counter": 2, "want_more": true }
{ "counter": 3, "want_more": false }</code></pre><p>In that example, the counter value read from state is cast to <code>int</code> because all numbers are serialized in the state as floating point numbers, in keeping with conventions established by JSON and JavaScript’s <code>Number</code> type. It should also be noted that <code>&quot;want_more&quot;: true</code> is honored here by Mito, but when run in the CEL input, the evaluation would only be repeated if the output also contains events.</p><p>It’s a requirement of CEL programs run by the CEL input that they return an <code>&quot;events&quot;</code> key in their output map. Its value can be a list of event maps, an empty list, or a single event map. The single event case is usually used for errors. The event will be published by the input, but its value will also be logged, and if it sets an <code>error.message</code> value, that will be used to update the integration’s Fleet health status. If your program produces a single non-error event, it’s best to wrap it in a list.</p><p>Take another look at the output of our GitHub issues program from earlier:</p><pre><code>{
  "url": "https://api.github.com/repos/elastic/integrations/issues",
  "per_page": 3,
  "max_pages": 3,
  "cursor": {
    "page": 2
  },
  "events": [
    { ... },
    { ... },
    { ... }
  ],
  "want_more": true
}</code></pre><p>The program effectively managed its state, by:</p><ul><li>Repeating initial state values in <code>url</code>, <code>per_page</code>, and <code>max_pages</code>.</li><li>Adding state that should be persisted across restarts in <code>cursor.page</code>.</li><li>Returning events ready to publish in the <code>events</code> list.</li><li>Requesting immediate re-evaluation with <code>want_more: true</code>.</li></ul><p>Now that you understand optional access and state management, as well as CEL basics and HTTP requests, the full GitHub issues program should be readable. Try running it with Mito and experimenting with some changes.</p><h2>Review and resources</h2><p>In this article, we looked at what the CEL language is and how it has been extended in the Mito library for use in the CEL input. We saw the flexibility of CEL in an example program that fetches issues information from the GitHub API, and went through all the details necessary to understand that program, covering access to settings in the initial state, interaction with HTTP APIs, returning events to be ingested, and managing the state for later program executions.</p><p>To learn more and build integrations using the CEL input, there are a number of resources worth exploring:</p><ul><li><a href="https://www.elastic.co/docs/reference/beats/filebeat/filebeat-input-cel">CEL input - Filebeat documentation</a></li><li><a href="https://pkg.go.dev/github.com/elastic/mito">Mito documentation</a></li><li><a href="https://cel.dev/">Common Expression Language - cel.dev website</a></li><li><a href="https://www.elastic.co/docs/extend/integrations">Create an Integration - Elastic documentation</a></li></ul><p>And perhaps the most valuable resource for building integrations with the CEL input is the CEL code of existing Elastic integrations, which can be found on GitHub:</p><p><a href="https://github.com/search?q=repo%3Aelastic%2Fintegrations+path%3A**%2Fcel.yml.hbs&amp;type=code"><code>cel.yml.hbs</code> files in the Elastic integrations repository - GitHub</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Entity resolution with Elasticsearch & LLMs, Part 2: Matching entities with LLM judgment and semantic search]]></title>
            <description><![CDATA[Using semantic search and transparent LLM judgment for entity resolution in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-semantic-search</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-semantic-search</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
            <pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/473ca4357c7d60f690edbd2a844acda169aca9c3-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In<a href="https://www.elastic.co/search-labs/blog/entity-resolution-llm-elasticsearch"> Part 1</a>, we prepared our watch list and extracted entity mentions. Now we’re ready to answer the hard question: Which entity does a mention actually refer to? Let&#x27;s return to the example in the first blog of this series, which set up why we need entity resolution: &quot;The Swift update is here!&quot; Imagine that this headline is accompanied by a bit more context:</p><ol><li>The new Swift update is here! Developers are eager to try out the new features.</li><li>The new Swift update is here! The new album will drop next month.</li></ol><p>With this added context, we should be able to resolve the name &quot;Swift&quot; to the correct entity.</p><p>In the <a href="https://www.elastic.co/search-labs/blog/entity-resolution-llm-elasticsearch">previous post</a>, we set up our watch list and enriched the entities with additional context. Looking at our examples above, we need to have at least the following two entities in the list: Taylor Swift and Swift Programming Language. We also covered how we extract entity mentions from text. Both of these examples would extract &quot;Swift&quot;. With these ingredients in place, the enriched watch list, and the extracted entities, we’re finally ready to introduce the star of the show: entity matching.</p><p><strong>Remember:</strong> This is an educational prototype designed to teach entity-matching concepts. Production systems might use different large language models (LLMs), custom matching rules, specialized judgment pipelines, or ensemble approaches combining multiple matching strategies.</p><h2>The problem: Why matching is hard</h2><p>Human language is a remarkable thing. One of the most interesting properties of it is its endless creativity. We can generate and understand an infinite number of new sentences. Is it any wonder, then, that exact matches in entity resolution are rare? Authors strive to be creative when they can. It would get quite tedious if we had to write and read full names whenever an entity is mentioned. So, while exact matches are easy, the reality is that we need a more sophisticated approach to entity resolution: one that’s robust enough to handle at least some of the boundless creativity of human authors. That’s why we separate the problem into two steps: Use Elasticsearch to retrieve plausible candidates at scale, and then use an LLM to judge whether those candidates truly refer to the same real-world entity.</p><h2>The solution: Three-step matching with transparent LLM judgment</h2><p>We’re in the midst of a paradigm shift in how we use computers. Just as the rise of the internet took us from localized computing to a globally connected network, generative AI (GenAI) is fundamentally changing how content, code, and information are created. In fact, the educational prototype that accompanies this series was almost exclusively &quot;vibe coded&quot; using an LLM with careful prompting by the author. This is not to say that LLMs have or even will reach the kind of productivity inherent with human language, but it does mean that we now have a powerful resource to help with entity resolution.</p><p>A common pattern we use with GenAI is retrieval augmented generation (RAG). Here, <em>retrieval</em> means retrieving entity candidates (not generating answers), and the LLM is used strictly for match evaluation and explanation. While we <em>could</em> ask an LLM to help us with end-to-end entity resolution, that’s a costly approach, both in terms of time and money. RAG helps LLMs do their work by using more efficient ways to provide context to the LLM, thereby empowering the LLM to efficiently help with entity resolution.</p><p>For the retrieval part of RAG, we again turn to Elasticsearch. We first find potential matches using a combination of exact matching, matching against aliases, and hybrid search, which combines keyword and semantic search. Once we find these potential matches, we send them to an LLM for judgment. The LLM acts as the final match evaluator. We also make the LLM explain its reasoning, an important differentiator with other entity resolution systems. Without these explanations, entity resolution is a black box; with them, we can see for ourselves why a match makes sense.</p><h2>Key concepts: Three-step matching, hybrid search, and transparent LLM judgment</h2><p><strong>What is three-step matching?</strong> At the onset of this project, we hypothesized that semantic search will be a crucial part of the system, but not every match requires such sophisticated search. In order to find matches efficiently, we take a progressive approach to the problem. First, we check for exact matches using keyword search. If we find such a match, our work is done and we can move on. If exact matching fails, we turn to alias matching. In the prototype, alias matching is also done using exact matching with keywords, for simplicity. In production, you might expand this step with normalization, transliteration rules, fuzzy matching, or curated alias tables. If we still haven&#x27;t found a potential match in the first two steps, then it&#x27;s time to bring in semantic search via Elasticsearch&#x27;s hybrid search with reciprocal rank fusion (RRF).</p><p><strong>What is hybrid search?</strong> In Elasticsearch, we can use semantic search to find meaningful matches that take context into account. Elasticsearch is widely used for vector search and hybrid retrieval. Semantic similarity is powerful for meaning, but it’s not a substitute for structured filtering (for example, by time ranges, locations, or identifiers), and it’s often unnecessary when an exact match is available. Elasticsearch made its mark with lexical search, which is great at tasks where semantic search doesn&#x27;t fit. To take full advantage of both approaches, we use lexical search alongside semantic search in a single hybrid query. We then merge the results to find the most likely matches using RRF. In the prototype, the top two results become potential matches that can be sent for LLM judgment.</p><p><strong>Why LLM judgment?</strong> LLM judgments and explanations allow our system to handle ambiguity and context transparently. This is vital for cases like &quot;the president&quot;, which could refer to multiple entities, depending on the context, but it also makes things like nicknames and cultural variations work well in the system. Finally, when we consider mission-critical tasks, like identifying entities from sanctions lists, we need to know why a match was accepted in order to trust the system. Crucially, the LLM does not search the full corpus; it evaluates only the small set of candidates returned by Elasticsearch.</p><h2>Real-world results: Matching with LLM reasoning</h2><p>A major challenge for any natural language processing task is the creation of a golden document, an &quot;answer key&quot; that tells us what the expected results are. Without this, it&#x27;s next to impossible to judge how well a system performs on a task, but creating such a document can be a laborious process. For the entity resolution prototype, we turned again to GenAI to help set up data we could test against.</p><p>We first defined several challenge types, such as nicknames and transliteration, and then asked the LLM to create a tiered collection of datasets that would get progressively larger and more challenging for the system. The creation of the datasets was less straightforward than one might hope. The LLM had a strong propensity for &quot;cheating&quot; by making it too easy to get the right answer. For example, one of the challenge types focused on semantic context. This type included things like resolving &quot;Russian author&quot; to &quot;Leo Tolstoy&quot;. The LLM incorrectly put &quot;Russian author&quot; as an alias for &quot;Leo Tolstoy&quot;, which negated the need for hybrid search to find the match.</p><p>After several refactorings to fix issues like this, we had five dataset tiers to work with. Tiers 1–4 were progressively larger with more challenge types. Tier 5 was the &quot;ultimate challenge&quot; dataset, made up of the trickiest examples from all challenge types. All of the test data is available in the <a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/comprehensive_evaluation">comprehensive evaluation directory</a>.</p><p>To evaluate our prompt-based entity resolution approach, we focused our attention on the tier 4 dataset. An important note is that the evaluation was conducted as a controlled experiment so that we could focus on entity match quality. The watch list data was pre-enriched with context, and entities were extracted from the article ahead of time. This ensured that evaluation was focused on matching rather than on extraction accuracy. This isolates match quality; end-to-end performance would additionally depend on extraction recall and enrichment quality.</p><h3>Evaluation dataset</h3><p>The tier 4 evaluation dataset provides a comprehensive test of the system&#x27;s capabilities:[1]</p><ul><li><strong>Watch list entities:</strong> 66 entities across diverse types (people, organizations, locations).</li><li><strong>Test articles:</strong> 69 articles covering real-world entity resolution scenarios.</li><li><strong>Expected matches:</strong> 206 expected entity matches across all articles.</li><li><strong>Challenge types: </strong>15 different challenge types testing various aspects of entity resolution.</li></ul><p>The challenge types included in the dataset are:</p><ul><li><strong>Nicknames:</strong> &quot;Bob Smith&quot; → &quot;Robert Smith&quot; (seven articles).</li><li><strong>Titles and honorifics:</strong> &quot;Dr. Sarah Williams&quot; → &quot;Sarah Williams&quot; (five articles).</li><li><strong>Semantic context:</strong> &quot;Russian author&quot; → &quot;Leo Tolstoy&quot; (eight articles).</li><li><strong>Multilingual names:</strong> Handling names in different scripts (six articles).</li><li><strong>Business entities:</strong> Corporate name variations (seven articles).</li><li><strong>Executive references: </strong>&quot;Microsoft CEO&quot; → &quot;Satya Nadella&quot; (five articles).</li><li><strong>Political leaders:</strong> Title-based references (five articles).</li><li><strong>Initials:</strong> &quot;J. Smith&quot; → &quot;John Smith&quot; (three articles).</li><li><strong>Name order variations:</strong> Different name ordering conventions (three articles).</li><li><strong>Truncated names:</strong> Partial name matches (three articles).</li><li><strong>Name splitting:</strong> Names split across text (three articles).</li><li><strong>Missing spaces/hyphens:</strong> Formatting variations (two articles).</li><li><strong>Transliteration:</strong> Cross-script name matching (two articles).</li><li><strong>Combined challenges:</strong> Multiple challenges in one article (six articles).</li><li><strong>Complex business:</strong> Hierarchical business relationships (five articles).</li></ul><p>Let&#x27;s see how prompt-based entity resolution performed.</p><h3>Overall performance</h3><p>The results show that there&#x27;s a lot of promise with LLM-powered match evaluation, but they also reveal a significant reliability issue. Because each candidate pair must be evaluated by the LLM, failures in structured output can suppress acceptance and recall even when retrieval is working well.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric</td><td style="border: 1px solid #ccc; padding: 4px;">Value</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Precision</td><td style="border: 1px solid #ccc; padding: 4px;">83.8%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Recall</td><td style="border: 1px solid #ccc; padding: 4px;">62.6%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">F1 score</td><td style="border: 1px solid #ccc; padding: 4px;">71.7%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total matches found</td><td style="border: 1px solid #ccc; padding: 4px;">344</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LLM acceptance rate</td><td style="border: 1px solid #ccc; padding: 4px;">44.8%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Error rate</td><td style="border: 1px solid #ccc; padding: 4px;">30.2%</td>
                    </tr>
              </tbody>
            </table>
          <h3>The error rate problem</h3><p>Recall that the first step we take in the prototype is to create potential match pairs using Elasticsearch. Each of these potential matches needs to be evaluated by the LLM. To efficiently process all of those matches, we batch the LLM calls together. This reduces API costs and latency, but there’s also an increased risk of getting malformed JSON in the output. As batch size increases, the JSON becomes longer and more complex, making it more likely that the LLM will generate invalid JSON. This is where the 30% error rate stems from. In the evaluation, we used a batch size of five matches per request. Even with this conservative batch size, we still see JSON parsing failures, which skews the evaluation results significantly.</p><h2>What&#x27;s next: Optimizing LLM integration</h2><p>Now that we&#x27;ve matched entities using semantic search and LLM judgment, we have a complete entity resolution pipeline. This approach introduces a new failure mode, however, when the model’s judgment is correct, but its output isn’t usable. We can optimize the LLM integration for better reliability and cost efficiency. In the next post, we&#x27;ll explore how to use function calling for structured output, which provides guaranteed structure and type safety while reducing errors and costs.</p><h2>Try it yourself</h2><p>Want to see entity matching in action? Check out the <a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=5%20minutes%20ago-,03_entity_matching_v3.ipynb,-Initial%20public%20lab">Entity Matching notebook</a> for a complete walk-through with real implementations, detailed explanations, and hands-on examples. The notebook shows you exactly how to match entities using three-step search, hybrid search with RRF, and LLM-powered judgment with reasoning.</p><p><strong>Remember:</strong> This is an educational prototype designed to teach the concepts. When building production systems, consider additional factors, like model selection, cost optimization, latency requirements, quality validation, error handling, and monitoring, which aren&#x27;t covered in this learning-focused prototype.</p><h2>Notes</h2><ol><li>These datasets are synthetic and designed for education; they approximate real challenges but are not representative of any single production domain.</li></ol>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch vector search is up to 8x faster than OpenSearch]]></title>
            <description><![CDATA[Exploring filtered vector search benchmarks of OpenSearch vs. Elasticsearch and why vector search performance is critical for context-engineered systems.]]></description>
            <link>https://www.elastic.co/search-labs/blog/opensearch-vs-elasticsearch-filtered-vector-search</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/opensearch-vs-elasticsearch-filtered-vector-search</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sachin Frayne]]></dc:creator>
            <pubDate>Wed, 25 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fb7ee623925ca6696d643e437ce8efe5fe749079-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<h2>Why search speed matters for AI agents and context engineering</h2><p>Our benchmarks on a 20M document corpus show that Elasticsearch delivers up to 8x higher throughput than OpenSearch for filtered vector search, while also achieving higher Recall@100 across the configurations we tested. Context engineering depends on more than fast vector retrieval. Teams also need strong relevance controls, like hybrid search and filtering, operational simplicity, and predictable performance, as workflows iterate. But because agents often run retrieve, reason, retrieve loops many times per request, retrieval latency becomes a multiplier, so improvements here translate directly into better end-to-end responsiveness and lower cost.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5a52a07773f0942c2baa732dacfe782aac0f415-1600x683.png" style="width: 100%;" /><p>For context engineering, retrieval isn’t a one-time step. Agents and applications repeatedly run loops, such as retrieve → reason → retrieve, to refine queries, verify facts, assemble grounded context, and complete tasks. This pattern is common in agentic workflows and iterative retrieval augmented generation (RAG). Because retrieval may be invoked many times per user request, it adds delay to the response and/or increases infrastructure costs.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/54ac476ff20a3cf93484298c9ae47612c12fc110-800x417.png" style="width: 100%;" /><h2>Why is vector search performance critical?</h2><p></p><p>Imagine a shopping assistant answering the question, “I need a carry-on backpack under $60 that fits a 15-inch laptop, is water resistant, and can arrive by Friday.”</p><p>In production, the assistant rarely issues one vector query and stops. It runs a retrieval loop to build the right context, and each step is typically constrained by filters, like availability, region, shipping promise, brand rules, and policy eligibility.</p><p><strong>Step 1: Interpret intent and translate to constraints.</strong></p><p>The agent turns the request into structured filters and a semantic query, such as:</p><ul><li>Filters: In stock, deliverable to the user’s postcode, delivery by Friday, price under $60, valid listing</li><li>Vector query: “Carry-on backpack 15-inch laptop water resistant”</li></ul><p><strong>Step 2: Retrieve candidates, and then refine.</strong></p><p>It often repeats retrieval with variations to avoid missing good matches:</p><ul><li>“travel backpack carry on laptop sleeve”</li><li>“water resistant commuter backpack 15 inch”</li><li>“lightweight cabin backpack”</li></ul><p>Each query uses the same eligibility filters, because retrieving irrelevant or unavailable items is wasted context.</p><p><strong>Step 3: Expand to confirm details and reduce risk.</strong></p><p>The agent then retrieves again to verify key attributes that affect the final answer:</p><ul><li>Material and water resistance wording</li><li>Dimensions and laptop compartment fit</li><li>Return policy or warranty constraints</li><li>Alternate options if inventory is low</li></ul><p>This is multistep context engineering: Retrieve, reason, retrieve, assemble.</p><h2>Why latency and recall matter for context engineering</h2><p>These interactions can involve dozens of filtered retrieval calls per user session. That makes per-call latency a direct multiplier on end-to-end response time, and low recall forces extra retries or causes the agent to miss eligible items, degrading answer quality.</p><p>Takeaway: In context-engineered systems, filtered approximate nearest neighbors (ANN) isn’t a single lookup. It’s a repeated operation under constraints, so vector search performance shows up immediately in latency, throughput, and cost, even when the large language model (LLM) is the most visible component.</p><h2>Benchmarking</h2><h3>Results</h3><p>In Graph 2, each dot represents one test configuration. The best results appear toward the top left, meaning higher recall with lower latency. Elasticsearch’s results are consistently closer to the top left than OpenSearch’s, indicating better speed and accuracy under the same workload settings.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c50d1df00968cac18149a2799e6242fbe49b66a0-1600x990.png" style="width: 100%;" /><h4>Some key insights</h4><ul><li><code>s_n_r_value</code>: Shorthand for <code>size_numCandidates_rescoreOversample</code> (k and numCandidates set equal to numCandidates in these tests), for example, <code>100_500_1</code> means size=100, numCandidates=500 and k=500, rescore oversample=1</li><li>Recall: Measured Recall@100 for that configuration</li><li>Avg latency (ms): Average end-to-end latency per query</li><li>Throughput: Queries per second</li><li>Recall %: Relative recall lift of Elasticsearch versus OpenSearch (Elasticsearch minus OpenSearch) / OpenSearch</li><li>Latency Xs: OpenSearch average latency divided by Elasticsearch average latency</li><li>Throughput Xs: Elasticsearch throughput divided by OpenSearch throughput</li></ul>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Engine</td><td style="border: 1px solid #ccc; padding: 4px;">`s_n_r_value`</td><td style="border: 1px solid #ccc; padding: 4px;">Recall</td><td style="border: 1px solid #ccc; padding: 4px;">Avg Latency (ms)</td><td style="border: 1px solid #ccc; padding: 4px;">Throughput</td><td style="border: 1px solid #ccc; padding: 4px;">Recall %</td><td style="border: 1px solid #ccc; padding: 4px;">Latency Xs</td><td style="border: 1px solid #ccc; padding: 4px;">Throughput Xs</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_250_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.7704</td><td style="border: 1px solid #ccc; padding: 4px;">25</td><td style="border: 1px solid #ccc; padding: 4px;">534.75</td><td style="border: 1px solid #ccc; padding: 4px;">9.70%</td><td style="border: 1px solid #ccc; padding: 4px;">2.28</td><td style="border: 1px solid #ccc; padding: 4px;">1.91</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_250_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.7023</td><td style="border: 1px solid #ccc; padding: 4px;">57.08</td><td style="border: 1px solid #ccc; padding: 4px;">279.58</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_500_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.8577</td><td style="border: 1px solid #ccc; padding: 4px;">25.42</td><td style="border: 1px solid #ccc; padding: 4px;">524.14</td><td style="border: 1px solid #ccc; padding: 4px;">7.20%</td><td style="border: 1px solid #ccc; padding: 4px;">2.4</td><td style="border: 1px solid #ccc; padding: 4px;">2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_500_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.8001</td><td style="border: 1px solid #ccc; padding: 4px;">60.9</td><td style="border: 1px solid #ccc; padding: 4px;">262.12</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_750_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.8947</td><td style="border: 1px solid #ccc; padding: 4px;">29.67</td><td style="border: 1px solid #ccc; padding: 4px;">528.09</td><td style="border: 1px solid #ccc; padding: 4px;">5.72%</td><td style="border: 1px solid #ccc; padding: 4px;">2.25</td><td style="border: 1px solid #ccc; padding: 4px;">2.21</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_750_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.8463</td><td style="border: 1px solid #ccc; padding: 4px;">66.76</td><td style="border: 1px solid #ccc; padding: 4px;">239.11</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_1000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9156</td><td style="border: 1px solid #ccc; padding: 4px;">29.65</td><td style="border: 1px solid #ccc; padding: 4px;">534.5</td><td style="border: 1px solid #ccc; padding: 4px;">4.66%</td><td style="border: 1px solid #ccc; padding: 4px;">2.46</td><td style="border: 1px solid #ccc; padding: 4px;">2.44</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_1000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.8748</td><td style="border: 1px solid #ccc; padding: 4px;">72.88</td><td style="border: 1px solid #ccc; padding: 4px;">219.01</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_1500_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9386</td><td style="border: 1px solid #ccc; padding: 4px;">31.84</td><td style="border: 1px solid #ccc; padding: 4px;">497.3</td><td style="border: 1px solid #ccc; padding: 4px;">3.38%</td><td style="border: 1px solid #ccc; padding: 4px;">2.71</td><td style="border: 1px solid #ccc; padding: 4px;">2.68</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_1500_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9079</td><td style="border: 1px solid #ccc; padding: 4px;">86.16</td><td style="border: 1px solid #ccc; padding: 4px;">185.4</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_2000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9507</td><td style="border: 1px solid #ccc; padding: 4px;">34.69</td><td style="border: 1px solid #ccc; padding: 4px;">457.2</td><td style="border: 1px solid #ccc; padding: 4px;">2.57%</td><td style="border: 1px solid #ccc; padding: 4px;">2.98</td><td style="border: 1px solid #ccc; padding: 4px;">2.96</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_2000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9269</td><td style="border: 1px solid #ccc; padding: 4px;">103.36</td><td style="border: 1px solid #ccc; padding: 4px;">154.55</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_2500_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9582</td><td style="border: 1px solid #ccc; padding: 4px;">37.9</td><td style="border: 1px solid #ccc; padding: 4px;">418.43</td><td style="border: 1px solid #ccc; padding: 4px;">1.99%</td><td style="border: 1px solid #ccc; padding: 4px;">3.28</td><td style="border: 1px solid #ccc; padding: 4px;">3.26</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_2500_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9395</td><td style="border: 1px solid #ccc; padding: 4px;">124.29</td><td style="border: 1px solid #ccc; padding: 4px;">128.53</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_3000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9636</td><td style="border: 1px solid #ccc; padding: 4px;">41.86</td><td style="border: 1px solid #ccc; padding: 4px;">379.4</td><td style="border: 1px solid #ccc; padding: 4px;">1.62%</td><td style="border: 1px solid #ccc; padding: 4px;">3.46</td><td style="border: 1px solid #ccc; padding: 4px;">3.44</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_3000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9482</td><td style="border: 1px solid #ccc; padding: 4px;">144.67</td><td style="border: 1px solid #ccc; padding: 4px;">110.34</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_4000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9705</td><td style="border: 1px solid #ccc; padding: 4px;">50.28</td><td style="border: 1px solid #ccc; padding: 4px;">316.21</td><td style="border: 1px solid #ccc; padding: 4px;">1.06%</td><td style="border: 1px solid #ccc; padding: 4px;">3.87</td><td style="border: 1px solid #ccc; padding: 4px;">3.85</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_4000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9603</td><td style="border: 1px solid #ccc; padding: 4px;">194.36</td><td style="border: 1px solid #ccc; padding: 4px;">82.22</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_5000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9749</td><td style="border: 1px solid #ccc; padding: 4px;">58.77</td><td style="border: 1px solid #ccc; padding: 4px;">270.91</td><td style="border: 1px solid #ccc; padding: 4px;">0.73%</td><td style="border: 1px solid #ccc; padding: 4px;">4.43</td><td style="border: 1px solid #ccc; padding: 4px;">4.41</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_5000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9678</td><td style="border: 1px solid #ccc; padding: 4px;">260.33</td><td style="border: 1px solid #ccc; padding: 4px;">61.38</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_6000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9781</td><td style="border: 1px solid #ccc; padding: 4px;">66.75</td><td style="border: 1px solid #ccc; padding: 4px;">238.59</td><td style="border: 1px solid #ccc; padding: 4px;">0.52%</td><td style="border: 1px solid #ccc; padding: 4px;">4.91</td><td style="border: 1px solid #ccc; padding: 4px;">4.89</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_6000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.973</td><td style="border: 1px solid #ccc; padding: 4px;">327.44</td><td style="border: 1px solid #ccc; padding: 4px;">48.81</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_7000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9804</td><td style="border: 1px solid #ccc; padding: 4px;">74.64</td><td style="border: 1px solid #ccc; padding: 4px;">213.49</td><td style="border: 1px solid #ccc; padding: 4px;">0.38%</td><td style="border: 1px solid #ccc; padding: 4px;">5.28</td><td style="border: 1px solid #ccc; padding: 4px;">5.27</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_7000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9767</td><td style="border: 1px solid #ccc; padding: 4px;">394.24</td><td style="border: 1px solid #ccc; padding: 4px;">40.53</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_8000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9823</td><td style="border: 1px solid #ccc; padding: 4px;">82.28</td><td style="border: 1px solid #ccc; padding: 4px;">193.59</td><td style="border: 1px solid #ccc; padding: 4px;">0.27%</td><td style="border: 1px solid #ccc; padding: 4px;">6.86</td><td style="border: 1px solid #ccc; padding: 4px;">6.83</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_8000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9797</td><td style="border: 1px solid #ccc; padding: 4px;">564.14</td><td style="border: 1px solid #ccc; padding: 4px;">28.33</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_9000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9837</td><td style="border: 1px solid #ccc; padding: 4px;">90.08</td><td style="border: 1px solid #ccc; padding: 4px;">176.96</td><td style="border: 1px solid #ccc; padding: 4px;">0.16%</td><td style="border: 1px solid #ccc; padding: 4px;">7.63</td><td style="border: 1px solid #ccc; padding: 4px;">7.61</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_9000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9821</td><td style="border: 1px solid #ccc; padding: 4px;">687.25</td><td style="border: 1px solid #ccc; padding: 4px;">23.25</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_10000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.9848</td><td style="border: 1px solid #ccc; padding: 4px;">97.64</td><td style="border: 1px solid #ccc; padding: 4px;">163.31</td><td style="border: 1px solid #ccc; padding: 4px;">0.08%</td><td style="border: 1px solid #ccc; padding: 4px;">8.38</td><td style="border: 1px solid #ccc; padding: 4px;">8.36</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">OpenSearch</td><td style="border: 1px solid #ccc; padding: 4px;">100_10000_1</td><td style="border: 1px solid #ccc; padding: 4px;">0.984</td><td style="border: 1px solid #ccc; padding: 4px;">818.64</td><td style="border: 1px solid #ccc; padding: 4px;">19.53</td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;"></td>
                    </tr>
              </tbody>
            </table>
          <p>For example, at <code>100_9000_1</code>, OpenSearch averages 687 milliseconds per retrieval versus 90 milliseconds on Elasticsearch, and in a 10-step retrieval loop that’s about 10 x (687 - 90) = six seconds of additional waiting time. </p><p>See the <a href="https://github.com/elastic/competitive-benchmarking-studies/tree/main/es-9.3-vs-os-3.5-vector-search/jingra/results/20260220">full results</a>.</p><h3>Methodology</h3><p>Using Python to send the queries and track the response timing and other statistics, we sent the following queries to the engines. Bear in mind that the performance of any vector search engine depends on how you tune its core parameters: how many candidates to consider, how aggressively to rescore, and how much context to return. These settings directly affect both recall (the likelihood of finding the right answer) and latency (how fast you get results).</p><p>In our benchmarks, we used the same candidate, rescore, and result-size settings you’d typically tune in an agentic retrieval loop, and we measured how Elasticsearch performs under that workload. We then ran OpenSearch with the same settings as a reference.</p><p>OpenSearch</p><pre><code>GET <INDEX_NAME>/_search
{
  "query": {
    "knn": {
      "<DENSE_VECTOR_FIELD_NAME>": {
        "vector": [...],
        "k": <NUMBER_OF_CANDIDATES>,
        "method_parameters": {
          "ef_search": <NUMBER_OF_CANDIDATES>
        },
        "rescore": {
          "oversample_factor": <OVERSAMPLE>
        },
        "filter": {
          <SOME_FILTER>
        }
      }
    }
  },
  "size": <RESULT_SIZE>,
  "_source": {
    "excludes": [
      "<DENSE_VECTOR_FIELD_NAME>"
    ]
  }
}</code></pre><ul><li><code>&quot;size&quot;: &lt;RESULT_SIZE&gt;</code>: Number of hits returned to the client. In this benchmark, result size is 100 to compute Recall@100.</li><li><code>&quot;k&quot;: &lt;NUMBER_OF_CANDIDATES&gt;</code>: The number of nearest neighbor candidates.</li><li><code>&quot;ef_search&quot;: &lt;NUMBER_OF_CANDIDATES&gt;</code>: The number of vectors to examine.</li><li><code>&quot;oversample_factor&quot;: &lt;OVERSAMPLE&gt;</code>: How many candidate vectors are retrieved before rescoring.</li></ul><p>Elasticsearch</p><pre><code>GET <INDEX_NAME>/_search
{
  "query": {
    "knn": {
      "field": "<DENSE_VECTOR_FIELD_NAME>",
      "query_vector": [...],
      "k": <NUMBER_OF_CANDIDATES>,
      "num_candidates": <NUMBER_OF_CANDIDATES>,
      "rescore_vector": {
        "oversample": <OVERSAMPLE>
      },
      "filter": {
        <SOME_FILTER>
      }
    }
  },
  "size": <RESULT_SIZE>,
  "_source": {
    "excludes": [
      "<DENSE_VECTOR_FIELD_NAME>"
    ]
  }
}</code></pre><ul><li><code>&quot;size&quot;: &lt;RESULT_SIZE&gt;</code>: Number of hits returned to the client. In this benchmark, result size is 100 to compute Recall@100.</li><li><code>&quot;k&quot;: &lt;NUMBER_OF_CANDIDATES&gt;</code>: Number of nearest neighbors to return from each shard.</li><li><code>&quot;num_candidates&quot;: &lt;NUMBER_OF_CANDIDATES&gt;</code>: Number of nearest neighbor candidates to consider per shard while doing <code>knn</code> search.</li><li><code>&quot;oversample&quot;: &lt;OVERSAMPLE&gt;</code>: How many candidate vectors are retrieved before rescoring.</li></ul><p>Example</p><p><code>Knn</code> query, (<code>100_500_1</code>), would be as follows:</p><p>OpenSearch</p><pre><code>GET search_catalog_128/_search
{
  "query": {
    "knn": {
      "search_catalog_embedding": {
        "vector": [...],
        "k": 500,
        "method_parameters": {
          "ef_search": 500
        },
        "rescore": {
          "oversample_factor": 1
        },
        "filter": {
          "term": {
            "valid": true
          }
        }
      }
    }
  },
  "size": 100,
  "_source": {
    "excludes": [
      "search_catalog_embedding"
    ]
  }
}</code></pre><p>Elasticsearch</p><pre><code>GET search_catalog_128/_search
{
  "query": {
    "knn": {
      "field": "search_catalog_embedding",
      "query_vector": [...],
      "k": 500,
      "num_candidates": 500,
      "rescore_vector": {
        "oversample": 1
      },
      "filter": {
        "term": {
          "valid": true
        }
      }
    }
  },
  "size": 100,
  "_source": {
    "excludes": [
      "search_catalog_embedding"
    ]
  }
}</code></pre><p>The full configuration, alongside Terraform scripts, Kubernetes manifests and the benchmarking code is available in this <a href="https://github.com/elastic/competitive-benchmarking-studies">repository</a> in the folder <a href="https://github.com/elastic/competitive-benchmarking-studies/tree/main/es-9.3-vs-os-3.5-vector-search">es-9.3-vs-os-3.5-vector-search</a>.</p><h3>Cluster setup</h3><p>We ran our tests on six e2-standard-16 cloud servers, each with 16 vCPUs and 64 GB RAM. On each server, we allocated 15 vCPUs and 56 GB RAM to each Kubernetes pod running the search engine node, with 28 GB reserved for the JVM heap.</p><p>The clusters ran Elasticsearch 9.3.0 and OpenSearch 3.5.0 (Lucene 10.3.2). Because both systems use the same Lucene version in this benchmark, the throughput and latency differences we observe cannot be attributed to Lucene alone and instead reflect differences in how each engine integrates and executes filtered k-nearest neighbor (kNN) retrieval and rescoring. We used a single index with three primary shards and one replica (so 6 shards total, 1 per node).</p><p>We also used a separate server in the same region to run the benchmark client and collect timing statistics.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f81002c9186e4c2d3e92f49d72418fee9860fc5e-761x401.png" style="width: 100%;" /><h3>The dataset</h3><p></p><p>For this benchmark, we used a large-scale ecommerce-style catalog embedding dataset with 20 million documents, designed to reflect real-world filtered vector retrieval at scale.</p><p></p><p>Each document represents a catalog item and includes:</p><p></p><ul><li>A 128-dimensional dense vector embedding used for approximate kNN retrieval.</li><li>Structured metadata fields used for filtering (for example, item validity and availability plus other catalog constraints) enabling the common production pattern of retrieving the nearest neighbors but only within an eligible subset.</li></ul><p></p><p>We chose this dataset because it captures the core performance challenge we see in agentic and RAG-style systems in production: Vector similarity alone is not enough, retrieval is frequently constrained by filters, and the system must maintain high recall while keeping latency low under those constraints. Compared to smaller QA-style datasets, a 20M document corpus also better reflects the scale and candidate pressure that filtered ANN systems face in practice.</p><h2>Conclusion</h2><p>In modern AI architectures, especially those built around context engineering, vector search speed isn’t a minor implementation detail. It’s a multiplier. When agents and workflows iterate through retrieve → reason → retrieve, retrieval performance directly shapes end-to-end latency, throughput, and the quality of the context fed into the model.</p><p>In our benchmarks, Elasticsearch consistently delivered higher recall at lower latency than OpenSearch in scenarios where correctness depends on retrieving the right document, not just a similar vector. On a controlled dataset, the difference is clear, and in production those gains accumulate across large volumes of retrieval calls, improving responsiveness, increasing capacity headroom, and reducing infrastructure costs.</p><h3>Further reading</h3><ol><li><a href="https://www.elastic.co/search-labs/blog/context-engineering-overview">What is context engineering?</a></li><li><a href="https://www.elastic.co/search-labs/blog/series/context-engineering-hybrid-search-evolution">The evolution of hybrid search and context engineering</a></li><li><a href="https://www.elastic.co/search-labs/blog/context-engineering-relevance-ai-agents-elasticsearch">The impact of relevance in context engineering for AI agents</a></li></ol>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elastic AutoOps now free for all: What you get with it]]></title>
            <description><![CDATA[Elastic AutoOps is now free for all self-managed clusters. Learn what you get with it and how it compares to Stack Monitoring.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-autoops-free-for-self-managed-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-autoops-free-for-self-managed-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Valentin Crettaz, Arnon Stern, Ori Shafir]]></dc:creator>
            <pubDate>Wed, 25 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2067a9457334a1b4bc14056fad34a304f39d1fab-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<h2><strong>Same AutoOps, regardless of your license</strong></h2><p>We’re making this simple: Starting today, AutoOps is available at no cost for every self-managed Elasticsearch cluster through <a href="https://www.elastic.co/blog/elasticsearch-autoops-on-prem">Elastic Cloud Connect</a>. Whether you’re on Free, Basic, Platinum, or Enterprise, you get the same feature product. This isn’t a limited preview or a &quot;lite&quot; version. It’s the same product used by the largest deployments.</p><h2><strong>An investment in the self-managed community</strong></h2><p>Extending it to every user, including those on the free distribution, reflects Elastic&#x27;s commitment to the success of the entire Elasticsearch community. By providing AutoOps for free, we’re investing in the stability and performance of the hundred of thousands of clusters that power the community&#x27;s search and analytics workloads.</p><p>Elastic Cloud Connect enables self-managed clusters to consume Elastic Cloud services, such as AutoOps and the <a href="https://www.elastic.co/search-labs/de/blog/cloud-connect-elastic-inference-service">recently announced</a> Elastic Inference Service (EIS), without the operational overhead of maintaining, patching, monitoring, and operating the services locally.</p><h2><strong>How AutoOps for self-managed works and what it provides</strong></h2><p>As clusters grow in complexity and size, you find yourself spending more time chasing configuration tweaks and trying to find the root cause of an issue. Monitoring tools show you metrics and leave the manual correlation to you and your favorite large language model (LLM) to find the root cause when the issue appears. AutoOps tells you what’s wrong, why, and exactly how to fix it, with real-time issue detection and specific resolution paths.</p><p>AutoOps runs on Elastic Cloud; there’s no infrastructure for you to provision or maintain. You simply run a lightweight agent on-premises to connect your cluster to the AutoOps service where operational metadata (such as node stats, cluster settings, and shard states) is shipped to AutoOps in real time to provide insights and recommendations. Your data never leaves your environment.</p><h2><strong>AutoOps vs. Stack Monitoring for self-managed users</strong></h2><p>Stack Monitoring provides the essential telemetry and basic monitoring for your nodes and indices, showing you the trend over time for various metrics, and alerts you when thresholds are crossed, yet it often leaves the diagnostic burden on the engineer. AutoOps offers a complete picture of cluster health by correlating all relevant metrics. This provides valuable insights and clear instructions on how to resolve issues when they occur.</p><p><strong>Faster root cause analysis</strong></p><p>Your cluster was humming along, but it suddenly got red in the middle of the night (and, as usual, nothing changed the days before).</p><ul><li><strong>With Stack Monitoring:</strong> A built-in alert will notify you when your cluster health turns red. To find the cause, you need to dig into your cluster logs and turn to Dev Tools to look at your shard allocations to understand why that primary shard couldn’t be allocated. Looking at your alerts history, you see another one informing you that your hot nodes reached 80% disk utilization two days ago. You can’t find any disk usage charts to learn more about your disk fill rate, you only know that your disk reached 90% utilization in the meantime, and when one of your data streams needed to roll over, a new backing index was created, but no shards could be allocated to any of your nodes.</li><li><strong>With AutoOps:</strong> The system notifies you when your cluster turns red (1). Looking at the timeline, you immediately realize that this happened because you failed to take action on the previous watermark events that AutoOps raised, namely the high watermark events (2) that started happening recently, and the low watermark ones (3) that built up over the past few days. It is now straightforward for you to know what you need to do to get your cluster back to green.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/445dac65e7fb0f061a4bff87d4a1a2cb4ee39140-1600x583.png" style="width: 100%;" /><p><strong>Higher signal to noise ratio</strong></p><p>Keeping your Elasticsearch cluster healthy is most probably your main concern. Yet, it’s not uncommon for the health status to sometimes flap between green and yellow (and sometimes red), and the cause is not always worthy of your time.</p><ul><li><strong>With Stack Monitoring:</strong> The built-in “Cluster Health” alert will continue to be raised on each health transition from green to either yellow or red. In some situations, like frequent index creations, this can create a lot of repeated and undesired noise. Also, and more importantly, there’s no distinction between a yellow and a red status.</li><li><strong>With AutoOps:</strong> There are dedicated “Status Red” and “Status Yellow” events, with different severities. The latter can be customized in many different ways to fit your use case, as shown in the screenshot below:<ol><li>Since the cluster health can turn yellow only for a brief duration, you can decide for how long to ignore the yellow status before being notified (for example, five minutes, in the screenshot below).</li><li>Furthermore, there are a lot of legitimate operations that Elasticsearch does all the time and that make the cluster turn yellow. You can pick any of the operations you don’t want to be notified about when your cluster turns yellow because of them (for example, adding replicas, relocating replicas, or closing or opening an index, among others).</li><li>Finally, and most importantly, if you have several clusters to manage, you don’t need to configure this for all of them separately; you simply decide to which ones this configuration should be applied. Simple, powerful!</li></ol></li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e2b2826b4c674e4a5924d1f8a9b855606c735fa9-1542x1518.png" style="width: 100%;" /><p><strong>More insightful correlations and comparisons of node metrics</strong></p><p>When your cluster runs on more than just a handful nodes, you often need to see how they perform against each other, especially when looking at search and indexing performance.</p><ul><li><strong>With Stack Monitoring:</strong> The Nodes list doesn’t allow you to focus on a specific data tier and doesn’t provide any search or indexing performance metrics that you can sort on. These metrics are available, but only once you drill into a specific node, which doesn’t allow you to easily compare node performance against each other.</li><li><strong>With AutoOps:</strong> The Nodes view allows you to select nodes from a specific data tier. It also provides you with over 50 metrics visualizations, among them search and indexing performance, which give you exactly the visual cues that you need to understand how each node performs against each other and whether there are any struggling nodes that require your attention. In the screenshot below, we can see that some nodes are indexing at double the rate of some others and that search latency is building up at four times the latency of the fastest nodes.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1eec2db4931ca2992579a5501dd939fc8a552709-1600x1377.png" style="width: 100%;" /><p><strong>Quick overview of the main differences</strong></p><p>Here’s a glimpse at the notable differences between AutoOps and Stack Monitoring, but you can find a more detailed breakdown in our <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops-vs-stack-monitoring#compare-capabilities">official documentation</a>.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Capability</td><td style="border: 1px solid #ccc; padding: 4px;">Stack Monitoring</td><td style="border: 1px solid #ccc; padding: 4px;">AutoOps</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Cluster, node, and index metrics</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Real-time dashboards</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Multi-cluster overview</td><td style="border: 1px solid #ccc; padding: 4px;">No</td><td style="border: 1px solid #ccc; padding: 4px;">Yes</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Root cause analysis</td><td style="border: 1px solid #ccc; padding: 4px;">No</td><td style="border: 1px solid #ccc; padding: 4px;">Automated analysis</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Fix recommendations</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">In-context Elasticsearch commands</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Performance tuning insights</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">Yes, based on usage patterns</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Alerts and notifications</td><td style="border: 1px solid #ccc; padding: 4px;">14 built-in alerts and 27 connectors</td><td style="border: 1px solid #ccc; padding: 4px;">100+ customizable alerts and 7 connectors</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Template and mapping analysis</td><td style="border: 1px solid #ccc; padding: 4px;">None</td><td style="border: 1px solid #ccc; padding: 4px;">Detects mapping misconfigurations</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Setup infrastructure</td><td style="border: 1px solid #ccc; padding: 4px;">Dedicated monitoring cluster</td><td style="border: 1px solid #ccc; padding: 4px;">Five-minute installation. No extra infrastructure and no cost. Only lightweight agent installation is needed.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Other stack components</td><td style="border: 1px solid #ccc; padding: 4px;">Kibana, Logstash, Elastic APM</td><td style="border: 1px solid #ccc; padding: 4px;">Coming soon</td>
                    </tr>
              </tbody>
            </table>
          <h2><strong>Start now: Five-minute installation</strong></h2><p>Connecting your cluster takes minutes, regardless of your license type:</p><ol><li>Log in to your free Elastic Cloud account, or <a href="https://cloud.elastic.co/registration?onboarding_service_type=ccm-autoops">sign up</a> for one.</li><li>Choose how to connect your cluster: Elastic Cloud on Kubernetes (ECK), Kubernetes, Docker, or Linux.</li><li>Enter your Elasticsearch cluster endpoint, and run the single command to install and run the lightweight Elastic agent.</li><li>Access AutoOps in your <a href="https://cloud.elastic.co/home">Elastic Cloud account</a>.</li></ol><p>For more details on AutoOps and instructions on connecting your self-managed cluster, read our <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/cc-autoops-as-cloud-connected">product documentation</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bf040d977b515ed6448ae780ede31beff701a4f6-1600x861.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7a5a01c8ec70dd467870aa06ac037a51bf8c86dc-1600x898.png" style="width: 100%;" /><h2><strong>Reach out if you have any questions</strong></h2><p>Feel free to reach out to us to share your questions and ideas via our <a href="https://elasticstack.slack.com/archives/C0A80TKS71D">Slack community</a>, by posting on our <a href="https://discuss.elastic.co/c/elastic-stack/monitoring/103">Discuss forum</a>, or by clicking the “Give Feedback” button on the AutoOps product page. If you’re connecting a paid self-managed Platinum or Enterprise cluster, you can contact support within your Elastic Cloud account.</p><h2><strong>Read more</strong></h2><p>If you’re interested to learn more about AutoOps and what it can do for you, please head to the <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/cc-autoops-as-cloud-connected">official AutoOps documentation</a> and the following Elastic Search Labs articles:</p><ul><li><a href="https://www.elastic.co/search-labs/blog/autoops-elasticsearch-easy-operations">AutoOps makes every Elasticsearch deployment simple(r) to manage</a></li><li><a href="https://www.elastic.co/search-labs/blog/elastic-autoops-self-managed-elasticsearch">AutoOps: A journey to simplify self-managed Elasticsearch management</a></li><li><a href="https://www.elastic.co/search-labs/blog/slow-search-elasticsearch-query-autoops">Leveraging AutoOps to detect long-running search queries</a></li></ul><p><a href="https://cloud.elastic.co/registration?onboarding_service_type=ccm-autoops"><strong>Start using AutoOps for free</strong></a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build a "chat with your website data" agent with Jina Embeddings v5 and Elasticsearch]]></title>
            <description><![CDATA[Learn how to build a working, grounded "chat with your website" experience in under an hour by using Elasticsearch Serverless, Jina Embeddings v5, Elastic Open Web Crawler, and Elastic Agent Builder.]]></description>
            <link>https://www.elastic.co/search-labs/blog/chat-website-data-elasticsearch-jina-ai</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chat-website-data-elasticsearch-jina-ai</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Ugo Sangiorgi, Lily Adler]]></dc:creator>
            <pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6e3ea84419f1edc60b5e2bfcb7e06693810d03a0-512x268.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Build a “chat with your website” experience in under an hour using <strong>Elasticsearch Serverless</strong>, <strong>Jina Embeddings v5</strong>, <strong>Elastic Open Web Crawler</strong>, and <strong>Elastic Agent Builder</strong>.</p><p>By the end, you’ll have a working agent that can search your crawled pages, cite relevant passages, and answer questions grounded in your content, no custom chunking or embedding pipeline required.</p><p>In this guide, you’ll:</p><ol><li>Start an <strong>Elasticsearch Serverless</strong> project.</li><li>Create an index using the new <code>semantic_text</code> field powered by <strong>Jina Embeddings v5</strong>.</li><li>Crawl any website using<strong> <a href="https://github.com/ugosan/elastic-crawler-control">Elastic Crawler Control (a.k.a. Crawly)</a></strong> (an open source UI + API wrapper around the <a href="https://www.elastic.co/guide/en/enterprise-search/current/crawler.html">Elastic Open Web Crawler</a>).</li><li>Chat with that data using the <strong>Elastic Agent Builder</strong> in Kibana.</li></ol><p><strong>What you’ll walk away with:</strong></p><ul><li>A repeatable pattern you can point at <em>any</em> website/docs source.</li><li>Chat that stays grounded in your content.</li></ul><h2>Prerequisites</h2><ul><li>An <strong>Elasticsearch Serverless (Search)</strong> project + an <strong>API key</strong> with write permissions.</li><li><strong>Docker + Docker Compose</strong> (to run the crawler UI).</li><li><code>git </code>(to clone the repo).</li></ul><h2>1. Start an Elasticsearch Serverless project</h2><p>First, we need a serverless project to host our data.</p><p>1. Log in to your <a href="https://cloud.elastic.co">Elastic Cloud Console</a>.</p><p>2. Click <strong>Create project</strong>.</p><p>3. Select <strong>Search</strong> as the project type. (This type is optimized for vector search and retrieval.)</p><p>4. Give it a name (for example, <code>es-labs-jina-guide</code>), and click <strong>Create</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6a138b12f85727872cd8105d954b5ae95f9af428-1600x1135.png" style="width: 100%;" /><p>5.<strong>&nbsp; Important:</strong> Save the <strong>Elasticsearch endpoint </strong>and <strong>API Key</strong> provided when the project is created. You’ll need these for the crawler.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7c43c7f91d141658dbaff852f452d64853d7ddb8-1536x298.png" style="width: 100%;" /><h2>2. Create the index</h2><p>Elasticsearch Serverless supports <code>semantic_text</code>, which handles chunking and embedding generation automatically. We’ll use the .<code>jina-embeddings-v5-text-small</code> model that’s hosted on GPUs on <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a>.</p><p>Create the index with the <code>semantic_text</code> field. This tells Elastic to automatically vectorize content put into the <code>field </code>property using the inference endpoint we just created.</p><p>In <strong>Kibana</strong> <strong>Dev tools</strong> run:</p><pre><code>PUT furnirem-website
{
  "mappings": {
    "_meta": {
      "description": "Each document represents a web page with the following schema: 'title' and 'meta_description' provide high-level summaries; 'body' contains the full text content; 'headings' preserves the page hierarchy for semantic weighting. URL metadata is decomposed into 'url_host', 'url_path', and 'url_path_dir1/2/3' to allow for granular filtering by site section (e.g., 'blog' or 'tutorials'). 'links' contains extracted outbound URLs for discovery. Crawl timestamp: 2026-01-26T12:54:16.347907."
    },
    "properties": {
      "body_content": { 
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          },
          "semantic_multilingual": {
            "type": "semantic_text",
            "inference_id": ".jina-embeddings-v5-text-small" 
          }
        }
      },
      "headings": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          },
          "semantic_multilingual": {
            "type": "semantic_text",
            "inference_id": ".jina-embeddings-v5-text-small"
          }
        }
      },
      "title": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          },
          "semantic_multilingual": {
            "type": "semantic_text",
            "inference_id": ".jina-embeddings-v5-text-small"
          }
        }
      }
    }
  }
}</code></pre><h2>3. Run the Elastic Open Crawler</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6d5796c8c3752d25f0c64f905570812f16332f1a-640x398.gif" style="width: 100%;" /><p><strong>Crawly</strong> is one example of how an application can be constructed around the functionalities that the Open Web Crawler provides.</p><p>The application wraps the Elastic Open Crawler in a FastAPI service that manages crawler processes and persists execution data. A React front end provides the interface for configuring and monitoring crawls.</p><p>What happens under the hood is that the crawler service (check <a href="https://github.com/ugosan/elastic-crawler-control/blob/main/crawler-service/app/crawler.py">crawler.py</a>) spawns JRuby processes via <code>subprocess.Popen</code>, allowing multiple concurrent crawls. Each execution&#x27;s configuration, status, and logs are persisted to disk (for now).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c20b5055d57f6fe2db4eada6c737d95b19a721f4-1250x960.png" style="width: 100%;" /><p>Clone the repository:</p><pre><code>git clone https://github.com/ugosan/elastic-crawler-control</code></pre><p>Create an <strong>env.local</strong> file with your Elasticsearch credentials:</p><pre><code>ES_URL=https://your-elasticsearch-endpoint.es.cloud
ES_API_KEY=your_api_key_here</code></pre><p>Start the services:</p><pre><code>docker-compose up</code></pre><p>Access the UI at <strong>http://localhost:16700</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3858d3764fc7fd5ac217545dfba94c83a0096fb2-1600x820.png" style="width: 100%;" /><p>You don’t necessarily need <code>seed_urls </code>unless you want to be specific, so your config can be as simple as below:</p><pre><code>{
  "domains": [
    {
      "url": "https://furnirem.com"
    }
  ],
  "max_crawl_depth": 3,
  "max_unique_url_count": 500,
  "output_index": "furnirem-website"
}</code></pre><p>From there, you can start a crawl on any website and check its progress:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f98187d216397973ccc786226466ff8b931118f0-1600x443.png" style="width: 100%;" /><p>Once it&#x27;s finished, we’re ready to query the content in Elasticsearch directly or use the pages you just crawled for chatting with the website on Agent Builder.</p><h2>4. Chat with data in Kibana</h2><p>Now that the data is indexed and vectorized, we can start chatting with the data using the <strong>Elastic Agent Builder</strong>.</p><ol><li>Open <strong>Kibana</strong>, and navigate to <strong>Agents</strong> (under the &quot;Search&quot; section).</li><li><strong>Test the agent</strong>:<ul><li>In the chat window, ask a question, like,<em>&quot;What is the difference between sparse and dense vectors?&quot;</em></li></ul></li></ol><p>The agent will search your Jina-embedded data, retrieve the relevant snippets from the Search Labs blog posts, and generate an answer.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/eebb72090181c5b18dadcc3751f3084857fe1dac-1600x1363.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4e84a862c6051fa7eda507d4470678ec5dd78eb1-1560x1174.png" style="width: 100%;" /><p>You can also chat with the data directly via Kibana API:</p><pre><code>POST kbn://api/agent_builder/converse/async
{
  "input": "What is the difference between sparse and dense vectors?",
  "agent_id": "elastic-ai-agent",
  "conversation_id": "<CONVERSATION_ID>"
}</code></pre><p>Use<strong> </strong><code>conversation_id </code>to resume an existing conversation with an agent in Elastic Agent Builder. If you don’t provide it on the initial request, the API starts a new conversation and returns a newly generated ID in the streaming response.</p><h2>Summary</h2><p>You now have a working “<strong>chat with your website</strong>” stack: Your site gets crawled, indexed, auto-embedded with <code>semantic_text </code>+ Jina v5, and surfaced through an agent in Kibana that answers questions grounded in your pages.</p><p>From here, you can point the same setup at docs, support content, or internal wikis and iterate on relevance in minutes.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[jina-embeddings-v5-text: Compact state-of-the-art text embeddings for search and intelligent applications]]></title>
            <description><![CDATA[Introducing jina-embeddings-v5-text models, including jina-embeddings-v5-text-small and jina-embeddings-v5-text-nano, and explaining how to use these multilingual embedding models via Elastic Inference Service (EIS).]]></description>
            <link>https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Scott Martens, Sofia Vasileva]]></dc:creator>
            <pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e98dd30ab925b4bb32830228d71a1a51d02a0917-1600x840.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Jina AI and Elastic are releasing <code>jina-embeddings-v5-text</code>, a family of new, high-performance, compact text embedding models with state-of-the-art performance for models of comparable size across all major task types.</p><p>The family includes two models:</p><ul><li><code>jina-embeddings-v5-text-small</code></li><li><code>jina-embeddings-v5-text-nano</code></li></ul><p>These models are the successful result of an innovative new training recipe for embedding models. They both outperform models many times their size, creating savings in memory and computing resources and responding faster to requests.</p><p>The <code>jina-embeddings-v5-text-small</code> model has 677M parameters, supports a 32768 token input context window, and produces 1024 dimension embeddings by default.</p><p><code>jina-embeddings-v5-text-nano</code> weighs in at roughly a third of its sibling&#x27;s size, with 239M parameters and a 8192 token input context window, yielding slender 768 dimension embeddings.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model name</td><td style="border: 1px solid #ccc; padding: 4px;">Total size</td><td style="border: 1px solid #ccc; padding: 4px;">Input context window size</td><td style="border: 1px solid #ccc; padding: 4px;">Embedding size</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-v5-text-small</td><td style="border: 1px solid #ccc; padding: 4px;">
677M params</td><td style="border: 1px solid #ccc; padding: 4px;">
32768 tokens</td><td style="border: 1px solid #ccc; padding: 4px;">1024 dims</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-v5-text-nano</td><td style="border: 1px solid #ccc; padding: 4px;">
239M params</td><td style="border: 1px solid #ccc; padding: 4px;">
8192 tokens</td><td style="border: 1px solid #ccc; padding: 4px;">768 dims</td>
                    </tr>
              </tbody>
            </table>
          <p>These two models are the best in class for overall MMTEB (<a href="https://huggingface.co/spaces/mteb/leaderboard">Multilingual MTEB</a>) benchmark performance. Among models with under 500M parameters, <code>jina-embeddings-v5-text-nano</code> is the top performer, despite having less than 250M parameters, and <code>jina-embeddings-v5-text-small</code> model is the leader among multilingual embedding models with under 750M parameters.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d97126285fa196c3045b1913b7754e4b186c1e4c-1300x1100.png" style="width: 100%;" /><p>These models are available via Elastic Inference Service (EIS), via an online API, and available for local hosting. For instructions on how to access <code>jina-embeddings-v5-text</code> models, see the “<strong>Getting started</strong>” section, below.</p><p>Embedding models and semantic indexing dramatically increase the accuracy of search algorithms but also have a variety of other uses for tasks involving semantic similarity and meaning extraction, for example:</p><ul><li>Finding duplicate texts.</li><li>Recognizing paraphrases and translations.</li><li>Topic discovery.</li><li>Recommendation engines.</li><li>Sentiment and intention analysis.</li><li>Spam filtering.</li><li>And many others.</li></ul><h2><strong>Features</strong></h2><p>This new model family has a number of features designed to improve relevance and reduce costs.</p><h3>Task optimization</h3><p>We’ve optimized the <code>jina-embeddings-v5-text</code> models for four broad task types:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Task</td><td style="border: 1px solid #ccc; padding: 4px;">Example use cases</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Retrieval</td><td style="border: 1px solid #ccc; padding: 4px;">Searching with natural language queries and retrieving the most relevant matches in a collection of documents.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Text matching</td><td style="border: 1px solid #ccc; padding: 4px;">Semantic similarity, deduplication, paraphrase and translation alignment, and more.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Clustering</td><td style="border: 1px solid #ccc; padding: 4px;">Topic discovery, automatic organization of document collections.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Classification</td><td style="border: 1px solid #ccc; padding: 4px;">Document categorization, sentiment and intent detection, similar tasks.</td>
                    </tr>
              </tbody>
            </table>
          <p>Optimizing for one task usually means having to compromise on another, so most embedding models only have competitive performance for one kind of task. But <code>jina-embeddings-v5-text</code> models are able to specialize in all four areas without compromising by training task-specific <a href="https://arxiv.org/abs/2106.09685">Low-Rank Adaptation (LoRA) adapters</a>.</p><p>LoRA adapters are a kind of plugin for an AI model that changes its behavior dramatically while only adding slightly to the total size. Instead of having an entire model for each task, each one with hundreds of millions of parameters, the <code>jina-embeddings-v5-text</code> model family lets you use just one model with a compact LoRA adapter for each task. This saves memory, storage space, and inference costs.</p><h3>Truncating embeddings</h3><p>We’ve trained the <code>jina-embeddings-v5-text</code> models using <a href="https://arxiv.org/abs/2205.13147">Matryoshka Representation Learning</a>, which lets you cut your embeddings down to smaller sizes at a minimal cost to their quality.</p><p>By default, <code>jina-embeddings-v5-text-small</code> generates 1024-dimension embedding vectors, each represented by a 16-bit number, making every embedding 2KB in size. For a large collection of documents, this can be a lot of data to store, and searching in a vector database full of embeddings is proportional both to the size of the database and to the number of dimensions each stored vector has.</p><p>But you can just halve the size of the embeddings (throw away 512 of the 1024 dimensions), and take up half the space while doubling search speeds. This has an impact on performance. Throwing away information reduces precision. But as the graph below shows, even getting rid of half of the embedding only reduces performance slightly:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f11969682c81bcf22b5b29a6204ba6ec40471b3d-900x700.png" style="width: 100%;" /><p>As long your embeddings are at least 256 dimensions, the loss in precision should remain fairly small. Below that level, however, relevance and accuracy deteriorate quickly.</p><p>Truncating embeddings like this empowers users to manage their own trade-offs between accuracy and computing costs. It gives you the tools to get big efficiency gains and large cost savings out of your search AI.</p><h3>Robust quantization</h3><p><em>Quantization </em>is another way of reducing the size of embeddings. Instead of throwing away part of each embedding, quantization reduces the precision of the numbers in the embedding. The <code>jina-embeddings-v5-text</code> models generate embeddings with 16-bit numbers, but we can round those numbers off, reducing their precision and the number of bits needed to store them. In the most extreme case, we can reduce each number to one bit (0 or 1), compressing <code>jina-embeddings-v5-text</code>’s default 1024 dimension embeddings from 2 kilobytes to 128 bytes, a 94% reduction from binary quantization alone. Just like for truncation, this produces large savings in memory and computing costs. However, also like truncation, quantization makes embeddings less accurate.</p><p>We’ve trained the <code>jina-embeddings-v5-text</code> models to work with Elasticsearch’s <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/bbq">Better Binary Quantization</a> by minimizing that loss of accuracy, and benchmark tests of binarized embeddings from these models show performance almost equal to their non-binarized equivalents. Consult <a href="https://arxiv.org/abs/2602.15547">the technical report</a> for detailed ablation studies of binarization performance.</p><h3>Multilingual performance</h3><p>Many embedding models are multilingual because they’ve been trained on materials that include large numbers of languages. But that doesn’t mean that they all perform equally well in all supported languages.</p><p>We identified 211 languages in the MMTEB multilingual benchmark and separated them so we could compare our models to similar models on a language-by-language basis. The image below summarizes our results as a heat map. Each patch is a language (identified by its ISO-639 code), and the greener it is, the better the model performed compared to the average of similar models:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/852ac5d0f1977bb0c1124d87f8863a9bb94eb7da-1600x765.png" style="width: 100%;" /><p>Although accuracy varies between languages, the <code>jina-embeddings-v5-text</code> models are state-of-the-art or nearly so across most of the world’s languages.</p><p>For details about multilingual performance, see the <a href="https://arxiv.org/abs/2602.15547"><code>jina-embeddings-v5-text</code> technical report</a>.</p><h2><strong>Jina in Elastic: State-of-the-art native AI for search</strong></h2><p>With <code>jina-embeddings-v5-text</code> models on EIS, you can run high-performance multilingual embedding models natively in Elasticsearch with fully managed, GPU-accelerated inference and no infrastructure to provision or scale. <code>jina-embeddings-v5-text</code> models extend the growing EIS model catalog with compact, multilingual models powered by the latest developments in AI. These models have state-of-the-art performance on information retrieval and standard data analysis benchmarks, and they offer unequaled, globe-spanning multilingual support.</p><p>With two models of vastly different sizes, users can determine which one is best suited for their applications and budgets. Furthermore, with robust embeddings that remain performant when truncated to smaller sizes or quantized to lower precision, <code>jina-embeddings-v5-text </code>models provide opportunities for further concrete savings in storage and computing costs as well as in processing latency.</p><p>With the <code>jina-embeddings-v5-text</code> family, Jina Reranker, and Elastic’s fast vector and BM25 search, users now have access to end-to-end, state-of-the-art <a href="https://www.elastic.co/docs/solutions/search/hybrid-search">hybrid search</a> from Elastic. When you need the most relevant results, whether for retrieval augmented generation (RAG) pipelines, search applications, or data analysis, Elastic with Jina search AI models provides solid and cost-effective quality.</p><h2><strong>Getting started</strong></h2><p>The <code>jina-embeddings-v5-text</code> models are fully integrated into <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a>, and you can use them by setting the <strong><code>type</code> </strong>field to<strong> <code>semantic_text</code></strong> when creating your index and specifying the model (<code>jina-embeddings-v5-text-small</code> or <code>jina-embeddings-v5-text-nano</code>) in the <code>inference_id</code><strong> </strong>field, as in this example:</p><pre><code>PUT multilingual-semantic-index
{
  "mappings": {
    "properties": {
      "content": {
        "type": "semantic_text",
        "inference_id": ".jina-embeddings-v5-text-small"
      }
    }
  }
}

# Ingest data about France
POST multilingual-semantic-index/_doc
{
  "content": "The capital of France is Paris"}

GET multilingual-semantic-index/_search
{
  "query": {
    "semantic": {
      "field": "content",
      "query": "What is the French capital?"
    }
  }
}</code></pre><p>Elasticsearch automatically selects the appropriate LoRA adapter during indexing and retrieval. The embedding dimensions (see the “<strong>Truncating embeddings”</strong> section, above) can be set when <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put">creating a custom inference endpoint</a>.</p><p>See the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector">Elasticsearch documentation</a> for more information on using <strong><code>jina-embeddings-v5-text</code> </strong>models.</p><h2><strong>More information</strong></h2><p>To learn more about <code>jina-embeddings-v5-text</code> models, read the <a href="https://jina.ai/news/jina-embeddings-v5-text-distilling-4b-quality-into-sub-1b-multilingual-embeddings/">release notes on the Jina AI blog</a> and the <a href="https://arxiv.org/abs/2602.15547">technical report</a>, with more detailed technical information about performance and Jina AI’s innovative new training procedure. For information about downloading and running these models locally, visit the <a href="https://huggingface.co/collections/jinaai/jina-embeddings-v5-text"><code>jina-embeddings-v5-text</code> collection page</a> on Hugging Face.</p><p>Jina AI models are available under a <a href="https://spdx.org/licenses/CC-BY-NC-4.0">CC-BY-NC-4.0 license</a>, so you are free to download them and try them out, but for commercial use, please contact <a href="https://www.elastic.co/contact?pg=global&amp;plcmt=nav&amp;cta=205352">Elastic sales</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Ensuring semantic precision with minimum score]]></title>
            <description><![CDATA[Improve semantic precision by employing minimum score thresholds. The article includes concrete examples for semantic and hybrid search. ]]></description>
            <link>https://www.elastic.co/search-labs/blog/semantic-precision-minimum-score</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/semantic-precision-minimum-score</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Mattias Brunnert]]></dc:creator>
            <pubDate>Fri, 20 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8b3b5910abfe16d48d309341a0027008b16c4340-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Semantic search has opened up a world of opportunities for search relevance. High-quality sparse and dense models, such as ELSER, E5 and Jina Embedding v4, return relevant results based on the meaning of words, rather than keyword matching. However, semantic search sometimes returns irrelevant results at the tail end or for queries that lack relevant results in the index. This property of sparse and dense models can confuse users or waste precious tokens for large language models (LLMs).</p><p>In this article, you’ll learn how you can use the minimum score parameter to increase the precision of your semantic search results. If you’d like to test the examples provided in this blog post, go to <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/ensuring-semantic-precision-with-minimum-score/ensuring_semantic_precision_with_minimum_score.ipynb">the associated Jupyter notebook</a>.</p><h2>Background: Precision and recall</h2><p>In search relevance, <em>precision </em>and <em>recall </em>are key concepts. Any reader not already familiar is highly encouraged to read up on them. Following is a summary.</p><ul><li><strong>Precision: </strong>The fraction of returned search results that are relevant to the user.</li><li><strong>Recall: </strong>The fraction of all relevant documents in the corpus that are included in the search result set.</li></ul><p>Or, in other words, precision is returning <strong>only </strong>relevant results; and recall is returning <strong>all </strong>relevant results. As you can imagine, these are often competing requirements. Semantic search tends to have very high recall but can struggle with precision. Keep reading to learn how to get around this property.</p><h2>Introducing the minimum score parameter</h2><p>The ‘min_score’ parameter allows us to improve precision by setting a minimum score, which will truncate the result set by removing any matches with a score less than the defined threshold. Following is a simple example:</p><pre><code>GET search-movies/_search
{
  "retriever": {
    "linear": {
      "min_score": 4,
      "retrievers": [
        ...
      ]
    }
  }
}</code></pre><h2>Normalizing the score</h2><p>Setting a minimum score is all well and good; however, not all semantic models return a score suitable for a static threshold. ELSER, for example, returns a score that is unbounded. <a href="https://huggingface.co/intfloat/e5-small#faq">Some</a> dense model scores are densely clustered and only make sense in the context of the specific query.</p><p>For most semantic search cases, we recommend using a normalization approach before applying the ‘min_score’. The normalization ensures that the document score is within a defined interval. Elasticsearch retrievers provide two such <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/linear-retriever#linear-retriever-normalizers">normalizers</a>, ‘l2_norm’ and ‘minmax’. The most commonly used is ‘minmax’, since it’s easy to understand and works well in many scenarios. Key properties of ‘minmax’ include:</p><ul><li>Document scores are distributed between 0–1.</li><li>The highest scoring document is always scored as 1.</li><li>The lowest scoring document is always scored as 0.<ul><li>This can make it less suitable for keyword search. See “Hybrid search” section for further discussion.</li></ul></li></ul><p>Following is an example of a normalized semantic query with <code>min_score</code>. Rank window size has been increased to 500 to allow us to return a longer list of search results, starting at 100.</p><pre><code>GET search-movies/_search
{
  "size": 100,
  "_source": [
    "title", "overview"
  ],
  "retriever": {
    "linear": {
      "rank_window_size": 500,
      "min_score": 0.25,
      "retrievers": [
        {
          "normalizer": "minmax",
          "retriever": {
            "standard": {
              "query": {
                "semantic": {
                  "field": "overview_vector",
                  "query": "superhero movie"
                }
              }
            }
          }
        }
      ]
    }
  }
}</code></pre><p>The size has been set to a higher value than normally seen in production. This is so we can inspect the quality of search results and tune the results.</p><h2>Hybrid search using the linear retriever</h2><p>For hybrid search, the simplest approach is to normalize all scores, assign weights, and apply a minimum score. Note that by choosing weights with a sum of 1, you keep the total score within a range of 0–1. This makes it easy to make sense of the final scores and tune <code>min_score</code>. Following is an example:</p><pre><code>GET search-movies/_search
{
  "size": 100,
  "_source": ["title", "overview","keywords"],
  "retriever": {
    "linear": {
      "rank_window_size": 500,
      "min_score": 0.25,
      "retrievers": [
        {
          "weight": 0.6,
          "normalizer": "minmax",
          "retriever": {
            "standard": {
              "query": {
                "semantic": {
                  "field": "overview_vector",
                  "query": "superhero movie"
                }
              }
            }
          }
        },
        {
          "weight": 0.4,
          "normalizer": "minmax",
          "retriever": {
            "standard": {
              "query": {
                "multi_match": {
                  "query": "superhero movie",
                  "fields": ["overview","keywords", "title"],
                  "type": "cross_fields",
                  "minimum_should_match": "2"
                }
              }
            }
          }
        }
      ]
    }
  }
}</code></pre><h2>Hybrid search using RRF</h2><p>With BM25, we often control precision through other means, such as using the <code>AND</code> operator or <code>minimum_should_match</code>. In addition, queries consisting of single, precise, and rare terms will naturally cause search results with few search results, often all being highly relevant. This can lead to:</p><ul><li>Results further back in the result get assigned a low normalized score in the BM25 retriever, even if the absolute BM25 score is close to top scoring hits.</li><li>When adding a very low BM25 score to the semantic score, the total can be approximated as the semantic score.</li><li>The lack of BM25 score contribution can cause the document to be discarded by the <code>min_score threshold</code>.</li></ul><p>As a solution, we can instead use reciprocal rank fusion (RRF) to combine BM25 and semantic results. RRF gets around the challenge of comparing scores from different search algorithms by instead focusing on the position in each result set. In this scenario, the <code>min_score</code> is only applied to the semantic retriever.</p><pre><code>GET search-movies/_search
{
  "_source": ["title", "overview","keywords"],
  "retriever": {
    "rrf": {
      "rank_window_size": 500,
      "retrievers": [
        {
          "linear": {
            "rank_window_size": 500,
            "min_score": 0.25,
            "retrievers": [
              {
                "normalizer": "minmax",
                "retriever": {
                  "standard": {
                    "query": {
                      "semantic": {
                        "field": "overview_vector",
                        "query": "superhero movie"
                      }
                    }
                  }
                }
              }
            ]
          }
        },
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "superhero movie",
                "fields": ["overview", "keywords","title"],
                "type": "cross_fields",
                "minimum_should_match": "2"
              }
            }
          }
        }
      ]
    }
  }
}</code></pre><h2>Conclusion</h2><p>By using <code>min_score</code>, we’ve shown how we can reduce the number of false positives in our result sets caused by the high recall of semantic search algorithms. To learn more about retrievers, please see this <a href="https://www.elastic.co/search-labs/blog/elasticsearch-retrievers">blog post</a> and the <a href="https://www.elastic.co/docs/solutions/search/retrievers-overview">Elasticsearch documentation</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dependency management on Kubernetes]]></title>
            <description><![CDATA[How to streamline dependency management on Kubernetes using Renovate CLI and Argo Workflows.]]></description>
            <link>https://www.elastic.co/search-labs/blog/dependency-management-kubernetes</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dependency-management-kubernetes</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Nikos Fotiou]]></dc:creator>
            <pubDate>Thu, 19 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/00519840e6eec7101c1fb096afcae976ee0c454e-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>This is how we built a self-hosted dependency management platform using Kubernetes, Argo Workflows, Argo Events, and Renovate CLI to automate updates, quickly address Common Vulnerabilities and Exposures (CVEs), and efficiently propagate new package versions across thousands of repositories.</p><h2><strong>Dependency management at Elastic</strong></h2><p>At Elastic, we have to manage hundreds or even thousands of repositories, both private and public. When a critical CVE is discovered, we need immediate answers and actions: Which repositories are vulnerable? How quickly can we patch them? Apart from security, productivity questions also arise: How can we quickly propagate the release of a new package version across all the repositories that depend on it without spending too much time on manual tasks?</p><p>The initial trigger for searching ways of doing dependency management was the need to establish a secure foundation with automated updates for <a href="https://www.elastic.co/blog/reducing-cves-in-elastic-container-images">reducing CVEs</a>. After carefully considering solutions on dependency management, we first started working on a self-hosted infrastructure. We were using our own Kubernetes cluster to run Mend Renovate Community Self-Hosted. The idea was to be able to provide a dependency management platform that our users could access in a self-service manner.</p><p>The initial experiment was successful, so more and more teams started onboarding our platform and using it in their everyday repositories’ lifecycle for updates and CVE patching. This happened so fast that we soon hit the ceiling of our self-hosted installation.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e14d9f98d4eccaa08a335d5bd23d88e5debbb344-1600x1103.png" style="width: 100%;" /><h3><strong>The challenge: How can we scale a dependency management platform in a large organization with a significant number of repositories?</strong></h3><p>Our dependency management platform was processing one repository at a time and the sequential processing model couldn’t keep up, due to the large number of repositories that we own. We had already identified that the issue resided within the concept that <strong>a single instance</strong> of our dependency management tool could process our big and ever-growing list of repositories. Repositories waited in a queue, sometimes for many hours. More than 50% of our repositories were not even processed daily. That means that more than 50% of our repositories waited more than 24 hours between scans.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/45ade5bda08f82bed0b3d0d3736cbd6f056e7a4e-1312x816.jpg" style="width: 100%;" /><p>Large repositories created larger bottlenecks, due to their sizable codebases and their multiple open PRs. GitHub webhook events disrupted the sequence. Automerge became unreliable because scan timing was unpredictable. We had made a promise to our users for the frequency of scans, and we couldn’t fulfill it.</p><h3><strong>The decision to build in-house: Meeting Elastic&#x27;s unique scale and security needs</strong></h3><p>While we considered commercial options, including <strong>Mend&#x27;s Renovate Self-Hosted Enterprise Self-Hosted edition</strong>, internally at Elastic we had a few key initiatives ramping up.</p><p>Our decision to build an in-house platform was driven by the recognition that only a deeply customized solution could meet Elastic&#x27;s specific, nonnegotiable requirements:</p><ol><li><strong>Investing in our internal developer platform:</strong> At the time, we had already started heavily investing in our internal developer platform. We were discussing and designing ways that each one of our services could fit into that. This meant that we wanted to test-drive our own rules and practices for our dependency management platform. On top of that, new guidelines were coming into play and we wanted to design the platform ahead of events.</li><li><strong>Native integration and workflow customization:</strong> We required straightforward integration with our internal tooling and internal processes. For example, we wanted to centralize configuration as code with our Service Catalog (Backstage). We have specific needs around the usage of Backstage that we wanted to make our platform compatible with. So, although it would be possible to make use of the Renovate Self-Hosted APIs alongside our Backstage automation, this wouldn’t cover completely for our internal processes.</li><li><strong>Elastic-specific defense-in-depth security:</strong> Our stringent security compliance required bespoke security mechanisms tailored to our ecosystem. We were working to <a href="https://entro.security/blog/how-elastic-scaled-secrets-nhi-security-elastics-playbook-from-visibility-to-automation/">harden our usage of “non-human identities.”</a> The way this hardening of access worked meant that the nonstandard means to authenticate to GitHub wouldn’t work with an off-the-shelf tool that didn’t support this internal implementation.Our workflow included implementing a parent-child workflow secret encryption pattern and using transient, single-use GitHub tokens. Building in-house was the only practical way to embed these unique security layers and minimize the attack surface across our complex multicloud environment.</li></ol><h2><strong>The solution: Workflow orchestration for dependency management</strong></h2><p>Our solution started from the fact that we wanted to build on the dependency management tool that we already used and not replace it and look for other solutions. It had shown signs of its potential, and its flexibility is important for different needs throughout our organization. We considered different solutions, and what helped us make up our minds was the big and sometimes special needs that we have to cover for. We decided to build a reliable and scalable dependency management platform, where each repository will be processed on its own, removing bottlenecks and setting us up for growth.</p><p>We designed the platform abiding to three core principles:</p><h3><strong>1. Parallel processing</strong></h3><p>Every repository gets its own dependency management processing environment. No more queues. Our concurrency is only limited by the number of resources we spend. We have also applied smart distributed scheduling to avoid getting rate limited by GitHub.</p><h3><strong>2. Self-serviceable</strong></h3><p>We use our Service Catalog (Backstage) to automatically onboard and manage any new repository. We use our own resource definition to give the end user the option to select how often a repository will be processed, how many resources they want to allocate to their schedules, and if they want to turn processing off or back on for any reason. We plan to add more options that way as our users’ needs evolve and they get more fluent with the new installation.</p><h3><strong>3. Reduced secret scope and namespace isolation</strong></h3><p>For increased security, we supply our dependency management pods with ephemeral GitHub tokens that are being generated at the start of each workflow. On top of that, we isolate our workloads in specific namespaces so they can be provided only the necessary secrets. We control what secrets can be accessed by each dependency management workflow using Kubernetes RBAC. We also use encryption to propagate the GitHub token from the parent to the child workflows.</p><p>We rebuilt our platform using Kubernetes and harnessing the power of Kubernetes, Argo Workflows powers the logic of our processes, and Renovate CLI is set up for scanning and processing one repository at a time.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5560ed20e2bd9ecdd574a9c835126d12b24c332f-1600x1157.png" style="width: 100%;" /><p><strong>The beauty:</strong> We’re using battle-tested open source projects in an original way, providing new working examples for all of those projects and, at the same time, amplifying development velocity and consolidating CVE reduction for our teams.</p><h2><strong>Dependency management architecture: Four microservices</strong></h2><p>The platform comprises four custom-built components:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2b3d4046c05bb261e45d40c59f864eb51fb9eaa9-1217x1600.png" style="width: 100%;" /><h3><strong>Workflows Operator (Go/Kubebuilder)</strong></h3><p>A Kubernetes operator managing workflow lifecycle through three Custom Resource Definitions (CRDs):</p><ul><li><strong>RepoConfig CRD:</strong> Single source of truth for repository configuration.</li></ul><p>This is how RepoConfig is defined in the operator:</p><pre><code>// RepoConfig is the Schema for the repoconfigs API
type RepoConfig struct {
	metav1.TypeMeta `json:",inline"`

	// metadata is a standard object metadata
	// +optional
	metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`

	// spec defines the desired state of RepoConfig
	// +required
	Spec RepoConfigSpec `json:"spec"`

	// status defines the observed state of RepoConfig
	// +optional
	Status RepoConfigStatus `json:"status,omitempty,omitzero"`
}</code></pre><p>And this is what an instance of RepoConfig would look like:</p><pre><code>apiVersion: workflows.elastic.co/v1
kind: RepoConfig
metadata:
  generation: 3
  name: elastic-test-repo
  namespace: dependency-management-operator
spec:
  owner: group:my-team
  renovate:
    config:
      resourceGroup: SMALL
      runFrequency: 4h
    enabled: true
  repository: elastic/test-repo</code></pre><ul><li><strong>Parent CRD:</strong> Manages CronWorkflows for scheduled scans.</li></ul><p>Inside the reconciliation loop of the parent controller, we make sure that workflow settings are created and kept up to date or even deleted if needed.</p><p>First, it gets some globally configured settings for workflows:</p><pre><code>func (r *ParentReconciler) reconcileSubResources(ctx context.Context, req ctrl.Request, parent *workflowsv1.Parent) error {
	logger := logf.FromContext(ctx)
	logger.Info("Reconcile SubResources for Parent", "name", req.NamespacedName)
	wfSet := workflowsettings.WorkflowSettings{
		RunFrequency:   parent.Spec.RunFrequency,
		ResourceGroups: "parent",
	}</code></pre><p>It makes sure a mutex configmap is up to date to prevent similar workflows from running together:</p><pre><code>	cfMngr := resources.NewConfigMapManager(r.Client, r.Scheme, r.OperatorConfig.ParentNamespace)
	err := cfMngr.CreateOrUpdateSyncMutexConfigmap(ctx, fmt.Sprintf("%s%s", r.OperatorConfig.ResourcesPrefix, r.OperatorConfig.SyncMutexCfgMapName), strings.TrimPrefix(parent.Spec.Repository, "elastic/"), r.OperatorConfig.SemaphoreConcurrencyLimit)</code></pre><p>Then it creates a Workflow Manager that’s the struct which will create or update the CronWorkflows and the Workflow Templates:</p><pre><code>	wfMngr := resources.NewArgoWorkflowManager(r.Client,
		r.Scheme,
		curateResourceName(
			strings.ReplaceAll(parent.Spec.Repository, "/", "-"),
		),
		parent.Namespace,
		"parent-workflow",
		false).
		WithOrganization(r.OperatorConfig.GitHubOrg).
		WithRepoName(parent.Spec.Repository).
		Init(true, true).
		WithPrefix(r.OperatorConfig.ResourcesPrefix).
		WithWfTemplateName(r.OperatorConfig.ParentWorkflowTemplate).
		WithResources(wfSet.GetResourceCategory()).
		WithSchedule(wfSet.GetCronSchedule()).
		WithImagePullSecrets([]corev1.LocalObjectReference{{
			Name: r.OperatorConfig.WorkflowImagePullSecrets,
		}}).
		AddArgument(true, true, "extra_cli_args").
		SetArgument(true, false, "extra_cli_args", "none").
		AddTemplate(resources.NewParentDAGTemplateInstance()).
		AddTemplate(resources.NewWorkflowsTemplateInstance("check-child-workflows", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
		AddTemplate(resources.NewWorkflowsTemplateInstance("security", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
		AddTemplate(resources.NewWorkflowsTemplateInstance("submit-child-workflow", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector))
	wfMngr.OverWriteCommand("submit-child-workflow", r.OperatorConfig.ChildNamespace)
	wfMngr.OverwriteWfTemplateName("parent-wftmpl")
	wfMngr.AddSynchronization(fmt.Sprintf("%s%s", r.OperatorConfig.ResourcesPrefix, r.OperatorConfig.SyncMutexCfgMapName), "{{workflow.parameters.repo_name}}")
	err = wfMngr.CreateOrUpdateCronWorkflow(ctx)
	if err != nil {
		return fmt.Errorf("failed to create or update cron workflow: %w", err)
	}
	err = wfMngr.CreateOrUpdateWorkflowTemplate(ctx)
	if err != nil {
		return fmt.Errorf("failed to create or update workflow template: %w", err)
	}
	return nil</code></pre><ul><li><strong>Child CRD:</strong> Manages WorkflowTemplates with per-repository resources.</li></ul><p>The child controller has a similar reconciliation duty to the parent, but this time it’s responsible for workflow templates in the child namespace that will be triggered by the parent workflows.</p><pre><code>func (r *ChildReconciler) reconcileSubResources(ctx context.Context, req ctrl.Request, child *workflowsv1.Child) error {
	logger := logf.FromContext(ctx)
	logger.Info("Reconcile SubResources for Child", "name", req.NamespacedName)
	wfSet := workflowsettings.WorkflowSettings{
		ResourceGroups: child.Spec.ResourceCategory,
	}
	wfMngr := resources.NewArgoWorkflowManager(r.Client,
		r.Scheme,
		curateResourceName(
			strings.ReplaceAll(child.Spec.Repository, "/", "-"),
		),
		child.Namespace,
		"runner",
		true).
		Init(false, true). // only manage workflow template
		WithPrefix(r.OperatorConfig.ResourcesPrefix).
		WithSuffix("-child-wftmpl").
		WithRepoName(child.Spec.Repository).
		WithOrganization(r.OperatorConfig.GitHubOrg).
		WithResources(wfSet.GetResourceCategory()). // will override resources of presets if set
		WithImagePullSecrets([]corev1.LocalObjectReference{{
			Name: r.OperatorConfig.WorkflowImagePullSecrets,
		}}).
		AddTemplate(resources.NewWorkflowsTemplateInstance("runner", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
		AddArgument(false, true, "repo_full_name").
		AddArgument(false, true, "repo_name").
		AddArgument(false, true, "encrypted_token").
		AddArgument(false, true, "extra_cli_args")
	wfMngr.OverWriteCommand("runner", r.OperatorConfig.ChildNamespace)
	err := wfMngr.CreateOrUpdateWorkflowTemplate(ctx)
	if err != nil {
		return fmt.Errorf("failed to create or update workflow template: %w", err)
	}
	return nil
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ac70492a1266ba02cb8afbafc5a486cb38a0edc-1600x1290.png" style="width: 100%;" /><p>The multi-controller pattern provides clear separation: RepoConfig Controller handles onboarding/offboarding, Parent Controller manages scheduling, and Child Controller handles execution templates.</p><h3><strong>GitHub Events Gateway (Go)</strong></h3><p>A secure webhook proxy that receives GitHub webhooks, verifies signatures, filters by organization/repository, and routes to Argo Events. We built 10 distinct sensors responding to dependency dashboard interactions, PR events, and package updates.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4828625456cbd6efa8020a20f10d23f294f98a02-1306x1600.png" style="width: 100%;" /><p>This gateway enables integration with GitHub Apps by:</p><ul><li>Verifying incoming GitHub webhook signatures for security.</li><li>Forwarding valid events to the Argo Events EventSource with all relevant headers and authentication.</li><li>We also configure an authSecret on the EventSource and provide this as a Bearer header in forwarded requests.</li><li>Providing logging, metrics, and retry logic.</li></ul><p>It performs various validations on each GitHub Event request.</p><p>It makes sure some HTTP attributes are present:</p><pre><code>// ValidateRequestMethod checks if the request method is POST.
func ValidateRequestMethod(r *http.Request) error {
	if r.Method != http.MethodPost {
		return fmt.Errorf("method not allowed, only POST is accepted")
	}
	return nil
}

// ValidateRequiredHeaders checks for required GitHub headers.
func ValidateRequiredHeaders(r *http.Request) error {
	eventType := r.Header.Get("X-GitHub-Event")
	deliveryID := r.Header.Get("X-GitHub-Delivery")
	signature := r.Header.Get("X-Hub-Signature-256")
	if eventType == "" || deliveryID == "" || signature == "" {
		return fmt.Errorf("missing required GitHub headers")
	}
	return nil
}

// ValidateUserAgent checks that the User-Agent header starts with GitHub-Hookshot/
func ValidateUserAgent(r *http.Request) error {
	userAgent := r.Header.Get("User-Agent")
	if !strings.HasPrefix(userAgent, "GitHub-Hookshot/") {
		return fmt.Errorf("invalid User-Agent")
	}
	return nil
}</code></pre><p>While it also validates the signature of each request and its organizsation:.</p><pre><code>// ValidateSignature verifies the GitHub webhook signature.
func ValidateSignature(r *http.Request, secret string) ([]byte, error) {
	payload, err := GitHub.ValidatePayload(r, []byte(secret))
	if err != nil {
		return nil, fmt.Errorf("invalid GitHub signature: %w", err)
	}
	return payload, nil
}

// ValidateAllowedOwner checks if the organization login is in the allowed organizations list.
func ValidateAllowedOwner(payload []byte, allowedGitHubOrganizations []string) (string, error) {
	var orgLogin string
	var payloadMap map[string]any
	if err := json.Unmarshal(payload, &payloadMap); err == nil {
		if orgObj, ok := payloadMap["organization"].(map[string]any); ok {
			if login, ok := orgObj["login"].(string); ok {
				orgLogin = login
			} else if name, ok := orgObj["name"].(string); ok {
				orgLogin = name
			}
		}
	}
	if !slices.Contains(allowedGitHubOrganizations, orgLogin) {
		return orgLogin, fmt.Errorf("organization login not allowed")
	}
	return orgLogin, nil
}</code></pre><p>Finally, it routes to Argo Events based on event type:</p><pre><code>	// Map eventType to Argo `EventSource` path
	var endpoint string
	switch eventType {
	case "push":
		endpoint = "/push"
	case "issues":
		endpoint = "/issues"
	case "pull_request":
		endpoint = "/pull-requests"
	default:
		slog.Info("Ignoring unhandled event type", "event_type", eventType, "delivery_id", deliveryID)
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
		return
	}
	forwardURL := h.config.ArgoEventSourceForwardURL + endpoint</code></pre><p>On the Argo Events side of things, 10 sensors watch the Argo Events EventBus for new events:.</p><pre><code>apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
  name: {{ .Values.sensors.packageUpdateOnDefaultBranch.name }}
  namespace: {{ .Release.Namespace }}
spec:
  eventBusName: {{ .Values.eventBus.name }}</code></pre><p>Then the script applies each sensor’s logic:</p><pre><code>script: |
          local e = event
          if not e or not e.body or not e.body.repository then
            return false
          end

          -- e.g., "refs/heads/main"
          local ref = e.body.ref
          local default_branch = e.body.repository.default_branch
          if not ref or not default_branch then
            return false
          end

          local expected = "refs/heads/" .. default_branch
          if ref ~= expected then
            return false
          end

        {{- if .Values.sensors.packageUpdateOnDefaultBranch.packageFiles }}
          patterns = { {{- range $i, $f := .Values.sensors.packageUpdateOnDefaultBranch.packageFiles }}{{ if $i }}, {{ end }}"{{ $f }}"{{- end }} }
        {{- end }}

          local function anyMatch(path)
            if type(path) ~= "string" then return false end
            for _, pat in ipairs(patterns) do
              -- match filename at repo root, or anywhere under subdirs
              if path:match(pat) or path:match(".+/" .. pat) then
                return true
              end
            end
            return false
          end

          local function filesContainPackage(paths)
            if type(paths) ~= "table" then return false end
            for _, p in ipairs(paths) do
              if anyMatch(p) then return true end
            end
            return false
          end

          -- Inspect all commits (GitHub includes added/modified/removed lists)
          local commits = e.body.commits
          if type(commits) ~= "table" then
            -- Fallback: some payloads include only head_commit
            commits = {}
            if type(e.body.head_commit) == "table" then
              table.insert(commits, e.body.head_commit)
            end
          end

          for _, c in ipairs(commits) do
            if filesContainPackage(c.added) or filesContainPackage(c.modified) or filesContainPackage(c.removed) then
              return true
            end
          end

          return false</code></pre><h3><strong>Backstage Syncer (Go)</strong></h3><p>This polls our Service Catalog (Backstage) for Repository Real Resource Entities, transforms them into RepoConfig CRDs, and keeps the platform in sync with configuration changes. Changes apply within three minutes.</p><pre><code>repoMap := make(map[string]map[string]interface{})
			for i := range entities {
				entity := &entities[i]
				if entity.Spec.Type != "GitHub-repository" {
					continue
				}

				implRaw, err := json.Marshal(entity.Spec.Implementation)
				if err != nil {
					logger.Error("Failed to marshal implementation", "error", err)
					continue
				}

				var implMap map[string]interface{}
				err = json.Unmarshal(implRaw, &implMap)
				if err != nil {
					logger.Error("Failed to unmarshal implementation map", "error", err)
					continue
				}
				var repoName string
				if specMap, ok := implMap["spec"].(map[string]interface{}); ok {
					if repo, ok := specMap["repository"].(string); ok {
						repoName = repo
					}
				}
				if repoName == "" {
					continue
				}

				var workflowsRaw []byte
				if v, ok := implMap["spec"].(map[string]interface{}); ok {
					if r, ok := v["renovate"]; ok {
						workflowsRaw, _ = json.Marshal(r)
					} else {
						workflowsRaw = []byte(`{}`)
					}
				} else {
					workflowsRaw = []byte(`{}`)
				}

				var workflowsWithDefaults schema.WorkflowsMetadata
				err = json.Unmarshal(workflowsRaw, &rworkflowsWithDefaults)
				if err != nil {
					logger.Error("Failed to unmarshal workflows config", "error", err)
					continue
				}

				workflowsMap := map[string]interface{}{
					"enabled":        workflowsWithDefaults.Enabled,
					"require_pr":     workflowsWithDefaults.RequirePr,
					"resource_group": string(workflowsWithDefaults.ResourceGroup),
					"run_frequency":  string(workflowsWithDefaults.RunFrequency),
				}
				repoMap[repoName] = map[string]interface{}{
					"renovate": workflowsMap,
					"owner":    entity.Spec.Owner,
				}
			}
			logger.Info("Fetched GitHub Repository data from Backstage", "repository_count", len(repoMap), "status_code", resp.StatusCode)</code></pre><p>Finally, it writes that data into RepoConfig instances.</p><h3><strong>Workflows base (Mixed: JavaScript, Go, Helm)</strong></h3><p>The foundation layer contains Helm charts, JavaScript configs, a Go wrapper for Renovate CLI with encryption support, and a custom APK Indexer for Alpine packages.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/908d19278face3ce1119dbee9146c1264b6e2f30-1600x873.png" style="width: 100%;" /><h2><strong>Self-service configuration</strong></h2><p>Teams configure their repositories declaratively through Backstage:</p><pre><code>spec:
  renovate:
    enabled: true
    config:
      resourceGroup: LARGE      # SMALL | MEDIUM | LARGE  
      runFrequency: "0 */4 * * *"  # Every 4 hours</code></pre><p>Resource groups allocate CPU and memory based on repository size:</p><ul><li><strong>SMALL:</strong> 500m CPU, 1Gi memory.</li><li><strong>MEDIUM:</strong> 1000m CPU, 2Gi memory.</li><li><strong>LARGE:</strong> 2000m CPU, 4Gi memory.</li></ul><p>Configuration is version-controlled, auditable, and applies automatically.</p><h2><strong>The parent-child pattern</strong></h2><p>The execution model uses a parent-child workflow pattern:</p><ul><li><strong>Parent workflow:</strong> Lightweight CronWorkflow running on schedule. Encrypts secrets, determines whether a scan should run, passes configuration to the child.</li><li><strong>Child workflow:</strong> Ephemeral pod where Renovate CLI runs. Allocated resources dynamically, decrypts secrets in isolation, terminates after completion.</li></ul><p>This separation provides security (secrets encrypted at parent level), resource optimization (parents use minimal resources), and scalability (children run in parallel).</p><h2><strong>The results</strong></h2><h3><strong>Performance transformation</strong></h3><ul><li><strong>Before:</strong> One repository at a time, some repositories would not get processed possibly even for a day or more, less than 1,000 scans per day.</li><li><strong>After:</strong> 100+ concurrent scans, usually 8,000 scans and up to 10,000 recorded scans per day, limited only by the amount of resources we’re willing to spend and how we handle GitHub rate limits.</li></ul><h3><strong>Cost efficiency</strong></h3><p>However weird it may sound, running 8,000 pods a day can get you the same result much cheaper than having one long-running pod trying to achieve the same results.</p><p>In the previous setup, we were running a single instance that, on a good day, would perform 500–600 scans. At the same time, due to the fact that different kinds of repositories would be executed on the same pod, we needed to size the pod for the biggest ones. That sizing would be much bigger than our current extra large offering, using 8 CPUs for the pod and 16G of memory.</p><p>To meet the current daily output, the single pod would need to run for 12 days. So comparing the cost of that single pod running for 12 days to 8,000 pods of our “MEDIUM” size running each day, our new design is far more efficient for the same output of scans:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric</td><td style="border: 1px solid #ccc; padding: 4px;">Scenario A (Workflows)</td><td style="border: 1px solid #ccc; padding: 4px;">Scenario B (The long-running single pod)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Setup</td><td style="border: 1px solid #ccc; padding: 4px;">8,000 pods (1 vCPU / 2GB)</td><td style="border: 1px solid #ccc; padding: 4px;">1 pod (8 vCPU / 16 GB)*</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Duration</td><td style="border: 1px solid #ccc; padding: 4px;">10 minutes each</td><td style="border: 1px solid #ccc; padding: 4px;">12 days continuous</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total work time</td><td style="border: 1px solid #ccc; padding: 4px;">1,333 compute hours</td><td style="border: 1px solid #ccc; padding: 4px;">288 compute hours</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total cost</td><td style="border: 1px solid #ccc; padding: 4px;">$65.83</td><td style="border: 1px solid #ccc; padding: 4px;">$113.75</td>
                    </tr>
              </tbody>
            </table>
          <p>However, let’s take into consideration that our default for our workloads is set to “SMALL,” with the great majority running successfully with 0.5 CPU and 1G RAM, and only a few need to change to medium, large. Let’s see what happens if 60% of our workloads are running on “SMALL,” 30% at “MEDIUM,” and 10% at “LARGE,” which is closer to the truth.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric</td><td style="border: 1px solid #ccc; padding: 4px;">Scenario A (Mixed swarm)</td><td style="border: 1px solid #ccc; padding: 4px;">Scenario B (The long runner)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Strategy</td><td style="border: 1px solid #ccc; padding: 4px;">8,000 pods (mixed sizes)</td><td style="border: 1px solid #ccc; padding: 4px;">1 pod (8 vCPU / 16 GB)*</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Duration</td><td style="border: 1px solid #ccc; padding: 4px;">10 minutes each</td><td style="border: 1px solid #ccc; padding: 4px;">12 days continuous</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Total cost</td><td style="border: 1px solid #ccc; padding: 4px;">$52.66</td><td style="border: 1px solid #ccc; padding: 4px;">$113.75</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Savings</td><td style="border: 1px solid #ccc; padding: 4px;">$61.09 (54% cheaper)</td><td style="border: 1px solid #ccc; padding: 4px;">—</td>
                    </tr>
              </tbody>
            </table>
          <p>We can see that, for the same output, we’re far more cost-efficient in our current setup.</p><h3><strong>Enhanced security</strong></h3><ul><li>Ephemeral GitHub tokens (minutes of exposure versus days).</li><li>Namespace isolation with Role-Based Access Control (RBAC) boundaries.</li><li>Secret encryption at rest in parent workflows.</li><li>Removed direct vault access.</li></ul><h3><strong>Predictable performance</strong></h3><p>With guaranteed scan frequency, we can finally set Service Level Objectives (SLOs). Automerge works reliably. Teams trust the platform to deliver what’s promised.</p><h2><strong>Key architectural decisions</strong></h2><p>Here are some of the milestone design decisions that shaped how the platform looks.</p><ul><li><strong>Why parent-child workflows?</strong></li></ul><p>We adopted this pattern to enforce a <strong>defense-in-depth</strong> strategy. By restricting high-value credentials (such as GitHub App secrets) to a dedicated, locked-down namespace, we use <strong>RBAC</strong> to ensure that ephemeral execution pods cannot arbitrarily access sensitive data. Recent supply chain vulnerabilities (for example, the <strong>&quot;Shai Hulud&quot;</strong> continuous integration/continuous delivery [CI/CD] attacks) have demonstrated the criticality of isolating runtime environments that execute dynamic scripts from the credential store.</p><p>Simultaneously, this decoupling enables <strong>granular resource optimization</strong>. The &quot;parent&quot; workflows act as lightweight orchestrators with a minimal footprint, while the &quot;child&quot; workflows handle the compute-intensive dependency scanning. This separation simplifies <strong>lifecycle management</strong> by allowing us to apply distinct reconciliation logic to each layer, granting users control over execution parameters (child) while retaining administrative control over the scheduling and security infrastructure (parent).</p><ul><li><strong>Why self-serviceable?</strong></li></ul><p>Eliminating our team as a bottleneck for repository configuration was a critical requirement. Our mission was to architect a scalable, <strong>self-service platform</strong> capable of supporting diverse use cases. We recognized that acting as <strong>gatekeepers</strong> for every configuration change was unsustainable, given the sheer volume of repositories. Instead, we adopted a philosophy of enablement: providing the “rails” (infrastructure and <strong>guardrails</strong>) while empowering users to drive the “trains” (execution and customization). We believe this shift toward <strong>team autonomy</strong> significantly enhances productivity by allowing users to tailor the system to their specific operational needs.</p><ul><li><strong>Why Kubernetes Operator pattern?</strong></li></ul><p>As mentioned above, a foundational design principle was to ensure that the platform was fully <strong>self-serviceable</strong>. We required an automated mechanism to capture user intent (such as toggling scans, adjusting scheduling frequency, or tuning runtime resource limits) and instantly propagate those changes to the underlying workflows. Anticipating future requirements, the system also needed to be easily <strong>extensible</strong>.</p><p>To achieve this, we developed a custom <strong>Dependency Management Kubernetes Operator</strong>. By using <strong>CRDs</strong> as the interface for configuration, we established a <strong>Kubernetes-native reconciliation loop</strong>. This operator continuously monitors the desired state defined by the user and automatically orchestrates the necessary updates to the workflow infrastructure. This ensures an <strong>event-driven</strong>, seamless operation, where the platform logic handles all complexity behind the scenes.</p><ul><li><strong>Why design a GitHub Events Gateway?</strong></li></ul><p>Adopting an <strong>event-driven architecture (EDA)</strong> was essential for the platform&#x27;s responsiveness. While CronWorkflows provided a reliable baseline schedule, we required the agility to handle <strong>ad hoc executions, </strong>such as users manually triggering scans via the dashboard. To achieve this, we needed a dedicated <strong>ingestion gateway</strong> to validate payload integrity and route requests intelligently.</p><p>We evaluated existing solutions, including the native GitHub EventSource for Argo, but we identified significant risks regarding <strong>operational overhead</strong> and strict <strong>GitHub API quotas</strong> (for example, webhook limits per repository). Consequently, we built a custom gateway to decouple our infrastructure from these limitations.</p><p>Crucially, this gateway served as a strategic <strong>traffic control point</strong> during our migration. It acted as a switch, enabling us to perform a <strong>gradual, granular rollout</strong> (traffic shifting) from the legacy system to the new infrastructure. This ensured that onboarding thousands of repositories was a controlled, risk-free process rather than a “big bang” switchover.</p><p></p><h2><strong>Lessons learned</strong></h2><p>Some lessons that we learned go hand-in-hand with the <a href="https://www.elastic.co/about/our-source-code">Elastic Source Code</a>:</p><ol><li><strong>Customer First: </strong>Platforms are built for users. So it’s important to take users’ needs as priority number one. This shapes the platform into efficiently designed infrastructure and applications that reduce friction with users, simplify the scaling of the platform and ease adoption.</li><li><strong>Space, Time: </strong>Sometimes the path of least resistance leads to <strong>shifting sands</strong>. We initially tried to optimize the existing sequential processing model, but this failed to resolve our issues; in fact, it only introduced more complexity and loose ends. The bold decision to <strong>rearchitect</strong> the platform with parallel processing required significant up-front effort. However, it ultimately paved the way for sustainable platform growth and virtually eliminated tedious daily administrative work.</li><li><strong>IT, Depends: </strong>A platform cannot operate in isolation; its success depends on how well it integrates with the broader ecosystem. In our case, integration with <strong>Backstage</strong> was critical, as it serves as the source of truth for seamless service onboarding. Similarly, connecting to <strong>Artifactory</strong> allowed us to manage private package updates efficiently, and the list of essential integrations goes on.</li><li><strong>Progress, SIMPLE Perfection: </strong>Throughout the implementation, we constantly pressure-tested our initial assumptions and adapted to new barriers as they emerged. Rather than getting paralyzed by perfectionism, we adopted an <strong>iterative approach</strong>, tackling challenges one by one and adjusting our migration strategy to meet real-world conditions.</li></ol><h2><strong>What’s next</strong></h2><p>The delivery of the platform enables us for more meaningful work that will help us improve the UX and efficiency of our platform. Some examples are:<br/></p><ul><li><strong>Increase and guardrail the adoption of auto-merge</strong></li></ul><p>The auto-merge feature significantly accelerates team velocity by eliminating tedious manual tasks. However, we need to make sure that strict <strong>guardrails</strong> are in place to ensure that this increased speed does not come at the expense of security.<br/></p><ul><li><strong>Improve observability around end-user experience</strong></li></ul><p>A critical priority for our roadmap is enhancing observability, not just at the platform level but also specifically from the <strong>end-user’s perspective</strong>. While capturing infrastructure metrics is straightforward, understanding the actual user experience requires deeper insights. We’re working to define core user-centric key performance indicators (KPIs) so our telemetry can detect friction points and performance issues <strong>before</strong> they escalate into user complaints.</p><ul><li><strong>Remove barriers for greater adoption</strong></li></ul><p>Looking ahead, our priority is to identify and remove any barriers hindering platform adoption. Whether this requires developing new integrations or deploying specific feature sets, we’re committed to data-driven planning. We’ve successfully built a platform designed for scale; our focus now shifts to <strong>maximizing its potential</strong>.<br/></p><h2><strong>The bigger picture</strong></h2><p>The dependency management workflows project demonstrates a broader principle: <strong>When you need to scale open source tools beyond their default deployment model, Kubernetes-native patterns provide a path forward</strong>.</p><p>By embracing:</p><ul><li>CRDs for configuration.</li><li>Operators for lifecycle management.</li><li>Event-driven architecture for responsiveness</li><li>GitOps for deployment.</li></ul><p>We built orchestration that scales independently of the number of repositories it manages. The performance of scanning one repository is the same whether we’re managing 100 or 1,000.</p><p>When a critical CVE is announced, we now have answers in minutes, not hours. That’s the difference between a bottleneck and a competitive advantage.</p><h2><strong>Acknowledgments</strong></h2><p>This platform builds on excellent open source tools:</p><ul><li><strong>Kubebuilder:</strong> The open source framework we used to kick-start our Kubernetes Operators that bootstrap and orchestrate our workflows. [<a href="https://github.com/kubernetes-sigs/kubebuilder">1</a>][<a href="https://book.kubebuilder.io/">2</a>]</li><li><strong>Backstage:</strong> The open source framework on which we’ve built our Service Catalog and which we use as our source of truth. [<a href="https://github.com/backstage/backstage">1</a>][<a href="https://backstage.io/">2</a>]</li><li><strong>Argo Workflows and Argo Events:</strong> The open source suite we used to orchestrate complex processes and add dynamic processing based on events. [<a href="https://github.com/argoproj/argo-workflows">1</a>][<a href="https://argo-workflows.readthedocs.io/en/stable/">2</a>][<a href="https://argoproj.github.io/argo-events/">3</a>][<a href="https://github.com/argoproj/argo-events">4</a>]</li><li><strong>Renovate CLI:</strong> The open source dependency management tool processing our repositories. [<a href="https://github.com/renovatebot/renovate">1</a>][<a href="https://docs.renovatebot.com/getting-started/running/">2</a>]</li></ul><p>* The AWS Fargate pricing model was used as a reference for of the cost of a single pod, although our workloads are not running necessarily on AWS and are running on full- blown Kubernetes clusters.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Better text analysis for complex languages with Elasticsearch and neural models]]></title>
            <description><![CDATA[Using neural models and the Elasticsearch inference API to improve search in Hebrew, German, Arabic, and other morphologically complex languages.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-text-analysis-neural-model</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-text-analysis-neural-model</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Noam Schwartz]]></dc:creator>
            <pubDate>Wed, 18 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a572f9832d8ebc603b70743ac8f2d6e4ea8d2e11-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>If you work with English search, standard text analysis usually just works. You index “running,” the analyzer strips the suffix to store “run,” and a user searching for “run” finds the document. Simple.</p><p>But if you work with languages like Hebrew, Arabic, German, or Polish, you know that standard rule-based analyzers often fail. They either under-analyze (missing relevant matches) or overanalyze (returning garbage results).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2484b1f7ce600fbbf75b76a12a67cdfdf9b6e6ab-800x600.jpg" style="width: 100%;" /><p>For years, we’ve had to rely on complex dictionaries and fragile regex rules. Today, we can do better. By replacing rule-based logic with <strong>neural models for text analysis</strong> (small, efficient language models that understand context), we can drastically improve search quality.</p><p>Here’s how to solve the morphology challenge by using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom"><strong>Elasticsearch inference API</strong></a> and a custom model service.</p><h2><strong>The problem: Why rules fail</strong></h2><p>Most standard analyzers are <strong>context-free</strong>. They look at one word at a time and apply a static set of rules.</p><ul><li><strong>Algorithmic analyzers</strong> (like Snowball) strip suffixes based on patterns.</li><li><strong>Dictionary analyzers</strong> (like Hunspell) look up words in a list.</li></ul><p>This approach breaks down when the structure of a word (its root and affixes) changes based on the sentence it lives in.</p><h3><strong>1. The semitic ambiguity (roots versus prefixes)</strong></h3><p>Semitic languages, like Hebrew and Arabic, are built on root systems and often attach prepositions (such as, in, to, or from) directly to the word. This creates ambiguous tokens that rule-based systems cannot solve.</p><ul><li><strong>Word:</strong> <code>בצל</code> (B-Tz-L).</li><li><strong>Context A:</strong> “The soup tastes better with <strong>onion</strong> (<em>batzal</em>).”</li><li><strong>Context B:</strong> “We sat <strong>in the shadow</strong> (<em>ba-tzel</em>) of the tree.”</li></ul><p>In Context A, <code>בצל</code> is a noun (onion). In Context B, it’s a preposition ב (in) attached to the noun <code>צל</code> (shadow).</p><p>A standard analyzer is forced to guess. If it aggressively strips the ב prefix, it turns &quot;onion&quot; into &quot;shadow.&quot; If it’s conservative and leaves it alone, a user searching for &quot;shadow&quot; (<em>tzel</em>) will fail to find documents containing &quot;in the shadow&quot; (<em>batzel</em>). Neural models solve this by reading the sentence to determine whether the ב is part of the root or a separate preposition.</p><h3><strong>2. The compound problem (German, Dutch, and more)</strong></h3><p>Languages like German, Dutch, Swedish, and Finnish concatenate nouns without spaces to form new concepts. This results in a theoretically infinite vocabulary. To search effectively, you must split (decompound) these words.</p><ul><li><strong>Word:</strong> <code>Wachstube</code>.</li><li><strong>Split A:</strong> <code>Wach</code> (guard) + <code>Stube</code> (room) = guardroom.</li><li><strong>Split B:</strong> <code>Wachs</code> (wax) + <code>Tube</code> (tube) = wax tube.</li></ul><p>A dictionary-based decompounder acts blindly. If both “Wach” and “Wachs” are in its dictionary, it might pick the wrong split, polluting your index with irrelevant tokens.</p><p>To see this problem in English: A naive algorithm might split “carpet” into “car” + “pet.” Without understanding meaning, rules fail.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/eefee9dc6206452d362f8f58dc35c793021dcb1e-800x524.jpg" style="width: 100%;" /><h2><strong>The solution: “Neural analyzers” (neural models for text analysis)</strong></h2><p>We don’t need to abandon the inverted index. We just need to feed it better tokens.</p><p>Instead of a regex rule, we use a <strong>neural model</strong> (like BERT or T5) to perform the analysis. Because these models are trained on massive datasets, they understand context. They look at the surrounding words to decide whether <code>בצל</code> means &quot;onion&quot; or &quot;in shadow&quot; or if <code>Wachstube</code> belongs in a military or cosmetic context.</p><h3><strong>Architecture: The inference sidecar</strong></h3><p>We can integrate these Python-based models directly into the Elasticsearch ingestion pipeline using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom"><strong>inference API</strong></a>.</p><p><strong>The pattern:</strong></p><ol><li><strong>External model service:</strong> A simple Python service (for example, FastAPI) hosts the model.</li><li><strong><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom">Elasticsearch inference API</a>:</strong> Defines this service as a custom model within Elasticsearch.</li><li><strong>Ingest pipeline:</strong> Sends text to the inference processor, which calls your Python service.</li><li><strong>Index mapping: </strong>Create a <code>whitespace</code> target field for the analyzed text.</li><li><strong>Indexing:</strong> The service returns the cleaned text, which Elasticsearch stores in the target field.</li><li><strong>Search:</strong> Queries are analyzed via the inference API before matching.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5c3055a1594f267c676347da36b1b8b2b187220c-1600x1248.png" style="width: 100%;" /><h2><strong>Implementation guide</strong></h2><p>Let’s build this for <strong>Hebrew</strong> (using <code>DictaBERT</code>) and <strong>German</strong> (using <code>CompoundPiece</code>).</p><p>To follow along, you’ll need:</p><ul><li>Python 3.10+.</li><li>Elasticsearch 8.9.x+.</li></ul><p>Install the Python dependencies:</p><pre><code>pip3 install fastapi uvicorn torch transformers</code></pre><h3><strong>Step 1: External model service</strong></h3><p>To connect Elasticsearch to our neural model, we need a simple API service that:</p><ol><li>Receives text from the Elasticsearch inference API.</li><li>Passes it through the neural model.</li><li>Returns analyzed text in a format Elasticsearch understands.</li></ol><p>This service interfaces Elasticsearch with the neural model. At ingest time, the Elasticsearch pipeline calls this API to analyze and store document fields; at search time, the application calls it to process the user&#x27;s query. You can deploy this on any infrastructure, including EC2, Lambda, or SageMaker.</p><p>The code below loads both models at startup and exposes <code>/analyze/hebrew</code> and <code>/analyze/german</code> endpoints:</p><pre><code>from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Union
from transformers import AutoTokenizer, AutoModel, AutoModelForSeq2SeqLM
from contextlib import asynccontextmanager
import torch

# Global models (loaded once at startup)
he_model = None
he_tokenizer = None
de_model = None
de_tokenizer = None


@asynccontextmanager
async def lifespan(app: FastAPI):
   """Load models at startup."""
   global he_model, he_tokenizer, de_model, de_tokenizer

   print("Loading Hebrew model (DictaBERT-Lex)...")
   he_tokenizer = AutoTokenizer.from_pretrained("dicta-il/dictabert-lex")
   he_model = AutoModel.from_pretrained("dicta-il/dictabert-lex", trust_remote_code=True)
   he_model.eval()

   print("Loading German model (CompoundPiece)...")
   de_tokenizer = AutoTokenizer.from_pretrained("benjamin/compoundpiece")
   de_model = AutoModelForSeq2SeqLM.from_pretrained("benjamin/compoundpiece")

   if torch.cuda.is_available():
       he_model.to("cuda")
       de_model.to("cuda")

   print("Models loaded successfully!")
   yield
   print("Shutting down...")


app = FastAPI(
   title="Neural Text Analyzer",
   description="Multi-language text normalization service",
   version="1.0.0",
   lifespan=lifespan
)


class InferenceRequest(BaseModel):
   """ES Inference API sends: {"input": ["text1", "text2"]} or {"input": "text"}"""
   input: Union[str, List[str]]


def format_response(normalized_text: str) -> dict:
   """
   Normalize output to OpenAI-compatible format for ES Inference API.
   ES extracts: $.choices[*].message.content You do not need to stick
   with the OpenAI output format.
   Using it here for consistency reasons, since using the completions API.
   """
   return {
       "choices": [
           {"message": {"content": normalized_text}}
       ]
   }


@app.post("/analyze/hebrew")
async def analyze_hebrew(request: InferenceRequest):
   """Hebrew lemmatization using DictaBERT-Lex."""
   global he_model, he_tokenizer

   if he_model is None:
       raise HTTPException(status_code=503, detail="Model not loaded")

   # Handle input (can be string or list)
   if isinstance(request.input, str):
       texts = [request.input]
   else:
       texts = request.input

   # Run prediction
   with torch.no_grad():
       results = he_model.predict(texts, he_tokenizer)

   # results format: [[[word, lemma], [word, lemma], ...]]
   if results and results[0]:
       lemmas = []
       for word, lemma in results[0]:
           if lemma == '[BLANK]':
               lemma = word
           lemmas.append(lemma)
       normalized = " ".join(lemmas)
   else:
       normalized = ""

   return format_response(normalized)


@app.post("/analyze/german")
async def analyze_german(request: InferenceRequest):
   """German decompounding using CompoundPiece (supports 56 languages)."""
   global de_model, de_tokenizer

   if de_model is None:
       raise HTTPException(status_code=503, detail="Model not loaded")

   # Handle input
   if isinstance(request.input, str):
       text = request.input
   else:
       text = request.input[0] if request.input else ""

   # Format: "de: <word>" for German
   input_text = f"de: {text}"

   inputs = de_tokenizer(input_text, return_tensors="pt")
   if torch.cuda.is_available():
       inputs = {k: v.to("cuda") for k, v in inputs.items()}

   with torch.no_grad():
       outputs = de_model.generate(**inputs, max_length=128)

   # IMPORTANT: decode outputs[0], not outputs
   result = de_tokenizer.decode(outputs[0], skip_special_tokens=True)

   # Clean up: "de: Donau-Dampf-Schiff" -> "Donau Dampf Schiff"
   # Note: model returns "de: " (with space after colon)
   if result.startswith("de: "):
       clean_result = result[4:].replace("-", " ")
   elif result.startswith("de:-"):
       clean_result = result[4:].replace("-", " ")
   elif result.startswith("de:"):
       clean_result = result[3:].replace("-", " ")
   else:
       clean_result = result.replace("-", " ")

   return format_response(clean_result.strip())


@app.get("/health")
async def health():
   return {"status": "healthy"}</code></pre><p>Save the code above to a file (for example, <code>analyzer_service.py</code>), and run:</p><pre><code>python3 -m uvicorn analyzer_service:app --port 8000</code></pre><p>Wait for “<em>Models loaded successfully!</em>” (takes ~30–60 seconds for models to download on first run).</p><p>Test locally:</p><pre><code>#Hebrew
curl -X POST http://localhost:8000/analyze/hebrew \
 -H "Content-Type: application/json" \
 -d '{"input": "הילדים אכלו גלידה בגינה"}'</code></pre><pre><code>#German
curl -X POST http://localhost:8000/analyze/german \
 -H "Content-Type: application/json" \
 -d '{"input": "Donaudampfschifffahrt"}'</code></pre><p>Expected output:</p><pre><code>- Hebrew: `{"choices":[{"message":{"content":"ילד אוכל גלידה גינה"}}]}`
- German: `{"choices":[{"message":{"content":"Donau Dampf Schiff Fahrt"}}]}`</code></pre><h3><strong>Step 2: Configure Elasticsearch inference API</strong></h3><p>We’ll use the<a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom"> <code>custom</code></a> inference endpoint. This allows us to define exactly how Elasticsearch talks to our Python endpoint.</p><p><strong>Note:</strong> Use <code>response.json_parser</code> to extract the content from our normalized JSON structure. You <strong>do not</strong> need to stick with the OpenAI output format. We’re using it here for consistency reasons, since we’re using the <em>completion</em> task type, which is text to text.</p><h4><strong>Exposing your local service</strong></h4><p>For testing, we’ll use <a href="https://ngrok.com">ngrok</a> to expose the local Python service to the internet. This allows any Elasticsearch deployment (self-managed, Elastic Cloud, or Elastic Cloud Serverless) to reach your service.</p><p>Install and run ngrok:</p><pre><code># Install ngrok (macOS) (Or download from https://ngrok.com/download)
brew install ngrok</code></pre><p>Expose your local service:</p><pre><code>ngrok http 8000</code></pre><p>ngrok will display a forwarding URL like:</p><p>Forwarding <a href="https://abc123.ngrok.io">https://abc123.ngrok.io</a> -&gt; <a href="http://localhost:8000">http://localhost:8000</a></p><p>Copy the HTTPS URL. You’ll use this in the Elasticsearch configuration.</p><p><strong>Configure the inference endpoint</strong></p><pre><code> PUT _inference/completion/hebrew-analyzer                           
 {                                  
   "service": "custom",                                              
   "service_settings": {                             
     "url": "https://abc123.ngrok.io/analyze/hebrew",  
     "headers": {                    
       "Content-Type": "application/json"               
     },                                                
     "request": "{\"input\": ${input}}",                     
     "response": {                                
       "json_parser": {                         
         "completion_result": "$.choices[*].message.content"     
       }                               
     }                                 
   }                                   
 }</code></pre><p>Replace <a href="https://abc123.ngrok.io">https://abc123.ngrok.io</a> with your actual ngrok URL.</p><p><strong>Note:</strong> ngrok is used here for fast testing and development. The free tier has request limits, and URLs change on restart. For production, deploy your service to a persistent infrastructure.</p><h4><strong>For production (with API Gateway)</strong></h4><p>In production, deploy your Python service to a secure, persistent endpoint (such as AWS API Gateway + Lambda, EC2, ECS, or any cloud provider). Use <code>secret_parameters</code> to securely store API keys:</p><pre><code> PUT _inference/completion/hebrew-analyzer                        
 {                                     
   "service": "custom",                  
   "service_settings": {                
     "url": "https://your-api-gateway.execute-api.region.amazonaws.com/prod/analyze/hebrew",                 
     "headers": {                      
       "x-api-key": "${api_key}",       
       "Content-Type": "application/json"  
     },                              
     "secret_parameters": {           
       "api_key": "YOUR-API-KEY"     
     },                           
     "request": "{\"input\": ${input}}",      
     "response": {                    
       "json_parser": {               
         "completion_result": "$.choices[*].message.content"  
       }                             
     }                               
   }                                 
 }</code></pre><h3><strong>Step 3: Ingest pipeline</strong></h3><p>Create a pipeline that passes the raw text field to our model and stores the result in a new field.</p><pre><code>PUT _ingest/pipeline/hebrew_analysis_pipeline
{
 "description": "Lemmatizes Hebrew text using a custom inference endpoint",
 "processors": [
   {
     "inference": {
       "model_id": "hebrew-analyzer",
       "input_output": {
         "input_field": "content",
         "output_field": "content_analyzed"
       }
     }
   }
 ]
}</code></pre><h3><strong>Step 4: Index mapping</strong></h3><p>This is the most critical step. The output from our neural model is already analyzed. We <strong>do not</strong> want a standard analyzer to mess it up again. We use the <code>whitespace</code> analyzer to simply tokenize the text we received.</p><pre><code>PUT /my-hebrew-index
{
 "mappings": {
   "properties": {
     "content": {
       "type": "text",
       "analyzer": "standard"
     },
     "content_analyzed": {
       "type": "text",
       "analyzer": "whitespace"
     }
   }
 }
}</code></pre><h3><strong>Step 5: Indexing</strong></h3><p><strong>Option A: Single document.</strong></p><pre><code>POST /my-hebrew-index/_doc?pipeline=hebrew_analysis_pipeline
{
"content": "הילדים אכלו גלידה בגינה"
}</code></pre><p><strong>Option B: Reindex existing data.</strong></p><p>If you have existing data in another index, reindex it through the pipeline:</p><pre><code>POST _reindex
{
 "source": {
   "index": "my-old-index"
 },
 "dest": {
   "index": "my-hebrew-index",
   "pipeline": "hebrew_analysis_pipeline"
 }
}</code></pre><p><strong>Option C: Set pipeline as default for index.</strong></p><p>Make all future documents automatically use the pipeline:</p><pre><code>PUT /my-hebrew-index/_settings
{
"index.default_pipeline": "hebrew_analysis_pipeline"
}</code></pre><p>Then index normally (no <code>?pipeline=</code> needed):</p><pre><code>POST /my-hebrew-index/_doc
{
"content": "הילדים אכלו גלידה בגינה"
}</code></pre><h3><strong>Step 6: Search</strong></h3><p>Search using a neural analyzer in Elasticsearch is a two-step process, so analyze the query first using the inference API, and then search with the result:</p><p><strong>A. Analyze the query.</strong></p><pre><code> POST _inference/completion/hebrew-analyzer
 {
   "input": "הילדים אכלו גלידה בגינה"
 }</code></pre><p><strong>B. Search with the result.</strong></p><pre><code> GET /my-hebrew-index/_search
 {
   "query": {
     "match": {
       "content_analyzed": "ילד אוכל גלידה גינה"
     }
   }
 }</code></pre><p>In production, wrap these two calls in your application code for a seamless experience.</p><h2><strong>Available models</strong></h2><p>The architecture above works for any language. You simply swap the Python model and adjust the post-processing of the output. Here are verified models for common complex languages:</p><ul><li><strong>Hebrew:</strong> Context-aware lemmatization. Handles prefix ambiguity (ב, ה, ל, and more) <a href="https://huggingface.co/dicta-il/dictabert-lex">dicta-il/dictabert-lex</a>.</li><li><strong>German: </strong>Generative decompounding. Supports 56 languages, including Dutch, Swedish, Finnish, and Turkish. <a href="https://huggingface.co/benjamin/compoundpiece">benjamin/compoundpiece</a>.</li><li><strong>Arabic:</strong> BERT-based disambiguation and lemmatization for Modern Standard Arabic. <a href="https://github.com/CAMeL-Lab/camel_tools">CAMeL Tools</a>.</li><li><strong>Polish:</strong> Case-sensitive lemmatization for Polish inflections. <a href="https://huggingface.co/amu-cai/polemma-large">amu-cai/polemma-large</a>.</li></ul><h2><strong>Conclusion</strong></h2><p>You don’t need to choose between the precision of lexical search and the intelligence of AI. By moving the “smart” part of the process into the analysis phase using the inference API, you fix the root cause of poor search relevance in complex languages.</p><p>The tools are here. The models are open-source. The pipelines are configurable. It’s time to teach our search engines to read.</p><h3><strong>Code</strong></h3><p>All code snippets from this article are available at <a href="https://github.com/noamschwartz/neural-text-analyzer">https://github.com/noamschwartz/neural-text-analyzer</a>.</p><p><strong></strong></p><p><strong>References</strong>:</p><ul><li><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom">https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom</a></li><li><a href="https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines">https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines</a></li><li><a href="https://ngrok.com">https://ngrok.com</a></li><li><a href="https://huggingface.co/dicta-il/dictabert-lex">https://huggingface.co/dicta-il/dictabert-lex</a></li><li><a href="https://huggingface.co/benjamin/compoundpiece">https://huggingface.co/benjamin/compoundpiece</a></li><li><a href="https://arxiv.org/pdf/2305.14214">https://arxiv.org/pdf/2305.14214</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[An open‑source Hebrew analyzer for Elasticsearch lemmatization]]></title>
            <description><![CDATA[An open-source Elasticsearch 9.x analyzer plugin that improves Hebrew search by lemmatizing tokens in the analysis chain for better recall across Hebrew morphology.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-lemmatization-hebrew-analyzer</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-lemmatization-hebrew-analyzer</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Lily Adler]]></dc:creator>
            <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8d9f79cec59d89f4e14657db7df846ed3104a2da-1024x565.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Hebrew is morphologically rich: Prefixes, inflections, and clitics make exact-token search brittle. This project provides an open-source Hebrew analyzer plugin for Elasticsearch 9.x that performs neural lemmatization in the analysis chain, using an embedded DictaBERT model executed in-process via ONNX Runtime with an INT8-quantized model.</p><h2><strong>Quick start</strong></h2><p>Download the relevant <a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin/releases">release</a> or build and install (Linux build script generates Elasticsearch‑compatible zip):</p><pre><code>./scripts/build_plugin_linux.sh</code></pre><p>Install in Elasticsearch:</p><pre><code>/path/to/elasticsearch/bin/elasticsearch-plugin install file:///path/to/heb-lemmas-embedded-plugin-<ES_VERSION>.zip</code></pre><p>Test:</p><pre><code>curl -k -X POST "https://localhost:9200/_analyze" \
  -H "Content-Type: application/json" \
  -u "elastic:<password>" \
  -d '{"tokenizer":"whitespace","filter":["heb_lemmas","heb_stopwords"],"text":"הילדים אוכלים את הבננות"}'</code></pre><h2><br/>Why Hebrew search is different</h2><p>Hebrew is morphologically rich: Prefixes, suffixes, inflection, and clitics all collapse into a single surface form. That makes naive tokenization insufficient. Without true lemmatization, search quality suffers; users miss relevant results due to simple variations in form. This project tackles that by embedding a Hebrew lemmatization model inside the analyzer itself, so every token passes through a neural model before indexing and querying.</p><h3>Example</h3><p>Users may search for the lemma “בית” (house), but documents might contain:</p><ul><li>בית (a house)</li><li>בבית (in the house)</li><li>לבית (to the house)</li><li>בבתים (in houses)</li><li>לבתים (to houses)</li></ul><p>Without lemmatization, these become different surface tokens; lemmatization normalizes them toward the same lemma (בית), improving recall:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d3dfd40569b3f4bfc79df639a49ae995e92b0bc1-1600x983.png" style="width: 100%;" /><h2>What this plugin does</h2><p>Rather than relying on rule-based stemming, the analyzer runs a Hebrew lemmatization model as part of the Elasticsearch analysis chain and emits one normalized lemma per token. Because the model is neural, it can use local context within each analyzed segment to choose a lemma in ambiguous cases—while still producing stable tokens that work well for indexing and querying. The analyzer:</p><ul><li>Runs a Hebrew lemmatization model inside Elasticsearch.</li><li>Produces better normalized tokens for Hebrew text.</li><li>Supports stopwords and standard analyzer pipelines.</li></ul><h2>The result: Fast, reliable lemmatization</h2><p>This analyzer is optimized for real‑world throughput:</p><ul><li>ONNX Runtime in‑process inference.</li><li>INT8-quantized model for lower latency and memory footprint.</li><li>Java Foreign Function Interface (FFI) for high‑performance native inference.</li></ul><p>The result: fast, reliable lemmatization with predictable operational behavior.</p><p>To evaluate performance, we ran a benchmark in a Docker container (4 cores, 12 GB RAM) on 1 million large documents (5.7 GB of data) from the Hebrew Wikipedia dataset. You’ll find the results below:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Metric (search)</td><td style="border: 1px solid #ccc; padding: 4px;">Task</td><td style="border: 1px solid #ccc; padding: 4px;">Value</td><td style="border: 1px solid #ccc; padding: 4px;">Unit</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Min throughput</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">409.75</td><td style="border: 1px solid #ccc; padding: 4px;">ops/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Mean throughput</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">490.65</td><td style="border: 1px solid #ccc; padding: 4px;">ops/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Median throughput</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">491.85</td><td style="border: 1px solid #ccc; padding: 4px;">ops/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Max throughput</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">496.13</td><td style="border: 1px solid #ccc; padding: 4px;">ops/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">50th percentile latency</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">7.02242</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">90th percentile latency</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">10.7338</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">99th percentile latency</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">19.0406</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">99.9th percentile latency</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">27.165</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">50th percentile service time</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">7.02242</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">90th percentile service time</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">10.7338</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">99th percentile service time</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">19.0406</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">99.9th percentile service time</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">27.165</td><td style="border: 1px solid #ccc; padding: 4px;">ms</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Error rate</td><td style="border: 1px solid #ccc; padding: 4px;">hebrew-query-search</td><td style="border: 1px solid #ccc; padding: 4px;">0</td><td style="border: 1px solid #ccc; padding: 4px;">%</td>
                    </tr>
              </tbody>
            </table>
          <h2>Open source and Elastic‑ready</h2><p>The plugin is fully open source and works on:</p><ul><li>Elastic open‑source distributions.</li><li>Elastic Cloud.</li></ul><p>You can build it yourself or download prebuilt releases and install it like any other plugin.</p><p>To upload the analyzer plugin to Elastic Cloud, navigate to the <strong>Extensions</strong> section within your Elastic Cloud console and proceed with the upload.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e2e8731aeb884e91624f2c8c0998cf8de08a16e3-1402x1600.png" style="width: 100%;" /><h2>Credits</h2><p>This project is a fork of the Korra ai Hebrew analysis plugin (MIT), which was implemented by <a href="http://Korra.ai">Korra.ai</a> with funding and guidance from the National NLP Program led by MAFAT and the Israel Innovation Authority.</p><p>This fork focuses on Elasticsearch 9.x compatibility and running lemmatization fully in-process via ONNX Runtime, using an INT8‑quantized model and bundled Hebrew stopwords. Lemmatization is powered by DictaBERT <a href="https://huggingface.co/dicta-il/dictabert-lex"><code>dicta-il/dictabert-lex</code></a> (CC‑BY‑4.0).</p><p>Huge thanks to the Dicta team for making high-quality Hebrew natural language processing (NLP) models available to the community.</p><h2>Links</h2><ul><li><a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin">GitHub repo</a></li><li><a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin/releases">Releases</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Elasticsearch 9.3 adds bfloat16 vector support]]></title>
            <description><![CDATA[Exploring the new Elasticsearch element_type: bfloat16, which can halve your vector data storage.]]></description>
            <link>https://www.elastic.co/search-labs/blog/bfloat16-vector-support-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/bfloat16-vector-support-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Simon Cooper]]></dc:creator>
            <pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f80f988d758f53742f6b4cd13b04d0cb27af7a17-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch 9.3 brings with it several important improvements to vector data, including a new <code>element_type: bfloat16</code>. This has the potential to <em>halve</em> your vector data storage, with minimal reductions in recall and runtime performance for most use cases.</p><h2>Storage formats in dense_vector fields</h2><p>Prior to 9.3, <code>dense_vector</code> fields support vectors of single bits, 1-byte integers, and 4-byte floats. We store the original vectors on top of any quantization and/or hierarchical navigable small world (HNSW) graph used for indexing, and the original vectors make up the vast majority of the required disk space of the vector indices. If your vectors are floating point, then the only option versions of Elasticsearch prior to 9.3 provide is to store 4 bytes per vector value: That’s 4kB for a single 1024-dimensional vector.</p><p>There are other floating-point sizes available, of course: <a href="https://en.wikipedia.org/wiki/IEEE_754">IEEE-754</a> specifies floating-point sizes of many different lengths, including the 4-byte <code>float32</code> and 8-byte <code>float64</code> used by Java <code>float</code> and <code>double</code> types. It also specifies a <code>float16</code> format, which only uses 2 bytes per value. However, this only has a maximum value of 65,504, compared to the 3.4x1038 of 4-byte <code>float32</code> values, and the conversion between the two involves several arithmetic operations.</p><p>As an alternative, many machine learning (ML) applications now use <a href="https://en.wikipedia.org/wiki/Bfloat16_floating-point_format">bfloat16</a>, which is a modification of IEEE-754 <code>float32</code> to only use 2 bytes. It does this by discarding the lowest 2 bytes of the fractional part of the value, leaving the sign and exponent unchanged. This effectively reduces the precision of the floating-point value <em>without</em> a corresponding reduction in range. The conversion from <code>float32</code> to <code>bfloat16</code> is a simple bitwise truncation on the <code>float32</code> value, with a bit of jiggling to account for rounding.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b0f8d88cfb8c696b8ef805424d7dc7a242023484-913x394.png" style="width: 100%;" /><h2>bfloat16 in Elasticsearch 9.3</h2><p>Elasticsearch 9.3 now supports storing vector element types as bfloat16. In memory, it will still process every vector value as a 4-byte float32, as Java does not have built-in support for bfloat16. As it writes vector data to disk, it will simply truncate and round each float32 value to a 2-byte bfloat16, and zero-expand each bfloat16 value back to float32 on reading the value into memory.</p><p>This effectively <em>halves</em> your vector index sizes, as it uses 2 bytes per value rather than 4 bytes. There may be a small performance cost during reading and writing data as Elasticsearch performs the necessary conversions, but this is often counterbalanced by a significant reduction in the I/O required, as the OS now has to read half as much data. And, for most datasets, there is a minimal effect on search recall.</p><p>As an example, this is the difference in sizes for bfloat16 on our <code>dense_vector</code> dataset:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d7d2459cead1b8d29d107a1b6bbbe21be1fc7315-1600x990.png" style="width: 100%;" /><p>So, if your input vectors are already at bfloat16 precision, then happy days! Elasticsearch accepts raw bfloat16 vectors as float values, and as <a href="https://www.elastic.co/search-labs/blog/base64-encoded-strings-vector-ingestion">Base64-encoded vectors</a>. The vectors are persisted to disk with the same precision as your original source data, immediately halving your data storage requirements.</p><p>If your input vectors are at 4-byte precision, then you can also use bfloat16 format to halve your index data sizes. Elasticsearch will truncate and round each value to 2-byte precision, throwing away the least significant bits of the fraction. This means that the vector values you get back from Elasticsearch won’t be exactly the same as what you originally indexed, so don’t use bfloat16 if you need to maintain the full 4-byte precision of float32.</p><p>Starting in Elasticsearch 9.3, and on Elasticsearch Serverless, you can specify <code>element_type: bfloat16</code> with all <code>dense_vector</code> index types on any newly created indices. If you wish to use bfloat16 with existing indices, you can reindex into an index with <code>element_type: bfloat16</code> and Elasticsearch will automatically convert your existing float vectors to bfloat16.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI agents that perform actions: Automating IT requests with Agent Builder and Workflows]]></title>
            <description><![CDATA[Using  Elastic Agent Builder and Workflows to create an AI agent that automatically performs IT actions, such as laptop refreshes.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-builder-one-workflow</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-one-workflow</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sri Kolagani, Ziyad Akmal]]></dc:creator>
            <pubDate>Fri, 13 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/80e98ed860633a0a20abcc55ad10b2854a4e8df0-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In the world of IT operations, context switching is the enemy of productivity. For internal teams, simple requests, like a laptop refresh or employee onboarding, often require navigating multiple portals, filling out rigid forms, and manually updating information technology service management (ITSM) tools like ServiceNow.</p><p>At a recent <strong>DevFest</strong>, we demonstrated how to bridge the gap between natural language requests and structured IT workflows. By combining <a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder"><strong>Elastic Agent Builder</strong></a> with <a href="https://www.elastic.co/docs/explore-analyze/workflows"><strong>Elastic Workflows</strong></a>, we can create AI assistants that not only answer questions but also perform complex actions.</p><p>In this post, we’ll dive into the architecture from that talk, specifically looking at how we built an automated &quot;Laptop Refresh&quot; workflow. We’ll demonstrate how to configure an agent that collects user requirements and triggers a server-side automation to interact directly with ServiceNow APIs.</p><p><strong>Watch the full breakdown:</strong> This post is based on our presentation at Google DevFest. You can <a href="https://www.youtube.com/watch?v=OzStbTUZqyw">watch the full session here</a> to see the demo in action.</p><h2><strong>The architecture: From chat to fulfillment</strong></h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/eb42459bfae9c2ac95f2012882ce826db5526705-1600x1000.png" style="width: 100%;" /><p><strong>Note:</strong> The technical implementation described in this document is a streamlined version of the full production environment. While the <strong>architecture diagram</strong> provided serves as an accurate structural reference for the actual deployment, the accompanying text and code snippets have been simplified for illustrative purposes and may differ from the final, complex configurations used in the live implementation.</p><p>The goal is to move from a manual, form-heavy process to a conversational interface. Instead of a user navigating a catalog, they simply tell the AI assistant that they’re due for a laptop upgrade.</p><p>As illustrated above, the flow consists of three distinct layers:</p><p><strong>1. Interaction layer (ElasticGPT/Agent Builder):</strong> The user interacts naturally with an interface powered by ElasticGPT. Behind the scenes, Agent Builder processes this conversation, handling intent detection and slot filling, to structure the data and orchestrate interactions with other internal systems.</p><ul><li><strong>Intent detection</strong><ul><li><strong>Mechanism:</strong> System prompt instruction.</li><li><strong>Implementation:</strong> The agent is explicitly told its single purpose in the <code>MISSION</code> statement. It doesn’t need to &quot;detect&quot; other intents because it’s scoped strictly to IT provisioning.<ul><li><em><strong>Code reference</strong>:</em> <code>MISSION: You are a specialized agent designed to collect complete employee onboarding information...</code></li></ul></li><li><strong>Constraint:</strong> If a user asks about non-IT topics (for example, &quot;What is the weather?&quot;), the <code>MISSION</code> implies that the agent should pivot back to data collection or decline, depending on the large language model’s (LLM&#x27;s) default safety alignment.</li></ul></li><li><strong>Slot filling (data collection)</strong><ul><li><strong>Mechanism:</strong> Phased conversation flow.</li><li><strong>Implementation:</strong> Instead of asking for all slots at once, the DATA <code>COLLECTION STRATEGY</code> breaks the slots into five logical phases. This prevents the context switching fatigue mentioned above.<ul><li><strong><em>Code reference:</em> </strong><code>PHASE 1: Personal information, PHASE 2: Employment Details, and so on.</code></li></ul></li><li><strong>Validation:</strong> The prompt enforces immediate validation (for example, <code>Validate inputs immediately</code>), acting as a gatekeeper before moving to the next slot.</li></ul></li></ul><p><strong>2. Automation layer ( Workflows):</strong> Once the agent has the data, it triggers a workflow. This workflow handles the logic: checking device eligibility, enforcing policy (for example, &quot;Is the laptop &gt; 3 years old?&quot;), and making API calls.</p><p><strong>3. System of record (ServiceNow):</strong> The workflow reads and writes directly to the ITSM tool to maintain audit trails and initiate fulfillment.</p><h2><strong>Step 1: Configuring the agent</strong></h2><p>The first step is defining the &quot;brain&quot; of the operation using <strong>Agent Builder</strong>. We need an agent that acts strictly within the bounds of IT provisioning. We don&#x27;t want a general chatbot; we want a data collection machine that feels like a helpful colleague.</p><p>We achieve this via a robust <strong>system prompt</strong>. The prompt dictates the agent&#x27;s operating protocol, enforcing a step-by-step data collection strategy.</p><p>Here’s the refined structure of the prompt we used. Notice how it enforces validation and logically groups questions to avoid overwhelming the user:</p><pre><code>MISSION: You are a specialized agent designed to collect complete employee onboarding information for IT equipment provisioning.

OPERATING PROTOCOL:
0. On every new chat, send a welcome message, and directly jump to data collection.

1. DATA COLLECTION STRATEGY:
   - Use a step-by-step approach across 5 clear phases
   - Validate inputs immediately

2. CONVERSATION FLOW:
   PHASE 1: Personal Information (Name, Email, Phone)
   PHASE 2: Employment Details (Job Title, Department, Manager)
   PHASE 3: Location & Shipping (Address, Country)
   PHASE 4: Technical Setup (Laptop Type, Accessories)
   PHASE 5: Confirmation

...

6. SUCCESS COMPLETION:
   After all data is collected and validated, invoke the tool "laptoprefreshworkflow" with the JSON payload.</code></pre><p>For a sample system prompt or instructions, please refer <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/building-actionable-ai-automating-it-requests-with-agent-builder-and-one-workflow/Try%20it%20yourself%20Agents/service_now_utility_agent.ipynb">here</a>.</p><p>By explicitly instructing the agent to send the data in a specific JSON format at the end of the conversation, we ensure that the input matches exactly what our automation layer expects.</p><h2><strong>Step 2: The automation layer (Workflows)</strong></h2><p>The agent provides the <em>intent</em> and the <em>data</em>, but <strong>Workflows</strong> provides the <em>muscle</em>.</p><p>We define a workflow using a YAML configuration. This workflow acts as the bridge between the AI agent and the ServiceNow REST APIs. It handles authentication, data retrieval, and the ordering process.</p><p>Below is the workflow definition. We’ve refined the code to use secure variable handling for credentials rather than hardcoding them.</p><h3><strong>Workflow inputs</strong></h3><p>First, we define the inputs the workflow expects to receive from the agent:</p><pre><code>YAML
version: "1"
name: Submit Laptop Refresh Request
enabled: true
triggers:
  - type: manual
inputs:
  - name: userid
    type: string
  - name: preferred-address
    type: string
  - name: laptop-choice
    default: Macbook latest
    type: string
  - name: laptop-keep-or-return
    default: return
    type: string</code></pre><h3><strong>Interacting with ServiceNow</strong></h3><p>The workflow executes a series of HTTP steps. Crucially, we first need to identify the user&#x27;s <em>current</em> asset to link the refresh request correctly.</p><p>1. Fetching computer data</p><p>We query the cmdb_ci_computer table in ServiceNow to find the asset currently assigned to the user.</p><pre><code>YAML
steps:
  - name: snow_get_computer_data
    type: http
    with:
      url: https://elasticdev.service-now.com/api/now/table/ci_computer?assigned_to={{ inputs.userid }}
      method: GET
      headers:
        Accept: application/json
        Content-Type: application/json
        # Best Practice: Use secrets for authorization headers
        Authorization: Basic {{ secrets.servicenow_creds }}
      timeout: 30s</code></pre><p>2. Adding to cart</p><p>Once we have the asset details and the user&#x27;s preferences, we don&#x27;t just create a generic ticket. We use the ServiceNow Service Catalog API to programmatically add the specific item to a cart.</p><pre><code>YAML
  - name: snow_post_add_item_to_cart
    type: http
    with:
      url: https://elasticdev.service-now.com/example
      method: POST
      headers:
        Accept: application/json
        Content-Type: application/json
        Authorization: Basic {{ secrets.servicenow_creds }}
      body: |
        {
            "sysparm_quantity": 1,
            "variables": {
              "caller_id_common": "{{ inputs.userid }}",
              "current_device": "{{ steps.snow_get_asset.output.data.result.sys_id }}",
              "laptop_keep_or_return": "{{ inputs.laptop-keep-or-return }}",
              "choose_your_laptop": "{{ inputs.laptop-choice }}",
              "shipping_address": "{{ inputs.preferred-address }}"
            }
        }</code></pre><p>3. Indexing the transaction</p><p>Finally, we want to keep a record of this transaction within Elasticsearch for analytics and future reference. We use the elasticsearch.index step to store the request details immediately after submission.</p><pre><code>YAML

  - name: index-submission-record
    type: elasticsearch.index
    with:
      index: laptop-refresh-submission-data
      id: "{{ steps.snow_post_submit_order.output.data.result.request_id }}"
      document:
        request-id: "{{ steps.snow_post_submit_order.output.data.result.request_id }}"
        user-id: "{{ inputs.userid }}"
        configuration-item: "{{ steps.snow_get_computer_data.output.data.result[0].sys_id }}"
        laptop-choice: "{{ inputs.laptop-choice }}"
        timestamp: "{{ steps.snow_post_submit_order.output.data.result.sys_created_on }}"</code></pre><p>For detailed workflow yaml, please refer <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/building-actionable-ai-automating-it-requests-with-agent-builder-and-one-workflow">here</a>.</p><h2><strong>The result</strong></h2><p>By stitching these components together, we create a seamless experience:</p><ol><li><strong>The user</strong> chats naturally with the agent to provide details.</li><li><strong>The agent</strong> structures this unstructured conversation into a JSON object.</li><li><strong>Workflow</strong> receives the JSON, validates the user&#x27;s current hardware via ServiceNow, creates the order, and indexes the result.</li></ol><p>This approach reduces a process that traditionally took users 5–10 minutes of form navigation into a quick conversation, while ensuring that IT operations retains full visibility and control.</p><p>Video demo: </p><a href="https://videos.elastic.co/watch/k7U1zBvuEJSSGvprkFnuGE">Watch video on Vidyard</a><h2><strong>Ready to build?</strong></h2><p>This pattern, using an agent for the interface and using Workflows for the execution, can be applied to almost any ITSM task, from password resets to software provisioning.</p><p>If you’re interested in trying this out, be sure to watch the <a href="https://www.youtube.com/watch?v=OzStbTUZqyw">DevFest talk</a> for the full context, and check out the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder">Elastic AI Agent Builder documentation</a> to get started building your own agents today.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Entity resolution with Elasticsearch & LLMs, Part 1: Preparing for intelligent entity matching]]></title>
            <description><![CDATA[Learn what entity resolution is and how to prepare both sides of the entity resolution equation: your watch list and the articles you want to search.]]></description>
            <link>https://www.elastic.co/search-labs/blog/entity-resolution-llm-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/entity-resolution-llm-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
            <pubDate>Thu, 12 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9908bbdc324f4187f057d54d7502055ee78f575e-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>&quot;The new Swift update is here!&quot;</p><p>When you see that headline, what do you think of? For a developer, it&#x27;s a call to action, time to dive into new syntax, concurrency models, and bug fixes for the Swift programming language. For a music fan, it&#x27;s a completely different story, a signal that Taylor Swift has just dropped a new album or is making a major announcement.</p><p>Your brain, in a fraction of a second, performs a remarkable feat of natural language processing (NLP). It doesn&#x27;t just read the word &quot;Swift&quot; in isolation; it uses the surrounding context (the headline&#x27;s source, your personal interests, and more) to resolve that single, ambiguous word to a unique, real-world entity.</p><p>In NLP, we call this ability to disambiguate <em>named entity resolution </em>(NER), and it&#x27;s something humans do all the time. Natural language is inherently ambiguous, so we need to be able to map entities like &quot;Bill Gates&quot; to &quot;the founder of Microsoft&quot; and &quot;The Eras Tour&quot; to &quot;Taylor Swift&#x27;s concert tour&quot;. For humans, these connections come easily; for computers, not so much. Think how disappointed a Swiftie would be when they find out the article that their smart assistant recommended is actually about programming.</p><p>This same challenge becomes critical when you&#x27;re monitoring news articles for mentions of specific people or organizations. Imagine you&#x27;re tracking sanctioned entities or monitoring mentions of specific companies. You have a watch list with “Sakura Shipping Group” on it, and you want to know when articles mention the company. Simple enough, right? But what happens when an article refers to “Sakura Shipping” instead of the full legal name? Or uses an abbreviation like “SSG”? Or describes it indirectly as “a major Japanese maritime logistics firm”? Or mentions the company in Japanese, as “さくら海運グループ”? Your simple text matching won’t find these mentions, even though they all refer to the same organization. For compliance and risk monitoring use cases, missing a mention could have serious consequences. You need to catch every variation, every alias, every way an entity might be referred to.</p><p>This is the problem of <em>entity resolution</em>: identifying when different mentions in text refer to the same real-world entity and determining which entity that is. To solve this, we need a system that can handle <em>semantic search</em> (understanding meaning, not just keywords), <em>named entity recognition</em> (extracting entities from text), and fast, scalable matching across millions of documents. That&#x27;s why we built this prototype on Elasticsearch. It provides built-in semantic search capabilities, integrated NER models, and the scalability needed for entity resolution.</p><p>In this series, we present an educational prototype for intelligent entity resolution that deliberately separates <em>retrieval</em> from <em>judgment and explanation</em>. Elasticsearch is used to efficiently narrow the search space by combining keyword, alias, and semantic (hybrid) search. Once plausible entity candidates are identified, a large language model (LLM) is used to determine whether a candidate refers to the same real-world entity, and the model’s rationale is provided in natural language.</p><p>This division of responsibilities avoids treating LLMs as black-box retrievers, preserves explainability for sensitive use cases, and demonstrates a reusable design pattern for building transparent, Elasticsearch-native systems. We examine why this pattern is particularly effective for entity resolution, where ambiguity is common and explainability matters. The goal is not to present a production-ready solution but to teach the architectural principles behind building transparent entity resolution systems.</p><p><strong>Important note:</strong> This series presents an educational prototype that teaches Elasticsearch-native entity resolution using LLM judgments. We&#x27;ve made some simplifying choices (such as using Wikipedia for entity enrichment) to keep the system accessible for learning. Production systems might use different data sources, additional validation steps, or more sophisticated enrichment pipelines. The goal here is to demonstrate the core concepts and architecture, not to provide a production-ready system.</p><p>This series shows how we can help computers make these necessary connections while working with a 100% Elasticsearch-native architecture. We&#x27;ll explore three major innovations:</p><ul><li>Enhancing entities with contextual information.</li><li>Recognizing basic and complex entities with comprehensive NER.</li><li>Providing transparent reasoning through Elasticsearch candidate matching and LLM-powered explanations.</li></ul><p>We&#x27;ll also evaluate the system and identify an important optimization that improves the overall performance of the educational prototype.</p><p>In this first post of a four-part series, we’ll focus on preparing both sides of the entity resolution equation: your watch list and the articles you want to search.</p><h2>The problem: Why entity resolution requires preparation</h2><p>Entity resolution is hard because we face challenges on both sides of the matching equation. On one side, entities can be mentioned in many different ways. A company might be referred to as &quot;Microsoft&quot;, &quot;Microsoft Corporation&quot;, &quot;MSFT&quot;, or even &quot;the Redmond-based tech giant&quot;, depending on the context and writing style. On the other hand, we need to find these mentions in articles, even when they&#x27;re not obvious, such as when an article uses &quot;the Russian President&quot; or &quot;F.D.R.&quot; instead of a full name.</p><p><strong>Why we can&#x27;t just match names directly:</strong> Without proper preparation, matching becomes unreliable. You might think, &quot;But I can just search for &#x27;Tim Cook&#x27; in the text, right?&quot; Well, yes, if the article always mentions him by that exact name. But what about when it says &quot;Apple CEO&quot; instead? Or &quot;Timothy D. Cook&quot; (his full name)? Your simple text search won&#x27;t find those mentions, even though they all refer to the same person.</p><p>Without entity preparation, we can&#x27;t match &quot;the Russian President&quot; to &quot;Vladimir Putin&quot; because we don&#x27;t know what &quot;the Russian President&quot; means without context. We can&#x27;t match &quot;J.R.R. Tolkien&quot; to &quot;John Ronald Reuel Tolkien&quot; because we don&#x27;t know that they&#x27;re aliases for the same person. We can&#x27;t match &quot;Apple CEO&quot; to &quot;Tim Cook&quot; because we can&#x27;t understand the semantic relationship. Without indexing, finding matches means checking every entity in your watch list individually. This doesn&#x27;t scale: With thousands of entities, every match becomes slow and expensive. For sanctioned individuals monitoring, this means missing critical mentions that use aliases or alternate spellings, a failure that could have serious consequences.</p><p><strong>Why we can&#x27;t just search text directly:</strong> Entity extraction is hard for the same reason entity resolution is hard: Entities can be mentioned in many different ways. The same person might be referred to as &quot;J.R.R. Tolkien&quot;, &quot;the author of <em>The Lord of the Rings</em>&quot;, or just &quot;Tolkien&quot;, depending on the context. Without proper extraction, we can&#x27;t find these mentions in the text. We&#x27;d have to manually identify every entity mention, which doesn&#x27;t scale. We&#x27;d miss entities mentioned in nonstandard ways (for example, titles or abbreviations). We also wouldn&#x27;t capture the context around entity mentions, which is crucial for accurate matching.</p><p>The solution is a two-phase system that prepares both your watch list and the articles you want to search.</p><h2>The solution: Two-phase preparation system</h2><p>To solve entity resolution, we need to prepare both sides of the matching equation. First, we enrich and index our watch list entities to enable semantic search. Second, we extract entity mentions from articles using hybrid techniques that capture explicit and implicit references. Together, these phases create the foundation for intelligent entity matching.</p><h3>Phase 1: Preparing your watch list</h3><p>The solution to preparing entities is to <em>enrich</em> them with meaningful contexts. This enables our entity matching system to work effectively. We&#x27;ll explain how context helps in a bit, but let&#x27;s walk through the prototype&#x27;s simple implementation first.</p><p>Our watch list of entities may be provided in multiple formats. The Office of Foreign Assets Control (OFAC) provides sanctions lists that include first and last names, addresses, and identifying information, such as passport numbers, date and place of birth, and nationality information [1]. While this provides a good amount of context, in practice many of these fields are omitted when the values are unknown for the given entity. Some lists may be just a set of names. The most helpful lists for our purposes come out of the box with rich descriptions, as is often the case with commercial or curated datasets.</p><p>The three-component system used in the prototype starts by managing our entities and organizing their metadata. Since entity lists can vary in the amount of information they contain, our prototype is designed to work with whatever it receives. The JSON format supports entities with minimal information (just a name and type) or full information (with aliases, descriptions, metadata, and more). For example, an entity might be as simple as:</p><pre><code>{
  "name": "J.R.R. Tolkien",
  "entity_type": "person"
}</code></pre><p>Or it might include additional context:</p><pre><code>{
  "name": "J.R.R. Tolkien",
  "entity_type": "person",
  "description": "English writer and philologist, author of The Lord of the Rings",
  "aliases": ["John Ronald Reuel Tolkien", "J.R.R. Tolkien", "Tolkien"],
  "priority": "medium"
}</code></pre><p>The system handles both cases gracefully during enrichment. For the prototype, the enrichment process adds context from Wikipedia (specifically, the first paragraph of the entity&#x27;s Wikipedia page) for entities that don&#x27;t already have context [2]. This Wikipedia context helps with semantic matching, but it doesn&#x27;t add other fields, like aliases or full names; those must come from the original dataset. (In production, you might use other approaches for enrichment, including an agentic system that figures out where to find the context information for a given entity. This is beyond the scope of our prototype, but it’s an exciting feature we could add in the future.) Finally, we index the entities in Elasticsearch with semantic search capabilities, creating a searchable index that understands meaning rather than just text.</p><h4><strong>Key concepts: Semantic search and indexing</strong></h4><p><strong>What is semantic search?</strong> <em>Semantics</em> refers to the meanings of words and phrases. Figuring out meaning is usually easy for humans, but it&#x27;s much more challenging for computers to &quot;get&quot; because it requires a depth of understanding that’s difficult to program. Semantic search works by turning this challenge into a math problem, something that computers are very good at [3].</p><p>Think of semantic search like map coordinates for meaning. Just as latitude and longitude tell you where something is on a map, semantic embeddings tell you where something is in &quot;meaning space.&quot; Whereas traditional keyword search requires exact matches, semantic search relies on describing that &quot;location&quot; in a multidimensional vector space. For example, you might have the coordinates for a specific &quot;big red building&quot;. When you search for a &quot;small red building&quot;, semantic search looks in the &quot;neighborhood&quot; for similar concepts in the vector space. Your big red building might appear as a nearest neighbor, but the relevance score will be lower because parts of the meaning don&#x27;t match.</p><p>Getting back to our example, when you search for &quot;Apple CEO&quot;, semantic search can find &quot;Tim Cook&quot; because the semantic embeddings capture the meaning that both refer to the same person, even though they use completely different words. This capability is invaluable when monitoring for sanctioned individuals, as aliases and code names may be used to evade detection.</p><p><strong>Why Elasticsearch for entity indexing?</strong> Elasticsearch has built-in semantic search capabilities using embedding models, like EmbEddings from bidirEctional Encoder rEpresentations (E5) [4]. This means we can create an index that understands meaning, not just text. When we index our enriched entities, Elasticsearch creates semantic embeddings that capture each entity&#x27;s meaning, enabling intelligent matching later.</p><p><strong>What is the <em>mapping schema</em>?</strong> The mapping schema defines how we structure entity data in Elasticsearch. Our schema includes several field types optimized for different search strategies, including:</p><ul><li><strong>Keyword fields</strong> (<code>id,</code> <code>name.keyword</code>, <code>aliases.keyword</code>): For exact matching on entity names and aliases.</li><li><strong>Text fields</strong> (<code>name</code>, <code>name_lower</code>, <code>context</code>, <code>aliases</code>): For traditional, case-normalized full-text search with BM25 scoring.</li><li><strong>Semantic text fields</strong> (<code>name_semantic</code>, <code>context_semantic</code>): For vector-based similarity search using the multilingual-e5-small model.</li></ul><p>This hybrid mapping enables multiple search strategies: exact matching for precise names, keyword search for aliases, and semantic search for meaning-based matching. Even better, Elasticsearch supports <em>hybrid search</em>, allowing us to use both keyword and semantic search simultaneously.</p><h4><strong>Before and after entity preparation</strong></h4><p>Before entity preparation, you have a simple list without much context, possibly nothing more than a name: &quot;J.R.R. Tolkien&quot;. That&#x27;s it. You can only match exact text matches, which means you&#x27;ll miss &quot;John Ronald Reuel Tolkien&quot;, &quot;Tolkien&quot;, and any other variations. For sanctioned individuals, this means missing critical mentions that use aliases or alternate spellings.</p><p>After entity preparation, you have a rich, searchable index. &quot;Vladimir Putin&quot; is now enriched with Wikipedia context, and if your original dataset included aliases, like &quot;Путин&quot; or &quot;Vladimir Vladimirovich Putin&quot;, those are indexed as well. The entity also has semantic embeddings that capture its meaning. The Wikipedia context helps semantic search understand that &quot;The Russian President&quot; refers to Vladimir Putin, enabling that match. If &quot;Путин&quot; was provided as an alias in your original dataset, exact matching handles that. Semantic variations work because your semantic embeddings understand meaning. For sanctioned individuals, this comprehensive preparation ensures you catch every mention, regardless of how the name is written or what alternative name is used.</p><h3>Phase 2: Extracting entities from articles</h3><p>Now that we have a searchable watch list, we need to extract entity mentions from articles. This is where article processing comes in.</p><p><strong>Remember:</strong> This is an educational prototype designed to teach entity extraction concepts. Production systems might use different NER models, custom extraction rules, or specialized extraction pipelines tailored to specific domains or languages.</p><p>We extract entities from articles using a hybrid NER approach that combines machine learning with pattern-based extraction. First, we process articles to prepare them for extraction. Then, we extract entities using a hybrid extraction approach that combines NER performed in Elasticsearch (using a deployed XLM-RoBERTa model) with pattern-based extraction to catch entities that NER might miss.</p><p>This hybrid extraction approach provides several benefits. NER automatically finds entity mentions in text, even when they&#x27;re not obvious. Pattern-based extraction catches entities that NER might miss, like titles and compound entities. We preserve the context around each entity mention, which helps with matching decisions later. The approach scales well, allowing us to process thousands of articles automatically, not just a few manually.</p><h4><strong>Key concepts: NER, pattern-based extraction, and hybrid extraction approach</strong></h4><p><strong>What is NER?</strong> <em>Named entity recognition</em> is a machine learning technique that identifies named entities in text. When we run NER on an article, it finds mentions like &quot;Microsoft&quot;, &quot;Seattle&quot;, and &quot;Washington&quot; and labels them as organization, location, or person entities.</p><p><strong>Why use NER in Elasticsearch?</strong> Using NER in Elasticsearch maintains our 100% Elasticsearch-native architecture, which simplifies the entity resolution prototype&#x27;s design. Instead of managing separate services for entity extraction and search, everything runs in one system. You can perform NER during document ingestion using inference pipelines, and the extracted entities are immediately available for indexing and searching. This unified approach reduces complexity, eliminates network calls between services, and makes deployment and management easier. The XLM-RoBERTa model is trained to recognize entities in multiple languages, so we can extract entities from articles in different languages without needing separate models for each language. For information on deploying NER models in Elasticsearch, see the <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-ner-example">Elasticsearch NER documentation</a>.</p><p><strong>What is pattern-based extraction?</strong> <em>Pattern-based extraction</em> uses rules and patterns to find entities that NER might miss. For example, NER might not recognize &quot;the author of <em>The Lord of the Rings</em>&quot; as an entity mention, but pattern-based extraction can catch titles and roles like &quot;the CEO&quot; or &quot;the President&quot;. However, pattern-based extraction is language-specific. The patterns need to be defined for each language you want to support. This is a significant drawback for multilingual systems, but it&#x27;s acceptable for our educational prototype, which focuses on demonstrating the core concepts. Production systems might use language-specific pattern sets or alternative approaches for multilingual support.</p><p><strong>How do they work together?</strong> The hybrid extraction approach combines both techniques. NER finds obvious entity mentions like &quot;J.R.R. Tolkien&quot;, while pattern-based extraction catches variations that NER might miss, such as &quot;the author of <em>The Lord of the Rings</em>&quot;. Together, they provide comprehensive coverage of entity mentions in text.</p><p>When we extract entities from an article mentioning &quot;the author of <em>The Lord of the Rings</em>&quot;, we get:</p><ul><li>Text: &quot;author of <em>The Lord of the Rings</em>&quot;</li><li>Type: PERSON (from pattern-based extraction)</li><li>Confidence: 0.85</li><li>Context: &quot;The author of <em>The Lord of the Rings</em> published a new edition&quot;</li></ul><h4><strong>Before and after entity extraction</strong></h4><p>With NER-only extraction, we might find &quot;J.R.R. Tolkien&quot; and &quot;<em>The Lord of the Rings</em>&quot; in the article, but we&#x27;d miss &quot;the author of <em>The Lord of the Rings</em>&quot; because NER doesn&#x27;t recognize descriptive phrases as entity mentions.</p><p>With hybrid extraction, we find both &quot;J.R.R. Tolkien&quot; (from NER) and &quot;the author of <em>The Lord of the Rings</em>&quot; (from pattern-based extraction). This comprehensive coverage enables better matching later, since we can match both the name and the descriptive phrase to our watch list.</p><h2>What&#x27;s next: Matching entities to our watch list</h2><p>Now that we&#x27;ve prepared both sides of the entity resolution equation, we have everything we need for intelligent matching:</p><ul><li>A searchable watch list enriched with context and indexed for semantic search.</li><li>Extracted entity mentions from articles using hybrid NER.</li></ul><p>Preparation gives us the raw ingredients, but it doesn’t tell us which entity a mention actually refers to. In the next post, we&#x27;ll explore how to match these extracted entities to our watch list using semantic search and LLM-powered judgment that handles ambiguity and context transparently.</p><h2>Try it yourself</h2><p>Want to see the preparation process in action? Check out these notebooks for complete walkthroughs with real implementations, detailed explanations, and hands-on examples:</p><ul><li><strong><a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=3%20minutes%20ago-,01_entity_preparation_v3.ipynb,-Initial%20public%20lab">Entity preparation notebook</a>:</strong> Shows you exactly how to enrich entities with Wikipedia context, create semantic search indexes, and prepare your watch list for intelligent matching.</li><li><strong><a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=3%20minutes%20ago-,02_article_processing_v3.ipynb,-Initial%20public%20lab">Article processing notebook</a>:</strong> Shows you exactly how to extract entities from articles using hybrid NER, handle multilingual content, and process compound entities.</li></ul><p><strong>Remember:</strong> This is an educational prototype designed to teach the concepts. When building production systems, consider additional factors, like data source reliability, validation pipelines, error handling, monitoring, compliance requirements, domain-specific NER models, custom extraction rules, and quality validation that aren&#x27;t covered in this learning-focused prototype.</p><h2>References</h2><ol><li><a href="https://sanctionslist.ofac.treas.gov/Home/">OFAC Sanctions List Search</a></li><li>The datasets used for the prototype also use a special field, &#x27;explicit_context&#x27;, in lieu of getting the context from Wikipedia. We do this to control for the entity preparation step when we&#x27;re testing other components such as entity matching.</li><li><a href="https://www.elastic.co/blog/retrieval-augmented-generation-explained#semantic-search:~:text=what%20we%E2%80%99ll%20do.-,Turning%20semantic%20search%20into%20a%20math%20problem,-Let%E2%80%99s%20begin%20with">The big ideas behind retrieval augmented generation</a></li><li><a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-e5">E5 in Elasticsearch</a></li></ol>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From vectors to keywords: Elasticsearch hybrid search in LangChain]]></title>
            <description><![CDATA[Learn how to use hybrid search in LangChain via its Elasticsearch integrations, with complete Python and JavaScript examples.]]></description>
            <link>https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Margaret Gu, Eyo Eshetu]]></dc:creator>
            <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9159af2b07b88f288e5c7cb719c8dcbe5d3b37d6-1080x608.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch hybrid search is available for LangChain across our <a href="https://github.com/langchain-ai/langchain-elastic">Python</a> and <a href="https://github.com/langchain-ai/langchainjs">JavaScript</a> integrations. Here we’ll discuss what hybrid search is, when it can be useful and we’ll run through some simple examples to get started.</p><p>We’re also planning to support hybrid search in the community-driven <a href="https://github.com/langchain4j/langchain4j">Java integration</a> very soon.</p><h2><strong>What is hybrid search?</strong></h2><p><em>Hybrid search</em> is an information retrieval approach that combines<em> keyword-based full-text search</em> (lexical matching) with <em>semantic search</em> (vector similarity). Practically, it means a query can match documents because they contain the right terms and/or because they express the right meaning (even if the wording differs).In simple terms, you can think of it like this:</p><ul><li>Lexical retrieval: “Do these documents contain the words I typed (or related words)?”</li><li>Semantic retrieval: “Do these documents mean something similar to what I typed?”</li></ul><p>These two retrieval methods produce scores on different scales, so hybrid search systems typically use a fusion strategy to merge them into one ranking, for example, using <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">reciprocal rank fusion</a> (RRF).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a36692581ec5adb54d3c517e171b6d2f372efd92-1249x514.png" style="width: 100%;" /><p>In the figure above, we show an example: <a href="https://www.elastic.co/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables">BM25</a> (keyword search) returns Docs A, B, and C, while semantic search returns Docs X, A, and B. The RRF algorithm then combines these two result lists into the final ranking: Doc A, Doc B, Doc X, and Doc C. With hybrid search, Doc C is included in the results thanks to BM25.</p><h2><strong>Why hybrid search matters</strong></h2><p>If you’ve built search or retrieval-augmented generation (RAG) features in production, you’ve probably seen the same failure modes show up again and again: </p><ul><li>Keyword search can be too literal. If the user doesn’t use the exact terms that appear in your documents, relevant content gets buried or missed.</li><li>Semantic search can be too fuzzy. It’s great at meaning, but it can also return results that feel related while missing a critical constraint, like a product name, an error code, or a specific phrase the user actually typed.</li></ul><p>Hybrid search exists because real user queries in production environments usually need <em>both</em>.</p><p>Next we’ll dive into how you get started with hybrid search in the LangChain integration for <a href="https://github.com/langchain-ai/langchain-elastic">Python</a> and <a href="https://github.com/langchain-ai/langchainjs">JavaScript</a>. If you want to read more about hybrid search, check out <strong><a href="https://www.elastic.co/what-is/hybrid-search">What is hybrid search?</a> </strong>and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-hybrid-search"><strong>When hybrid search truly shines</strong></a>.</p><h3>Setting up a local Elasticsearch instance</h3><p>Before running the examples, you&#x27;ll need Elasticsearch running locally. The easiest way is using the <a href="https://github.com/elastic/start-local?tab=readme-ov-file"><code>start-local</code></a> script:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>After starting, you&#x27;ll have:</p><ul><li>Elasticsearch at http://localhost:9200.</li><li>Kibana at http://localhost:5601.</li></ul><p>Your API key is stored in the .env file (under the elastic-start-local folder) as <code>ES_LOCAL_API_KEY</code>.</p><div style="display:none">Unknown block type "infoPanel", specify a component for it in the `components.types` option</div><h2>Getting started with hybrid search in LangChain (Python and JavaScript)</h2><p>The dataset is a CSV with information on 1,000 science fiction movies, taken from an IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>. This demo uses a subset of the data, which has been cleaned. You can download the dataset used for this article from our <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>, along with the full code for this demo.</p><h3>Step 1: Install what you need.</h3><p>First you’ll need the LangChain Elasticsearch integration and Ollama for embeddings. (You can also use some other embedding model if you wish.)</p><p><strong>In Python:</strong></p><pre><code>pip install langchain-elasticsearch langchain-ollama</code></pre><p><strong>In JavaScript:</strong></p><pre><code>npm install @langchain/community @langchain/ollama @elastic/elasticsearch csv-parse</code></pre><h3>Step 2: Configure your connection and dataset path.</h3><p><strong>In Python:</strong></p><p>At the top of the script, we set:</p><ul><li>Where Elasticsearch is <code>(ES_LOCAL_URL)</code>.</li><li>How to authenticate <code>(ES_LOCAL_API_KEY)</code>.</li><li>Which demo index name to use <code>(INDEX_NAME)</code>.</li><li>Which CSV file we’ll ingest <code>(scifi_1000.csv)</code>.</li></ul><pre><code>ES_URL = os.getenv("ES_LOCAL_URL", "http://localhost:9200") 
ES_API_KEY = os.getenv("ES_LOCAL_API_KEY")
INDEX_NAME = "scifi-movies-hybrid-demo" 
CSV_PATH = Path(__file__).with_name("scifi_1000.csv")</code></pre><p><strong>In JavaScript:</strong></p><p>Notes for JavaScript:</p><ul><li>JavaScript uses <code>process.env</code> instead of <code>os.getenv</code>.</li><li>Path resolution requires <code>fileURLToPath</code> and <code>dirname</code> for Elasticsearch modules.</li><li>The class is called <code>ElasticVectorSearch</code> (not <code>ElasticsearchStore</code> as in Python).</li></ul><pre><code>import { Client } from "@elastic/elasticsearch";
import { OllamaEmbeddings } from "@langchain/ollama";
import {
  ElasticVectorSearch,
  HybridRetrievalStrategy,
} from "@langchain/community/vectorstores/elasticsearch";
import { parse } from "csv-parse/sync";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));

const ES_URL = process.env.ES_LOCAL_URL || "http://localhost:9200";
const ES_API_KEY = process.env.ES_LOCAL_API_KEY;
const INDEX_NAME = "scifi-movies-hybrid-demo";
const CSV_PATH = join(__dirname, "scifi_1000.csv");</code></pre><p>We can now also create the client.</p><p>In Python:</p><pre><code>es = Elasticsearch(ES_URL, api_key=ES_LOCAL_API_KEY)</code></pre><p>In JavaScript:</p><pre><code>const client = new Client({
  node: ES_URL,
  auth: ES_API_KEY ? { apiKey: ES_LOCAL_API_KEY } : undefined,
});</code></pre><h3>Step 3: Ingest the dataset, and then compare vector-only vs. hybrid.</h3><h4>Step 3a: Read the CSV and build what we index.</h4><p>We build three lists:</p><ul><li><code>texts</code>: The actual text that will be embedded + searched.</li><li><code>metadata</code>: Structured fields stored alongside the document.</li><li><code>ids</code>: Stable IDs (so Elasticsearch can dedupe if needed).</li></ul><p><strong>In Python:</strong></p><pre><code># --- Ingest dataset ---
texts: list[str] = []
metadatas: list[dict] = []
ids: list[str] = []

with CSV_PATH.open(newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        movie_id = (row.get("movie_id") or "").strip()
        movie_name = (row.get("movie_name") or "").strip()
        year = (row.get("year") or "").strip()
        genre = (row.get("genre") or "").strip()
        description = (row.get("description") or "").strip()
        director = (row.get("director") or "").strip()

        # This text is both:
        #  - embedded (vector search)
        #  - keyword-matched (BM25 in hybrid mode)
        text = "\n".join(
            [
                f"{movie_name} ({year})" if year else movie_name,
                f"Director: {director}" if director else "Director: (unknown)",
                f"Genres: {genre}" if genre else "Genres: (unknown)",
                f"Description: {description}" if description else "Description: (missing)",
            ]
        )
        texts.append(text)
        metadatas.append(
            {
                "movie_id": movie_id or None,
                "movie_name": movie_name or None,
                "year": year or None,
                "genre": genre or None,
                "director": director or None,
            }
        )
        ids.append(movie_id or movie_name)</code></pre><p><strong>In JavaScript:</strong></p><pre><code>async function main() {
  // --- Ingest dataset ---
  const texts = [];
  const metadatas = [];
  const ids = [];

  const csvContent = readFileSync(CSV_PATH, "utf-8");
  const records = parse(csvContent, {
    columns: true,
    skip_empty_lines: true,
  });

  for (const row of records) {
    const movieId = (row.movie_id || "").trim();
    const movieName = (row.movie_name || "").trim();
    const year = (row.year || "").trim();
    const genre = (row.genre || "").trim();
    const description = (row.description || "").trim();
    const director = (row.director || "").trim();

    // This text is both:
    //  - embedded (vector search)
    //  - keyword-matched (BM25 in hybrid mode)
    const text = [
      year ? `${movieName} (${year})` : movieName,
      director ? `Director: ${director}` : "Director: (unknown)",
      genre ? `Genres: ${genre}` : "Genres: (unknown)",
      description ? `Description: ${description}` : "Description: (missing)",
    ].join("\n");

    texts.push(text);
    metadatas.push({
      movie_id: movieId || null,
      movie_name: movieName || null,
      year: year || null,
      genre: genre || null,
      director: director || null,
    });
    ids.push(movieId || movieName);
  }</code></pre><p><strong>What’s important here:</strong></p><ul><li>We don’t embed only the description. We embed a combined text block (title/year + director + genre + description). That makes results easier to print and sometimes improves retrieval.</li><li>The same text is what the lexical side uses, too (in hybrid mode), because it’s indexed as searchable text.</li></ul><h4>Step 3b: Add texts to Elasticsearch using LangChain.</h4><p>This is the indexing step. Here we embed texts and write them to Elasticsearch.</p><p>For asynchronous applications, please use <a href="https://reference.langchain.com/python/integrations/langchain_elasticsearch/#langchain_elasticsearch._async.vectorstores.AsyncElasticsearchStore"><code>AsyncElasticsearchStore</code></a> with the same API.</p><p>You can find our <a href="https://reference.langchain.com/python/integrations/langchain_elasticsearch/">reference docs</a> for both the sync and async versions of ElasticsearchStore, along with more parameters for advanced fine-tuning RRF.</p><p><strong>In Python:</strong></p><pre><code>print(f"Ingesting {len(texts)} movies into '{INDEX_NAME}' from '{CSV_PATH.name}'...") 

vector_store = ElasticsearchStore(
    index_name=INDEX_NAME,
    embedding=OllamaEmbeddings(model="llama3"),
    es_url=ES_LOCAL_URL,
    es_api_key=ES_LOCAL_API_KEY,
    strategy=ElasticsearchStore.ApproxRetrievalStrategy(hybrid=False),
)

#This is the indexing step. We embed the texts and add them to Elasticsearch
vectore_store.add_texts(texts=texts, metadatas=metadatas, ids=ids)</code></pre><p><strong>In JavaScript:</strong></p><pre><code>  console.log(
    `Ingesting ${texts.length} movies into '${INDEX_NAME}' from 'scifi_1000.csv'...`
  );

  const embeddings = new OllamaEmbeddings({ model: "llama3" });

  // Vector-only store (no hybrid)
  const vectorStore = new ElasticVectorSearch(embeddings, {
    client,
    indexName: INDEX_NAME,
  });

  // This is the indexing step. We embed the texts and add them to Elasticsearch
  await vectorStore.addDocuments(
    texts.map((text, i) => ({
      pageContent: text,
      metadata: metadatas[i],
    })),
    { ids }
  );</code></pre><h4>Step 3c: Create another store for hybrid search.</h4><p>We create another ElasticsearchStore object pointing at the same index but with different retrieval behavior: <code>hybrid=False</code> is <strong><em>vector-only</em></strong> search and <code>hybrid=True</code> is <strong><em>hybrid search</em></strong> (BM25 + kNN, fused with RRF).</p><p><strong>In Python:</strong></p><pre><code># Since we are using the same INDEX_NAME we can avoid adding texts again 
# This ElasticsearchStore will be used for hybrid search

hybrid_store = ElasticsearchStore(
    index_name=INDEX_NAME,
    embedding=OllamaEmbeddings(model="llama3"),
    es_url=ES_LOCAL_URL,
    es_api_key=ES_LOCAL_API_KEY,
    strategy=ElasticsearchStore.ApproxRetrievalStrategy(hybrid=True),
)</code></pre><p><strong>In JavaScript:</strong></p><pre><code>  // Since we are using the same INDEX_NAME we can avoid adding texts again
  // This ElasticVectorSearch will be used for hybrid search
  const hybridStore = new ElasticVectorSearch(embeddings, {
    client,
    indexName: INDEX_NAME,
    strategy: new HybridRetrievalStrategy(),
  });

  // With custom RRF parameters
  const hybridStoreCustom = new ElasticVectorSearch(embeddings, {
    client,
    indexName: INDEX_NAME,
    strategy: new HybridRetrievalStrategy({
      rankWindowSize: 100,  // default: 100
      rankConstant: 60,     // default: 60
      textField: "text",    // default: "text"
    }),
  });</code></pre><h4>Step 3d: Run the same query both ways, and print results.</h4><p>As an example, let’s run the query <em>“Find movies where the main character is stuck in a time loop and reliving the same day.&quot; </em>and compare the results from hybrid search and vector search.</p><p><strong>In Python:</strong></p><pre><code>query = "Find movies where the main character is stuck in a time loop and reliving the same day."
k = 5

print(f"\n=== Query: {query} ===")

vec_docs = vector_store.similarity_search(query, k=k)
hyb_docs = hybrid_store.similarity_search(query, k=k)

print("\nVector search (kNN) top results:")
for i, doc in enumerate(vec_docs, start=1):
    print(f"{i}. {(doc.page_content or '').splitlines()[0]}")

print("\nHybrid search (BM25 + kNN + RRF) top results:")
for i, doc in enumerate(hyb_docs, start=1):
    print(f"{i}. {(doc.page_content or '').splitlines()[0]}")</code></pre><p><strong>In JavaScript:</strong></p><pre><code>  const query =
    "Find movies where the main character is stuck in a time loop and reliving the same day.";
  const k = 5;

  console.log(`\n=== Query: ${query} ===`);

  const vecDocs = await vectorStore.similaritySearch(query, k);
  const hybDocs = await hybridStore.similaritySearch(query, k);

  console.log("\nVector search (kNN) top results:");
  vecDocs.forEach((doc, i) => {
    console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
  });

  console.log("\nHybrid search (BM25 + kNN + RRF) top results:");
  hybDocs.forEach((doc, i) => {
    console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
  });
}

main().catch(console.error);</code></pre><p><strong>Example output</strong></p><pre><code>Ingesting 1000 movies into 'scifi-movies-hybrid-demo' from 'scifi_1000.csv'...

=== Query: Find movies where main character is stuck in a time loop and reliving the same day. ===

Vector search (kNN) top results:
1. The Witch: Part 1 - The Subversion (20  18)
2. Divinity (2023)
3. The Maze Runner (2014)
4. Spider-Man (2002)
5. Spider-Man: Into the Spider-Verse (2018)

Hybrid search (BM25 + kNN + RRF) top results:
1. Edge of Tomorrow (2014)
2. The Witch: Part 1 - The Subversion (2018)
3. Boss Level (2020)
4. Divinity (2023)
5. The Maze Runner (2014)</code></pre><h2><strong>Why these results? </strong></h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped/escape) can appear.</li><li>Hybrid (BM25 + kNN + RRF) rewards documents that match both keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</li></ul><p>Hybrid search doesn’t guarantee that every result is perfect. It balances lexical and semantic signals so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords.</p><h2>Full code example</h2><p>You can find our full demo code in Python and JavaScript, as well as the dataset used, hosted on <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>.</p><h2>Conclusion</h2><p>Hybrid search provides a pragmatic and powerful retrieval strategy by combining traditional BM25 keyword search with modern vector similarity into a single, unified ranking. Instead of choosing between lexical precision and semantic understanding, you get the best of both worlds, without adding significant complexity to your application.</p><p>In real-world datasets, this approach consistently yields results that feel more intuitively correct. Exact term matches help anchor results to the user’s explicit intent, while embeddings ensure robustness against paraphrasing, synonyms, and incomplete queries. This balance is especially valuable for noisy, heterogeneous, or user-generated content, where relying on only one retrieval method often falls short.</p><p>In this article, we demonstrated how to use hybrid search in LangChain through its Elasticsearch integrations, with complete examples in both Python and JavaScript. We’re also contributing to other open-source projects, such as <a href="https://github.com/langchain4j/langchain4j/pull/4069">LangChain4j</a>, to extend hybrid search support with Elasticsearch.</p><p>We believe hybrid search will be a key capability for generative AI (GenAI) and agentic AI applications, and we plan to continue collaborating with libraries, frameworks, and programming languages across the ecosystem to make high-quality retrieval more accessible and robust.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to defend your RAG system from context poisoning]]></title>
            <description><![CDATA[How context engineering techniques prevent context poisoning in LLM responses.]]></description>
            <link>https://www.elastic.co/search-labs/blog/context-poisoning-llm</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/context-poisoning-llm</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Tue, 10 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/32e26f6dc2c8fc41d4c1d981eff6487eb8423fa5-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>With the recent release of models like Claude Sonnet 4.5, Gemini 3 series and GPT 5 featuring million-token context windows, there’s a growing misconception that context management is becoming simpler. If a large language model (LLM) can process millions of tokens at once, does it really matter what information we provide?</p><p>The reality is precisely the opposite. <a href="https://www.elastic.co/search-labs/blog/context-engineering-overview"><em>Context engineering</em></a>, the practice of managing what information reaches your LLM, is more critical than ever. Large context windows don’t eliminate the need for precision; they amplify it. With more context comes exponentially more opportunities for error, hallucinations, and irrelevant information to contaminate your LLM reasoning process.</p><p>Whether you’re using retrieval-augmented generation (RAG) retrieval, tool outputs, or memory systems, effective context engineering isn’t about providing more information but about providing <em>the right</em> information. That’s where Elasticsearch comes in, serving as your context engineering platform.</p><p>In this article, we’ll explore what context poisoning is, how it manifests across different types of memory, and how Elasticsearch RAG capabilities provide defense at every stage of the retrieval pipeline, from ingestion to composition, ensuring your LLM receives clean, relevant, and reliable context.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d832932f12723226ecc98e2a9b8d6bfc008829bf-1080x571.png" style="width: 100%;" /><h2>What is context poisoning?</h2><p><em>Context poisoning</em> occurs when compromised, outdated, or irrelevant information enters an LLM’s context window, leading to degraded responses, hallucinations, or perpetuated errors. Once corrupted or incorrect information enters the context window, it propagates into answers. The LLM references it as truth, creating cascading errors across the conversation.</p><p>This poisoning can happen at multiple stages of the LLM lifecycle (like in training), but our focus is on the retrieval and composition stages. Although adversarial attacks, like prompt injection, also pose risks, this article focuses on the operational patterns that teams encounter most frequently in production environments.</p><h3><strong>Operational understanding</strong></h3><p>Context poisoning often happens for reasons like:</p><ul><li><strong>Context rot:</strong> Information becomes outdated but remains in your knowledge base without being updated or deleted.</li><li><strong>Context overflow: </strong>Too much information overwhelms the LLM&#x27;s attention to the real important and relevant context, leading to missing relevant information from answers.</li><li><strong>Conflicting information:</strong> Multiple sources provide contradictory data, confusing the model.</li><li><strong>Semantic noise: </strong>Vectorial similar but contextually irrelevant content dilutes relevance.</li><li><strong>Malicious injection: </strong>Content deliberately inserted by attackers into knowledge bases, including prompt injections or manipulated data.</li></ul><p>Understanding these patterns is the first step toward building robust defenses. Let’s examine each pattern and how Elasticsearch helps you address them. You can follow along with the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/context-poisoning">supporting notebook</a>.</p><h2>Types of context poisoning</h2><h3><strong>Temporal degradation</strong></h3><p>Over time, information in your knowledge base becomes outdated, and without proper management, stale content continues to be retrieved and presented to your LLM as current truth. This is especially problematic in industries where information changes frequently, like product documentation, pricing, regulations, or news.</p><h4><strong>Impact</strong></h4><p>Your LLM provides outdated advice, references to deprecated features, or contradictions to current reality, disengaging user trust.</p><h4><strong>Solutions: Temporal filtering in hybrid search</strong></h4><p>Elasticsearch’s date-based query capabilities ensure your RAG system prioritizes recent and relevant information through explicit temporal filters.</p><p><strong>Example: Product documentation search with time filtering</strong></p><p>A user asks your chatbot about authentication setup. Six months ago, the authentication had a significant change, so it’s important to only return documents from six months or earlier.</p><h3><strong>Without temporal filtering</strong></h3><pre><code>POST product-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "how to configure OAuth authentication"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "configure OAuth authentication",
                "fields": ["title^2", "content"]
              }
            }
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "last_updated", "version", "content_snippet"]
}</code></pre><h4><strong>Response without filtering: Contradictory results</strong></h4><p>The LLM receives three different methods for OAuth configuration: the current security API (9.x), legacy realm settings (7.x), and the deprecated shield plugin (6.x). This contradictory context leads to confused or misleading responses:</p><pre><code>{
  "hits": {
    "total": { "value": 23 },
    "max_score": 24.5,
    "hits": [
      {
        "_id": "doc-oauth-2025",
        "_score": 24.5,
        "_source": {
          "title": "OAuth 2.0 Authentication Setup",
          "last_updated": "2025-10-15",
          "version": "9.x",
          "content_snippet": "To configure OAuth 2.0 authentication in Elasticsearch 9.x, use the new security API..."
        }
      },
      {
        "_id": "doc-oauth-2023",
        "_score": 23.8,
        "_source": {
          "title": "OAuth Authentication Configuration",
          "last_updated": "2023-04-20",
          "version": "7.x",
          "content_snippet": "Configure OAuth using the legacy realm settings in elasticsearch.yml..."
        }
      },
      {
        "_id": "doc-oauth-deprecated",
        "_score": 22.9,
        "_source": {
          "title": "Setting Up OAuth (Deprecated)",
          "last_updated": "2022-11-10",
          "version": "6.x",
          "content_snippet": "Use the shield plugin to configure OAuth authentication..."
        }
      }
    ]
  }
}</code></pre><h4><strong>With temporal filtering</strong></h4><p>Add a filter to restrict results to documents updated within the last six months:</p><pre><code>POST product-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "how to configure OAuth authentication"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "configure OAuth authentication",
                "fields": ["title^2", "content"]
              }
            }
          }
        }
      ],
      "filter": [
        {"range": {"last_updated": {"gte": "now-6M"}}},
        {"term": {"status": "published"}}
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "last_updated", "version", "content_snippet"],
  "size": 5
}</code></pre><h4><strong>This hybrid search query</strong></h4><ul><li><strong>Semantic search</strong> (<code>semantic</code>) captures related concepts and context using the <code>content_semantic</code> field.</li><li><strong>Lexical search</strong> (<code>multi_match</code>) matches exact keywords like “OAuth” with field boosting <code>title^2</code>.</li><li><strong>Reciprocal rank fusion (RRF)</strong> combines both results sets with balanced reranking, retrieving the most relevant results.</li><li><strong>Temporal filter</strong> ensures only documents updated within the last six months are retrieved.</li><li><strong>Status filter</strong> restricts results to published documents, excluding drafts or deprecated content.</li></ul><h4><strong>Response with temporal filtering: Consistent results</strong></h4><p>The temporal filtering eliminated outdated documents, leaving only current documentation for version 9.x. The LLM now receives consistent context and generates confident, accurate responses:</p><pre><code>{
  "hits": {
    "hits": [
      {
        "_source": {
          "title": "OAuth 2.0 Authentication Setup",
          "last_updated": "2026-01-15",
          "version": "9.x",
          "content_snippet": "Configure OAuth 2.0 in Elasticsearch 9.x using the security API via Stack Management > Security."
        }
      },
      {
        "_source": {
          "title": "OAuth Provider Configuration",
          "last_updated": "2025-12-20",
          "version": "9.x",
          "content_snippet": "Configure Okta, Azure AD, Auth0 via security API with OIDC auto-discovery."
        }
      }
    ]
  }
}</code></pre><h4><strong>Relative versus absolute time filters</strong></h4><p><strong>Relative filtering </strong>(recommended for most use cases):</p><pre><code>"filter": [
  {
    "range": {
      "last_updated": {
        "gte": "now-1y"
      }
    }
  }
]</code></pre><p><strong>Absolute filtering </strong>(for specific time ranges):</p><pre><code>"filter": [
  {
    "range": {
      "last_updated": {
        "gte": "2025-01-01",
        "lte": "2025-12-31"
      }
    }
  }
]</code></pre><h4><strong>Impact on LLM response quality</strong></h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0c225743cc92df873bd8685b5073fe749f4d2da6-1600x414.png" style="width: 100%;" /><ul><li><strong>Without filtering: </strong>LLM receives contradictory guidance from 2023–2025, producing uncertain responses mixing deprecated and current methods.</li><li><strong>With temporal filtering: </strong>LLM receives only recent documentation, generating confident responses based on current best practices.</li></ul><h3><strong>Information conflicts</strong></h3><p>When your RAG system retrieves documentation for features that behave differently across deployment types, versions, or configurations, conflicting information can confuse the LLM about which guidance applies to the user’s specific context.</p><h4><strong>Impact</strong></h4><p>The LLM has to use more resources and tokens to understand and determine which information is correct, becoming more prone to errors and hallucinations.</p><h4><strong>Solutions: Hybrid search with metadata boosting</strong></h4><p>Elasticsearch’s <code>bool</code> query with a <code>should</code> clause allows you to boost values to prioritize documents matching specific metadata, ensuring deployment-specific or version-specific documentation appears first in the context window. For query syntax details, refer to <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">Bool query reference</a>.</p><h4><strong>Example: Deployment-specific feature documentation</strong></h4><p>A user asks, “How do I configure custom users in serverless?” Your knowledge base contains information about cloud, self-hosted, and managed deployments. With proper metadata prioritization, the LLM retrieves signals about feature availability and provides correct guidance:</p><pre><code>POST platform-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "bool": {
                "must": [
                  {
                    "multi_match": {
                      "query": "How do I configure custom users in serverless?",
                      "fields": ["title^2", "content"]
                    }
                  }
                ],
                "should": [
                  {"term": {"deployment_type": {"value": "serverless", "boost": 3.0}}},
                  {"term": {"doc_status": {"value": "current", "boost": 2.0}}}
                ]
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "How do I configure custom users in serverless?"
              }
            }
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "deployment_type", "feature_supported", "content_snippet"],
  "size": 5
}</code></pre><h4><strong>What this query does</strong></h4><ul><li><code>must</code> clause: All documents must match “How do I configure custom users in serverless?”</li><li><code>should</code> clauses with explicit boosting:<ul><li>Documents with <code>deployment_type: “serverless”</code> receive 3x boost.</li><li>Documents with <code>doc_status: “current”</code> receive 2x boost.</li></ul></li><li>Semantic search runs in parallel to capture conceptual matches.</li><li>RRF combines lexical (with metadata boosting) and semantic results to get the best of both approaches.</li></ul><h4><strong>Expected response:</strong></h4><pre><code>{
  "hits": {
    "hits": [
      {
        "_source": {
          "title": "Authentication in Serverless",
          "deployment_type": "serverless",
          "feature_supported": false,
          "content_snippet": "Custom authentication not available in Serverless. Use SSO with your identity provider."
        }
      },
      {
        "_source": {
          "title": "User Management in Serverless",
          "deployment_type": "serverless",
          "feature_supported": false,
          "content_snippet": "Direct user creation not supported in Serverless. Use your organization's IdP."
        }
      },
      {
        "_source": {
          "title": "Role-Based Access in Serverless",
          "deployment_type": "serverless",
          "feature_supported": true,
          "content_snippet": "Configure roles in Serverless console. Roles sync with SSO provider groups."
        }
      },
      {
        "_source": {
          "title": "API Keys in Serverless",
          "deployment_type": "serverless",
          "feature_supported": true,
          "content_snippet": "Create API keys for programmatic Serverless access. Keys inherit user permissions."
        }
      },
      {
        "_source": {
          "title": "SSO Configuration for Serverless",
          "deployment_type": "serverless",
          "feature_supported": true,
          "content_snippet": "Configure SSO in Serverless via Cloud console with SAML 2.0 or OIDC."
        }
      }
    ]
  }
}</code></pre><h4><strong>How metadata boosting resolves conflicts</strong></h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/43a9227fc651ebd6ed6f0058972e0dac502aa7c5-955x1600.png" style="width: 100%;" /><h4><strong>Impact on LLM response quality</strong></h4><ul><li><strong>Without metadata boosting: </strong>The<strong> </strong>context window receives equal-weight documents from all deployment types. The LLM produces vague responses that hedge between possibilities, failing to clearly state deployment-specific limitations.</li><li><strong>With metadata boosting (3x): </strong>Managed-specific documentation dominates the top results. The LLM generates direct answers about feature unavailability and provides actionable alternatives while maintaining the cross-deployment context for follow-up questions.</li></ul><h3><strong>Semantic noise</strong></h3><p>Vector similarity search can retrieve documents that are semantically related but contextually irrelevant to the user’s need. This “semantic drift” occurs when embeddings capture a similarity without understanding the query intent. So when your context window fills with irrelevant information, the LLM&#x27;s ability to generate precise answers declines.</p><h4><strong>Impact</strong></h4><p>The LLM receives correct information that doesn’t answer the question, wasting the context window and lowering the quality of the provided answer.</p><h4><strong>Solution: Hybrid search</strong></h4><p>Elasticsearch hybrid search combines lexical precision with semantic understanding, using explicit product filters to eliminate cross-product drift while maintaining conceptual recall.</p><h4><strong>Example: Technical documentation search</strong></h4><p>A developer searches for “Elastic Agent configuration,” and your knowledge base contains both the Elastic Agent (Elastic Observability) and the Elastic Agent Builder documentation. Both use the word &quot;agent&quot; prominently, making them semantically similar.</p><p>Let’s search for agent configuration documentation:</p><pre><code>POST elastic-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "agent configuration logs metrics collection",
                "fields": ["title^3", "content", "tags^2"],
                "type": "best_fields"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "configuring agents to collect logs and metrics from hosts"
              }
            }
          }
        }
      ],
      "filter": [
        {"terms": {"product": ["observability", "elastic-agent"]}},
        {"term": {"doc_type": "configuration"}}
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "product", "tags", "url"],
  "size": 5
}</code></pre><p>This hybrid query:</p><ul><li><strong>Lexical component</strong> (<code>multi_match</code>) ensures exact keyword matches for &quot;agent&quot;, &quot;configuration&quot;, &quot;logs&quot;, &quot;metrics&quot;, and “collection”.</li><li><strong>Field boosting</strong> (<code>title^3</code>, <code>tags^2</code>) prioritizes documents where terms appear in important fields.</li><li><strong>Semantic component</strong> captures conceptual relationships and the intent about “configuring data collection agents”.</li><li><strong>RRF </strong>merges both result sets with balanced ranking using <code>rank_constant: 20</code>.</li><li><strong>Product filter </strong>restricts results to Elastic Observability and Elastic Agent domains, eliminating Agent Builder docs entirely.</li><li><strong>Category filter</strong> restricts results to &quot;observability&quot; and &quot;elastic-agent&quot; domains, eliminating semantic drift to other domains.</li></ul><p><strong>Expected response:</strong><br/></p><pre><code>{
  "hits": {
    "hits": [
      {
        "_source": {
          "title": "Elastic Agent Input Configuration",
          "product": "elastic-agent",
          "tags": ["inputs", "logs", "metrics", "configuration"],
          "url": "/docs/elastic-agent/inputs"
        }
      },
      {
        "_source": {
          "title": "Configure Elastic Agent for Log and Metric Collection",
          "product": "elastic-agent",
          "tags": ["configuration", "logs", "metrics", "observability"],
          "url": "/docs/elastic-agent/configure"
        }
      },
      {
        "_source": {
          "title": "Agent Policies and Integrations",
          "product": "observability",
          "tags": ["policies", "integrations", "fleet"],
          "url": "/docs/fleet/policies"
        }
      },
      {
        "_source": {
          "title": "Configuring Agent Outputs",
          "product": "elastic-agent",
          "tags": ["outputs", "elasticsearch", "logstash"],
          "url": "/docs/elastic-agent/outputs"
        }
      },
      {
        "_source": {
          "title": "Manage Elastic Agents with Fleet",
          "product": "observability",
          "tags": ["fleet", "agent-management", "deployment"],
          "url": "/docs/fleet/manage-agents"
        }
      }
    ]
  }
}</code></pre><h4><strong>Why hybrid search works</strong></h4>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Search type</td><td style="border: 1px solid #ccc; padding: 4px;">Strengths</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Lexical only</td><td style="border: 1px solid #ccc; padding: 4px;">Precise keyword matching</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Semantic only</td><td style="border: 1px solid #ccc; padding: 4px;">Captures semantic meaning</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hybrid search</td><td style="border: 1px solid #ccc; padding: 4px;">Precision and recall, intent understanding</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Before and after: LLM response comparison</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/437dbe84121502b6c9b320d29b0df5ac67bd35fa-1600x371.png" style="width: 100%;" /><h2>Elasticsearch RAG best practices</h2><p>Following these best practices optimizes your context engineering and significantly reduces the risk of context poisoning in your RAG systems. By implementing the following strategies, you ensure that every token in your context window contributes to relevant, accurate, and trustworthy LLM responses.</p><ol><li><strong>Choose the right search strategy for your data: <br/></strong>Select your search approach based on your data characteristics and query patterns. Choose between lexical, semantic, or hybrid search. For more details, refer to <a href="https://www.elastic.co/docs/solutions/search/search-approaches">Search approaches | Elastic Docs</a>.</li><li><strong>Implement temporal awareness<br/></strong>Time-sensitive information requires active management to prevent outdated content from contaminating your context window. Use range queries with relative time filters (like <code>now-6M</code> or <code>now-1y</code>) for content that changes frequently, ensuring your RAG system prioritizes recent content. For more details, refer to <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-range-query">Range query | Reference</a>. </li><li><strong>Use metadata boosting<br/></strong>When your knowledge base contains similar content across different contexts, such as multiple product versions, deployment types, or user roles, metadata boosting helps prioritize contextually relevant results. For more details, refer to <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">Boolean query | Reference</a>.</li><li><strong>Apply reranking when needed<br/></strong>For complex or high-priority queries where precision is critical, consider implementing a reranker solution where the model can significantly improve the search result quality by reordering results based on the semantic understanding of the queries and documents. For more details, refer to <a href="https://www.elastic.co/docs/solutions/search/ranking">Ranking and reranking | Elastic Docs</a>.</li><li><strong>Optimize chunking strategies<br/></strong><em>Chunking</em> is the process of breaking down large text into smaller “chunks.” Document chunking strategy affects both semantic representation and retrieval precision. Smaller chunks provide more granularity, but you may lose context; and larger chunks preserve more context, but you reduce retrieval precision. For more details, refer to <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">Understanding chunking strategies in Elasticsearch</a>.</li><li><strong>Filter the data before it reaches the LLM<br/></strong>Vector similarity search can retrieve semantically related but contextually irrelevant documents. Apply explicit filters on product, category, or domain fields to constrain results to the appropriate context before delivering the results to the LLM. For more details, refer to <a href="https://www.elastic.co/search-labs/blog/rag-in-production">RAG pipelines in production: Operationalize your GenAI project - Elasticsearch Labs</a>.</li><li><strong>Calibrate your retrieval volume (k)<br/></strong>Finding the &quot;Goldilocks zone&quot; for the number of documents retrieved is essential. Too few results lead to incomplete answers, while too many can cause the LLM to miss key facts. Balance your token budget against the depth of the model&#x27;s window. For more details, refer to <a href="https://www.elastic.co/docs/solutions/search/vector/knn">kNN search in Elasticsearch | Elastic Docs</a>.</li><li><strong>Consider summarization for large documents<br/></strong>When retrieved content exceeds your context budget, summarization techniques help retain essential information while reducing token count. For more details, refer to <a href="https://www.elastic.co/search-labs/blog/ai-generated-summaries-using-elastic">Adding AI summaries to your site with Elastic - Elasticsearch Labs</a>.</li><li><strong>Monitor and iterate<br/></strong>Over time, as knowledge bases grow and content evolves, we recommend that you implement monitoring to track relevance score distributions, temporal patterns in retrieved results, and user feedback signals. Watch for signs like outdated documents, declining user satisfaction scores, or a growing number of “no relevant results” queries. For more details, refer to <a href="https://www.elastic.co/observability-labs/blog/data-quality-and-failure-store-in-streams">Elastic Observability: Streams Data Quality and Failure Store Insights</a>.</li></ol><h2>Conclusion</h2><p>The new era of million-token context windows has not made context management obsolete; it has made context engineering more critical than ever. As context windows grow, so does the potential for poisoning from any source retrieval, tools, or memory.</p><p>The patterns shown in this article apply beyond just RAG. Temporal filtering, metadata boosting, and hybrid search are foundational techniques that improve context quality, regardless of source.</p><p>By implementing these strategies, you maintain control over what information reaches your LLM, ensuring relevance, accuracy, and trust at scale.</p>]]></content:encoded>
        </item>
    </channel>
</rss>