<?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>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs</link>
    </image>
    <link>https://www.elastic.co/search-labs</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/feed.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Wed, 16 Sep 2026 19:29:26 GMT</lastBuildDate>
  <item>
    <title><![CDATA[One button, three places: How we rebuilt Kibana's page headers with stricter APIs]]></title>
    <description><![CDATA[We gave Kibana's shared shell typed contracts, which is how design system governance became the default, and why the new page headers have no breadcrumbs.]]></description>
    <content:encoded><![CDATA[<p>We replaced the open-ended React APIs behind every page header in Kibana with typed contracts, which put design system governance in the shell itself. Dozens of teams no longer have to get each header right by hand. Now a page declares what a control means, and the shared shell decides how it looks and where it goes. We removed breadcrumbs along the way, because the redesigned navigation already shows you where you are. The new chrome is live in Elastic Cloud Serverless today and ships to Elastic Cloud Hosted and self-managed in 9.6.</p><h2>What is the Kibana chrome?</h2><p>The chrome is everything that frames an application in <a href="https://www.elastic.co/kibana">Kibana</a>: the global header and the navigation, along with the header of the page that you’re currently on. Every application renders inside it. Because the chrome is shared, its APIs decide how consistent the whole product feels and how hard the next redesign will be.</p><h2>What inconsistent page headers looked like to users</h2><p>The clearest way to see the problem is to look at what users saw:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt63a6acf0d30ae807/6aaa27addcc13f41b2527962/unnamed_(1).png" alt="Three Kibana pages with different page header layouts, showing inconsistent action button and link placement" /><p></p><p>Take something as basic as a primary action, the one button a page most wants you to click, like <strong>Create index</strong>. Before this work, primary actions lived in three different places, depending on the page:</p><ul><li><p>In the old app header.</p></li><li><p>In the page template header.</p></li><li><p>Somewhere in the page body itself.</p></li></ul><p>Common links had the same problem, with share, feedback, and documentation links appearing in separate spots with different styling from one app to the next. Breadcrumbs were misconfigured on many pages. For example, some showed a breadcrumb for each tab within a page, and some weren't clickable. Still others repeated the title of the page you were already on.</p><p>Every one of these was the output of a well-intentioned team using a flexible API in the way it’s usually used. Users had to relearn each page's layout, and every attempt to redesign the chrome had to account for hundreds of local variations.</p><h2>Goals for the page header redesign</h2><p>The goal of the redesign was simple; that is, pages should express what they need (for example, a title, badge, or primary action), and the shared shell should decide how it looks and where it goes. Common links and primary actions should get one home, everywhere. If it's the primary action, it's always in the same place, styled the same way. And consistency should stop being something that each of dozens of teams has to get right by hand, forever.</p><p>Getting there wasn't a restyling exercise; it required changing the contract between applications and the platform.</p><h2>How typed props replaced EuiPageHeader's open React nodes</h2><p>Each page header was easy to build in isolation, and the local choices piled up into visible differences across the product: inconsistent spacing, title styling, and action placement from one page to the next.</p><p>The fix, everywhere this showed up, was the same mechanism: stricter APIs. Instead of accepting arbitrary React nodes, a control declares what it means through typed props, and the shared shell decides how it looks and where it goes. That one change buys two things at once: consistency, and governance over what the shared surface is allowed to contain.</p><p>Take the page header. With <code>EuiPageHeader</code>, the API exposed layout settings and accepted React nodes for nearly every visible area:</p>&lt;EuiPageHeader
  pageTitle={
    &lt;&gt;
      Index Management
      &lt;EuiBadge color="accent"&gt;Beta&lt;/EuiBadge&gt;
    &lt;/&gt;
  }
  description={
    &lt;&gt;
      View and manage your Elasticsearch indices.{' '}
      &lt;EuiLink href={docsUrl}&gt;Learn more&lt;/EuiLink&gt;
    &lt;/&gt;
  }
  bottomBorder
  alignItems="top"
  responsive={false}
  tabs={tabs}
  rightSideItems={[
    &lt;RefreshButton onClick={onRefresh} /&gt;,
    &lt;CreateButton onClick={onCreate} /&gt;,
  ]}
/&gt;<p>Because <code>pageTitle</code>, <code>description</code>, and <code>rightSideItems</code> all accept arbitrary React nodes, every team filled them in differently. One page put a badge next to the title, while another styled its own pill. One team's actions were plain buttons in one order; the next team's were a different mix in another order, some collapsing into a menu and others not. Everyone used the same shell component, yet the headers looked and behaved like they came from different products. The shared component guaranteed the wrapper, not what went inside it.</p><p>The <code>AppHeader</code> API closes that off. The same header is expressed as typed props:</p>&lt;AppHeader
  title="Index Management"
  badges={[
    {
      label: 'Beta',
      color: 'accent',
      tooltip: 'This feature is in beta.',
    },
  ]}
  description={{
    text: 'View and manage your Elasticsearch indices.',
    learnMoreUrl: docsUrl,
  }}
  tabs={tabs}
  menu={{
    primaryActionItem: {
      id: 'create',
      label: 'Create index',
      iconType: 'plusInCircle',
      run: onCreate,
    },
    items: [
      {
        id: 'refresh',
        label: 'Refresh',
        iconType: 'refresh',
        run: onRefresh,
      },
    ],
  }}
/&gt;<p>Now there’s only one way to express each part, so every page renders it the same. Badges live in their own typed collection, separate from the title. A description carries its text and, optionally, a "Learn more" URL. Actions declare whether they’re primary or secondary, and the component decides how they look and when they collapse. Plus, richer controls fit the same shape. An editable title or a favorite toggle is a structured config rather than a bespoke React tree:</p>&lt;AppHeader
  title={{
    text: indexName,
    onSave: renameIndex,
  }}
  favorite={{
    status: favoriteStatus,
    onToggle: toggleFavorite,
  }}
/&gt;<p>The split is consistent throughout; the application supplies state and behavior (the current name, what happens on rename, how to toggle a favorite) and the header owns presentation. It renders the controls, handles keyboard interaction, shows validation errors, and reflects pending changes. Because those roles are explicit, layout and responsive behavior stay inside the shared component, and applications don't need a coordinated update every time the header changes.</p><h3>Why a shared UI shell needs a closed set of controls</h3><p>The global shell had a similar problem that was one step more open-ended. Any plugin could register arbitrary content on the left or right and pick its position with a number, and no conversation with the platform team or designers was required:</p>chrome.navControls.registerLeft({
  content: &lt;ProjectPicker /&gt;,
});

chrome.navControls.registerRight({
  order: 10,
  content: &lt;AiAssistantButton /&gt;,
});

chrome.navControls.registerRight({
  order: 20,
  content: &lt;FeedbackButton /&gt;,
});<p>That <code>registerRight</code> API is an open door. Any team can add a control, and the header fills up with elements no one designed together. The controls compete for space and carry their own styling, and their order is decided by whoever picked the larger number. The shell only knows that one React node follows another; it can't tell that one opens an AI assistant and another collects feedback, so it can't reason about them or keep them coherent.</p><p>The new shell replaces that open canvas with a closed set of named roles under <code>chrome.controls</code> and <code>chrome.help</code>. The old registry hasn't gone anywhere yet, because the classic header still runs alongside the new one while applications migrate, but nothing in the new shell is reachable through it:</p>chrome.controls.projectPicker.set(&lt;ProjectPicker /&gt;);
chrome.controls.aiButton.register({ content: &lt;AiAssistantButton /&gt; });
chrome.controls.globalSearch.set({ onClick: openGlobalSearch });
chrome.help.registerFeedbackHandler(openFeedback);<h3>Global chrome header versus application page header</h3><p>Part of what made the old chrome hard to evolve was that "the header" was really several things tangled together. The redesign draws a hard line between two surfaces with different owners:</p><p>
</p><p><strong>Chrome (global) header</strong></p><p><strong>Application header</strong></p><p>Owner</p><p>The platform</p><p>The page</p><p>Scope</p><p>What's true everywhere in Kibana</p><p>What's true on this page</p><p>Contents</p><p>Navigation, project or deployment picker, search, help, AI assistant, feedback</p><p>Title, badges, description, tabs, page actions</p><p>How it's populated</p><p>Named slots under <code>chrome.controls</code> and <code>chrome.help</code></p><p><code>AppHeader</code> typed props, rendered directly or set via <code>chrome.appHeader.set()</code></p><p>Because each surface has one owner and a typed contract between them, the platform can redesign the chrome without auditing hundreds of pages, and an application can evolve its header without colliding with global controls. Setting a header config returns a cleanup callback, so an application tears down its own contribution when it unmounts.</p><h2>Design decisions that strict APIs force</h2><p>Stricter APIs forced design decisions that flexible APIs had let everyone defer. Two are worth calling out.</p><h3>Why we removed breadcrumbs from Kibana's navigation</h3><p>Removing breadcrumbs from the chrome was one of the more debated calls, and the state of the data made it easier than expected. In practice, breadcrumbs across Kibana were widely misconfigured:</p><ul><li><p>Some pages generated a breadcrumb for every tab on the page.</p></li><li><p>Some breadcrumbs weren't clickable.</p></li><li><p>Some repeated the title of the current page.</p></li></ul><p>They added visual weight without reliably adding orientation.</p><p>At the same time, the redesigned navigation already conveys hierarchy. The primary and secondary navigation show you where you are and give you a direct way back up. Keeping breadcrumbs would have meant maintaining a third (and frequently wrong) representation of the same information, so we stopped drawing the trail and let the navigation do that job.</p><p>The breadcrumb API itself didn't disappear. Applications still register breadcrumbs, and the new shell reads them to derive the back button and to fall back to a page title when an app hasn't supplied one. The data changed jobs rather than going away, which is why migrating an app to the new header rarely starts with ripping breadcrumbs out:</p><p><strong>Before:</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt45c047fdfcad7c14/6aaa27d97d925c9a3baf92a0/unnamed.png" alt="Kibana page header with a breadcrumb trail repeating the GenAI Settings page title" /><p><strong>After:</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b04544a35718f97/6aaa27fe92336926efb1c2d0/unnamed.png" alt="the same Kibana page header with breadcrumbs removed, showing only the page title" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ecbc17acb523ead/6aaa28219936f5d5f78b976d/unnamed.png" alt="Kibana breadcrumbs repeating a page title and tab name, annotated to show misconfigured navigation hierarchy" /><h3>How many action buttons a page header should show</h3><p>The other recurring debate was priority. With every page's actions now flowing through one menu structure, which buttons deserve to be visible, and in what order? Priorities shift as products evolve, so this conversation is ongoing.</p><p>For launch, we made one deliberate simplification: we locked the number of buttons that can appear in the app menu. A page gets one primary action and a bounded set of secondary actions before the rest collapse into a menu. The constraint keeps any single page from dividing the user's attention across a wall of buttons, and it makes the priority conversation explicit instead of letting it be settled by whoever adds the next button.</p><p>We went through several iterations to land here. Initially, we had many visible actions. A page could have up to three buttons, plus a secondary action to the left of the primary one. That took up too much space, so we gradually simplified the layout by removing the secondary action and reducing the number of visible buttons. We also made the overflow menu more structured. Some items, such as feedback and documentation, now have a fixed place in the footer of the overflow menu.</p><h2>What strict design system governance costs teams</h2><p>Under the old API, a plugin could add a novel UI by choosing a side, picking an order, and mounting a React tree. Under the stricter API, a new kind of control may need a shared capability before a plugin can add it. That takes more work up front and forces a design decision that teams could previously avoid.</p><p>We accepted the cost of stricter APIs because the alternative pushed it into every later redesign. As long as arbitrary React trees mounted at arbitrary points, every change to the shell's layout or accessibility had to account for all of them.</p><p>That’s the trade we made, and it pays back on both fronts. Consistency stops being something each team has to get right by hand; there’s one way to express a control, so pages match by default. And the shared surface stays governed. Its set of controls is a deliberate design decision, not whatever accumulated at the edges. Applications describe what their controls mean, and the shell is free to decide how they look, both today and in the next redesign.</p><h2>Where the redesigned Kibana chrome is available</h2><p>The redesigned chrome is available now in <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a>. Open any project, and you're already using it. For <a href="https://www.elastic.co/cloud">Elastic Cloud Hosted</a> and self-managed users, it ships in 9.6.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/design-system-governance-kibana-page-headers</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/design-system-governance-kibana-page-headers</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Developer Experience]]></category>
    <dc:creator><![CDATA[Anton Dosov,Ryan Keairns,Krzysztof Kowalczyk,Alex Marhaba]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc5228b5443fe9d20/6aaa28eb27e4f0a047befaa4/unnamed.png" length="0" type="image/png"/>
    <pubDate>Wed, 16 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Columnar storage isn't a columnar database. What Columnar mode brings to Elasticsearch]]></title>
    <description><![CDATA[Elasticsearch has stored data in columns since 2013, but adding full columnar database capabilities required a new mode.]]></description>
    <content:encoded><![CDATA[<p>When we wrote that Elasticsearch is becoming a columnar database, the sharpest reply we got was that it already is one. That reply is correct on the facts. Doc values, the per-field column store that Elasticsearch inherited from Lucene, arrived in 2013, and nearly every aggregation, sort, and query in Elasticsearch Query Language (ES|QL) has read them since Elasticsearch 2.0 made them the default. Each field's values sit together in their own file on disk. So the interesting question is what else a columnar database needs (rather than whether we store columns), and the answer turns out to be five things.</p><p>Doc values were built to make aggregations, sorting, and grouping possible on a document engine, and they do that job well. <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage">Columnar Mode</a> changes what the columns are for, and this post walks through the five properties that separate storing columns from being a columnar database.</p><h2>Five properties separate a column store from a columnar database</h2><h3>Doc values were an optimization on top of <code>_source</code></h3><p>For most of the last decade, the original JSON document was the source of truth and the columns were a derived convenience. That ordering has consequences throughout the engine.</p><p>Because the engine could always fall back to <code>_source</code>, per-field storage was allowed to be lossy. Text fields had no doc values at all, since text could be reread from the stored document when needed. Even synthetic source, which reconstructs a document from its fields rather than storing a copy, sometimes reads from row-shaped structures to stay faithful to the JSON that arrived, with values that exceed <code>ignore_above</code> and fields that arrived unmapped going into stored fields.</p><p>The result is a clear contract; whatever JSON you send, you get back, and the columns accelerate everything else. For an engine whose job is to return your documents, that’s the right way round. Columnar Mode inverts it. Every field stores itself exactly once as doc values, doc values cannot be turned off, text fields get doc values, too, and the document is reconstructed from the columns when something asks for it.</p><h3>How dictionary encoding handles high-cardinality data</h3><p>Sorted doc values, the default for keyword fields, store a dictionary of distinct values plus one ordinal per document pointing into it. This is an excellent trade when values repeat. A <code>host.name</code> field drawn from a hundred machines, or a status code that’s almost always 200, compresses beautifully and groups quickly.</p><p>It works like the index cards in a warehouse. When 50 crates hold the same product, one card and 50 pointers beats writing the product name 50 times. When 50 crates hold the same product, you can store the product name in the index with the list of 50 crate IDs. When every crate holds something unique, you might as well just put the product name on the crates; the index will help you find what crate you want, but it won't save ink.</p><p>High-cardinality fields describe a lot of real data, including URLs and trace identifiers, along with message bodies. Columnar Mode, which skips the dictionary and compresses the values in blocks instead, uses binary doc values for high-cardinality strings. Which of the two a field gets isn’t something you configure. The engine decides per field, based on the values it sees, so each column is encoded for the data it actually holds instead of one default applied to every field. Pure columnar systems have long carried cardinality in their type system, but usually as something you declare, and you own the consequences when the data shifts underneath it. Here, it’s the engine's job.</p><h3>Why every field builds an inverted index by default</h3><p>By default, a keyword field also builds an inverted index and a numeric field also builds a BKD tree. That happens on every field because at write time the engine doesn’t know which capability you’ll want at read time, significantly increasing the footprint of each field. Those structures also have to be rebuilt during segment merges, which costs CPU exactly when ingest is heaviest.</p><p>Our time series engine (TSDB) is proof of what happens when you stop paying for capability that the workload doesn’t use. Replacing the indices on <code>@timestamp</code> and dimension fields with <em>doc value skippers</em>, which are sparse structures holding the minimum and maximum value for each block of documents, removed 10 bytes of the original 25 bytes per OpenTelemetry (OTel) data point. There was no measurable query regression on time range and dimension filters, and indexing CPU dropped by about 10%, as a bonus.</p><p>Columnar Mode generalizes that default. Fields aren’t indexed unless something needs them to be, with only text-mapped fields keeping their inverted index for fast free-text search.</p><h3>Metadata fields like _id and _routing were row-shaped</h3><p>The fields you never think about followed the same document-first design. The <code>_id</code> field was a stored field plus an inverted index. Custom <code>_routing</code> was a stored field. Sequence numbers were kept for optimistic concurrency control, regardless of whether a workload ever updated a document.</p><p>TSDB deals with all three. It synthesizes <code>_id</code> from the <code>_tsid</code> and <code>@timestamp</code> values that already identify a data point, using a segment-level bloom filter to catch duplicates, which removes 5 bytes per data point with no loss of functionality. It trims sequence numbers once replication no longer needs them, which removes 4 bytes. Add a codec block size increase from 128 to 512 elements for another 2 bytes, and those four changes contribute across versions 9.1 through 9.4 to the 21 bytes that took OTel metrics <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">from 25 bytes per data point down to 3.75</a>.</p><p>Columnar Mode makes those ideas general rather than metrics-specific. By general availability (GA), all metadata fields will store themselves as doc values, while we plan to follow up and add a sort id mode synthesizing the identifier from the index sort fields, in addition to derived fields that will generalize what <code>_tsid</code> does for time series to any set of fields.</p><h3>Columnar query execution in the ES|QL compute engine</h3><p>A column store only pays off if the engine reads it as columns. Aggregations inherited the document-at-a-time shape from search, which is the natural fit for an engine built around documents. Reading columns instead lets the engine hand a whole block of values to a single instruction, and that’s where the numbers below come from.</p><p>The ES|QL compute engine changed that shape, and TSDB again shows the size of the effect:</p><ul><li><p><strong>Vectorized execution</strong> of time series aggregations was worth up to 8x on its own.</p></li><li><p><strong>Decoding on-disk data</strong> straight into the primitive arrays the engine aggregates over, with no intermediate copies, was worth roughly another 10x.</p></li><li><p><strong>Constant blocks</strong> turned repeated values into a form of in-memory run-length encoding.</p></li><li><p><strong>Filter pushdown</strong> moved filters down to Lucene, where skippers can discard whole blocks unopened.</p></li></ul><p>Together with the rest of the block-level query work, query latency improved by up to 160x compared to earlier versions.</p><p>That work continues. Skipper-aware operators, aggregations that group on ordinals and convert to real values as late as possible, and richer per-block summaries are all in progress, and they benefit every index mode because every mode reads doc values underneath.</p><h2>What Columnar Mode changes for logs and analytical data</h2><p>Storing values in columns is a storage detail. A columnar database needs five things:</p><ol><li><p>The columns are the only copy of the data.</p></li><li><p>Each column is encoded for the data it actually holds.</p></li><li><p>Metadata is columnar, too.</p></li><li><p>Fields add indices only when something needs them.</p></li><li><p>The query engine processes blocks of values rather than records or documents.</p></li></ol><p>TSDB reached all five for metrics in Elasticsearch 9.4, which is why the numbers in this post come from metrics rather than from a slide. Columnar Mode applies the same treatment to logs and security telemetry, along with analytical data. It’s in technical preview in Elasticsearch 9.5, with GA targeted for 9.7.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt723063cac8566f76/6aa8fb5a5ceda93f62dae858/unnamed.png" alt="Elasticsearch doc values, inverted index and _source across standard index mode, LogsDB and Columnar Mode" /><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-doc-values-columnar-database</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-doc-values-columnar-database</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Yannis Roussos]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25aa087300badaae/6aa8fb39f54868afce81383a/unnamed.png" length="0" type="image/png"/>
    <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[You and your AI agent shouldn't be using curl: Introducing the Elastic CLI and Agent Skills]]></title>
    <description><![CDATA[Elastic CLI reaches every Elasticsearch, Kibana and Cloud API from one command, and it's what Elastic Agent Skills run on. Input is validated against a JSON Schema before anything leaves your machine, and API keys stay in your OS keychain.]]></description>
    <content:encoded><![CDATA[<p><a href="https://github.com/elastic/cli">The Elastic CLI</a> gives you one command for every public Elastic API: Elasticsearch, Kibana, and Elastic Cloud's control plane, including Serverless projects. Learn <code>elastic es search</code>, and you already know how <code>elastic kb data-views list</code> behaves. It's built to be driven by an AI coding agent as easily as by you, so every command takes JSON in and out and validates input against a JSON Schema before sending anything. Plus, it exits with a code that an agent can branch on. Administrators control which commands run at all, and API keys go to your OS keychain, never into a large language model (LLM) transcript. Our Agent Skills now run on it. The command line interface (CLI) is in technical preview today.</p><p><a href="https://cloud.elastic.co/serverless-registration">Start a free Elastic Cloud Serverless trial</a> or <a href="https://cloud.elastic.co/login">log in to Elastic Cloud</a> to follow along, and install the CLI via <a href="https://www.npmjs.com/">npm</a>: </p>npm install -g @elastic/cli<h2>Designing the Elastic CLI for people and AI agents</h2><p>A useful side effect of building flexible tools for developers is that they’re more useful to AI agents, too. It’s also the tool that our<a href="https://github.com/elastic/agent-skills"> Agent Skills</a> now use to get things done, closing <a href="https://www.elastic.co/search-labs/blog/agent-skills-elastic">the loop that we opened in March 2026</a>, when we said that a CLI for agent workflows was coming.</p><p>The CLI gives every public API across Elasticsearch, Kibana, and Elastic Cloud one shape: the same flags, input and output conventions, authentication method, and failure mode. Consistency is the ergonomic feature; everything else is built on it.</p><p>Agents need the same thing, only stricter. An agent won’t know how to craft a valid CLI command or notice if a tool “feels” wrong; it needs output it can parse and input it can validate before sending, along with failures it can branch on. Agents are now a first-class interface to Elastic, alongside people, whether they live on the platform or in your editor and terminal.<a href="https://github.com/elastic/agent-skills"> Agent Skills</a>, and now the CLI, are how we serve the second kind, so those needs are built into the core of the CLI rather than tacked on.</p><h3>JSON input and output for every command</h3><p>Agents love structured text, and almost every Elastic API already speaks JSON, so first-class JSON support was a hard requirement. Developers who are quick with a <u><code>jq</code></u> query will be equally satisfied.</p><ul><li><p><strong>JSON output:</strong> Any command, like <code>elastic version</code> and <code>elastic es indices delete ...</code>, supports <code>--json</code>, which prints JSON-parseable output to stdout and nothing else. Failed commands print <code>{"error": {"code": "...", "message": "..."}}</code> to stderr.</p></li><li><p><strong>JSON input:</strong> Every command that takes input accepts JSON on stdin or via <code>--input-file</code>. Every top-level key in that JSON also works as a CLI argument, and inline arguments take precedence, so you can keep a big request body in a file and tweak a value or two per invocation.</p></li><li><p><strong>JSON Schema as </strong><strong><code>-</code></strong><strong><code>-help</code></strong><strong> output:</strong> Pass <code>--help --json</code> to any command, and it prints a valid JSON Schema for its input, which also feeds nicely into codegen tools. <code>elastic cli-schema</code> prints the whole command tree.</p></li></ul><h3>Exit codes that an AI agent can branch on</h3><p>Agents loop on exit codes as much as they do over stdout. All failure modes are distinguishable from success, even if stdout and stderr are never read.</p><h3>Safety rails: Keychain storage, allow lists, and validation</h3><p>No model uses tools perfectly 100% of the time, so an agent-friendly CLI should provide safety rails wherever possible.</p><ul><li><p><strong>Contexts and secret storage:</strong> Connection details live in named contexts in <code>~/.elasticrc.yml</code>, <code>kubectl</code>-style; switch with <code>--use-context</code>. API commands never take credentials as flags. <code>elastic config context add</code> writes API keys to your OS keychain (macOS, Linux, Windows) and leaves a <code>$(keychain:...)</code> reference in the YAML; <code>$(env:...)</code>, <code>$(cmd:...)</code>, and <code>$(file:...)</code> work, too. Creating a Serverless project with <code>--save-as</code> writes its credentials straight to the keychain and never prints them, so nothing leaks into logs or LLM transcripts.</p></li><li><p><strong>Allowlists/blocklists:</strong> A <code>commands.allowed</code> (or <code>commands.blocked</code>) list in the config file, globally or per context, ensures that only the commands an administrator wants are runnable.</p></li></ul>commands:
   allowed:
     - version
     - stack.es.search
     - stack.es.esql.*<ul><li><p><strong>Validation:</strong> Every command has a JSON Schema, so inputs are validated before any request is sent. Add <code>--dry-run</code> to any command that takes input, and it validates and exits without sending anything.</p></li><li><p><strong>Confirmation:</strong> Destructive commands prompt in a terminal. In a noninteractive session, where agents live, they refuse to run without <code>--yes</code> and say so in a structured error.</p></li><li><p><strong>Sanitization:</strong> Index, field, and pipeline names have length limits and forbidden characters. <code>elastic sanitize index-name '&lt;value&gt;'</code> (and <code>field-name</code>, <code>pipeline-name</code>, …) prints a version stripped of anything invalid.</p></li></ul><h3>Keeping API responses inside an agent's context window</h3><p>Elastic APIs return a lot of data, and an agent’s context window is finite. Three controls help keep unnecessary text out of the context window:</p><ul><li><p><strong>Field masks:</strong> <code>--output-fields</code> takes a comma-separated list, with dot notation for nested fields.</p></li></ul>elastic es info --output-fields 'name,version.number'
 # {
 #   "name": "serverless",
 #   "version": { "number": "9.5.0" }
 # }<ul><li><p><strong>String templates:</strong> For total control, <code>--output-template</code> takes a <a href="https://mustache.github.io/">mustache</a>-style template.</p></li></ul>elastic es info --output-template 'ES version: {{ version.number }}'
# ES version: 9.5.0<ul><li><strong>Command profiles:</strong> <code>--command-profile</code> serverless (or <code>default_profile: serverless</code> in your config) hides Elastic Cloud Hosted commands and the Elasticsearch namespaces that don’t exist on Serverless. That means less to scroll past and less for an agent to guess wrong. It’s the profile we recommend for agents.</li></ul><p><strong>Control</strong></p><p><strong>What it does</strong></p><p><strong>Syntax</strong></p><p><strong>When to use</strong></p><p>Field mask</p><p>Returns only the fields you name, using dot notation for nested fields</p><p><code>--output-fields 'name,version.number'</code></p><p>You want valid JSON back, just less of it. This is the default choice for agents parsing structured output.</p><p>String template</p><p>Renders the response through a mustache-style template</p><p><code>--output-template 'ES version: {{ version.number }}'</code></p><p>You need one value in a specific shape, for a shell variable, a log line, or a prompt.</p><p>Command profile</p><p>Hides commands and namespaces that don't apply to your deployment</p><p><code>--command-profile serverless</code>or <code>default_profile: serverless</code></p><p>You want a smaller command surface so an agent has less to scroll past and less to guess wrong. This is recommended for agents.</p><p></p><h2>Helpers for bulk ingest, scroll search, and msearch</h2><p>Some of Elasticsearch’s most popular APIs have a learning curve, so elastic es helpers wraps them:</p><ul><li><p><code>scroll-search</code>: Stream a large result set as NDJSON with paging handled for you.</p></li><li><p><code>bulk-ingest</code>: Ingest from a file, a directory, or stdin (NDJSON, JSON arrays, or CSV) with streaming, batching, concurrency, and retries.</p></li><li><p><code>msearch</code>: Send multiple searches in one request.</p></li><li><p><code>watch</code>: Print new documents from an index to stdout as they’re indexed. This is great for piping into logging tools.</p></li></ul><p><code>elastic es</code> and <code>elastic kb</code> are aliases for <code>elastic stack elasticsearch</code> and <code>elastic stack kibana</code>. If we don’t ship a command you need, <code>elastic extension create</code> scaffolds one for you.</p><h2>Searching Elastic docs from the terminal</h2><p>If you or your agents don’t know which API to use, elastic docs search (plus docs read and docs ask) searches Elastic’s documentation from the terminal, returning Markdown or <code>--json</code>. These are experimental. You’ll see a warning until you pass <code>--accept-experimental</code>, so explore, but don’t script against them yet.</p><h2>Shell completion for Bash, Zsh, and Fish</h2><p>Autocomplete hooks are available for Bash, Zsh, and Fish, and they always respect your <code>commands.allowed</code> or <code>commands.blocked</code> policy.</p><h2>How Elastic Agent Skills use the CLI</h2><p><a href="https://github.com/elastic/agent-skills">Agent Skills</a> teach an AI coding agent how an Elastic expert approaches a job; for example, which cluster health field is the verdict or how to stage a reindex so it doesn’t fall over. They capture process and judgment but not transport. A skill that embeds <a href="https://curl.se/">curl</a> with an auth header has hard-coded a hostname, key, and runtime, and it breaks when any of those change.</p><p>So our skills now use a <em>universal</em> format that runs unchanged in any runtime that can execute the <code>elastic</code> CLI, including Claude Code, Codex, Cursor, and GitHub Copilot. The body refers to operations in HTTP shorthand (<code>GET /_cluster/health</code>, <code>POST /_query</code>), and an operations table at the end binds each to a CLI command. That table is the only place transport appears:</p><p>HTTP API (shorthand)</p><p><code>elastic</code> CLI command</p><p><code>GET /{index}/_mapping</code></p><p><code>elastic es indices get-mapping --index '&lt;index&gt;'</code></p><p><code>POST /_query</code></p><p><code>elastic es esql query --format tsv --query "&lt;esql&gt;"</code></p><p><code>POST cloud:/api/v1/serverless/projects/elasticsearch</code></p><p><code>elastic cloud serverless projects search create --input-file &lt;json&gt; --wait --save-as &lt;ctx&gt;</code></p><p>Every universal skill also inherits a blunt preamble; that is, use the CLI, don’t guess credentials, don’t call the HTTP API directly, and never ask the user to paste an API key into the chat.</p><p>The two halves need each other. The skill supplies the expertise that the model doesn’t have, and the CLI supplies a way to act on it that’s validated, credential-safe, and scoped by your allowlist. Tell your agent to <em>spin up a Serverless project and load products.csv into it</em>, and the provisioning skill creates it with <code>--save-as</code>. The ingest skill dry-runs a mapping and loads with <code>elastic es bulk</code>, and the Elasticsearch Query Language (ES|QL) skill writes a query that parses on the first try. Every step returns JSON, exits non-zero on failure, and can only do what your policy allows.</p><p>Skills for Elastic Cloud onboarding and provisioning, Elastic Workflows, and Kubernetes investigation are available today. Skills for Elasticsearch query, ingest, reindex, and index design, plus Kibana dashboards and alerting, are close behind.</p><h2>What’s in the Elastic CLI technical preview, and what’s next</h2><p>The preview covers all public Elasticsearch Serverless, Kibana Serverless, and Elastic Cloud APIs. Hosted-only 9.x Elasticsearch API coverage is nearly 100%, and hosted-only 9.x Kibana APIs will be added soon.</p><p>We’re actively planning more developer experience work, including broader coverage for all supported stack releases, more helpers for common workflows, more skills in the public catalog, and loading the same skills into agents that run on the Elastic platform itself. What shapes that list is hearing how you and your agents use the CLI. Tell us what’s awkward and what’s missing, along with what you’d automate next.</p><h2>Install the Elastic CLI and Agent Skills</h2><p>The Elastic CLI is available now on npm (Node.js 22+). Install it and the skills together:</p>npm install -g @elastic/cli # or: npx -y @elastic/cli --help
npx skills add elastic/agent-skills<p>Then, add a context and check it:</p>elastic config context add prod --es-url https://&lt;project&gt;.es.us-east-1.aws.elastic.cloud --es-api-key &lt;KEY&gt;
 elastic status<p>Even without a project, you can <a href="https://cloud.elastic.co/serverless-registration">start a free Serverless trial</a> in about a minute, with no credit card. If you already have a project, <a href="https://cloud.elastic.co/login">log in</a> and create API keys for Elastic Cloud and your Elasticsearch clusters. Before pointing an agent at anything real, start with a trial project, a read-only key, and a scoped commands.allowed list. Be sure to take five minutes to read the <a href="https://github.com/elastic/agent-skills#security-considerations">security notes</a> in the skills repo.</p><p>Replace all those curl commands in your Bash scripts, and add some usage instructions to your AGENTS.md. Then let your agent’s skills work efficiently and accurately with our APIs. Let us know what you think, and don’t hesitate to<a href="https://github.com/elastic/cli/issues"> open an issue</a> if you find a bug or if your use case isn’t well supported. Your feedback directly shapes what we build next.</p><h2>Elastic CLI and Agent Skills resources</h2><ul><li><p><a href="https://github.com/elastic/cli">Elastic CLI on GitHub</a> and<a href="https://github.com/elastic/cli/tree/main/docs/cli"> CLI documentation</a></p></li><li><p><a href="https://github.com/elastic/agent-skills">Elastic Agent Skills on GitHub</a></p></li><li><p><a href="https://agentskills.io">agentskills.io specification</a></p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/serverless">Elastic Cloud Serverless documentation</a></p></li><li><p><a href="https://github.com/elastic/cli/issues">Report a CLI issue</a> ·<a href="https://github.com/elastic/agent-skills/issues"> Report a skills issue</a> ·<a href="https://discuss.elastic.co/"> Discuss</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-cli-ai-agents</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-cli-ai-agents</guid>
    <category><![CDATA[Developer Experience]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Josh Mock,Matt Ryan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt111f4783cff3ef01/6aa7bad035eddc3a1a11d192/image1.png" length="0" type="image/png"/>
    <pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How we built PromQL into Elasticsearch]]></title>
    <description><![CDATA[PromQL runs on the same Elasticsearch compute engine as ES|QL, with no plugin and no separate process to operate. Getting there meant changing how the engine evaluates time windows and builds grouping keys.]]></description>
    <content:encoded><![CDATA[<p>More than 80% of the Prometheus Query Language (PromQL) queries in our real-world corpus run on Elasticsearch without modification. Elasticsearch 9.5 makes the PromQL and the Prometheus-compatible API generally available (GA), so you can ingest Prometheus metrics with<a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write"> remote write</a> and query them through the<a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api"> Prometheus HTTP APIs</a> or the<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql"> PROMQL</a> command in Elasticsearch Query Language (ES|QL).</p><p>PromQL compiles to the same compute engine that runs ES|QL and inherits its planner and distributed execution, along with its release process. We didn’t build a second engine for this, and there’s no plugin to install.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9e310070ec244c5d/6aa3928e224d356c5e0dd18e/1.png" alt="PromQL compatibility in Elasticsearch rising from zero to 80% between 9.4 Tech Preview and 9.5 GA" /><p>This post is about how we built it.</p><p>Key takeaways:</p><ul><li><p><strong>One engine:</strong> The implementation combines Elasticsearch’s mature distributed planning, storage, and testing infrastructure with its newer compute engine, which provides a columnar execution runtime. This lets PromQL reuse proven Elasticsearch capabilities while executing through a modern, native vectorized pipeline rather than introducing a separate runtime.</p></li><li><p><strong>One server:</strong> Elasticsearch implements the Prometheus remote write and query APIs directly, so Prometheus-compatible ingest and queries run without any additional plugins.</p></li><li><p><strong>Engineered for efficiency:</strong> Supporting PromQL required new engine primitives for range-aligned evaluation grids, backward-looking windows, dynamic label grouping, pipeline result reshaping, and compact wide aggregation keys. These primitives allow PromQL queries to execute efficiently end to end, with the relevant semantics implemented directly in the compute engine rather than through external post-processing.</p></li><li><p><strong>Compatibility measured in real use:</strong> In addition to Prometheus compliance tests, we built a differential-testing and quality-control pipeline over 2,000 PromQL queries collected from public repositories. </p></li></ul><p>Read more:</p><ul><li><p><a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Query Prometheus Metrics in Elasticsearch with PromQL</a></p></li><li><p><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch">Ship Prometheus Metrics to Elasticsearch with Remote Write</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api">Bringing Fire to Elasticsearch: Adding Native Prometheus APIs</a></p></li></ul><h2><strong>Why run PromQL on Elasticsearch</strong></h2><p>Many teams already store logs and traces in Elasticsearch while running metrics in Prometheus or another dedicated metrics back end.</p><p>Prometheus and its ecosystem are strong and widely adopted, but large deployments can also bring operational sprawl and scaling challenges, along with limited retention. </p><p>So we set out to combine Elastic’s highly optimized time-series database (TSDB) with a best-in-class metrics ecosystem. The result is a smaller observability stack, with fewer systems to operate and metrics storage that scales horizontally and supports long-term retention.</p><h2><strong>One engine: PromQL and ES|QL share the same compute engine</strong></h2><p>We made an early architectural decision not to run a separate PromQL engine next to Elasticsearch.</p><p>PromQL is instead another front end to the Elasticsearch compute engine.</p><p>This puts PromQL in the normal Elasticsearch development lifecycle. It uses the same planner, distributed execution engine, testing infrastructure, and release process as Elasticsearch itself.</p><p>To learn more about Elasticsearch’s query engine, check <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">our blog</a>.</p><p>Like ES|QL’s time series queries that use the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> source command, PromQL is translated into a highly optimized query plan and executed across the cluster. Nodes process columnar batches through vectorized operators, while partial results move through exchanges until the final result is assembled. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcf3a413e89209805/6aa392b38406d9813bcaa1d8/2.png" alt="PromQL and ES|QL frontends feed one shared Elasticsearch planner and columnar execution DAG across shards" /><p>This also means that PromQL and ES|QL operate over the same execution engine and time-series data. ES|QL can additionally extend a PromQL computation with post-processing that PromQL doesn’t support, such as lookup joins and inline aggregations.</p><p>For example, assume Prometheus request counters are stored in <code>metrics-*</code> and keyed by the <code>service</code> label. A lookup index, <code>service_registry</code>, maps each instance to its owning team and environment and to its service tier:</p><p></p><p></p><p>This architecture requires the execution engine to support PromQL semantics natively and efficiently rather than ES|QL syntax sugar. The following sections describe the changes and new execution primitives we introduced to achieve that.</p><h2><strong>One server: Prometheus remote write and HTTP API built into Elasticsearch</strong></h2><p>Query execution is only half of the story. The Prometheus ecosystem also expects familiar ingest and query APIs.</p><p>Prometheus protocols are the de facto standard for everything metrics in almost every team’s observability stack. So we built the HTTP API directly in the Elasticsearch server, which eliminated the need for a third component and tightened integration stability and performance.</p><p>On the ingest side, we <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">added</a> an endpoint for the <a href="https://prometheus.io/docs/specs/prw/remote_write_spec/">Prometheus remote write</a> protocol. It accepts Snappy-compressed Protocol Buffer messages, maps labels to TSDS dimensions, maps the metric name/value into metric fields, infers counter versus gauge mappings, and writes directly into TSDS. The built-in template is dynamic, so users don’t have to predeclare every Prometheus label or metric.</p><p>On the query side, Elasticsearch <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">exposes</a> Prometheus query APIs. A request enters through the Prometheus endpoint and is executed in the compute engine.</p><h2><strong>Running PromQL efficiently in a columnar engine</strong></h2><p>Sharing an execution engine doesn’t mean treating PromQL as syntax sugar over ES|QL. PromQL has different time, grouping, and response semantics, as well as workload characteristics that matter at scale. Supporting it efficiently required extending the compute engine rather than compensating in the API layer.</p><h3><strong>PromQL time grids: Aligning evaluation steps with TSTEP</strong></h3><p>Time-series query engines are optimized for grouping over time.</p><p>Elasticsearch normally groups timestamps with <code>TBUCKET(...)</code>, which truncates each timestamp to a fixed interval boundary. Truncation is cheap and produces deterministic bucket boundaries. It also makes intermediate results easier to reuse.</p><p>Prometheus defines evaluation points differently. For a range query, timestamps are laid out as fixed steps anchored to the query range, rather than derived by truncating each sample timestamp. Two queries with the same step but different range boundaries can therefore produce different evaluation grids.</p><p>To preserve these semantics, we introduced <code>TSTEP(...)</code>, which derives its grouping grid from the query range and step,  rather than truncating timestamps to globally aligned boundaries.</p><p>PromQL uses <code>TSTEP(...)</code> internally, preserving Prometheus timestamp semantics while still lowering the operation to a native Elasticsearch execution primitive.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt437155bca2029671/6aa3937927a5312436dcbf38/3.png" alt="TSTEP vs TBUCKET in Elasticsearch: PromQL step grid anchored to query start, TBUCKET to fixed boundaries" /><p>Some might see this as a simple problem, but the nuances matter. </p><p>Take, for example, a query that finds a 5m rolling average of a metric: </p><p></p><p></p><p>At evaluation time <code>T</code>, the result represents the average over the preceding five-minute range: </p><p><code>(T - 5m, T]</code></p><p>When the query is executed with a five-minute step, each output value is labeled with the upper end of its corresponding five-minute window.</p><p>Elasticsearch previously lacked this semantic and supported only forward-looking window aggregation functions:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt97aac2fa95cf657d/6aa393b71ade64390142ee2e/4.png" alt="Forward-looking window aggregation where each bucket covers the interval from timestamp T to T plus W" /><p>We rewrote the window-evaluation path so that both ES|QL and PromQL use a common backward-looking windowing implementation:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a12ceb27d9ab95b/6aa393dcf08ee141fe856719/5.png" alt="Backward-looking PromQL window covering T minus W to T, the range used by rate and avg_over_time" /><p>Together, <code>TSTEP(...)</code> and backward-looking windows preserve the two time semantics that matter for PromQL range evaluation.</p><h3><strong>Dynamic label grouping: How PromQL </strong><strong><code>without()</code></strong><strong> resolves at runtime</strong></h3><p>Time grids determine when a PromQL expression is evaluated. Aggregation determines which input series are combined and which labels identify each output series.</p><p>For most analytical query engines, that identity is known when the query is planned. The planner can allocate grouping columns and choose an aggregation strategy. It also carries a fixed key through the execution pipeline.</p><p>That’s how ES|QL works:</p><p></p><p></p><p>The output series are grouped by an explicit key <code>(cluster, namespace)</code>.</p><p>PromQL can express the same operation in the opposite direction:</p><p></p><p></p><p>Now we know which dimensions <em>not</em> to use. We don’t necessarily know the full grouping key until the query is executed. This is a small language difference with significant execution consequences. </p><p>One possible implementation is to discover every label used by the metric, subtract <code>instance</code> and <code>pod</code>, and rewrite the expression into an ordinary <code>by(...)</code> aggregation. That adds a <a href="https://www.elastic.co/search-labs/blog/esql-metrics-info-ts-info-time-series-catalog">discovery phase</a> before planning. It also becomes inefficient for high-dimensional metrics where only a subset of all possible dimensions may have useful value in a particular series. Most queries need only a small subset of the available dimensions, so carrying the entire dimension universe as an aggregation key wastes memory and adds bookkeeping overhead.</p><p>We instead extended the time-series execution path with dynamic grouping columns. The engine loads dimensions as the series are read and applies the exclusions per time series. This avoids making the grouping schema a prerequisite for planning and avoids carrying a large sparse set of grouping columns through aggregation.</p><h3><strong>Dimension packing: Keeping wide PromQL grouping keys cheap</strong></h3><p>The <a href="https://prometheus.io/docs/practices/rules/#aggregation">idiomatic</a> way of writing PromQL aggregations involves heavy use of <code>without(...)</code> over <code>by(...)</code>:</p><p></p><p></p><p>Excluding labels rather than explicitly listing them makes dashboards and alerts resilient to schema evolution. If a new label is added to the metric, the query continues to preserve it unless it’s explicitly excluded.</p><p>The consequence for the engine is that effective grouping keys can be wide. Many of those labels often have low cardinality, yet each still participates in every aggregation stage.</p><p>In a columnar engine, each grouping column is normally represented as a separate vector. Ten grouping labels therefore mean 10 vectors flowing through every aggregation operator:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2f491d0d9fdb82f2/6aa3940027a53105ccdcbf3c/6.png" alt="Elasticsearch columnar page: rows split into typed blocks with delta and ordinal dictionary compression" /><p>Dimension fields are declared in the index mapping; the planner knows the schema up front, and grouping keys stay narrow and predictable.</p><p>In the columnar engine, each grouping label is carried as a separate vector or block. A key with 10 labels therefore requires 10 vectors to be read, hashed, compared, and retained by aggregation operators. As key width grows, so does the amount of data and bookkeeping that must move through the pipeline:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt13b25c686bbb46e0/6aa39429de2395f2e2e86d1d/7.png" alt="PromQL aggregation without packing: each grouping label hashed as a separate block into 64-byte keys" /><p>To avoid paying that per-column cost for every additional label, we introduced dimension packing. Before aggregation begins, the engine encodes the full grouping key into a single compact representation. Hash and comparison operations run on the packed key rather than on each block independently:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a75855ef51c017b/6aa394468406d95667caa1de/8.png" alt="Dimension packing in Elasticsearch encodes PromQL grouping labels into one 16-byte key before hashing" /><p>Packing lets hashing and comparison operate on a single compact key rather than an increasing number of grouping blocks, making aggregation overhead less sensitive to key width. Because the engine is shared, ES|QL time-series queries will benefit from this optimization as well.</p><h3><strong>Building the Prometheus HTTP API response inside the pipeline</strong></h3><p>Unlike Elasticsearch's ES|QL column-oriented response format, the Prometheus response is row-oriented.  The Prometheus API returns one result row per time series, with its samples represented as timestamp-value pairs. </p><p>To support a compatible API layer, we had to regroup in the HTTP layer converting the columnar results into boxed row objects and accumulate them in map- and list-based structures until the complete Prometheus response could be produced. </p><p>We replaced this with the <code>TimeSeriesCollapse</code> compute operator. It groups rows by series and aligns samples to the query’s fixed step grid. It emits the reshaped result as ordinary columnar pages containing one row per series, with aligned multi-valued timestamp and value blocks. And it preserves compact, vectorized block representation throughout the pipeline: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt99e2ee3137bc2fa6/6aa39496d29b4e02e81da7b6/9.png" alt="TimeSeriesCollapse operator reshapes five columnar rows into two Prometheus time series per output page" /><p>The HTTP layer can now serialize those blocks directly, avoiding the maps, lists, boxed objects, and associated allocations required by the earlier implementation.</p><h2><strong>Testing PromQL compatibility against 2,000 real queries</strong></h2><p>Prometheus <a href="https://github.com/prometheus/compliance">compliance tests</a> were our starting point.</p><p>Even though they gave us a strong baseline, they didn’t tell us how frequently individual PromQL features appear in real workloads. To complement that baseline, we built a second test corpus from over 2,000 PromQL queries collected from public repositories. </p><p>We then classified those queries by the language features and expression patterns they exercise:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf008821c47e46040/6aa394b68406d90c5bcaa1e2/10.png" alt="PromQL feature use across 2,000 real queries: aggregations 59.67%, selectors 57.55%, rate functions 45.21%" /><p>For each compatible query shape, we run the same query against Elasticsearch and Prometheus and compare the results. </p><p>In addition to that, we actively rely on <a href="https://en.wikipedia.org/wiki/Fuzzing">fuzz testing</a>, which catches issues that unit tests alone are unlikely to expose, including differences in timestamp alignment, label retention, aggregation behavior, range-vector evaluation, and response encoding.</p><h2><strong>Which PromQL functions and APIs are supported in 9.5</strong></h2><p>Since 9.4 (technical preview), PromQL support in Elasticsearch has expanded substantially. In Elasticsearch 9.5, both the<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql"> <code>PROMQL</code></a> command in ES|QL and the<a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api"> Prometheus HTTP APIs</a> are generally available (GA), with more than 80% of the PromQL workflows in our real-world corpus now running without modification:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6706c16409dccfd7/6aa394d327a5312acfdcbf41/1.png" alt="PromQL compatibility in Elasticsearch rising from zero to 80% between 9.4 Tech Preview and 9.5 GA" /><p>The main additions since technical preview are:</p><p><strong>Feature</strong></p><p><strong>Example</strong></p><p><strong>Status</strong></p><p>Prometheus remote write ingest</p><p><code>POST /_prometheus/api/v1/write</code></p><p>GA in 9.5</p><p>Range queries</p><p><code>/api/v1/query_range</code></p><p>GA in 9.5</p><p>Instant queries</p><p><code>/api/v1/query</code></p><p>GA in 9.5</p><p>Metric metadata and build info</p><p><code>/api/v1/metadata</code>, <code>/api/v1/status/buildinfo</code></p><p>GA in 9.5</p><p>Native histogram functions</p><p><code>histogram_quantile</code>, <code>histogram_count</code>, <code>histogram_sum</code></p><p>GA in 9.5</p><p>Per-selector offset modifiers</p><p><code>[5m] offset 1h</code></p><p>GA in 9.5</p><p>Top-level <code>or</code> operator</p><p><code>rate(a[5m])</code> or <code>rate(b[5m])</code></p><p>GA in 9.5, up to eight operands</p><h3><strong>Prometheus remote write ingest</strong></h3><p>Elasticsearch <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">accepts</a> Prometheus remote write (v1) messages directly:</p><p></p><p></p><p>Snappy-compressed Protocol Buffer messages are decoded, and labels are mapped to TSDS dimensions. Metric names and values are written into the time-series index. The built-in template is dynamic, so users don’t have to predeclare every Prometheus label or metric.</p><h3><strong>Range and instant queries through the Prometheus HTTP API</strong></h3><p>Both range and instant query endpoints are <a href="https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api">available</a>:</p><p></p><p></p><p></p><p></p><p>Range queries return matrices evaluated over a time window, and instant queries return vectors evaluated at a single timestamp. These endpoints can be used by Kibana, Grafana, or Prometheus-compatible alerting tools, and custom dashboards.</p><h3><strong>Metric metadata and build info endpoints</strong></h3><p>Elasticsearch <a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api#promql-http-api-metadata">exposes</a> metadata about available metrics and a build-info endpoint:</p><p></p><p></p><p></p><p></p><p>The metadata endpoint returns metric types and help text, and the build-info endpoint returns the Prometheus-compatible server version. Grafana and other tools use these endpoints for feature detection and UI behavior.</p><h3><strong>Native histogram functions: histogram_quantile, count, and sum</strong></h3><p>Elasticsearch supports the <a href="https://www.elastic.co/docs/reference/query-languages/promql/functions/histogram">main PromQL operations</a> over native histograms:</p><p></p><p></p><p></p><p></p><p>Native histograms adapt their bucket layout to the data, providing useful precision across a wide value range without requiring users to configure every bucket boundary in advance. Classic histograms continue to work alongside native histograms.</p><h3><strong>Per-selector offset modifiers in PromQL</strong></h3><p>Offset modifiers shift a selector’s time window backward:</p><p></p><p></p><p>This returns the request rate from one hour earlier. Per-selector offsets are commonly used to compare current traffic, latency, or resource usage with an earlier baseline, such as the same period one week ago.</p><h3><strong>Top-level </strong><strong><code>or</code></strong><strong> operator in PromQL</strong></h3><p>Elasticsearch supports the top-level PromQL <code>or</code> operator:</p><p></p><p></p><p>In PromQL, <code>or</code> isn’t a Boolean operation. It performs a union between two sets of time series. Results from the left side are retained; a series from the right side is added only when its label set doesn’t match a series already returned by the left side. This is useful during migrations where the same logical metric may exist under an old and a new name.</p><p>The implementation follows Prometheus’s left-side precedence rules and preserves the <code>__name__</code> label. Top-level chains of up to eight operands are supported.</p><h2><strong>PromQL features not yet supported in Elasticsearch</strong></h2><p>GA doesn’t mean complete PromQL compatibility. Some less common and more complex parts of PromQL remain unsupported. These gaps now define the next phase of the work: </p><p><strong>Feature</strong></p><p><strong>Example</strong></p><p><strong>Status</strong></p><p>Advanced vector matching</p><p><code>on(instance) group_left</code></p><p>Planned</p><p>Sorting and ranking</p><p><code>topk</code>, <code>bottomk</code>, <code>limitk</code>, <code>sort</code>, <code>sort_desc</code></p><p>Planned</p><p>Label manipulation</p><p><code>label_replace</code>, <code>label_join</code></p><p>Planned</p><p>Absolute time modifier</p><p><code>@ 1710000000</code></p><p>Planned</p><p>Mixed-offset compound expressions</p><p><code>rate(...) - rate(... offset 1h)</code></p><p>Planned</p><p>Alerting and target endpoints</p><p><code>/api/v1/alerts</code>, <code>/api/v1/targets</code></p><p>Out of scope</p><h3><strong>Advanced </strong><a href="https://prometheus.io/docs/prometheus/latest/querying/operators/#group-modifiers"><strong>vector matching</strong></a><strong> with </strong><strong><code>on()</code></strong><strong> and </strong><strong><code>group_left</code></strong></h3><p>Some binary operations that require Prometheus vector matching aren’t yet part of GA.</p><p>For example, this query divides per-instance request rates by a per-instance capacity metric:</p><p></p><p></p><p>The <code>on(instance)</code> clause specifies which labels identify matching series. <code>group_left</code> permits many request-rate series to match a single per-instance capacity series, while retaining the labels from the higher-cardinality left-hand side.</p><p>These expressions are common when joining a detailed metric with metadata or a lower-cardinality capacity metric. Basic binary expressions are supported where applicable, while the remaining vector-matching forms are planned work.</p><h3><strong>Sorting and ranking: </strong><strong><code>topk</code></strong><strong>, </strong><strong><code>bottomk</code></strong><strong>, and </strong><strong><code>sort</code></strong></h3><p>Prometheus sorting and ranking functions are also not yet part of GA:</p><p></p><p></p><p>This returns the 10 services with the highest request rate. Similar queries are widely used in “top offenders” dashboards for traffic, latency, errors, and resource consumption.</p><p>The remaining functions include:</p><p></p><p></p><p></p><p></p><p></p><p></p><h3><strong>Label manipulation with label_replace and label_join</strong></h3><p>PromQL can construct or rewrite labels during query evaluation. These functions are particularly useful when dashboard variables, naming conventions, or label schemas don’t match exactly:</p><p></p><p></p><p>This creates an <code>environment</code> label from the <code>cluster</code> label.</p><p>Another common example combines existing labels into a display-oriented label:</p><p></p><p></p><p>This produces a <code>target</code> label, such as <code>payments/api-7f6d9</code>. <code>label_replace(...)</code> and <code>label_join(...)</code> aren’t yet included in GA.</p><h3><strong>Advanced time modifiers: The </strong><strong><code>@</code></strong><strong> modifier and mixed offsets</strong></h3><p>Several advanced time modifiers and expression forms remain outside the GA scope.</p><p>For example, an absolute <code>@</code> modifier evaluates a selector at a fixed Unix timestamp rather than at the query’s normal evaluation time:</p><p></p><p></p><p>This is useful for comparisons against a fixed historical point.</p><p>PromQL also permits expressions in which the two sides use different offsets:</p><p></p><p></p><p>This compares current traffic with traffic one hour earlier. Per-selector <code>offset</code> is available in GA, but not every combination of offsets and compound expressions is part of GA yet.</p><h3><strong>Prometheus API endpoints not yet implemented</strong></h3><p>In addition, the Prometheus HTTP API surface isn’t yet fully complete. Notably:</p><p>Alerting metadata through:</p><p></p><p></p><p>used by tools that inspect active alert state.</p><p>Target discovery through:</p><p></p><p></p><p>used to inspect scrape targets, health, and labels.</p><p>These endpoints concern Prometheus server and scrape-target state rather than querying metrics stored in Elasticsearch.</p><p>For the full list of limitations, see the <a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-limitations#promql-limitations-form-post">PromQL limitations</a> page. </p><h2><strong>Try PromQL in Elasticsearch 9.5</strong></h2><p>To query Prometheus metrics in Elasticsearch 9.5 or Serverless, see the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql">PromQL documentation</a> and the <a href="https://www.elastic.co/docs/reference/query-languages/promql/promql-http-api">Prometheus HTTP API reference</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/promql-elasticsearch-compute-engine</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/promql-elasticsearch-compute-engine</guid>
    <category><![CDATA[Query Languages]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Sergey Sidorov,Felix Barnsteiner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf690e82ea51bbeec/6aa37da41ade6445cc42eded/unnamed.png" length="0" type="image/png"/>
    <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Trust, but benchmark: How we let an AI agent optimize Elasticsearch]]></title>
    <description><![CDATA[We share how we built a harness that automatically identifies and implements optimizations in the Elasticsearch codebase.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch executes a diverse set of workloads, including sustained heavy index building and real-time search and analytics. Delivering excellent performance across the board requires going broad in coverage while simultaneously diving deep enough into the codebase to understand optimization opportunities for each workload. Traditionally, human attention has been the bottleneck in this process; there simply aren't enough engineering hours to scrutinize every hot code path looking for inefficiency across a large and evolving surface area.</p><p>However, with the rapid progression of coding agents, performance optimization has become a task we can tackle semiautomatically. Unlike many software engineering challenges, optimizing code offers a cheap and objective verifier. If you ask an AI model to make code faster, there’s a hard number at the end telling you exactly what happened, backed by profiling tools that explain why. This makes performance a perfect candidate for automation, provided you can actually trust the numbers.</p><p>If you simply point a coding agent at a benchmark, you typically get low signal-to-noise: wins that fall inside the variance of the environment, or variations caused by thermal throttling rather than better code. To capture optimizations that actually benefit Elasticsearch users, we had to bridge the gap between "checkable in principle" and "checked in practice." We built a highly trustworthy measurement loop: a <a href="https://en.wikipedia.org/wiki/Agent_harness">harness</a> that assumes the agent will be wrong a good fraction of the time but reliably catches and proves it when it’s right and then helps guide it where to look next.</p><p>Once the machinery is in place, the results speak for themselves. By letting this harness loose on the codebase, we've already begun uncovering meaningful wins across the stack. In part 2 of this post, we’ll dive into some examples it has found so far, including string conversion inefficiencies in Elasticsearch Query Language (ES|QL), an improvement to our NEON vector dot product implementation, and an upgrade opportunity for the gzip library we were using. In this part, we’ll take a look at the design choices we made and how they relate to the broader topic of effective harness development.</p><h2>The AI code optimization pipeline architecture</h2><p>The first step in any software engineering problem is to identify the correct high-level components. We made an architectural choice that turned out to be very helpful for this problem: separate understanding where opportunities exist from the loop making code changes. The agent starts with a real workload but only uses it to mine information about where to seek performance improvements. At this stage, it’s instructed to go broad and consider a range of performance-related signals. Once it has found and classified the hot spots, the agent reads the context of the code around them to understand the optimization opportunities. We use a separate task to condense the ranked list of hot spots into artifacts that a loop can iterate against in minutes: a microbenchmark that we prove exercises the hot path in its real operating regime. Finally, we use a proposer-verifier loop to actually make changes to the codebase to improve performance on the benchmark. This hands off to validation to assess the impact on real workloads at the end. Our CLI (<code>atune</code>) supplies the tools this process needs, and the rest is largely automated by a set of task-specific instructions.</p><p>For context, our high-level architecture looks like the following. Pink boxes are the humans, and teal boxes are the agent. There are three task types, one skeleton loop, and one referee.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e561ef2d3b61421/6aa3e83fd1556f0fcc7269f8/unnamed.png" alt="AI code optimization pipeline: exploration to benchmark, human approval, exploitation, validation and PR review" /><p><em>An exploration task profiles a real workload and produces ranked opportunities; a human promotes one into an exploitation task, which iterates against an approved microbenchmark and commits each accepted experiment; a validation run on the real workload guards the result before a human reviews and opens the PR. Where no benchmark covers the hot path, a benchmark task authors one and a human approves it into a registry. A performance atlas informs every task and accumulates what each one learns.</em></p><h2>Why performance optimization suits autonomous agents</h2><p>Three properties make a task ideally suited for autonomous work, and it's worth being explicit about them because they provide a checklist you can use to evaluate automation candidates. You want:</p><ol><li><p>An objective verdict so that the agent can be held to something other than its own opinion.</p></li><li><p>A dense guiding signal so that it knows where to look next instead of guessing.</p></li><li><p>A bounded blast radius so that being wrong is affordable.</p></li></ol><p>Performance gives you all three. Benchmarks provide the verdict and profilers provide the gradient, while a rejected patch costs you wall-clock time rather than correctness. The change gets reverted, and the reason gets recorded. Life goes on. Regarding the first two, we've come to think the gradient matters more than the verdict. ″This got faster″ is binary, whereas a profile hints at what to try next. An agent can generate its next hypothesis conditioned on a rich guiding signal, the richer the better, rather than grinding through a list.</p><h2>Signals are what the agent gets to see</h2><p>A useful mental model is that the CLI is the agent's sensory apparatus. That changes how you design each command. Rather than exposing a capability, you design it to return a clear and concise answer to a question about the task at hand, and, when relevant, an explanation the model can reason over, instead of raw data it has to parse and interpret. These are the signals that the agent acts on, and we ended up with the following for our harness:</p><p><strong>Signal</strong></p><p><strong>Question it answers</strong></p><p>Facet-decomposed macro profile</p><p>Where in the code does real workload time go, per query type?</p><p>Allocation and lock sampling, in the same capture</p><p>Is the cost cycles, garbage, or contention?</p><p>Cost-composition classification</p><p>Is this in scope compute, other product code, GC, JIT tax, or parked threads?</p><p>Input-shape instrumentation</p><p>What does the workload actually feed this code?</p><p>Statistical verdict</p><p>Did this change help, at this measured noise floor?</p><p>Allocation-rate comparison</p><p>Did the new code end up allocating more?</p><p>Interpreted disassembly</p><p>Why did that result happen?</p><p>End-to-end A/B guard, with differential profile attribution</p><p>Did anything appear to break, and was it us?</p><p>Environment check</p><p>Is this machine even fit to measure right now?</p><p>Upstream duplicate search</p><p>Has somebody already reported or fixed this?</p><p>Four of these signals are worth dwelling on, because in each case the tool encodes a judgment that the agent would otherwise have had to keep making by hand.</p><p>Facet-decomposition is the clearest example. Blended CPU shares hide breadth: if you profile a mixed query workload, the grouping hash map insert and the percentiles sketch update can both show up as single-digit percentages of the total run time and look comparable. They aren't comparable at all, because the hash map insert is paid for by nearly every aggregation query, while the sketch update is only paid for when somebody asks for percentiles. So the profiler runs each named query facet as its own race, and every opportunity the agent records carries a breadth field (universal, broad, or narrow) and gets ranked by headroom × tractability × breadth. A universal 3% beats a narrow 10%. Putting the ranking function in the tool prevents it from having to be rediscovered on every run.</p><p>The cost-composition classification works as a router. Rather than handing the agent a flat top-N list of frame names, it buckets every sampled stack into ″in scope compute,″ ″other product compute,″ ″GC,″ ″JIT and safepoint overhead,″ ″off CPU waiting,″ and ″parked threads.″ Each of those buckets implies a different kind of investigation. If GC is above about 15%, the real target is allocation rate, and the CPU top frames will actively mislead you, because they show where objects were collected rather than where they were created. If JIT and safepoint overhead is above about 25%, you're looking at a ceiling rather than an opportunity, since no in-scope code change will move it. If threads are parked and core utilization is low, this is a concurrency problem and CPU flame graphs are the wrong instrument entirely. We wrote that mapping into the playbook as a table, so the model (even a cheap one) reads a profile the way that an experienced engineer would, rather than reaching straight for the top frame. The classification is a prior for forming a hypothesis, though, not a substitute for evidence, so the agent still has to cite specific frames when it proposes an experiment.</p><p>Interpreted disassembly is a tool we hadn't originally provided, but it most definitely earns its place. It helps answer <em>why</em>, the question that unblocks the next hypothesis. Flame graphs tell you where the time goes; they rarely tell you why a change made things worse. So <code>atune asm</code> runs the benchmark briefly with the JIT told to print the assembly for one hot method, captures both sides of the working-tree diff, reduces each to the final C2 compilation, normalizes the addresses, and diffs them. The diff alone would likely still be 4,000 lines of aarch64, so on top of it sits an interpretation layer: a per-mnemonic delta, a net instruction count, the compilation tier that was actually captured, and a vectorization signal that counts vector register references on each side and raises a warning when they're eliminated, halved, or narrowed from <a href="https://en.wikipedia.org/wiki/Advanced_Vector_Extensions">Advanced Vector Extensions</a> (AVX) to <a href="https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions">Streaming SIMD Extensions</a> (SSE) width. The playbook then maps mnemonic patterns to causes:</p><p><strong>Pattern in the diff</strong></p><p><strong>Likely cause</strong></p><p><code>b.eq</code><code>/</code><code>b.ne</code> up, <code>csel</code> down</p><p>New unpredictable branches</p><p>Clusters of <code>str</code><code>/</code><code>ldr</code> against the stack pointer</p><p>The compiler ran out of registers</p><p>NEON loads replaced by scalar compares</p><p>The vector path degraded</p><p>In one experiment, the agent fused two <a href="https://en.wikipedia.org/wiki/Single_instruction,_multiple_data">SIMD</a> mask extractions into one, and the benchmark regressed by 26%. The vectorization warning explained it in about 10 seconds. Without that tool, the agent has a dead end and no working model of the machine; with it, it has a corrected model and several new ideas.</p><p>The fourth signal is less a single tool than a habit; the instruments check themselves. Core utilization is derived two independent ways, from sample density and from process sampling, so the two can be compared. The classification is rejected if the unclassified bucket exceeds a budget, on the grounds that a breakdown which can't account for its own samples shouldn't be reasoned over. The disassembly capture warns when the compilation it caught isn't the steady-state one. The upstream duplicate search is restricted to read-only commands, and that restriction is enforced by a test that greps the source, so no future edit can quietly reintroduce the ability to file anything. Each of these exists because a tool that can be confidently wrong is worse than a tool that is merely absent.</p><p>One small piece of design is worth highlighting as a specific instance of good return practice. <code>atune compare</code> returns 0 for improved, 1 for no change, 2 for regressed, and 3 for error, and the loop branches on this code. That means no parsing and no ambiguity about what the verdict was. Plus, no tokens are spent interpreting prose.</p><p>If you take one thing away from our CLI design, it’s a broader design principle. In a general setting, the interesting thing isn't the individual signals we found useful to understand performance; it's that the CLI is capturing and packaging the judgment of an experienced performance engineer into tools that return answers rather than raw data. Structurally imposing good judgment about the problem an agent is tasked with improves outcomes. The right CLI is as much part of that story as the instructions. Furthermore, tokens are saved by tools that return decisions and digests rather than data. That means a comparison verdict instead of raw JMH output; a triage summary sitting on top of a 4,000-line disassembly diff.</p><h2>From 20-second probes to hours of validation</h2><p>Building a verifier that you can afford to consult is a separate problem from building one that you can trust. This covers the affordability part. Or, if you like aphorisms, real workloads are where truth lives and where iteration goes to die. A macro profile takes 45 to 60 minutes, and an end-to-end validation run takes hours. But a microbenchmark takes minutes. That's why the exploration-then-exploitation split works; you go broad on the real workload once and then hand off to a microbenchmark that you can iterate against in minutes.</p><p>The catch is that the handoff is only sound if the microbenchmark exercises the hot path in a realistic operating regime; that is, with the right cardinality and right data distribution. If you get that wrong, your fast loop spins fast but in the wrong direction. Until we finalized the handoff procedure, we saw cases where the agent accepted changes on a benchmark whose key distributions happened to flatter it, and only the end-to-end run caught the problem.</p><h3>Handing off from exploration to exploitation</h3><p>The handoff between the two phases is structured rather than informal. An exploration task's primary deliverable is a set of opportunity records, and each one carries the scope paths that an exploitation task would be allowed to edit, a headroom estimate, a classification (constant factor, structural,</p><p>allocation, or concurrency), and the benchmark it would gate on (or an explicit "no benchmark coverage" flag, if none exists). Each also carries a narrow test pattern so that the correctness gate stays cheap. The test pattern field exists because of a specific incident; an exploration task omitted it, and the resulting exploitation task ran a very heavy test suite on every experiment. The fix was to change the upstream artifact rather than add an instruction downstream. This is a pattern we use repeatedly; make and record decisions as early as possible rather than re-derive them each time.</p><h3>Validating a new benchmark before it can gate anything</h3><p>Where benchmark coverage is genuinely missing, a dedicated benchmark task authors one, and that new benchmark has to pass validity checks before anything can rely on it. Two of them are mechanical: what fraction of the benchmark’s hot self-time comes from frames that actually appear in the production profile and whether the parameters fall inside the input shapes we measured. The third is a checklist that the agent has to attest item by item. It exists to avoid the JIT getting a simpler world than production.</p><ol><li><p>Inputs have to be reshuffled rather than fixed or sorted, so the branch predictor doesn’t get too good.</p></li><li><p>Results have to be consumed, or dead-code elimination deletes the thing that you meant to measure.</p></li><li><p>Inputs must not be compile-time constants, or they get folded away.</p></li><li><p>Call sites have to see roughly the product mix of types, because a monomorphic call site inlines, whereas a megamorphic one doesn’t.</p></li></ol><p>A human then approves it into a hash-pinned registry. Until that happens, it's inert, because task setup refuses any task citing an unapproved benchmark. We think of that approval as the strongest gate in the system, and it's deliberately placed. An approved benchmark can decide accept or reject in every future task, so it's the one place where we ask for a human signature on an artifact rather than on a decision.</p><h3>The validation ladder</h3><p>Underneath all of this sits a hierarchy of feedback mechanisms with the property that each rung is cheaper and weaker than the one below it, and the cheap tiers are for rejection only.</p><p><strong>Tier</strong></p><p><strong>Cost</strong></p><p><strong>Role</strong></p><p>probe</p><p>~20–60 s</p><p>Directionally right? Can never accept</p><p>codegen capture</p><p>~4 min</p><p>Why did that happen?</p><p>screen</p><p>~5–15 min</p><p>Cheap statistical filter</p><p>confirm</p><p>~20–60 min</p><p>The accept decision</p><p>end-to-end</p><p>hours</p><p>Regression guard, advisory</p><p>The asymmetry between accept and reject is doing real work here. A probe is a single paired fork, whereas the accept predicate requires a full confirm run with a matching calibration record. Agents are very good at telling believable stories; indeed, they're trained on many tasks judged by both LLMs and by humans, so being convincing is actively rewarded. We don't want an agent to be able to promote a cheap signal into a decision by being persuasive about it.</p><p>For this task benchmark, wall-clock is a real cost consideration. A confirm run might take an hour. So one has to weigh carefully all the costs involved when choosing the setup. A model that lands one hypothesis in four typically beats a cheaper one landing one in 10 by a margin on end-to-end metrics. The usual instinct to down-spec the model on a long-running loop is exactly backward here. When your loop has an uncertain outcome and significant costs beyond the tokens it consumes, you may well find yourself in the same situation.</p><h2>How do you know a performance improvement is real?</h2><p><em>Is it faster?</em> is a statistical question. So we made the accept predicate code rather than judgment and put it somewhere the agent can't bypass. There are four ideas in the accept decision, and in each case, the alternative we rejected is as informative as the choice we made.</p><h3>Forks are the statistical unit</h3><p>Each <a href="https://github.com/openjdk/jmh">JMH</a> fork collapses to its mean, and verdicts come from an exact <a href="https://en.wikipedia.org/wiki/Mann%E2%80%93Whitney_U_test">two-sided Mann-Whitney U test</a> at α = 0.05 plus a seeded bootstrap confidence interval over three to five fork means per side. The reason is that iterations within a fork share JIT and heap state and are therefore autocorrelated, so treating them as independent samples manufactures significance out of nothing. We rejected comparing single-run scores, which is pure noise, and iteration-level <a href="https://en.wikipedia.org/wiki/Student%27s_t-test">t-tests</a>, which can be confidently wrong. Seeding the bootstrap means that a rerun reproduces the verdict exactly because the agent needs to be able to tell the difference between a result that changed and a result that was never stable.</p><h3>Pair candidate and baseline in time</h3><p>The screen (three forks, optionally over a subset of parameters) exists only to kill bad hypotheses in maybe 10 minutes instead of an hour. The accept decision itself comes from a confirm run that measures candidate and baseline back to back using a stash-flip. In an unstable environment, thermal and background drift only cancels if both sides ran under the same conditions, so an hours-old baseline is really a different experiment.</p><h3>The noise floor is measured, not assumed</h3><p>The minimum effect size that a task will accept has to clear the A/A-calibrated coefficient of variation for that specific benchmark on that specific machine, and both the confirm run and the comparison refuse to proceed without a matching calibration record. A 1–2% improvement on a laptop is indistinguishable from noise, and because the floor varies by benchmark and by JDK, any global constant you pick will be too loose somewhere and too tight somewhere else.</p><h3>The accept rule is composite and deliberately conservative</h3><p>A parameter combination counts as improved only if p &lt; α, the effect clears the calibrated floor, and the confidence interval excludes zero. Overall acceptance then requires that nothing regressed (not the primary benchmarks and not the guards) and that at least one primary combination improved. The headline figure is the <a href="https://en.wikipedia.org/wiki/Geometric_mean">geometric mean</a> of the per-combination speedup ratios, which is always positive and composes across experiments, so a task's cumulative improvement is a meaningful number rather than a sum of incomparable percentages.</p><h3>What must not get slower</h3><p>Guards deserve a note of their own, because they answer a different question to the primary benchmarks; not <em>Did this get faster?</em> but <em>What must not get slower while it does?</em> The task definition lists them separately for that reason, and they're typically the operations and the input regimes that the change isn't aimed at. If you're optimizing insert throughput on a hash table, iteration is a guard and so is a collision-heavy key distribution. A change that improves the common case by weakening the hash function will look good on uniformly distributed keys while catastrophically degrading more adversarial inputs. We know that because it happened; the collision distribution was missing from the matrix that accepted one of our early experiments, and the task now carries a comment telling future readers never to drop it again for a hash-quality-sensitive scope. While guards cost wall-clock on every confirm, they also surface edge-case regressions, and omitting them can be much more costly in the long run.</p><p>Runtime isn't the only thing worth guarding, either. The confirm run also captures normalized allocation rate on both sides and flags any change that buys speed with more than about 15% extra garbage. That one is advisory rather than blocking, because sometimes the trade is the right one. However, it's the kind of regression a purely time-based accept rule would happily wave through but might raise a red flag to an experienced performance engineer with better understanding of the calling context.</p><h3>The end-to-end gate is one-sided and default open</h3><p>The end-to-end gate is a different statistical problem: small n, high noise, and a very strong prior that we should accept based on our microbenchmark results. Our first design treated accept and reject on an equal footing, and it produced multiple clearly spurious rejections, so the redesign is one-sided and default open. An operation is flagged only when the median regression exceeds the threshold and every candidate repetition is slower than every base repetition. That full-separation criterion is the nonparametric one-sided test at this sample size, and it's robust to the single outlier repetition that would occasionally fool us otherwise. A flag then also has to be corroborated against a differential CPU flame graph, where only a rise in the task's own in-scope CPU share counts as real. Near misses get reported for transparency but don't trigger triage, and nothing is ever auto-rejected; a flag is a request for human attention rather than a verdict. We have a final backstop which is the large suite of performance tests that we already run against Elasticsearch on a daily basis.</p><p>The one-sided gate lesson generalizes beyond benchmarking. A noisy gate should be one-sided and default open where there is strong prior reason to accept. A symmetric threshold on a noisy signal doesn't just cost you real wins, it teaches the loop to distrust its own instruments, and that’s a much more expensive failure.</p><h2>Exploration and exploitation need different permissions</h2><p>Exploration and exploitation might look like two phases of one activity, but they have different inputs (a macro workload versus a pinned scope) and different outputs (ranked opportunities versus commits). They also have different failure modes, which means they want different permissions. We made the split a first-class property of a task, which lets us enforce it; an exploration task literally cannot commit. The baselining, comparison, checkpointing, and validation CLI all refuse exploration tasks, benchmarking allows probes only, and every probe diff is always reverted. A broad, speculative survey is safe because nothing it does can edit the code.</p><p>A nice ancillary benefit is prompt focus. Each type reads one playbook, in full, with the others explicitly not loaded. If you try to write a single document covering both "find where the headroom is" and "land a validated win inside this scope", you get something that does neither well, because the instructions for good exploration (follow the profile, widen the net, a broad survey is preferred) are close to the opposite of the instructions for good exploitation (one hypothesis at a time, minimal diff, never widen scope).</p><h2>AI agent memory: Journals, knowledge bases, and postmortems</h2><p>Sessions are ephemeral, but what you can learn from them isn't, so the harness accumulates three durable assets, plus one disposable view derived from them. These are a journal of the code changes we’ve tried, a knowledge base of how the code performs, postmortems of when the harness failed and, because sessions can be stopped and resumed, a session summary. What makes them work together is a clean ownership rule about which kind of fact goes where.</p><p>The journal records what we tried and measured. It's append-only, one file per task, and one record per experiment, and it's written before the code is edited. Rejections carry a forward-looking note in the form "do not retry X because Y", which is probably the highest value line, because it's what stops the next session re-deriving a dead end. Records also carry the environment and the driving model, which means that hypothesis hit rates are comparable across models.</p><p>The knowledge base records how the code works and how it performs. It's an indexed collection of per-area summaries, each stamped with the commit it was written against, and every playbook ends with an upkeep step that appends whatever durable facts the run turned up. Because the performance characteristics of the JDK also change from time to time, for example, a new <a href="https://download.java.net/java/early_access/loom/docs/api/jdk.incubator.vector/jdk/incubator/vector/Vector.html">Vector API</a> might implement vector masking more efficiently on AArch64, findings from profile data are also tagged with the JVM version they apply to.</p><p>The postmortems record mistakes that the agent has made in the past, and they're indexed by symptom rather than by date. The question a session actually has when a number looks wrong is <em>Have I seen this shape of wrongness before?</em>, and a chronological list doesn't answer it. So the rows read like "validation fails on operations structurally unrelated to your diff", or "screen reports no matching combinations".</p><p>Keeping the journal and the knowledge base distinct sounds pedantic but isn't because without the rule, both of them turn into a diary that has a tendency to bloat the context window or miss critical information in context.</p><p>The disposable state is a session handoff, and our advice is to never write it by hand and to avoid asking a model for a session summary, if possible. Our harness regenerates a one-page digest mechanically from the journal, so a resumed session doesn’t spend time and tokens reconstructing where the task had got to. Because it’s derived rather than authored, it can’t drift from the record in the way hand-maintained content does. That’s also why it isn’t part of the audit trail; because it’s cheap to regenerate it from the journal.</p><p>Mechanical session handoffs point to two lessons, and they turn out to be the same one. Every time a person appears in the loop, it’s a source of friction and an opportunity for error. And every time you reach for a model, ask whether code can do the same job. This seems like an odd thing to advocate in a project whose primary premise is delegating to a model, but the habit is easy to fall into once you have one to hand. Judgment is expensive, wherever it happens to sit, so the person and model both have to earn their place on merit.</p><p>Two further things are critical for durable agent memory. The first is that knowledge rots, so you have to lint it. Elasticsearch's main branch moves daily, which means the knowledge base's file citations go stale, so one linter checks them. Another checks that every command, flag, and path cited in the agent-facing docs actually exists, that every postmortem is linked from the index, and that every task type has a playbook, and it runs as part of the test suite. Treating prose written for an agent as a testable artifact is the reason it stays true. The second is that loading discipline is half of memory. The instruction is to load the index and then the one or two summaries matching the task's scope; never to bulk-load the rest. Memory you can't afford to read isn't memory.</p><h2>Coding agent guardrails: Containment, scope, and stop conditions</h2><p>Elasticsearch is millions of lines of code, and an unscoped "make it faster" run against a codebase that size is unreviewable and unfalsifiable. It’s also expensive. The converse is that a verifier only protects what it can see, so we have to apply the same boundary to what it can change. The outcome is a task that fixes its goal, allowed paths, benchmarks, thresholds, and stop conditions before any code is edited, and none of those are things the agent may change during a run.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2bae81c11c37e29b/6aa3e867d1556f62a67269fc/unnamed.png" alt="Coding agent guardrails: human owns scope and thresholds, agent owns judgement, atune CLI enforces mechanically" /><p><em>The human owns the safety envelope, defining scope, thresholds, and stop conditions before any code is edited, and owning every handoff that crosses a trust boundary. The agent session owns judgment: reading profiles, forming one hypothesis at a time, editing inside scope. The atune CLI owns mechanical enforcement: scope checks, correctness gates, statistics, calibration, stop conditions, environment checks, and the signals. Persisted state is the record: a worktree pinned at a base commit, an append-only journal, and generated reports.</em></p><p>Containment happens in two layers. The first layer is coarse and task-agnostic; it’s a single static permissions file lets the session edit the per-task worktree (which includes a local branch of Elasticsearch, all journal entries, and CLI artifacts) and the knowledge base, and it denies the pristine Elasticsearch clone, the task definitions, the harness config, and the audit trail. The second is precise and per-task: a git-level scope check that runs over tracked and untracked files before any build, so an out-of-scope edit is rejected before it can be benchmarked or committed.</p><p>Two containment layers have a nice corollary. Widening the first layer to the whole worktree costs nothing, because nothing out of scope survives the second one. Coarse containment plus precise scope beats trying to make a single mechanism do both jobs, which is what we tried first and which left us with a permissions file that needed editing for every new task.</p><p>Stop conditions are mechanical. There's a maximum number of experiments and of consecutive rejections, along with a cumulative improvement target, and proposing a new experiment is refused once one of them fires. A human can override; the agent can't. That asymmetry is what makes the gates independent of which model happens to be driving.</p><p>Tests are add-only. New test files ship with the checkpoint, and modifying or deleting an existing test is blocked by the scope check. This closes the single most tempting shortcut in the entire problem space by construction rather than by instruction, which seems like the right way to handle any shortcut you'd otherwise have to keep asking an agent not to take.</p><p>The harness lives outside the code it optimizes. The Elasticsearch clone is a separate, gitignored directory, and each task gets a worktree pinned at a base commit. The payoffs compound; the subject repo stays pristine and upstream mergeable with nothing related to the harness leaking into a PR, and the audit trail is versioned independently of a codebase that moves daily. Also, tasks are isolated from each other and from any developer checkout. Targeting a newer Elasticsearch means creating a new task rather than repointing an old one, because that task's numbers are tied to its base. It also means that the harness is retargetable in principle, since the Elasticsearch-specific parts are configuration, knowledge base, and benchmark registry rather than architecture.</p><h3>The decisions the agent never makes</h3><p>The rule we settled on is that the agent runs the loops and a human owns every step that crosses a trust boundary. That is creating work, blessing a measurement instrument, publishing a branch, or acting outside the repo. None of those are in the agent's allowlist, and each has a reason worth stating:</p><ul><li><p>Humans have to sign off on the task because the thing being constrained can't set its own limits.</p></li><li><p>Calibrating the noise floor needs to be done once per benchmark, per machine, and by default is measured rather than assumed. However, it's rather expensive and we allow a human to override if they know the environment well.</p></li><li><p>Deciding what to optimize next by promoting an opportunity is a judgment and a new scope.</p></li><li><p>Since benchmarks go on to gate other tasks, we consider reviewing this artifact part of the correctness safety net.</p></li><li><p>We leave outward-facing actions, such as pushing a branch or filing an issue, to a human until we're confident in the process.</p></li><li><p>We allow actions to be forced, but the override has to sit outside the thing being overridden.</p></li></ul><p>How the human actions get surfaced in the workflow matters. The generated report and the session handoff both print the human actions currently due, at the moment they become due, rather than leaving them to be inferred from the playbook.</p><p>We're deliberately not taking a position on how permanent the manual processes are. The right amount of supervision for a new technology is an empirical question, and we'd rather measure it than argue about it. We’ve started with a relatively high degree of supervision because that's the cheap direction in which to be wrong (a gate you never needed is easier to remove than a regression you shipped) and because the harness makes the question answerable. Every gate is a named, logged transition, so over time we can see which of them ever changed an outcome and which only ever cost friction. In summary, measure first, and then refine.</p><h2>Building the harness is the same kind of loop</h2><p>A lot of the harness design didn't fall out of an initial design document. The signal set, the ranking function, the shape of a task, and the exact wording of a playbook rule each came from watching a run go wrong. If there's one piece of advice here that generalizes, it's to use the thing before it's ready and to instrument your own disappointment.</p><p>The clearest example is a rule we now call <em>distrust surprising results</em>. A validation run reported that every operation had regressed, the worst of them by 14.8%. It was wrong twice over. A target operation pattern had overmatched a completely different code path, and a stale output directory from an earlier run was being read alongside the new one. Offered a coherent story, the agent took it and reverted a change that was actually good.</p><p>What went into the playbook after that incident is not "be careful." It's a three-step check to run before acting on a surprising verdict:</p><ol><li><p>Trace the code path, and confirm that the thing which moved can even reach your diff.</p></li><li><p>Read the raw per-repetition data rather than the summary, and recompute one headline number by hand.</p></li><li><p>Compare the report's shape against a known good run, because a structurally different report implicates the pipeline rather than the code.</p></li></ol><p>Alongside that, there’s another important rule, which is if the agent concludes that the harness is buggy, it must <em>not</em> fix it mid-run, because a mid-run harness change makes every result in that run incomparable. It should journal the evidence and stop.</p><p>Improving the harness is itself a loop worth describing. Asking the model to review its own transcripts and the harness documents, and to propose the rule itself, usually works well. It's good at spotting where its own instructions were ambiguous, in a way that's hard to reproduce by rereading the instructions yourself. What makes that output useful is having somewhere for it to land: a terse rule in the playbook, the narrative in a dated postmortem, a symptom keyed index row, and a linter that keeps the citations honest.</p><p>Restraint turns out to be part of the same discipline. The design document carries an explicit list of extension points that we've deliberately not built, because the need for them is still speculative. That's the same "don't guess, wait for evidence" rule we impose on the optimization loop, applied to ourselves.</p><h2>How this applies beyond performance optimization</h2><p>A few of these themes aren't specific to performance work or to Elasticsearch.</p><p>Verifiable work is the current frontier. The same insight drives <a href="https://arxiv.org/pdf/2411.15124">reinforcement learning with verifiable rewards</a>, and it’s what <a href="https://arxiv.org/pdf/2506.13131">AlphaEvolve</a> is built around. The tasks agents are consistently good at are the ones that come with a cheap oracle (tests, compilers, benchmarks), and the interesting move isn't finding more such domains but manufacturing oracles for domains that lack them. Performance is an instructive case precisely because the oracle is, in some senses, obvious, and yet building it still took most of the engineering.</p><p>The referee pattern generalizes, too. Separating a fallible optimizer from mechanical enforcement is the same shape as sandboxed execution and policy engines: judgment in the model, invariants in code. The practical consequence is that the system's safety doesn't depend on which model drives it. A weaker model wastes benchmark time, but it can't corrupt the code or accept a bogus win.</p><p><a href="https://en.wikipedia.org/wiki/Goodhart%27s_law">Goodhart</a> is a standing adversary for any optimization task that uses an agent, and it has a <a href="https://arxiv.org/pdf/2209.13085">formal treatment worth reading</a>. An agent optimizes <em>exactly</em> what you tell it to, so the two-tier benchmark structure, add-only tests, benchmark approval registry, adversarial guard distributions, and a marker file that makes timing an instrumented build mechanically impossible are all one design theme wearing different clothes.</p><p>Tools are context engineering. The <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents">consensus is drifting away from "expose everything"</a> and toward a few well-shaped <a href="https://www.anthropic.com/engineering/writing-tools-for-agents">tools that return digests</a>: the principles of progressive disclosure, self-documenting interfaces, and verdicts rather than payloads.</p><p>Memory is becoming architecture. A rules file, per-task playbooks, a durable knowledge base, and an append-only journal form a hierarchy with different lifetimes, owners, and loading rules, and the hard part is eviction and staleness rather than storage; hence, the linters.</p><p>Finally, human-in-the-loop is a dial rather than a switch, so where it should sit is something to measure per domain rather than assert.</p><h2>What's in part 2 of this post</h2><p>The harness design is a set of hypotheses about what autonomous performance work needs, and the harness was built so that we could test them. Part 2 is that test: the first four PRs we raised using it, their gains, how many hypotheses it took to get each one, which gates actually caught something, and where the harness got in its own way. That includes the changes which didn't survive end-to-end validation, since as usual, the rejections are as informative.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-code-optimization-elasticsearch-agent-harness</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-code-optimization-elasticsearch-agent-harness</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Thomas Veasey,Chris Hegarty]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9efb1bf91e4491b/6aa3e56cd909f868f36d69a6/unnamed.png" length="0" type="image/png"/>
    <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch Vector Database: Ship in minutes, scale affordably to hundreds of billions]]></title>
    <description><![CDATA[The hard parts of hybrid retrieval, already done, with optimized defaults, third party and native Jina AI models, and managed GPU inference all out of the box. Build fast, scalable AI apps, not infrastructure.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch is one of the most widely deployed platforms for vector workloads in the world, powering semantic search, retrieval augmented generation (RAG), and recommendations for companies like GitHub, Docusign, Seismic, and many others. Today we're announcing Elasticsearch Vector Database, a new serverless offering optimized for vector based applications. You bring your documents and your queries, and we handle the embeddings and index tuning, along with the infrastructure. Plus, we keep it cheap and scalable. </p><p>For new users, this is the fastest way to get high-quality vector search running. If you already use Elasticsearch, the new offering is vector search on the platform where your data already lives, with no new system to adopt. Elasticsearch Vector Database supports a range of scenarios, from grounding a large language model (LLM), to giving an AI agent retrieval and memory, to serving hundreds of billions of vectors. <a href="https://cloud.elastic.co/registration?onboarding_token=vector">Spin up a new project</a> and get started in minutes.</p><h2>One engine, every vector use case</h2><p>Elasticsearch Vector Database is built for anyone building applications using vectors:</p><ul><li><p><strong>RAG:</strong> Retrieve the right context for your LLM with dense and sparse vector retrieval, or go with hybrid search combining both vector and lexical retrieval. The quality of your generation improves with the quality of your retrieval.</p></li><li><p><strong>AI agents:</strong> Give agents fast, filtered retrieval over documents and conversation memory, with the low latencies that multistep agent loops demand.</p></li><li><p><strong>Semantic search:</strong> Match on meaning, not keywords, with one field type and zero pipeline code.</p></li><li><p><strong>Recommendations and similarity:</strong> Find nearest neighbors across products, images, or whatever content you have, at scale.</p></li></ul><h2>Everything your vector workload needs, optimized out of the box</h2><p>Building a vector-based application means wiring together several separate pieces: setting up and hosting embedding models, indexing your documents through them, storing the vectors efficiently, applying the embedding model to each query, matching against the vector store, and finally, retrieving the documents behind the matches. Elasticsearch Vector Database handles all of it for you, with no additional configuration or setup.</p><h3>Vector indexing with vectordb_document index mode</h3><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-vectordb-document-mode"><code>vectordb_document</code></a> index mode, a new index configuration purpose-built for vector-first workloads, is on by default, so you get the settings that experts would choose. Here's what it turns on:</p><ul><li><p><strong>bfloat16 by default:</strong> Vectors are stored at half the size of float32 with negligible impact on recall, cutting your disk footprint roughly in half before quantization even enters the picture.</p></li><li><p><strong>Source vectors excluded:</strong> In Elasticsearch, your embeddings already live in the index structures used for search; keeping a second raw copy in <code>_source</code> just inflates storage and slows down fetching results. We exclude the duplicate so responses return faster and you store less.</p></li><li><p><strong>The right files preloaded into cache:</strong> The data structures that vector queries touch first are warmed into memory ahead of time, so your first (and your thousandth) query is lightning fast.</p></li><li><p><strong>Parallel merging:</strong> Merging consolidates segments into better-organized vector structures, which lifts both recall and latency, and running those merges multi-threaded means you get there faster.</p></li></ul><h3>Vector storage, compression, and auto-tuning</h3><ul><li><p>Your vectors are compressed automatically.<a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch"> Better Binary Quantization (BBQ)</a> shrinks vector memory footprints by up to 32x while preserving recall, and DiskBBQ reduces memory requirements further for large-scale workloads.<a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-diskbbq"> </a></p></li><li><p>Opt in to<a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-diskbbq"> auto-calibration</a>, which tunes each segment's quantization to your data and retunes on every merge as data drifts. When tested across 18 datasets, queries per second (QPS) improved by an average of 16.7%, with recall gains in most of them.</p></li></ul><h3>Embeddings on managed GPU inference</h3><ul><li><p>Generate embeddings with native <a href="https://www.elastic.co/jina-search-models">Jina AI embedding and reranking models</a>, or bring third-party models, all on managed GPUs via <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a> with no model servers to operate. Or self-host, if you prefer your own.</p></li><li><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><strong><code>semantic_text</code></strong></a> field type automatically handles chunking and embedding, along with querying, the simplest path to semantic search in the market. </p></li></ul><h3>Hybrid search and filtered vector search</h3><ul><li><p><a href="https://www.elastic.co/elasticsearch/hybrid-search">Hybrid search</a> is built in, combining full-text and vector retrieval in a single query. Blend the results with reciprocal rank fusion (RRF) or any other blending mechanism you want. Vector search is usually the hardest part of hybrid search to configure well. With Elasticsearch Vector Database, you have it handled, and your whole hybrid stack gets better. </p></li><li><p>With <a href="https://www.elastic.co/search-labs/blog/filtered-hnsw-knn-search">filtered vector search</a>, apply metadata filters as part of vector retrieval itself and not as an afterthought that wrecks recall.</p></li></ul><h3>Enterprise on day one</h3><p>You also get role-based access control (RBAC), audit logging, and the compliance certifications that pure-play vector databases generally lack.</p><h2>Affordable at scale and predictable</h2><p>Elasticsearch Vector Database is built to stay affordable as you grow: BBQ and DiskBBQ compression that keeps storage linear and memory low means scaling to hundreds of billions of vectors doesn't blow up your bill. And <a href="https://cloud.elastic.co/pricing/serverless?s=vectordb">what you do pay</a> is built from numbers you already know: how much data you store and how much you index, along with how much search capacity you need. Estimate your document count and vector dimensions, plus your query load, and you can work out what you'll pay before you create the project. You can also understand your bill line by line at the end of the month. There are no opaque compute units and no surprise charges for background operations.</p><h2>How to get started with Elasticsearch Vector Database</h2><h3>Create a serverless vector database project</h3><p>Create a new <a href="https://cloud.elastic.co/registration?onboarding_token=vector">serverless Vector Database project in Elastic Cloud</a>. Point your data at the endpoint, and you're ready to index.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt556cbdfba551f248/6aa10b4332b53038406d321a/image1.png" alt="Elastic Cloud Serverless project types: Elasticsearch, Vector Database, Observability and Security" /><h3>Create an index using semantic_text</h3><p>Vector index mode handles the vector configuration. Using <code>semantic_text</code> means that embeddings and chunking setup are managed for you, as is index setup, on managed GPU inference, with no embedding pipeline to build.</p>PUT my-vectors
{
"mappings": {
"properties": {
"description": { "type": "semantic_text" }
    }
  }
}<h3>Ingest documents</h3><p>Index text, and the embeddings are generated for you.</p>POST /my-vectors/_doc
{
  "id": "park_rocky-mountain",
  "title": "Rocky Mountain",
  "description": "Bisected north to south by the Continental Divide, this portion of the Rockies has ecosystems varying from over 150 riparian lakes to montane and subalpine forests to treeless alpine tundra."
}<h3>Run a semantic search query</h3><p>Query the same semantic field you just created:</p>GET /my-vectors/_search
{
  "query": {
    "semantic": {
      "field": "description",
      "query": "a mountain range in the middle of north america"
    }
  }
}<p>And you get results back:</p>{
  "took": 80,
  "hits": {
    "max_score": 0.7792325,
    "hits": [
      {
        "_index": "my-vectors",
        "_score": 0.7792325,
        "_source": {
          "id": "park_rocky-mountain",
          "title": "Rocky Mountain",
          "description": "Bisected north to south by the Continental Divide, ..."
        }
      }
    ]
  }
}<p>Semantic search is just the start. Run fully textual queries or combine both into hybrid queries. You can even craft your own vector queries for full control. Follow the <a href="https://www.elastic.co/docs/solutions/vector-database/vector-full-text-search">semantic search quickstart</a> in the docs for the full instructions.</p><h2>What's next for vector search in Elasticsearch</h2><p>We're already working on the next improvements:</p><ul><li><p><strong>Better multi-tenant handling:</strong> If your data needs to stay separated per tenant, we'll give you a way to do it faster and with less code.</p></li><li><p><strong>Automatic index optimization:</strong> From "brand new index" to "fully optimized," with as little tinkering as possible.</p></li><li><p><strong>Continuous infrastructure improvements:</strong> Ongoing tuning of Vector Database's settings and infrastructure so you're always getting the best throughput and fastest responses.</p></li></ul><h2>Try Elasticsearch Vector Database on Elastic Cloud Serverless</h2><p>Go from an empty project to a hybrid, filtered vector query in minutes, with production-grade defaults doing the tuning for you. Build fast, scalable AI apps, not infrastructure.</p><p>Start on <a href="https://cloud.elastic.co/registration?onboarding_token=vector">Elastic Cloud Serverless</a>, or dive into the <a href="https://www.elastic.co/docs/solutions/vector-database">full documentation </a>and <a href="https://www.elastic.co/docs/api/doc/elastic-cloud-serverless/group/endpoint-vectordb-projects">API reference.</a> You can also access the new offering on<a href="https://aws.amazon.com/marketplace/pp/prodview-voru33wi6xs7k"> AWS Marketplace</a>,<a href="https://console.cloud.google.com/marketplace/product/elastic-prod/elastic-cloud"> Google Cloud Marketplace</a> and<a href="https://portal.azure.com/#view/Microsoft_Azure_Marketplace/GalleryItemDetailsBladeNopdl/id/elastic.ec-azure-vector/"> Microsoft Marketplace</a>.</p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-database-rag-serverless</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-database-rag-serverless</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Dustin Coates]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4def84aae6aff861/6aa10ab1ee57e53d9b05253c/cover.png" length="0" type="image/png"/>
    <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One field, one copy: How Elasticsearch columnar storage drops the inverted index]]></title>
    <description><![CDATA[Storing each field once means no inverted index, so doc values now read in bulk and skippers let queries skip whole ranges of documents, while new mapping attributes control what each field is allowed to contain.]]></description>
    <content:encoded><![CDATA[<p>As part of the 9.5.0 release, Elasticsearch introduced <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">columnar and logsdb_columnar index modes</a> in technical preview. Elasticsearch has had columnar storage using Lucene’s doc values since version 1.0.0. Lucene’s doc values power analytics and search functionalities, like group by and sorting by a field. So, what changes with the columnar index modes? </p><p>The changes are about storage and performance, along with the out-of-the-box (OOTB) experience. Up until 9.5.0, Elasticsearch operated as a document-based search engine by default. It could be set up to behave like a columnar system storage-wise, but that wasn’t the OOTB experience. <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">Columnar index modes</a> make a number of fundamental changes that allow Elasticsearch to optimize columnar analytic and search use cases:</p><ul><li><p>Fields are stored once as doc values only and are no longer indexed by default.</p></li><li><p>New multi-value semantics. The original ordering of multiple values per field per document (for example, in arrays) is preserved by default.</p></li><li><p>Mappings are always flat, and object and passthrough fields in mappings are always auto-flattened.</p></li></ul><h2>How columnar index modes fit into Elasticsearch</h2><p>Many of the columnar index mode changes originate from <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time series data streams (TSDSs)</a>. As part of making TSDB a competitive metrics solution, we improved doc values format on disk and only store dimensions and metric fields once as doc values. We also improved query performance. TSDB is already columnar today. Essentially, this makes TSDB’s storage mode columnar. The lessons learned from TSDB are now being applied more broadly to Elasticsearch. </p><p>Note that columnar index modes are opt-in and columnar, and document-based indices can coexist in the same cluster. An enterprise search use case can use a document-oriented index mode, while a logging use case can use logsdb_columnar index mode and be fully columnar, all in the same cluster. In fact, there are currently seven index modes, and indices can all use them in the same cluster.</p><h2>How columnar storage stays fast without an inverted index</h2><p>Indexed fields, either an inverted index for string-based fields or block k-dimensional (BKD) tree for numeric fields, allow Elasticsearch to query or filter by field very efficiently. However, the cost for this is an additional expensive data structure that uses a lot of disk space and is expensive to build at index time and at merge time. With the columnar index modes, fields are no longer indexed by default, so what did we do for query performance to be still acceptable on fields that were no longer indexed?</p><p>One major change was improving doc values scanning performance. This is key and is the cornerstone that any columnar system relies on. Previously, the scanning of doc values was essentially document by document. Lucene’s doc values API only allowed for looking up one value at a time. Historically, this fit the execution model of a search engine. In our own doc value format, we build the capability to allow bulk reading of values for Elasticsearch Query Language (ES|QL) queries. Also over recent minor Lucene releases, Lucene doc values API added support for bulk reading. Without this, fast columnar scanning wouldn’t have been possible.</p><p>Secondly, we fully adopted <a href="https://www.elastic.co/search-labs/blog/docvaluesskippers-lucene-range-queries">doc value skippers</a>, a hierarchical skiplist over doc values. Contrary to an inverted index or a BKD tree, doc values skippers are lightweight data structures. At its core, a <em>skipper</em> allows queries to skip over a range of documents that don’t match a query. It can do this because it stores information, like min and max values. So, for example, when a range query is executed, an interval of documents can be skipped based on the intermediate result and a doc value skipper’s min and max values. The effectiveness of doc value skippers depends on the order in which documents are laid out on disk. This is why <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar#index-sorting">index sorting</a> should be enabled or altered to match the use case.</p><p>By significantly improving our columnar scanning and doubling down on doc values skippers, we’re able to avoid indexing fields by default. Note that a field can still be indexed; <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-index">the <code>index</code> mapping attribute</a> just defaults to <code>false</code> in columnar mode. The exceptions to this rule are text-based fields, which are still indexed by default. This is because text fields provide free text search, which includes text analysis, along with phrase and wildcard matching. This is different from just filtering.</p><h2>How Elasticsearch handles high and low cardinality fields</h2><p>When setting up a schema with string fields, an important configuration parameter is often <em>cardinality</em>; that is, whether many unique values or a few unique string values are expected. Many systems have dedicated field or column types that target low and high cardinality string fields.</p><p>Fields that have low cardinality are typically stored with a dictionary, containing all unique values. Then, for each row offset, the offset into the dictionary containing the term the row has is stored. This is often called an <em>ordinal</em>. For low cardinality fields, this works well, as storing an ordinal per document takes up much less space. Encoding techniques, like delta encoding, offset encoding, and bitpacking, work well for ordinals to compact the per-document storage to just a few bits. </p><p>However, for high cardinality fields, the dictionary and ordinal approach can work counterintuitively. If a larger percentage of the documents have a unique value, building the dictionary becomes expensive and storage savings diminish. The dictionary then becomes another level of indirection for reading values. This is why most systems in that case store values in a columnar fashion using block-based compression. For example, values of multiple rows are stored in 128KB blocks using a sliding-window dictionary-based compression algorithm (like zstandard or lz4). This, in general, is a simple and effective method to store higher cardinality fields and avoids building and maintaining a dictionary. </p><p>With document-based Elasticsearch, there are two ways to map a string: using either the keyword field mapping or one of the text-based field mappings. The former stores an inverted index and dictionary-based doc values. The latter only stores an inverted index. This is why, typically, a text field mapper is often used in combination with a keyword mapper as a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/multi-fields">multi-field</a>. Also, keyword field mapper (as the name suggests) is meant for keywords or fields that have a lower cardinality and uses dictionary-based doc values implementation. However, in practice, keyword field mappers are also used for high cardinality.</p><p>In columnar mode, every field is only stored once by default. For keyword fields, this means only doc values are stored with no inverted index. Text-based fields now also store doc values and an inverted index by default. Text-based field mappers are different from the keyword field mapper, as these are not automatically used via Elasticsearch’s dynamic mapping logic for columnar indices, and therefore text fields keep storing an inverted index by default.</p><p>For both keyword- and text-based fields, we didn’t choose to expose a cardinality mapping attribute. It’s not always possible to know ahead of time whether a field is low or high cardinality. When flushing and merging segments to disk, Elasticsearch sees all values and can determine the cardinality of a field. This is why we’re choosing to automatically determine whether the usage of a dictionary and ordinal-based encoding is beneficial over block-based compression using a simple cardinality threshold. If a field is below this threshold, dictionary and ordinal-based encoding is used; otherwise block-based compression is used. This simplifies configuration of the mappings and makes it possible to automatically optimize storage as data evolves, since some segments may use dictionaries while others may use blocks for the same field. However, this is currently not ready yet and so, as part of 9.5.0, in columnar mode, both keyword- and text-based field mappers store values in doc values in a block-based compressed layout on disk.</p><p>The two approaches compare as follows:</p><p>
</p><p><strong>Dictionary and ordinal encoding</strong></p><p><strong>Block-based compression</strong></p><p>Suits</p><p>Low cardinality fields</p><p>High cardinality fields</p><p>What’s stored</p><p>A dictionary of unique values, plus one ordinal per document</p><p>Values for many documents compressed together in blocks</p><p>Compression</p><p>Delta encoding, offset encoding, and bitpacking reduce each ordinal to a few bits</p><p>Sliding-window dictionary compression, such as zstandard or lz4, typically over 128KB blocks</p><p>Read path</p><p>Resolve the ordinal, and then look up the value in the dictionary</p><p>Decompress the block, and then read the value directly</p><p>Cost as cardinality rises</p><p>Dictionary grows large, savings shrink, and the extra indirection stays</p><p>Stable, with no dictionary to build or maintain</p><p>Used in columnar mode tech preview</p><p>Not yet</p><p>Yes, for both keyword and text fields</p><h2>Columnar mapping attributes: multi_value, nullability, on_failure</h2><p>The columnar index modes provide more control over how data is stored as doc values. By default, Elasticsearch is lenient and accepts all non-malformed values (for example, nulls and multiple values per field and document). If documents have fields with multiple values per document or no value, doc values store additional data structures to deal with them and therefore implicitly increase costs. </p><p>With columnar, new mapping attributes provide additional control. Note that these new mapping attributes are currently only available with the columnar index modes but will eventually also be available for all index modes.</p><p>Three new mapping attributes are involved:</p><p><strong>Attribute</strong></p><p><strong>Default</strong></p><p><strong>Enforces</strong></p><p><strong>On violation</strong></p><p><strong>Available</strong></p><p><code>multi_value</code></p><p><code>true</code></p><p>One value per document per field</p><p>Document indexing fails</p><p>9.5.0</p><p><code>nullability</code></p><p><code>true</code></p><p>Field must have a value</p><p>Document indexing fails</p><p>9.5.0</p><p><code>on_failure</code></p><p><code>fail</code></p><p>How the above failures are handled</p><p>Sets <code>fail</code> or <code>ignore</code> behavior</p><p>Next minor release</p><h3>multi_value: Enforcing single-valued fields</h3><p>By default, Elasticsearch accepts multiple values per document. To understand the implications of this, we first should take a look at how Elasticsearch (using Lucene’s doc values) stores a dense numeric field where all documents have a single value: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3abb1f6fb702b165/6a9fa698893681d9ef3df7d6/unnamed.png" alt="Columnar storage doc values layout: value blocks and a block index resolve which value belongs to a docId" /><p>With this layout, all values are stored in blocks. The number of values per block depends on the index mode but is typically 128 values and is always the same within an index. All values in a block are encoded using various encoding techniques, like delta encoding and bit packing, so each block can have a different size, depending on how well the encoding techniques compress the values. This is why a block index is required. </p><p>Lucene has the notion of a docid (internal numbering for a document), which is essentially a row identifier.</p><ol><li><p>Queries produce matching docids.</p></li><li><p>In case of a dense field, the block id can be resolved from the docid directly.</p></li><li><p>The offset of a block can be resolved from the block index.</p></li><li><p>Once that has been looked up, the target block gets decoded and all values are available.</p></li><li><p>Finally, from docid, the ordinal within the decoded values array can be resolved, which produces the final value.</p></li></ol><p>Now let’s have a look at how the data layout changes when documents have multiple values per document:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9d5ca280a19c7a70/6a9fa6edee57e5dec9051db9/unnamed.png" alt="Multi-value doc values layout in columnar storage: offsets map a docId to values spanning value blocks" /><p>To determine how many values belong to a single docid, an offset lookup is required.</p><p>In this case, a docid has one or more offsets. Each offset points to a block index. Values for a single document are adjacent but can stretch over blocks. In general, compaction of values works well in blocks because values are similar. However, multi-value fields can cause the compaction of values to be less efficient if the number of values per field and document is large and values aren’t similar. This and the additional storage of offsets result in multi-value fields typically having a higher storage footprint on disk.</p><p></p><p>If a field is truly single-valued, you may want to enforce this property. <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/doc-values#doc-values-multi-value">The <code>multi_value</code> mapping attribute</a> makes that possible now. An example is a log level field. Logs typically have one log level (such as debug, info, or error). Enforcing that this field is single-valued in your mappings can help avoid accidentally using more storage than anticipated. A mapping snippet example that disallows the field <code>log.level</code> to have multiple values per document:</p>{
	"properties": {
		"log.level": {
			"type": "keyword",
			"multi_value": false
		}
	}
}<p></p><p>Note that even if a field allows multiple values, this doesn’t mean an offset lookup is stored. This only happens when a Lucene segment has at least one document with two or more values. The <code>multi_value</code> mapping attribute exists just for enforcement.</p><h3>nullability: Requiring every document to have a value</h3><p>By default, Elasticsearch accepts documents with fields that have no value or null value. Just as  multiple values per document require additional accounting, documents with no value require additional accounting to identify which of them have at least one value.</p><p>Doc values store a docid to offset lookup (known as IndexedDISI in Lucene) in case not all documents have a value in a segment. The offset either points directly to the block index for single-valued fields or the offset lookup in case of multi-valued fields. This lookup is compact compared to the value blocks being stored. However, if it were to be created for fields that should have at least one value per document, that would be a waste.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/doc-values#doc-values-nullability">The <code>nullability</code> mapping attribute</a> allows you to control whether documents are allowed to have no value. Just like the <code>multi_value</code> mapping attribute, the <code>nullability</code> attribute exists for enforcement.  Following is a mapping snippet example that requires the <code>log.level</code> field to have a value:</p>{
  "properties": {
     "log.level": {
        "type": "keyword",
        "nullability": false
     }
  }
}<h3>on_failure: What happens when validation fails</h3><p>What happens if a document has multiple values for a field and if the <code>multi_value</code> mapping attribute is set to <code>false</code> or when a field is mapped with nullability set to <code>false</code> and a document doesn’t have that field? At the moment, indexing such documents will fail with a bad request error.</p><p>As part of the next minor release, <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/doc-values#doc-values-on-failure">the <code>on_failure</code> mapping attribute</a> will be available. This allows you to indicate how to handle these validation failures, on a per-mapped field basis. This will support two values:</p><ol><li><p>Fail: Fail indexing of the entire document with a client error. This is the current behavior in Elasticsearch 9.5.0.</p></li><li><p>Ignore: Ignore the validation error, mark the field as ignored, and store values for that field in a hidden field so that it can be introspected when requesting the source. </p></li></ol><h2>Trying the columnar index modes</h2><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">columnar index modes</a> are still under active development, but we encourage you to give them a test drive. As we prepare the columnar index modes for general availability (GA), we’ll add more performance and efficiency improvements. We believe that by adapting a columnar mindset, many use cases will benefit from being more cost effective or having better performance characteristics.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/columnar-storage-elasticsearch-index-modes</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/columnar-storage-elasticsearch-index-modes</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[Index Data]]></category>
    <category><![CDATA[Mappings]]></category>
    <dc:creator><![CDATA[Martijn van Groningen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltef1303bd86788300/6a9fa5cdf08ee10715855390/unnamed.png" length="0" type="image/png"/>
    <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Query rewrite rules in Elasticsearch: 2.3x faster wildcard scans]]></title>
    <description><![CDATA[A second rule makes empty-string filters 1.6x faster. It reads string lengths straight from the offset array and never touches the compressed bytes. Both rules came from the same habit of running real queries and hunting for the special case.]]></description>
    <content:encoded><![CDATA[<p>Lucene query rewrite rules make two string scan queries in Elasticsearch's <a href="https://www.elastic.co/docs/reference/elasticsearch/columnar">columnar mode</a> 2.3x and 1.6x faster. Both rules spot a query shape at runtime and swap in a cheaper implementation. For a wildcard query like <code>*google*</code>, that's a substring search in place of the automaton. A filter like <code>SearchPhrase != ''</code> can skip Zstd decompression, because it only needs string lengths that are sitting in an offset array.</p><p>Columnar mode is Elasticsearch's analytics-optimized <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage">columnar storage</a> mode, built for scan-heavy workloads, like log analytics. In this mode, keyword fields don't get an inverted index by default, so term and wildcard queries scan doc values. <a href="https://www.elastic.co/search-labs/blog/docvaluesskippers-lucene-range-queries">DocValuesSkippers</a> (zone maps) already trim how much data a scan touches, but these rewrites cut the cost of what's left. </p><h2>How Lucene's query rewrite mechanism works</h2><p>In Lucene, every query has the option to implement a <code>rewrite</code> method that returns another query. This method returns a query with the same semantics but a different implementation. The query engine repeatedly calls the <code>rewrite</code> method until the returned query doesn’t change. This final query is the one that’s actually evaluated. Importantly, the <code>rewrite</code> can see the actual query arguments and specialize the implementation based on these.</p><p>For example, in a query looking for documents where a string field contains the value "foo", the <code>rewrite</code> method knows that the term we’re searching for is "foo". In theory, <code>rewrite</code> could replace the general query class with something specific to "foo". For example, the original query class <code>ScanningBinaryDocValuesTermQuery</code> could be replaced with <code>FooQuery</code>. Now this rule probably wouldn't be helpful, but it gives a sense for the level of specialization that’s achievable with rewrite rules.</p><h3>Rewrite rules and query optimization in database systems</h3><p>It's worth placing rewrite rules in the larger context of database systems. Lucene and Elasticsearch aren’t the first systems to use transformation rules to optimize queries. Most (or maybe all) database systems use some kind of rule system during query optimization. The most influential rewrite rule system was in IBM's <a href="https://dl.acm.org/doi/10.1145/141484.130294">Starburst</a> database. This system's core contribution was extensibility; for example, it was possible to add new data types and storage methods, along with (most importantly to us) optimizer rewrite rules.</p><p>Each rule consisted of two parts:</p><ol><li><p><strong>A condition function:</strong> A predicate determining whether the rule applies to the current query graph.</p></li><li><p><strong>An action function:</strong> The transformation that rewrites the query plan into a more optimal form.</p></li></ol><p>A rule engine applied matching rules until a stopping condition was met.</p><p>Though Lucene's <code>rewrite</code> method is superficially different from these condition and action functions, it achieves the same goal. It checks whether certain conditions match, and if they do, it applies the rewrite by returning a new query. If conditions don’t match, the <code>rewrite</code> returns <code>this</code>, replacing the query with itself; that is, choosing not to apply the rule.</p><h3>Why these rules live in Lucene, not the ES|QL query optimizer</h3><p>Elasticsearch actually contains a separate rewrite rule system within the <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> optimizer. This operates on the high-level structure of a query; for example, doing predicate pushdown to avoid unnecessary computation on documents that will be filtered out. But it’s still useful to have the rule system within Lucene. Since Lucene acts as the storage layer for ES|QL (and classic <code>_search</code>) queries, it’s easier to express rewrites that take advantage of the physical data format in Lucene rather than in a higher-level optimizer.</p><h2>A query rewrite rule for wildcard queries: Simpler code, no automaton</h2><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-wildcard-query">Wildcard queries</a> support the <code>?</code> and <code>*</code> operators to match any character once or any character multiple times. These operators can appear any number of times in a wildcard query. As with regexes, to evaluate whether a string matches a wildcard query, we build an automaton from the query string and then use the string bytes to do state transitions through the automaton. This is relatively fast, but if you have to evaluate it for every document, the latency really adds up.</p><p>But maybe we don't always have to run an automaton. Consider a query like <code>*foo*</code>. How would you implement this if you were writing a simple query engine to find matching strings in a list of strings? Pretty much every programming language has the tool you want built in: a method that finds a substring within a given string. This function doesn't need a complicated automaton; it probably just consists of a couple of <code>for</code> loops.</p><p>Now of course we couldn't use this function to implement an arbitrary wildcard query, but we don't have to. The rule rewrite system isn't for the general form. It's for implementing special cases, and it can see the specific query. It knows that we’re looking for <code>*foo*</code> and realizes that this specific case doesn't require the heavyweight automaton machinery. And it can do the same for any query that starts and ends with a <code>*</code>, with some term in the middle.</p><p>The following pseudo-code shows the pattern. At the top, we have the generic <code>WildcardQuery</code>. It has two notable fields: the query string (for example, <code>*foo*</code>) and the automaton built for that query. The <code>matches</code> method checks whether the field value for a given <code>docId</code> is a match by using it to evaluate the state transitions of the automaton. More interestingly, its rewrite method checks whether the query matches our special case. We show this with a regex that checks whether the query string starts with a <code>*</code>, has any non-<code>*</code>characters at least once, and then ends in a <code>*</code>. If so, we return the special case as a <code>ContainsQuery</code> and pass in the inner query string (since it doesn't care about the <code>*</code>s). The <code>ContainsQuery</code> then just does a simple <code>contains</code> check to see whether the term bytes are somewhere within the value bytes.</p>class WildcardQuery(query, automaton, docValues):

    boolean matches(docId):
        value = docValues.loadValue(docId)
        return automaton.matches(value)

    Query rewrite():
        if query matches r"^\*[^*]+\*$":
            return ContainsQuery(query[1:-1], docValues)
        return self


class ContainsQuery(term, docValues):

    boolean matches(docId):
        value = docValues.loadValue(docId)
        return value.contains(term)<h3>Benchmarking the wildcard rewrite on ClickBench Q20</h3><p>The wildcard rewrite is straightforward, but does it actually work? Yes, we can use the <a href="https://benchmark.clickhouse.com/">ClickBench</a> benchmark, which has several queries of this form. Query 20 (Q20) is <code>FROM hits | WHERE URL LIKE "*google*" | STATS count = COUNT(*)</code>. It's exactly the query shape that this rule matches: a string match against the wildcard query <code>*google*</code>. And since the query is just counting, we can see exactly how well this technique works. It turns out to be quite effective. Q20 saw a 1.75x improvement on median latency of hot query times, with no filter cache. All benchmarks in this post were run on an Intel Core i9-13900H.</p><h3>Adding SIMD to the substring search: 1.75x to 2.3x</h3><p>But can we do better? Yes, switching to a simple contains check opens up a new possibility. Instead of using the two for loops, we can swap scalar logic for single instruction, multiple data (SIMD) logic. Elasticsearch uses the <a href="https://openjdk.org/jeps/438">Panama vector API</a> (see our <a href="https://www.elastic.co/blog/accelerating-vector-search-simd-instructions">post on SIMD in Elasticsearch</a>), which lets us implement the contains check in SIMD. This works particularly well for longer strings that can take advantage of the wide SIMD registers; for strings under 24 characters, we still use the scalar approach. With this change, we saw another 1.32x improvement, resulting in a total speedup of 2.3x over the automaton-based approach.</p><h2>A query rewrite rule for empty strings: Less data, no decompression</h2><p>One benefit of Lucene-based rules is that they’re low level and can fit to the data format. That’s the case for this rule, which applies to string data. </p><h3>How columnar storage encodes string data</h3><p>In Elasticsearch's standard mode, string values are stored by document; this is a row-major format. But in columnar mode, unsurprisingly, the data is stored in columnar-major format. A column of string data is stored in chunks. Each chunk contains many string values and consists of an array of integer offsets and a (Zstd-compressed) blob of the strings' bytes. For a string at index <code>i</code>, <code>offsets[i]</code> points to the offset in the decompressed byte blob where the string starts. So the length of string <code>i</code> can be computed from <code>offset[i+1]-offsets[i]</code>. (There's a dummy extra offset at the end, so we can easily compute the length of the last string). The following diagram shows how a chunk with the strings ‘Feta’, ‘Asiago’, ‘’, ‘Stilton’, and ‘Brie’ is encoded.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt046cea94361c6305/6a9ee0c3508cca28e2a15ff9/image1.png" alt="Columnar storage chunk with an offsets array and byte blob, showing an empty string as two equal offsets" /><h3>Why a term query has to decompress the chunk</h3><p>Now that we understand the columnar format, let's get back to query optimization. First, consider a term query for the query <code>foo</code>. We’re looking for documents where a given string field exactly matches the string <code>foo</code>. So how do we implement this on a string column in the above format? The algorithm is straightforward:</p>docId = 0
for chunk in chunks:
    bytes = zstd_decompress(chunk.bytes)
    for i in range(len(chunk.offsets) - 1):
        value = bytes[chunk.offsets[i] : chunk.offsets[i+1]]
        if value == term:
            yield docId
        docId++<p>The bottleneck is the Zstd decompression step. But there's not much we can do about that; if we want to check the bytes, we have to decompress the chunks. But remember, we aren't trying to optimize the general case, we’re looking for special cases. (In reality, you don't just try to think up special cases. These optimizations came about by first running a useful query, realizing that it could be faster, and then looking for ways to improve it.)</p><h3>Rewriting the empty string query as a length check</h3><p>One special case we found that’s worth improving is a query for the term <code>""</code>. Admittedly, it's a silly term, but empty strings are all over the place. Since they're rarely useful, we usually filter them out with a query like <code>term != ""</code>. Thankfully, this is a query we can optimize.</p><p>Consider the above algorithm for the empty string term. The line <code>if value == term</code> is a bit weird; we’re asking <em>Does this value equal the empty string?</em> We can do that, but there are no bytes to compare, so the check unwinds:</p><ol><li><p>We only need to know whether the value has length 0.</p></li><li><p>If we only need the length, we don't need to look up the value in the decompressed chunk.</p></li><li><p>If we never look up a value, we don't need any bytes from the chunk at all.</p></li><li><p>If we need no bytes from the chunk, we don't need to decompress it.</p></li></ol><p>All we need are the lengths, and those live in the offsets array. It's compressed, too, but with cheap integer compression rather than Zstd, which is much faster.</p><p>With this realization, we can rewrite empty string term queries. The one new operation we need is <code>docValues.loadLength(docId)</code>, which reads directly from the offset array without touching the compressed bytes. After the previous example, this should look familiar. The most interesting part is <code>TermEqualsQuery.rewrite</code>; it finds the empty string special case and replaces the query with the simpler version that only checks the length.</p>class TermEqualsQuery(term, docValues):

    boolean matches(docId):
        value = docValues.loadValue(docId)  # requires Zstd decompression
        return value == term

    Query rewrite():
        if term == "":
            return LengthEqualsQuery(0, docValues)
        return self


class LengthEqualsQuery(queryLen, docValues):

    boolean matches(docId):
        length = docValues.loadLength(docId)  # reads only from offset array
        return length == queryLen<h3>Benchmarking the empty string rewrite: 1.6x faster</h3><p>Now let's see how this stacks up. There aren't any pure-scan ClickBench queries that use this rule as directly as Q20 does for the previous rule, so we'll make our own. Consider the query: <code>FROM hits | WHERE SearchPhrase != '' | STATS count(*)</code>. On this query, we see a 1.6x speedup, which is a great improvement for a fairly uncomplicated change. Better yet, ES|QL can take advantage of <code>loadLength</code> directly. Any time that ES|QL accesses a string's <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions/byte_length"><code>BYTE_LENGTH</code></a>, without needing the string itself, the request uses this same specialized length loading to avoid unnecessary decompression.</p><h2>What makes a good query rewrite rule</h2><p>The two rules covered here follow the same shape: identify that a query is a special case, and then swap it for a cheaper implementation. But they reduce cost in different ways. </p><p></p><p>
</p><p><strong>Wildcard rule</strong></p><p><strong>Empty string rule</strong></p><p>Query shape detected</p><p><code>*term*</code></p><p><code>field == ""</code></p><p>Replaced with</p><p>SIMD substring search</p><p>Length check on the offsets array</p><p>Cost reduced</p><p>Algorithmic work</p><p>Data access</p><p>Speedup</p><p>2.3x</p><p>1.6x</p><p>The underlying pattern is worth noting: finding a query that leaves performance on the table, finding a special case that can be optimized, and swapping in a cheaper implementation. The hard parts are finding queries that uncover these opportunities for optimization and then identifying the special cases. The actual fix is often relatively straightforward, as both rules here show. Our work on columnar mode has provided many opportunities to run interesting queries and hunt down exactly these kinds of wins.</p><p>That's also why extensibility in a rule system is so important. These rules can't be built into a database from the start; they're found through an incremental discovery process. Lucene's rewrite system makes that practical. As columnar mode grows to handle new workloads, rules like these will keep emerging.</p><p>To try columnar mode and the optimizations described in this article, use Elastic Cloud Serverless or Elasticsearch 9.5 or later, where columnar mode is available as a technical preview.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/query-rewrite-columnar-storage-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/query-rewrite-columnar-storage-elasticsearch</guid>
    <category><![CDATA[Lucene]]></category>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Parker Timmins,Martijn Van Groningen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt838d10c743cf1f3e/6a9ee03c8936813a883df5d5/image2.png" length="0" type="image/png"/>
    <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ Backfill time series data in Elasticsearch: Load months of historical metrics through the bulk API]]></title>
    <description><![CDATA[Elasticsearch works out the time boundaries and creates the past backing indices as the documents land, so a historical data migration runs on your normal ingest path.]]></description>
    <content:encoded><![CDATA[<p>You can now write documents with past timestamps straight into Elasticsearch <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time series data streams (TSDB)</a>. Send months of historical metrics through the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk">bulk API</a>, the <a href="https://www.elastic.co/docs/manage-data/ingest/otlp-endpoint">OpenTelemetry Protocol (OTLP) endpoint</a>, or the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">Prometheus remote write endpoint</a>. Elasticsearch creates the past backing indices as the documents arrive, computing each index's time boundaries and attaching it to the data stream. Backfilled documents are stored exactly like live ones, with columnar storage and write-time deduplication, along with up to <a href="https://www.elastic.co/blog/70-percent-storage-savings-for-metrics-with-elastic-observability">70% storage savings</a>. Time series data backfill ships in Elasticsearch 9.5, disabled by default, and turns on with one cluster setting. How far back you can write depends on your lifecycle configuration, since backfill doesn’t apply to indices that are already read-only as a result of downsampling or a searchable snapshot.</p><h2>How historical metrics were loaded before backfill</h2><p>Even if loading historical metrics isn’t a very common use case, it’s an important step when teams are adopting TSDB. Two scenarios have been the most prominent: bootstrapping a new time series data stream and migrating data from a different system or data stream to a time series one.</p><h3>Bootstrapping a new time series data stream</h3><p>You want to start a new time series data stream with a week of historical data so you have something meaningful to query from the start. With existing tooling, you had to set <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/time-series"><code>index.look_back_time</code></a> to the seven-day maximum in the index template, and all historical data would land in a single backing index. For anything beyond seven days, you needed to create past backing indices manually.</p><h3>Migrating metrics from another system</h3><p>You have months worth of metrics stored on a different system and want to move your full dataset to TSDB. You need to load months of metrics history alongside live ingestion. The workaround was to manually create all the necessary past backing indices with the right <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/time-series"><code>time_series.start_time</code></a> and <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/time-series"><code>time_series.end_time</code></a> and to index into it directly using the index name. You then attached it to the data stream via the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/modify-data-stream">modify data stream API</a>. It worked, but it required understanding the index time semantics and repeating the steps for each time window, along with coordinating that process around ongoing writes.</p><p>We wanted both scenarios to feel as close to normal bulk indexing as possible.</p><h2>What time series data backfill changes</h2><p>In 9.5, Elasticsearch can create backing indices covering past time ranges, which extends the eligible write window backward.</p><p>The eligible write windowis the range of <code>@timestamp</code> values that a time series data stream accepts for new documents. </p><p>In the past, the eligible write window was determined only by the existing writable backing indices at the moment the request was received by Elasticsearch.</p><p>In 9.5, Elasticsearch can expand the eligible write window in the past by creating backing indices. This converts the eligible write window to a sliding window extending from the present back to the first read-only or destructive lifecycle action. Common examples of these actions, which are typically defined within your lifecycle configuration, are <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/downsampling-concepts">downsampling</a> or <a href="https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore/searchable-snapshots">searchable snapshots</a>. Examples also include <a href="https://www.elastic.co/docs/manage-data/lifecycle/data-stream/tutorial-data-stream-retention">retention configurations</a>. </p><p>So, given that loading historical data is enabled in a cluster, the eligible write window of the data stream with the following lifecycle configuration is determined by the downsampling action, because it’s the first action that makes backing indices read-only. So, for this data stream Elasticsearch accepts documents whose <code>@timestamp</code> is no older than three months.</p>GET _data_stream/metrics/_lifecycle
{
  "enabled": true,
  "downsampling": [{ "after": "90d", "fixed_interval": "10m" }],
  "data_retention": "365d"
}<h3>Why loading historical data into TSDB is hard</h3><p>TSDB consists of data streams optimized for timestamped measurements. It uses a columnar storage layout and enforces immutable dimensions. It also organizes data into time-bound backing indices; each <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-bound-tsds">index covers a specific time range</a> and accepts only documents whose <code>@timestamp</code> falls within it.</p><p>As time passes, rollover creates new backing indices to cover upcoming ranges. Until this release, there was no corresponding mechanism for the past. Creating indices in the past is tricky because historical data might span over a long period of time and can arrive at Elasticsearch out of order. Consequently, Elasticsearch cannot determine the write timeframe that its backing index should cover. Our solution to this is to use a preconfigured interval and lazily create past backing indices.</p><h2>How Elasticsearch creates past backing indices</h2><p>When a document is detected whose timestamp isn't covered by any existing backing index, Elasticsearch determines the time boundaries for the missing indices and creates them. It then adds them to the data stream in a single atomic operation. </p><p>Lazily creating the indices ensures that a single request in the past won’t overwhelm the cluster by requiring the creation of 300 indices all at once. It also doesn’t create indices before there are docs to write into them.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a3c89f8c228a3b6/6a9a5b6532b530b1df6d23a0/unnamed.png" alt="Time series data backfill timeline: past backing indices accept documents within the retention limit, rejecting older ones" /><h3>Proactive vs. reactive: How we chose the index creation approach</h3><p>We explored two ways to detect when a past backing index needs to be created.</p><p>The first is proactive. Inspect each incoming document's timestamp before routing, and create any missing past backing indices up front. This keeps the write path clean. By the time a document is routed, the index it needs already exists. It does require the data stream to already exist with at least one time series backing index, since that's what we inspect to determine the eligible write window and the time boundaries of the new index. The downside is that it adds work to every bulk request targeting a time series data stream, even requests that contain no past timestamps and need no backfill at all.</p><p>The second is reactive. Let the document fail the normal indexing, intercept that failure, create the missing index, and retry. This avoids any overhead on the common case, since the extra work only happens when a mismatch actually occurs. The tradeoff is more complexity in the failure handling path and a retry on every backfill document.</p><p>We ran performance tests on the proactive approach against bulk requests with no past timestamps and found no measurable regression. The overhead of inspecting timestamps turned out to be negligible. That settled it. Proactive creation is simpler and consistent with how index auto-creation already works in Elasticsearch. Plus, it adds no measurable cost to the workloads that don't use backfill.</p><h3>How Elasticsearch determines past index boundaries</h3><p>Each new past backing index has three properties to compute: its duration, its start time, and its end time.</p><p><strong>Property</strong></p><p><strong>How it's set</strong></p><p><strong>Constraint</strong></p><p>Duration</p><p>Defaults to one day, configurable via the cluster setting <a href="https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/miscellaneous-cluster-settings#time-series-data-stream"><code>data_streams.past_tsdb_index_interval</code></a></p><p>Minimum one hour. If the triggering timestamp falls in a gap up to 1.3 times the configured duration, Elasticsearch collapses it into a single bridging index rather than creating many tiny ones.</p><p>Start time</p><p>Anchored to the start of the next existing backing index, working backward in multiples of the configured duration</p><p>Increased to match the end time of the previous neighboring index, where they would otherwise overlap.</p><p>End time</p><p>Start time plus the configured duration</p><p>Reduced to match the start time of the next index, where they would otherwise overlap.</p><h3>Handling concurrent writes</h3><p>In a distributed setup, multiple nodes can receive bulk requests with overlapping past timestamps at the same time. Each node collects the timestamps that aren’t matching any of the existing indices and sends a request to the master node. </p><p>The master node executes a cluster update that sorts them and then, one by one, checks whether the timestamp is covered by an existing or newly created index. Otherwise, it issues a new create index request with the time boundaries calculated as described above. The cluster updates are always sequential and guaranteed to produce valid cluster states, so new indices are guaranteed to not overlap with existing indices.</p><h3>How lifecycle age works for backfilled indices</h3><p>Past backing indices hold old data but are new indices. Without an adjustment, lifecycle features would apply downsampling and retention based on when the index was created rather than when the data is from. We account for this by using the <code>index.time_series.end_time</code> as the <a href="https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/data-stream-lifecycle-settings#_index_level_settings"><code>index.lifecycle.origination_date</code></a>. As a result, the age of the index as perceived by both <a href="https://www.elastic.co/docs/manage-data/lifecycle/data-stream">data stream lifecycle</a> and <a href="https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management">index lifecycle management (ILM)</a> is based on the age of its data and not its creation time.</p><h2>How to use time series data backfill</h2><h3>How to enable time series data backfill</h3><p>Backfill support ships disabled by default. <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-cluster-put-settings">Enable it at the cluster level</a>:</p>PUT _cluster/settings
{
"persistent": {
"data_stream.past_tsdb_index_creation_enabled": true
	}
}<h3>Bootstrapping with historical metrics</h3><p>To load historical data into a new time series data stream:</p><ol><li><p><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-put-index-template">Create your index template.</a> </p></li><li><p><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-create-data-stream">Initialize your data stream.</a> (This is an important step because an existing data stream is a requirement for creating past backing indices.)</p></li><li><p>Start indexing. </p></li></ol><p>Past backing indices are created automatically as documents with historical timestamps arrive, each covering one day of data by default. No additional configuration is needed.</p><h3>Data migration into an existing data stream</h3><h4>Migrating data within the eligible write window</h4><p>For data that falls within the eligible write window of your data stream, point your migration pipeline at the data stream and let Elasticsearch manage the rest.</p><h4>Migrating data beyond a read-only action</h4><p>For data older than the write window (for example, you're migrating 18 months of metrics but downsampling kicks in after seven days), you need a separate data stream without read-only lifecycle actions. Retention isn’t an issue since the data would be deleted anyway. The pattern is:</p><p>1. Create an index template for the historical data stream, using the same mappings as the original but without a lifecycle:</p>PUT _index_template/my-metrics-historical
{
  "index_patterns": ["metrics-historical-*"],
  "data_stream": {},
  "template": {
    "settings": { "index.mode": "time_series" },
    "mappings": {
      "properties": {
        "sensor_id": { "type": "keyword", "time_series_dimension": true },
        "temperature": { "type": "half_float", "time_series_metric": "gauge" },
        "@timestamp": { "type": "date" }
      }
    }
  }
}<p>2. Create the historical data stream. If this step isn’t executed, the first indexing request might fail. During the first indexing request, Elasticsearch can create the data stream but it cannot yet create any past backing indices, so indexing a historical document might fail. Creating the data stream explicitly ensures that all indexing requests will be accepted:</p>PUT _data_stream/metrics-historical-2024<p>3. Index historical data into the historical data stream while current data continues flowing into the original.</p><p>4. When the load is complete, add lifecycle. This is only supported by data stream lifecycle since this feature functions on a data stream level:</p>PUT _data_stream/metrics-historical-2024/_lifecycle
{
"enabled": true,
"downsampling": [{ "after": "7d", "fixed_interval": "10m" }]
}<p>5. Query across both data streams with a wildcard pattern (<code>my-metrics*</code>) or a data stream alias.</p><p>6. If retention is configured, delete the historical data streams when their data expires. Data stream lifecycle will delete the data but it won't clean up the data stream itself.</p><p>As you see, the historical data needs to fit on the target tier as a whole because lifecycle will be enabled after the data is loaded. If you have a large historical import, you might choose to split it into batches. Make sure each batch can fit on the target tier as a whole at the time of indexing, to avoid running your cluster out of disk space. Lifecycle will start processing the batch's indices as soon as it's enabled, but it will need time to process the whole backlog.</p><h2>Protecting the cluster during large migrations: Downsampling floodgate</h2><p>When data stream lifecycle runs against a data stream with many indices that all qualify for downsampling, it queues them simultaneously. Downsampling is CPU and I/O intensive; it reads and rewrites all data in an index. Queuing dozens of operations at once can overwhelm the master node with persistent task updates while it coordinates them.</p><p>The downsampling floodgate scenario could occur before backfill support (for example, when adding a lifecycle policy to an existing data stream with months of accumulated data). Backfill makes it more likely by design.</p><p>In 9.5 and serverless, we added flood protection to data stream lifecycle. It now tracks how many indices per data stream are actively being downsampled. If that count reaches a threshold, data stream lifecycle pauses queuing further operations for that data stream until the count drops. The threshold is configurable via the cluster setting <a href="https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/data-stream-lifecycle-settings#_cluster_level_settings"><code>data_streams.lifecycle.downsampling.max_indices_in_progress</code></a>. Other data streams aren't affected.</p><h2>Limitations and prerequisites of time series data backfill</h2><ul><li><p>Backfill doesn’t apply to read-only indices. If downsampling or a searchable snapshot transition has already run on a time period, documents for that period are still rejected.</p></li><li><p>The feature requires a preexisting time series data stream with at least one time series backing index.</p></li><li><p>System data streams are excluded.</p></li><li><p>Replicated data streams rely on the leader data stream, so no direct backfilling is possible.</p></li><li><p>Scaling remains your responsibility. Loading months of data can trigger significant storage usage, force merge operations, and lifecycle activity in parallel. Check that your cluster has the headroom to manage it before starting.</p></li></ul><h2>Conclusion</h2><p>Prior to the Elasticsearch 9.5 release, loading historical data into TSDB was a manual process. By automating the generation and management of past backing indices, we aim to transform historical data migration to a native capability of your standard ingest pipelines. The inherent complexity of managing time-bound indices remains, but it has transitioned from a user responsibility into an internal Elasticsearch function. Whether you’re bootstrapping a fresh data stream or migrating extensive historical datasets, the platform now handles the heavy lifting, allowing you to focus on analyzing your metrics. We look forward to seeing how these improvements streamline your adoption of TSDB.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/time-series-data-backfill</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/time-series-data-backfill</guid>
    <category><![CDATA[Index Data]]></category>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[Mary Gouseti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc78b8fb3f37f3ee8/6a9a5ad6ecbe18174b1e37ac/unnamed.png" length="0" type="image/png"/>
    <pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Introducing SPARKLINE in ES|QL: Spot trends at a glance]]></title>
    <description><![CDATA[Spot trends across thousands of groups at a glance without leaving your workflow. ES|QL's new SPARKLINE function turns aggregations into trend lines. One array per row, zero effort.]]></description>
    <content:encoded><![CDATA[<p>When you run a <code>STATS ... BY</code> query and get back dozens or hundreds of results (log patterns, hosts, services, status codes), the counts alone don't tell you what's happening <em>over time</em>. Is the error count or log pattern climbing or settling down? Is it within the usual range? To answer those questions today, you either build a separate time-series visualization or eyeball the numbers and hope for the best.</p><p>This can be a lot of effort, time, and context switching for what should be just a glance. In this blog, we explain how Elasticsearch Query Language’s (ES|QL’s) SPARKLINE works, what it does, and how to get started.</p><h2>How it works</h2><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/aggregation-functions/sparkline">SPARKLINE</a> is an ES|QL aggregate function with a straightforward signature:</p><p><code>SPARKLINE(aggregation, key, buckets, from, to)</code></p><ul><li><p><strong><code>aggregation</code></strong><strong>:</strong> Expression that calculates the y-axis value, including any supported aggregation: <code>COUNT(*)</code>, <code>SUM(bytes)</code>, <code>AVG(latency)</code>, or others.</p></li><li><p><strong><code>key</code></strong><strong>:</strong> Date expression from which to derive buckets.</p></li><li><p><strong><code>buckets</code></strong><strong>:</strong> Target number of buckets.</p></li><li><p><strong><code>from</code></strong><strong> / </strong><strong><code>to</code></strong><strong>:</strong> The time range boundaries. (In Kibana, they bind to the time picker via query parameters.)</p></li></ul><p>Under the hood, SPARKLINE buckets the time range, computes the aggregation per bucket, and packs the results into a single ordered array. Empty buckets are zero-filled so every group shares the same x-axis grid for fast, easy visual comparison.</p><p>The function composes naturally with <code>STATS ... BY</code>, so you can combine it with any grouping.</p><h2>Where sparklines shine</h2><p>The first place you'll see SPARKLINE in action is Discover's log pattern analysis, starting 9.5. When you run a <code>CATEGORIZE</code> query, Discover constructs the SPARKLINE query under the hood and renders trend lines next to each pattern. You don't write <code>SPARKLINE</code> yourself here; Discover handles it when you use the “identify patterns” option in the ES|QL editor. The result is immediate: You scan dozens of log patterns and instantly see which ones are spiking <em>right now</em> versus which have been steady all day.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte16b62f513b5915d/6a9904382707c505d6329e29/unnamed.png" alt="A dashboard shows a bar chart of hourly event counts above a table of results. The chart displays activity across several days, and the table lists each row with a count value, a sparkline, and pattern tags." /><p>Here’s the query under the hood:</p><p>Consider a platform team investigating how they can cut logging costs. They point Discover at tens of millions of documents and let <code>CATEGORIZE</code> cluster them into patterns. Two patterns rise to the top: verbose lifecycle messages like "fetching resource..." and "completed resource...", each with several millions of hits. The sparklines next to those rows tell the rest of the story: flat, steady streams running around the clock. Not incident-driven. Not bursty. Just constant noise, silently consuming hundreds of terabytes of storage.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt366155f45da3169b/6a9904777f53142f28c63114/unnamed.png" alt="A dashboard shows a bar chart of hourly result counts above a table of rows. The chart displays consistent activity over several days, and the table lists each row with a count value, a sparkline, and pattern tags." /><p>The fix in cases like this is usually straightforward: Adjust log levels, drop the pattern at ingest, or route it to a cheaper tier. The hard part was always <em>finding</em> it. With log pattern analysis and inline sparklines in ES|QL, that discovery takes seconds instead of hours. </p><p>Elastic’s internal site reliability engineering (SRE) teams routinely use log pattern analysis successfully, using every ES|QL enhancement. </p><p>These are just some concrete examples. Break down request counts by region, and see which regions are trending up. Compare latency across container IDs. Monitor queue depths by consumer group. The uses are endless.</p><h2>Simple by design</h2><p>We deliberately kept SPARKLINE focused:</p><ul><li><p><strong>It's an aggregate function</strong>, not a new command. It composes with existing <code>STATS ... BY</code> syntax, so there's nothing new to learn structurally.</p></li><li><p><strong>It returns data that the consumer can render in context.</strong> The function produces an array of values. How those values are rendered, as a mini-chart in Discover, a line in a notebook, or a JSON array in an API response, is up to the consumer.</p></li><li><p><strong>It fills empty buckets.</strong> Every group gets the same number of values aligned to the same time grid. This is a deliberate choice: Sparklines are most useful when you can compare shapes across rows at a glance, and that requires consistent alignment.</p></li></ul><p>The pattern is always the same: one query, many trend lines, instant visual triage.</p><h2>What's next</h2><p>SPARKLINE ships as a <strong>technical preview</strong> in Elasticsearch 9.5. Future work includes <strong>rendering sparklines in more ES|QL surfaces</strong>, beyond the initial integration, with the <code>CATEGORIZE</code> context in Kibana. This includes dashboards but also Elastic Observability use cases like the following:</p><p>In application performance monitoring (APM) workflows, engineers routinely analyze rate, errors, and duration (RED) metrics to understand service health. The challenge is that aggregate numbers hide dimensional outliers. A service might look healthy overall, but one region, one container, or one newly deployed version could be quietly degrading.</p><p>Today, the Elastic APM UI lets you break down metrics by transaction name, but root-cause analysis requires slicing by arbitrary dimensions: availability zone, service version, container ID, cloud region. SPARKLINE can make this practical. Break down error rate by <code>service.version</code>, and instantly see which version's trend line diverges from the pack.</p><h2>Get started</h2><p>SPARKLINE is available in Elasticsearch 9.5 as a technical preview. Try it with the <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql-rest">ES|QL _query API</a> or in Kibana's Discover. Check the <a href="https://elastic.co/docs/reference/query-languages/esql/functions-operators/aggregation-functions/sparkline">SPARKLINE function reference</a> for the full syntax and supported types.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-sparkline-function</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-sparkline-function</guid>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Daniel Rubinstein]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb8ad1babd1641235/6a9901a43481c241218af226/unnamed.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[No more allocation delays: Decoupling snapshots from shard relocation in stateless Elasticsearch]]></title>
    <description><![CDATA[Clusters scale out under load without waiting for a snapshot to finish, because snapshots now read straight from the object store and no longer pin shards in place.]]></description>
    <content:encoded><![CDATA[<p>Stateless snapshots now read directly from the object store. Server-side "undesired allocation due to snapshot" warnings stopped entirely after the release. Primary shards stay free to relocate while a snapshot runs, so clusters scale out under load without waiting for one to finish. Across the fleet, cache misses dropped by more than 60% and median cache population throughput rose roughly 50%. </p><h2>Why snapshots pin primary shards in stateful Elasticsearch</h2><p>In traditional stateful Elasticsearch, snapshots lock primary shards to their active nodes, completely preventing relocation. That works fine when cluster topology is stable and nodes stay online between maintenance windows, but stateless Elasticsearch works differently. Index data lives in an external object store, with local disk as a cache, and the cluster scales automatically based on CPU, memory, and data size, both vertically (upsizing nodes) and horizontally (adding nodes).</p><p>During vertical scale-up, existing nodes must vacate all shards and shut down before new hardware takes over. Since Elasticsearch version 8.13, shard snapshots can pause during node shutdowns and resume after relocation, so a long-running snapshot doesn't block an infrastructure update.</p><p>Horizontal scale-out is a different story: No nodes shut down, so pause logic never triggers. New nodes sit idle while existing nodes finish their snapshots, and as soon as a snapshot is queued, the primary shard is pinned to its node, significantly delaying relocation.</p><p>Clusters typically scale out because they're already under heavy load. Blocking shard relocations at that moment limits the cluster's ability to reduce pressure, which shows up as degraded indexing throughput and higher latency. The resource imbalance can also trigger unexpected autoscaling behavior. And even when overall topology stays the same, shard locking disrupts hotspot mitigation and workload distribution. These failures used to surface as server-side warnings: "undesired allocation due to snapshot."</p><p>
</p><p><strong>Stateful Elasticsearch</strong></p><p><strong>Stateless Elasticsearch</strong></p><p>Snapshot reads from</p><p>Local shard data on the node holding the primary</p><p>The object store, using file locations recorded in the commit</p><p>Primary shard during snapshot</p><p>Pinned to its node until the snapshot completes</p><p>Free to relocate at any time</p><p>Effect on horizontal scale-out</p><p>New nodes wait for in-flight snapshots before taking shards</p><p>New nodes take shards immediately, regardless of snapshot state</p><p></p><h2>How stateless snapshots read directly from the object store</h2><p>A shard snapshot pins primary shards because it needs to read local shard data. In stateless Elasticsearch, that data already lives in the object store, so reading from local disk is unnecessary. Letting snapshots read directly from the object store removes the requirement to lock primary shards. They can relocate freely, and backup is decoupled from cluster balancing.</p><p>Stateless commits include location information for each data file in the object store, so snapshots can read and stream directly to the snapshot repository (a separate object store bucket). In the future, we plan to look at server-side ranged copies, which object stores support natively, to skip the local copy step entirely.</p><h2>Tracking commits when shards relocate mid-snapshot</h2><p>A snapshot is bound to a specific commit point that determines which files to back up, and those files must remain accessible for the full duration of the operation. In stateful clusters, this is simple: The snapshotting node and the data node are the same, so the commit is managed locally and held until completion.</p><p>In a stateless model, the snapshotting node and the data node can be entirely separate, or they can diverge if a shard relocates mid-snapshot. To handle this, we added a transport action that acquires commits on remote data nodes over the network. The data node tracks which commit belongs to which snapshot and releases it once cluster state signals completion.</p><p>There's a wrinkle during relocation. A stationary shard relies on its commit point to preserve files. A relocating shard must release its commit so its local store can close cleanly. To keep files accessible through that transition, a newly recovered primary temporarily preserves all existing data files in the object store until notified of snapshot completion via cluster state. This handles both graceful relocations and ungraceful recovery from node or engine failures.</p><h2>No more allocation delays and improved cache stats</h2><p>After stateless snapshots shipped, the server-side "undesired allocation due to snapshot" warnings stopped. The chart below shows the before and after, with the release marked by the red arrow. Hotspot mitigation became more responsive because shard relocations no longer had to wait for backup operations.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c08d57928ea1c6c/6a97a33f2707c544c5329695/unnamed.png" alt="Bar chart showing undesired allocation due to snapshot warnings dropping to zero after stateless snapshots shipped" /><p>Cache use is also improved. Snapshots that bypass local shard data stop competing with indexing for cache space. After the release (also marked in the chart), we observed the following two positive changes in cache metrics:</p><ol><li><p>The median cache population throughput, defined as bytes per second for filling the local disk cache from the object store, increased about 50%.</p></li><li><p>Cache misses, where data must be retrieved from the object store to fill local disk cache, have dropped more than 60%. </p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0b85caa56b35485d/6a97a3683eabd0c326440fab/unnamed_(1).png" alt="Charts showing cache population throughput rising 50% at p50 and cache misses falling over 60% after release" /><h2>What comes after stateless snapshots</h2><p>Object-store-native architectures are increasingly the standard for cloud-native data systems, and stateless snapshots are a step toward fully exploiting that model across Elasticsearch operations. Backups read from the object store, and shards move freely. Neither process waits on the other. Removing the local shard dependency is a step toward further modularizing the stateless architecture.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/stateless-snapshots-shard-relocation</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/stateless-snapshots-shard-relocation</guid>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[David Turner,Yang Wang]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt685f7746435d0451/6a97a2aaf08ee14b39853cbb/unnamed.png" length="0" type="image/png"/>
    <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Avoiding and Correcting Hotspots: How Elasticsearch Serverless Balances Shards]]></title>
    <description><![CDATA[Elasticsearch Serverless replaces the Elasticsearch node-weight based shard rebalancing algorithm with resource usage aware rebalancing that avoids index shard colocation, OOM events and write load hotspotting]]></description>
    <content:encoded><![CDATA[<p>The Elasticsearch Serverless Balancer addresses write load hotspots, prevents data node out-of-memory (OOM) events and avoids index-level hotspots in Elasticsearch Serverless clusters: these are workload edge cases that in non-Serverless require manual intervention and custom tuning of cluster settings. Serverless shard balancing focuses on staying within the bounds of node-level resource constraints. Rebalancing moves are explainable, where moves are made explicitly to either avoid performance degradation or correct hotspots when they develop. Shard movements are generally found to be fewer, as well.</p><h2>How Elasticsearch Shard Balancing Works</h2><p>Elasticsearch uses a weights-based algorithm to create a Desired Balance, an assignment of shards to data nodes. The Balancer determines the target allocation of shards across a cluster of nodes using four key metrics weighted in a linear algorithm. A total weight is calculated per node, and the shard balancer aims to equalize the total weights across cluster nodes. A final Desired Balance shard allocation is precomputed based on the latest cluster state information, and then the elected master node initiates incremental shard moves to reach the desired shard allocation.</p><p>The four metrics are:</p><ul><li><p><strong>Write Load:</strong> the total write threadpool activity per node, using the sum of threadpool indexing activity per data-stream shard.</p></li><li><p><strong>Disk Usage:</strong> the total disk usage of shards per node, using the sum of disk space used per shard.</p></li><li><p><strong>Shard Count:</strong> the total number of shards assigned to a node.</p></li><li><p><strong>Index Balance (shard anti-affinity):</strong> per index, how many shards in the index are assigned to the node.</p></li></ul><p>The total weight of a node is calculated using a linear algorithm that finds the deviation from the node-level cluster average for each individual metric, applies a different weight factor multiplier to each, and then takes the sum of all resultant values. The weight factor multipliers attempt to equalize the relative magnitude of each metric so that metrics with large values do not eclipse metrics with naturally small values. Write load tends to be a small value, related to thread usage, and thus gets multiplied by a relatively larger weight factor of <code>10</code>; whereas disk usage in bytes is a very large number and therefore gets multiplied by a tiny weight factor of <code>2e-11</code>.</p><p>The following are the cluster settings with default values, representing the different weight factors:</p><p><code>cluster.routing.allocation.balance.shard: 0.45</code></p><p><code>cluster.routing.allocation.balance.index: 0.55</code></p><p><code>cluster.routing.allocation.balance.disk_usage: 2e-11</code></p><p><code>cluster.routing.allocation.balance.write_load: 10.0</code></p><p>The linear algorithm looks something like this:</p>final float shardWeightFactor =
    settingValue("cluster.routing.allocation.balance.shard");
final float writeLoadWeightFactor = 
    settingValue("cluster.routing.allocation.balance.write_load");
final float diskUsageWeightFactor = 
    settingValue("cluster.routing.allocation.balance.disk_usage");
final float indexWeightFactor = 
    settingValue("cluster.routing.allocation.balance.index");

final float shardCountDeviation = numShardsOnNode - averageShardsPerNode;
final float writeLoadDeviation = totalWriteLoadOnNode - averageWriteLoadPerNode;
final float diskUsageDeviation = totalShardDiskUsageOnNode - averageShardDiskUsagePerNode;
final float indexDeviation = numIndexShardsOnNode - averageNumIndexShardsPerNode;

return shardCountDeviation * shardWeightFactor
    + writeLoadDeviation * writeLoadWeightFactor
    + diskUsageDeviation * diskUsageWeightFactor
    + indexDeviation * indexWeightFactor;<p>Shard movements are triggered to ensure that the difference in total node weight across cluster nodes remains below the <code>cluster.routing.allocation.balance.threshold</code> with a default value of <code>1</code>: whenever the threshold is exceeded, shards are moved from the most heavily weighted nodes to the least heavily weighted nodes until the difference between the most heavily weighted and least heavily weighted node is at or below the <code>threshold</code>. Whenever cluster activity occurs that changes shard allocation (e.g., create/delete index, add/remove node, or the disk usage grows), the Balancer rechecks the weights across nodes and triggers shard rebalancing if the delta between the most and least heavily weighted nodes exceeds the configured threshold. The threshold-based approach attempts to balance the trade-off between keeping the cluster perfectly balanced and minimizing shard movements. Large Elasticsearch deployments that use nodes with greater resources typically benefit from raising the <code>threshold</code> setting: a larger weight delta between nodes reduces shard rebalancing.</p><p>Shard movement is also constrained by strict shard assignment rules that prohibit certain node assignments according to cluster and index level settings. Examples include: not assigning copies of the same shard to the same node or host; not allowing further assignment of shards to a node that does not have spare disk space; and excluding node(s) as host for a particular index. More on this below.</p><h2>How a Balanced Cluster Looks (Based on Weights)</h2><p>Using the linear algorithm and cluster setting defaults previously described, the following is an example of what the Balancer considers balanced. Notably, it can sometimes allow considerable deviation across nodes in any one particular metric. For simplicity, index balance is not included.</p><p><em>Weight Node1 = 0</em>.45 (5 - 6) + 10 (0.3 - 0.33) + 2e-11 (1e+11 - 8e+10) =  <em> - 0.35</em></p><p><em>Weight Node2 </em>= 0.45 (7 - 6) + 10 (0.4 - 0.33) + 2e-11 (4e+10 - 8e+10) = <em>  0.35</em></p><p><em>Weight Node3 </em>= 0.45 (6 - 6) + 10 (0.3 - 0.33) + 2e-11 (1e+11 - 8e+10) = <em>  0.10</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt716ecf26a404be16/6a969bee0897906115efbc2d/2.png" alt="Weights-based shard balancing: three Elasticsearch data nodes with 5, 7 and 6 shards, differing disk usage and write load" /><h2>Limitations of Weights-Based Shard Allocation</h2><p>Elasticsearch Serverless deployments are managed by Elastic and could run into many edge cases that, without additional configuration, the weights-based shard allocation handles poorly. In self-managed Elasticsearch deployments, it is possible to work around many of these issues by configuring the cluster to suit the user’s workload. However, Elasticsearch Serverless is configured once and must work across all customer use cases. Issues experienced by some Elasticsearch customers (early adopters of Serverless among them) include:</p><ul><li><p>Continuous rebalancing background noise in active clusters. This could be because the <code>threshold</code> setting needs tuning or because the cluster is very busy.</p></li><li><p>The Balancer’s behavior cannot be tuned in a predictable manner. Adjusting the Balancer settings (individual weight factors) can lead to unpredictable outcomes due to the linear algorithm. For example, decreasing the shard count weight factor relative to the other weight factors can lead to data node OOM events when shard count balancing is deprioritized and too many shards pile up on a single node.</p></li><li><p>No explanation of why the Balancer is making shard moves. The linear algorithm is difficult to understand without relevant node metrics.</p></li><li><p>The linear algorithm allows a high value in one metric to cancel out a low value in another metric. For example, a node can have a higher than average (across cluster nodes) write load, but counterbalance with a lower than average shard count (or vice versa), and the linear algorithm cancels out the spikes: no shards are moved to address the write load hotspot.</p></li><li><p>Index-level hotspots can occur when a disproportionate number of index shards are assigned to the same node, rather than spreading out across nodes, despite the index balance weight in the linear algorithm. Index balance weight can, in some situations, be little compared to the other weight factors. It can also get skewed and counterbalanced by another non-average individual weight in the linear algorithm, as described in a previous bullet.</p></li><li><p>No search load balancing.</p></li><li><p>Regular indices do not have write load estimate support, leaving some write load hotspots unaddressed. Only data stream indices have write load estimates.</p></li><li><p>Write load hotspots can be missed. Write load estimates are only refreshed at rollover time, which can be infrequent in some configurations, causing new load to be ignored for some time. The write load is also the average write load activity over a potentially large window of time between index rollover events, so temporary write load increases can disappear when averaged with inactive write periods.</p></li></ul><p>The above issues persist in some Elasticsearch deployments and require monitoring and workload tuning to manage when they do occur. Shard allocation balancing in Elasticsearch Serverless aims to address these issues and avoid any manual intervention requirements using a new approach that is explained in subsequent sections of this article.</p><h2>Elasticsearch Serverless Shard Allocation </h2><p>Elasticsearch Serverless considers node resources individually: shards are rebalanced away from a node when any resource usage on that node grows to threaten performance, and shard movements to a node are declined when the assignment could threaten that node’s performance.</p><p>The Elasticsearch single combined score per node is replaced in Elasticsearch Serverless with independent per-resource decisions:</p><p>
</p><p><strong>Elasticsearch Weights-Based Balancing</strong></p><p><strong>Elasticsearch Serverless Resource-Aware Deciders</strong></p><p><strong>Decision Basis</strong></p><p>Single weighted sum across four metrics</p><p>Each resource evaluated independently</p><p><strong>Metric Interaction</strong></p><p>A high value can offset a low one</p><p>No offsetting; each decider acts separately</p><p><strong>Decision Types</strong></p><p><code>YES</code> / <code>NO</code></p><p><code>YES</code> / <code>NO</code> / <code>NOT_PREFERRED</code></p><p><strong>Rebalancing Trigger</strong></p><p>Weight delta across nodes exceeds <code>threshold</code></p><p>Individually configurable safe limits per resource</p><p><strong>Explainability</strong></p><p>Can only make an educated guess</p><p>Each move traces to a named decider</p><p>The Elasticsearch Balancer has three phases, in order of priority, for shard movement decisions. The first phase is to assign unassigned shards. Assignment of unassigned shards is the top priority for data availability reasons. The second phase is to move shards that can no longer remain where they are assigned due to cluster configuration changes. Internally, <code>AllocationDecider</code> implementations enforce cluster settings, like <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/shard-allocation#index-allocation-filters">index-level shard allocation filtering</a>, <a href="https://www.elastic.co/docs/deploy-manage/distributed-architecture/shard-allocation-relocation-recovery/shard-allocation-awareness">shard allocation awareness</a>, <a href="https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/cluster-level-shard-allocation-routing-settings#disk-based-shard-allocation">disk usage thresholds</a>, or moving shards off of a node before shutdown. The third phase rebalances shards when the <code>cluster.routing.allocation.balance.threshold</code> is exceeded, using the previously described weights algorithm.</p><p>The new Serverless balancing approach adds additional logic to the Balancer’s first and second phases, leveraging the existing <code>AllocationDecider</code> logic, and eliminates the third phase. Previously, each <code>AllocationDecider</code> had simple responses of <code>YES</code> and <code>NO</code>. Now, the decision type of <code>NOT_PREFERRED</code> has been added, along with several new <code>AllocationDecider</code> implementations. An <code>AllocationDecider</code> will return <code>NOT_PREFERRED</code> when it observes that performance might suffer from a shard’s assignment to a particular cluster node. The Serverless Balancer will prefer a node assignment for the shard where all <code>AllocationDecider</code> implementations reply <code>YES</code>.</p><p>A <code>NOT_PREFERRED</code> shard allocation may be left uncorrected if all other node assignments return <code>NO</code> or <code>NOT_PREFERRED</code>. Such responses mean that the shard cannot be assigned elsewhere without either violating a cluster/index rule or potentially degrading the performance of another cluster node. Serverless Autoscaling activates before all cluster nodes hotspot: even one unaddressable hotspot leads to a scale-up event. New <code>AllocationDecider</code> implementations have also been added for important finite resources, like available heap memory (further discussion below), using only the original <code>YES</code> and <code>NO</code> decisions: exceeding certain categories of resources can lead to node unavailability.</p><p>The individual weight metrics in the Balancer’s linear algorithm have been replaced by resource-aware <code>AllocationDecider</code> implementations, and new <code>AllocationDecider</code> implementations are being built for additional resources: Serverless Search Tier load-balancing improvements are currently in development. Each shard migration will have a clear purpose to address a potential resource usage bottleneck.</p><p>Internal stats have shown far fewer shard movements in general, without any noticeable accompanying node performance degradations – one workload showed a 50% reduction in shard movements with the same write throughput. Fewer shard movements has the benefit of: avoiding momentary read/write latencies from warming up local caches; and saving on cloud infrastructure costs moving data between servers.</p><h3>Serverless IndexBalanceDecider: Avoid Colocation of Index Shards</h3><p>The <code>IndexBalanceDecider</code> ensures index shard anti-affinity much more strictly than the original weights-based linear algorithm could achieve. Colocation of index shards in excess of the index’s average shards per available node is avoided, except in the case of a strict <code>NO</code> assignment (essentially non-existent right now in Serverless except for shutting down nodes and rolling upgrade incompatible version checks) or <code>NOT_PREFERRED</code> assignment due to temporary node hotspotting.</p><p>The <code>IndexBalanceDecider</code> is a very effective means of pre-balancing both write load and search load before user workloads begin to generate load statistics: each index begins life with its shards distributed across as many nodes as possible.</p><h4>IndexBalanceDecider Results: Even Write Load Distribution Across Cluster Nodes</h4><p>Write load across data nodes became much more evenly distributed after the <code>IndexBalanceDecider</code> was enabled in the Serverless Production environment. Projects fleet-wide generally show even ingest load (counted in saturated <code>WRITE</code> threadpool threads), combining the release of the <code>IndexBalanceDecider</code> and many other prior improvements:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb1d22b863724026b/6a969c19ecdaa7898505223b/4.png" alt="Ingestion load per data node across an Elasticsearch Serverless project, showing even write load distribution over time" /><p>A reproducible workload demonstrates a clear before and after view of the impact of the new <code>IndexBalanceDecider</code> when an ingest workload was run with and without it enabled:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb3788a358c3a3043/6a969c385c3126893d43ef2b/5.png" alt="Scale test ingestion load per node with the IndexBalanceDecider off then on, showing write load spread across data nodes" /><p>The <code>IndexBalanceDecider</code> also serves in the Serverless Search Tier to distribute shards of the same index as much as allowed, similarly limited only by the tier’s node count and the number of shards in each index.</p><h3>Serverless HeapUsageDecider: Assign Shards by Available Heap</h3><p>The <code>HeapUsageDecider</code> limits shard count on a node based on available heap to hold in-memory shard metadata and run associated write/read operations, removing the dependency on shard count limits per node. The <code>HeapUsageDecider</code> returns a strict <code>YES</code> or <code>NO</code> decision, rather than using the new <code>NOT_PREFERRED</code> decision type, because a data node risks an OOM event if the estimated available heap memory is exceeded.</p><h4>HeapUsageDecider Results: Reduced Data Node OOMs</h4><p>Data node OOMs in the serverless index tier decreased significantly as the <code>HeapUsageDecider</code> rolled out to the Serverless production environment.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5c28891290e3a635/6a969c88d04dac5ed56ca2f1/6.png" alt="Indexing OOM errors on Elasticsearch Serverless data nodes falling to near zero after the HeapUsageDecider rollout" /><p>Index tier OOM errors still occur from time to time in the Serverless Index Tier, though at a much reduced rate, as miscellaneous runaway memory usage edge cases are surfaced. The remaining OOM errors are being progressively resolved as they are identified, through a combination of memory usage improvements in the code, adding component level limits, and updating the internal Elasticsearch Serverless memory model service to more completely account for memory usage.</p><p>The <code>HeapUsageDecider</code> is not yet turned on in the Serverless Search Tier, due to the need for additional and different metrics, but that work is in active development.</p><h3>Serverless WriteLoadDecider: Prevent and Correct Write Load Hotspots</h3><p>The <code>WriteLoadDecider</code> receives periodically refreshed (every 30 seconds by default) per shard and per node write load stats and uses the data to correct and avoid write load hotspots. The master node retrieves stats directly from each data node’s write threadpool: an Elasticsearch node tracks the total time that its <code>WRITE</code> threadpools is in use, and each individual Elasticsearch shard instance tracks how much time it spent using its node’s <code>WRITE</code> threadpool.</p><p>A write load hotspot is identified at the node level. The criteria for a hotspot is the presence of <code>WRITE</code> threadpool queue latency above a configured threshold and sufficiently high, and sustained, <code>WRITE</code> threadpool thread saturation. Once that situation is detected, the Balancer is signaled to select shards to move away from a hotspotting node, until fresh non-hotspotting write load stats are received from the node. The Balancer will do nothing if all nodes are hotspotting at once, expecting the Autoscaler to solve the problem by introducing more, or bigger, data nodes to the cluster.</p><p>The <code>WriteLoadDecider</code> uses a heuristic to choose shards to move away from a hotspotting node that aims to minimize ingest disruptions while still effectively reducing a node’s write load. A shard write load <code>threshold</code> is identified on a hotspotting node: the <code>threshold</code> is currently calculated as ½ the ingest load of the hottest shard on that node. Shards that can be moved are then prioritized in the following order:</p><p><code>threshold</code><code> = ½ * </code><code>maxWriteLoadShardOnNode</code></p><ol><li><p>Shards with write load in the range [<code>threshold</code>, <code>maxWriteLoadShardOnNode</code>), the shard at or closest to threshold preferred.</p></li><li><p>Shards with write load in the range (<code>threshold</code>, <code>0</code>], the shard closest to threshold preferred.</p></li><li><p>Shards with write load equal to <code>maxWriteLoadShardOnNode</code>.</p></li><li><p>Shards with zero write load.</p></li></ol><p>The heuristic prefers to avoid disruption to the highest ingest shards and instead chooses middlingly loaded shards. Movement of the hottest shard will cause the most latency disruption; and movement of the coldest shards will be the least effective in resolving a hotspot.</p><p>The Balancer limits write load hotspot correction shard moves to one move per hotspotting node per stats refresh period, in order to see the effect of a move in real-time node-level write load, before attempting any further corrections. This was a simple initial design that proved effective. Furthermore, the Balancer will not move a shard whose write load alone is sufficient to meet the node-level hotspot criteria: this would just relocate a hotspot to another data node, not actually resolve the hotspot. The Serverless Autoscaler and Serverless Autosharding components are relied upon to resolve hotspots that reallocation of shards cannot.</p><p>The <code>WriteLoadDecider</code> returns <code>NOT_PREFERRED</code> when acceptance of a shard could cause a node to start experiencing <code>WRITE</code> threadpool queue latency and create a hotspot. A shard will still be relocated to a <code>NOT_PREFERRED</code> node, however, and risk some performance degradation, as a better option than, say, risking a data node OOM from keeping a shard on a data node where the <code>HeapUsageDecider</code> returns <code>NO</code>.</p><h4>WriteLoadDecider Results: Hotspots are Quickly Corrected </h4><p>Hotspot stats showed general improvement as the <code>WriteLoadDecider</code> was rolled out to Serverless production, in particular the fleet-wide time to correct a hotspot decreased greatly:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1e3ad68727d2121d/6a969cb28814aa3f7c89c987/7.png" alt="Write load hotspot duration p50, p95 and p100 in Elasticsearch Serverless dropping after the WriteLoadDecider rollout" /><p>Since these graphs were collected, additional work has been released incrementally to better prevent and correct hotspots, and improvements are still in progress.</p><h2>Serverless Autoscaling, Autobalancing, and Autosharding</h2><p>Elasticsearch Serverless relies on both new autobalancing logic and new autoscaling logic. The Serverless Balancer must sufficiently distribute shard resource usage across nodes in order to fully saturate the cluster’s resources. The Serverless Autoscaler will trigger a scale-up event when it receives a report that a certain percentage of the total cluster resources are in use and more resources are needed. The Autoscaler will not scale up the cluster if one node is hotspotting and another node has an excess of available resources because the resources are summed across nodes. Therefore, the Balancer must first do a good job on load distribution, and then the Autoscaler will activate as needed.</p><p>Autosharding based on write load is also in progress and coming soon to address shard hotspots. Elasticsearch Serverless projects have a default number of shards per index based on the project type. These defaults generally work, but do not account for all possible workloads. Hotspots can occur when an index has too few shards, as well as too many. Too few index shards leads to the Balancer being unable to further distribute an index’s write load across available data nodes, and then the Autoscaler will not see a problem because the cluster-level resources are not fully consumed. Conversely, indices cannot by default have too many shards, since that could degrade search performance for small indices and potentially strain cluster metadata operations if the total number of shards in a cluster grew too large.</p><h2>Production Example: 708 TB Data Set, 37 Index Tier Nodes (not counting Search Tier), 4,100 Indices, 30,000 Shards</h2><p>The following graphs cover a period when the Index Tier, in an Elasticsearch Serverless project, scales up from 10 to 37 indexing nodes and then back down to 10 after a write load spike dissipated.</p><h3>Graph of the Ingest Load Per Index Node</h3><p>This graph shows fairly even distribution of load, though a little less even temporarily during scale-up. There are nearly 250 fully saturated write threads at peak load. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6daf46a0823c4a37/6a969cd437d7f3a2008e9204/8.png" alt="Ingestion load per Elasticsearch Serverless index node, peaking near 250 saturated write threads during a load spike" /><h3>Graph of CPU Saturation Per Index Node</h3><p>CPU usage remains within safe bounds. Usage is mostly below 60%, except for momentary outliers that reach into the 90% range as write load rises before nodes are added to the cluster.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf60cff9da89290c9/6a969d203eabd090dd4409b4/9.png" alt="CPU usage per Elasticsearch Serverless data node, mostly below 60% with brief peaks above 90% during scale-up" /><h3>Graph of WRITE Threadpool Queue Latency Per Node </h3><p>When a node’s <code>WRITE</code> threadpool is fully saturated, tasks are placed in the threadpool’s queue. Queuing can happen with few tasks, if active write tasks are long-running, or there may simply be a lot of tasks.</p><p>This graph’s time window is zoomed in further than the others. One node reaches 75 seconds of queue latency during the scale-up spike. There are 29 nodes when the queue latency spike occurs at 19h25m, before autoscaling calls for 37 nodes at 19h28m.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9c16dbf83897366c/6a969d478814aa399089c98b/10.png" alt="Maximum WRITE threadpool queue latency per data node, with one Elasticsearch Serverless node reaching 75 seconds" /><h3>Graphs of Total Cluster Ingest per Second, in Documents and MBs</h3><p>Ingest rate peaks at 190,000 documents / second and 54.40MB / second. The document ingest rate is respectable at 4000-5000 docs/sec per node. The MBs ingest rate, however, is very low in this case: this can happen when indexing operations involve heavy computation. Document ingestion rate can also vary depending on the size of the documents.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8cec5b62e8339a7f/6a969d7e37d7f35c438e9208/12.png" alt="Total indexing request rate for an Elasticsearch Serverless cluster, peaking at 190,000 documents per second" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9c147b7bd608b73/6a969dae5f9db76cd1560dc8/11.png" alt="Bulk byte indexing rate for an Elasticsearch Serverless cluster, peaking at 54.40 MB per second during the write spike" /><h2>What’s Next for Elasticsearch Serverless Balancing</h2><p>The team is currently working on shard balancing improvements for the Serverless Search Tier, focusing on creating metrics and <code>AllocationDecider</code> implementations for search performance. The team is excited to share these improvements soon!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-shard-balancing-serverless</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-shard-balancing-serverless</guid>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Dianna Hohensee]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltca84dead3c455000/6a969ba12707c589983290d7/1.png" length="0" type="image/png"/>
    <pubDate>Tue, 01 Sep 2026 15:25:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Dashboard activity logs: Find out which Kibana dashboards get used]]></title>
    <description><![CDATA[Kibana now logs who viewed, edited or deleted each dashboard, how long it took and what failed, so you can catch a broken dashboard before anyone reports it.]]></description>
    <content:encoded><![CDATA[<p>Kibana logs every dashboard view, edit, create, delete, and refresh, along with the user behind each one. Two lines in <code>kibana.yml</code> enable this logging. When you point Discover at the index, you can find the dashboards that nobody opened in 30 days and rank them by load time or see who edited the one that broke this morning. Dashboard <a href="https://www.elastic.co/docs/reference/kibana/user-activity">activity logs</a> run on self-managed clusters today, with Elastic Cloud support coming.</p><h2>How dashboard activity logs differ from the Kibana audit log</h2><p>Dashboard activity logs and the <a href="https://www.elastic.co/docs/reference/kibana/kibana-audit-events">Kibana audit log</a> both write structured logs about user actions, but they answer different questions.</p><p>
</p><p><strong>Kibana audit log</strong></p><p><strong>Dashboard activity logs</strong></p><p>Answers</p><p>Who accessed what, and when</p><p>Which dashboards are used, and how well they perform</p><p>Built for</p><p>Security and compliance teams</p><p>Kibana admins and dashboard owners</p><p>Tracks</p><p>Security-relevant events across Kibana</p><p>Five dashboard actions: create, update, delete, view, refresh</p><p>Enabled by</p><p>Its own setting in <code>kibana.yml</code></p><p><code>user_activity.enabled: true</code> in <code>kibana.yml</code></p><h2>What dashboard activity logs capture</h2><p>The user activity service records structured events every time a user interacts with a dashboard. Each event captures <em>what happened</em> and <em>to which dashboard</em>, in addition to <em>who did it</em>. Five actions are tracked:</p><p><strong>Action</strong></p><p><strong>Fires when</strong></p><p><strong>Includes duration</strong></p><p><code>dashboard_create</code></p><p>A dashboard is created</p><p>No</p><p><code>dashboard_update</code></p><p>An edit is saved</p><p>No</p><p><code>dashboard_delete</code></p><p>A dashboard is removed</p><p>No</p><p><code>dashboard_view</code></p><p>A user opens a dashboard</p><p>Yes, time on the dashboard until they navigate away</p><p><code>dashboard_refresh</code></p><p>A user changes filters or time range, or auto-refresh runs</p><p>Yes, refresh duration</p><p>Very often, a <code>dashboard_view</code> event also triggers a refresh.</p><p>Every event carries the same core fields, with two that appear conditionally:</p><p><strong>Field</strong></p><p><strong>What it holds</strong></p><p><strong>Present on</strong></p><p><code>user.name</code></p><p>Name of the user who performed the action</p><p>Every event</p><p><code>user.email</code></p><p>Email address of the user</p><p>Every event</p><p><code>user.roles</code></p><p>Roles assigned to the user</p><p>Every event</p><p><code>object.name</code></p><p>Dashboard name</p><p>Every event</p><p><code>object.id</code></p><p>Dashboard ID</p><p>Every event</p><p><code>kibana.space</code></p><p>Kibana space the dashboard belongs to</p><p>Every event</p><p><code>client.ip</code></p><p>IP address the request came from</p><p>Every event</p><p><code>event.action</code></p><p>Which of the five actions occurred</p><p>Every event</p><p><code>event.outcome</code></p><p>Whether the action succeeded or failed</p><p>Every event</p><p><code>event.duration</code></p><p>Time taken, in nanoseconds</p><p><code>dashboard_view</code> and <code>dashboard_refresh</code></p><p><code>error.type</code> / <code>error.message</code></p><p>Error class and message when something fails</p><p>Events where <code>event.outcome</code> is <code>failure</code></p><h2>How Kibana records dashboard activity</h2><p>Under the hood, Kibana plugins report events from the browser or server through a core client, and valid events are written to a dedicated logger. No data is sent to a third party; because events are standard JSON logs, you control where they go and how they’re ingested.</p><h2>What you can do with dashboard usage data</h2><p>Dashboard activity data answers five operational questions that would otherwise require further investigation:</p><ul><li><p><strong>Clean up unused dashboards.</strong> Filter for dashboards with zero <code>dashboard_view</code> events. If nobody's looking at it, archive it. This is critical for customers who are managing thousands of dashboards. </p></li><li><p><strong>Troubleshoot performance.</strong> The <code>event.duration</code> field tells you exactly how long each dashboard load or refresh takes. Sort by duration to find your slowest dashboards.</p></li><li><p><strong>Edit history.</strong> Every create, update, and delete is logged with the user who made the change. You no longer have to wonder who modified a critical dashboard or when it happened.</p></li><li><p><strong>Plan capacity.</strong> Identify users running heavyweight queries during peak hours. If one user's auto-refresh is hammering the cluster every 10 seconds, you'll see it.</p></li><li><p><strong>Monitor errors proactively.</strong> Dashboards throwing errors surface immediately through <code>error.type</code> and <code>error.message</code> fields, so you don’t need to wait for users to report them.</p></li></ul><h2>How to enable dashboard activity logs in Kibana</h2><p>Add two lines to your <code>kibana.yml</code> ( the service is disabled by default):</p>user_activity:
  enabled: true<p>Events will start flowing immediately using a default JSON console appender. You can customize the output appender and filter specific actions using the same logging configuration schema that Kibana already uses:</p>user_activity:
  enabled: true
  appenders:
    console_json_default_appender:
      type: console
      layout:
        type: json
  filters:
    - policy: keep
      actions: [dashboard_view, dashboard_refresh]<p>Ship these logs into an Elasticsearch index (for example, via Filebeat), and you have a fully queryable dataset of dashboard usage.</p><h2>How to query dashboard activity in Discover</h2><p>Once your activity logs are indexed, open Discover and point it at your user activity index pattern. You'll immediately see every dashboard interaction as a structured event, and they’re filterable by action type, user, dashboard name, and time range.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c49281e39a0301e/6a950fc6a3077ca19c3fe663/1.png" alt="Kibana Discover showing dashboard activity logs with event.action, object.name, duration and outcome columns" /><p>From here, you can quickly answer specific questions like the examples below. </p><h3>How many times was a dashboard viewed? </h3><p>Type your question in natural language in the Discover query editor, and press <strong>Cmd+J</strong> to automatically generate the Elasticsearch Query Language (ES|QL) query, as shown below:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt68dce42018ead590/6a9510d5a16336aae0371701/2.gif" alt="Generating an ES|QL query from plain language in Discover to search dashboard activity logs" /><h3>Which dashboards had zero views in the last 30 days?</h3><p>Dashboards with no activity simply don't appear in the logs, so you can't filter directly for zero views. Instead, this query works backward, pulling every dashboard created (and not deleted) in the past year and then checking which of those had zero views in the last 30 days.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta5fe21fc80d3e276/6a9510f5923082d373c4a4f0/3.png" alt="ES|QL query on dashboard activity logs listing 30 Kibana dashboards with zero views in the last 30 days" /><h3>Which dashboards took longer than 10 seconds to load?</h3><p>Note that <code>event.duration</code> is recorded in nanoseconds, so the query converts to seconds before filtering:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaa956eb37e5278e8/6a95113b6fe1457f2bb1bf89/4.png" alt="ES|QL query ranking slow Kibana dashboards by load time, topped by Host Metrics Overview at 69 seconds" /><h3>Which dashboards are throwing errors, and what's failing?</h3><p>This query shows dashboards with one or more panels throwing errors during <code>dashboard_refresh</code> events, so you can quickly spot recurring issues and prioritize fixes:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8911faf07ccd694f/6a951153ecbe18f7691e19c0/5.png" alt="Dashboard activity logs showing failed dashboard refreshes grouped by error, with 17 errors on one dashboard" /><h2>Build a dashboard usage overview with AI chat</h2><p>We’re planning to add out-of-the-box dashboards along with the activity logs, but in the meantime, instead of manually building visualizations, open the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/chat">AI chat</a> in Kibana and ask it to create a dashboard from your user activity data. </p><p>The generated dashboard gives you at-a-glance visibility into your most-viewed dashboards, heaviest users, slowest-performing panels, and recent errors; that is, exactly the operational view that large deployments need.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0734798e18f535af/6a9511928814aa03da89c1be/6.gif" alt="Building a dashboard usage overview from the user-activity-logs index using Agent Chat in Kibana" /><h2>Get started with dashboard activity logs</h2><p>User activity logs are available in Kibana 9.5. Begin by enabling the service. Then ship the logs into an index, and start building the operational visibility that your team has been asking for. For full configuration details and the complete event schema, see the <a href="https://www.elastic.co/docs/reference/kibana/user-activity">user activity documentation</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/dashboard-activity-logs-kibana</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dashboard-activity-logs-kibana</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[Operations]]></category>
    <category><![CDATA[Analytics]]></category>
    <dc:creator><![CDATA[Teresa Alvarez Soler,Rudolf Meijering]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt505cee76dae8eff8/6a950f70e657a3cdea75aeb7/image4.png" length="0" type="image/png"/>
    <pubDate>Mon, 31 Aug 2026 15:15:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Migrating 1,100 files to Redux Toolkit v2 without freezing the Kibana monorepo]]></title>
    <description><![CDATA[Kibana gave Redux Toolkit v2 the default package name and pushed v1 onto an explicit alias, which inverts the usual migration order. Webpack externals, yarn resolutions and an ESLint rule keep React Redux v7 and v9 out of each other's way.]]></description>
    <content:encoded><![CDATA[<p>We moved roughly 1,100 files in the Kibana monorepo onto <a href="https://github.com/elastic/kibana/pull/235577">Redux Toolkit (RTK) v2</a> aliases without asking a single plugin team to pause feature work. The usual migration pattern runs the other way around. Default package names (<code>@reduxjs/toolkit</code>, <code>react-redux</code>, <code>redux</code>) now resolve to v2, and existing v1 code sits behind explicit aliases, like <code>redux-toolkit-v1</code> and <code>react-redux-v7</code>. Both versions live in <code>node_modules</code> at once, kept apart at runtime by npm aliases and webpack module replacement. An ESLint rule scoped to 36 plugin paths catches anything that tries to cross. When a team is ready, it deletes its path from that list and switches back to the default imports, and the teams around it carry on shipping.</p><h2>Why upgrade to Redux Toolkit v2?</h2><p>RTK v2 was released in late 2023. That's nearly three years of running on a major version behind in one of the most widely used state management libraries in the JavaScript ecosystem. It reflects how hard this upgrade is in a codebase of Kibana's size. A <a href="https://github.com/elastic/kibana/pull/178986">previous attempt</a> tried the big-bang approach and stalled when the real scope became clearer. </p><p>So what does v2 actually bring? It ships alongside Redux core 5.0, React-Redux 9.0, Reselect 5.0, and Redux Thunk 3.0. React-Redux 9.0 requires React 18 and drops the <code>useSyncExternalStore</code> shim that v8 carried for React 16/17. Since Kibana already runs React 18, upgrading sheds legacy compatibility code and keeps Kibana on the actively maintained Redux majors.</p><p>RTK v2 also brings genuinely useful new features, including inline selectors in <code>createSlice</code> and opt-in inline async thunks through a customized <code>buildCreateSlice</code> setup, along with a <code>combineSlices</code> API with slice reducer injection for code splitting. That last one is particularly interesting for Kibana's plugin architecture where lazy-loading is the norm.</p><h2>How Redux is used across the Kibana monorepo</h2><p>Before diving into the solution, it's worth understanding just how varied Redux usage is across Kibana. A full audit of the codebase (tracked in <a href="https://github.com/elastic/kibana/issues/239863">#239863</a>) revealed several distinct camps:</p><p><strong>Pattern</strong></p><p><strong>Plugins and packages</strong></p><p><strong>What the migration needs</strong></p><p>Redux Toolkit v1</p><p>Discover, Lens, Synthetics, Security Solution</p><p>Full v1 to v2 migration</p><p>Plain Redux v4</p><p>Canvas, Maps, Index Management, Cross-Cluster Replication</p><p><code>redux-v4</code> alias only, no RTK migration</p><p>Kea</p><p>Enterprise Search (150+ files), Content Connectors</p><p><code>react-redux-v7</code> alias, no RTK migration</p><p><code>redux-saga</code></p><p>Synthetics, Graph, Uptime</p><p>Store setup only, saga is version-independent</p><p><code>typescript-fsa</code></p><p>Security Solution data-table package</p><p>Out of scope</p><p>Types and single imports</p><p>Expressions, Monitoring</p><p>Alias swap only</p><p>Plugins such as Discover, Lens, Synthetics, and Security Solution use RTK v1 APIs,including <code>createSlice</code>, <code>configureStore</code>, <code>createAsyncThunk</code>, and <code>createSelector</code>. These are the ones that actually need the v1 to v2 migration. But even here, complexity varies wildly. Lens uses stand-alone <code>getDefaultMiddleware</code> (removed in v2) and <code>PreloadedState</code> (also removed). Security Solution is the largest consumer at 300+ files, mixing modern RTK with legacy plain Redux patterns.</p><p>Canvas, Maps, Index Management, Cross-Cluster Replication, and several others still use plain Redux v4via <code>createStore</code>, <code>combineReducers</code>, <code>applyMiddleware</code>, and <code>connect</code>, which are classic patterns from the pre-RTK era. These don't need RTK migration at all since they're not using it  in the first place, but they do need the <code>redux-v4</code> alias since the default <code>redux</code> package is now v5.</p><p>Enterprise Search and Content Connectors use <code>kea</code>, a Redux abstraction layer with its own logic builders (<code>kea()</code>, <code>useValues</code>, <code>useActions</code>). There are more than 150 files in Enterprise Search alone. RTK migration isn't applicable here, since <code>kea</code> is its own world. But it <em>does</em> depend on <code>react-redux</code> v7 under the hood, which is where the bundler tricks come in.</p><p>Synthetics, Graph, and Uptime use <code>redux-saga</code> for side effects. Saga integration is actually independent of the RTK version, but these plugins need their store setup migrated.</p><p>The Security Solution data-table package uses <code>typescript-fsa</code> and <code>typescript-fsa-reducers</code> instead of RTK entirely, with its reducer embedded into Security Solution's main store, and isn’t part of the RTK migration at all.</p><p>The Expressions plugin only imports <code>shallowEqual</code> from <code>react-redux</code>, and Monitoring only imports types. These just need an alias swap.</p><p>Asking every team to migrate simultaneously was a nonstarter. The breaking changes in RTK v2 include stricter type checking and removed APIs, like <code>enableES5()</code> from immer, <code>getDefaultMiddleware</code> and <code>PreloadedState</code> gone entirely, <code>AnyAction</code> replaced by <code>UnknownAction</code>, and behavioral changes in how middleware is configured.</p><h2>Running Redux Toolkit v1 and v2 side by side</h2><p>The solution was to flip the typical migration pattern on its head. Instead of keeping the default imports on v1 and introducing v2 under aliases, the default package names (for example, <code>@reduxjs/toolkit</code>, <code>react-redux</code>, and <code>redux</code>) now point to v2. The old versions live under versioned aliases:</p><ul><li><p><code>redux-toolkit-v1</code></p></li><li><p><code>react-redux-v7</code></p></li><li><p><code>redux-v4</code></p></li><li><p><code>immer-v9</code></p></li><li><p><code>reselect-v4</code></p></li><li><p><code>redux-thunk-v2</code></p></li></ul>{
"@reduxjs/toolkit": "2.12.0",
"redux-toolkit-v1": "npm:@reduxjs/toolkit@1.9.7",
"react-redux": "9.2.0",
"react-redux-v7": "npm:react-redux@7.2.8"
}<p>This is npm's alias syntax. <code>"react-redux-v7": "npm:react-redux@7.2.8"</code> installs the old version under a different name. Both versions coexist in <code>node_modules</code> without conflicts.</p><p>The insight here is that all existing code in this pull request (PR) was moved to v1 aliases. Every <code>import { useSelector } from 'react-redux'</code> became <code>import { useSelector } from 'react-redux-v7'</code>. That's ~1,100 files touched, but the vast majority (~1,000) are mechanical one-liner import swaps. When a team is ready to migrate to v2, they switch back to the default import names. Once all v1 aliases disappear from the codebase, the old packages can be removed entirely.</p><p>This avoids the alternative, where v2 imports would end up under nonstandard names permanently, leaving nonstandard imports in the codebase for the long term.</p><h2>Serving both versions through the bundler</h2><p>Getting two versions of the same library to coexist at runtime is where things got interesting. Kibana uses <code>kbn-ui-shared-deps-npm</code> to bundle common dependencies as shared webpack externals. This needed to serve both the new v2 packages <em>and</em> the v1 aliases so that both are available at runtime.</p><h3>Pinning @elastic/charts with yarn resolutions</h3><p>Then there's <code>@elastic/charts</code>. It depends on RTK v1 internally and can't just be upgraded independently since it's an upstream package. Yarn resolutions pin its nested dependencies to v1 versions:</p>{
"@elastic/charts/@reduxjs/toolkit": "npm:@reduxjs/toolkit@1.9.7"
}<p>A <code>NormalModuleReplacementPlugin</code> in the shared deps webpack config detects when an import of <code>immer</code>, <code>@reduxjs/toolkit</code>, <code>redux</code>, <code>react-redux</code>, or <code>reselect</code> originates from within <code>@elastic/charts</code> and redirects resolution to the nested v1 copies. This ensures that <code>@elastic/charts</code> resolves to its compatible v1 dependency set.</p><h3>Keeping Kea on React Redux v7 with webpack externals</h3><p>The <code>kea</code> library was another fun case. It declares <code>react-redux</code> as a peer dependency (<code>&gt;= 7</code>), so without special handling its imports resolve to Kibana's default v9 package. The migration keeps Kea consumers on <code>react-redux-v7</code>, so Kea must use that same React context. The fix uses function-based webpack/rspack externals that skip externalizing <code>react-redux</code> when the import comes from <code>node_modules/kea</code>, combined with a <code>NormalModuleReplacementPlugin</code> that rewrites it to <code>react-redux-v7</code>. This ensures that <code>kea</code> uses the v7 React context that matches the <code>&lt;Provider&gt;</code> wrapping its consumers.</p><p>Both the webpack (<code>kbn-optimizer</code>) and the rspack (<code>kbn-rspack-optimizer</code>) configs needed these changes, with a shared <code>isKeaReactReduxImport</code> helper extracted to keep the logic consistent.</p><h2>Using an ESLint rule to prevent cross-version imports</h2><p>With two versions available, accidental cross-version imports are the biggest risk. A new <code>@kbn/imports/no_redux_toolkit_v2_imports</code> ESLint rule catches any import of the v2 default packages (such as <code>@reduxjs/toolkit</code>, <code>react-redux</code>, or <code>redux</code>, among others) in code that hasn't been migrated yet. It even auto-fixes them to the v1 aliases for file imports and Jest mock paths.</p><p>The rule is scoped via an override in <code>.eslintrc.js</code> to the ~36 plugin and package paths currently using v1. When a team migrates, they simply remove their path from the override list. This clean, self-service approach requires no coordination.</p>// .eslintrc.js (simplified)
overrides: [{
  files: [
'src/platform/plugins/shared/discover/**/*.{ts,tsx}',
'src/platform/plugins/shared/workflows_management/**/*.{ts,tsx}',
// ... 34 more paths
],
  rules: {
'@kbn/imports/no_redux_toolkit_v2_imports': 'error',
  },
}]<h2>Why mixing React Redux v7 and v9 breaks the context</h2><p>This is worth calling out because it's an easy failure mode to miss during an upgrade. <code>react-redux</code> v9 and v7 create separate React contexts. If a component tree has a v9 <code>&lt;Provider&gt;</code> at the top but a child component calls <code>useSelector</code> from v7 (or vice versa), React-Redux cannot find the matching context. In development, it throws an error explaining that the component must be wrapped in a matching <code>&lt;Provider&gt;</code>; in production, the missing context causes a runtime error when the hook accesses the store.</p>Error: could not find react-redux context value; please ensure the component is wrapped in a &lt;Provider&gt;<p>This means that each plugin needs to be explicitly pinned to one version. Shared packages that use <code>react-redux</code> can only be consumed by code on the same version, since mixing isn't possible. This is a constraint that makes the migration inherently per plugin rather than per file.</p><h2>Migration batches: What can move independently</h2><p>The dual-version setup gives every team a clear path forward, and the dependency graph analysis from the tracking issue identified natural migration batches:</p><ul><li><p><strong>Batch 1: Independent, self-contained stores.</strong> Packages like <code>kbn-coloring</code>, <code>transform</code>, <code>timelines</code>, and <code>expandable-flyout</code> have fully internal Redux stores with no types leaking through their public APIs. These can be migrated independently by their owning teams, with minimal risk.</p></li></ul><ul><li><p><strong>Batch 2: Coupled packages.</strong> Some packages share RTK types across boundaries and <em>must</em> migrate together. The machine learning (ML)/artificial intelligence for IT operations (AIOps) chain is one example: <code>@kbn/ml-response-stream</code> exports a <code>streamSlice</code> (a <code>createSlice</code> return value) that <code>@kbn/aiops-log-rate-analysis</code> embeds directly into its <code>configureStore</code>. Migrating one without the other causes type mismatches between v1 and v2 slice types. Similar coupling exists across the Lens ecosystem. The Lens plugin depends on <code>@kbn/coloring</code> (which has its own RTK store), <code>@kbn/lens-embeddable-utils</code>, and <code>@kbn/lens-common</code>, while itself being consumed by 40+ packages and plugins across chart expressions, visualizations, Maps, Canvas, and observability plugins. Whether Redux types leak through a package's public API determines if it can be migrated independently or needs coordination. <code>kbn-coloring</code>'s store is internal to its React components so it's safe to migrate alone, but other coupling points need careful analysis.</p></li></ul><ul><li><p><strong>Batch 3+: The big ones.</strong> Discover, Security Solution, and Lens each have their own migration timelines. Security Solution's 300+ files and mix of RTK with plain Redux v4 and <code>typescript-fsa</code> make it the largest effort, but the different patterns can be addressed independently. Lens has the trickiest v2 breaking changes around middleware configuration; stand-alone <code>getDefaultMiddleware</code> and <code>PreloadedState</code> are both removed in v2, and it has four custom middleware files with complex typing.</p></li></ul><p>Beyond the batched migrations:</p><ul><li><p><strong>Deprecated features</strong> can stay on v1 aliases. When the feature is removed, the v1 imports disappear through code deletion, without any migration work.</p></li><li><p><strong>Plain Redux v4 plugins</strong> (Canvas, Maps, and others) are entirely out of scope for RTK migration. They'd benefit from modernization, but that's a separate initiative.</p></li><li><p><strong>Kea plugins</strong> need <code>react-redux-v7</code> to <code>react-redux</code> alias updates eventually, but no RTK migration. The longer-term question (whether to keep Kea or migrate to RTK v2) is a separate decision.</p></li><li><p><strong>The dual-version approach</strong> adds measurable bundle overhead during the transition. It’s a trade-off but is acceptable for the migration period.</p></li></ul><h2>Lessons for other large monorepo upgrades</h2><p>The ESLint rule turned out to be the linchpin. Without automated enforcement, aliased imports would drift back to default names within weeks. With it, the migration state is visible in the paths listed in the override. As of the initial PR, zero files import from <code>@reduxjs/toolkit</code> v2. Every RTK usage goes through the <code>redux-toolkit-v1</code> alias. That's the starting line.</p><p>The preparation work also reached beyond import paths. Jest mocks referencing <code>react-redux</code> needed updating to <code>react-redux-v7</code>, as did Storybook previews, test helpers, and ambient type declarations. Multiple rounds of <code>node scripts/eslint_all_files --no-cache --fix</code> caught the mechanical cases; the remaining cases needed manual fixes.</p><p>If you're facing a similar major dependency upgrade in a large monorepo, the pattern of giving the new version the default name and the old version an explicit alias is worth considering. New code naturally uses the current version, while older usage stays visible and trackable until it reaches zero.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/redux-toolkit-v2-migration-kibana-monorepo</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/redux-toolkit-v2-migration-kibana-monorepo</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Developer Experience]]></category>
    <category><![CDATA[Kibana]]></category>
    <dc:creator><![CDATA[Walter Rafelsberger]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3fa3c79a7e246c89/6a9111235c3126655043df06/unnamed.png" length="0" type="image/png"/>
    <pubDate>Fri, 28 Aug 2026 15:20:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Know your facts: How Elasticsearch AI Indices let agents skip the reading and keep the answer]]></title>
    <description><![CDATA[A technical walkthrough of precomputing facts into an Elasticsearch AI Index, so agents answer from a single ES|QL query instead of reading whole documents, with fewer tokens and lower latency.]]></description>
    <content:encoded><![CDATA[<p>Pulling whole documents into an agent's context to answer one question is expensive, and the cost compounds with every miss. In this walkthrough, we precompute the facts instead. A Kibana workflow distills each document into a fact-level Knowledge Indicator (KI), stored in an Elasticsearch AI Index and retrieved with a single Elasticsearch Query Language (ES|QL) query. On the same question, an agent answering from KIs reached the same grounded answer using fewer tokens and lower latency than reading raw documents, without loading a single full document into context. These facts are precomputed once and then stored for use by future agents when they encounter similar queries. This is Part 2 of our series on building context with AI indices; <a href="https://www.elastic.co/search-labs/blog/ai-index-building-context-agents">Part 1</a> covered routing agents to the right index.</p><p>Managing context depends on good retrieval. Rather than have agents rediscover the same content for every question, burning tokens by retracing similar steps over and over again, Elastic’s agentic AI capabilities enable us to precompute these details and store them in a structured, searchable form, and they let agents load that context directly. We call this precomputed unit of context a Knowledge Indicator.</p><p>The default agentic retrieval augmented generation (RAG) pattern does the opposite. It retrieves whole documents and dumps them into the model's context at query time, paying for that retrieval in tokens and latency on every single question. Precomputing the answer as a KI moves that cost out of the hot path and does it once.</p><h2>How it works: AI Index, Kibana Workflows, and the query-ki skill</h2><p>Building context through AI indices has three main parts: the AI Index (a special Elasticsearch index where KIs live), Kibana Workflows to create your KIs, and a <code>query-ki</code> skill to help agents directly query KIs using ES|QL: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt201b0bf84c5f5002/6a8fef16ecdaa77015050aa9/unnamed.png" alt="AI Index architecture: Kibana Workflows write Knowledge Indicators, agents read them via the query-ki ES|QL skill" /><p>This blog post is similar to Part 1 in that we’re using the same core building blocks. But in this post, we’re demonstrating a very different use case. Instead of precomputing index metadata, we’re distilling specific <em>facts</em> from our indexed documents that may be used to directly answer agents’ questions without subsequent searches. We've also provided a <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/precomputed-context-technical-walkthrough-part-2/index-facts-kis.ipynb">notebook</a>, if you'd like to create the same KIs yourself, end to end, as you go through these examples. </p><h3>Prerequisites: Elasticsearch Serverless and an LLM API key</h3><p>This tutorial assumes you have:</p><ol><li><p>An Elasticsearch Serverless project. You can <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">sign up for a trial</a> if you don't have one.</p></li><li><p>An API key to access your Elasticsearch project.</p></li><li><p>An OpenAI-compatible large language model (LLM) API key, to access AI indices via Deep Agents scripts.</p></li></ol><h2>Load the BrowseComp-Plus sample corpus into Elasticsearch</h2><p>First, we’ll need some sources. Sources can be data that already exists in your Elasticsearch indices or external data accessed via connectors or ES|QL data sources. 
For this blog, we’ll create an index, <code>browsecomp-plus</code>, to hold our example data, with the following mappings:</p>{
  "browsecomp-plus": {
    "mappings": {
      "_meta": {
        "description": "BrowseComp-Plus corpus: ~100k human-verified web documents (news articles, Wikipedia entries, institutional pages) used as a reasoning-intensive browsing/QA retrieval benchmark. BM25-only index."
      },
      "properties": {
        "docid": {
          "type": "keyword",
          "meta": {
            "description": "Stable corpus document id."
          }
        },
        "text": {
          "type": "text",
          "meta": {
            "description": "Full document text: title, date, and body content."
          }
        },
        "title": {
          "type": "text",
          "meta": {
            "description": "Document title (from the document's front matter)."
          }
        },
        "url": {
          "type": "keyword",
          "meta": {
            "description": "Source URL the document was crawled from."
          }
        }
      }
    }
  }
}<p>and populate it with a small sample of BrowseComp-Plus data via the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"><code>_bulk</code> API</a>. You can use the supporting <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/precomputed-context-technical-walkthrough-part-2/index-facts-kis.ipynb">notebook</a> to load a sample of this data in your project. </p><h2>Create the AI Index that stores your KIs</h2><p>Just like in Part 1, the first step is to create an AI Index:</p>PUT ai-index-idx-my-corpus<p>This is preconfigured with the same required mappings as we listed out in Part 1. We perform hybrid search here using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> out of the box.</p><h2>How agents retrieve KIs using ES|QL</h2><p>A KI is a document in the AI Index. What makes KIs useful is <em>retrieval</em>, or querying the AI Index to find the right content. This query is packaged within a small, portable skill that’s harness-agnostic and can be run in any agent harness. </p><p>Here’s a sample <code>query-ki</code> skill:</p> ---
name: query-ki
description: &gt;-
  Retrieve Knowledge Indicators (precomputed context) from the Elasticsearch AI
  Index before answering. Use it to find which index to search (routing profiles)
  or to look up precomputed facts without reading source documents. Trigger on any question that depends on specific facts, names, dates, or on choosing a data source.
allowed-tools: esql_query
---

# Retrieving Knowledge Indicators

Knowledge Indicators (KIs) live in Elasticsearch indices named <code>ai-index-*</code>.
Retrieve them by calling the <code>esql_query</code> tool with the query below. Substitute
the user's question for <code>&lt;query&gt;</code>, and <code>corpus_entry</code> as the <code>&lt;ki_type&gt;</code> for facts.

```esql
FROM ai-index-idx-* METADATA _id, _index, _score
| WHERE type == "&lt;ki_type&gt;"
| FORK
    (WHERE MATCH(content, "&lt;query&gt;") OR MATCH(description, "&lt;query&gt;")
     | SORT _score DESC | LIMIT 20)
    (WHERE MATCH(content.semantic, "&lt;query&gt;") OR MATCH(description.semantic, "&lt;query&gt;")
     | SORT _score DESC | LIMIT 20)
| FUSE
| SORT _score DESC
| KEEP title, content, description, tags
| LIMIT 5
```

Ground your answer in what the query returns, and cite the KI titles you used. If
nothing relevant comes back, say so rather than guessing.<p>Save this as<code>skills/query-ki/SKILL.md</code>.</p><p>Here’s what this skill is doing: </p><ul><li><p>We’re defining <code>corpus_entry</code> as our KI use case.</p></li><li><p>We’re performing a hybrid ES|QL search on our AI indices, filtering by the appropriate <code>type</code>, using reciprocal rank fusion (RRF) as the default method to fuse results.</p></li><li><p>The KI results will directly ground the agent’s answer when determining what facts are relevant to the users’ query.</p></li></ul><p>When we say that AI indices and KIs are <em>harness-agnostic</em>, it’s because the skill is just instructions plus a query. It will work in Elastic Agent Builder, a Kibana workflow agent, Claude Code, or any other harness. We’ll be using Deep Agents for examples of how to query it outside the Kibana ecosystem. Since an AI Index is, at its core, an Elasticsearch index, you can also explore your data directly. </p><h2>Precompute facts as KIs for agentic RAG</h2><p>In this example, we extract actual facts so agents can retrieve an answer without consuming a full document. We generate one fact-based KI per selected document, though the actual number and structure of KIs you generate are completely customizable.</p><p>We'll use a sample of the <a href="https://github.com/texttron/BrowseComp-Plus">BrowseComp-Plus</a> corpus, indexed into a <code>browsecomp-plus</code> index, with <code>docid</code>, <code>url</code>, <code>title</code>, and <code>text</code> fields.</p><h3>Baseline: Retrieving whole documents with RRF</h3><p>As a baseline, here's a simple <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">RRF</a> query:</p>POST /_query?format=txt
{
  "query": """
    FROM browsecomp-plus METADATA _score, _id, _index
    | FORK
        (WHERE match(title, "What was the actress who played Torvi from Vikings also known for?") | SORT _score DESC | LIMIT 100)
        (WHERE match(text,  "What was the actress who played Torvi from Vikings also known for?") | SORT _score DESC | LIMIT 100)
    | FUSE // uses RRF by default
    | SORT _score DESC
    | KEEP _id, title, text
    | LIMIT 10
  """
}<p>This drops several hundred words of raw body text into the model's context. It may work, but it's expensive, and the cost compounds with every miss.</p><h3>Build the Kibana workflow</h3><p>The workflow below reads a batch of documents with a single ES|QL query and writes one fact-level KI per document into the AI Index. Each iteration runs two steps: <code>generate_ki</code> distills a raw document into a structured KI, and <code>sink_ki</code> writes it to the AI Index keyed on <code>docid</code> so reruns are idempotent.</p><p>Copy and paste the following YAML into the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a> editor:</p>version: '1'
name: browsecomp-plus-doc-ki
description: Query the BrowseComp-Plus corpus with ES|QL, generate a KI per doc with an AI agent, and bulk-write each into the AI Index as a corpus_entry.
enabled: true
tags:
  - precomputed-context
  - browsecomp-plus
triggers:
  - type: manual
steps:
  - name: query_corpus
    type: elasticsearch.esql.query
    with:
      # WHERE drops empty bodies and restricts to the curated KI_DOCIDS -- the
      # specific documents this example's question depends on -- so the workflow
      # generates only a handful of KIs instead of one per corpus document.
      # SUBSTRING keeps the prompt bounded (a full body would blow the context window).
      # Column order drives the foreach.item[N] indices:
      #   item[0]=docid  item[1]=title  item[2]=url  item[3]=text
      query: &gt;
        FROM browsecomp-plus
        | WHERE text IS NOT NULL AND docid IN ("11589", "50639", "64501", "41758", "57766", "84983", "82008")
        | KEEP docid, title, url, text
        | EVAL text = SUBSTRING(text, 1, 12000)

  - name: loop_corpus_docs
    type: foreach
    foreach: '{{ steps.query_corpus.output.values }}'
    steps:
      # Turn the raw doc into a retrieval-optimized Knowledge Indicator.
      - name: generate_ki
        type: ai.agent
        timeout: 300s
        with:
          message: &gt;
            You are a knowledge engineer building a Knowledge Indicator (KI)
            for an enterprise document-retrieval corpus. A KI is a compact,
            high-signal record that a hybrid (BM25 + semantic) search engine
            and an AI agent use to FIND and JUDGE the source document without
            reading it in full.

            Read the document below and extract a faithful, richly structured KI.
            Follow these rules strictly:
            - Be 100% grounded: never state anything not supported by the text.
            - Prefer concrete, named specifics (people, organizations, products,
              dates, places, figures) over vague phrasing.
            - Write for retrieval, not prose flourish. No marketing language.
            - If a field cannot be determined from the text, return an empty
              string or empty array rather than guessing.

            Document ID: {{ foreach.item[0] }}
            Original Title: {{ foreach.item[1] }}
            Source URL: {{ foreach.item[2] }}
            Document Body:
            {{ foreach.item[3] }}
          schema:
            type: object
            properties:
              title:
                type: string
                description: A concise, specific, human-readable title (&lt;= 12 words).
              summary:
                type: string
                description: A dense 3-5 sentence factual summary capturing the document's main claims, named entities, and conclusions. PRIMARY semantic search surface.
              answers_questions:
                type: array
                items:
                  type: string
                description: 2-5 natural-language questions this document can authoritatively answer.
              key_entities:
                type: array
                items:
                  type: string
                description: 3-10 salient named entities (people, organizations, products, places, dates) explicitly mentioned in the text.
              topics:
                type: array
                items:
                  type: string
                description: 3-8 short topic/category labels.
              tagline:
                type: string
                description: A single ultra-short phrase (&lt;= 6 words) as a quick-reference label.
            required:
              - title
              - summary
              - answers_questions
              - key_entities
              - topics

      # Direct bulk write to the AI Index. The explicit <code>index</code> action row sets
      # _id = docid so re-runs upsert in place (idempotent). <code>index:</code> in <code>with</code>
      # supplies the default target index for the bulk request.
      - name: sink_ki
        type: elasticsearch.bulk
        with:
          index: ai-index-idx-my-corpus
          operations:
            - index:
                _id: '{{ foreach.item[0] }}'
            - '@timestamp': '{{ execution.startedAt | date: "%Y-%m-%dT%H:%M:%S.%LZ" }}'
              type: corpus_entry
              title: '{{ foreach.item[1] | default: steps.generate_ki.output.structured_output.title }}'
              tags:
                - browsecomp-plus
              references:
                uri: '{{ foreach.item[2] }}'
              attributes:
                docid: '{{ foreach.item[0] }}'
                url: '{{ foreach.item[2] }}'
                source_index: browsecomp-plus
                tagline: '{{ steps.generate_ki.output.structured_output.tagline }}'
                topics: '{{ steps.generate_ki.output.structured_output.topics | json }}'
                answers_questions: '{{ steps.generate_ki.output.structured_output.answers_questions | json }}'
                key_entities: '{{ steps.generate_ki.output.structured_output.key_entities | json }}'
              content: &gt;
                === SOURCE / PROVENANCE ===
                Backing Elasticsearch index: browsecomp-plus
                Document ID (docid): {{ foreach.item[0] }}
                Source URL: {{ foreach.item[2] }}
                Retrieve the full original document with ES|QL:
                FROM browsecomp-plus | WHERE docid == "{{ foreach.item[0] }}"
                === KNOWLEDGE INDICATOR ===
                {{ steps.generate_ki.output.structured_output.summary }}
                Questions this document answers: {{ steps.generate_ki.output.structured_output.answers_questions | join: " | " }}
                Key entities: {{ steps.generate_ki.output.structured_output.key_entities | join: ", " }}
              description: &gt;
                {{ steps.generate_ki.output.structured_output.tagline }}.
                Topics: {{ steps.generate_ki.output.structured_output.topics | join: ", " }}.
                Entities: {{ steps.generate_ki.output.structured_output.key_entities | join: ", " }}.<p>Here’s what this workflow is doing: </p><ul><li><p><code>query_corpus</code> runs an ES|QL query against the <code>browsecomp-plus</code> index, applying some rules, like dropping documents with empty bodies and trimming each body to 12,000 chars so the agent prompt stays inside the context window.</p></li><ul><li><p>Note: In this example, we’re cherry-picking some concrete KI IDs, because generating KIs for every document in the index would take a long time, and we want this exercise to be short for those following along.</p></li></ul><li><p><code>loop_corpus_docs</code> iterates over every returned document, running the following two steps per document: </p></li><ul><li><p><code>generate_ki</code> reads the document and calls an LLM to emit a strictly grounded, structured KI.</p></li><li><p><code>sink_ki</code> bulk-writes each KI into the AI Index (<code>ai-index-idx-my-corpus</code>) as a KI of type <code>corpus_entry</code>. It forces <code>_id</code> to be the same as the document’s <code>docid</code> so rerunning the workflow is idempotent.</p></li></ul></ul><p>To summarize, this workflow turns each raw corpus document into a compact, searchable metadata record that agents can find and judge without reading the full source into the context window.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a37c99585270959/6a8ff23da1b20b401c8728c7/unnamed.png" alt="Kibana Workflow browsecomp-plus-doc-ki: query_corpus, generate_ki and sink_ki write a corpus_entry KI to the AI Index" /><p>This workflow is used for example purposes, and the same <code>foreach</code> caveat as in Part 1 applies. For scale, use <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/composition"><code>workflow.executeAsync</code></a> or native parallel support. The <a href="https://www.elastic.co/docs/explore-analyze/workflows/reference/cheat-sheet">cheat sheet</a> is useful for optimizing Workflows. There could also be cost and efficiency gains in production by using <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/ai-steps#ai-prompt"><code>ai.prompt</code></a> or by choosing different models with which to create KIs. </p><h3>Inspect the KIs in your AI Index</h3><p>Once the workflow runs, you can query the AI Index to browse what was written:</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt626b3ba4dc3a52c0/6a8ff268971ef9107f537cb5/unnamed.png" alt="ES|QL query in Kibana Discover returning five corpus_entry Knowledge Indicators from an Elasticsearch AI Index" /><p>Here’s an example of what one of the KI documents looks like: </p>{
  "_index": "ai-index-idx-my-corpus",
  "_id": "57766",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "@timestamp": "2026-08-05T20:35:39.034Z",
    "type": "corpus_entry",
    "title": "Vikings (TV series) - Wikipedia",
    "tags": [
      "browsecomp-plus"
    ],
    "references": {
      "uri": "https://en.wikipedia.org/wiki/Vikings_%28TV_series%29"
    },
    "attributes": {
      "docid": "57766",
      "url": "https://en.wikipedia.org/wiki/Vikings_%28TV_series%29",
      "source_index": "browsecomp-plus",
      "tagline": "Ragnar Lothbrok's rise and legacy",
      "topics": """["Historical drama television","Viking Age","Norse mythology and sagas","Canadian-Irish co-production","Television cast and production","Medieval Scandinavia"]""",
      "answers_questions": """["When did the Vikings TV series premiere and on which network?","Who created and wrote the Vikings TV series?","Where was the Vikings TV series filmed?","Who are the main cast members of Vikings?","What historical and literary sources inspired the Vikings TV series?"]""",
      "key_entities": """["Michael Hirst","Travis Fimmel","Katheryn Winnick","History Channel","Amazon Prime Video","Ashford Studios","County Wicklow, Ireland","Vikings: Valhalla","Ragnar Lodbrok","Wardruna"]"""
    },
    "content": """=== SOURCE / PROVENANCE === Backing Elasticsearch index: browsecomp-plus Document ID (docid): 57766 Source URL: https://en.wikipedia.org/wiki/Vikings_%28TV_series%29 Retrieve the full original document with ES|QL: FROM browsecomp-plus | WHERE docid == "57766" === KNOWLEDGE INDICATOR === Vikings is a historical drama television series created and written by Michael Hirst, co-produced between Canada and Ireland, that premiered on the History Channel on March 3, 2013, and concluded on March 3, 2021, after 6 seasons and 89 episodes. The series is inspired by the sagas of legendary Norse hero Ragnar Lodbrok — drawing on 13th-century texts Ragnars saga Loðbrókar and Ragnarssona þáttr, as well as Saxo Grammaticus' Gesta Danorum — and follows Ragnar's rise from farmer to Scandinavian king, then the exploits of his sons across England, Scandinavia, Kievan Rus', the Mediterranean, and North America. Principal cast includes Travis Fimmel as Ragnar Lothbrok, Katheryn Winnick as Lagertha, Gustaf Skarsgård as Floki, and Alexander Ludwig as Bjorn Ironside, among many others. The series was filmed entirely in Ireland at Ashford Studios and County Wicklow, with additional location shoots in Iceland, Morocco, Norway, and Canada; the first season budget was US$40 million. A sequel series, Vikings: Valhalla, premiered on Netflix on February 25, 2022. Questions this document answers: When did the Vikings TV series premiere and on which network? | Who created and wrote the Vikings TV series? | Where was the Vikings TV series filmed? | Who are the main cast members of Vikings? | What historical and literary sources inspired the Vikings TV series? Key entities: Michael Hirst, Travis Fimmel, Katheryn Winnick, History Channel, Amazon Prime Video, Ashford Studios, County Wicklow, Ireland, Vikings: Valhalla, Ragnar Lodbrok, Wardruna
""",
    "description": """Ragnar Lothbrok's rise and legacy. Topics: Historical drama television, Viking Age, Norse mythology and sagas, Canadian-Irish co-production, Television cast and production, Medieval Scandinavia. Entities: Michael Hirst, Travis Fimmel, Katheryn Winnick, History Channel, Amazon Prime Video, Ashford Studios, County Wicklow, Ireland, Vikings: Valhalla, Ragnar Lodbrok, Wardruna.
"""
  }
}<h3>Query KIs from LangChain Deep Agents</h3><p>We’ll use <a href="https://docs.langchain.com/oss/python/deepagents/overview">LangChain Deep Agents</a> with an OpenAI-compatible key to show that AI indices and KIs will work with any agent harness, inside and outside of Kibana’s Agent Builder ecosystem. </p><p>First, let’s create <code>facts_baseline_agent.py</code> to measure our baseline before applying KIs: </p># Example question: What was the actress who played Torvi from Vikings also known for?
import os
import sys
import time
from elasticsearch import Elasticsearch

from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent

if len(sys.argv) &lt; 2:
    sys.exit(f'Usage: python {sys.argv[0]} "your question"')

es = Elasticsearch(os.environ["ES_URL"], api_key=os.environ["ES_API_KEY"])


@tool
def esql_query(query: str) -&gt; list[dict] | str:
    """Execute an ES|QL query against Elasticsearch and return the matching rows.

    Args:
        query: A complete ES|QL query string, e.g. 'FROM browsecomp-plus | LIMIT 5'.
               Full-text search syntax: WHERE MATCH(field, "value") — not field MATCH "value".
    """
    try:
        resp = es.esql.query(query=query, format="json")
        cols = [c["name"] for c in resp["columns"]]
        return [dict(zip(cols, row)) for row in resp["values"]]
    except Exception as e:
        return f"ES|QL error: {e}"


@tool
def get_mapping(index: str) -&gt; dict:
    """Return the field mapping for an Elasticsearch index or pattern."""
    return es.indices.get_mapping(index=index).body


baseline_agent = create_deep_agent(
    model=ChatOpenAI(  # any OpenAI-compatible endpoint; configure via LLM_* env vars
        base_url=os.environ.get("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
        model=os.environ.get("LLM_MODEL", "anthropic/claude-sonnet-4.5"),
        api_key=os.environ["LLM_API_KEY"],
    ),
    tools=[esql_query, get_mapping],  # no query-ki skill
    system_prompt=(
        "You are a research assistant answering questions about a document corpus "
        "stored in the Elasticsearch index <code>browsecomp-plus</code> (fields: docid, url, "
        "title, text). You have NOT memorized the corpus. Answer by querying the raw "
        "index directly with ES|QL via the esql_query tool. "
        "Full-text search syntax: WHERE MATCH(field, \"value\") — never use field MATCH \"value\". "
        "Use get_mapping if you are unsure of field names. Ground your answer strictly "
        "in the rows returned, and cite the docid or url you used."
    ),
)

start = time.perf_counter()
result = baseline_agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": sys.argv[1],
            }
        ]
    }
)
latency = time.perf_counter() - start

print("\n--- Tool calls ---")
for m in result["messages"]:
    if isinstance(m, AIMessage) and m.tool_calls:
        for tc in m.tool_calls:
            print(f"  [{tc['name']}] {str(tc['args'])[:120]}")
total = sum(
    len(m.tool_calls)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.tool_calls
)
print(f"Total: {total}\n")

print("--- Usage ---")
input_tokens = sum(
    (m.usage_metadata or {}).get("input_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
output_tokens = sum(
    (m.usage_metadata or {}).get("output_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
print(f"Tokens: {input_tokens + output_tokens} (input {input_tokens}, output {output_tokens})")
print(f"Latency: {latency:.2f}s\n")

print("--- Answer ---")
print(result["messages"][-1].content)<p>When I ran this baseline query, <em>What was the actress who played Torvi from Vikings also known for?</em>, it output the following: </p>% python facts_baseline_agent.py "What was the actress who played Torvi from Vikings also known for?"

--- Tool calls ---
  [esql_query] {'query': 'FROM browsecomp-plus | WHERE MATCH(text, "Torvi Vikings actress") | LIMIT 10'}
  [esql_query] {'query': 'FROM browsecomp-plus | WHERE MATCH(text, "Georgia Hirst") | LIMIT 10'}
  [read_file] {'file_path': '/large_tool_results/toolu_bdrk_01SVEwHdtc9uE1zdfgCwchb5', 'offset': 0, 'limit': 50}
  [esql_query] {'query': 'FROM browsecomp-plus | WHERE MATCH(text, "Torvi Vikings TV") | LIMIT 10'}
  [esql_query] {'query': 'FROM browsecomp-plus | WHERE MATCH(text, "Vikings series cast actress") | LIMIT 10'}
  [esql_query] {'query': 'FROM browsecomp-plus | WHERE MATCH(text, "Michael Hirst Vikings") | LIMIT 10'}
  [esql_query] {'query': 'FROM browsecomp-plus | WHERE MATCH(text, "Torvi") | LIMIT 20'}
  [read_file] {'file_path': '/large_tool_results/toolu_bdrk_01PS3ryzML5BstqMXnV4c65N', 'offset': 0, 'limit': 100}
Total: 8

--- Usage ---
Tokens: 386187 (input 384940, output 1247)
Latency: 44.86s

--- Answer ---
Perfect! I found the information. Based on the document from docid 11589 (Georgia Hirst's Wikipedia page), I now have the answer to the question.

**Georgia Hirst**, the actress who played Torvi in the History Channel series Vikings (2014-2020), is also known for:

1. **Her role as Becky in the zombie horror movie "Ravers" (2018)** - This film had its world premiere at the 2018 London FrightFest Film Festival.

2. **Being the daughter of Michael Hirst**, the creator and writer of Vikings. This is a notable connection that's frequently mentioned about her.

3. **Her role in the short film "Dungeness" (2018)**, directed by Stephen Gates, where she was the sole actor.

4. **Her role as Grace in the interactive rom-com "Five Dates" (2020)**.

5. **Her advocacy work** - In 2017, she made an appearance on Sky News urging young women to get smear tests after being diagnosed with precancerous cells at age 22.

6. **Her philanthropy** - She joined The Celebs in 2021 to record a cover of The Beatles' "Let It Be" in support of the Mind charity.

So while she's primarily known for her role as Torvi in Vikings (where she appeared in 63 episodes across seasons 2-6), she's also recognized for her work in horror films like "Ravers," her health advocacy, and being part of the Hirst family that created the show.<p>(Note: Deep Agents automatically adds the <code>read_file</code> tool to handle paginated tool results, which is why it shows up in the output.) </p><p>Next, let’s create an agent that knows how to use our <code>query-ki</code> skill, <code>facts_ki_agent.py</code>: </p># Example question: What was the actress who played Torvi from Vikings also known for?
import os
import sys
import time
from elasticsearch import Elasticsearch
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent
from deepagents.backends.filesystem import FilesystemBackend

if len(sys.argv) &lt; 2:
    sys.exit(f'Usage: python {sys.argv[0]} "your question"')

es = Elasticsearch(os.environ["ES_URL"], api_key=os.environ["ES_API_KEY"])


@tool
def esql_query(query: str) -&gt; list[dict] | str:
    """Execute an ES|QL query against Elasticsearch and return the matching rows.

    Args:
        query: A complete ES|QL query string, e.g. 'FROM ai-index-idx-* | LIMIT 5'.
    """
    try:
        resp = es.esql.query(query=query, format="json")
        cols = [c["name"] for c in resp["columns"]]
        return [dict(zip(cols, row)) for row in resp["values"]]
    except Exception as e:
        return f"ES|QL error: {e}"


# FilesystemBackend loads skills from disk, relative to root_dir.
backend = FilesystemBackend(root_dir=".", virtual_mode=False)

agent = create_deep_agent(
    model=ChatOpenAI(  # any OpenAI-compatible endpoint; configure via LLM_* env vars
        base_url=os.environ.get("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
        model=os.environ.get("LLM_MODEL", "anthropic/claude-sonnet-4.5"),
        api_key=os.environ["LLM_API_KEY"],
    ),
    tools=[esql_query],
    skills=["skills"],
    backend=backend,
    system_prompt=(
        "You are a research assistant answering questions about a document corpus. "
        "You have NOT memorized the corpus. When a question depends on specific facts, "
        "names, dates, or events, use the query-ki skill to retrieve Knowledge "
        "Indicators before answering. Ground your answer strictly in what it returns, "
        "and cite the KI titles you used."
    ),
)

start = time.perf_counter()
result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": sys.argv[1],
            }
        ]
    }
)
latency = time.perf_counter() - start

print("\n--- Tool calls ---")
for m in result["messages"]:
    if isinstance(m, AIMessage) and m.tool_calls:
        for tc in m.tool_calls:
            print(f"  [{tc['name']}] {str(tc['args'])[:120]}")
total = sum(
    len(m.tool_calls)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.tool_calls
)
print(f"Total: {total}\n")

print("--- Usage ---")
input_tokens = sum(
    (m.usage_metadata or {}).get("input_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
output_tokens = sum(
    (m.usage_metadata or {}).get("output_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
print(f"Tokens: {input_tokens + output_tokens} (input {input_tokens}, output {output_tokens})")
print(f"Latency: {latency:.2f}s\n")

print("--- Answer ---")
print(result["messages"][-1].content)<p>This agent will query the KI indices to generate the answer, rather than pulling every document into context. </p><p>When I ran these results against the same query, here was the output: </p>% python facts_ki_agent.py "What was the actress who played Torvi from Vikings also known for?"

--- Tool calls ---
  [read_file] {'file_path': './query-ki/SKILL.md', 'limit': 1000}
  [esql_query] {'query': 'FROM ai-index-idx-* METADATA _id, _index, _score\n| WHERE type == "corpus_entry"\n| FORK\n    (WHERE MATCH(co
Total: 2

--- Usage ---
Tokens: 27625 (input 27037, output 588)
Latency: 15.22s

--- Answer ---
Based on the retrieved information, **Georgia Hirst** played Torvi in Vikings. She is also known for:

1. **Ravers (2018)** - A zombie horror film where she played the character Becky. This was her debut film and premiered at the London FrightFest Film Festival.

2. **Being the daughter of Michael Hirst** - The creator and writer of Vikings itself, making her connection to the show a notable family affair. Her older half-sister Maude Hirst also appeared in Vikings as Helga.

3. **Cervical cancer awareness advocacy** - She has publicly advocated for cervical cancer screening after being diagnosed with precancerous cells at age 22 and successfully recovering through treatment.

4. **Charity work** - In 2021, she participated in a celebrity cover of The Beatles' "Let It Be" in support of the Mind charity (a mental health organization), alongside Anne Hegerty, Ivan Kaye, Eunice Olumide, and Shona McGarty.

**Sources cited:** "Georgia Hirst" and "Georgia Hirst - Wikipedia" Knowledge Indicators from the AI Index.<h2>How much can precomputing facts reduce agent token usage?</h2><p>Both agents had similar conclusions, but they took far different paths to get there: </p><p>The same question and the same grounded answer result in 93% fewer tokens and two tool calls instead of eight, when answering from KIs.</p><p>
</p><p>Baseline (No AI Index)</p><p>With AI Index</p><p>Total tool calls</p><p>8</p><p>2</p><p><code>read_file</code> calls</p><p>2</p><p>1</p><p><code>esql_query</code> calls</p><p>6, all against the <code>browsecomp-plus</code> index</p><p>1, from <code>ai-index-idx-*</code></p><p>Tokens consumed</p><p>386,187</p><p>27,625</p><p>Latency</p><p>44.86s</p><p>15.22s</p><p>Answer</p><p>Grounded, correct</p><p>Grounded, correct</p><p>Exact tool call counts, latency, and answers will vary between runs and using different agents. </p><p>Both agents produced solid, grounded answers. The difference is cost. Querying KIs from the AI Index cut token use by 93% and cut latency by roughly two thirds. Here’s how both paths went, side by side:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt52fac74907997882/6a8ff2a049d4293b02a4fd64/unnamed.png" alt="Agentic RAG tool calls: 8 calls and 386,187 tokens without Knowledge Indicators, 2 calls and 27,625 tokens with them" /><p>That was in-depth, but it shows what AI indices and Workflows do together: the same answer, at a fraction of the tokens.</p><h2>Build precomputed context in Elasticsearch Serverless</h2><p>This walkthrough shows how to generate more sophisticated KIs based on documented facts and query them for knowledge retrieval use cases using Elasticsearch primitives. </p><p>Managing context is critical in agentic search systems. And at its core, context is a retrieval problem. AI indices help you manage context within the Elastic Stack. Try it out in Serverless, and let us know what you think in our <a href="https://discuss.elastic.co/top?period=monthly">Discuss forums</a> or the <code>#stack-kibana</code> channel in our <a href="https://elasticstack.slack.com/signup#/domain-signup">Community Slack</a>.</p><p>We’d also love to hear from you about what use cases you’d like to solve using AI indices.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/agentic-rag-precomputed-facts-ai-index</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agentic-rag-precomputed-facts-ai-index</guid>
    <category><![CDATA[AI Tools ]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso,Matt Nowzari ,Apostolos Matsagkas,Peter Pišljar]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3e1939e01169bb08/6a8fedbec8ced9f736055f59/1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Taming PUNKs: How ES|QL queries Elasticsearch fields it was never told about]]></title>
    <description><![CDATA[In Elasticsearch 9.5, ES|QL can query unmapped fields. It reads them from _source or returns nulls, so a query keeps working when a field drops out of the mapping and you avoid a reindex that takes hours.]]></description>
    <content:encoded><![CDATA[<p>How do you make an analytical query engine use data that it cannot know exists? You “just” read the query, since everything that the user asks for is right there. Right?</p><p>In Elasticsearch 9.5, <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> queries no longer fail when a field isn't in the mapping. The new <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-unmapped-fields"><code>unmapped_fields</code> setting</a> lets queries load values from <code>_source</code> or fill with <code>nulls</code>, so queries keep working even when a backing index changes and a field goes missing, and can use unmapped data without reindexing. Here’s how we built that: the design choices and the edge cases (including a class of fields we nicknamed PUNKs), along with the testing strategies that gave us the confidence to ship it in general availability (GA).</p><h2>Why ES|QL queries fail when a field is unmapped</h2><p>You built a visualization using an ES|QL query. You refined it, and the query grew. You’re at 15 chained commands and counting, but it does <em>just</em> the right thing. It works, and your dashboard is <em>useful</em>.</p><p>Your query uses an index from a remote cluster, say <code>my-remote:logs-foo</code>. But actually, <code>logs-foo</code> is an alias, and at some point, the remote cluster makes it point to a different backing index. The new index is missing a field that’s used in your query, and your query and visualization break.</p><p>Or maybe you have an already fairly large index, and while building ES|QL queries on top of it, you realize that you’d like to use a field in the indexed documents that unfortunately never made it into the index mapping. You could reindex the data, but that would take hours.</p><p>ES|QL’s <code>unmapped_fields</code> setting is meant to deal with these types of situations.</p><p>If your query looks like this:</p><p>and <code>some_field</code> is unmapped, ES|QL’s default behavior is to fail with a verification exception.</p><p>You can use the <code>unmapped_fields</code> setting to instead either fill <code>some_field</code> with <code>null</code>s or read it from the document’s <code>_source</code>, like so:</p><h2>How ES|QL resolves queries with field caps</h2><p>Before we jump into the inner workings of <code>unmapped_fields</code>, we have to look into how ES|QL resolves queries regularly. Let’s consider the above query:</p><p>We said that if <code>some_field</code> isn’t in the mapping for <code>index</code>, ES|QL will reject the query. How does it make that decision?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7ba9af4b4b550018/6a8ee83fe41d7fea88654d42/image4.png" alt="ES|QL query resolution flow: analyzer checks index mappings, unresolved fields fail with Unknown column error" /><h3>How field caps tells ES|QL which fields exist</h3><p>In a typical schema-on-write fashion, Elasticsearch clusters maintain mappings with their respective indices. As a first step, ES|QL makes an internal request to the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-field-caps">field caps endpoint</a> to determine which fields the <code>index</code> has. It then passes the query, together with the field caps response, to the query planner, which consists essentially of the query analyzer (unrelated to analyzers of text fields) and query optimizer. The analyzer makes sense of raw names, like <code>some_field</code>, and notices that they correspond to index fields (or not). If all went well, the query is then passed on to the optimizer, which rewrites the query for efficiency, before it’s handed to the compute engine for execution.</p><h3>How the analyzer resolves field names in the query plan</h3><p>Let’s zoom in to the analyzer. The parsed query is represented in a tree structure, and the analyzer partially rewrites it, one command at a time, until it either has resolved all references or not.</p><p>For illustration, let’s use a somewhat more complex query and see how the analyzer would resolve it:</p><p>The parsed tree is actually a chain here, and it looks something like this:</p><p></p><p>The analyzer then moves up through the query tree to try and resolve the field names used in every command.</p><p>This is a simplified version of how we represent parse trees in tests and when debugging. The bottom of the chain corresponds to the <code>FROM</code> command and contains a list of all mapped fields that we know about, obtained from the field caps endpoint. (The <code>{f}</code> suffix marks an actually mapped field for better distinction later.)</p><p>The two <code>EVAL</code> nodes on top of it correspond to the remaining commands, and their fields are still unresolved, expressed by the question mark <code>?</code> in front of the name. At this point, the analyzer still has to check whether they correspond to existing index fields.</p><p>For the <code>EVAL</code> that defines <code>uppercased_mapped</code>, it can see that the previous command outputs <code>mapped_field</code>, so the unresolved <code>?mapped_field</code> marker can be replaced by a real field reference:</p><p>Next, it encounters the topmost <code>EVAL</code>, which defines <code>uppercased_unmapped</code>. The previous tree nodes produce only two fields: <code>[mapped_field, uppercased_mapped]</code>. The reference <code>?unmapped_field</code> thus has to remain unresolved. We bail here and emit the verification exception to the user.</p><h2>How unmapped_fields LOAD and NULLIFY work</h2><h3>Adding unmapped fields to the query plan</h3><p>When using <code>unmapped_fields=”NULLIFY”</code> or <code>”LOAD”</code>, we do something else; we act as if the field was actually in the index. The analyzer adds <code>unmapped_field</code> to the <code>From</code> node and marks it as unmapped to signal to the compute engine that this has to be read from <code>_source</code> or filled with <code>null</code>s. Let’s express this with a <code>{u}</code> (for <strong>u</strong>nmapped):</p><p>After amending the <code>From</code>, the analyzer can continue trying to resolve the topmost <code>Eval</code> node. It sees that the upstream nodes produce the fields <code>[mapped_field, unmapped_field, uppercased_mapped]</code> and thus <code>unmapped_field</code> can be correctly resolved:</p><p></p><p>The query plan is now fully resolved and can be passed down the regular optimization-execution pipeline. Other than the actual value extraction mechanism, everything stays the same. Schematically, the workflow looks like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt59bf2d2da201d00a/6a8ee8df8658b77c28469356/image2.png" alt="ES|QL unmapped fields flow: analyzer retries with NULLIFY or LOAD instead of failing on an unresolved field" /><h3>Example: enabling unmapped fields with the SET directive</h3><p>To give an example, let’s fire up a cluster and create an index with non-dynamic mappings.</p>PUT /index
{                                 
  "mappings": {
    "dynamic": false,
    "properties": {
      "mapped_field": {"type": "keyword"}
    }
  }
}

POST /index/_doc?refresh
{
  "mapped_field":"foo"
  "unmapped_field": "bar"
}<p>We can run the example query, above:</p>POST /_query
{
  "query": """
           FROM index
           | EVAL uppercased_mapped = TO_UPPER(mapped_field)
           | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)
           """
}<p>This should result in the error message:</p><p><code>Unknown column [unmapped_field], did you mean [mapped_field]?</code></p><p>To make things work, we can prepend <code>SET unmapped_fields=”...”;</code> with <code>LOAD</code> or <code>NULLIFY</code>:</p>POST /_query
{
  "query": """
           SET unmapped_fields="LOAD";
           FROM index
           | EVAL uppercased_mapped = TO_UPPER(mapped_field)
           | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)
           """
}

 mapped_field  |unmapped_field |uppercased_mapped|uppercased_unmapped
---------------+---------------+-----------------+-------------------
foo            |bar            |FOO              |BAR<h3>Inspecting the analyzer's rewrite steps</h3><p>If you want to see what the query analyzer is doing to the parse tree, you can log the query rewrite steps, like so:</p>PUT /_cluster/settings"
{
  "transient" : {
    "logger.org.elasticsearch.xpack.esql.analysis.Analyzer.changes": "TRACE"
  }
}<p>This will log a line containing <code>Rule rules.ResolveUnmapped applied with change…</code> You’ll see that <code>unmapped_field</code> is added to the bottom of the parse tree as described above.</p><h2>Why we have to infer the schema</h2><p>Of course, this isn’t the only possible method to deal with unmapped fields. Here are some alternatives:</p><ol><li><p>We could also scan or probe the documents in <code>index</code> to determine that their <code>_source</code> actually has the <code>unmapped_field</code>.</p></li><li><p>We could disable verifications in the analyzer and make the compute engine blindly pass unmapped fields through individual computation steps.</p></li></ol><p>The first alternative front-loads more work to understand the <em>actual</em> schema of an index and thus generally increases latency. It doesn’t scale to large, highly distributed datasets. The second alternative isn’t viable since it means a large-scale change to how ES|QL’s compute engine is built, because it passes around streams of data with fixed columns from one operator to another.</p><p>In contrast, the approach we chose is neatly compatible with ES|QL’s existing optimization pipeline.</p><p>The trade-off is that the analyzer has to correctly <em>infer</em> a schema based on the actual index mappings (obtained from the field caps endpoint) and additional fields used inside the query. </p><p>This isn’t always straightforward. There were two main challenges:</p><ol><li><p>There are many different query shapes and commands that can be used. The mechanism needs to detect unmapped fields, update the proper <code>FROM</code> command, and pass the new field through the halfway resolved plan correctly in all cases.</p></li><li><p>There are many different mappings we have to deal with, and we specifically need to make our feature work correctly when mappings <em>change over time</em> on top of that.</p></li></ol><p>In the following, we’ll focus on <code>LOAD</code>, although some problems (generally many fewer) also apply to <code>NULLIFY</code>.</p><h3>Which index to load unmapped fields from for LOOKUP JOIN and FORK</h3><p>To briefly illustrate the first problem, here are some choices we needed to make:</p><ul><li><p>Which index do we load from when using <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join">lookup joins</a>? This one?</p><p>The <code>unmapped_field</code> cannot be attributed to both indices. We chose <code>index</code> since this is where we expect mappings to change more often than in lookup indices.</p></li><li><p>Similarly, how do we deal with subqueries and views or the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code> command</a>? In the following:</p><p>one fork branch triggers loading of an unmapped field. Is it also present in the other fork branch? (Yes, it should be, but it’s not obvious and is specifically not true if the two <code>FORK</code>s are replaced by independent subqueries.)</p></li></ul><h3>Two principles to keep queries working</h3><p>The second problem, diversity of mappings and their evolution over time, is a far bigger driver for complexity. We strove for two basic usability principles:</p><ul><li><p>Queries that work in the default mode should generally still work when using <code>unmapped_fields=”NULLIFY”</code> and <code>”LOAD”</code>.</p></li></ul><ul><li><p>Queries that work when all fields are mapped should generally still work with <code>NULLIFY</code> and <code>LOAD</code> when a field becomes unmapped and vice versa.</p></li></ul><h3>The type of unmapped fields and inadvertent type conflicts</h3><p>Let’s talk about data types to see where this leads to complexity. First, when using <code>unmapped_fields=”LOAD”</code>, we need to assume a data type for unmapped fields. We chose <code>KEYWORD</code>, which allows us to avoid type conflicts when reading from <code>_source</code>. One document can contain <code>”unmapped_field”: “foo”</code>, and another can contain <code>”unmapped_field”: 123.4</code>. It’s fine because we treat both as strings.</p><p>However, this is a violation of the second principle when a non-<code>KEYWORD</code> field happens to go unmapped. Consider this query:</p><p>If <code>some_field</code> becomes unmapped, we’ll have to assume that the <code>KEYWORD</code> type and the query will fail with a type conflict.</p><p>Type conflicts <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-multi-index#esql-multi-index-invalid-mapping">aren’t new</a> and can be dealt with by using explicit casts in the query, like so:</p><p>It would be great if ES|QL just inferred a useful type to cast to, but this is something for the future.</p><h3>Type conflicts with partially unmapped fields, or: making PUNKs well behaved</h3><p>In addition to fully unmapped fields, <em>partially unmapped</em> fields are everywhere and should also work with <code>LOAD</code>. Let’s look at a query that uses multiple indices.</p><p>Let’s say that there are indices <code>index</code> and <code>index_without_some_field</code>, containing just the following documents.</p>// index1
{
  "some_field": "foo"
}

// index2
{
  "some_field": "bar"
}<p>Now let’s consider the query:</p>FROM index, index_without_some_field<p>and assume that <code>some_field</code> is unmapped in <code>index_without_some_field</code>. This will return:</p>some_field
-------------
 foo
 null<p>because ES|QL doesn’t load unmapped fields per default.</p><p>Of course, when setting <code>unmapped_fields=”LOAD”</code>, we want to load from <code>_source</code> for <code>index_without_some_field</code>:</p>SET unmapped_fields="LOAD";
FROM index, index_without_some_field

 some_field
-------------
 foo
 bar           // loaded from _source<p>As with fully unmapped fields, the case is simple when <code>some_field</code> is mapped as <code>KEYWORD</code> in <code>index</code>. When loading from <code>_source</code> for <code>index_without_some_field</code>,  we treat the field as <code>KEYWORD</code> as well, so there’s no conflict.</p><h3>What makes a field a PUNK</h3><p>The case is less clear when <code>some_field</code>is partially unmapped and the mapped leg is of a type other than <code>KEYWORD</code>. Such fields caused a lot of trouble until we found the best solution, which makes their acronym quite fitting: <strong>p</strong>artially <strong>u</strong>nmapped <strong>n</strong>on-<strong>k</strong>eyword fields, or PUNKs.</p><p>Unfortunately, PUNKs are far from being esoteric. For instance, it’s very natural to filter on a PUNK:</p><p>If <code>some_field</code> is mapped as <code>INTEGER</code> in <code>index</code>, the type conflict looks like this:</p><ul><li><p>Mapped as an <code>INTEGER</code> in <code>index</code>.</p></li><li><p>Unmapped in <code>index_without_some_field</code> and thus treated as <code>KEYWORD</code>.</p></li></ul><p>This can again be resolved manually by providing an explicit cast:</p><p>But this is far from acceptable. Even queries that work fine without <code>NULLIFY</code> and <code>LOAD</code> typically have <em>some</em> PUNKs; the unmapped leg is simply treated as <code>null</code> then. Both guiding principles are violated if <code>LOAD</code> requires an explicit cast here.</p><h3>Casting implicitly to the mapped type</h3><p>The solution is to introduce an implicit cast to the mapped type. In this case, we know that <code>some_field</code> is an <code>INTEGER</code> in <code>index</code>, and thus we treat it essentially as if the user wrote:</p><p>This means that queries that work without <code>LOAD</code> keep working. (ES|QL may even give you more data because we load the unmapped leg of PUNKs from <code>_source</code>.) Queries that used to work when a field is fully mapped also keep working when it goes unmapped in some (but not all) of its indices without having to alter the query in any way.</p><p><strong>Behavior</strong></p><p><strong>Default</strong></p><p><strong><code>NULLIFY</code></strong></p><p><strong><code>LOAD</code></strong></p><p>Unmapped field in query</p><p>Query fails</p><p>Query runs</p><p>Query runs</p><p>Values returned</p><p>None</p><p><code>null</code></p><p>Read from <code>_source</code> </p><p>Assumed type</p><p>n/a</p><p><code>NULL</code></p><p><code>KEYWORD</code></p><p>Partially unmapped field (PUNK)</p><p>Unmapped leg is <code>null</code></p><p>Unmapped leg is <code>null</code></p><p>Cast to the mapped type</p><p>Pushdown optimization</p><p>Full</p><p>Full</p><p>Per-node where fully mapped</p><h2>Don't throw it all away: Keeping ES|QL query optimization with unmapped fields</h2><p>There's one more thing to get right; that is, to make sure that optimizations still work correctly with <code>LOAD</code>. Consider the previous query:</p><p>ES|QL’s optimizer aggressively pushes down such <code>WHERE</code> filters and turns them into Lucene queries, so the compute engine doesn’t perform unnecessary work.</p><p>For this query, evaluating the filter in the compute engine would require fetching each and every document from the index; meaning, a full scan, very slow. If <code>some_field</code> was mapped as an <code>INTEGER</code> in both indices, we would instead perform a Lucene query, which looks like this:</p>{
  "range": {
    "some_field": {
      "gt" : 10,
      "boost" : 0.0
    }
  }
}<p>The compute engine then doesn’t have to load each document separately and check whether it matches the filter. Documents with <code>some_field &lt;= 10</code> are never fetched from the Lucene index, which is very efficient at this kind of filtering. Nice.</p><h3>Why filter pushdown is unsafe for unmapped fields</h3><p>If <code>some_field</code> is unmapped in <code>index_without_some_field</code>, however, it’s wrong to narrow the documents down using the same Lucene query, as Lucene interprets an unmapped <code>some_field</code> as <code>null</code> and thus no documents from <code>index_without_some_field</code> will ever match. This edge case is easy to miss, and it doesn’t help that there are several flavors of similar pushdowns. For instance, in the query:</p><p>the compute engine pushes even the counting to Lucene. Again, this is only correct if <code>some_field</code> is fully mapped.</p><p>This means that such optimizations can't apply to unmapped fields. It would be disappointing if a query used hundreds of indices and only one of them happened to not map <code>some_field</code>, causing the whole query to run unoptimized.</p><h3>How the local optimizer recovers the fast path</h3><p>Luckily, this problem has a solution, too. ES|QL actually has multiple optimizer runs:</p><ol><li><p>First, a preliminary optimizer run on the node handling the <code>_query</code> request.</p></li><li><p>Then, a second, local optimizer run on every node we fan out to because we need to fetch documents from its shards.</p></li></ol><p>The workflow after the initial optimization looks more like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb2c907ea31960f96/6a8eeabe36492afa7685fa36/image1.png" alt="" /><p>If the current node happens to map <code>some_field</code> in all shards, the local optimizer detects this situation and treats <code>some_field</code> like any other fully mapped field, including performing Lucene queries to greatly narrow down the dataset to be processed. In fact, data nodes process <code>LIMIT</code> queries like:</p><p>in batches of shards (to avoid loading too much data too eagerly), which includes a full local optimizer run per batch. This makes it even more likely to encounter batches where <code>some_field</code> is fully mapped, allowing ES|QL to run a fast Lucene query.</p><h2>Is it working now? Testing unmapped_fields across every ES|QL query shape</h2><p>As we have seen from the optimizer issues above, problems can hide in plain sight, even for very simple queries. Because <code>unmapped_fields=”LOAD”</code> can affect each and every kind of query, the surface area for bugs is essentially all of ES|QL.</p><p>Accordingly, getting good test coverage was tricky and challenged us to refine our testing strategies.</p><h3>Reusing spec tests with unmapped_fields</h3><p>Conveniently, ES|QL has an extensive corpus of test queries, together with expected result sets; we call them <em>spec tests</em> because they’re written using a simple text specification language, which looks roughly like this:</p>simpleEval
row a = 1 | eval b = 2
;

a:integer | b:integer
1         | 2
;<p>This lets us create new tests out of the existing ones by introducing slight variations. For instance, any existing test that runs without <code>SET unmapped_fields=”...”</code> should produce the exact same results when run with <code>SET unmapped_fields=”NULLIFY”</code>.</p><p>It also helped find major issues early in the development process, especially for <code>NULLIFY</code>. The <code>LOAD</code> setting changes the meaning of queries much more dramatically, limiting the usefulness of this approach. However, ES|QL also uses what we call <em>generative testing</em>; that is, we string together random commands, run the query, and then check whether the server reports a bug. This approach cannot confirm the correctness of results, but it still helped greatly with finding query types that didn’t work properly and resulted in some kind of error. (Property-based tests would be a refinement in the future by running the queries against a reference implementation. This way, correctness of results can also be checked.)</p><h3>Testing type conflicts across different mappings</h3><p>In the end, one of the most important testing dimensions was using different indices with various mappings in the same query. (Recall how, above, we had to deal with type conflicts to come up with a solid approach for PUNKs? It doesn’t end there; all kinds of type conflicts are more complex with <code>LOAD</code>.) Since we couldn’t automatically generate correct expected results, ES|QL’s test suite had to grow by adding more than 10,000 lines of CSV spec tests. Fortunately, adding such tests is a well-suited task for an AI agent, which has cut down the effort dramatically. (Of course, the test results were still reviewed by humans.)</p><p>All testing strategies together provided us with good confidence for the GA release of <code>unmapped_fields</code> with Elasticsearch 9.5.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-unmapped-fields-deep-dive</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-unmapped-fields-deep-dive</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Mappings]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Alexander Spies]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9f8079c053533ad8/6a8ee79f8658b748a0469342/image4.png" length="0" type="image/png"/>
    <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Three SLOs every search team needs: monitoring search latency, availability and quality with OpenTelemetry]]></title>
    <description><![CDATA[Your OpenTelemetry search spans already carry the signals for SLOs, burn rate alerts, anomaly detection and incident response, and this post shows how to build all four in Elastic Observability.]]></description>
    <content:encoded><![CDATA[<p>Every search request your API handles already emits an OpenTelemetry span with latency, error, and result count data. You built that instrumentation for product analytics. Turns out it also gives you search monitoring for free. This post takes those spans and turns them into three SLOs (99% of queries under 250ms, 99.9% availability, zero-results rate below 15%), then layers on alerting, anomaly detection and an incident response workflow, all with Elastic Observability's built-in tooling. If you instrumented your search API following Blogs 2-4, you can set this up in an afternoon.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Use Elastic APM's built-in views to explore search latency and throughput and to explore errors.</p></li><li><p>Define Service Level Objective (SLOs) for search, including latency targets and availability, along with search quality.</p></li><li><p>Create SLOs in Kibana that track your search health over time, with burn rate alerting.</p></li><li><p>Build operational dashboards with Elasticsearch Query Language (ES|QL) that show latency percentiles and time breakdowns and that include trends.</p></li><li><p>Set up alerts for latency regressions and error spikes, along with zero-results rate increases.</p></li><li><p>Establish an incident response pattern for search degradation.</p></li></ul><h2>What you'll need</h2><ul><li><p>Search instrumentation from <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> (search spans with <code>search.*</code>  attributes in Elastic).</p></li><li><p>Kibana access with permissions to create SLOs and alert rules.</p></li><li><p>Basic understanding of SLOs. (We'll explain the search-specific parts.)</p></li><li><p>An Elastic cluster with an Enterprise subscription, an <a href="https://www.elastic.co/cloud?utm_campaign=G-TXT-EMEA-UK+CA-Core-EN-Lead_Gen-CloudTrials-BR&amp;utm_content=Brand-Cloud&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elastic%20cloud%20trial&amp;utm_id=701610000005lJVAAY&amp;gad_source=1&amp;gad_campaignid=22979576770&amp;gbraid=0AAAAADrDgoKn2OUpnHv5-QMO2ZrcBzj4K&amp;gclid=Cj0KCQjwjb3SBhDgARIsAMKiWziycspjFFEKHsgcIEsVdAYu6qNwIrTL27hQoMuX4eZbcm9oFox0tBYaAmH1EALw_wcB">Elastic Cloud trial</a>, or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">a local deployment</a> with the trial activated.</p></li></ul><h2>Why search monitoring matters beyond cluster health</h2><p>Search is the primary navigation path for a significant share of visitors, and search-initiated sessions tend to show stronger purchase intent than browse sessions. A search outage is a revenue event, rather than a minor feature degradation. A latency regression from 100ms to 500ms changes user behavior before anyone files a ticket.</p><p>Most platform teams monitor search at the infrastructure level, checking whether the Elasticsearch cluster is healthy and whether nodes are responding. They also determine whether the disk is full. These are all necessary but not sufficient. A cluster can be green while search quality silently degrades; for example, queries returning stale data after a bad index deployment or latency creeping up as the index grows. This could also include zero-results rates climbing because a synonym list wasn't updated.</p><p>The gap is between "search is up" and "search is working well."</p><p><strong>A note on examples:</strong> As we have throughout this series, we use ecommerce search for concrete examples, but these reliability patterns apply equally to any search application, including content platforms, internal knowledge bases, job boards, and support portals.</p><h3>OpenTelemetry search spans as monitoring signals</h3><p>If your team followed <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> in this series, every search request already emits an OTel span with <code>search.*</code> attributes. These include the query text, result count, Elasticsearch execution time, and error status. Those spans land in <code>traces-generic.otel-default</code>  in Elastic.</p><p><strong>Following along with code?</strong> The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has all the instrumentation from <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a>. Generate traffic, and then follow along with the SLO and dashboard setup below. See <code>queries/blog6_reliability.esql </code> for ready-to-run queries.</p><p>The search team built that instrumentation for <em>product analytics</em>; that is, understanding what users search for and measuring click-through rates (CTRs) and conversion rates. They’ve prioritized relevance work, but the same spans contain everything you need for operational monitoring. Span duration gives you a latency signal, and <code>search.result_count == 0</code> value reflects quality. Span errors point to availability signals.</p><p>This post shows how to put this operational value to work, beginning with what you can see right now in Kibana and then building SLOs and alerting on top of it, along with incident response.</p><h2>Search monitoring out of the box with Elastic APM</h2><p>Before building anything new, let's look at what Elastic APM already gives you out of the box.</p><p>If your search API is instrumented with Elastic Distribution of OpenTelemetry (EDOT) (as in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>), it appears automatically as a service in the Elastic APM UI. Open <strong>Observability </strong>&gt;<strong> APM </strong>&gt;<strong> Services</strong> in Kibana, and select your search service (named <code>search-analytics-demo</code> if you're using the reference project). You'll immediately see the following:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt901b364e81dae2dc/6a8d6f3e0897900dc2ef9425/image6.gif" alt=" Elastic APM service overview page for an OpenTelemetry-instrumented search API showing Overview, Transactions and Errors tabs" /><h3>Elastic APM service overview for search</h3><p>The service overview page shows latency distribution and throughput over time, along with error rate, and doesn’t require configuration. You can see at a glance whether search is healthy, and the time-series charts make regressions obvious. If latency crept up after yesterday's deployment, you'll see it here.</p><h3>Trace waterfall: breaking down search request latency</h3><p>Click into any transaction, and you'll see the <em>trace waterfall</em>, which is a visual breakdown of every span in the request. For a search API call, this typically shows:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb005066de9a98d55/6a8d6f82b74e9d482044a3b3/blog6-trace-waterfall-span-details.gif" alt="Elastic APM transaction view for POST /api/search showing search latency distribution, throughput, and failed request rate" /><p>The waterfall makes the invisible visible. You can see that the 503ms API response breaks down into HTTP handling, a 241ms query rules lookup, and a 260ms Elasticsearch query,  plus the custom <code>search</code> span (36ms) carrying all of our <code>search.*</code> attributes. Click any span, and the metadata flyout shows exactly what was captured: <code>search.query: "usb hub"</code><code>,</code> <code>search.result_count: 33</code>, <code>search.took_ms: 43</code>, the index name, hit IDs, and more.</p><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> discussed the gap between <code>search.took_ms </code> and span duration. The waterfall shows you exactly where that gap lives, without writing any queries.</p><h3>Automatic search error capture with OpenTelemetry</h3><p>One of the most valuable things OTel auto-instrumentation gives you is <em>automatic error capture</em>. When an Elasticsearch query fails because of issues like a tripped circuit breaker or a timeout, or if an index isn’t found,  the span records the exception type and message, along with the stack trace. <a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">Blog 4</a> mentioned this as a side benefit of span-based conversion tracking; here it becomes an operational lifeline.</p><p>The errors tab on your service page automatically aggregates these, grouped by error type and frequency. The instrumentation captures the details for you, so you don't need custom error handling or logging. During an incident, this is often the fastest way to understand what's actually failing.</p><h3>Service map: search API and Elasticsearch dependencies</h3><p>The <a href="https://www.elastic.co/docs/solutions/observability/apm/service-map">service map</a> shows dependencies between your search API and Elasticsearch, making it easy to see whether a latency problem is in your service or in the cluster it depends on.</p><p>All of this is available the moment you deploy the instrumentation from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>, without building any dashboards or writing any queries. This is the foundation everything else in this post builds on.</p><h2>Defining service level objectives for search</h2><p>An SLO defines <em>good enough</em> in measurable terms. You define what <em>working</em> means, measure it continuously, and alert when you're burning through your error budget too fast, instead of reacting when something breaks.</p><p>Elastic Observability has a <a href="https://www.elastic.co/guide/en/observability/current/slo.html">built-in SLO framework</a> that handles <a href="https://www.elastic.co/guide/en/observability/current/slo.html#slo-important-concepts">Service Level Indicator</a> (SLI) calculation and budget tracking. It also takes care of burn rate alerting. You create SLOs directly in Kibana. No ES|QL or custom pipelines are required for the core indicators.</p><h3>Three SLOs every search service needs</h3><p>Navigate to <strong>Observability </strong>&gt; <strong>SLOs</strong> in Kibana, and click <strong>Create SLO</strong>. The <a href="https://www.elastic.co/guide/en/observability/current/slo-create.html">SLO creation workflow</a> walks you through three steps: Define the SLI (what to measure), set the objective (the target), and describe the SLO.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ea1fc523a27cb57/6a8d7037bc1d3a69d7701bba/blog6-slo-creation-form.gif" alt="Creating a search latency SLO in Kibana showing SLI preview chart, rolling time window, and 99% target objective setting" /><h4>1. Search latency SLO: 99% of queries under 250ms</h4><p>Indicator type: Elastic APM latency target: 99% of searches complete in under 250ms.</p><p>The Elastic APM latency indicator is purpose-built for this. Select your search service (<code>search-analytics-demo</code>), and set the threshold to 250ms.  Elastic handles the rest, including calculating the percentage of transactions below the threshold and tracking your error budget over time.</p><p>Note: The Elastic APM latency indicator measures all HTTP transactions for the <code>search-analytics-demo</code> service, including health checks and click/cart/checkout endpoints, along with static asset requests, not just the <code>POST /api/search</code> endpoint. For a search-only latency SLO, use a custom Kibana Query Language (KQL) indicator with <code>name: "search" AND attributes.search.query: *</code> on the <code>traces-generic.otel-default</code><code> index</code>. The Elastic APM indicator is still valuable for whole-service health. For full coverage, combine both.</p><p>This measures end-to-end span duration; that is, what the user actually experiences. If your Elasticsearch query takes 50ms but the user waits 300ms because of network overhead or slow application logic, this SLO catches it. Use <code>search.took_ms</code> in the Elastic APM waterfall to diagnose <em>where</em> the latency lives when the SLO starts burning.</p><h4>2. Search availability SLO: 99.9% success rate</h4><p>Indicator type: Elastic APM availability <strong>t</strong>arget: 99.9% of searches succeed.</p><p>The Elastic APM availability indicator calculates the percentage of successful transactions for your service. When the Elasticsearch client throws an exception or the search endpoint returns a 5xx, the span's status records an error and this SLO counts it.</p><p>Note: Like the latency SLO, the Elastic APM availability indicator covers all HTTP transactions on <code>search-analytics-demo</code>, not just <code>POST /api/search</code>. Click/cart/checkout errors will consume this budget. For a search-only availability SLO, use a custom KQL indicator with <code>name: "search" AND attributes.search.query: *</code> for good events and <code>name: "search"</code> as the total query.</p><p>An 0.1% error budget on 100,000 daily searches means that you can tolerate 100 errors per day. That's tight, but search errors are hard failures and the user gets nothing. Availability SLOs should be stricter than latency SLOs.</p><h4>3. Search quality SLO: tracking zero-results rate</h4><p>Indicator type: Custom KQL target: 85% of searches return at least one result (zero-results rate &lt; 15%); index: <code>traces-generic.otel-default</code>; <strong>g</strong>ood query: <code>name: "search" AND attributes.search.result_count &gt; 0</code>; total query:<code>name: "search" AND attributes.search.query: *</code>.</p><p></p><p>Note on KQL versus ES|QL: The SLO framework uses KQL for its indicator filters rather than ES|QL. KQL uses <code>field: value</code>syntax and is the same language you see in the Kibana search bar. The ES|QL queries throughout this series are for ad hoc analysis and dashboards; KQL here is the SLO indicator's document filter. Both query the same <code>traces-generic.otel-default</code> index.</p><p>This is the SLO that surprises most teams. A search that returns an empty result set isn't an error; HTTP status is 200 and the span status is OK. Plus, no exception was thrown. But from the user's perspective, it failed. They asked for something and got nothing.</p><p>The quality SLO uses the custom KQL indicator type because it relies on our custom <code>search.result_count</code> attribute, which the built-in Elastic APM indicators don't know about. But the SLO framework handles everything else, including budget tracking and burn rate calculation, along with alerting.</p><p>A sudden spike in zero-results rate, such as from 12% to 40% over an hour, is almost always an infrastructure event, like a failed index deployment or a mapping change that broke queries. It could also be a synonym list misconfiguration. That's an operational problem, not a relevance problem.</p><h3>Reading your search health in the SLO overview</h3><p>Once the latency, availability and quality SLOs are created, the SLO overview page shows your search health at a glance:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9e9ed72bf5840749/6a8d706fd05d4c5a8957f946/blog6-slo-overview-drill-in.gif" alt="Search Latency SLO detail showing 50% observed against 99% objective, burn rate windows, historical SLI, and error budget" /><p></p><p>Each SLO shows the current value, the target, the remaining error budget, and the burn rate. Green means <em>healthy</em>:  Search availability is at 100%, and Search quality is just above its 85% target. Red means <em>violated</em>: Search latency is at 50% against a 99% objective, with the burn rate breached at 200x the sustainable rate. When a budget bar starts shrinking faster than expected, you know something changed, even before users complain.</p><p>Clicking into the SLO detail shows burn rate across multiple time windows (1h, 6h, 24h, 72h) and the historical SLI trend. It also shows remaining error budget. For the latency SLO, the Elastic APM latency indicator tracks the percentage of transactions below your 250ms threshold. For the quality SLO, the custom KQL indicator uses <code>traces-generic.otel-default</code> with the good query filtering for <code>attributes.search.result_count &gt; 0</code>. This is where the custom <code>search.*</code> attributes from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> pay off, since they're the foundation of meaningful SLOs.</p><h2>Burn rate alerting for search SLOs</h2><p>When you create an SLO through the Kibana UI, a default burn rate alert rule is automatically created. This is where the real operational value lives.</p><p><a href="https://www.elastic.co/guide/en/observability/current/slo-burn-rate-alert.html">Burn rate alerts</a> improve on threshold alerts ("error rate &gt; 1%"), which are noisy and miss slow degradation. : Burn rate alerts measure how fast you're consuming your error budget relative to the SLO window.</p><p>A burn rate of 1.0 means that you're spending budget at exactly the sustainable rate, but a burn rate of 10.0 means that you're burning 10x too fast and you'll exhaust the budget in 1/10th of the window.</p><p>The default burn rate rule uses a multi-window approach, with four severity levels:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5794fbcc015d4076/6a8d709811115542160cfc88/blog6-burn-rate-alert-config.gif" alt="Selecting alert rule types in Elastic Observability including anomaly detection, APM anomaly, custom threshold and SLOs" /><p></p><p><strong>Severity</strong></p><p><strong>Burn rate</strong></p><p><strong>Long window</strong></p><p><strong>Short window</strong></p><p><strong>What it means</strong></p><p>Critical (page)</p><p>&gt; 14.4x</p><p>1 hour</p><p>5 minutes</p><p>Exhausts budget in ~50 hours</p><p>High (ticket)</p><p>&gt; 6.0x</p><p>6 hours</p><p>30 minutes</p><p>Exhausts budget in ~5 days</p><p>Medium (review)</p><p>&gt; 3.0x</p><p>24 hours</p><p>120 minutes</p><p>Exhausts budget in ~10 days</p><p>Low (awareness)</p><p>&gt; 1.0x</p><p>72 hours</p><p>360 minutes</p><p>Trending toward exhaustion</p><p>The short window prevents alerting on brief spikes that self-resolve, and the long window catches sustained degradation. Together, they balance responsiveness with alert fatigue.</p><h3>Routing search alerts to PagerDuty, Slack and Jira</h3><p>Alerts are only useful if they reach the right people in the right tools. Elastic's <a href="https://www.elastic.co/guide/en/kibana/current/alerting-getting-started.html">alerting framework</a> supports a wide range of <a href="https://www.elastic.co/guide/en/kibana/current/action-types.html">connectors</a> out of the box, including:</p><ul><li><p><strong>Incident management:</strong> PagerDuty, Opsgenie, xMatters for on-call routing.</p></li><li><p><strong>Chat:</strong> Slack, Microsoft Teams for team notifications.</p></li><li><p><strong>Case management:</strong> <a href="https://www.elastic.co/guide/en/kibana/current/jira-action-type.html">Jira</a>, <a href="https://www.elastic.co/guide/en/kibana/current/servicenow-action-type.html">ServiceNow</a> for automatic ticket creation when SLOs breach.</p></li><li><p><strong>Custom:</strong> Webhooks for integrating with any system via HTTP.</p></li></ul><p>You can also use Elastic's built-in <a href="https://www.elastic.co/guide/en/kibana/current/cases.html">cases</a> to track incidents directly within Kibana, linking alerts and traces in one place, along with investigation notes, with push to Jira or ServiceNow when escalation is needed.</p><p>A typical routing setup:</p><p></p><p><strong>Alert</strong></p><p><strong>Severity</strong></p><p><strong>Channel</strong></p><p>Latency SLO burn rate &gt; 14.4</p><p>Page</p><p>PagerDuty</p><p>Availability SLO burn rate &gt; 14.4</p><p>Page</p><p>PagerDuty + Slack</p><p>Quality SLO burn rate &gt; 6</p><p>Ticket</p><p>Jira (auto-create) + Slack</p><p>CTR anomaly (machine learning [ML] job)</p><p>Notification</p><p>Slack (search team)</p><h2>Anomaly detection for search quality</h2><p>Some search degradations are gradual shifts that slip past threshold-based alerts, rather than sudden spikes. A relevance regression after a model update might reduce CTR by 15% over a week, and latency might creep up by 5ms per day as the index grows. These are real problems, but they don't trigger burn rate alerts until it's too late.</p><p>Elastic's <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-ad-overview.html">anomaly detection</a> is built for exactly this. It learns normal patterns in your search metrics and flags deviations automatically, and you don’t have to configure any thresholds. </p><h3>Detecting search quality degradation with ML anomaly detection</h3><ul><li><strong>Latency anomalies:</strong></li></ul><p> <a href="https://www.elastic.co/docs/reference/machine-learning/ootb-ml-jobs-apm">Elastic APM anomaly detection</a> can be enabled directly from the Elastic APM UI for your search service. It learns the typical latency distribution, including daily and weekly patterns, and alerts when behavior deviates. A gradual 5ms/day creep will eventually register as anomalous before it hits your SLO threshold.</p><ul><li><strong>CTR drops:</strong></li></ul><p>A relevance regression is invisible to traditional monitoring; latency is fine and errors are zero, plus the result counts are normal, but the ranking changed and users aren't clicking. Anomaly detection on click volume per query is a practical proxy: When a query that normally receives 20 first-click events per hour drops to 5, something likely changed.</p><p>To set this up: In <strong>Kibana</strong> → <strong>Machine Learning</strong> → <strong>Anomaly Detection</strong>, create a new job. Use the <strong>Multi-metric</strong> wizard, and select <code>traces-generic.otel-default</code> as the index. Configure a <code>count</code> detector on <code>attributes.search.first_click</code> split by <code>attributes.search.query</code>. This creates a per-query click-volume baseline and alerts when individual query engagement drops outside the expected range.</p><p>Note: This job detects click-volume anomalies per query, not CTR (which requires dividing clicks by searches). Click volume is a useful proxy (a CTR regression usually manifests as a drop in absolute click count), but be aware that a traffic surge with flat click volume would show as a CTR drop without triggering this alert. For true CTR anomaly detection, use a scheduled ES|QL transform to materialize hourly CTR values and run anomaly detection on the computed ratio.</p><p>Route the resulting ML alert rule to your Slack search channel.</p><ul><li><strong>Throughput shifts:</strong></li></ul><p>A sudden drop or unexpected surge in search volume can indicate upstream problems (like load balancer changes or traffic shifts) or downstream issues (such as search becoming unresponsive or users retrying).</p><p>Configure <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-configuring-alerts.html">ML anomaly alert rules</a> to route these to your notification channels. These complement your SLO burn rate alerts; burn rates catch budget consumption, and anomaly detection catches pattern changes.</p><h2>Building a search monitoring dashboard with ES|QL</h2><p>SLOs tell you <em>whether</em> search is healthy. When they indicate a problem, you need a dashboard that tells you <em>why</em>.</p><p>The search team and the on-call team need different views of the same data. A search engineer wants query-level detail, such as which queries have low CTR and which ones return nothing. They’re also interested in where to invest in relevance. But an on-call SRE wants the operational picture, including whether search is fast and whether it’s up. It also wants to know whether search is degrading, and if so, since when.</p><h3>Search monitoring panels for the on-call dashboard</h3><p>Build it in <a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana dashboards</a> using <a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Kibana Lens</a> panels. Lens supports <a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL as a data source</a>, so the queries from <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> can power dashboard panels directly. The key panels include:</p><ul><li><p><strong>Search throughput over time:</strong>  A sudden drop is often the first sign of a problem.</p></li><li><p><strong>Latency percentiles (p50, p95, p99) over time:</strong> When they diverge (p50 flat, p99 spikes), you have a subset of slow queries.</p></li><li><p><strong>Error rate over time:</strong> Spikes here mean hard failures.</p></li><li><p><strong>Zero-results rate over time:</strong> A step change upward, especially correlated with a deployment, means something changed in the index or query pipeline.</p></li></ul><p>The ES|QL for each panel follows the patterns from earlier blogs. For example, a latency percentile panel:</p>FROM traces-generic.otel-default
| WHERE name == "search"
AND attributes.search.query IS NOT NULL
| EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
| STATS
    p50 = PERCENTILE(attributes.search.took_ms, 50),
    p95 = PERCENTILE(attributes.search.took_ms, 95),
    p99 = PERCENTILE(attributes.search.took_ms, 99)
BY bucket
| SORT bucket<p>This includes three lines on one chart. When they diverge, such as when p50 stays flat but p99 spikes, you likely have a subset of queries that are slow while the majority are fine. That's a different diagnosis than all queries slowing down (cluster-level pressure).</p><h3>Drill-down panels: slowest queries and top zero-result queries</h3><p>For investigation, add a few detail panels, such as:</p><p><strong>Slowest queries:</strong> A table showing the queries with the highest p95 latency and their search volume. During an incident, this narrows the problem from "search is slow" to "these specific queries are slow."</p><ul><li><p><strong>Top zero-result queries:</strong> A table showing which queries most frequently return nothing. When zero-results rate spikes, this panel immediately shows which queries are responsible.</p></li></ul><p>These drill-down panels use the same ES|QL patterns as Blogs <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">2</a> and <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">3</a>, just surfaced on a persistent dashboard instead of run ad hoc.</p><h2>Search incident response using OpenTelemetry traces</h2><p>As an example, an alert fires, noting that search latency has spiked. What happens now?</p><p>The trace data from Blog 2's instrumentation gives you a structured path from symptom to root cause.</p><h3>Step 1: Assess scope</h3><p>Start at the on-call dashboard, and get answers to the basics:</p><ul><li><p><em>When did it start?</em> Narrow the time range to the degradation window.</p></li><li><p><em>How bad is it?</em> Is p50 affected (all queries slow) or just p99 (a subset)?</p></li><li><p><em>Is it just search?</em> Check the Elastic APM service map to determine whether the Elasticsearch dependency is also degraded.</p></li></ul><h3>Step 2: Find the problem queries</h3><p>If the problem is a subset of queries (p99 spike but p50 is fine), use the slowest queries panel or run:</p>FROM traces-generic.otel-default
| WHERE name == "search"
AND attributes.search.query IS NOT NULL
  AND attributes.search.took_ms &gt; 100
| STATS
    count = COUNT(*),
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms)
BY attributes.search.query
| SORT count DESC
| LIMIT 10<p>Adjust the <code>100</code> ms threshold to match your environment's normal range. It can be lower for a fast cluster or higher if your data volume makes 100ms typical. This narrows the problem from "search is slow" to "these specific queries are slow." That's the difference between restarting the cluster and investigating a specific query pattern.</p><h3>Step 3: Drill into the trace waterfall</h3><p>Pick a slow query, and open it in the Elastic APM trace view. The waterfall shows exactly where time was spent. (Refer back to the trace waterfall GIF above to see a real example of a <code>POST /api/search</code>  trace broken down into its component spans.)</p><p>The overhead gap between <code>search.took_ms</code> (Elasticsearch time) and span duration (end-to-end time) is your diagnostic tool:</p><p></p><p><strong>Scenario</strong></p><p><strong>search.took_ms</strong></p><p><strong>Span duration</strong></p><p><strong>Diagnosis</strong></p><p>Elasticsearch slow</p><p>400ms</p><p>430ms</p><p>Elasticsearch problem: Check slow log, cluster metrics.</p><p>App slow</p><p>50ms</p><p>350ms</p><p>Application / network overhead: Check serialization, network.</p><p>Both slow</p><p>400ms</p><p>700ms</p><p>Multiple issues: Investigate both.</p><p></p><p>If the problem is in Elasticsearch, drill into the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html">Search Profile API</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/monitor-elasticsearch-cluster.html">cluster monitoring</a>. If it's application overhead, look at the spans around the search span in the waterfall.</p><h3>Step 4: Correlate with events</h3><p>Check whether the degradation correlates with:</p><ul><li><p><strong>Deployments:</strong> Did someone deploy a new version of the search service or push a new index?</p></li><li><p><strong>Cluster events:</strong> Is Elasticsearch under memory pressure, or are there long garbage collection pauses? Or maybe the disk is I/O saturated?</p></li><li><p><strong>Network:</strong> Is latency between the search service and Elasticsearch elevated?</p></li></ul><p>Elastic Observability's unified platform makes this correlation straightforward because traces, logs, metrics, and infrastructure data all live in the same Kibana instance. You're adding filters in the same interface, rather than switching between tools.</p><h2>Going further: Infrastructure metrics and cost attribution</h2><p>This post focuses on what you get from trace data; that is, the spans your search API already emits. But OTel and Elastic Observability support a wider instrumentation picture that becomes valuable as your search infrastructure matures.</p><ul><li><p><strong>Infrastructure metrics.</strong> Adding host and container metrics (like CPU, memory, disk I/O, and network) alongside your traces lets you correlate search performance with infrastructure use. When p99 latency spikes, you can immediately see whether the Elasticsearch nodes are under memory pressure and whether garbage collection  pauses are increasing. You can also check whether disk I/O is saturated, and you can do all this in the same Kibana interface. The <a href="https://www.elastic.co/guide/en/fleet/current/elastic-agent-installation.html">Elastic Agent</a> collects these automatically for your infrastructure, and the <a href="https://www.elastic.co/guide/en/observability/current/analyze-hosts.html">infrastructure monitoring UI</a> surfaces them alongside your Elastic APM data.</p></li></ul><ul><li><p><strong>Total cost attribution (TCA).</strong> With infrastructure metrics flowing alongside traces, you can start attributing infrastructure costs to specific services and operations. How much compute does your search service consume? How does that correlate with query volume? If a new ranking model doubles CPU usage per query, you can see the cost impact directly. This is particularly valuable for teams running search on cloud infrastructure where costs scale with resource consumption; understanding the cost per search helps justify infrastructure investment and identify optimization opportunities.</p></li></ul><ul><li><p><strong>Logs correlation.</strong> OTel auto-instrumentation injects trace context (such as trace ID and span ID) into your application logs. This means that when you're investigating a slow search in the trace waterfall, you can click through to the exact log lines from that request, including Elasticsearch slow log entries and application debug output. It also includes error details that don't fit in span attributes. The <a href="https://www.elastic.co/guide/en/observability/current/application-logs.html">logs correlation</a> feature automatically ties them together.</p></li></ul><p>These are natural next steps once you have traces working. Each one extends the same unified platform, without new tools or separate pipelines.</p><h2>How search analytics and search monitoring share one data pipeline</h2><p>Here's how it all fits together:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt540076694fa1cbcc/6a8d717236492a486785f109/image3.png" alt="Search monitoring workflow: out-of-the-box APM features, SLO and alert definition, dashboard building, incident response" /><p></p><p>The data flows from the instrumentation you built in Blog 2. This one investment supports two audiences: The search team gets product analytics (<a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–5</a>), and the SRE team gets operational monitoring (this post). Neither team needs separate data pipelines.</p><h2>Getting started with search monitoring in Elastic</h2><p>This is the last post in the series, and it brings us full circle. <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">Blog 1</a> describes the vision: Instrument search once with OTel, and send spans to Elastic. Then use ES|QL to answer any question about search behavior. <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> build the instrumentation and analytics, and <a href="https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams">Blog 5</a> shows how to feed that data back into relevance improvements. This post shows how the same data powers operational monitoring, including SLOs, alerting, anomaly detection, and incident response.</p><p>The key takeaway for search engineers is that the instrumentation you built for analytics already generates the signals. The SLOs and alerts are built-in capabilities of Elastic Observability, as are the dashboards. You're closer to production-grade search monitoring than you might think. Observability isn't a separate discipline you need to learn from scratch. </p><p>If you've been following along and built the instrumentation from Blogs 2–4, start here:</p><ol><li><p><strong>Open Elastic APM:</strong> Look at your search service, and explore a trace waterfall. You can also check the errors tab.</p></li><li><p><strong>Create three SLOs:</strong> Latency (Elastic APM latency), availability (Elastic APM availability), and quality (custom KQL for zero-results).</p></li><li><p><strong>Enable anomaly detection:</strong> One click in the Elastic APM UI for latency anomalies.</p></li><li><p><strong>Build the on-call dashboard:</strong> Four Lens panels with the queries from this post.</p></li></ol><p>By the end of an afternoon of work, your search service can have the same observability coverage as any other critical production system.</p><h2>Get started</h2><h3>Working code</h3><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li></ul><h3>Elastic APM and traces</h3><ul><li><p><a href="https://www.elastic.co/guide/en/apm/guide/current/apm-overview.html">Elastic APM Overview:</a> Elastic APM concepts and trace analysis.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/apm-ui.html">Elastic APM UI:</a> Service overview, transactions, dependencies, errors.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/service-map">Service Maps:</a> Dependency visualization and health.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/open-telemetry.html">OpenTelemetry Integration:</a> OTel ingestion in Elastic.</p></li></ul><h3>SLOs and alerting</h3><ul><li><p><a href="https://www.elastic.co/guide/en/observability/current/slo.html">SLOs in Elastic Observability:</a> Creating and managing SLOs.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/slo-create.html">Create an SLO:</a> SLI types (like Elastic APM latency, Elastic APM availability, or custom KQL), time windows, budgeting.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/slo-burn-rate-alert.html">SLO Burn Rate Alerts:</a> Multi-window burn rate alerting.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/action-types.html">Alert Connectors:</a> Slack, PagerDuty, webhook, email integrations.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/alerting-getting-started.html">Alerting Framework:</a> Rule types and configuration.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/jira-action-type.html">Jira Connector:</a> Automatic ticket creation from alerts.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/servicenow-action-type.html">ServiceNow Connector:</a> ITSM integration.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/cases.html">Elastic Cases:</a> Built-in incident tracking with external push.</p></li></ul><h3>Anomaly detection</h3><ul><li><p><a href="https://www.elastic.co/guide/en/machine-learning/current/ml-ad-overview.html">Anomaly Detection Overview:</a> Unsupervised time series anomaly detection.</p></li><li><p><a href="https://www.elastic.co/docs/reference/machine-learning/ootb-ml-jobs-apm">Elastic APM Anomaly Detection:</a> Enable ML for latency, throughput, error rate.</p></li><li><p><a href="https://www.elastic.co/guide/en/machine-learning/current/ml-configuring-alerts.html">ML Anomaly Alert Rules:</a> Alerting on detected anomalies.</p></li></ul><h3>Dashboards and ES|QL</h3><ul><li><p><a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana dashboards:</a> Building operational dashboards.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Lens:</a> Visualization editor.</p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL in Lens:</a> ES|QL-powered dashboard panels.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL Overview:</a> Language reference.</p></li></ul><h3>Elasticsearch operations</h3><ul><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-slowlog.html">Slow Log Configuration:</a> Threshold-based query slow logging.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html">Search Profiling:</a> Profile API for query execution analysis.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/monitor-elasticsearch-cluster.html">Monitoring Elasticsearch:</a> Cluster stats, search rate, latency.</p></li></ul><h3>From this series</h3><ul><li><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">Modern search analytics with OpenTelemetry:</a> The vision.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Instrument your search API </a>: Search spans and <code>search.*</code> attributes.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Measuring search quality </a>: CTR, MRR, click distribution.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">From clicks to conversions</a>: Conversion tracking and revenue attribution.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams">Personalizing search from behavior</a>: Judgment lists, rank features, Learning To Rank (LTR).</p></li></ul><p><em>This is the final post in a six-part series on search analytics with OpenTelemetry and Elastic. Start from the beginning: </em><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry"><em>Modern search analytics with OpenTelemetry,</em></a><em> or to start building, jump to </em><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql"><em>Instrument your search API.</em></a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/opentelemetry-search-monitoring-slos</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/opentelemetry-search-monitoring-slos</guid>
    <category><![CDATA[Operations]]></category>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb4ac8bfdb49b5f32/6a8d5eb7da6aeaa5fa37aae2/image1.png" length="0" type="image/png"/>
    <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Let the big model think, let the small model work: Splitting LLM costs in Elastic Workflows]]></title>
    <description><![CDATA[Build an Elastic workflow that sends a data sample to a large model to propose classification labels. A human signs off, then a smaller model applies them across the full corpus.]]></description>
    <content:encoded><![CDATA[<p>Split the expensive part of large language model (LLM) classification from the cheap part. This article builds an <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic workflow</a> where Claude Sonnet reads a stratified sample of NASA pilot incident reports and proposes classification labels based on what it finds. A human reviews the schema and signs off, and then <a href="https://mistral.ai/news/mistral-small-3-1/">Mistral Small 3.1</a> applies the labels across the full corpus. The routing is YAML, the results land in Elasticsearch as structured data, and the pattern works wherever you have free text that needs labeling.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0ceb64be1f3ad4e5/6a87ef8373f743fa6848688d/image2.png" alt="Example NASA ASRS pilot incident report showing a free-text narrative describing a near-miss at an uncontrolled airfield, the type of document classified by the LLM pipeline" /><p><a href="https://asrs.arc.nasa.gov/">NASA Aviation Safety Reporting System (ASRS)</a> reports describe unusual events during flights, such as missed altitudes, confusing clearances, runway issues, or mechanical problems. Each report already has an official category, like altitude deviation, course deviation, or ground encounter. In this article, we ask a different question: <em>What does this report reveal about the pilot who wrote it?</em> The idea is to ask a model to infer a schema grounded on the data to classify the report based on criteria that help us figure out information about the report writers. Then ask a second model to apply the labels.</p><p><em><strong>You can find the full workflow definitions and helper scripts </strong></em><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/larger-llms-task-planning-smaller-llms-execution"><em><strong>here</strong></em></a><em><strong>.</strong></em></p><h2>What you need to run this LLM pipeline</h2><ul><li><p>Elastic Stack 9.4+ or Elastic Cloud Serverless. Elastic Workflows has been generally available (GA) since 9.4.</p></li><li><p>Elastic Agent Builder enabled in your deployment.</p></li><li><p>A <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/ai-connector">Kibana generative AI (GenAI) connector</a> pointing at Claude Sonnet (or an equivalent reasoning model). This is the planner.</p></li><li><p>A <a href="https://console.mistral.ai/api-keys">Mistral API key</a>. We’ll use it to register an Elasticsearch inference endpoint.</p></li><li><p>Python 3.10+ with <code>elasticsearch&gt;=9.0</code> and <code>pandas</code>. Used by the dataset loader.</p></li></ul><h2>How two-tier LLM orchestration works</h2><p>The workflow has two jobs: Decide what labels should exist, and then apply those labels to every report.</p><p><strong>The first job is open-ended.</strong> A large model reads a varied sample of reports and proposes a small schema of categorical fields. A field is one way to describe the writer, such as <code>attribution_style</code> or <code>procedure_orientation</code>. Each field has a few allowed values, such as <code>self_critical</code>, <code>system_attributing</code>, or <code>balanced</code>.</p><p><strong>The second job is repeatable.</strong> After a human approves the schema, a smaller model reads each report and chooses one value for each field.</p><p>We use Elastic Workflows because the steps are known ahead of time: sample reports, propose labels, wait for approval, classify every document, and store the results. Writing those steps in YAML makes the process reproducible, observable, and cheaper to rerun.</p><h3><strong>Why split LLM work across two model tiers?</strong></h3><p>A small model could handle classification, but schema discovery is a different shape of problem. It requires reading a diverse sample, spotting latent patterns, and proposing complex structures. In practice, smaller models over-anchor on surface keywords and produce redundant or nonexclusive fields.</p><p>Classification is simpler, the schema exists, the values are enumerated, and the task is to pick one per field. A smaller model handles this reliably and at a fraction of the cost, since it runs once per document across the entire corpus.</p><p><em>Large</em> and <em>small</em> here mean reasoning capability. In this article, Claude Sonnet plays the planner and Mistral Small 3.1 plays the executor.</p><h2>Classifying NASA pilot reports with a two-tier LLM pipeline</h2><p>We’ll use the NASA ASRS database, which collects voluntary, anonymous incident reports from pilots, controllers, and mechanics. The dataset is public, and the reports are written as free-text narratives.</p><p>What we want to ask is:</p><p><em>What does this report reveal about the pilot who wrote it?</em></p><p>The planner reads a varied sample of reports and decides which distinctions are meaningful based on how the reports are actually written.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b7f0b9d35ef59e3/6a87efa5b6895193cca26e8e/image4.png" alt="Elastic Workflow pipeline diagram showing schema discovery by a large language model, human approval via waitForInput, and classification by a small language model storing results in Elasticsearch" /><p><strong>Step</strong></p><p><strong>Role</strong></p><p><strong>Model tier</strong></p><p><code>sample</code></p><p>Pull a diverse subset of reports from the corpus.</p><p>(no LLM)</p><p><code>discover</code></p><p>Read the question and the sample, propose a schema of fields with enum values.</p><p><strong>Large</strong></p><p><code>approve</code></p><p>Human reviews the proposed schema and approves or edits it.</p><p>(Human via <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/wait-for-input"><code>waitForInput</code></a>)</p><p><code>apply</code></p><p>Iterate over the corpus, assign one value per field to each report.</p><p><strong>Small</strong></p><p><code>store</code></p><p>Write the schema and the per-document field values to Elasticsearch.</p><p>(No LLM)</p><h2>Registering Mistral and Claude as Elasticsearch inference endpoints</h2><p>The small model will be registered as an Elasticsearch <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/infer-service-mistral.html">inference endpoint</a> using the native <code>mistral</code> service integration. </p>INFERENCE_ID = "mistral-small-extractor"

es.inference.put(
    task_type="chat_completion",
    inference_id=INFERENCE_ID,
    inference_config={
        "service": "mistral",
        "service_settings": {
            "api_key": MISTRAL_API_KEY,
            "model": "mistral-small-latest",
            # 6 RPM is conservative for the Mistral free tier to avoid 429s.
            "rate_limit": {"requests_per_minute": 6},
        },
    },
)<p>The alias <code>mistral-small-latest</code> resolves to <a href="https://mistral.ai/news/mistral-small-3-1">Mistral Small 3.1</a>. It has a 128k context window and supports JSON-mode output.</p><p>The large model will be an <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/ai-connector">AI connector</a> pointing at Claude Sonnet. The Agent Builder UI walks you through creating the connector. Take a note of the connector ID since we’ll reference it from the workflow.</p><h2>Indexing NASA ASRS incident reports into Elasticsearch</h2><p>The ASRS dataset is indexed with keyword mappings for aggregation fields and text mappings for the narratives the models will read.</p><p>Download the ASRS CSV (the database publishes quarterly extracts at the <a href="https://asrs.arc.nasa.gov/search/database.html">ASRS Database Online</a> page), and index it. The mappings are:</p>{
  "properties": {
    "acn":          { "type": "keyword" },
    "flight_phase": { "type": "keyword" },
    "anomaly":      { "type": "keyword" },
    "synopsis":     { "type": "text" },
    "narrative":    { "type": "text" }
  }
}<p>The mapping types follow how each field is used. <code>flight_phase</code> and <code>anomaly</code> are mapped as <code>keyword</code> because we’ll run terms aggregations on them to build the sample, and aggregations need exact, non-analyzed values. <code>narrative</code> and <code>synopsis</code> are mapped as <code>text</code> because they hold free-form prose that the models will read. The companion notebook has the full loader script that reads the CSV and bulk-indexes the documents.</p><h2>Building a stratified sample for the planning LLM</h2><p>The YAML snippets in this and the following sections are steps of the workflow definition that the notebook registers via the Workflows API. The first two steps generate a representative sample: They aggregate by flight phase and by anomaly and pull a few documents per bucket with <code>top_hits</code>.</p>- name: by_phase
  type: elasticsearch.request
  with:
    method: POST
    path: "/incident_reports/_search"
    body:
      size: 0
      aggs:
        per_phase:
          terms:
            field: flight_phase
            size: 8
          aggs:
            sampled_docs:
              top_hits:
                size: 5
                _source: ["acn", "synopsis", "narrative"]

- name: by_anomaly
  type: elasticsearch.request
  with:
    method: POST
    path: "/incident_reports/_search"
    body:
      size: 0
      aggs:
        per_anomaly:
          terms:
            field: anomaly
            size: 8
          aggs:
            sampled_docs:
              top_hits:
                size: 3
                _source: ["acn", "synopsis", "narrative"]<h2>How the large LLM discovers a classification schema from the data</h2><p>The prompt needs both the question and the sample. A question alone may produce generic labels disconnected from the corpus, and a sample alone produces descriptive clusters that ignore the angle of the question. </p><p>When both are present and the output is structured, the model produces labels that are grounded in the data and oriented to the task: a schema of categorical fields, each with two to four mutually exclusive value options backed by evidence from the sample.</p><p>Here’s the planner step from the workflow:</p>- name: discover
  type: ai.prompt
  connector-id: "claude-sonnet"
  with:
    systemPrompt: |
      You design categorical schemas for use by downstream classifiers. A
      schema is a small set of fields, each with a few mutually exclusive
      values. Every value you propose must be grounded in evidence from the
      provided sample and must serve the stated question. You do not invent
      values that are not supported by at least two documents in the sample.
      You do not propose fields that a reasonable analyst could have written
      without reading the documents.
    prompt: |
      Question:
      ${{ inputs.goal }}

      Sample documents stratified by flight phase:
      ${{ steps.by_phase.output.aggregations.per_phase.buckets | json }}

      Sample documents stratified by anomaly type:
      ${{ steps.by_anomaly.output.aggregations.per_anomaly.buckets | json }}

      Propose between 2 and 4 categorical fields that:
      - serve the question (you can explain how)
      - depend on patterns visible in the sample (you can cite document IDs)
      - would not be obvious to someone who has not read the sample

      For each field, return: name (snake_case), definition, why_useful,
      and values (2 to 4 mutually exclusive options).

      For each value, return: value (snake_case) and definition.
    schema:
      type: object
      properties:
        fields:
          type: array
          minItems: 2
          maxItems: 4
          items:
            type: object
            required: [name, definition, why_useful, values]
            properties:
              name: { type: string }
              definition: { type: string }
              why_useful: { type: string }
              values:
                type: array
                minItems: 2
                maxItems: 4
                items:
                  type: object
                  required: [value, definition]
                  properties:
                    value: { type: string }
                    definition: { type: string }
    temperature: 0.3<p>The structured output schema enforces the shape of the response:</p><p> </p><ul><li><p><code>name</code>: Identifier for the categorical field.</p></li><li><p><code>definition</code>: What this field measures, in one sentence.</p></li><li><p><code>why_useful</code>: How this field serves the question; this also helps the downstream classifier understand the intent.</p></li><li><p><code>values</code>: Two to four mutually exclusive options. Each has a <code>value</code> and a <code>definition</code>.</p></li></ul><p>Here’s an example of the produced schema. We can see how the writer is being classified and the reasons why the model decided to create the category. <code>definition</code>and <code>why_useful</code> fields are used by the second model to classify the documents.</p>{
  "fields": [
    {
      "name": "attribution_style",
      "definition": "How the reporter frames responsibility for what happened.",
      "why_useful": "Surfaces reporting culture independent of the technical event. Useful for training and safety-management programmes that want to distinguish reporter style from incident type.",
      "values": [
        {
          "value": "self_critical",
          "definition": "Assigns the cause primarily to their own action, even when external factors clearly contributed."
        },
        {
          "value": "system_attributing",
          "definition": "Frames the cause as external: ATC, equipment, weather, or organisational factors."
        },
        {
          "value": "balanced",
          "definition": "Distributes responsibility across self and system without emphasising either."
        }
       ]
    },
    {
      "name": "procedure_orientation",
      "definition": "How the reporter relates to written procedure.",
      "why_useful": "Distinguishes pilots who frame events through SOPs from those who frame them through personal judgment.",
      "values": [
        // procedure_first, experience_first (same structure as above)
      ]
    }
  ]
}<h2>Human-in-the-loop schema approval with waitForInput</h2><p>The proposed schema is now passed to a person for approval. Elastic Workflows has a <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/wait-for-input"><code>waitForInput</code></a> step that pauses the workflow with a schema, exposes a form, and resumes when the input is submitted.</p><p><code>waitForInput</code> has no timeout of its own, so if nobody responds, the execution <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/human-in-the-loop#what-happens-while-the-workflow-is-paused">waits indefinitely</a>. To put a limit on that, set a workflow-level <code>settings.timeout</code>; if it elapses before the reviewer submits the form, the execution is canceled.</p>- name: human_gate
  type: waitForInput
  with:
    message: "Review and edit the proposed schema. The approved fields will be applied across the full corpus."
    schema:
      type: object
      required: [approved_fields]
      properties:
        approved_fields:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              definition: { type: string }
              values:
                type: array
                items:
                  type: object
                  properties:
                    value: { type: string }
        notes:
          type: string<p>When the workflow reaches this step, the execution pauses and the Kibana UI shows an "Action is required" badge. Clicking <strong>Provide action</strong> opens a form where the reviewer can paste or edit the schema JSON. Since <code>waitForInput</code> cannot be prepopulated from a previous step, the code polls the <em>discover</em> step output and prints a paste-ready JSON block that can be copied directly into this form.</p>discover = step_output(execution_id, "discover")  # polls until the step completes

# Strip <code>why_useful</code> (not part of the human_gate form) and wrap in the shape
# expected by the waitForInput form so this is paste-ready.
approved_fields = [
    {
        "name": field["name"],
        "definition": field["definition"],
        "values": [
            {"value": v["value"], "definition": v["definition"]}
            for v in field["values"]
        ],
    }
    for field in discover["content"]["fields"]
]

print(json.dumps({"approved_fields": approved_fields, "notes": ""}, indent=2))<p>The <code>step_output</code> helper (in the notebook) polls the execution via <code>GET /api/workflows/executions/{id}</code> until the <em>discover</em> step completes and then returns its output.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf3771a69b51089e7/6a87efeca8b3236eb5cc01f5/image1.png" alt="Kibana execution view showing an Elastic Workflow paused at the waitForInput step with the Provide action button highlighted for human-in-the-loop schema approval" /><p>Code JSON output pasted on Kibana:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt85d91c93043ad0d1/6a87f00d6ea6da5cfe0083e0/image5.png" alt="Kibana Provide action modal displaying the approved classification schema JSON with pilot experience level fields, where a reviewer edits the schema before the workflow resumes" /><p>The reviewer can keep useful fields, rewrite unclear ones, merge overlapping values, and add notes. After approval, the workflow resumes and sends the final schema to the executor step.</p><p>For a new corpus, keep this human gate in place. Once the schema is stable, you can auto-approve and only fall back to review when it’s worth it: Route just the low-confidence extractions to a person, or compare a new discovery run against the schema stored in the <code>schemas</code> index and trigger review only when fields or values change beyond a threshold.</p><h2>Classifying the full corpus with a smaller LLM</h2><p>By the time the workflow reaches this step, the open-ended part of the job is over. From here, the small model takes over and classifies each report against the approved schema.</p>- name: fetch_corpus
  type: elasticsearch.request
  with:
    method: POST
    path: "/incident_reports/_search"
    body:
      size: 100
      _source: ["acn", "narrative"]
      query:
        match_all: {}

- name: classify_all
  type: foreach
  foreach: "${{ steps.fetch_corpus.output.hits.hits }}"
  iteration-on-failure:
    retry:
      max-attempts: 5
      delay: "3s"
    fallback:
      - name: notify_failure
        type: slack_api.postMessage
        connector-id: "team-alerts"
        with:
          channelNames:
            - "#pipeline-alerts"
          text: "Classification failed for ACN ${{ foreach.item._source.acn }} after all retries."
    continue: true
  steps:
    - name: classify
      type: ai.agent
      inference-id: "mistral-small-extractor"
      timeout: "120s"
      with:
        message: |
          You will classify the following report against a fixed schema.
          For each field in the schema, assign exactly one of its value
          options, or null if none of the values clearly applies. Include
          the short quote that supports the assignment and a confidence
          score between 0 and 1. Set review_required to true if any field
          returned null or any confidence is below 0.5.

          Schema:
          ${{ steps.human_gate.output.approved_fields | json }}

          Report:
          ${{ foreach.item._source.narrative }}
        schema:
          type: object
          properties:
            field_values:
              type: object
              additionalProperties: true
            review_required: { type: boolean }
    - name: write_extraction
      type: elasticsearch.index
      with:
        index: extractions
        document:
          acn: "${{ foreach.item._source.acn }}"
          field_values: "${{ steps.classify.output.structured_output.field_values }}"
          review_required: "${{ steps.classify.output.structured_output.review_required }}"<p><em>Note: The classification step uses </em><em><code>ai.agent</code></em><em> instead of </em><em><code>ai.prompt</code></em><em> because </em><a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/ai-steps#step-types"><em><code>ai.agent</code></em><em> accepts an </em><em><code>inference-id</code></em></a><em>, which lets it call the Elasticsearch </em><em><code>_inference</code></em><em> endpoint directly, while </em><em><code>ai.prompt</code></em><em> only accepts a </em><em><code>connector-id</code></em><em>.</em></p><p>The <code>fetch_corpus</code> step is the third <code>elasticsearch.request</code> in the workflow, so it’s worth saying why we read from the index again. The first two (<code>by_phase</code> and <code>by_anomaly</code>) only pulled a small stratified sample for the planner to reason over, not the data to label. Now that the schema is approved, <code>fetch_corpus</code> pulls the documents we actually want to classify. We cap it at 100 with <code>match_all</code> to keep the demo fast; this is where you would page through the full corpus.</p><p>For every field, it returns a value (or null), a confidence, and a short quote. Setting <code>additionalProperties: true</code> in the JSON schema lets the step return one entry per field without the workflow having to know the field names ahead of time. A stored extraction looks like this:</p>{
  "acn": "2238341",
  "field_values": {
    "attribution_style": {
      "value": "self_critical",
      "confidence": 0.82,
      "quote": "I should have caught the altitude bust earlier"
    },
    "procedure_orientation": {
      "value": "procedure_first",
      "confidence": 0.44,
      "quote": "we ran the QRH before doing anything else"
    }
  },
  "review_required": true
}<p>Here, <code>review_required</code> is <code>true</code> because <code>procedure_orientation</code> came back at <code>0.44</code> confidence, below our <code>0.5</code> threshold, which is the signal a confidence-based quality gate would act on.</p><p>The <code>fetch_corpus</code> step pulls the documents to classify. The <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/foreach"><code>foreach</code></a>step iterates over them sequentially, and <code>iteration-on-failure</code> handles the errors: <code>retry</code> covers transient API errors from the inference endpoint, and, if all attempts fail, the <code>fallback</code> step posts to <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/slack-action-type#slack-workflow-examples">Slack</a> so the failure doesn’t pass silently. (An <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/email-action-type">email</a> connector works the same way.) <code>continue: true</code> then lets the loop move on to the next document instead of failing the whole run. </p><p><em>For production-scale corpora, consider using </em><a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/composition#workflow-executeasync"><em><code>executeAsync</code></em></a><em>, which is the fan-out version of execute.</em></p><h2>Writing schemas and extractions back to Elasticsearch</h2><p>The workflow produces two things: the approved schema and the per-document field values. The <code>store_schema</code> step runs right after the human gate, before the classification step fans out:</p>- name: store_schema
  type: elasticsearch.index
  with:
    index: schemas
    document:
      question: "${{ inputs.goal }}"
      approved_fields: "${{ steps.human_gate.output.approved_fields }}"
      reviewer_notes: "${{ steps.human_gate.output.notes }}"<p>Each extraction is written inside the <code>foreach</code> loop, so results are persisted as they’re produced rather than batched at the end.</p><p>The <code>schemas</code> index holds one document per discovery run (question, approved fields, reviewer notes). The <code>extractions</code> index holds one document per report per schema version. </p><h2>What this two-tier LLM orchestration pattern gives you</h2><p>We built one Elastic workflow that pulls a stratified sample from an incident report index, sends it with a question to a large reasoning model to generate a classification schema based on the data and a user-defined angle, pauses for human approval, and then iterates over the full corpus with a small Mistral model that assigns one value per field. </p><p>The approved schema and per-document field values are written back to Elasticsearch as structured data.</p><p>The point of the exercise is that two different shapes of work, schema discovery, and schema application can use two different model tiers and that a workflow lets you write the routing decision down.</p><h2>Next steps for your own LLM pipeline</h2><ul><li><p>Try it on a corpus of your own. The pattern doesn’t care whether the input is incident reports, customer feedback, weekly status updates, or property listings.</p></li><li><p>Promote the <code>foreach</code> step to <code>workflow.executeAsync</code> once you’re comfortable for parallel fan-out at scale.</p></li><li><p>Schedule the rediscovery workflow on a cron trigger so you can discover different schema variations based on the data that comes in.</p></li><li><p>Read the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows documentation</a> for the full step catalog.</p></li></ul><h3><strong>Related reading</strong></h3><ul><li><p><a href="https://www.elastic.co/search-labs/blog/build-ai-agents-elastic-inference-service">Build AI agents with Elastic Inference Service</a> (EIS) covers the broader multi-model wiring pattern via EIS, complementary to the Workflows-orchestrated split shown here.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/ai-agentic-workflows-elastic-ai-agent-builder">How to build AI agentic workflows with Elasticsearch</a> is a higher-level survey of how Agent Builder and Workflows fit together.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/langextract-elasticsearch-tutorial-usage-example">LangExtract and Elasticsearch tutorial</a> explores a different extraction pattern using a hand-authored schema; useful for contrast with the discover-then-apply approach above.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/llm-orchestration-elastic-workflows</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/llm-orchestration-elastic-workflows</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdf42ed268fb6f953/6a87ef5c386ac3fab0adf4e2/image3.png" length="0" type="image/png"/>
    <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One setting for production vector search: How vectordb_document mode tunes Elasticsearch automatically]]></title>
    <description><![CDATA[Benchmarks across four datasets show how one index setting applies bfloat16 vector quantization, cache preloading and parallel merges to improve vector search throughput and decrease storage.]]></description>
    <content:encoded><![CDATA[<p>We’re introducing one setting for production-ready vector search. The new <code>vectordb_document</code> index mode stores raw vectors as bfloat16 to halve their disk footprint and preloads vector data structures into the filesystem cache. It also lets segment merges run unthrottled and in parallel. In our benchmarks, it delivered up to 2× the queries per second (QPS) at high recall with <code>bbq_hnsw</code> and cut time to a search-ready index by roughly 20% with <code>bbq_disk</code>, with no tuning required. It’s available in Stateful Elasticsearch 9.5 and in Elasticsearch Serverless today.</p><p>Elasticsearch supports a diverse range of use cases, including observability metrics and logs and complex geospatial analysis. However, as vector search becomes a core component of modern architectures, the need for specialized optimization has grown. Achieving peak performance for vector-heavy workloads often requires navigating a complex space of configuration knobs. To reduce this operational toil, we wanted to provide opinionated, high-performance defaults through a single setting that simplifies performance tuning for production environments. The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-vectordb-document-mode"><code>vectordb_document</code> index mode</a> is designed specifically for optimal vector search workloads.</p><h2>How to set up vectordb_document mode</h2><p>Setup is a single setting. When you’re creating an index, define the following in the index settings:</p>PUT my-index
{
  "settings" : {
    "index" : {
      "mode" : "vectordb_document"
     }
   }
}<p><code>vectordb_document</code> mode is available on all Elasticsearch subscription tiers, including Basic.</p><p>Most indices used for vector search also support other operations, such as aggregations or geo-search. They also support hybrid search. Because vector search is typically the most computationally demanding part of these mixed workloads, we recommend using the <code>vectordb_document</code> index mode to prioritize performance for your most intensive operations. An index in <code>vectordb_document</code> mode remains highly capable, supporting almost all operations available in the default standard mode, while optimizing specifically for the resource-heavy demands of vector search.</p><p>The <code>“_document”</code> suffix represents our roadmap. We're also developing a <code>“vectordb_columnar”</code> mode as another way to optimize vector search, suited to different data and access patterns.</p><h2>Elasticsearch vector search benchmarks across four datasets</h2><p>To validate these defaults, we performed extensive benchmarking across various datasets and two index types: <code>bbq_hnsw</code> and <code>bbq_disk</code>. We ran all benchmarks on a single-node Elasticsearch instance on AWS using a <code>c8gd.2xlarge</code> instance (Graviton 4, ARM64, local NVMe SSD) with a pod limited to 8GB RAM (2GB heap) and 4 CPUs, using a single shard.</p><p>Datasets:</p><p><strong>Dataset</strong></p><p><strong>Vectors</strong></p><p><strong>Dims</strong></p><p><strong>Use case</strong></p><p><code>laion-img-emb-512-20M-cosine</code></p><p>20 million</p><p>512</p><p>Pure vector search (low dim)</p><p><code>msmarco-v2-10M-jina-v5-1024</code></p><p>10 million</p><p>1024</p><p>Pure vector search (med dim)</p><p><code>dbpedia-openai-1M-3072-angular</code></p><p>1 million</p><p>3072</p><p>Pure vector search (high dim)</p><p><code>arxiv-for-fanns-large</code></p><p>2.7 million</p><p>4096</p><p>Filtered search</p><h3>bbq_hnsw: HNSW index performance with vectordb_document</h3><h4>Query throughput and recall</h4><p>On all four datasets, <code>vectordb_document</code> produced a better QPS–recall curve, and the shape of the advantage is itself informative. On <code>dbpedia-openai-1M</code>, <code>msmarco-v2-10M</code>, and <code>arxiv-for-fanns-large</code>, the curves start close together at low recall (essentially identical on arXiv) and separate as recall rises, reaching roughly 1.4×, 2.2×, and 2× at the high-recall end. On <code>laion-img-emb-512-20M</code>, the curves are apart from the start and settle at about 2× from recall 0.80 upward.</p><p>The gap widens with recall because higher recall is bought with oversampling, and oversampling is exactly where <code>vectordb_document</code> saves. The two effects compound. bfloat16 storage halves the bytes read per rescored candidate. More importantly for hierarchical navigable small world (HNSW), the oversample factor is applied to the graph search itself;  each segment is searched for k × oversample candidates, so cost scales with oversample times segment count. Unthrottled parallel merging leaves fewer, larger graphs (14–21 segments versus 22–29), and the graph and quantized vector files are preloaded into the filesystem cache, so <code>vectordb_document</code> pays far less for each increment of oversample.</p><p>Comparing identical search settings, rather than equal recall, makes the effect explicit. With rescoring switched off, the two modes are within 1%–28% of each other; at oversample 5, <code>vectordb_document</code> is 2–4× faster; at oversample 10, up to 10×. Those high-oversample settings sit off the QPS–recall frontier, which is why the curves above top out nearer 2×, but they isolate where the saving comes from.</p><p><strong>Oversample</strong></p><p><strong>DBpedia</strong></p><p><strong>LAION</strong></p><p><strong>MS MARCO</strong></p><p><strong>arXiv</strong></p><p>off</p><p>1.23×</p><p>1.28×</p><p>1.11×</p><p>1.01×</p><p>5</p><p>4.07×</p><p>2.66×</p><p>2.66×</p><p>2.05×</p><p>10</p><p>10.5×</p><p>2.33×</p><p>2.46×</p><p>4.03×</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7be4c804a0ed7c22/6a86d603e3ec262b84519f42/1.png" alt="Elasticsearch vector search QPS vs recall for bbq_hnsw on LAION 512-dim dataset showing 2x throughput with vectordb_document" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt231178fa23d0726e/6a86d61ceb4ccc9c18f20f42/2.png" alt="Elasticsearch vector search QPS vs recall for bbq_hnsw on MS MARCO 1024-dim dataset with vectordb_document mode enabled" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt80dfbad51b8f3bb9/6a86d63feb4ccc69c2f20f48/3.png" alt="Elasticsearch vector search QPS vs recall for bbq_hnsw on DBpedia 3072-dim dataset with vectordb_document mode enabled" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt85e29cf189fcc51b/6a86d65173f7435dda4861ed/4.png" alt="Elasticsearch vector search QPS vs recall for bbq_hnsw on arXiv 4096-dim filtered search with vectordb_document mode" /><h4>Indexing speed and merge behavior </h4><p>In our benchmarks, <code>vectordb_document</code> increased upload time by roughly 16%. This is expected: merges now run unthrottled and parallelized across threads, so they compete with indexing for CPU while documents are still being ingested. Measured indexing time rose 9%–26% across the four datasets. HNSW graph construction is CPU-bound, so that contention is felt directly.</p><p>The same changes make the post-upload phase much cheaper. Disabling auto-throttling removes merge rate limiting entirely (in default mode, DBpedia spent 57% of its merge time throttled), and bfloat16 halves the raw vector data, cutting total bytes merged by 42%–49%. Post-ingest merging finished 75%–86% sooner on three of the four datasets, which brings total time to a search-ready index to only about 6% above the baseline. DBpedia was the exception in the other direction: Its merge tail dominated, so total time actually fell 25%.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaf89df79ce0e2185/6a86d6f4da6aead8bb379480/1.png" alt="Upload time comparison across four datasets for bbq_hnsw HNSW index with and without vectordb_document mode" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9162cec285c4f2a1/6a86d708a8b3231735cbfbbd/2.png" alt="Total ingest time including merges for bbq_hnsw HNSW index comparing default mode to vectordb_document mode" /><h3>bbq_disk: disk-based vector search performance with vectordb_document</h3><h4>bbq_disk query throughput and recall</h4><p>For <code>bbq_disk</code> indices, <code>vectordb_document</code> mode's impact on search throughput varied by dataset. On the lower-dimensional datasets, QPS was essentially unchanged at equal recall: <code>laion-img-emb-512-20M-cosine</code> (512 dims) and <code>msmarco-v2-10M-jina-v5-1024</code> (1024 dims) track each other closely across the recall range, with <code>vectordb_document</code> ahead in the lower recall end and a few percent behind at the high-recall end. On the higher-dimensional datasets, we saw a consistent gain of roughly 20% at equal recall: <code>dbpedia-openai-1M-3072-angular</code> (3072 dims) and <code>arxiv-for-fanns-large</code> (4096 dims).</p><p>Our interpretation is that this is mainly a rescoring effect. <code>vectordb_document</code> stores vectors as bfloat16, so each rescored candidate reads 2× dims bytes instead of 4× dims. The sweep supports this directly: The advantage grows with the query-time oversample factor, which is exactly what sets how many candidates get rescored. On DBpedia, the QPS ratio rises from 1.16× at oversample 3 to 4.3× at oversample 8, and on arXiv from 1.20× to 1.56×, while on laion and MS MARCO, it stays flat or drifts just below 1. Rescoring is simply a much larger share of the query on the smaller, higher-dimensional datasets; on the 10 million and 20 million ones, scanning 1-bit posting lists dominates.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc3dccb980e1e3a57/6a86d7ebeb4ccc0f68f20f4c/1.png" alt="Elasticsearch vector search QPS vs recall for bbq_disk on LAION 512-dim dataset with vectordb_document mode enabled" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltffd0c65e300e7985/6a86d80462f1e2732aafe73f/2.png" alt="Elasticsearch vector search QPS vs recall for bbq_disk on MS MARCO 1024-dim dataset with vectordb_document mode" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7574001d33d755e7/6a86d81a386ac33034adef66/3.png" alt="Elasticsearch vector search QPS vs recall for bbq_disk on DBpedia 3072-dim showing 20% gain with vectordb_document" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt45ec0bc4e1a47504/6a86d82aa1b20b40a18701ff/4.png" alt="Elasticsearch vector search QPS vs recall for bbq_disk on DBpedia 3072-dim showing 20% gain with vectordb_document" /><h4>Indexing speed and merge behavior </h4><p>Across the four datasets, <code>vectordb_document</code> cut total time to a fully merged, search-ready index by about 20%. Every dataset improved, from 6% on <code>msmarco-v2-10M</code> to 50% on <code>dbpedia-openai-1M</code>, where the post-upload merge phase alone fell from 322 seconds to 54 seconds. Upload time on its own is less clear-cut: On the day charted below, it finished 2%–15% sooner, depending on dataset, but on the earlier run set, it was marginally slower, so we read upload as unchanged to modestly faster and treat time-to-searchable as the real result.</p><p>The gain is almost entirely in merging. In default mode, Elasticsearch rate-limits how fast merges may write, and that limiter was binding hard: 75% of all merge time on DBpedia and 73% on laion was spent paused by it. <code>vectordb_document</code> disables the limiter, so paused time is zero and merge time drops 63% on DBpedia and 42% on laion. bfloat16 helps for the same reason: The limiter meters bytes written, so halving the raw vector data means less to write under the cap. The gradient across datasets follows the throttling rather than the byte count: arXiv, at 21% of merge time throttled, saw 30% less merge time, while MS MARCO, never throttled, got 55% fewer bytes but only 11% less merge time.</p><p>The two index types respond differently on ingest because merging costs something different in each. Merging <code>bbq_hnsw</code> segments means rebuilding HNSW graphs: CPU-bound work that competes directly with the equally CPU-bound graph construction on incoming documents, and on a 4-CPU pod that contention surfaces as a slower upload. <code>bbq_disk</code> merges are dominated by writing bytes rather than by CPU, so lifting the rate limiter spends I/O bandwidth that the local NVMe has to spare, and bfloat16 means that there are fewer bytes to write in the first place. Both index types reach a fully merged index far sooner; the difference is only whether the upload phase pays for it.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5792080b8d372fdd/6a86d85a8bbe584d8790e7cf/5.png" alt="Upload time comparison across four datasets for bbq_disk index with and without vectordb_document mode" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfc61269e37f0d5f4/6a86d86b762d1efd795cb927/6.png" alt="Total ingest time for bbq_disk index showing 20% faster time to search-ready with vectordb_document mode" /><h2>What vectordb_document sets under the hood</h2><h3><code>element_type</code> (<code>dense_vector</code>)</h3><ul><li><p><strong>Value: </strong>bfloat16.</p></li><li><p><strong>Impact: </strong>Stores each dimension of the raw vector as bfloat16 instead of the default float32, halving the storage of raw vectors with negligible impact on recall.</p></li><li><p><strong>Benefit: </strong>Lower disk footprint (reduced total cost of ownership [TCO]), faster fetching of vectors for rescoring.</p></li></ul><h3>Dynamic float array mapping</h3><ul><li><p><strong>Value: </strong>Float arrays with 32 or more values are dynamically mapped as <code>dense_vector</code>. (In the default mode, this threshold is 128.)</p></li><li><p><strong>Impact:</strong> No need to explicitly declare the field as a dense vector field; the system identifies it automatically.</p></li><li><p><strong>Benefit: </strong>Configuration simplicity.</p></li></ul><h3><code>exclude_source_vectors</code></h3><ul><li><p><strong>Value: </strong>true.</p></li><li><p><strong>Impact: </strong>Vectors are stored once in the vector index and not duplicated in <code>_source</code>. They’re omitted from <code>_source</code> in responses, but they can still be retrieved on request.</p></li></ul><p><strong>Benefit: </strong>Lower disk footprint; faster queries, since large vectors are no longer shipped with every <code>_source</code> fetch.</p><h3><code>index.store.preload</code></h3><ul><li><p><strong>Value: </strong>[<code>"vex"</code>, <code>"veq"</code>, <code>"veb"</code>, <code>"cenivf"</code>].</p></li><li><p><strong>Impact: </strong>Preloads search-time vector data structures into the filesystem cache whenever new segments are opened.</p></li><li><p><strong>Benefit: </strong>Reduced query latency.</p></li></ul><h3><code>index.merge.intra_merge_parallelism_enabled</code></h3><ul><li><p><strong>Value: </strong>true.</p></li><li><p><strong>Impact: </strong>Use parallel threads for segment merging to achieve optimal segment sizes.</p></li><li><p><strong>Benefit: </strong>Faster convergence to fewer, larger segments leads to better recall, lower query latency.</p></li></ul><h3><code>index.merge.scheduler.auto_throttle</code></h3><ul><li><p><strong>Value: </strong>false.</p></li><li><p><strong>Impact: </strong>Allows merges to proceed at full speed.</p></li><li><p><strong>Benefit: </strong>Merges reach optimal segment sizes sooner, resulting in better recall, lower query latency.</p></li></ul><p>While these defaults are optimized for the majority of use cases, most can be overridden individually to suit unique hardware constraints or extreme performance requirements, with one exception: <code>exclude_source_vectors: true</code> is locked in and cannot be changed on a <code>vectordb_document</code> index.</p><h2>Summary</h2><p>The <code>vectordb_document</code> index mode is a production-ready foundation for vector search: By adopting high-performance defaults, teams can focus on building features rather than on hand-tuning merge or storage settings or on adjusting preload settings. </p><p>Across four datasets, the picture is consistently favorable, though the balance differs by index type. With <code>bbq_hnsw</code>, QPS recall improved on every dataset, from roughly on par at low recall to as much as double at the high-recall end, in exchange for a modest ingest cost of about 16% longer upload and 6% longer time to a search-ready index. With <code>bbq_disk</code>, the trade runs the other way: Total time to a search-ready index dropped by about 20%, while search improved by about 20% at equal recall on the higher-dimensional datasets (3072 and 4096 dims) and was essentially unchanged on the lower-dimensional ones. The split comes down to what merging costs in each: Rebuilding HNSW graphs is CPU work that competes with indexing; whereas <code>bbq_disk</code> merges are write-bound and simply run faster once they’re unthrottled.</p><p>In both cases, the new defaults move the system in the direction that most users want, with no per-index tuning required. And, for the settings that genuinely depend on the data itself, such as the degree of quantization, auto-calibration now derives them for you. (See <a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-elasticsearch">How Elasticsearch auto-tunes vector quantization to hit your recall target</a>.)</p><p>Try it out by creating an index with the <code>vectordb_document</code> index mode in Stateful Elasticsearch 9.5 or in Serverless.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-vectordb-document-mode</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-vector-search-vectordb-document-mode</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Mayya Sharipova,Gilad Gal,Quinn Harper]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt24f9f391ea8b5d02/6a86ce55bed19d4545af9b54/unnamed.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ES95: Adaptive Compression for Elasticsearch Time-Series Metrics]]></title>
    <description><![CDATA[ES95 is Elasticsearch 9.5's new adaptive time series codec that cuts @timestamp storage by 92% and floating point fields by up to 74%, with zero configuration.]]></description>
    <content:encoded><![CDATA[<p><em>The best compression strategy is the one that understands your data.</em></p><p>Observability workloads are storage-intensive by nature, and the composition of that storage determines both cost and query performance. <code>ES95</code> introduces adaptive compression: rather than applying the same encoding to every numeric field, it automatically selects the encoding that best matches each field's structure. The result is a 33.6% reduction in total doc-values storage, 19% to 74% reduction on floating-point gauge metrics and a 92% reduction in <code>@timestamp</code>. No configuration or migration required.</p><h3>Observability data is storage-intensive</h3><p>Data processing systems are rarely limited by how fast they can compute. They’re limited by how fast they can move bytes: off disk, across the network, and through the memory hierarchy. Compression is how a storage engine trades CPU time for memory bandwidth, spending comparatively cheap CPU cycles so fewer bytes have to travel through the parts of the system that are usually constrained. In a read-heavy system like Elasticsearch, that trade-off pays back every time data is queried, often long after it was written.</p><p>Storage size and query performance move together; fewer bytes on disk means fewer bytes to read on every range query, every aggregation and every dashboard load. Compression is not just about saving storage. Every byte that is never written is also a byte that never has to be read.</p><p>The right encoding depends on the structure of the values themselves, and the largest wins come from exploiting the structure already present in the data rather than squeezing an opaque stream of bytes. Few workloads expose that structure more clearly than observability metrics.</p><p>A single host reports hundreds of metrics every few seconds, including CPU utilization, memory ratios, request latencies, and network throughput. Multiply that by thousands of hosts across weeks of retention, and the bytes accumulate fast. Most of that volume is structured but not uniform: timestamps arrive at near-constant intervals from thousands of concurrent series, counters increase monotonically, while gauges like <code>23.47</code> or<code>1.15</code> are short decimal measurements.</p><p>A fixed compression approach cannot adapt to that variety. A timestamp column and a floating-point gauge column compress through fundamentally different techniques, but a codec that applies the same approach to both will necessarily handle one of them poorly. For most of Elasticsearch's time-series codec history, gauges were on the losing end of that trade-off.</p><h3>The structure the old codec was not built to exploit</h3><p>Elasticsearch stores time-series numeric values in <em>doc values</em>: a column-oriented structure where all values for the same field sit adjacent on disk. That adjacency makes compression possible: the codec compares consecutive values of the same field, finds patterns, and exploits them.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2386e2eb944a502a/6a85671fa8b3235ccccbf4f9/unnamed.png" alt="Row-oriented vs column-oriented storage in Elasticsearch time series indices showing how field values cluster on disk" /><p>The <a href="https://www.elastic.co/search-labs/blog/time-series-data-elasticsearch-storage-wins">time-series codec before ES95</a> applied the same fixed encoding to every numeric field: delta encoding followed by normalization, GCD (greatest common divisor) reduction, and bit-packing. Each encoding technique activated where it helped and skipped where it would not. For timestamps and integer counters, this approach was remarkably effective. For floating-point gauges, it could find almost nothing to work with.</p><p>The reason is how they’re stored. To support range queries, Elasticsearch stores floating-point values as integers that preserve numeric ordering. A change of 0.01 in a CPU percentage reading translates to a jump of trillions in that integer space. The codec sees those large jumps and has no strategy to further reduce their footprint. Storage stays near the original eight bytes per value.</p><p>The codec was doing the right thing with the representation it had, but the latter was chosen for querying, not compression, and the goals conflict at the bit level.</p><h3>The cost of a fixed format</h3><p>A compression stage for floating-point values was already on the roadmap, so the interesting part wasn’t the algorithm. The obstacle was architectural.</p><p>The previous codec baked its compression approach into the storage format. Adding a new encoding meant changing the meaning of existing bytes on disk, which forced a format migration, a rollout that can last weeks or months in large production clusters. Over time, that migration burden constrains codec development itself. The question stops being <em>Is this a good compression idea?</em> and becomes <em>Is it worth another format migration?</em> That rigidity limits the cadence of codec evolution and leads to missed compression improvements.</p><h3>The right encoding without configuration</h3><p><code>ES95</code> solves this at the architecture level for time-series indices. Each field's encoding is no longer baked into the format. It is selected automatically at write time, based on what the field mapping already declares: the field's name, its data type, and its metric role. Timestamps are encoded differently than counters. Counters are encoded differently than gauges. <code>ES95</code> encodes all of them, and it chooses the right strategy for each.</p><p>Users already tell Elasticsearch everything the codec needs to know. The mapping describes the data; the codec chooses the compression strategy.</p><p>Compression strategy is a codec concern, not a user concern.</p><p>The alternative would have been to expose per-field encoding selection as a configuration parameter, letting users opt in to better compression for specific fields. That would shift the burden of knowing which encoding fits which data type onto those least equipped to make that call and would guarantee that most deployments never see the benefit. <code>ES95</code> keeps that decision inside the codec, where it belongs. This matters most in managed and serverless deployments, where users expect the system to automatically make optimal storage decisions.</p><h3>The timestamp result nobody planned for</h3><p>With the adaptive architecture in place, the team set out to ship the planned float-compression algorithm. Before it arrived, the architecture proved itself by substantially improving compression for timestamps.</p><p>A time-series index is sorted first by its time-series identifier (<code>_tsid</code> constructed by the metric’s dimensions) and then by timestamp within each series. Timestamps on disk aren’t one smooth sequence; there are many smooth sequences laid end to end, one per series, with a large jump at every boundary where one series ends and the next begins.</p><p>The codec compresses data in fixed-size blocks without regard to those series boundaries. A block straddling a series boundary holds timestamps from two different series. The jump between them breaks monotonicity, reducing delta encoding effectiveness on blocks spanning different time series. A block that would otherwise compress to near-zero bits per value ended up needing nine or more, because bit-packing encodes every value in a block using the same fixed number of bits, so one large jump sets the cost for all of them.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd7576b4b4a9e1c2d/6a85677d1eb9e5448c2c3336/unnamed.png" alt="SplitDelta encoding splits compression blocks at series boundaries, cutting @timestamp storage by 92.4%" /><p>In the ideal case, every block belongs to a single series: timestamps increase at near-constant intervals, delta encoding captures the regularity, and bit-packing compresses the result to near-zero bits per value. With few series, boundary blocks are rare and the overhead barely registers. On an observability cluster ingesting millions of documents across thousands of series, that changes. The cost scales along two dimensions: series count and data density. More series means more boundary events; sparser series means multiple jumps packed into single blocks. In high-churn environments, both compound, and boundary blocks accumulate into a standing tax on the most-read field in any time-series workload. It’s why <code>@timestamp</code> storage grew faster than the data that produced it.</p><p>The fix was to detect series boundaries and treat each run as its own independent sequence. Instead of trying to encode across the jump between two series, which forces every value in the block to pay the storage cost of that one large jump, each run is compressed on its own terms. The boundary simply becomes a seam: the jump is never seen by the encoder on either side.</p><p>That encoding is called <code>SplitDelta</code>. <code>@timestamp</code> and monotonic long counters now use it by default. No format change. No migration. Existing segments retain legacy encoding.</p><p>On the high-cardinality <a href="https://github.com/elastic/rally">Elasticsearch Rally</a> benchmark, that single unplanned encoding cut counters storage by 20%–30% and <code>@timestamp</code> storage by 92.4%, from 1.03 GB to 79 MB. Gigabytes to megabytes, and no, that isn’t a typo.</p><p>The pluggable pipeline had already paid for itself. <code>SplitDelta</code>, which wasn’t part of the original plan, slotted in without a format change or migration before ALP even shipped.</p><h3>ALP: recovering the decimal that was always there</h3><p>Most floating-point metrics can be expressed as short decimals with no loss of accuracy: CPU utilization at <code>23.47</code>, load average at <code>1.15</code>. <a href="https://dl.acm.org/doi/10.1145/3626717">ALP</a> (Adaptive Lossless floating-Point compression) recovers that decimal structure from the floating-point representation, converting values into integers that the existing pipeline already handles well. <code>ES95</code> feeds ALP's output into the same mature integer compression pipeline used for timestamps and counters, extracting additional savings rather than treating ALP as a standalone encoding. Values that don’t fit ALP's model (such as irregular high-precision floats or special values) fall back to direct bit-packing or the original representation without degrading the rest of the block.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt056bdb51022428c9/6a85689f4acc96ce082471b1/unnamed.png" alt="ALP converts floating-point time series metrics to integers for compression through the Elasticsearch encoding pipeline" /><p><code>ALP</code> lets Elasticsearch treat floating-point metrics according to the structure they actually contain rather than the binary representation they happen to use. It’s applied automatically to double-valued gauge fields, selected by field type and metric role, through exactly the door the architecture had built for it.</p><h3>What the numbers say</h3><p>Here’s what the <a href="https://github.com/elastic/rally">Elasticsearch Rally</a> benchmark looks like on a high-cardinality workload containing 2.26 billion data points. Results are from an internal <code>tsdb-metricsgen</code> benchmark.</p><p><strong>Field or metric</strong></p><p><strong>Storage reduction (%)</strong></p><p><code>@timestamp</code></p><p><strong>−92.4%</strong></p><p><code>cpu.load_average.5m</code></p><p><strong>−74.3%</strong></p><p><code>system.cpu.utilization</code></p><p><strong>−63%</strong></p><p><code>memory.utilization</code></p><p><strong>-19%</strong></p><p>Total doc values</p><p><strong>−33.6%</strong></p><p>That overall 33.6% reduction deserves context.</p><p>A time-series index contains more than metrics. Every data point also carries the labels that identify the series: host names, IP addresses, regions, container IDs. Those dimension fields are stored as keywords. <code>ES95</code> doesn’t target dimensions.</p><p>On this benchmark, two dimension fields, <code>host.ip</code> and <code>host.mac</code>, accounted for 44% of doc-values storage after <code>ES95</code> ran. The 33.6% total reflects that mix. The per-field breakdown is the honest picture. Compression for dimension fields is an active area of work.</p><p>The per-field variation is the most convincing result. Some gauges shrank by nearly three quarters, while others moved by less than a fifth. That spread is direct evidence that <code>ES95</code> matches compression to the structure actually present in each field. A fixed encoding treats every field identically and misses most of those wins.</p><h3>Better compression without extra configuration</h3><p>The storage reductions from <code>SplitDelta</code> and <code>ALP</code> are the most visible results of <code>ES95</code>. The more consequential result is the architecture that produced them.</p><p>Before <code>ES95</code>, every new compression technique required a format evolution. That reality shaped which ideas were practical to pursue. Today, new encodings become implementation decisions inside the codec rather than migration projects. Existing data never needs to move, and users gain better compression on newly written data simply by upgrading Elasticsearch. <code>SplitDelta</code> and <code>ALP</code> are the first encodings to benefit from this architecture. They will not be the last.</p><p>Asking users to choose compression algorithms would only duplicate information Elasticsearch already has. There are no per-field compression parameters to tune, and no expert knowledge is required to get good storage efficiency. Different fields get different strategies because <code>ES95</code> understands what kind of data each field contains, not because a user configured it. As the codec evolves, those decisions evolve with it. The API does not.</p><p>In Elasticsearch Serverless, good defaults are part of the product. Users expect the system, not configuration, to make storage decisions. <code>ES95</code> is designed to honor that expectation: encoding that starts right and gets better over time.</p><h3>The compression was always there</h3><p><code>ES95</code> establishes a new standard for how time-series codec evolution works. New encodings become implementation decisions, not migration projects. Users get better compression on newly written data with every Elasticsearch upgrade.</p><p>The compression was already in the data. <code>ES95</code> just removed what was hiding it.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/time-series-database-compression-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/time-series-database-compression-elasticsearch</guid>
    <category><![CDATA[Index Data]]></category>
    <category><![CDATA[Operations]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Salvatore Campagna]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt27b1a5308344647f/6a8566caf9838a4c963bea55/unnamed.png" length="0" type="image/png"/>
    <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Skip the mapping explosion: ES|QL queries schemaless JSON keys without dynamic mapping]]></title>
    <description><![CDATA[Flattened fields turn Elasticsearch into a schema-on-read store where you index schemaless data under one mapping, then use ES|QL's FIELD_EXTRACT to pull out any JSON key you need to filter, group or join on, with predicates pushed into the columnar store.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> now reads<a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/flattened"> <code>flattened</code> fields</a>. FIELD_EXTRACT pulls any key out of a schemaless JSON object so you can filter, group, sort and join on keys you never mapped. The planner pushes those predicates into the columnar store rather than parsing the whole blob per row, which means dynamic JSON keys from OTel attributes, log labels, user metadata or whatever else you didn't want to map individually are queryable without causing a<a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/mapping-limit"> mapping explosion</a>.</p><h2>Why dynamic mapping breaks down with schemaless data</h2><p>Elasticsearch wants a schema. Every field you index has a mapping that defines its type, its analyzer (for text), and how it is stored. That schema makes storage compression efficient, with fast search and cheap aggregation. It becomes a liability the moment your data stops looking like a database table.</p><p>Consider the shapes that show up in real systems:</p><ul><li><p>Log events where every service adds its own attributes. One emits <code>labels.region</code>, another <code>labels.k8s.pod</code>, and another <code>labels.tenant_id</code>.</p></li><li><p>OpenTelemetry resource attributes, where the set of keys is defined by whatever agent happened to send the span.</p></li><li><p>User-supplied metadata bags, feature flags, or tagging systems, where the keys are open-ended by design.</p></li></ul><p>If you map each key as its own field, the mapping grows unbounded. This is the classic "mapping explosion." Thousands of dynamically created fields inflate the cluster state, slowing down every mapping update and eventually hitting the field limit. Each field also carries overhead in the index. You pay a structural cost for keys you didn’t plan for and may query only once.</p><p>The naive escape hatch is to store the whole object as a string and give up on querying its contents. That trades one problem for another: you keep the data but lose the ability to filter or group by anything inside it.</p><p>The <code>flattened</code> field type is the better path. You can index an entire JSON object under a single mapped field, keeping the keys queryable with almost none of the mapping-explosion cost. </p><p>This post covers how a flattened field stores the data on a disk and how ES|QL reads it back.</p><h2>How flattened fields index dynamic JSON keys under one mapping</h2><p>Map one field as flattened:</p>PUT logs
{
 "mappings": {
"properties": {
"labels": { "type": "flattened" }
   }
 }
}<p>Then write arbitrary nested JSON into it:</p>POST logs/_doc
{
 "labels": {
   "region": "us-east-1",
   "k8s": { "pod": "web-7f9", "node": "ip-10-0-0-3" },
   "retries": 4
 }
}<p>There is exactly one field in the mapping, <code>labels</code>, no matter how many keys appear across your documents. The cluster state doesn’t grow when a new key shows up. The subkeys remain individually searchable. You can reference <code>labels.region</code> or <code>labels.k8s.pod</code> in queries, even though neither was ever declared.</p><p>The catch and central tradeoff is that every leaf value is a keyword. The number 4 above is indexed as the string <code>"4"</code>. There is no numeric typing, no date parsing, and no range math on dynamic keys. Flattened fields exchange per-field richness for schema flexibility. That fact explains almost every design decision that follows.</p><h2>How Elasticsearch stores flattened field JSON keys on disk</h2><p>There are two types of queries on flattened fields: an unkeyed query on the root flattened field, and a keyed query on a specific subfield. Following the previous example, a query of the form <code>labels: "us-east-1"</code> matches a value under <em>any</em> key, while the query<code>labels.region: "us-east-1"</code> matches a value under the <em>specific</em> <code>region</code>key.</p><p>To support these two distinct query formats, the flattened mapper writes each leaf value into two distinct Lucene fields.</p><p>Take this document:</p>{ "labels": { "region": "us-east-1", "k8s": { "pod": "web-7f9" } } }<p>The mapper produces:</p><ul><li><p>A root field under labels, holding the bare values:</p></li></ul>us-east-1
web-7f9<ul><li><p>A keyed field under labels._keyed, holding the flattened key concatenated with its value:</p></li></ul>region\0us-east-1
k8s.pod\0web-7f9<p>In the keyed field, nested objects are dot-flattened into a single key (k8s.pod), and the key is joined to its value with a reserved NULL byte (\0) as the separator. Keys that contain a NULL byte are rejected at parse time, so the separator is always unambiguous. To find the value, you split on the first NULL.</p><p>These two fields make both query shapes work:</p><ul><li><p><code>labels: "us-east-1"</code> matches a value under <em>any</em> key, so it searches the root field.</p></li><li><p><code>labels.region: "us-east-1"</code> matches a value under a <em>specific</em> key. It rewrites the query to the term <code>region\0us-east-1</code> and searches the keyed field.</p></li></ul><h2>Query restrictions on flattened field subkeys</h2><p>Because every key's terms live in one sorted list, the keyed field cannot answer every query shape a plain keyword field can. Three restrictions follow:</p><ol><li><p>No fuzzy, regexp, or wildcard on a specific subkey. Nothing about the layout makes them impossible,  but the pattern would have to be combined with the key prefix so it can’t walk past the key boundary. The mapper doesn’t do that at this writing.</p></li><li><p>Any range query on a subkey needs at least one bound. Elasticsearch already has a query for "this field has some value here, whatever it is": the <code>exists</code> query. On a flattened subkey it runs as a prefix query on key\0, which sweeps every term belonging to that key. A range with neither bound would sweep exactly the same terms. Rather than support two spellings of one scan, the mapper rejects the boundless range and asks for the <code>exists</code> query.</p></li><li><p>A range query on a subkey needs the field to be indexed. A flattened field can be mapped with <code>index: false</code>, which skips the inverted index and keeps only doc values, the columnar per-document storage covered in the next section. Exact-match queries survive that. With no terms to look up, Elasticsearch scans the doc values column instead, which is slower but gives the same answer. Range queries have no equivalent fallback, so a range on a subkey of an unindexed flattened field throws an Exception.</p></li></ol><p>All three are limits on the Lucene query the mapper is willing to build, and where you notice them depends on how you query.</p><p>On the search API, where you name <code>labels.region</code> directly, they come back as errors.</p><p>In ES|QL you will not see them as errors at all. There, the same limits decide only whether a predicate is pushed into Lucene or runs in the compute engine on the extracted column. </p><p>This is pushed to a term query on the keyed field:</p><p>This is not, so the filter runs per row on the extracted keyword:</p><p>Same answer, more work. That distinction is the subject of the second half of this post.</p><h2>Under the hood: how range queries stay inside key boundaries</h2><p>This part is internal. You don’t need it to use the field, but it explains where the bounds rule comes from.</p><p>For a handful of documents in one segment, the shared term list looks like this:</p>k8s.pod\0web-7f9
region\0us-east-1
region\0us-west-2
tenant_id\0acme<p>Each key owns a contiguous slice of that list. For example, all values for key "region" are clustered together in an ordered sublist. A range with both bounds set encodes each bound the same way a term is encoded, so a lower bound of "us-east" on the region key becomes region\0us-east and an upper bound of "us-west" becomes region\0us-west. Both endpoints already carry the key prefix, so the scan can’t leave the region slice. Nothing special is needed.</p><p>The half-open case is problematic. Handing Lucene a lower bound of <code>region\0us-east</code> with no upper bound would scan to the end of the term list, straight through <code>tenant_id\0acme</code> and every other key that sorts after region. So the mapper substitutes a sentinel for the missing side:</p><ul><li><p>A missing lower bound becomes key\0, inclusive. That’s the encoding of the empty value, and it’s the first term in the key's slice.</p></li><li><p>A missing upper bound becomes key\1, exclusive. Byte 0x01 is the next byte after the 0x00 separator, so it sorts after every key\0value term and before the first term of any other key.</p></li></ul><p>A one-sided range is therefore boxed into [key\0, key\1), which makes it exactly as safe as a closed one.</p><p>The upper sentinel also covers the case where one key is a prefix of another. If an index holds both region and regionx, then region\1 still sorts below regionx\0eu-west-1, because 0x01 is smaller than the x that follows the shared region prefix. A range on region cannot leak into regionx.</p><h3>How flattened fields use the inverted index and doc values</h3><p>Each leaf value can be written into two Lucene structures. Both are enabled by default, but can be disabled by the mapping configuration.</p><ul><li><p>The inverted index (when the field is indexed). 
Two untokenized keyword terms are indexed per value, one on the root path and one on the keyed path. This powers term, prefix, and range searches.</p></li><li><p>Doc values (when <code>doc_values</code> is enabled). 
A columnar, document-ordered structure. This powers sorting, aggregations, and ES|QL reads.</p></li></ul><p>The inverted index answers the question: "Which documents contain this term?" </p><p>Doc values answer another question: "For this document, what are the values?" Doc values are laid out column by column so a scan touches only the bytes it needs. A flattened field uses both inverted indexes and doc values, so it can serve search and analytics from the same field.</p><p>The nature of the index means that the root field is only present in some cases. When the inverted index is disabled by the mapping, any value search requires a linear scan of the doc values for the searched value. In this case, when performing a search on the root field, there isn’t much additional overhead compared to just scanning the keyed field and ignoring the key markers. So the flattened mapper skips writing the root field, relying on the keyed field for both root and keyed queries.</p><h3>Why flattened fields switched from dictionary to binary doc values</h3><p>Historically, flattened field doc values used Lucene's <code>SortedSetDocValues</code>, which is a dictionary-compressed format. This means that every <code>key\0value</code> value indexed across all documents per segment is stored in one big sorted, deduplicated set of values. Each document tracks a list of ordinals into that value set.</p><p>This dictionary approach provides great compression for low-cardinality fields that tend to have repeated values. It is byte-efficient to store a value only once, and then refer to it by a single integer value. However, there is overhead associated with building and maintaining that dictionary of values, and that overhead is wasted effort when operating on high-cardinality fields that don’t repeat values.</p><p>Because flattened fields are a catch-all type, their cardinality tends to be very high. So while the dictionary approach works, it’s not the most compact or scan-friendly layout for this data, especially in time-series indices where flattened bags are common and storage pressure is real.</p><p>Recent versions switched the storage to use Lucene’s <code>BinaryDocValues</code>. This format just stores a literal binary blob for each document, which is compressed using Zstandard by our doc values codec when written to disk.</p><p>This new binary format provides an additional benefit: it allows us to maintain original array ordering without any overhead. Flattened fields support the mapping parameter <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/flattened#flattened-params">preserve_leaf_arrays</a>, which affects how multivalued fields are returned when using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#synthetic-source">synthetic source</a>. When configured to <code>preserve_leaf_arrays: exact</code>, returned values preserve the order, duplicates, and nulls from the original source value.</p><p>The dictionary encoding inherent to sorted-set doc values means the returned values are sorted, deduplicated, and de-nulled. To implement <code>preserve_leaf_arrays</code>, flattened fields have traditionally used an additional sidecar field, tracking the required metadata to reconstruct the original source value. However, the nature of binary doc values means that this sidecar field is no longer needed. The values are just stored and returned as indexed.</p><h3>Limitations of schema on read with flattened fields</h3><p>The limitations below follow from the keyword-only rule and the shared keyed field:</p><ul><li><p>No numeric, date, or Boolean typing on dynamic keys. <code>100</code> and <code>"100"</code> are the same term.</p></li><li><p>No fuzzy, regexp, or wildcard on a specific subkey.</p></li><li><p>No multi-fields (<code>fields</code>) or <code>copy_to</code> on the flattened field.</p></li><li><p>A <code>depth_limit</code> (default 20) on how deeply nested the object can be.</p></li></ul><p>If you need real typing for a <em>known</em> key, flattened now supports explicitly <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/flattened#flattened-properties">mapped subfields</a>: you declare individual keys with real types under a <code>properties</code> block, and those keys are indexed by their own typed mapper instead of the keyed field. You get numeric ranges on <code>labels.status_code</code>, while everything else in <code>labels</code> stays dynamic and keyword-only. This is the escape hatch for the handful of keys you actually know about.</p><h2>Schema on read: querying flattened field JSON keys in ES|QL</h2><p>Search has supported flattened fields for years. ES|QL, the newer, piped query language built on a columnar compute engine, now reads flattened fields, too. Support began in the Technical Preview of Elasticsearch 9.5.0. It has two distinct pieces.</p><h3>What ES|QL returns when you select a flattened field</h3><p>ES|QL uses a dedicated data type for flattened fields, rather than folding it into <code>keyword</code>. When you select the root, you get the whole object back as a JSON string:</p>labels:flattened
{"k8s.pod":"web-7f9","region":"us-east-1"}<p>Keys come back sorted. You can carry this value through a query, count it, group by it, and run the multi-value and comparison functions on it. But an opaque JSON blob is not usually what you want to filter or aggregate on. For that, you need to reach inside it and process its contents.</p><h3>How FIELD_EXTRACT reads JSON keys from flattened fields</h3><p>There is no dotted-path syntax for dynamic keys in ES|QL. You cannot write <code>labels.region</code> for an unmapped key, because to the engine the flattened root is a single leaf value, not a set of columns. Instead you use a function:</p><p>The absence of a dotted-path syntax is a tentative limitation. The keyed field already addresses individual subkeys, so a more natural syntax for reaching into a flattened root is something we are planning to support.</p><p>FIELD_EXTRACT(field, path) takes a flattened field and a key, and returns a keyword. The rules are easier to see against a document. Take this one:</p>POST logs/_doc
{
 "labels": {
   "region": "us-east-1",
   "k8s": { "pod": "web-7f9", "node": "ip-10-0-0-3" },
   "tags": ["prod", "canary"],
   "retries": 4
 }
}<p>And this query:</p><p>The result is:</p>region     | pod     | k8s  | tags            | retries | namespace
us-east-1  | web-7f9 | null | [prod, canary]  | 4       | null<p>Four things to take from this:</p><ul><li><p>The dot is part of the key, not a navigation operator. The mapper already collapsed the nested object into the flat key k8s.pod, so "k8s.pod is a direct lookup, not a walk from k8s to pod.</p></li><li><p>Matching is exact. "k8s" returns null because there is no leaf stored at k8s, only at k8s.pod and k8s.node. For the same reason, "host" will not find "host.name", and matching is case-sensitive, so "Region" will not find "region".</p></li><li><p>Arrays come back multi-valued. "tags" yields a multi-valued keyword you can <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/mv_expand">MV_EXPAND</a>, count, or filter on. A missing key yields null.</p></li><li><p>Everything is a keyword. "retries" comes back as the string "4", and a Boolean leaf comes back as "true" or "false".</p></li></ul><p>JSONPath syntax is rejected outright, at parse time rather than per row. Both FIELD_EXTRACT(labels, "['k8s.pod']") and FIELD_EXTRACT(labels, "tags[0]") fail with <em>field_extract path must be a literal flattened sub-field name</em>.</p><p>Once extracted, the value is an ordinary <code>keyword</code> column. You can filter on it, group by it, sort by it, or use it as the join key in a <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a>:</p><h3>How ES|QL pushes flattened field predicates into the columnar store</h3><p>The obvious way to implement FIELD_EXTRACT would be to read the whole flattened root out of storage, render it as a JSON string, hand it to the compute engine, and parse it once per row to pull out a single key. But that means reading every key to use once and paying for a JSON parse on every document. So this obvious implementation is not performant.</p><p>ES|QL avoids this whenever possible. Between storage and the compute engine sits the <em>block loader</em>, the step that turns stored data into the columnar blocks the engine operates on. FIELD_EXTRACT hooks into that step instead of running after it.</p><p>When ES|QL loads a column, the flattened field type inspects the request. If the request is an extraction of a single constant key and the field has doc values, it routes straight to the keyed doc-values loader, which reads the key\0value entries for only that key out of the columnar structure. The column that arrives at the compute engine already contains only that key's values. The JSON string is never built and never parsed, and the other keys in the object are never read.</p><p>When extraction can’t be fused into the block loader, for example, because the key is computed per row or the root is the output of another function such as CASE, ES|QL falls back to the parse-per-row path. The results are identical either way. Only the cost changes.</p><p>The comparison can push down further. A predicate like <code>FIELD_EXTRACT(labels, "region") == "us-east-1"</code> can be pushed to Lucene as a term query against the synthetic keyed field, the same <code>region\0us-east-1</code> term the search path uses. So a filter on an extracted subkey can be answered by the inverted index (if available), and the projection can be answered by doc values, exactly like a first-class field, even though the key was never in the mapping.</p><p>Ordering comparisons push down, too. The four, single-sided comparators (&gt;, &gt;=, &lt;, &lt;=) and closed BETWEEN-style ranges all become a range query on that same synthetic keyed field. This is where the key\0 / key\1 sentinels earn their keep: the single-sided forms are only pushable because the mapper can box an open bound inside the key. The pushed range is treated as a candidate, and the predicate is re-evaluated on the extracted keyword column afterwards, so multi-valued keys don’t slip through.</p><p>The values are keywords, so the ordering is lexicographic, not numeric. FIELD_EXTRACT(labels, "retries") &gt; "10" compares strings, which means "9" is greater than "10". If you need numeric ranges on a key, map it explicitly under properties, or cast the value in ESQL.</p><p>Explicitly mapped subfields behave differently on purpose. Because they carry real types, comparison semantics diverge from the keyword path, so they are loaded and compared through their own typed mapper rather than fused into the keyed loader. And when you select a flattened root that has mapped subfields, ES|QL loads it from <code>_source</code>, so every leaf renders as a string and no keys are dropped silently.</p><h2>When to use flattened fields vs. dynamic mapping in Elasticsearch</h2><p>Use <code>flattened</code> fields when:</p><ul><li><p>The set of keys is open-ended or unknown ahead of time.</p></li><li><p>You would otherwise cause a mapping explosion.</p></li><li><p>Keyword-level filtering and grouping on the values is enough, and you don’t need numeric or date semantics on the dynamic keys.</p></li><li><p>You have a few keys that <em>do</em> need real types. Map those explicitly under <code>properties</code>, and let the rest stay dynamic.</p></li></ul><p>Avoid it, or map fields normally, when the schema is stable and you need full-text analysis, numeric aggregation, or date math across the board.</p><p>Remember that flattened is not a dumping ground for JSON you have given up on. It’s a real columnar-and-inverted store for schemaless data, and with ES|QL support, it’s now a first-class analytical citizen. You can keep the messy, unmapped parts of your data messy, and still filter, group, join, and aggregate across them as needed.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/schema-on-read-esql-json-keys</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/schema-on-read-esql-json-keys</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Mappings]]></category>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Jordan Powers,Dima Leontyev]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb3fca0aedcfba9ab/6a8419fae41d7f68b46522b9/unnamed.png" length="0" type="image/png"/>
    <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Ask the source: Scaling code search to a billion lines with Elasticsearch and Elastic Agent Builder]]></title>
    <description><![CDATA[Sourcerer matches Claude Code and Codex on code retrieval quality and searches up to thousands of times faster than grep. Every answer links back to the exact files and lines across repos and versions.]]></description>
    <content:encoded><![CDATA[<p>Code is the source of truth for its own behavior; it’s always authoritative and never outdated. Definitive answers live at a specific commit in a particular repository, but enterprise deployments depend on many versioned projects working together. Understanding how it all works is a major code search effort.</p><p><em>Does App A v1.2.3 support Feature X? Is it compatible with App B v9.8.7 when running on Kubernetes? Will I need more JVM heap space?</em></p><p>These are the kinds of questions that our field teams handle constantly. Answering them is harder than it looks. Documentation offers context, but it's an abstraction that can't anticipate every possible question. When we hit one it doesn't cover, our options are to interrupt an engineer who should be developing code or to hunt through that code ourselves. Often we don't have the time or expertise to navigate that much of it.</p><p>Coding agents do this well for a single repository on your laptop. Wouldn't it be great if we could scale that to our entire code estate? As a field engineer, I wanted that capability to serve my customers: agentic code intelligence across every project, dependency, platform, and version that we support. And I wanted it grounded in linked citations and always available to everyone as a service.</p><p>So I built it with <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a> and <a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a> and packaged it into a command line interface (CLI). I released it under an Apache 2.0 license and called it <a href="https://github.com/elastic/sourcerer">Sourcerer</a>. This blog post reports multiple performance benchmarks of Sourcerer as a code research agent and walks through the design and rationale of its implementation.</p><h2>Sourcerer</h2><p><a href="https://github.com/elastic/sourcerer">Sourcerer</a> explores code like a frontier coding agent, searching across many versioned repositories as fast as it would in a single repository, and it generates answers with linked citations that establish trust.</p><p>Sourcerer consists of:</p><ol><li><p>A set of configuration files for tools, skills, and agents in Agent Builder.</p></li><li><p>A set of index templates to store and search code from Git commit snapshots.</p></li><li><p>A CLI to install those assets and index and prune commit snapshots from remote Git repositories.</p></li></ol><p>At Elastic, we're using Sourcerer to support our customers with verifiable information about our software directly from the source. Our internal deployment has indexed over a billion lines of code from our own public and private repositories. It also includes our core dependencies, such as Apache Lucene and OpenJDK, along with our common integrations, like Kubernetes and OpenTelemetry. Our solution architects, customer architects, consulting architects, and support engineers no longer have to hunt for answers in documentation or reach out to our engineers who should be building software rather than supporting it.</p><h2>Code search benchmarks</h2><h3>Agentic code retrieval</h3><p><a href="https://arxiv.org/abs/2606.07297">SWE-Explore</a> is a new benchmark, published on June 5, 2026, by Zhang et al., that evaluates "how well coding agents explore, localize, and rank repository context." It appears to be the only benchmark that specifically tests agentic code retrieval quality. I ran the benchmark with Sourcerer to see how it performs and compares to the other coding agents from the original paper, and again with Claude Code to measure and compare its token usage and task durations with Sourcerer's.</p><h4>Retrieval scores</h4><p>Sourcerer performs as well as frontier coding agents on relevance metrics for code retrieval (see Figure 1). The composite retrieval score is the arithmetic mean of all retrieval metrics weighed by their Pearson correlations (<em>r</em>) as reported in the paper; I did this to rank the agents by a measurement of overall retrieval quality. Sourcerer trailed Claude Code by 0.002 and surpassed Codex by 0.022 on a 0.0–1.0 scale, which should be interpreted as a statistical tie, given that the results vary slightly on each run due to the indeterminism of large language models (LLMs).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt12808f4ed4f16659/6a7deb00cbb9ed3b2b53f944/image2.png" alt="" /><p></p><p>Generally, all the coding agents, including Sourcerer, performed well on precision metrics and suboptimally on recall metrics, although recall metrics had lower Pearson correlations and thus less importance. Table 1 shows Sourcerer's retrieval scores alongside the scores of the other agents tested in the original paper (page 8, table 6). "SignalReg" is the inverse of what the authors called "NoiseReg" (that is, 1 – NoiseReg); I did this to keep that metric consistent with the other metrics whose ranges imply that higher is better.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9283fe7e6209465/6a7deb0ec2602368e0b38642/image4.png" alt="" /><h4>Token usage and task duration</h4><p>Zhang et al. didn’t publish metrics for token usage or task durations, so I ran the benchmark again to capture those metrics for Claude Code. Given the high token cost of running the benchmark with any agent that uses an LLM, I opted to test only one coding agent, and Claude Code was the one that I expected most people would find useful as a comparison.</p><p>Compared to Claude Code, Sourcerer used ~13.9% more tokens to complete all 848 benchmark tasks. Sourcerer used 156,379,977 input tokens and 1,612,175 output tokens, while Claude Code used 137,629,435 input tokens and 1,137,176 output tokens. Sourcerer took ~8.9% longer to complete all 848 benchmark tasks. Sourcerer took 40,717 seconds, and Claude Code took 37,460 seconds.</p><p>I view these results on token usage and task duration as an acceptable modest tax in exchange for efficiently searching across multiple repositories and versions. That said, there’s room to explore optimizations to Sourcerer's tools, skills, and system prompt, the harness of Agent Builder, or the search engine of Elasticsearch and Lucene.</p><h4>Single-repo vs. multi-repo scope</h4><p>Critically, the SWE-Explore benchmarks only measure the retrieval scores, token usage, and task duration of agents searching within the boundaries of a single commit snapshot of a repository for any given task. This is the typical search space of a development coding agent. Sourcerer's intended scope is much broader, covering many commit snapshots of many repositories. The benchmark on "search speed and scalability," covered next in this report, shows Sourcerer's unique advantage when searching across many repositories.</p><h4>Retrieval benchmark methodology</h4><p><a href="https://www.elastic.co/search-labs/blog/code-search-sourcerer-elasticsearch#appendix-a.-swe-explore-benchmark-configuration">Appendix A</a> explains the configuration of these benchmarks in detail.</p><p>Sourcerer searched all indexed representations of the <a href="https://huggingface.co/datasets/SWE-Explore-Bench/SWE-Explore-Bench">SWE-Explore-Bench dataset</a> (see <a href="https://www.elastic.co/search-labs/blog/code-search-sourcerer-elasticsearch#appendix-a.-swe-explore-benchmark-configuration">Appendix A</a>). Both benchmark runs used the same GPT-5.4 model that was used in the original paper. Sourcerer communicated with GPT-5.4 through <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a>, while Claude Code communicated with GPT-5.4 through a shim proxy to be compatible with the OpenAI API.</p><p>I made a best effort to ensure that Sourcerer's benchmark task prompt was like-for-like with Claude Code's (see <a href="https://www.elastic.co/search-labs/blog/code-search-sourcerer-elasticsearch#appendix-a.-swe-explore-benchmark-configuration">Appendix A</a>). Both agents received identical instructions for their roles and tasks, along with output formats, and differed only in their brief harness-specific instructions. I instructed Sourcerer not to use its repo discovery skill and instead gave it explicit repo filtering instructions, ensuring that it was on a level playing field with Claude Code, which already receives the resolved directories. An alternative could have been to leave Sourcerer's repo discovery skill active while instructing Claude Code to find the repository in a filesystem that has all the repositories for the benchmark. I left Sourcerer's system prompts and skills, in addition to its tools, unmodified from their defaults, given that we're comparing the two harnesses overall, and much of which in Claude Code is closed source and not visible or controllable anyway.</p><h3>Code search speed and scalability</h3><p>Coding agents tend to use external tools to match substrings or regular expressions as a first line of retrieval. LLMs are trained to use shell commands, like <code>ls</code> and <code>grep</code>, when exploring code on a filesystem. Claude Code's own built-in <a href="https://code.claude.com/docs/en/tools-reference#grep-tool-behavior">Grep</a> tool invokes <a href="https://github.com/BurntSushi/ripgrep"><code>ripgrep</code></a>. This is the behavior I wanted to reproduce in Elasticsearch.</p><p>Elasticsearch has a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field type that can scale regular expression matching to billions of documents. Sourcerer mimics the inputs and outputs of <code>grep</code> in Elasticsearch using <a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.code.grep.yml"><code>sourcerer.code.grep</code></a>, an Elasticsearch Query Language (ES|QL) tool that performs an<a href="https://www.elastic.co/docs/reference/query-languages/sql/sql-like-rlike-operators"><code>RLIKE</code></a> query on a<a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field of an index where each document has the contents of a single line of code. Sourcerer also provides<a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.code.search.yml"><code>sourcerer.code.search</code></a>, which performs a BM25-ranked <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions/match"><code>MATCH</code></a> query against an analyzed text field, for relevance-ranked discovery rather than exact substring retrieval.</p><p>I benchmarked the speed of both approaches against the speed of <code>ripgrep</code> and <code>grep</code> on a filesystem using two different corpus sizes and pattern rarities. Corpus sizes included one commit (7,215,509 lines of code) and 52 commits (200,325,684 lines of code) from the<a href="https://github.com/elastic/elasticsearch"> elastic/elasticsearch</a> repository. The single commit covers the release tag for v9.4.3. The 52 commits cover the latest patch release tag for every major and minor release from v6.0.1 to v9.4.3. Sourcerer searched the corpus as indexed in Elasticsearch, while <code>ripgrep</code>  and <code>grep</code> searched the corpus as stored on a filesystem, reflecting their respective use cases. The regular expression patterns included one that appears rarely among the commits (DiskBBQ) and one that appears commonly among the commits (XContentType).</p><h4>sourcerer.code.grep</h4><p>The first search I benchmarked was a rare pattern for DiskBBQ that appears only in some commits:</p><p><code>.*[dD][iI][sS][kK][-_]?[bB][bB][qQ].*</code></p><p>Search latency (in seconds) spanning a single commit (605 matches found from 7,215,509 lines of code):</p><p><strong>Retrieval method</strong></p><p><strong>Cache</strong></p><p><strong>p0</strong></p><p><strong>p50</strong></p><p><strong>p100</strong></p><p><strong>stdev</strong></p><p><strong>vs. sourcerer.code.grep</strong></p><p><code>sourcerer.code.grep</code></p><p>Cold</p><p>0.069s</p><p>0.124s</p><p>0.167s</p><p>0.020s</p><p>-</p><p><code>sourcerer.code.grep</code></p><p>Warm</p><p>0.027s</p><p>0.029s</p><p>0.046s</p><p>0.005s</p><p>-</p><p><code>ripgrep</code> </p><p>Cold</p><p>0.788s</p><p>0.800s</p><p>0.809s</p><p>0.005s</p><p>~6.5x slower</p><p><code>ripgrep</code> </p><p>Warm</p><p>0.081s</p><p>0.088s</p><p>0.109s</p><p>0.009s</p><p>~3.0x slower</p><p><code>grep</code></p><p>Cold</p><p>3.565s</p><p>3.580s</p><p>3.821s</p><p>0.055s</p><p>~28.9x slower</p><p><code>grep</code></p><p>Warm</p><p>0.825s</p><p>0.827s</p><p>0.833s</p><p>0.002s</p><p>~28.5x slower</p><p>Search latency (in seconds) spanning 52 commits (1,041 matches found from 200,325,684 lines of code):</p><p><strong>Retrieval method</strong></p><p><strong>Cache</strong></p><p><strong>p0</strong></p><p><strong>p50</strong></p><p><strong>p100</strong></p><p><strong>stdev</strong></p><p><strong>vs. sourcerer.code.grep</strong></p><p><code>sourcerer.code.grep</code></p><p>Cold</p><p>0.159s</p><p>0.164s</p><p>0.270s</p><p>0.028s</p><p>-</p><p><code>sourcerer.code.grep</code></p><p>Warm</p><p>0.027s</p><p>0.031s</p><p>0.053s</p><p>0.006s</p><p>-</p><p><code>ripgrep</code> </p><p>Cold</p><p>22.356s</p><p>22.364s</p><p>22.459s</p><p>0.026s</p><p>~136.4x slower</p><p><code>ripgrep</code> </p><p>Warm</p><p>16.017s</p><p>16.123s</p><p>16.297s</p><p>0.058s</p><p>~520.1x slower</p><p><code>grep</code></p><p>Cold</p><p>101.962s</p><p>102.386s</p><p>104.281s</p><p>0.752s</p><p>~624.3x slower</p><p><code>grep</code></p><p>Warm</p><p>73.082s</p><p>73.507s</p><p>74.915s</p><p>0.536s</p><p>~2,371.2x slower</p><p>Table 2. p0/p50/p100/stdev retrieval speeds of <code>sourcerer.code.grep</code>, <code>ripgrep</code> , and <code>grep</code>, under cold and warm caches, at two corpus scopes (20 runs per method per cache state; three warmup runs discarded before each warm-cache measurement). All percentiles computed via linear interpolation. Ratios are computed against <code>sourcerer.code.grep</code>'s p50 at the matching cache state. See the “Methodology” section for cache definitions and query/command syntax.</p><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field type indexes trigrams of each value and uses them as a filter to narrow the candidate set for a regular expression before verifying full matches. For a selective pattern like this one (605 matches out of 7.2 million lines, 1041 out of 200 million) this approaches sublinear time complexity relative to corpus size. <code>ripgrep</code>  and <code>grep</code> both perform scans with linear time complexity, with <code>ripgrep</code>  using multithreading and single instruction, multiple data–accelerated (SIMD-accelerated) literal prefiltering to speed up searches, but neither has a mechanism to skip the vast majority of a corpus the way that an indexed trigram search can.</p><p>This shows up starkly in how each approach scales. Going from the single-commit corpus to the all-commits corpus is a 27.8x increase in line count. <code>sourcerer.code.grep</code> warm-cache time barely moves from 0.029 seconds to 0.031 seconds. ripgrep's warm-cache time goes from 0.089 seconds to 16.1 seconds, a 183x change; and grep's goes from 0.83 seconds to 1.2 minutes, an 89x change. Both filesystem tools scale worse than linearly with corpus size on this hardware, while the indexed approach is nearly flat.</p><p>The second search I benchmarked was a common pattern for XContentType that appears in all commits:</p><p><code>.*[xX][cC][oO][nN][tT][eE][nN][tT][tT][yY][pP][eE].*</code></p><p>Search latency (in seconds) spanning a single commit (7,999 matches found from 7,215,509 lines of code):</p><p><strong>Retrieval method</strong></p><p><strong>Cache</strong></p><p><strong>p0</strong></p><p><strong>p50</strong></p><p><strong>p100</strong></p><p><strong>stdev</strong></p><p><strong>vs. sourcerer.code.grep</strong></p><p><code>sourcerer.code.grep</code></p><p>Cold</p><p>0.158s</p><p>0.172s</p><p>0.224s</p><p>0.015s</p><p>-</p><p><code>sourcerer.code.grep</code></p><p>Warm</p><p>0.055s</p><p>0.057s</p><p>0.140s</p><p>0.023s</p><p>-</p><p><code>ripgrep</code> </p><p>Cold</p><p>0.794s</p><p>0.804s</p><p>0.824s</p><p>0.007s</p><p>~4.7x slower</p><p><code>ripgrep</code> </p><p>Warm</p><p>0.093s</p><p>0.095s</p><p>0.096s</p><p>0.001s</p><p>~1.7x slower</p><p><code>grep</code></p><p>Cold</p><p>3.385s</p><p>3.399s</p><p>3.421s</p><p>0.010s</p><p>~19.8x slower</p><p><code>grep</code></p><p>Warm</p><p>0.681s</p><p>0.683s</p><p>0.686s</p><p>0.001s</p><p>~12.1x slower</p><p>Search latency (in seconds) spanning 52 commits (290,662 matches found from 200,325,684 lines of code):</p><p><strong>Retrieval method</strong></p><p><strong>Cache</strong></p><p><strong>p0</strong></p><p><strong>p50</strong></p><p><strong>p100</strong></p><p><strong>stdev</strong></p><p><strong>vs. sourcerer.code.grep</strong></p><p><code>sourcerer.code.grep</code></p><p>Cold</p><p>1.587s</p><p>1.644s</p><p>1.756s</p><p>0.047s</p><p>-</p><p><code>sourcerer.code.grep</code></p><p>Warm</p><p>1.480s</p><p>1.541s</p><p>1.627s</p><p>0.040s</p><p>-</p><p><code>ripgrep</code> </p><p>Cold</p><p>22.426s</p><p>22.440s</p><p>22.548s</p><p>0.028s</p><p>~13.6x slower</p><p><code>ripgrep</code> </p><p>Warm</p><p>16.021s</p><p>16.276s</p><p>16.498s</p><p>0.119s</p><p>~10.6x slower</p><p><code>grep</code></p><p>Cold</p><p>97.699s</p><p>97.922s</p><p>99.150s</p><p>0.404s</p><p>~59.6x slower</p><p><code>grep</code></p><p>Warm</p><p>68.883s</p><p>69.184s</p><p>70.374s</p><p>0.360s</p><p>~44.9x slower</p><p>Table 3. p0/p50/p100/stdev retrieval speeds for the pattern <code>.*[xX][cC][oO][nN][tT][eE][nN][tT][tT][yY][pP][eE].*</code> under the same conditions as Table 2.</p><p>The relative search latencies of <code>sourcerer.code.grep</code> compared to <code>grep</code> shows why the pattern's match count matters as much as the corpus size:</p><p><strong>Corpus</strong></p><p><strong>Corpus size</strong></p><p><strong>Cache</strong></p><p><strong>Speed of sourcerer.code.grep with a rare pattern (DiskBBQ)</strong></p><p><strong>Speed of sourcerer.code.grep with a common pattern (XContentType)</strong></p><p>1 commit</p><p>7,215,509 lines</p><p>Cold</p><p>~28.9x faster</p><p>~19.8x faster</p><p>1 commit</p><p>7,215,509 lines</p><p>Warm</p><p>~28.5x faster</p><p>~12.1x faster</p><p>52 commits</p><p>200,325,684 lines</p><p>Cold</p><p>~624.3x faster</p><p>~59.6x faster</p><p>52 commits</p><p>200,325,684 lines</p><p>Warm</p><p>~2,371.2x faster</p><p>~44.9x faster</p><p>Table 4. This table shows how much faster <code>sourcerer.code.grep</code> was compared to <code>grep</code> when searching across two different corpus sizes and two different pattern rarities.</p><p>The pattern with far more matches shows a dramatically smaller Elasticsearch advantage, most strikingly at all-commits scope, where the advantage drops from 2,371x to 45x. The reason is visible in the absolute numbers: <code>sourcerer.code.grep</code>'s warm-cache time at all-commits scope jumps from 0.031 seconds (DiskBBQ) to 1.541 seconds (XContentType), a 49.7x increase for a 279x increase in match count, while <code>grep</code>'s warm-cache time barely changes (73.5 seconds to 69.2 seconds, effectively flat, since it scans the same number of bytes regardless of how many of them match). The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field's trigram index has sublinear-in-corpus-size behavior that comes specifically from narrowing the candidate set before verification. Once a pattern matches hundreds of thousands of lines, the bottleneck shifts from narrowing candidates to collecting and serializing all of them, a cost that scales with match count rather than corpus size. <code>sourcerer.code.grep</code> still wins by a wide margin even in this less favorable case, but the margin depends heavily on how selective the search is, not just how large the corpus is.</p><h4>sourcerer.code.search</h4><p>Sourcerer's other retrieval tool, <code>sourcerer.code.search</code>, performs a BM25-ranked <code>MATCH</code> query rather than an exact-substring regex match. Its speed on both patterns is included below for reference.</p><p><strong>Pattern</strong></p><p><strong>Corpus scope</strong></p><p><strong>p0</strong></p><p><strong>p50</strong></p><p><strong>p100</strong></p><p><strong>stdev</strong></p><p><strong>Matches found</strong></p><p>DiskBBQ</p><p>One commit</p><p>0.013s</p><p>0.017s</p><p>0.018s</p><p>0.002s</p><p>288</p><p>DiskBBQ</p><p>52 commits</p><p>0.014s</p><p>0.020s</p><p>0.046s</p><p>0.007s</p><p>493</p><p>XContentType</p><p>One commit</p><p>0.062s</p><p>0.063s</p><p>0.154s</p><p>0.020s</p><p>7,177</p><p>XContentType</p><p>52 commits</p><p>2.203s</p><p>2.359s</p><p>2.611s</p><p>0.125s</p><p>249,737</p><p>Table 5. p0/p50/p100/stdev retrieval speeds of <code>sourcerer.code.search</code>, warm cache only, for both patterns at both corpus scopes (20 runs per row; cold-cache figures omitted; see Methodology). Match counts are <code>sourcerer.code.search</code>'s own, not the regex-based methods' BM25 matches on tokens rather than substrings, so these aren’t directly comparable to Tables 2–4.</p><h4>What drives the speed advantage</h4><p><code>sourcerer.code.grep</code> outperformed <code>ripgrep</code> and <code>grep</code> at every corpus scale and pattern rarity tested, along with every cache state tested. But it wasn’t by a fixed margin. The advantage ranged from ~3–30x on a common pattern to over 2,300x on a rare pattern, because indexed and brute-force search respond to different things. The trigram narrowing of the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field does less work as a pattern gets more selective, while <code>grep</code> and <code>ripgrep</code> do the same amount of work regardless of how much of the corpus happens to match. <code>sourcerer.code.search</code> is a third option for the cases where the exact string isn't known at all.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc34bd5e704c0112/6a7deb29b8c2e6ffc9be51a1/image1.png" alt="" /><p></p><p>Figure 2: Sourcerer's <code>sourcerer.code.grep</code> tool outperformed <code>ripgrep</code> and <code>grep</code> in every combination of corpus size and pattern rarity benchmarked, sometimes by multiple orders of magnitude. Its outperformance was strongest when searching rare patterns in large corpora and weakest when searching common patterns in small corpora.</p><p>These search speed benchmarks show what <em>scalable</em> means in practice for an enterprise code search agent. It's being able to search the histories of any number of repositories at interactive speeds, just like how a developer coding agent searches the working state of a single repository on a filesystem.</p><h4>Speed benchmark methodology</h4><p><a href="https://www.elastic.co/search-labs/blog/code-search-sourcerer-elasticsearch#appendix-b.-code-search-speed-and-scalability-benchmark-configuration">Appendix B</a> explains the configuration of this benchmark. Note that while the Elasticsearch deployment had two data nodes each with the same specs as the virtual machine used for <code>ripgrep</code> and <code>grep</code>, the benchmark consisted of one index with one primary shard and one replica shard. Each search ran on a single shard, which means that <code>sourcerer.code.grep</code> had the same amount of vCPUs and memory available per search as <code>ripgrep</code> and <code>grep</code>, despite having twice as much capacity across the overall deployment. Having two data nodes actually incurred a slight latency <em>penalty</em> compared to just one data node. For brevity, I've omitted results from the benchmark with one data node. We don't recommend single-node deployments in production, so it's worth including the realistic latency overhead that comes with a multi-node deployment in this benchmark.</p><p>I compared all retrieval methods under both cold and warm caches, 20 cold runs and 20 warm runs per method. The definitions of cold and warm caches weren’t like-for-like between the Elasticsearch and filesystem benchmarks. For <code>ripgrep</code> and <code>grep</code>, a <em>cold cache</em> meant performing a full-page cache drop (<code>sync; echo 3 &gt; /proc/sys/vm/drop_caches</code>) immediately before each cold run; and a <em>warm cache</em> meant three discarded warmup executions immediately followed by the 20 measured runs, with no cache drops in between. For ES|QL, a <em>cold cache</em> meant calling <code>POST /_cache/clear</code> before each run. This only clears Elasticsearch's internal caches, not the OS page caches of the data nodes, which can't be cleared by hand on Elastic Cloud Hosted (ECH). A <em>warm cache</em> for ES|QL meant three discarded warmup queries immediately before the 20 measured runs, to help ensure that the caches on both data nodes would be warm. <code>sourcerer.code.search</code>'s cold-cache figures are omitted from Table 5 for the same reason discussed elsewhere in this post: Its cold-cache measurements came back statistically indistinguishable from its own warm-cache measurements, evidence that the OS-level cache-clearing limitation affects it more than it affects <code>sourcerer.code.grep</code>, which showed a consistent, physically sensible cold/warm gap throughout.</p><h3>Code search indexing throughput</h3><p>I didn't conduct a formal benchmark of indexing throughout. I'll share my general observations instead.</p><p>Typically, I see a sustained indexing throughput of 20K–25K lines per second on data nodes that each have ~16GiB RAM and ~8 vCPU on c4a-highcpu instances on Google Cloud Platform (GCP). That includes writing to a primary shard and its replica shard. I've seen throughput as high as ~60K lines per second on <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> with <a href="https://www.elastic.co/search-labs/blog/elasticsearch-serverless-tier-autoscaling">Search Power</a> set to "Performant."</p><p>An engineering team at Elastic compared Sourcerer's indexing throughput to semantic code search implementations that used either sparse vector generation (<a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-elser">Elastic Learned Sparse EncodeR [ELSER])</a> or dense vector embedding generation (<a href="https://jina.ai/models/jina-embeddings-v5-text-small/">Jina</a>). Sourcerer indexed ~25x faster than <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-elser">.elser-2-elastic</a> and ~15x faster than <a href="https://jina.ai/models/jina-embeddings-v5-text-small/">.jina-embeddings-v5-text-small</a>, while retrieval quality was similar among all of them. More concretely, what took ~6 hours to index with ELSER took ~14 minutes to index with Sourcerer.</p><h2>Code search solution design and rationale</h2><p>The remainder of this blog post explains the rationale for my design decisions of Sourcerer, giving expert insights for practitioners of Elasticsearch and generative AI (GenAI).</p><h3>Goals</h3><p>Ultimately, we want an agent that answers questions about deployed software and its supporting infrastructure by searching the primary sources of truth (the code itself) and generating verifiable responses that cite those sources so they can be trusted. Inspired by <a href="https://arxiv.org/abs/2605.15184">the success of coding agents with grep</a>, my main functional goal for Sourcerer was to reproduce the search behavior of a coding agent and generate responses with citations, all using Agent Builder. My nonfunctional goals were to keep it fast and scalable, as well as accurate, when searching across many versioned repositories, while maintaining acceptable costs and ease of use. Of these goals, reproducing the search behaviors of coding agents would be the most consequential, as it would dictate the access pattern, index design, and query design, plus their effects on nonfunctional goals.</p><h3>Access pattern</h3><p>From a human perspective, the intended access pattern is simple: We expect to ask natural language questions about software and receive plain language answers grounded in the source of truth. From the perspective of the agent handling those questions, the intended access pattern is to reproduce the search behavior of coding agents to find what it needs. The LLMs used by coding agents are heavily trained to explore code with shell commands, like<code>ls</code>or <code>find</code>, <code>grep</code> or <code>ripgrep</code>, <code>cat</code>, <code>head</code>, <code>tail</code>, and so on. <a href="https://code.claude.com/docs/en/tools-reference">Claude Code's built-in tools</a>, such as <a href="https://code.claude.com/docs/en/tools-reference#glob-tool-behavior">Glob</a> and <a href="https://code.claude.com/docs/en/tools-reference#grep-tool-behavior">Grep</a>, provide similar functions.</p><p>I chose to go with the grain of how models are trained. So my intended access pattern for Sourcerer was to reproduce the names, inputs, and outputs of shell commands as <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/esql-tools">ES|QL tools in Agent Builder</a>. That way, the Agent Builder harness would allow an LLM to use its trained intuition to achieve similar results as a frontier harness, like Claude Code or Codex, without having to fill the LLM's limited context window with instructions for using a different search interface. This was the main context engineering problem to solve with Sourcerer. Solving it would enable faster searches that scale across many repositories at once, allowing an agent to answer questions about deployments in which many different versioned software projects work together.</p><h3>Index design</h3><p>I decided on three index templates to fulfill this access pattern:</p><ul><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/index_templates/sourcerer-v2-refs.json"><code>sourcerer-refs</code></a>: Each document indexes the high-level metadata for a single Git reference or <em>ref</em> identified by its unique commit hash, which can have a tag name or branch name associated with it. A ref represents an entire snapshot of a repository at a point in time. This is a small index. The agent mainly uses this to discover the repositories and snapshots that are available to search.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/index_templates/sourcerer-v2-files.json"><code>sourcerer-files</code></a>: Each document indexes the metadata for a single file of a given ref. This is a larger index. The agent mainly uses this to navigate files and directories using <code>ls</code> semantics.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/index_templates/sourcerer-v2-lines.json"><code>sourcerer-lines</code></a>: Each document indexes the contents of a single, numbered line of code for a given file of a given ref. Yes, every line of code becomes a document. This is the largest index (but perhaps not as large as you might expect). The agent mainly uses this to search and view code using <code>grep</code> and <code>cat</code> semantics.</p></li></ul><p>The design is almost entirely denormalized. Each index has the same namespacing fields for fast, joinless filtering. Each indexed ref stores all files and lines from its commit snapshot, rather than storing diffs and reconstructing them at search time or storing unique files and lines with a mutable array of ref names associated with each. These choices trade duplicative storage (the cheapest compute resource) for faster searches and less segment merging pressure.</p><h4>Namespacing</h4><p>All three indices use four fields to namespace the ref, file, or line of code:</p><ul><li><p><code>git.host</code>: A Git hosting provider (for example, github, gitlab).</p></li><li><p><code>git.org</code>: An account name (for example, elastic).</p></li><li><p><code>git.repo</code>: A Git repository (for example, elasticsearch, kibana).</p></li><li><p><code>git.commit</code>: A commit hash, stored as the full 40-character SHA-1 digest for integrity.</p></li></ul><p>The document <code>_id</code> hashes for files and lines are also namespaced by <code>{git.host}</code>, <code>{git.org}</code>, <code>{git.repo}</code>, and <code>{git.commit}</code> to allow for idempotent indexing. That means you can safely rerun an indexing job without duplicating any documents.</p><p>Likewise, the index names are namespaced with the same semantics, using tildes (<code>~</code>) as a reliable separator since it's a disallowed character in Git repository names and organization names:</p><ul><li><p><code>sourcerer-v*-files~{git.host}~{git.org}~{git.repo}</code></p></li><li><p><code>sourcerer-v*-lines~{git.host}~{git.org}~{git.repo}</code></p></li></ul><p>This namespace convention has many benefits:</p><ul><li><p>Agents can quickly narrow the search space for refs and files, along with lines of code, by these common scoping fields, keeping searches fast and focused.</p></li><li><p>The semantics reflect common permission boundaries. You can reproduce the access policies of your Git hosting provider by implementing your choice of index-level security and/or document-level security based on host or organization or based on repository.</p></li><li><p>You can instantly delete indices for a whole repository or organization, or for a host, without an expensive <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-delete-by-query"><code>_delete_by_query</code></a>.</p></li><li><p>The index names are future proofed for different levels of granularity. Sourcerer might eventually allow indexing code by <code>{git.host}</code>, <code>{git.host}~{git.org}</code>, or <code>{git.host}~{git.org}~{git.repo}~{git.commit}</code> for selective shard sizing optimizations. The query syntax would be unaffected because they target index aliases (<code>sourcerer-files</code> and <code>sourcerer-lines</code>), not individual indices.</p></li></ul><h4>Settings</h4><p>Three index settings help to optimize storage costs and search speed:</p><ul><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/sorting">Index sorting</a> gives faster searches and better compression at the cost of reduced indexing throughput. Each index sorts documents on disk by <code>git.host</code>, <code>git.org</code>, <code>git.repo</code>, <code>git.commit</code>. File and line documents are further sorted by <code>file.path</code>, and line documents are further sorted by <code>line.number</code>.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#synthetic-source">Synthetic <code>_source</code></a> discards <code>_source</code> and instead reconstructs it as needed when reindexing. None of the queries access <code>_source</code>, which makes it dead weight. Enabling this setting reclaims ~50% storage space in the files and lines indices. While it requires an Enterprise license, enabling it on a non-licensed deployment won’t prevent the index from being created; instead the setting will be ignored.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules#index-codec"><code>best_compression</code></a> is a fallback for Elastic deployments that lack an Enterprise license to use synthetic <code>_source</code>. It provides decent compression for <code>_source</code> (~11% storage savings by my observations) in exchange for a modest tax on indexing throughput (~15% slower), while search speeds are essentially unaffected because the queries don't fetch <code>_source</code>.</p></li></ul><p>I use <a href="https://www.elastic.co/docs/manage-data/data-store/aliases">index aliases</a> to support zero-downtime upgrades when reindexing to a new schema.</p><h4>Mappings</h4><p>The indices mainly use <code>keyword</code> fields. They facilitate efficient filtering with basic wildcard support and aggregations, along with optimal storage usage and indexing throughput.</p><p>The <code>line.content</code> field is indexed both as a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field and as a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/text"><code>text</code></a> field with <a href="https://www.elastic.co/docs/manage-data/data-store/text-analysis">tokenization</a> and <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity">similarity</a> settings tuned for code search. This gives agents the option to search code using familiar and effective <code>grep</code>-like regular expressions on the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field or using the inverted index of the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/text"><code>text</code></a> field to return only the highest ranking matched lines to reduce token usage and increase search speed. Both options are remarkably fast and scalable, taking milliseconds to finish in most cases.</p><h4>Shards</h4><p>While shards are becoming less relevant with the rise of stateless platforms like <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a>, I want the solution to accommodate all deployment modes of Elasticsearch. So I've given attention to the effect of the index design on the number and size of primary shards. Based on my observations, I expect that most users won’t need to give much attention to shards.</p><p>By default, Elasticsearch enforces a <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards#shard-count-per-node-recommendation">soft limit of 1,000 shards per data node</a> (including replicas). That means this solution will hit a soft limit of just under 250 repositories indexed per data node, because each repository is written to a files index and a lines index, each with one primary shard and one replica. Additionally, there’s a conventional best practice of limiting shard sizes to ~50GB, which affects how many refs you can index per repository. Both of these limits can be pushed a bit. But they reveal that this solution design really is optimized for its intended use case of searching the commit snapshots of supported, deployed software. You wouldn't use Sourcerer to index every repository on the Internet, and you shouldn't use it to index every ephemeral development branch. Plus, you should decide how many refs are worth retaining for each repository.</p><p>Repository-level granularity of indices appears to strike the right balance of shard counts and shard sizes. For reference, Kibana is one of the largest repositories on GitHub (<a href="https://stacey-gammon.github.io/repo-stats/">source</a>). I observed its shard size to be a manageable 60GB–75GB when retaining only the latest patch release for every major and minor version release from v6.0.0 to v9.5.0. That's great coverage for the Elastic deployments we see in the wild. If that's one of the largest repositories out there, you can expect just about any other repository to fit in a single shard as long as you have a reasonable retention policy, which I discuss in the next section (“Pruning”).</p><h4>Pruning</h4><p>By default, Sourcerer retains everything you index. Pruning lets you delete old refs to prevent unbounded growth. You can define ref retention policies based on the age of refs and the number of refs indexed in the repo. You can also define these policies based on the number of semantic versions indexed in the repo at any level of granularity for major, minor, patch, build, and prerelease versions. Some common configurations are to retain only the latest commit of the default branch or the most recent patch release tag for every major and minor version release tag.</p><h3>Elastic Agent Builder tools</h3><p>With the index design in place, we can review the tools that query those indices.</p><p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/esql-tools">ES|QL tools in Agent Builder</a> are parameterized ES|QL queries with descriptions to guide the agent's use of them. Sourcerer has tools for several purposes: repo discovery, file discovery, code search, and code display. These tools reproduce the names, inputs, and outputs of shell commands that coding agents prefer to use when exploring code, making them intuitive enough for the LLM to use with minimal instructions passed into its context window.</p><h4>Repo discovery</h4><p>These are typically the first tools that the agent calls. Unlike most coding agents, which search within a single repository on a filesystem, Sourcerer is aware that its search space likely has multiple repositories and versions, and so its first step is to decide which repos and refs to scope its searches to.</p><ul><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.repos.list.yml"><code>sourcerer.repos.list</code></a>: Lists the repos that are available to search.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.repos.search.yml"><code>sourcerer.repos.search</code></a>: Lists the repos whose file contents best match a given query.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.refs.list.yml"><code>sourcerer.refs.list</code></a>: Lists the repos and refs that are available to search.</p></li></ul><h4>File discovery</h4><p>All file discovery tools support glob matching (<code>*</code> and <code>**</code>) on file paths.</p><ul><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.ls.yml"><code>sourcerer.files.ls</code></a>: Lists files and directories that match a given pattern.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.tree.yml"><code>sourcerer.files.tree</code></a>: Lists files and directories that match a given pattern in a tree-like format.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.wc.yml"><code>sourcerer.files.wc</code></a>: Counts lines, words, characters, bytes, and longest lines for each matching file.</p></li></ul><h4>Code search</h4><p>All file code search tools support glob matching (<code>*</code> and <code>**</code>) on file paths.</p><ul><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.code.grep.yml"><code>sourcerer.code.grep</code></a>: Searches lines of code using <a href="https://www.elastic.co/docs/reference/query-languages/sql/sql-like-rlike-operators"><code>RLIKE</code></a> on a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code></a> field for rapid execution of regular expressions.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.code.search.yml"><code>sourcerer.code.search</code></a>: Searches lines of code using <a href="https://www.elastic.co/docs/reference/query-languages/sql/sql-functions-search#sql-functions-search-match"><code>MATCH</code></a> on a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/text"><code>text</code></a> field that has been tuned for code search.</p></li></ul><h4>Code retrieval</h4><p>All file code retrieval tools concatenate the desired lines of any matching file and return them as a single, contiguous block of code in <code>grep -n</code> format, which is a format preferred by coding agents, including Claude Code's built-in <a href="https://code.claude.com/docs/en/tools-reference#grep-tool-behavior">Grep</a> tool. This lets the agent see a faithful representation of file contents with line-level attribution for precise citations, without requiring the agent to reconstruct the contents or infer line numbers through reasoning.</p><p>To illustrate, here's how the <a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.head.yml"><code>sourcerer.files.head</code></a> tool formats the first five lines of <a href="https://raw.githubusercontent.com/elastic/kibana/refs/tags/v9.5.0/README.md">Kibana's <code>README.md</code></a> file, reconstructed from five documents from the lines index:</p>1:# Kibana
2:
3:Kibana is the open source interface to query, analyze, visualize, and manage your data stored in Elasticsearch.
4:
5:- [Getting Started](#getting-started)<p>All file code retrieval tools support glob matching (<code>*</code> and <code>**</code>) on file paths. Agents typically use these tools to display the contents of a single file.</p><ul><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.cat.yml"><code>sourcerer.files.cat</code></a>: Concatenates and displays all lines for each matching file.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.head.yml"><code>sourcerer.files.head</code></a>: Concatenates and displays the first <code>n</code> lines for each matching file.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.tail.yml"><code>sourcerer.files.tail</code></a>: Concatenates and displays the last <code>n</code> lines for each matching file.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_tools/sourcerer.files.read_lines.yml"><code>sourcerer.files.read_lines</code></a>: Concatenates and displays the range of lines between two given line numbers for each matching file.</p></li></ul><h3>Agent Builder skills</h3><p>With the tools implemented, we can review the skills that guide the agent's proper use of them. Agents typically invoke the follow skills in this order:</p><ul><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/skills/repo-discovery/SKILL.md"><code>sourcerer-repo-discovery</code></a>: Guides the agent in discovering and selecting the repositories that are available to search for a given prompt. While this is typically the first skill an agent invokes, the agent might return to it when tracing dependencies from other repositories or when answering questions that span multiple repositories or versions.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/skills/ref-resolution/SKILL.md"><code>sourcerer-ref-resolution</code></a>: Guides the agent in resolving the names of tags or branches to their unique, immutable commit hashes. This lets the agent reliably filter its searches to a single commit snapshot.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/skills/code-search/SKILL.md"><code>sourcerer-code-search</code></a>: Guides the agent in exploring code, with basic best practices on when and how to use the available tools for maximum efficiency.</p></li><li><p><a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/skills/code-citations/SKILL.md"><code>sourcerer-code-citations</code></a>: Guides the agent in citing files, directories, lines of code, and ranges of lines of code. Sourcerer auto-generates more specific citation skills for each major Git hosting provider, so that its citation links conform to the URL formats of each respective host.</p></li></ul><h3>Agent system prompt</h3><p>The final packaging of the agent comes with a <a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/elastic/agent_builder_agents/sourcerer.yml">system prompt</a> that succinctly describes the agent's role and its high-level instructions. The configure file that defines the system prompt also defines the tools and skills that are made available to the agent, so that it can only execute what we permit it to execute.</p><h3>Sourcerer CLI</h3><p>The <a href="https://github.com/elastic/sourcerer">Sourcerer CLI</a> assists with setup and indexing, along with pruning to keep operations simple. Configuration is managed through a <a href="https://github.com/elastic/sourcerer/blob/main/specs/sourcerer-yml.md"><code>sourcerer.yml</code></a> configuration file.</p><ul><li><p><code>sourcerer setup</code>: Idempotently loads the index templates and Agent Builder configurations, in addition to Kibana dashboards. This is typically a one-time operation and takes a few seconds.</p></li><li><p><code>sourcerer index</code>: Checks for new refs that match patterns defined in <a href="https://github.com/elastic/sourcerer/blob/main/specs/sourcerer-yml.md"><code>sourcerer.yml</code></a> and then idempotently indexes them, skipping any refs that have already been indexed or that qualify for pruning based on retention policies. It calls <code>git</code> to clone repos and to list remote refs, as well as to  check out refs.</p></li><li><p><code>sourcerer prune</code>: Checks the retention policies for any refs that qualify for pruning and then deletes them from all three indices using <code>_delete_by_query</code>.</p></li></ul><p>You can easily schedule indexing and pruning using external schedulers, like cron. For our internal use at Elastic, we maintain <a href="https://github.com/elastic/sourcerer/blob/main/specs/sourcerer-yml.md"><code>sourcerer.yml</code></a> files in a private Git repository and schedule indexing and pruning with <a href="https://github.com/features/actions">GitHub Actions</a>. I prefer to index code frequently, while pruning outside of normal working hours to prevent agents from suddenly losing context in the middle of a conversation.</p><h3>Feature license summary</h3><p>For transparency, here’s a summary of the license levels for all non–open source software (non-OSS) features referenced in this solution design.</p><p>Subscription features:</p><ul><li><p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder">Agent Builder</a> (optional; you can query the indices from a different harness using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/">Elasticsearch API</a>).</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#synthetic-source">Synthetic <code>_source</code></a> (optional).</p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/controlling-access-at-document-field-level#document-level-security">Document-level security</a> (optional).</p></li></ul><p>Free features, proprietary to Elastic (not OSS as defined by the Open Source Initiative [OSI]):</p><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a>.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword#wildcard-field-type"><code>wildcard</code> field</a>.</p></li></ul><p>A subscription gives you the magic of "everything just works" with Agent Builder, along with resource optimizations, finer security permissions, and platform support. Without a subscription, you can still index and prune code with the Sourcerer CLI and search the code using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/">Elasticsearch API</a>.</p><h2>Conclusion</h2><p>Sourcerer demonstrates that Elasticsearch + Agent Builder is an exceptional solution for agentic enterprise code intelligence, as evidenced in many ways:</p><ul><li><p><strong>Accurate:</strong> Sourcerer's agentic code retrieval quality is at parity with frontier coding agents as demonstrated by its performance on the academic benchmark SWE-Explore.</p></li><li><p><strong>Fast:</strong> Sourcerer's query speed ranges from milliseconds in a single repository to multiple seconds across a billion lines of code. Indexing is also much faster than could be achieved with vector embedding generations.</p></li><li><p><strong>Scalable:</strong> Elasticsearch sharding enables horizontal scaling, making it possible to search the current and historical states of an entire enterprise software estate. Alternatively, the stateless architecture of <a href="https://www.elastic.co/cloud/serverless">Serverless</a> naturally scales without having to plan shards.</p></li><li><p><strong>Resilient:</strong> Elasticsearch replication enables high availability to keep the agent operational 24/7. Likewise, the stateless architecture of <a href="https://www.elastic.co/cloud/serverless">Serverless</a> naturally provides high availability.</p></li><li><p><strong>Polyglot:</strong> Sourcerer's indexing and retrieval methods are completely language-agnostic and tolerant of malformed code.</p></li><li><p><strong>Secure:</strong> Elasticsearch <a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/controlling-access-at-document-field-level">document-level security</a> enforces access policies for humans and agents at the organization, repository, and commit levels.</p></li><li><p><strong>Efficient:</strong> Sourcerer demonstrates an efficient use of storage, memory, compute, and token consumption for its intended use case.</p></li><li><p><strong>Manageable:</strong> The Sourcerer CLI, and its use of native <code>git</code> commands and Elastic REST APIs, makes it easy to get started with and operate, as well as schedule.</p></li><li><p><strong>Universal:</strong> Organizations from all industries build software, and Git is the source of truth for ~85% of them (<a href="https://fosspost.org/git-market-share-statistics/">source</a>). Sourcerer has value to all of these organizations. </p></li></ul><p>Sourcerer is currently less than two months old, and the benchmark results suggest that there’s room to improve recall and token efficiency, along with task duration. I'll continue to work on this project in the near future.</p><h2>Try it yourself</h2><p>Sourcerer depends on Elasticsearch and Kibana. You can get started with those in a couple ways:</p><ul><li><p><a href="https://www.elastic.co/cloud/cloud-trial-overview">Elastic Cloud</a> (includes an Enterprise trial).</p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">Local setup with Docker</a> (includes an Enterprise trial).</p></li></ul><p>Then you can get started with <a href="https://github.com/elastic/sourcerer">Sourcerer</a>.</p><p>I built this with <a href="https://www.elastic.co/elasticsearch/agent-builder">Agent Builder</a>. What will you build?</p><h2>References</h2><p>Sen, S. (2026). <em>Is Grep All You Need? How Agent Harnesses Reshape Agentic Search</em>. arXiv.<a href="https://arxiv.org/abs/2605.15184"> https://arxiv.org/abs/2605.15184</a></p><p>Zhang, S. (2026). <em>SWE-Explore: Benchmarking How Coding Agents Explore Repositories</em>. arXiv.<a href="https://arxiv.org/abs/2606.07297"> https://arxiv.org/abs/2606.07297</a></p><h2>Appendices</h2><h3>Appendix A. SWE-Explore benchmark configuration</h3><p>Benchmark environment:</p><ul><li><p>Sourcerer version: v1.0.0 (commit hash: <a href="https://github.com/elastic/sourcerer/tree/26d2e84e3f9e4c1e1598a48532eba9a739465c3e">26d2e84e3f9e4c1e1598a48532eba9a739465c3e</a>)</p></li><li><p>Elastic Cloud Hosted (ECH):</p></li><ul><li><p>Region: GCP - Los Angeles (us-west2)</p></li><li><p>CPU Optimized hardware (c4a-highcpu)</p></li><li><p>3x Elasticsearch data nodes (each with 16GiB RAM, 8 vCPUs)</p></li><li><p>2x Kibana instances (each with 2GiB RAM)</p></li><li><p>Elastic stack version: v9.5.0 (commit hash: <a href="https://github.com/elastic/elasticsearch/tree/dbedef007f580447413782705acb9afec41f945b">dbedef007f580447413782705acb9afec41f945b</a>)</p></li></ul><li><p>Claude Code version: 2.1.202</p></li><li><p>LLM: GPT-5.4</p></li></ul><p>Indices as reported by <code>GET /_cat/indices</code>:</p>index                                          pri rep docs.count docs.deleted store.size
sourcerer-v1-files~ansible~ansible               1   1     234423            0     18.5mb
sourcerer-v1-files~apache~druid                  1   1      46720            0      4.1mb
sourcerer-v1-files~apache~lucene                 1   1      47932            0      4.2mb
sourcerer-v1-files~astral-sh~ruff                1   1      49021            0      9.1mb
sourcerer-v1-files~astropy~astropy               1   1      38994            0      6.1mb
sourcerer-v1-files~axios~axios                   1   1        339            0       89kb
sourcerer-v1-files~babel~babel                   1   1      24798            0      4.9mb
sourcerer-v1-files~briannesbitt~carbon           1   1      14054            0      1.2mb
sourcerer-v1-files~burntsushi~ripgrep            1   1        408            0    232.4kb
sourcerer-v1-files~caddyserver~caddy             1   1       2162            0    533.9kb
sourcerer-v1-files~django~django                 1   1    1329708            0     91.7mb
sourcerer-v1-files~element-hq~element-web        1   1      29426            0      6.1mb
sourcerer-v1-files~facebook~docusaurus           1   1       9288            0        2mb
sourcerer-v1-files~faker-ruby~faker              1   1       1130            0    207.2kb
sourcerer-v1-files~fastlane~fastlane             1   1      13663            0      1.7mb
sourcerer-v1-files~flipt-io~flipt                1   1      10518            0    873.8kb
sourcerer-v1-files~fluent~fluentd                1   1       3340            0    744.4kb
sourcerer-v1-files~fmtlib~fmt                    1   1       1243            0    133.7kb
sourcerer-v1-files~future-architect~vuls         1   1       3952            0      828kb
sourcerer-v1-files~gin-gonic~gin                 1   1        217            0     76.6kb
sourcerer-v1-files~gohugoio~hugo                 1   1      11545            0      1.2mb
sourcerer-v1-files~google~gson                   1   1       1432            0    349.4kb
sourcerer-v1-files~gravitational~teleport        1   1     100317            0     16.3mb
sourcerer-v1-files~hashicorp~terraform           1   1      13511            0      1.5mb
sourcerer-v1-files~immutable-js~immutable-js     1   1        458            0      120kb
sourcerer-v1-files~internetarchive~openlibrary   1   1      35448            0      5.9mb
sourcerer-v1-files~javaparser~javaparser         1   1       5154            0      1.3mb
sourcerer-v1-files~jekyll~jekyll                 1   1        720            0    272.9kb
sourcerer-v1-files~jordansissel~fpm              1   1        167            0     98.3kb
sourcerer-v1-files~jqlang~jq                     1   1       1172            0    291.4kb
sourcerer-v1-files~laravel~framework             1   1      29021            0      4.9mb
sourcerer-v1-files~matplotlib~matplotlib         1   1     133013            0     19.9mb
sourcerer-v1-files~micropython~micropython       1   1      15631            0        3mb
sourcerer-v1-files~mrdoob~three.js               1   1       9858            0      1.1mb
sourcerer-v1-files~mwaskom~seaborn               1   1        633            0      181kb
sourcerer-v1-files~navidrome~navidrome           1   1      10301            0      2.1mb
sourcerer-v1-files~nlohmann~json                 1   1       1090            0    281.9kb
sourcerer-v1-files~nodebb~nodebb                 1   1     104805            0     14.5mb
sourcerer-v1-files~nushell~nushell               1   1       8990            0      1.7mb
sourcerer-v1-files~pallets~flask                 1   1        251            0    120.1kb
sourcerer-v1-files~php-cs-fixer~php-cs-fixer     1   1       8300            0        1mb
sourcerer-v1-files~phpoffice~phpspreadsheet      1   1      17140            0      3.6mb
sourcerer-v1-files~preactjs~preact               1   1       3446            0    772.5kb
sourcerer-v1-files~projectlombok~lombok          1   1      24153            0        4mb
sourcerer-v1-files~prometheus~prometheus         1   1       3416            0    644.9kb
sourcerer-v1-files~protonmail~webclients         1   1      84927            0     14.9mb
sourcerer-v1-files~psf~requests                  1   1        928            0    329.4kb
sourcerer-v1-files~pydata~xarray                 1   1       5114            0    910.6kb
sourcerer-v1-files~pylint-dev~pylint             1   1      24945            0      3.8mb
sourcerer-v1-files~pytest-dev~pytest             1   1       8880            0      1.3mb
sourcerer-v1-files~qutebrowser~qutebrowser       1   1      29453            0      4.6mb
sourcerer-v1-files~reactivex~rxjava              1   1       1959            0    523.7kb
sourcerer-v1-files~redis~redis                   1   1      12646            0        2mb
sourcerer-v1-files~rubocop~rubocop               1   1      15349            0      3.3mb
sourcerer-v1-files~scikit-learn~scikit-learn     1   1      39030            0      6.2mb
sourcerer-v1-files~sharkdp~bat                   1   1       2526            0    789.3kb
sourcerer-v1-files~sphinx-doc~sphinx             1   1      55638            0      8.8mb
sourcerer-v1-files~sympy~sympy                   1   1     112572            0     16.7mb
sourcerer-v1-files~tokio-rs~axum                 1   1       1474            0    365.9kb
sourcerer-v1-files~tokio-rs~tokio                1   1       5175            0        1mb
sourcerer-v1-files~tutao~tutanota                1   1       9267            0      1.6mb
sourcerer-v1-files~uutils~coreutils              1   1       3226            0    806.6kb
sourcerer-v1-files~valkey-io~valkey              1   1       6681            0      1.4mb
sourcerer-v1-files~vuejs~core                    1   1       2785            0      750kb
sourcerer-v1-lines~ansible~ansible               1   1   23067027            0      6.9gb
sourcerer-v1-lines~apache~druid                  1   1   10230691            0      1.4gb
sourcerer-v1-lines~apache~lucene                 1   1    9857695            0      1.5gb
sourcerer-v1-lines~astral-sh~ruff                1   1    5895776            0    797.9mb
sourcerer-v1-lines~astropy~astropy               1   1   16855521            0      5.1gb
sourcerer-v1-lines~axios~axios                   1   1     104915            0     17.3mb
sourcerer-v1-lines~babel~babel                   1   1     661054            0     96.4mb
sourcerer-v1-lines~briannesbitt~carbon           1   1    2256429            0      339mb
sourcerer-v1-lines~burntsushi~ripgrep            1   1     121406            0     20.5mb
sourcerer-v1-lines~caddyserver~caddy             1   1     423391            0     60.4mb
sourcerer-v1-lines~django~django                 1   1  187595831            0     26.4gb
sourcerer-v1-lines~element-hq~element-web        1   1    5435563            0        1gb
sourcerer-v1-lines~facebook~docusaurus           1   1    1098502            0      173mb
sourcerer-v1-lines~faker-ruby~faker              1   1     281240            0     72.8mb
sourcerer-v1-lines~fastlane~fastlane             1   1    3774359            0    515.3mb
sourcerer-v1-lines~flipt-io~flipt                1   1    2318144            0    339.1mb
sourcerer-v1-lines~fluent~fluentd                1   1     616236            0     87.9mb
sourcerer-v1-lines~fmtlib~fmt                    1   1     525144            0     78.7mb
sourcerer-v1-lines~future-architect~vuls         1   1    1340951            0    410.3mb
sourcerer-v1-lines~gin-gonic~gin                 1   1      42186            0      6.2mb
sourcerer-v1-lines~gohugoio~hugo                 1   1    1323287            0    415.2mb
sourcerer-v1-lines~google~gson                   1   1     269864            0     82.1mb
sourcerer-v1-lines~gravitational~teleport        1   1   35961497            0      5.3gb
sourcerer-v1-lines~hashicorp~terraform           1   1    1913651            0    284.3mb
sourcerer-v1-lines~immutable-js~immutable-js     1   1     132082            0     41.1mb
sourcerer-v1-lines~internetarchive~openlibrary   1   1    6505234            0    903.1mb
sourcerer-v1-lines~javaparser~javaparser         1   1     756387            0    233.7mb
sourcerer-v1-lines~jekyll~jekyll                 1   1      57072            0      9.2mb
sourcerer-v1-lines~jordansissel~fpm              1   1      32285            0      8.8mb
sourcerer-v1-lines~jqlang~jq                     1   1     373367            0     56.1mb
sourcerer-v1-lines~laravel~framework             1   1    4680025            0      1.2gb
sourcerer-v1-lines~matplotlib~matplotlib         1   1   24426468            0      3.6gb
sourcerer-v1-lines~micropython~micropython       1   1    2104199            0      320mb
sourcerer-v1-lines~mrdoob~three.js               1   1    5519778            0   1014.6mb
sourcerer-v1-lines~mwaskom~seaborn               1   1     219103            0       35mb
sourcerer-v1-lines~navidrome~navidrome           1   1    1293741            0    405.1mb
sourcerer-v1-lines~nlohmann~json                 1   1     174859            0     53.5mb
sourcerer-v1-lines~nodebb~nodebb                 1   1    6621106            0      2.2gb
sourcerer-v1-lines~nushell~nushell               1   1    1424029            0    405.3mb
sourcerer-v1-lines~pallets~flask                 1   1      34538            0     11.1mb
sourcerer-v1-lines~php-cs-fixer~php-cs-fixer     1   1    1272280            0    357.6mb
sourcerer-v1-lines~phpoffice~phpspreadsheet      1   1    1999435            0      291mb
sourcerer-v1-lines~preactjs~preact               1   1     975966            0    274.3mb
sourcerer-v1-lines~projectlombok~lombok          1   1    1480367            0    470.7mb
sourcerer-v1-lines~prometheus~prometheus         1   1    1132908            0    484.6mb
sourcerer-v1-lines~protonmail~webclients         1   1   22247802            0      3.2gb
sourcerer-v1-lines~psf~requests                  1   1     337782            0    144.1mb
sourcerer-v1-lines~pydata~xarray                 1   1    2413091            0    714.7mb
sourcerer-v1-lines~pylint-dev~pylint             1   1    1188057            0    351.7mb
sourcerer-v1-lines~pytest-dev~pytest             1   1    1882734            0    532.2mb
sourcerer-v1-lines~qutebrowser~qutebrowser       1   1    6734755            0        1gb
sourcerer-v1-lines~reactivex~rxjava              1   1     486774            0    135.3mb
sourcerer-v1-lines~redis~redis                   1   1    3551629            0        1gb
sourcerer-v1-lines~rubocop~rubocop               1   1    2899319            0    763.3mb
sourcerer-v1-lines~scikit-learn~scikit-learn     1   1   10940081            0      3.2gb
sourcerer-v1-lines~sharkdp~bat                   1   1     317845            0      118mb
sourcerer-v1-lines~sphinx-doc~sphinx             1   1   15102452            0      3.9gb
sourcerer-v1-lines~sympy~sympy                   1   1   45538891            0     13.8gb
sourcerer-v1-lines~tokio-rs~axum                 1   1     146486            0     40.6mb
sourcerer-v1-lines~tokio-rs~tokio                1   1    1068294            0    293.9mb
sourcerer-v1-lines~tutao~tutanota                1   1    2932455            0    985.3mb
sourcerer-v1-lines~uutils~coreutils              1   1     478330            0    127.7mb
sourcerer-v1-lines~valkey-io~valkey              1   1    1876977            0    575.5mb
sourcerer-v1-lines~vuejs~core                    1   1     684268            0    190.9mb
sourcerer-v1-refs                                1   1        847            0    457.5kb<p>Benchmark task prompt:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3b4aec652d09215e/6a816866f6ab8740b5d16c31/Screenshot_2026-08-16_at_10.35.51_a.m..png" alt="" /><p>Table 6. This table compares <a href="http://google.com/url?q=https://github.com/Qiushao-E/SWE-Explore-Bench/blob/main/explorers/claude_code.py%23L30-L47&amp;sa=D&amp;source=docs&amp;ust=1786869254292102&amp;usg=AOvVaw3XAbMzUTtNbrrEmcvVpLY9">Claude Code's task prompt </a>used in the original paper and <a href="https://github.com/elastic/sourcerer/blob/main/src/sourcerer/commands/benchmark/swe_explore_bench/explorer.py#L145-L177">Sourcerer's task prompt</a> used in this benchmark run. The highlights indicate where Sourcerer's task prompt differed from Claude Code's: red indicates an instruction from Claude Code's task prompt that wasn't used by Sourcerer's, and green indicates an instruction that was unique to Sourcerer's task prompt.</p><h3>Appendix B. Code search speed and scalability benchmark configuration</h3><p>Benchmark environment:</p><ul><li><p>Sourcerer version: v2.0.0 (commit hash: <a href="https://github.com/elastic/sourcerer/tree/dcc373b3bf7b24168e00ed7b172aea7227f705f9">dcc373b3bf7b24168e00ed7b172aea7227f705f9</a>)</p></li><li><p>Elastic Cloud Hosted (ECH) for ES|QL:</p></li></ul><ul><li><p>Region: GCP - Los Angeles (us-west2)</p></li><li><p>CPU Optimized hardware (c4a-highcpu)</p></li><li><p>2x Elasticsearch data nodes (each with 16GiB RAM, 8 vCPUs)</p></li><li><p>1x Kibana instances (2GiB RAM)</p></li><li><p>Elastic stack version: v9.5.0 (commit hash: <a href="https://github.com/elastic/elasticsearch/tree/8d4246a64bc255212407b1b313fe402391299c88">8d4246a64bc255212407b1b313fe402391299c88</a>)</p></li></ul><ul><li><p>Virtual machine for <code>ripgrep</code> and <code>grep</code>:</p></li><ul><li><p>Region: GCP - Los Angeles (us-west2-a)</p></li><li><p>CPU Optimized hardware (c4a-highcpu-8-lssd)</p></li><li><p>16GiB RAM</p></li><li><p>Image: projects/ubuntu-os-cloud/global/images/ubuntu-2404-noble-arm64-v20260717</p></li><li><p>Provisioned IOPS: 3300</p></li><li><p>Provisioned throughput: 215</p></li></ul></ul><p>Indices as reported by <code>GET /_cat/indices</code>:</p>index                                           pri rep docs.count docs.deleted store.size
sourcerer-v2-lines~github~elastic~elasticsearch   1   0  200387235            0     44.8gb<p>Cluster settings adjusted to avoid truncating the returned match count on frequent patterns:</p>PUT /_cluster/settings
{
  "persistent": {
    "esql.query.result_truncation_max_size": 1000000
  }
}<p>Regular expression used by <code>ripgrep</code> and <code>grep</code>:</p><ul><li><p>DiskBBQ: <code>.*[dD][iI][sS][kK][-_]?[bB][bB][qQ].*</code></p></li><li><p>XContentType: <code>.*[xX][cC][oO][nN][tT][eE][nN][tT][tT][yY][pP][eE].*</code></p></li></ul><p>ES|QL query syntax used for <code>sourcerer.code.grep</code>:</p><p>ES|QL query syntax used for <code>sourcerer.code.search</code>:</p><p>ES|QL query parameters used in all tests:</p><ul><li><p><code>git_host="github"</code></p></li><li><p><code>git_org="elastic"</code></p></li><li><p><code>git_repo="elasticsearch"</code></p></li><li><p><code>n=1000000</code></p></li></ul><p>ES|QL query parameters used for specific tests:</p><ul><li><p>Corpus with single commit: <code>git_commit="45f6a06b1b441b41fe711059b8720013173e7c89"</code></p></li><li><p>Corpus with all commits: <code>git_commit="*"</code></p></li><li><p><code>sourcerer.code.grep</code> pattern for rare pattern (DiskBBQ): <code>regex=".*[dD][iI][sS][kK][-_]?[bB][bB][qQ].*"</code></p></li><li><p><code>sourcerer.code.grep</code> pattern for common pattern (XContentType): <code>regex=".*[xX][cC][oO][nN][tT][eE][nN][tT][tT][yY][pP][eE].*"</code></p></li><li><p><code>sourcerer.code.search</code> pattern for common pattern (DiskBBQ): <code>q="DiskBBQ"</code></p></li><li><p><code>sourcerer.code.search</code> pattern for common pattern (XContentType): <code>q="XContentType"</code></p></li></ul><p><code>n</code>  was set high enough to recover the true, uncapped match count for each pattern (605 and 1,041 matches for DiskBBQ;  7,999 and 290,662 matches for XContentType), confirmed in each case by comparing <code>hits_returned</code> against <code>ripgrep</code>'s and <code>grep</code>'s own match counts on the same underlying data.</p><p>Command syntax used for <code>ripgrep</code> :</p><p><code>rg -n --no-ignore -j 6</code></p><p>Command syntax used for <code>grep</code>:</p><p><code>grep -E -r -n --binary-files=without-match --exclude-dir=.git</code> </p><p>Directories of each cloned repository snapshot and the approximate total sizes of their nonbinary files tracked by Git, which is the search space of <code>ripgrep</code> and <code>grep</code>:</p>54M     elastic-elasticsearch-v6.0.1
55M     elastic-elasticsearch-v6.1.4
56M     elastic-elasticsearch-v6.2.4
79M     elastic-elasticsearch-v6.3.2
83M     elastic-elasticsearch-v6.4.3
89M     elastic-elasticsearch-v6.5.4
95M     elastic-elasticsearch-v6.6.2
99M     elastic-elasticsearch-v6.7.2
100M    elastic-elasticsearch-v6.8.23
99M     elastic-elasticsearch-v7.0.1
99M     elastic-elasticsearch-v7.1.1
135M    elastic-elasticsearch-v7.10.2
137M    elastic-elasticsearch-v7.11.2
140M    elastic-elasticsearch-v7.12.1
144M    elastic-elasticsearch-v7.13.4
147M    elastic-elasticsearch-v7.14.2
149M    elastic-elasticsearch-v7.15.2
154M    elastic-elasticsearch-v7.16.3
157M    elastic-elasticsearch-v7.17.29
103M    elastic-elasticsearch-v7.2.1
105M    elastic-elasticsearch-v7.3.2
109M    elastic-elasticsearch-v7.4.2
113M    elastic-elasticsearch-v7.5.2
118M    elastic-elasticsearch-v7.6.2
124M    elastic-elasticsearch-v7.7.1
127M    elastic-elasticsearch-v7.8.1
132M    elastic-elasticsearch-v7.9.3
149M    elastic-elasticsearch-v8.0.1
152M    elastic-elasticsearch-v8.1.3
173M    elastic-elasticsearch-v8.10.4
182M    elastic-elasticsearch-v8.11.4
187M    elastic-elasticsearch-v8.12.2
191M    elastic-elasticsearch-v8.13.4
196M    elastic-elasticsearch-v8.14.3
205M    elastic-elasticsearch-v8.15.5
230M    elastic-elasticsearch-v8.16.6
233M    elastic-elasticsearch-v8.17.10
241M    elastic-elasticsearch-v8.18.8
250M    elastic-elasticsearch-v8.19.18
149M    elastic-elasticsearch-v8.2.3
153M    elastic-elasticsearch-v8.3.3
155M    elastic-elasticsearch-v8.4.3
159M    elastic-elasticsearch-v8.5.3
161M    elastic-elasticsearch-v8.6.2
164M    elastic-elasticsearch-v8.7.1
168M    elastic-elasticsearch-v8.8.2
170M    elastic-elasticsearch-v8.9.2
231M    elastic-elasticsearch-v9.0.8
244M    elastic-elasticsearch-v9.1.10
254M    elastic-elasticsearch-v9.2.8
267M    elastic-elasticsearch-v9.3.7
305M    elastic-elasticsearch-v9.4.3]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/code-search-sourcerer-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/code-search-sourcerer-elasticsearch</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Dave Moore]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9ea6549b4ea8708/6a7deaae0da673fe0c57c6ea/image3.png" length="0" type="image/png"/>
    <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Search relevance from click streams: Using Learn To Rank and behavioral signals with OpenTelemetry]]></title>
    <description><![CDATA[Learn how to turn click streams and behavioral signals from OpenTelemetry search analytics into judgment lists, rank features and Learn To Rank models that make search relevance improve over time.]]></description>
    <content:encoded><![CDATA[<p>Every click on a search result is an implicit relevance judgment, and conversions are a stronger signal. The search analytics you've been capturing through OpenTelemetry contain the behavioral data to improve search relevance. Techniques start simple, with fixes you can ship this week and build toward Learn To Rank (LTR) models trained on real click data. The instrumentation that surfaces problems also generates the training data to fix them.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Build judgment lists from click data to evaluate and improve relevance.</p></li><li><p>Apply basic search tuning (field weights, boosts, query rules) informed by analytics.</p></li><li><p>Create rank features from behavioral signals, like popularity and conversion rate.</p></li><li><p>Understand LTR and how click data becomes training data.</p></li><li><p>Close the feedback loop between analytics and relevance improvement.</p></li></ul><h2>What you'll need</h2><ul><li><p>Search analytics data from the previous blogs in this <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">series</a> (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">search</a>, <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">click</a>, and <a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">conversion</a> spans in Elastic).</p></li><li><p>An Elasticsearch index with product data (the reference project includes sample data with rank features).</p></li><li><p>Familiarity with Elasticsearch queries (BM25 [Elasticsearch's default text scoring algorithm], <code>rank_feature</code>, function scores).</p></li></ul><h2>Search relevance techniques at a glance</h2><p><strong>Technique</strong></p><p><strong>Effort</strong></p><p><strong>What it improves</strong></p><p><strong>When to use</strong></p><p><strong>Field weight tuning</strong></p><p>Low</p><p>Relevance for attribute-rich queries</p><p>First step; quick wins</p><p><strong>Query rules</strong></p><p>Low–medium</p><p>Specific high-value queries</p><p>Known bad results for specific terms</p><p><strong>Rank features</strong></p><p>Medium</p><p>Blending behavioral signals with text score</p><p>Popularity, conversion, freshness boosts</p><p><strong>LTR</strong></p><p>High</p><p>Systematic ranking from click data</p><p>When you have ≥5k labeled query-doc pairs</p><h2>From search analytics to search relevance improvements</h2><p>Over the past three posts, you built a full instrumentation pipeline, including search spans with <code>search.*</code> attributes and click tracking with position data and click-through rate (CTR) / Mean Reciprocal Rank (MRR) metrics. This pipeline also includes conversion spans tying searches to revenue. And it all sits in <code>traces-generic.otel-default</code>, queryable with Elasticsearch Query Language (ES|QL).</p><p></p><p>Now you have dashboards, and you know that your CTR is 28%. You also know which queries generate revenue and which ones users abandon. Plus, you can tell your product manager exactly where the funnel leaks.</p><p></p><p>Now what?</p><p></p><p>The real value of search analytics is using behavioral data to improve relevance and close feedback loops. It’s also important to make search learn from its users. Your experience tells you that measurement is necessary but not sufficient, and a dashboard that shows poor ranking doesn't improve that ranking. </p><p></p><p></p><p></p><p>This post covers four practical areas:</p><p></p><ol><li><p><strong>Judgment lists:</strong> The foundation for evaluating and improving relevance.</p></li><li><p><strong>Basic search tuning:</strong> Field weights, boosts, and query rules informed by analytics.</p></li><li><p><strong>Rank features for personalization:</strong> Feeding behavioral signals back into ranking.</p></li><li><p><strong>LTR:</strong> Training machine learning (ML) models on click data to optimize ranking automatically.</p></li></ol><p></p><p>Each one draws directly from the <code>search.*</code> attributes you're already collecting, and they don’t require any new instrumentation.</p><h2>What are judgment lists?</h2><p>Before diving into specific techniques, it's worth understanding the concept that ties them all together: <em>judgment lists</em>.</p><p></p><p>A judgment list is a set of query-document pairs with relevance grades: For a given query, how relevant is each document? They look like this:</p><p></p><p><strong>Query</strong></p><p><strong>Document</strong></p><p><strong>Grade</strong></p><p><strong>Label</strong></p><p>"wireless headphones"</p><p>SKU-001 (Sony WH-1000XM5)</p><p>3</p><p>Highly relevant</p><p>"wireless headphones"</p><p>SKU-042 (AirPods Max)</p><p>2</p><p>Relevant</p><p>"wireless headphones"</p><p>SKU-099 (Wired earbuds)</p><p>0</p><p>Not relevant</p><p></p><p>Judgment lists serve three purposes:</p><p></p><ul><li><p><strong>Evaluating current relevance.</strong> Given a set of queries and known-good results, how well does your search rank them? Metrics like <a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">Normalized Discounted Cumulative Gain</a> (NDCG) and the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Rank Eval API</a> use judgment lists to score your ranking quality. This gives you a baseline before making changes.</p></li></ul><p></p><ul><li><p><strong>Measuring the impact of changes.</strong> When you adjust field weights or add synonyms, judgment lists tell you whether the change helped or hurt. The same is true when you change boosting rules. Run the same evaluation before and after: If NDCG went up, the change improved relevance for those queries.</p></li></ul><p></p><ul><li><p><strong>Training LTR models.</strong> LTR algorithms need labeled training data, such as, <em>For this query, these documents are relevant and those aren't</em>. Judgment lists are that training data.</p></li></ul><h3>Manual vs. automated judgment lists</h3><p>Traditionally, judgment lists are created by human assessors who manually rate documents for a set of test queries. This works well for a small number of high-value queries (your top 50 searches, for example), but it doesn't scale. A large catalog with thousands of distinct queries and frequent inventory changes makes manual assessment impractical.</p><p></p><p>This is where your click data becomes valuable. Every click is an implicit relevance judgment; that is, a signal that for a given query, a given document was relevant enough to engage with. Conversions are even stronger signals. By aggregating this data, you can build judgment lists automatically from real user behavior, at a scale that manual assessment can't match.</p><p></p><p>The trade-off is noise. Clicks are influenced by position bias (users click higher-ranked results more often regardless of relevance) and presentation effects. They’re also influenced by accidental clicks. We'll cover techniques to handle this noise later in this post. But the key insight is that approaches like personalization and LTR lean toward automated judgment lists because it's not scalable to manually create lists for every query or user segment, nor for every inventory change.</p><p></p><p>The <a href="https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch">judgment lists guide on Search Labs</a> covers the concept in depth, including how to structure lists for evaluation with the Rank Eval API.</p><h2>How search analytics identify search relevance problems</h2><p>The most immediate use of your analytics data is to identify and fix specific relevance problems. This only requires using data to direct manual improvements, no personalization or ML.</p><h3>Finding problem queries with search analytics</h3><p>The ES|QL queries from the click in your application give you per-query CTR and MRR. Sort by search volume descending and CTR ascending to find your highest-impact relevance failures:</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true)
  AND attributes.search.query IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
  BY attributes.search.query
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
| WHERE searches &gt; 5
| SORT ctr_pct ASC, searches DESC
| LIMIT 20<p>This is the CTR-by-query query re-sorted to surface problems first. The <code>WHERE searches &gt; 5</code> filter removes one-off queries that would dominate the low-CTR list with small sample noise.</p><ul><li><p><strong>Zero-CTR queries with results. </strong> Your ranking is returning content, but none of it’s compelling. These queries often benefit from synonym expansion. You can also improve them with boosting rules or pinned results.</p></li><li><p><strong>Low-CTR, high-volume queries.</strong> These are the biggest relevance investment opportunities, and they affect the most users.</p></li><li><p><strong>Low-MRR, high-CTR queries.</strong> Users find what they need but have to scroll for it. In these instances, the relevant documents exist, but they're ranked wrong.</p></li></ul><h3>Try it: Close the loop in the reference project</h3><p>If you have click data in the reference project(<code>python generate_traffic.py --blog 3 --sessions 100</code>), you can run the problem-query query above against <code>traces-generic.otel-default</code> and observe your lowest-CTR queries.</p><p></p><p>The reference project's <code>app.py</code> already uses <code>rank_feature</code> boosting: <code>rank_features.popularity</code>, <code>rank_features.conversion_rate</code>, <code>rank_features.margin_score</code>, and <code>rank_features.freshness</code> are indexed on every product and blended into the BM25 score at query time. To see the effect of adjusting a boost:</p><p></p><ol><li><p>Open <code>reference/app.py</code>, and find the <code>"should"</code> clause in <code>_build_search_query()</code>.</p></li><li><p>Change the <code>"boost"</code> value on <code>rank_features.popularity</code> from <code>2</code> to <code>5</code>.</p></li><li><p>Restart the server (<code>python app.py</code>) and rerun <code>generate_traffic.py --blog 3 --sessions 50</code>.</p></li><li><p>Compare the top-queries and click position distribution in ES|QL before and after.</p></li></ol><p></p><p>This is informed manual tuning using the behavioral data you collected, not ML. That's the pattern for the rest of this post: First, ES|QL tells you what's wrong. Then, configuration changes and, eventually, LTR models fix it.</p><h3>Field weights, boosts, and scoring for search relevance</h3><p>The simplest tuning lever in Elasticsearch is adjusting how different fields contribute to the relevance score. A <code>multi_match</code> query across <code>title</code>, <code>description</code>, and <code>brand</code> fields can weight the title higher because a match there is usually more relevant. Your analytics data tells you where these weights are wrong: If users consistently click products with titles that don't match the query but with descriptions that do, your title boost may be too aggressive.</p><p></p><p>Beyond field weights, you can incorporate business metrics directly into scoring. A common example is <em>boosting by profit margin</em>; that is, products with higher margins rank slightly higher when relevance scores are similar. The <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rank-feature.html"><code>rank_feature</code> field type</a> is designed for exactly this: Index a numeric signal (margin, popularity, recency) alongside each document, and the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-rank-feature-query.html"><code>rank_feature</code> query</a> blends it with text relevance at query time. For more complex scoring combinations, like decay functions, weighted field values, and scripts, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html"><code>function_score</code> query</a> gives you full control. The <a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch">boosting by profit and popularity guide on Search Labs</a> walks through this in detail, including the trade-offs between additive and <a href="https://www.elastic.co/search-labs/blog/bm25-ranking-multiplicative-boosting-elasticsearch">multiplicative boosting</a> approaches.</p><h3>Query rules for targeted search relevance fixes</h3><p>For targeted interventions, <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/searching-with-query-rules">query rules</a> let you pin, boost, or exclude specific results for specific queries, and no model training is required. They're the search equivalent of a manual override.</p><p></p><p>Your analytics data tells you exactly where to apply them. When you see a zero-CTR query, like "returns policy", that consistently returns product results instead of the returns page, you can pin the returns page at position 1 for that query. If you notice a high-revenue query where the best-selling product appears at position 4, you can boost it.</p><p></p><p>Query rules are valuable precisely because they're simple. They solve known problems immediately while you build toward more sophisticated approaches. The <a href="https://www.elastic.co/search-labs/blog/elasticsearch-query-rules-ui-introduction">query rules tutorial</a> on Search Labs walks through the setup.</p><h2>Building judgment lists from click data</h2><p>The basic tuning above handles individual problems. To improve relevance systematically, you need judgment lists, and your click data can build them automatically.</p><h3>Converting click data into relevance grades</h3><p>The simplest approach is to count clicks per query-document pair and assign graded relevance:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
  AND attributes.search.query IS NOT NULL
| STATS
    click_count = COUNT(*),
    avg_position = AVG(attributes.search.result_click_position)
  BY attributes.search.query, attributes.search.result_click_id
| SORT attributes.search.query, click_count DESC<p>This gives you a table of (<code>query</code>, <code>document</code>, <code>click_count</code>, <code>avg_position</code>) tuples. The grading step maps click counts to relevance levels:</p><p><strong>Click count</strong></p><p><strong>Suggested grade</strong></p><p><strong>Label</strong></p><p>0</p><p>0</p><p>Not relevant (never clicked for this query)</p><p>1</p><p>1</p><p>Marginally relevant</p><p>2–3</p><p>2</p><p>Relevant</p><p>4+</p><p>3</p><p>Highly relevant</p><p></p><p>The thresholds depend on your traffic volume. For a high-traffic site, you might need 10+ clicks before calling something "highly relevant." For lower traffic, even two or three clicks is a meaningful signal. The point is that click frequency across users is a stronger signal than any single click.</p><p></p><p>You can strengthen judgments further by incorporating conversion data, a document that gets clicked <em>and</em> added to cart is a stronger relevance signal than one that gets clicked and abandoned:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action IN ("click", "add_to_cart")
  AND attributes.search.query IS NOT NULL
| STATS
    clicks = COUNT(CASE(attributes.search.action == "click", 1)),
    carts = COUNT(CASE(attributes.search.action == "add_to_cart", 1))
  BY attributes.search.query, attributes.search.result_click_id
| SORT attributes.search.query, clicks DESC<p>A document with five clicks and three cart additions is a stronger candidate for grade 3 than one with five clicks and zero cart additions.</p><h3>Handling position bias in click data</h3><p>There's a problem with raw click counts: <em>position bias</em>. Users click position 1 more often because they <em>see</em> it first, not necessarily because it's the most relevant result. Blog 3 introduced this concept when we discussed click position distribution and referenced the foundational work by <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf">Joachims et al. (2005)</a>.</p><p></p><p>Position bias matters for judgment lists because it means raw click data over-weights whatever the current ranking happens to surface first. If you train an LTR model on biased judgments, it learns to replicate the existing ranking, which defeats the purpose.</p><p></p><p>Two practical approaches to handle this:</p><p></p><ul><li><p><strong>Position-normalized click rates.</strong> Instead of raw click counts, calculate click-through rate <em>per position</em>. A document clicked 3 out of 10 times when shown at position 5 is arguably more relevant than one clicked 5 out of 10 times at position 1. Position 5 gets less visibility, so a higher click rate there is a stronger relevance signal.</p></li></ul><ul><li><p><strong>Skip-above heuristics.</strong> When a user clicks position 3 but skips positions 1 and 2, those skipped documents are implicitly judged "less relevant" for that query. This is the core insight from Joachims et al.: Skipped-above results provide negative training signals. You can extract these pairs from your click data:</p></li></ul>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
  AND attributes.search.query IS NOT NULL
| STATS
    min_click_position = MIN(attributes.search.result_click_position),
    max_click_position = MAX(attributes.search.result_click_position),
    click_count = COUNT(*)
  BY attributes.search.query_id, attributes.search.query
| WHERE min_click_position &gt; 1
| SORT click_count DESC<p>Queries where the minimum click position is greater than 1 are sessions where the user skipped the top result(s). The documents at those skipped positions, for those queries, are candidates for grade 0 in your judgment list, because the user saw them and chose something lower.</p><p></p><p>For production judgment list generation, the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR tutorial on Search Labs</a> walks through the complete pipeline. The <a href="https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data">training LTR models with user behavior data</a> guide covers the specific workflow of deriving training data from click-through behavior, including the Clicks Over Expected Clicks (COEC) algorithm for debiasing. And the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb">LTR Jupyter notebooks</a> provide working Python code for feature extraction and model training.</p><h2>Rank features for search relevance personalization</h2><p>Basic tuning applies the same ranking to every user. Personalization means adapting results based on who’s searching (their history, preferences, or segment). Rank features are the most accessible way to do this in Elasticsearch.</p><h3>Building behavioral signals from click streams</h3><p>You can aggregate your click and conversion data at the document level to produce signals that reflect overall or segment-specific popularity:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action IN ("click", "add_to_cart")
| STATS
    clicks = COUNT(CASE(attributes.search.action == "click", 1)),
    carts = COUNT(CASE(attributes.search.action == "add_to_cart", 1))
  BY attributes.search.result_click_id
| EVAL cart_rate_pct = ROUND(100.0 * carts / clicks, 1)
| SORT clicks DESC
| LIMIT 20<p>These document-level signals (click popularity, cart rate, and conversion rate) are indexed as <code>rank_feature</code> fields on each product document. The workflow:</p><p></p><ol><li><p>Run the ES|QL query above periodically (daily or weekly).</p></li><li><p>Write the results back to a <code>click_popularity</code> or <code>conversion_rate</code> <code>rank_feature</code> field on each product document.</p></li><li><p>Use a <code>rank_feature</code> query to blend text relevance with behavioral signals.</p></li></ol><p></p><p>The <code>rank_feature</code> query applies a saturation function by default: Initial popularity gains matter most, diminishing as values get large. This prevents a single viral product from dominating all queries. You can tune the function's pivot point to control how much influence the feature has relative to text relevance.</p><h3>Personalizing rank features by user segment</h3><p>The query above produces <em>global</em> popularity; that is, it’s the same for every user. Personalization comes from segmenting these signals. If you're tracking <code>enduser.pseudo.id</code> or <code>user.id</code>, you can compute features per user cohort:</p><p></p><ul><li><p><strong>Category affinity:</strong> How often does this user click products in "electronics" versus "clothing"?</p></li><li><p><strong>Price sensitivity:</strong> Does this user tend to click and convert on higher- or lower-priced items?</p></li><li><p><strong>Brand preference:</strong> Which brands does this user engage with most?</p></li></ul><p></p><p>These become additional rank features, applied at query time based on who's searching. The <a href="https://www.elastic.co/search-labs/blog/personalized-search-elasticsearch-ltr">personalized search with LTR</a> guide on Search Labs walks through training per-user ranking models. For a lighter approach, the <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch">cohort-aware ranking guide</a> shows how to use multiplicative boosting to personalize at the segment level without ML, just analytics-derived weights applied to rank features at query time.</p><h3>Combining multiple signals with the linear retriever</h3><p>When you're blending text relevance with semantic search and behavioral rank features, you need a way to combine them. Elasticsearch's <a href="https://www.elastic.co/search-labs/blog/linear-retriever-hybrid-search">linear retriever</a> gives you precise control over how different query types contribute to the final ranking. It computes a weighted sum of normalized scores, so you can say <em>text relevance matters 60%, semantic similarity 30%, popularity 10%</em> and adjust those weights based on your analytics. This is unlike Reciprocal Rank Fusion (RRF), which only considers relative rank positions.</p><p></p><p>This is particularly useful for personalization because you can vary the weights per user segment. A returning customer might get more weight on purchase history, while a first-time visitor gets more weight on global popularity.</p><p></p><p>If you're also using semantic search with embedding models, the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service">Elastic Inference Service</a> (EIS) now offers GPU-accelerated embedding generation, including multilingual models, directly within Elastic Cloud, making it straightforward to add a semantic retriever alongside your text and behavioral signals.</p><h2>How Learn To Rank uses click streams to optimize ranking</h2><p>Rank features handle individual signals, but LTR handles all of them at once. It trains an ML model to combine features (like text relevance, popularity, CTR, recency, margin, or user affinity) into a single ranking function optimized for your users.</p><h3>What you need for Learn To Rank in Elasticsearch</h3><p>Elasticsearch has supported <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">native Learning to Rank since version 8.12</a> as an Enterprise subscription feature. The pipeline:</p><p></p><ol><li><p><strong>Judgment list.</strong> Query-document pairs with relevance grades (built from click data, as above).</p></li><li><p><strong>Feature extraction.</strong> Numeric signals for each query-document pair (BM25 score, popularity, CTR, recency, price, margin, user segment features).</p></li><li><p><strong>Model training.</strong> Typically XGBoost or LambdaMART, trained on your judgment list with features.</p></li><li><p><strong>Deployment.</strong> Upload the trained model to Elasticsearch via Eland, and use it as a rescorer.</p></li></ol><p></p><p>The click and conversion data from this series feeds steps 1 and 2, and the judgment list is your training labels. The document-level features from the rank features section above (click popularity, conversion rate, margin) are additional features alongside text relevance scores.</p><h3>Why automated judgment lists from click data scale better</h3><p>This is where the scalability argument for automated judgment lists becomes concrete. A manually curated judgment list might cover your top 100 queries well, but personalized ranking needs judgment data across thousands of queries and multiple user segments. You can't hire assessors to rate results for "wireless headphones" separately for electronics enthusiasts, budget shoppers, and professional audio engineers.</p><p></p><p>Automated judgment lists from click data scale to every query your users actually run and update as inventory and user behavior change. They can also be segmented by cohort. The <a href="https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data">training LTR models with user behavior data</a> guide demonstrates this end-to-end workflow, showing how to go from raw click events to trained ranking models.</p><p></p><p>For teams looking to close this loop even further, the <a href="https://www.elastic.co/search-labs/blog/agentic-search-relevance-autotuning-elasticsearch">agentic autotuning approach</a> demonstrates using an AI agent to continuously monitor search quality and generate judgment lists from user interactions. It automatically retrains LTR models, turning the feedback loop into an autonomous system.</p><h3>What Learn To Rank requires: Traffic, pipeline, and evaluation</h3><p>LTR requires enough traffic to generate meaningful judgment lists and engineering time to build and maintain the pipeline. It also requires ongoing evaluation to ensure that the model improves over time. But for search applications with sufficient volume, it's the most effective way to make ranking learn from user behavior. The <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR introduction on Search Labs</a> covers the full scope of what's involved.</p><h2>Elasticsearch Relevance Studio: Visual search relevance tuning</h2><p>Between query rules (manual, targeted) and LTR (ML, systemic), there's a middle ground: visual relevance tuning. <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> is a tool for comparing and tuning search configurations side by side. It lets you adjust boost values, field weights, and query structures while seeing the results update in real time.</p><p></p><p>Analytics data, especially CTR and MRR, tells you which queries to focus on. Start from a ranked list of problem queries (the low-CTR, high-volume queries from the ES|QL analysis above), and work through them systematically in Relevance Studio, instead of guessing which searches need tuning.</p><p></p><p>The workflow:</p><p></p><ol><li><p><strong>Identify problem queries.</strong> Run the CTR-by-query and MRR-by-query analyses.</p></li><li><p><strong>Open those queries in Relevance Studio.</strong> See current results alongside the tuned version.</p></li><li><p><strong>Adjust field weights and boosting.</strong> Experiment with configuration changes.</p></li><li><p><strong>Evaluate with judgment lists.</strong> Use the Rank Eval API to confirm that the change improves NDCG for your test queries.</p></li><li><p><strong>Measure the impact in production.</strong> Rerun the analytics after deploying changes, and compare CTR / MRR.</p></li></ol><p></p><p>This before-and-after loop is where analytics and tuning connect. With the data analytics you can prioritize the queries that affect the most users and verify that changes actually helped. Without this data, tuning is guesswork, and you're adjusting weights without knowing which queries matter or how to measure improvement. </p><p></p><p>If you are looking at tuning and evaluating relevance you should also investigate the <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> project which lets you directly compare search strategies. </p><h2>Closing the loop between search analytics and search relevance</h2><p>Search relevance improvement follows a consistent loop: measure quality with ES|QL analytics, apply changes (query rules, field weights, rank features or LTR models), then measure again.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc895531c8a5ff31/6a7ef484af7924b0b425af35/image1.png" alt="Diagram showing the search analytics feedback loop from click streams through judgment lists to search relevance improvement" /><p>The key insight is that the same instrumentation that measures search quality also generates the data to improve it. Your search spans produce the queries to analyze, and your click spans produce the judgment data for evaluation and LTR training. Plus, your conversion spans tell you which improvements matter most to the business.</p><h3>Measuring search relevance improvements after changes</h3><p>After deploying a change, like a new query rule or updated field weights, or deploying an LTR model, you need to know whether it helped:</p><p></p><p><strong>Metric</strong></p><p><strong>What it measures</strong></p><p><strong>How to compare</strong></p><p>CTR trend</p><p>Whether more users are clicking results after the change</p><p>Run the CTR-by-query ES|QL query for the week before and after; compare per-query percentages</p><p>MRR trend</p><p>Whether clicks are shifting toward higher-ranked positions</p><p>Compare mean reciprocal rank per query before and after using the MRR-by-query analysis</p><p>Conversion rate trend</p><p>Whether more clicks are turning into purchases or cart additions</p><p>Run the click-to-conversion ES|QL query for both periods; compare cart rate percentages</p><p>Revenue per query</p><p>Whether the business impact of the change is positive</p><p>Compare revenue attributed to affected queries via conversion spans before and after deployment</p><p></p><p>Run the same ES|QL queries before and after. If your change was a query rule for "laptop bag", compare that query's CTR and MRR from the week before to the week after.</p><h3>A/B testing with experiment attributes</h3><p>The <code>feature_flag.key</code> attribute  is designed for exactly this. Route a percentage of traffic to a new ranking configuration, and set <code>feature_flag.key</code> to the experiment name and optionally <code>feature_flag.result.variant</code> to the variant (for example, <code>"control"</code> or <code>"variant-boost-v2"</code>). Then propagate it to click spans the same way you propagate `query_id`, and compare metrics between groups:</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true)
  AND attributes.feature_flag.key IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
  BY attributes.feature_flag.key, attributes.feature_flag.result.variant
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p>This gives you an A/B comparison of CTR by experiment variant. You don’t need a separate experimentation platform for basic comparisons, although you’ll want a proper framework for statistical rigor on sample sizes and significance.</p><h3>Monitoring for search relevance regressions</h3><p>Once you've identified your high-value queries, that is, the ones that drive revenue and have been tuned for good engagement, you need to protect them. Relevance regressions on your top 20 revenue-generating queries are business-critical incidents.</p><p></p><p>This is where monitoring comes in. In the next blog in the series, we'll cover setting up alerts on these metrics: CTR drops on high-revenue queries and MRR regressions after deployments. We’ll also cover conversion rate anomalies. The same ES|QL queries that power your analytics dashboards can drive alerting rules; the feedback loop covers measurement and improvement. It also provides operational protection.</p><h2>Search relevance improvement roadmap: Week 1 to Quarter 1</h2><p>Here's a practical starting point. You don't need to implement LTR on day one. The approaches build on each other:</p><p></p><ul><li><p><strong>Week 1: Evaluate and fix known problems.</strong> Run the CTR-by-query analysis. Find your zero-CTR queries with high search volume, and fix the worst ones with synonyms or pinned results via query rules. You could also use adjusted field weights. Use judgment lists (even a small manual one for your top queries) to confirm that the changes improve NDCG before deploying. You should get immediate, targeted impact.</p></li></ul><p></p><ul><li><p><strong>Month 1: Add behavioral rank features.</strong> Extract document-level click popularity and conversion rates from your analytics. Index them as <code>rank_feature</code> fields, and blend behavioral signals with text relevance using the linear retriever or `rank_feature` queries. Then consider simple business boosts like margin. This lifts baseline quality across all queries without manual intervention per query.</p></li></ul><p></p><ul><li><p><strong>Quarter 1: Automate with LTR.</strong> Once you have enough click data (typically several weeks of production traffic), build judgment lists automatically from click and conversion data. Train an LTR model that combines text features, behavioral features, and business features, and then deploy it as a rescorer. The ranking now learns from user behavior and improves as you collect more data.</p></li></ul><p></p><p>At each stage, measure the impact with the same metrics. CTR and MRR are your scorecards, as is conversion rate. If a change doesn't move them, it didn't help, regardless of how sophisticated the approach.</p><h2>What's next: Search reliability engineering and SLOs</h2><p>We've covered the full sequence: instrument search, measure quality, track conversions, and improve relevance. The missing piece is making sure it all keeps working.</p><p></p><p>Next, we close the series with search reliability engineering, including Service Level Objectives (SLOs) for search quality and alerting on metric regressions, along with operational dashboards that catch problems before users notice them. The same metrics you've been building become the basis for search health monitoring, turning your analytics pipeline into a reliability system.</p><p></p><h2>Get started</h2><h3>Working code</h3><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li></ul><p></p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt259007788af667fe/6a7ee686ef5bef8db54fa878/image2.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Skip the stateful OTel Collector: Elasticsearch 9.5 natively stores both metric temporalities]]></title>
    <description><![CDATA[Ingest cumulative and delta OpenTelemetry metrics under the same metric name while ES|QL and PromQL queries auto-detect temporality per series, with no new syntax or conversion pipelines required.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch 9.5 natively stores both cumulative and delta OpenTelemetry (OTel) counters and histograms, even when mixed for the same metric name. You ingest via OpenTelemetry Protocol (OTLP) and Elasticsearch preserves the temporality metadata automatically.<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"> ES|QL TS</a> and<a href="https://www.elastic.co/docs/reference/query-languages/promql"> PromQL</a> queries detect the temporality per series and interpret the data correctly, without new syntax, configuration changes to your OTel SDKs or stateful OTel Collector conversion. Existing queries and downsampled data continue to work as expected.</p><h2>What is metric temporality in OpenTelemetry?</h2><p>Metrics stores usually receive client-side, pre-aggregated metrics. For example, if an application records request response times, it won’t send each individual response time as a single data point to your metrics back end. Instead, the application (or rather the OTel SDK) pre-aggregates those raw response times into counters or histograms. These pre-aggregated values are then exported at a periodic interval, dramatically reducing the number of data points. <em>Temporality</em> is about how this pre-aggregation works. There are two temporality models: <em>cumulative</em> and <em>delta</em>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1829137fe767c17d/6a7edfc40da673bd3357cae8/image6.png" alt="Diagram showing how delta and cumulative temporality represent the same OTel counter metric data points differently" /><h3>Cumulative temporality in OTel metrics</h3><p>With <em>cumulative temporality</em>, each data point represents the totalamount of change in the metric value since the process started. Values monotonically increase, with occasional reset to 0 (for example, when the process restarts).</p><p>Take a counter tracking the total CPU time consumed by a Java Virtual Machine (JVM):</p><p><strong>Timestamp</strong></p><p><strong>Value</strong></p><p><strong>Meaning</strong></p><p>10:01</p><p>12.4s</p><p>12.4s total CPU time since start</p><p>10:02</p><p>13.1s</p><p>13.1s total CPU time since start</p><p>10:03</p><p>13.9s</p><p>13.9s total CPU time since start</p><p>To compute the rate of change between 10:01 and 10:02, we subtract: <code>13.1 - 12.4 = 0.7s</code> of CPU time was consumed in that interval. Dividing by the time range of the interval gives us the <code>rate</code>. This is the default temporality for counters in both Prometheus and OTel.</p><h3>Delta temporality in OTel metrics</h3><p>With <em>delta temporality</em>, each data point represents the change since the last measurement. Values are independent of each other. In other words, after each export, the OTel SDK resets all values for all series.</p><p>The same raw observations from the cumulative example above would look as follows with delta temporality.</p><p><strong>Timestamp</strong></p><p><strong>Value</strong></p><p><strong>Meaning</strong></p><p>10:01</p><p>0.5s</p><p>0.5s of CPU time in this interval</p><p>10:02</p><p>0.7s</p><p>0.7s of CPU time in this interval</p><p>10:03</p><p>0.8s</p><p>0.8s of CPU time in this interval</p><p>To compute the rate or increase, we can use the value directly, without any subtraction.</p><h3>Trade-offs between cumulative and delta OpenTelemetry metrics</h3><p>Both temporalities have practical trade-offs:</p><ul><li><p><strong>Resilience to data loss: </strong>Cumulative counters are self-describing: If you miss an export, the next data point still gives you the correct total. Delta values are incremental, so a lost data point means that the corresponding increase is lost.</p></li><li><p><strong>Metric producer memory footprint: </strong>For cumulative temporality, the OTel SDKs need to keep a state for every series in memory. For delta temporality, the footprint is much lower. There, the SDKs only need to keep track of counters or histograms which changed since the last export. If there are a lot of counters or histograms and many of them don’t increase each period, this difference can be quite substantial.</p></li><li><p><strong>Aggregation across restarts: </strong>Cumulative counters require reset detection logic, which in edge cases can fail: If the metric value decreases, it’s detected as a reset. We assume that the application was restarted and the counter started from 0 again. This can be missed if the first reported counter value after the restart is higher than before the restart. A concrete example:</p></li><ul><li><p>The service consumes 1 second CPU time and restarts.</p></li><li><p>After the restart, the service performs a CPU-intensive task and consumes 2 seconds of CPU time before the metric is exported again.</p></li><li><p>The metric back end just sees 1 followed by 2 as the metric value. It never observes a decrease and therefore misses the reset.</p></li></ul></ul><p>Delta values don't have this problem since each value is independent.</p><p>If you’re using histograms, the trade-offs have an even bigger effect:</p><p><strong>Trade-off</strong></p><p><strong>Cumulative</strong></p><p><strong>Delta</strong></p><p>Histogram size</p><p>Buckets accumulate across exports, consuming more storage</p><p>Buckets reset each export, producing smaller histograms</p><p>Min/max accuracy</p><p>Approximated from buckets for custom time ranges (tracked values represent extremes since process start)</p><p>Exact per-export minimum and maximum values</p><p>Query performance</p><p>Faster: only the first and last value in a time range plus resets are needed</p><p>Slower: all histograms in the queried range must be combined</p><p>OpenTelemetry supports both models and lets you choose per SDK via the <code>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE</code> environment variable.</p><h2>Why native temporality support eliminates OTel Collector workarounds</h2><p><a href="https://prometheus.io/docs/concepts/metric_types/#counter">Prometheus</a> and most other metrics back ends pick a side: All metrics have to be either cumulative or delta. Elasticsearch previously followed that pattern, too, with native storage of cumulative counters and delta histograms, and workarounds for everything else. Delta counters were stored as gauges, functional but without native counter semantics for rate queries. And cumulative histograms were unsupported.</p><p>One workaround for unsupported temporalities is to configure your metric producers (for example, <a href="https://opentelemetry.io/docs/languages/">OTel SDKs</a>) to produce data with the temporality that your back end supports. In large-scale deployments, this can be a very challenging task. And sometimes this isn’t even possible (for example, if you consume OTLP metrics from third-party services).</p><p>Another workaround is to convert the temporality prior to ingestion. In the OTel Collector, you would typically use the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/cumulativetodeltaprocessor/README.md">cumulative-to-delta processor</a>, which comes with a big warning sign about <em>statefulness</em>. The conversion is inherently stateful, requiring ordered delivery of metric series to the same collector and persisted state across restarts. In practice, it works, but at scale, it comes with a lot of deployment headaches.</p><p>With Elasticsearch 9.5, you can skip the conversion pipeline entirely. Elasticsearch natively stores and queries metric data with both temporalities. It doesn’t require any stateful conversion required or explicit configuration of your OTel SDKs.</p><h2>Demo: ingesting cumulative and delta OTel metrics side by side</h2><p>To demonstrate the temporality support, we'll reuse a demo setup from our <a href="https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql">OTel histogram metrics ES|QL blog post</a>: a Java <a href="https://github.com/renaissance-benchmarks/renaissance">Renaissance</a> benchmark instrumented with the <a href="https://opentelemetry.io/docs/zero-code/java/agent/">OTel Java agent</a>. The twist this time: We run two instances of the benchmark, each configured with a different temporality:</p><ul><li><p><strong><code>renaissance-delta</code></strong><strong>: </strong>Exports metrics with delta temporality.</p></li><li><p><strong><code>renaissance-cumulative</code></strong><strong>: </strong>Exports metrics with cumulative temporality.</p></li></ul><p>Both instances report the same metrics under the same service name <code>renaissance</code>, but with different <code>service.instance.id</code> values. Here’s the relevant section of the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-temporality-demo/docker-compose.yml">docker-compose.yml</a> that can be found in <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/elasticsearch-temporality-demo">the companion code</a>:</p>renaissance-delta:
  environment:
    OTEL_SERVICE_NAME: renaissance
    OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=delta-instance"
    OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
    OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM

renaissance-cumulative:
  environment:
    OTEL_SERVICE_NAME: renaissance
    OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=cumulative-instance"
    OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: cumulative
    OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM<p>To run the demo yourself, you'll also have to fill out the <a href="https://www.elastic.co/docs/reference/opentelemetry/managed-inputs/managed-otlp-endpoint">managed OTLP endpoint URL</a> and the corresponding API key:</p>OTEL_EXPORTER_OTLP_ENDPOINT: https://&lt;cluster-endpoint&gt;
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey &lt;base64 api key&gt;"<p>After starting the demo with <code>docker compose up --build</code>, both instances will start reporting metrics to Elasticsearch.</p><h3>Querying OTel counter metrics with ES|QL and PromQL</h3><p>Let's query the first few raw data points of <code>jvm.cpu.time</code> for both instances to see the different temporalities in action:</p><p>This gives us the first five data points for each service instance:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte236c69ee14c534f/6a7ee18f2888399d7307f09f/image2.png" alt="ES|QL query results showing raw cumulative and delta OTel metrics for jvm.cpu.time from two service instances" /><p>The benchmark consumes CPU at a nearly constant rate. This is directly visible based on the delta temporality data: The values are nearly constant between exports. In contrast, the cumulative temporality values grow over time, as they represent the total CPU usage of the benchmark instance.</p><p>Now let's have a look at how to properly query this metric using PromQL:</p>PROMQL sum by (service.instance.id) (rate(jvm.cpu.time))<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf7ed76cee486794e/6a7ee0250e035cf4d8b865a2/image4.png" alt="PromQL rate query showing CPU time per service instance with cumulative and delta OTel metrics overlaid" /><p>The screenshot shows that both benchmark instances consume a nearly constant of 1 to 1.2 number of CPU cores with some variance. This query works because we made our <code>rate</code> implementation respect the temporality: Every time series (so every service instance in our case) stores the temporality as a metric dimension. The <code>rate</code> implementation looks at this dimension and interprets the data accordingly: For delta temporality, values are summed up; for cumulative temporality, a difference computation is done. This all happens automatically in the background, without requiring any changes to your queries.</p><p>We’ve adapted <code>rate</code>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#increase"><code>increase</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#irate"><code>irate</code></a> to work this way. The same applies when using those functions in ES|QL TS queries:</p><p>Because Elasticsearch tracks the temporality as a dimension, you can have multiple series with different temporalities for the same metric, just like in the demo use case. Aggregating across series also works as expected, because at that point <code>rate</code>, <code>increase</code>, or <code>irate</code> already took care of normalizing the data:</p>PROMQL sum(rate(jvm.cpu.time))<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b8eb2e5ee6199bc/6a7ee06a28883935c907f097/image3.png" alt="PromQL chart showing total CPU time aggregated across both cumulative and delta OTel metrics instances" /><h3>Querying OTel histogram metrics across temporalities</h3><p>Metric temporality applies to histograms in the same way it applies to counters: histogram buckets are effectively a set of counters, each tracking values in a specific range.As in our histogram demo, we use exponential histograms, where bucket boundaries adapt automatically to minimize relative error.</p><p>Due to this similarity, histograms can also be cumulative or delta. Either the counter per bucket is reset after each metric export or the cumulative count carries over between exports.</p><p>Let's query the median major garbage collection (GC) duration for our benchmark instances, which is a histogram metric:</p>PROMQL histogram_quantile(0.5,  sum by (service.instance.id) (increase(jvm.gc.duration{jvm.gc.action=~".*major.*"})))<p>Or the equivalent ES|QL query:</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb02046819944c5ff/6a7ee0f6dcb4372a2b2d1232/image5.png" alt="Median major GC duration queried across cumulative and delta OpenTelemetry histogram metrics per instance" /><p>Again, both queries will automatically load the temporality per series and interpret the histograms accordingly. In PromQL, this is handled by the <code>increase</code> function. Note that in ES|QL, you don't explicitly call <code>increase</code> on histograms. The <code>TS</code> command automatically handles the temporality-aware merging of histograms when you use aggregation functions, like <code>PERCENTILE</code>, <code>MEDIAN</code>, or <code>AVG</code>.</p><h2>How Elasticsearch stores metric temporality in TSDB</h2><p>Elasticsearch's time series database (TSDB) stores metric temporality in a dedicated dimension field on each document. The <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/time-series#index-time-series-temporality-field"><code>index.time_series.temporality_field</code></a> index setting lets you specify which field carries the temporality information. The field must be a <code>keyword</code> field with <code>time_series_dimension: true</code> and the permissible values <code>"delta"</code> or <code>"cumulative"</code>.</p><p>As soon as this setting is present on a time series index, ES|QL and PromQL will load the corresponding field when performing temporality-dependent aggregations. If the field isn’t present or has no value on a document, we fall back to defaults based on the type of the corresponding metric: counters default to cumulative, and histograms default to delta. This matches the historical behavior and ensures existing queries and existing data continue to work without changes.</p><p>When you ingest metrics via the OTLP endpoint, Elasticsearch automatically adds a <code>temporality</code> dimension field to each document, populated from the <a href="https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality">OTLP AggregationTemporality</a> metadata. For custom (neither OTLP nor <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">Prometheus remote write</a>) ingestion, you’ll have to manually set up the <code>index.time_series.temporality_field</code> setting and populate your temporality dimension.</p><p>The temporality is also respected during downsampling: As it’s a dimension, it’s preserved automatically and used to compute the aggregate values.</p><h2>Getting started with mixed-temporality OTel metrics in Elasticsearch</h2><p>With Elasticsearch 9.5, cumulative versus delta is no longer a decision you have to get correct at the start. Ingest both temporalities side by side, even for the same metric name, and let ES|QL and PromQL handle the rest. You can switch between both without having to touch your queries. For more details, see the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/metric-temporality">metric temporality documentation</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/otel-metrics-cumulative-delta-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/otel-metrics-cumulative-delta-elasticsearch</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jonas Kunz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfebe6e53bb1ad4e4/6a7eded6b591027803eeca82/image1.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your AI agent doesn't need your API key: OAuth 2.1 for Elasticsearch MCP server authentication]]></title>
    <description><![CDATA[OAuth 2.1 lets you connect AI agents to the Elasticsearch MCP server with a browser sign-in instead of an API key. Your agent gets a short-lived token tied to your permissions that you can revoke any time.]]></description>
    <content:encoded><![CDATA[<p>Claude Desktop, Cursor or any MCP host can now connect to your Elasticsearch data with a one-time browser sign-in. OAuth 2.1 is now GA for the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Agent Builder MCP server</a> in Elastic Cloud Serverless. Instead of pasting an API key into a config file, your agent gets a short-lived token tied to your permissions. Every connection can be audited individually and revoked without affecting anything else on your account. Refresh tokens roll for 30 days, so you rarely need to sign in again. Org owners can see exactly who authorized which agents, and Agent Builder is the first Elastic surface using this model, with the rest of the Elastic API to follow.</p><h2>Why OAuth is better than API keys for MCP server authentication</h2><p>With OAuth, tokens are short-lived credentials that expire on their own, so a leaked token is a narrowing window rather than a standing grant. Every token traces back to an explicit consent: which user, which client, which time.</p><p>In contrast, an API key is a long-lived credential. It lives in a configuration file on the machine that runs the agent, working for whoever uses it until someone rotates or deletes it. That model is manageable for a CI pipeline you wrote and deployed for your team. It gets uncomfortable when the key is held by an AI agent that assembles its own requests, retrieves untrusted content that may contain injected instructions, and sometimes passes context to sub-agents.</p><p>The failure mode is familiar from every credential-leak postmortem: the key ends up somewhere it shouldn't (a log file, a prompt), and from that moment anyone who has the key can access everything its creator could. The audit trail doesn't help much: API key logs tell you that a key with a given name did something, but not who authorized the client that used it or when.</p><p><strong>Attribute</strong></p><p><strong>OAuth 2.1</strong></p><p><strong>API Key</strong></p><p>Credential lifetime</p><p>Short-lived, auto-refreshing</p><p>Long-lived until manually rotated</p><p>Audit trail</p><p>User, client and timestamp per connection</p><p>Key name only</p><p>Revocation</p><p>Per connection, immediate</p><p>Requires key rotation</p><p>Blast radius</p><p>Single client connection</p><p>Everything the key creator can access</p><h2>How to set up OAuth 2.1 for the Elasticsearch MCP server</h2><p>Setup is a one-time step per project:</p><p>1. In your Elastic Cloud Serverless project, open Agent Builder → Tools library → MCP clients → Create MCP client (OAuth). This gives you a client ID and the MCP server URL.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt083d8f85c74de3e0/6a7db08bb8c2e6c02dbe5027/image8.png" alt="Agent Builder MCP clients page showing the Client ID and MCP server URL needed for OAuth setup in Elastic Cloud Serverless" /><p></p><p>2. Add the server to your MCP host. In Cursor or Claude Desktop, the configuration file entry looks like this:</p>{
  "mcpServers": {
        "kibana-mcp": {
          "command": "npx",
          "args": [
            "mcp-remote",    "https://&lt;your-project&gt;.kb.&lt;region&gt;.aws.elastic.cloud/api/agent_builder/mcp",
            "--static-oauth-client-info",
            "{\"client_id\":\"MYCLIENTID111\"}"
          ]
     }
   }
}<p>3. The first time the agent calls a tool, the host opens your browser on an Elastic Cloud consent screen. You sign in with your normal Elastic Cloud credentials and see what the agent is requesting access to.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte83792ca688a17df/6a7db0cac8b7ac2b925221e8/image1.png" alt="Elastic Cloud OAuth consent screen where a user authorizes an MCP client to access Agent Builder in a Serverless project" /><p>4. Click Authorize. This creates an <a href="https://www.elastic.co/docs/deploy-manage/app-connections">application connection</a> between you, your machine, and your project, and Elastic Cloud issues the agent a short-lived access token.</p><p>From there, your agent has access to your project’s data. The mechanics are invisible. When the access token expires, the host refreshes it using a refresh token with a 30-day rolling expiry, so you are not re-authenticating every hour. If you stop trusting a connection, open the application connections page in Elastic Cloud and revoke the connection. Both tokens die immediately, and nothing else about your account changes.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9ce697d344c24ebd/6a7db12a2888394e8707eaaa/image4.png" alt="Application connections page in Elastic Cloud showing an active OAuth MCP client connection with option to revoke access" /><p>The agent acts with the permissions of the user who consented. Calls to <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools">Agent Builder tools</a>, such as ES|QL queries, Workflows, and Streams, are evaluated based on your role. If you want an agent with less access than your own, authorize the connection as a user with a tighter role, or keep using a scoped API key for that workload. </p><h2>How to manage and revoke MCP server connections in Elastic Cloud</h2><p>Org owners can list every active application connection across an organization or a specific project: which MCP hosts were authorized, by whom, and when. Revocation is per connection, so cutting off one misbehaving agent does not disturb the authorizing user's account or any other integration. There is no shared credential to rotate and no blast radius beyond the one client.</p><p>This is the practical difference for teams. An OAuth app connection records who consented, to which client, and when. An API key log shows that <code>agent-key-3</code> queried an index, but not who authorized that agent. </p><h2>What's next for OAuth authentication across the Elastic API</h2><p>Agent Builder is the first Elastic surface behind OAuth, and the same authorization model will carry to the rest of the Elastic API surface, including Elasticsearch and Elastic Cloud management, as we work toward a single Elastic MCP endpoint.</p><p>To try it now, open Agent Builder in an Elastic Cloud Serverless project and create an OAuth client, or start with the <a href="https://www.elastic.co/docs/deploy-manage/app-connections/oauth-clients">documentation</a>. If you don't have a project yet, you can <a href="https://cloud.elastic.co/registration">start a free trial</a>.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-mcp-server-oauth-authentication</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-mcp-server-oauth-authentication</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <dc:creator><![CDATA[Alex Chalkias]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt08bd254292cdfc15/6a7dafffef5bef01744fa2be/image7.png" length="0" type="image/png"/>
    <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[0.35% trained, 100% competitive: the frozen-tower architecture behind jina-embeddings-v5-omni]]></title>
    <description><![CDATA[The latest jina embeddings model generates multimodal embeddings for text, images, video and audio, competing with models nearly 6x its size on vector search while training just 0.35% of the weights.]]></description>
    <content:encoded><![CDATA[<p><code>jina-embeddings-v5-omni</code> is our latest multimodal embedding model. It generates embeddings for text, image, video, and audio. Among open-weight models that support those modalities, it’s the best-performing under 2 billion parameters. The notable part is how little of it we actually trained. Every encoder tower stayed frozen, and only about 0.35% of the model's weights (projectors and a handful of delimiter tokens) were ever updated during training. We call this architecture pattern <strong>G</strong>eometry-preserving <strong>E</strong>mbeddings via <strong>L</strong>ocked <strong>A</strong>ligned <strong>TO</strong>wers (GELATO). Let's break down each letter of this acronym:</p><ul><li><p><strong>Geometry-preserving Embeddings:</strong> <code>jina-embeddings-v5-omni</code> sits atop the foundation laid by <code>jina-embeddings-v5-text</code>. That original text embedding space is completely unchanged, with its geometry left wholly intact.</p></li><li><p><strong>Locked:</strong> Synonymous with "frozen." All of the towers in this architecture have their weights locked. </p></li><li><p><strong>Aligned:</strong> Aligning the other modalities with the text model's vector space, allowing for cross-modal comparison.</p></li><li><p><strong>TOwers:</strong> Modality component that converts one type of raw input into vectors.</p></li></ul><p>The model comes in two variants: <code>small</code> and <code>nano</code>. The former has more parameters (1.57 billion) than the latter (0.95 billion), but functionally their architectures are nearly identical. For the sake of brevity, we mostly focus on <code>jina-embeddings-v5-omni-small</code> in this article.</p><h2>What are vectors, towers and frozen encoders?</h2><p><code>jina-embeddings-v5-omni</code> relies on three core machine learning (ML) concepts: vectors, towers, and frozen weights. Here's what each means; feel free to skip ahead if you're already familiar with them. </p><h3>How vectors represent data in embedding models</h3><p>How does AI understand abstract concepts? Can a machine comprehend what "ice cream" is? Does it understand that "chocolate fudge" and "rocky road" have more in common with each other than "sorbet"? The answer, surprisingly, is yes. The mechanism that makes it possible is the vector.</p><p>Take a look at this diagram.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0424e9623c929644/6a7c3b16b8c2e6845bbe4875/image10.png" alt="Simplex diagram mapping ice cream flavors as vector coordinates to explain how embedding models represent similarity" /><p>If you tried visualizing a way to organize all ice cream flavors, you may end up with something like this: a simplex (triangle) with three vertices, each representing a base flavor. Each flavor is closer to or farther from each vertex, depending on how much of the corresponding base makes up that particular flavor. Chocolate ice cream is all chocolate, so it hugs the top vertex. Vanilla has a similar affinity for the bottom-left vertex. But cookies and cream is roughly a 50/50 mix, so it's about equidistant from both. Neapolitan blends all three, so it sits at the center of the triangle.</p><p>This is functionally how vectors work. These flavors get funneled into an ML model (more specifically known as an <em>embedding model</em>) that will then generate coordinates for each of these flavors along this simplex. By measuring the distance between coordinates, software can parse how related or unrelated two flavors are. It's easy to see that "chocolate" and "chocolate brownie fudge" are closely related flavors because they sit close to one another, but "strawberry" and "cookies and cream" are far away from each other, so we can infer that they aren’t similar.</p><p>It won't be quite this simple though. Rather than three labeled points, real embeddings run along hundreds or even thousands of dimensions. Nor will they have nice, human-readable labels; the vertex markers are something that only the model understands. The benefit, though, is that we can graph basically <em>anything</em> like this.</p><p>To clarify some jargon: These coordinates = vectors = embeddings. For the rest of this article, we use these terms interchangeably.</p><p>Much like how embeddings go by many different names, so too do the models that create them.</p><h3>What is a tower in multimodal embedding models?</h3><p>The term <em>tower</em> comes from Contrastive Language-Image Pre-training (CLIP), a model released by OpenAI in 2021 that was one of the first to learn a shared embedding space across text and images. In CLIP, each modality is handled by a completely separate model. On an architecture schematic, these models look like towers standing side by side, each taking one type of input and producing vectors in a shared space. The name stuck, and you'll see it used broadly across multimodal ML. </p><p>In the context of <code>jina-embeddings-v5-omni</code>, the word is used a bit more loosely. Its architecture doesn't have true parallel towers in the CLIP sense. All modalities ultimately funnel into a single central text model, rather than sitting as equals beside it. </p><p>With that caveat in place: A <em>tower </em>(or <em>modality component</em>) is a pipeline that converts one type of raw input into vectors. A <em>text tower</em> vectorizes strings, and a <em>vision tower</em> generates image embeddings. An <em>audio tower</em> does the same for sound.</p><h2>Why freeze a tower instead of training it?</h2><p>If we want multimodal capabilities, could we Frankenstein multiple towers that handle each of those inputs together into one model? The issue with this approach is that vectors from different models aren’t intelligible to each other. Images will exist in one vector space and audio in another, for example. This means that we have no ability to compare across different modalities. Think of the vectors outputted from one model as existing in their own language. Let's say our audio tower outputs Spanish and our image tower outputs English. Conceptually, the vectors can be describing the same things, but downstream tools that try to make use of these embeddings are "monolingual," so we're out of luck.</p><p>The CLIP-style approach is to take several towers and train them together, letting them all reshape each other until their outputs agree. It's sort of like having Spanish and English speakers try to communicate for long enough that they eventually all start speaking Spanglish.</p><p>This approach works, but it has a side effect: Towers that you already had working get remodeled in the process. Any embeddings that they produced before are now incompatible with any that are produced by the older version of the tower. If you had a text tower and generated 100 million embeddings with it, you would now need to re-embed all of those strings.</p><p>To combat this issue, you can freeze certain towers. This locks their weights, which are the knobs and dials that influence how they behave. This way, training never changes them. What you train instead is a small projector that <em>translates</em> one tower's output into another tower's language. Many different models train some portion of projectors and towers while leaving some others frozen. What makes <code>jina-embeddings-v5-omni</code>unique is that we froze <em>every</em> encoder and trained only the projectors and delimiter tokens. By the end of this article, you’ll understand exactly how that works.</p><p>You can think of these frozen and trainable components as clusters of neurons or isolated regions of the mind, and the whole embedding model (<code>jina-embeddings-v5-omni</code>) as the entire brain. Between these learning-capable components sit fixed math operations, such as merging, squashing, selecting, and rescaling numbers on their way from one tower to the next. They have no weights, so there’s nothing in them to freeze or train. </p><p>With that groundwork laid, here’s the full architecture.</p><h2>How the jina embeddings multimodal architecture works</h2><p>The architecture routes all modalities through frozen encoders and small trainable projectors into a single shared text embedding space.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7e1a4560e21a7922/6a7c3b3ccffa6ee67a5e7b27/image6.png" alt="jina-embeddings-v5-omni architecture diagram showing frozen encoders, trainable projectors, and shared text embedding space" /><h2>Why the text tower is the backbone</h2><p>The first thing to notice here is the general flow of data in this diagram. Image, video, and audio inputs all (eventually) end up in the same space as text inputs. Why is the architecture set up like this?</p><p><code>jina-embeddings-v5-omni</code> builds on top of <code>jina-embeddings-v5-text</code>: It retains its text-processing backbone and extends it with pretrained vision and audio components.</p><p>This has two main advantages:</p><ol><li><p>The text model is already state of the art for its size and what it does, and it stays completely untouched. There’s no need to fix what isn't broken. We also don't need to re-embed anything we already embedded with <code>jina-embeddings-v5-text</code>.</p></li><li><p>A single shared vector space is what makes cross-modal search work at all. Because image, audio, and text all resolve to vectors in the same geometry, you can query an image with text, or audio with text, and compare them directly with <em>cosine similarity</em> (a similarity measure based on the angle between two vectors). If each modality lived in its own separate space, those comparisons would be meaningless.</p></li></ol><h2>How vision and audio encoders feed into the text model</h2><p>The first step in building upon the foundation set by <a href="https://huggingface.co/collections/jinaai/jina-embeddings-v5-text">jina-embeddings-v5-text</a> is integrating vision and audio encoders into this architecture. In this case, we use the existing <a href="https://qwen.ai/blog?id=qwen3.5">Qwen3.5</a> vision encoders and the <a href="https://qwen.ai/blog?id=qwen2.5-omni">Qwen2.5-Omni</a> audio encoder, which themselves have been adapted from <a href="https://huggingface.co/docs/transformers/model_doc/siglip2">SigLIP2</a> and <a href="https://huggingface.co/openai/whisper-large-v3">Whisper-large-v3</a>, respectively. They’re ultimately what’s responsible for generating raw vectors for all vision- and audio-based data. The emphasis is on <em>raw</em> here, since much transformation still needs to be done afterward.</p><h2>How the vision encoder processes images</h2><p>In the case of images, we’re borrowing more from Qwen than just the encoder. Additional plumbing inherited from Qwen is attached to the output of the encoder. Let's walk through what comes out of the encoder and how the inherited downstream components transform that output.</p><h3>Vision encoder (frozen)</h3><p>Let's use an image as our primary example, since the visual component of video is basically identical. Take this image of a banana split.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta37c8dee62d6377a/6a7c3b59ef5bef22a14f9b4f/image12.png" alt="Banana split photo used as example input for the jina-embeddings-v5-omni vision encoder" /><p>Rather than generating one single, clean vector embedding for this image, the vision encoder breaks the image up into 14-pixel by 14-pixel sections and generates a tiny <em>patch token</em> (basically a mini-vector) for each of these sections.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1867889b7011a10b/6a7c3b89ef5bef80504f9b53/image8.png" alt="Banana split image divided into 14x14 pixel patches showing how the vision encoder generates patch tokens" /><p>While still inside of the vision encoder, each token looks at every other token that makes up the image and pulls in information from the ones relevant to it, updating its own vector based on that context. This process allows us to preserve fine-grained details. Now, instead of one single vector for the whole image, we have multiple, smaller vectors that represent particulars of the whole dish.</p><h3>LayerNorm (frozen)</h3><p><a href="https://arxiv.org/abs/1607.06450">LayerNorm</a> rescales each patch's numbers so they sit in a consistent range before anything else touches them. It stops some patches from being wildly larger than others and drowning out the rest.</p><h3>2x2 merge </h3><p>We saw in the vision encoder section that we split up the image into small, 14-pixel by 14-pixel squares. However, processing this many patch tokens will become expensive downstream. For this reason, the 2x2 merge operation consolidates four patch tokens into one. The squares are now 28 pixels by 28 pixels. The corresponding patch tokens are similarly consolidated.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt851c4e33cf477e3e/6a7c3ba429138b20673b1555/image4.png" alt="Banana split with 28x28 pixel patches after 2x2 merge reduces token count before the projector" /><h3>fc_vision_1 (frozen) </h3><p><code>fc_vision_1</code> is the first of two matrix multiplies; it mixes the merged patch numbers into a new set. This layer is inherited from Qwen and left as is.</p><h3>GELU </h3><p><a href="https://arxiv.org/abs/1606.08415">Gaussian Error Linear Unit (GELU)</a> is a gate applied to each number. It lets useful signals through and squashes the rest toward zero. It’s the one nonlinear step and is ultimately what lets the two <code>fc_vision</code> layers together learn shapes that a single flat multiply couldn't.</p><h3>fc_vision_2 (trainable) </h3><p>As mentioned earlier, trying to put Qwen image embeddings directly into the same vector space as Jina text embeddings would be like trying to include a Spanish sentence in an English novel. Outside of its native context, its meaning is totally lost.</p><p>That is what the trainable projector <code>fc_vision_2</code> is here to fix. It learns to translate the image embedding into something that <code>jina-embeddings-v5-text</code> can understand. For this reason, you can think of a trainable projector as a translator.</p><p>The emphasis belongs on <em>trainable</em>: This is the first component we’ve encountered in our walkthrough so far that isn’t frozen. Many components of this architecture are frozen, meaning that their weights are locked and never change during training, but <code>fc_vision_2</code> is one of the few parts that actually gets updated, because it has to <em>learn</em> how to translate Qwen's image dimensions into a form that lands meaningfully inside Jina's text space.</p><h3>× 4</h3><p>By now, you may have noticed that <code>fc_vision_2</code> has a "× 4" marked on the bottom, along with <code>fc_audio</code>, both encoders, and the ″Embedding text″ section within <code>jina-embeddings-v5-text</code>. In this case, it represents four different instances of <code>fc_vision_2</code>, each optimized and tuned to one of four slightly different tasks, outlined below.</p><p><strong>Task</strong></p><p><strong>What it facilitates</strong></p><p><strong>Example user input</strong></p><p><strong>Example end result</strong></p><p><strong>Note</strong></p><p>Retrieval</p><p>Finds a similar match for the input (that is, standard Google search)</p><p>"melting ice cream" as a string/text</p><p>Picture of a fallen ice cream cone on asphalt</p><p>
</p><p>Text-matching</p><p>Judges how similar inputs are</p><p>A text string "melting ice cream" and an image of a fallen ice cream cone on asphalt</p><p>Score judging how similar the two inputs are</p><p>The name of this task is a bit of a misnomer. It's called <em>text-matching</em>, but it works for any modality, not only text.</p><p>Clustering</p><p>Groups data into clusters</p><p>A large array of ice cream images</p><p>Lets the user discover natural groups, like "sundaes" and "popsicles"</p><p>
</p><p>Classification</p><p>Places data into predefined buckets</p><p>Two string labels: "melting" and "intact", along with a large array of ice cream images</p><p>Sorts the array of ice cream images into the two provided categories, based on their proximity in vector space to the label embeddings</p><p>
</p><p>To clarify, the "Example end result" column is the takeaway after some additional math and processing happens once the output vector is generated. The point is that  <code>jina-embeddings-v5-omni</code> only generates vectors. Those vectors take on a mildly different form to optimize for the selected task type.</p><p>These task types are also explicitly outlined in the Low-Rank Adaptation (LoRA) component of the architecture diagram, which we'll cover in a moment.</p><h2>How the audio encoder processes sound</h2><p>Before we go any deeper into the trenches of our model architecture, let's back up and see how the audio-oriented path differs and how it stays the same. If you were able to follow along during the vision section, the audio portion will be a breeze. We have no extra inherited plumbing from Qwen this time, only the audio encoder and one trainable projector.</p><h3>Audio encoder (frozen)</h3><p>Before audio can enter the encoder, it needs to be converted into a form that the encoder can work with. Raw audio is a one-dimensional wave, which isn't particularly useful to a neural network on its own. Instead, the audio is first transformed into a <em>mel spectrogram</em>: a 2D representation that maps frequency against time, weighted to emphasize the frequency ranges that the human ear is most sensitive to. Think of it as a visual fingerprint of the sound. Below is a mel spectrogram of a person saying, "I scream, you scream, we all scream for ice cream."</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3a69dc8ef9bc87a2/6a7c3bc3b59102b0f4eebd49/image5.png" alt="Mel spectrogram of a person saying I scream you scream we all scream for ice cream, used as audio encoder input" /><p>That spectrogram is then sliced into fixed-length 40ms chunks, analogous to how the vision encoder breaks an image into 14×14 pixel-tiles. The encoder then produces one token per chunk.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteec40a3a17212d5e/6a7c3bd829138bb20b3b1559/image3.png" alt="Mel spectrogram sliced into 40ms chunks showing how the audio encoder tokenizes sound for multimodal embeddings" /><p>This matters for the same reason it does in the vision pipeline: Fine-grained detail would otherwise be lost. A single embedding for the full phrase "I scream, you scream, we all scream for ice cream" would smear everything into one blurry vector. Slicing it into short chunks keeps each fragment of sound intact, so the model can later distinguish "scream" from "cream" rather than collapsing them into an average. The same applies to music, animal noises, environmental sounds, and more.</p><h3>fc_audio (trainable)</h3><p>Once the encoder has produced its tokens, <code>fc_audio</code> performs the same translation role that <code>fc_vision_2</code> does for images: It projects each audio token from the encoder's 1280-dimensional output space into <code>jina-embeddings-v5-text</code>’s hidden dimension (1024 for <code>small</code>, 768 for <code>nano</code>). Like <code>fc_vision_2</code>, it carries a "× 4" in the architecture diagram, meaning that there are four instances, each optimized for a specific task type (retrieval, text-matching, clustering, and classification).</p><h3>Delimiters (trainable)</h3><p>We need to convey to <code>jina-embeddings-v5-text</code> that this embedding represents an unexpected modality. We can do this pretty easily with delimiters. In the world of HTML, this looks like:</p>&lt;p&gt; Your text here &lt;/p&gt;<p>The first and last <code>&lt;p&gt;</code> tag conveys that everything in between them is a paragraph element.</p><p>In our case, things are a bit more complicated. For audio, the architecture diagram shows the delimiters as <code>&lt;aud_start&gt;</code> and <code>&lt;aud_end&gt;</code>, but that isn't quite accurate. The delimiters themselves are actually vectors rather than hard-coded strings.</p><p>Each task type has its own pair of delimiter vectors (hence the "4 × special tokens"). These vectors are identical every time rather than being different for each audio embedding. So a task type of retrieval with an audio input type always gets start delimiter vector X and end delimiter vector Y; text-match with audio input always gets a start delimiter vector A and an end delimiter vector B, and so on.</p><p>This is necessary because the <code>Transformer layers</code> component only understands vectors. So, by the time the audio embedding makes its way there, it looks like:</p>&lt;aud_start_vector_delimiter&gt;
&lt;aud_patch_token_1&gt;
&lt;aud_patch_token_2&gt;
&lt;aud_patch_token_3&gt;
...
&lt;aud_end_vector_delimiter&gt;<p>The same pattern applies to images:</p>&lt;vis_start_vector_delimiter&gt;
&lt;vis_patch_token_1&gt;
&lt;vis_patch_token_2&gt;
&lt;vis_patch_token_3&gt;
...
&lt;vis_end_vector_delimiter&gt;<p>Video is where this gets interesting. Up to 32 evenly spaced-out frames are pulled from the video and fed into the vision encoder. </p><p>Rather than producing one delimiter-wrapped segment, each sampled frame gets its own <code>&lt;vis_start&gt;</code> / <code>&lt;vis_end&gt;</code> wrapper, and these per-frame segments are concatenated into one long sequence:</p>&lt;vis_start&gt; [frame 1 patch tokens] &lt;vis_end&gt;
&lt;vis_start&gt; [frame 2 patch tokens] &lt;vis_end&gt;
...
&lt;vis_start&gt; [frame 32 patch tokens] &lt;vis_end&gt;<p>This is what makes multi-frame video work: Rather than averaging frames or treating them separately, the transformer receives the whole video as one token stream, allowing its attention to relate tokens across frames. This means that earlier frames can inform how later ones are interpreted. If the video does have an audio component, it's pulled out and fed into the audio encoder and ultimately prepended to the frame sequence.</p>&lt;aud_start&gt; [audio patch tokens] &lt;aud_end&gt;
&lt;vis_start&gt; [frame 1 patch tokens] &lt;vis_end&gt;
&lt;vis_start&gt; [frame 2 patch tokens] &lt;vis_end&gt;
...<p>The transformer layers component then processes this entire concatenated sequence as a single input.</p><h3>Jina text transformer layers (frozen) </h3><p>We’ve generated patch tokens for our images, videos, and audio files. We’ve also wrapped them inside of vector delimiters, all for the sake of having them understood by these layers. They’ll allow each patch token to examine the other patch tokens and determine whether they need to update themselves based on the surrounding context. I know what you're thinking:</p><p>Didn't we already do this inside of the encoder? We split up the image into 14-pixel by 14-pixel sections and generated patch tokens for each section, and then the encoder updated each patch token based on surrounding context within the same image.</p><p>And you're right! We did. But there's a key difference now.</p><p>Originally, that recontextualization ran on the attention of Qwen's vision encoder. The operation within the <code>Transformer layers</code> runs the frozen Jina text transformer's attention. It’s the same operation with different learned parameters, so it transforms the tokens differently.</p><p>Think of it like a move: The projector is the flight and the moving trucks. It physically relocates you from vision land to text land, landing you in the right city and even the right neighborhood. The transformer's attention is what happens after you've unpacked. You're already home; you spend the next few weeks figuring out exactly where you fit, meeting the neighbors, finding your bearings, and adjusting your exact spot based on who's actually around you. You did the macro move already. This is the micro fine-tuning.</p><p>Lastly, the <code>&lt;vis_end_vector_delimiter&gt;</code> will absorb all the information from the patch tokens it wraps.</p><h3>LoRA (frozen) </h3><p><a href="https://arxiv.org/abs/2106.09685">LoRA</a> is a way of fine-tuning an existing model without completely retraining it. It’s a small set of extra adjustment knobs bolted onto the transformer that nudges its behavior to optimize for one of the specific tasks (such as retrieval or classification).</p><h3>Last-token pooling </h3><p>Since <code>&lt;vis_end_vector_delimiter&gt;</code> absorbed all the other patch tokens into itself, we don't need to consider anything except it, so we throw the rest away. It acts as a stand-alone embedding that represents a summary of the whole.</p><h3>L2 normalization </h3><p><code>&lt;vis_end_vector_delimiter&gt;</code> could be any length now, which is no good. This step shrinks or stretches it so its length is exactly 1, without changing the direction it points. This is tidying so that comparing it to other vectors later is a fair, clean angle comparison. It changes only the vector’s scale, not what it means.</p><h3>Enough about ice cream</h3><p>As we've journeyed our way through <code>jina-embeddings-v5-omni</code>, we’ve been careful to outline which components are frozen and which ones are trainable. By now, you may have noticed that every single tower has been frozen. In fact, only the small projectors (translators) and delimiter tokens have been trainable. We dubbed this architecture pattern GELATO. This makes the entire training process significantly cheaper.</p><p>To be clear, we didn't invent the concept of frozen towers. Prior work on <a href="https://arxiv.org/abs/2111.07991">Locked-image Tuning (LiT)</a>, <a href="https://arxiv.org/abs/2406.04292">VISTA</a>, and <a href="https://arxiv.org/abs/2310.14037">Multi-modAl Retrieval model via Visual modulE pLugin (MARVEL)</a> froze one side or the other. What no one had done before GELATO was push the idea to its limit: text, image, video, and audio all in one model with every encoder frozen. The only trained pieces are a single projector layer per modality and a handful of delimiter tokens.</p><h2>But is it any good?</h2><h3>Benchmark results: jina embeddings vs. other multimodal embedding models</h3><p>There's no point in building out a model and releasing it if you don't even know if it's any good, especially compared to the competition. That's why we have benchmarks and evaluation frameworks. The benchmarks that <code>jina-embeddings-v5-omni</code> was run against are Massive Image Embedding Benchmark (MIEB), Massive Audio Embedding Benchmark (MAEB), Massive Multimodal Embedding Benchmark–Video (MMEB-Video), and Massive Multilingual Text Embedding Benchmark (MMTEB).</p><p>As for the models we compare against, it's important not to make apples and oranges comparisons. For that reason, we’re specifically using open-weight omni-style models with support for the same media types:</p><ul><li><p><a href="https://huggingface.co/collections/LanguageBind/languagebind-model">LanguageBind</a></p></li><li><p><a href="https://huggingface.co/nvidia/omni-embed-nemotron-3b">Omni-Embed-Nemotron-3B</a></p></li><li><p><a href="https://huggingface.co/LCO-Embedding/LCO-Embedding-Omni-3B">LCO-Embedding-Omni-3B</a></p></li><li><p><a href="https://huggingface.co/LCO-Embedding/LCO-Embedding-Omni-7B">LCO-Embedding-Omni-7B</a></p></li></ul><h3>Evaluation</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt73d8f8759a643405/6a7c3c081d23d2a618b4e44d/image1.png" alt=" Benchmark table comparing jina-embeddings-v5-omni against multimodal embedding models on text, image, video and audio" /><p>The table above is sorted by parameter count. Models with the fewest parameters are clustered at the top, and models with the most parameters hug the bottom. This context is important because performance alone isn't the final variable. If a $100 ice cream with gold flakes and the finest dairy milk tastes the same as (or worse than) the average gallon of ice cream that you can buy from your local grocery store, it would be silly to buy it, because you're paying a massive premium for nothing.</p><p>A similar situation is unfolding here. <code>jina-embeddings-v5-omni-small</code> and <code>nano</code> outperform every other model on text, despite ranking low to middle in terms of parameter count.</p><p>Audio performance is strong, as well. <code>jina-embeddings-v5-omni-small</code> and <code>nano</code> beat out all other models except those from LCO, which they both trail by about 2 to 3 points.</p><p>The gap shrinks when considering image performance, particularly with <code>jina-embeddings-v5-omni-small</code>. It beats both <code>LanguageBind</code> and <code>Omni-Embed-Nemotron-3B</code><code>.</code> It lags less than a point behind both LCO models, despite the fact that they have 4.70 billion and 8.93 billion, respectively, compared to Jina’s 1.57 billion.</p><p>Video is the weakest performer for our models, though even in that case <code>jina-embeddings-v5-omni-small</code> still beats <code>Omni-Embed-Nemotron-3B</code>, which has three times as many parameters. Ultimately, when these scores are averaged out, you get the following rankings:</p><p><strong>Model</strong></p><p><strong>Number of parameters (B)</strong></p><p><strong>Average score</strong></p><p><code>LCO-Embedding-Omni-7B</code></p><p>8.93</p><p>54.43</p><p><code>jina-embeddings-v5-omni-small</code></p><p>1.57</p><p>54.04</p><p><code>LCO-Embedding-Omni-3B</code></p><p>4.70</p><p>53.83</p><p><code>jina-embeddings-v5-omni-nano</code></p><p>0.95</p><p>47.49</p><p><code>Omni-Embed-Nemotron-3B</code></p><p>4.70</p><p>41.21</p><p><code>LanguageBind</code> </p><p>1.14</p><p>35.82</p><p><code>LCO-Embedding-Omni-7B</code> has nearly six times the number of parameters as <code>jina-embeddings-v5-omni-small</code> but barely squeaks past it in average performance.</p><p>One benchmark deserves a special callout for anyone building search or retrieval augmented generation (RAG): visual document retrieval, measured on the <a href="https://huggingface.co/vidore">ViDoRe benchmark</a>. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0637adcdd0cbe9fe/6a7c3c245751aa67567e250e/image11.png" alt="ViDoRe visual document retrieval scores showing jina-embeddings-v5-omni matching larger models with fewer parameters" /><p>Here, <code>jina-embeddings-v5-omni-small</code> scores 79.25 using only 0.92 billion active text-and-image parameters, ahead of <code>LCO-Embedding-Omni-3B</code> (78.24) and within striking distance of <code>LCO-Embedding-Omni-7B</code> (80.32), a model nearly 10 times its size on that path. <code>nano</code> matches that exact same 79.25 score with just 0.31 billion active parameters. The larger <code>Omni-Embed-Nemotron-3B</code> does take the top spot at 85.64, but it carries roughly five times the active parameters of <code>jina-embeddings-v5-omni-small</code>, so our models remain the most parameter-efficient of the group. If your workload is retrieving pages of documents by their layout and text, this is the number to weigh.</p><h3>Limitations of frozen-tower multimodal embeddings</h3><p>GELATO's frozen-tower design delivers strong results at low training cost, but it comes with trade-offs worth naming plainly. As already mentioned, the most consistent weak spot is video. <code>jina-embeddings-v5-omni-small</code> trails the LCO models on video, and <em>moment retrieval</em> (locating a specific event within a clip) is the weakest subtask of all. This is partly structural, since each frame produces its own token set before everything is concatenated and pooled into a single final embedding. Packing that much information into one embedding means that the early dimensions carry a heavier load, so video embeddings degrade faster than image embeddings when truncated to smaller sizes.</p><p>Audio has its own gap. While retrieval and classification scores are competitive, audio clustering is the weakest audio subtask (6.13 for <code>jina-embeddings-v5-omni-small</code>). </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta4ff53dd4329772f/6a7c3c43f1246402446febad/image13.png" alt=" Detailed benchmark results by task type for jina-embeddings-v5-omni across MIEB, MMEB-Video and MAEB" /><p>Cross-modal audio–text retrieval trails <code>LCO-Omni-7B</code> by 11–15 percentage points, a larger gap than the 6–7 points seen on the image–text (I-T) pair. The <code>fc_audio</code> projector is the natural next target for additional trainable parameters, suggesting the audio–text (A-T) alignment path has more room to grow than the multilayer vision pipeline. </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdc303bd9ff664c18/6a7c3cf05751aa08427e2515/image9.png" alt="Five vision ablation configurations testing frozen vs trainable encoders and projectors for multimodal embeddings" /><h3>How multimodal embeddings distribute in vector space</h3><p>We've already discussed performance via benchmarks, but what about how the actual embeddings are distributed in vector space? How does that tangibly differ from model to model?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt225cf7e9ce7b2f98/6a7c3c570da673645857bd58/image15.png" alt="Three audio ablation configurations showing projector-first training outperforms encoder-first for audio embeddings" /><p>In the above illustration, data from video clips is funneled into each model and graphed in vector space. It’s then compressed down to two dimensions via the <a href="https://arxiv.org/abs/1802.03426">Uniform Manifold Approximation and Projection (UMAP)</a> method for easy visualization. Each modality corresponds to a different component of the video:</p><p><strong>Modality</strong></p><p><strong>Component of the video</strong></p><p>Image</p><p>Frame from the middle of the video</p><p>Video</p><p>The full video</p><p>Audio</p><p>Audio track from the video</p><p>Text</p><p>Description of the video</p><p>Immediately, some interesting patterns stand out.</p><p>Our models and the LCO models seem to have different modalities all mixed together, while <code>LanguageBind</code> and <code>Omni-Embed-Nemotron-3B</code> seem to lean more toward having their embeddings separated by modality.</p><p>Our models and the LCO models exhibit <em>interleaved geometry</em> for these vectors. This means that different modalities aren't clearly separated in vector space, but instead intermingle in similar areas. This is less pronounced with <code>Omni-Embed-Nemotron-3B</code>, since only image and video seem to occupy a similar space.</p><p><code>LanguageBind</code> is fully separated, with different modalities occupying entirely different spaces. This is known as the <em>modality-gap pattern</em>.</p><p>So which one is better? In practice, interleaved geometry tends to be the more useful of the two, and it’s worth noticing that the strongest models in our benchmarks (ours and LCO's) all exhibit it. However, there are trade-offs.</p><p>Interleaved geometry excels at cross-modal retrieval, since everything is jumbled up together in the vector space and, therefore, much closer. It's easier to find a matching picture for the text "strawberry ice cream" when the text and image embeddings sit so close together in vector space.</p><p>When you're trying to do a same-modality task though, the image that was so conveniently within reach is now in the way. However, in practice, this is easily mitigated by metadata filtering on something like a “modality” field. </p><p>No such workaround exists for the issues inherent to models that exhibit the modality-gap pattern. It’s easy to find another video of syrup poured on ice cream, since all the videos are sitting together in isolation. But having modalities confined into clusters like that makes finding an accompanying image much harder.</p><h2>Why this architecture?</h2><p>GELATO gives a lot of performance for very little training. You keep all your towers as they are and train only small projectors and delimiter tokens, which is significantly cheaper than the alternatives. To put concrete numbers on "cheaper": for <code>jina-embeddings-v5-omni-small</code>, training just the vision projector updates 4.20 million parameters instead of the 920.6 million a full fine-tune would touch. At the same 15,000-step budget, that projector-only run finishes about 1.8 times faster and peaks at 7.52 GiB of GPU memory instead of 12.96 GiB. The audio path shows an even wider gap, with projector-only training running 3.2 to 3.9 times faster than full training. But how did we conclude this was the way to go? We used a process known as <em>ablation</em>.</p><p>Ablation is when you remove or change one piece of a system to see how much it actually mattered. Imagine you've been working on an ice cream recipe. Every time you make a tweak, like doubling the milk, swapping brown sugar for white, using vanilla beans instead of extract, or taking out the chocolate chunks, that's ablation.</p><p>Ablation in ML functions much in the same way. It asks whether removing, rearranging, freezing, or unfreezing certain components makes the whole system more, less, or equally as performant. In this case, we’re particularly interested in whether unfreezing certain components, and in what order, may affect performance. We conducted five ablation studies on the <code>Qwen3.5</code> vision stack. The results are measured in mean nDCG@10 (normalized Discounted Cumulative Gain), a standard score for ranking quality where higher is better.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteebe4f380bb51372/6a7c3c77f6ab872458d15bc2/image7.png" alt=" Cross-modal retrieval metrics for image-text and audio-text pairs across multimodal embedding models" /><p>Overall, nearly every ablation study yielded basically identical results, except for case #3, which performed terribly. Before we explain why, let's look at the equivalent ablation diagram for audio.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt86c48cbf40c279c1/6a7c3c881d23d290d8b4e451/image2.png" alt="UMAP plots comparing interleaved embedding geometry in jina-embeddings-v5-omni vs modality-gap patterns in competitors" /><p>In this instance, ablation case #2 performs the worst. Do you see the commonality between the worst performer here and the worst performer among the vision ablations? Across both modalities, the same rule holds: If you unfreeze the encoder before the projector has been trained, you’ll see worse performance.</p><p>For both modalities, we ultimately chose ablation case #1 for the final architecture. Both had relatively high scores. In vision's case, the configurations that edged out case #1 did so by margins too small to justify their added training stages and extra per-task artifacts. A similar story unfolds for audio, with case #3 beating out case #1 by a small margin but requiring more per-task artifacts.</p><p>Ablation validates the GELATO approach: It's cheaper and nearly identical in quality to train a dedicated translator (rather than the speaker).</p><h2>Summary: why frozen encoders make multimodal embeddings cheaper</h2><p>Rather than expensively retraining multiple towers to achieve multimodal capabilities, GELATO allows us to minimize cost by freezing our already functioning towers and training only small projectors to translate embeddings. These embeddings get funneled into <code>jina-embeddings-v5-text</code>, ultimately allowing all the output vectors to exist in the same, interleaved geometry. We can now compare text, audio, images, and video at a fraction of the cost of the competition. </p><p>Both <code>jina-embeddings-v5-omni-small</code> and <code>jina-embeddings-v5-omni-nano</code> are open-weight for personal use and available now. You can download them from the <a href="https://huggingface.co/jinaai">Jina AI collection on Hugging Face</a> and start generating multimodal embeddings today, or read the <a href="https://arxiv.org/abs/2605.08384">full technical report</a> for the complete set of benchmarks and ablations.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/multimodal-embeddings-gelato-jina-v5-omni</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multimodal-embeddings-gelato-jina-v5-omni</guid>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Jon Avezbaki]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt640f1072281c7143/6a7c3ae7cffa6ef6105e7b23/image14.png" length="0" type="image/png"/>
    <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Two lines of JSON to replace your ILM policy: data stream lifecycle adds frozen tier support]]></title>
    <description><![CDATA[In Elasticsearch 9.5, frozen_after in data stream lifecycle moves indices to searchable snapshots on object storage on their own, keeping them queryable alongside downsampling and retention.]]></description>
    <content:encoded><![CDATA[<p>Data stream lifecycle in Elasticsearch 9.5 can move backing indices to the frozen tier as searchable snapshots on object storage, with no ILM policy required. Add frozen_after next to <code>data_retention</code> and optional downsampling in a few lines of JSON, or set it in Kibana. The feature is generally available in 9.5.</p><h2>How to configure frozen_after in data stream lifecycle</h2><p><code>frozen_after</code> sits at the top level of the lifecycle, next to <code>data_retention</code> and <code>downsampling</code>.</p>PUT _data_stream/my-data-stream/_lifecycle
{
"data_retention": "90d",
"frozen_after": "30d"
}<p>That's the whole feature, at the API level. Indices in <code>my-data-stream</code> stay on hot for 30 days, then move to frozen for the remaining 60. After 90 days they're deleted, and the backing snapshot goes with them.</p><p>It composes with the rest of the lifecycle, including downsampling:</p>PUT _data_stream/my-data-stream/_lifecycle
{
  "data_retention": "90d",
  "frozen_after": "30d",
  "downsampling": [
    { "after": "1d", "fixed_interval": "1h" }
  ]
}<p>Same options in an index template:</p>PUT _index_template/my-index-template
{
"index_patterns": ["my-data-stream*"],
"data_stream": {},
"template": {
"lifecycle": {
"data_retention": "90d",
"frozen_after": "30d"
}
  }
}<p>The order of values is enforced: <code>frozen_after</code> has to be less than <code>data_retention</code> and greater than any <code>downsampling.after</code>. The API rejects configurations that don't make physical sense.</p><h3>Where frozen tier data is stored: the default snapshot repository</h3><p>Frozen tier data is held as partially-mounted searchable snapshots, which means DLM needs a snapshot repository to write into. Rather than make you choose a repository per lifecycle, 9.5 introduces a <a href="https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore/self-managed#snapshot-repo-default">cluster-level default snapshot repository</a>.</p>PUT _cluster/settings
{
  "persistent": {
    "repositories.default_repository": "my-snapshot-repo"
  }
}<p>DLM uses this repository for every frozen tier index in the cluster. On Elastic Cloud Hosted (ECH), the default is pre-populated with <code>found-snapshots</code> so existing clusters work out of the box. You can change it to a repository you control if you'd rather keep your frozen data in a bucket you own (useful if you want object versioning, lifecycle backups to Glacier, or anything else that needs bucket-level access). Wherever you can set <code>frozen_after</code> in Kibana, the UI shows the current default repository inline and links to the place to change it, so you can see where frozen data will be written.</p><p>If the cluster doesn't have a default repository configured, you can still write a lifecycle with <code>frozen_after</code>. The API accepts it but returns a warning:</p>{
  "acknowledged": true,
  "warnings": [
    {
      "message": "No default snapshot repository has been configured. Data will not be moved to the frozen tier until a default snapshot repository is configured."
    }
  ]
}<p>The data stays on hot until a default repository is configured and exists. The same logic applies if the cluster lacks a valid Enterprise license. Errors are visible in the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-explain-data-lifecycle">data stream lifecycle status API</a> for that stream.</p><h3>Configuring frozen_after in Kibana</h3><p>Kibana in 9.5 lets you set <code>frozen_after</code> and the default snapshot repository from the UI. In <strong>Streams</strong>, the Retention tab shows the frozen phase on the lifecycle timeline alongside hot and any downsampling steps. Click the timeline to open the data lifecycle flyout, set <code>frozen_after</code>, and see the timeline update before you save. <strong>Index Management</strong>'s Data Streams page opens the same flyout.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt569984ebea197a38/6a7c38dff6ab871d74d15bb6/image1.jpg" alt="Kibana Streams UI showing frozen_after set to 30 days in the data stream lifecycle Edit data phases flyout" /><h2>How frozen tier conversion works in data stream lifecycle</h2><p>When a backing index ages past <code>frozen_after</code>, DLM walks through five steps in order:</p><ol><li><p><strong>Clone</strong>. Mark the index read-only and clone it to a zero-replica copy so the original stays available while conversion runs.</p></li><li><p><strong>Force merge</strong>. Merge the clone to a single segment. On completion a cluster state marker is written; duplicate force-merge requests (for instance after a master failover) are deduplicated, so a restart doesn't repeat the work.</p></li><li><p><strong>Snapshot</strong>. Write the merged clone to the default repository, and record the snapshot name in cluster state on success. If a stalled snapshot from a previous attempt is detected, DLM deletes it and re-runs the step.</p></li><li><p><strong>Mount</strong>. Create a partially-mounted searchable snapshot index from the snapshot.</p></li><li><p><strong>Swap and delete</strong>.Once the mounted index's shards are fully allocated, atomically swap it in for the original in the data stream, then delete the original. The swap is atomic, so query results don't see a data volume dip during the transition.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt35ff5577f138d9de/6a7c38fb1012c95976266824/image2.png" alt="Data stream lifecycle frozen tier conversion steps: clone, force merge, snapshot, mount, swap and delete" /><p>On failure, DLM retries from the last successful step on the next run.</p><p>Each step is idempotent, and the cluster state markers make sure work already done isn't repeated after a master failover. Throttling caps concurrent conversions so a lifecycle change covering thousands of indices doesn't overwhelm the cluster. Errors surface in the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-explain-data-lifecycle">data stream lifecycle status API</a> and roll up to the lifecycle health indicator.</p><h2>Scope and limitations of frozen_after</h2><ul><li><p><strong>Data indices only</strong>. <code>frozen_after</code> applies to a data stream's data indices. Failure store indices don't currently support the frozen tier and continue to be governed by their own <code>data_retention</code> setting.</p></li><li><p><strong>Enterprise license required</strong>. Frozen tier in DLM is implemented with searchable snapshots and requires an Enterprise license. You can write <code>frozen_after</code> on any license, but data won't move to frozen until the license is valid.</p></li><li><p><strong>Serverless ignores the field</strong>. In Elastic Cloud Serverless, <code>frozen_after</code> is accepted but ignored - Serverless manages tiering on your behalf. Built-in templates may include the field, so we don't reject it, but the step is skipped.</p></li></ul><h2>Getting started with frozen_after</h2><ol><li><p>In Kibana, open <strong>Streams</strong> or <strong>Index Management</strong> and choose a data stream backed by data stream lifecycle.</p></li><li><p>Open the <strong>Edit data lifecycle</strong> flyout, set a frozen-after value, and save. The lifecycle timeline shows the new phase.</p></li><li><p>On a self-managed cluster, set <code>repositories.default_repository</code> to a repository you control. On ECH, <code>found-snapshots</code> is already configured if you want zero setup.</p></li><li><p>For declarative workflows, write the same configuration into your index templates so new data streams pick up the lifecycle automatically.</p></li></ol><h2>Learn more</h2><ul><li><p><a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/data-stream-lifecycle">Data stream lifecycle</a></p></li><li><p><a href="https://www.elastic.co/docs/manage-data/lifecycle/data-tiers#frozen-tier">Frozen tier overview</a></p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore/searchable-snapshots">Searchable snapshots</a></p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/streams/management/retention">Manage data retention for Streams</a></p></li></ul><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/data-stream-lifecycle-frozen-tier</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/data-stream-lifecycle-frozen-tier</guid>
    <category><![CDATA[Index Data]]></category>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[Edward Lewis]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a5176d6139aa2d7/6a7c38c9be33783da7dadeb9/elastic-de_150810_blogheaderimage_ciscorevolutionizesai_treated_02_V1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Building context in Elasticsearch: how AI Indices power smarter agents using fewer tokens]]></title>
    <description><![CDATA[Store AI agent context in an AI Index and power smarter agents using fewer tokens. Step-by-step walkthrough with ES|QL and Kibana Workflows included.]]></description>
    <content:encoded><![CDATA[<p>Agents burn tokens exploring your data before they answer anything, inspecting mappings, sampling documents, probing which index to use. Elasticsearch AI Indices let you precompute that work once and store it as a Knowledge Indicator (KI): a structured, searchable record agents retrieve directly instead of rediscovering from scratch. This walkthrough shows you how to build the full pipeline: create an AI Index, generate routing KIs with a Kibana Workflow, and wire them to any agent harness via a portable ES|QL skill. We've also provided a <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/building-context-technical-walkthrough-part-1">notebook</a> if you'd like to run it yourself end to end as you go through the examples in this blog. This is Part 1 in a blog series providing a technical walkthrough to managing your context through KIs and AI indices. </p><p>While AI indices will be included in future Stack releases, today we recommend using Serverless.</p><h2>How it works: AI Index, Kibana Workflows, and the query-ki skill</h2><p>Building context in this walkthrough has three moving parts:</p><ol><li><p>An <strong>AI Index</strong>, where KIs live. It's a regular Elasticsearch index or data stream with a specific naming convention triggering component templates to configure the right mappings automatically.</p></li><li><p><strong>Kibana Workflows</strong>, which read from your data sources, run an LLM to structure content into KIs, and write those KIs into the AI Index.</p></li><li><p>A <strong><code>query-ki</code></strong><strong> skill</strong>, a skill that queries KIs directly from the AI Index using ES|QL, and that a chat agent can call as a tool.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfb84f57dc630f26b/6a7b37268bcd80ea30262453/image4.png" alt="Architecture: Kibana Workflows write KIs to an AI Index, agents read AI agent context via a query-ki skill" /><p></p><h3>Prerequisites</h3><p>This tutorial assumes you have:</p><ol><li><p>An Elasticsearch Serverless project. You can <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">sign up for a trial</a> if you don't have one.</p></li><li><p>An API key to access your Elasticsearch project. </p></li></ol><h2>Create sample indices for agent routing</h2><p>First, we’ll need some sources. Sources can be data that already exists in your Elasticsearch indices, or external data accessed via connectors or ES|QL data sources. </p><p>For this blog, we’ll create some indices with example data. We’ll start with an example using three datasets: <a href="https://huggingface.co/datasets/BeIR/fiqa">BEIR/fiqa</a> (financial), <a href="https://huggingface.co/datasets/BeIR/nfcorpus">beir-nfcorpus</a> (biomedical/nutrition), and <a href="https://huggingface.co/datasets/BeIR/scifact">beir-scifact</a> (scientific fact-checking). Each index is populated with its own <code>_meta.description</code>. </p><p>Here are the mappings we define for these indices: </p>{
  "beir-fiqa": {
    "mappings": {
      "_meta": {
        "description": "FiQA: financial question answering corpus from StackExchange Finance community posts and web crawls. Covers investments, banking, taxes, and market analysis. BM25-only index."
      },
      "properties": {
        "text": {
          "type": "text",
          "meta": {
            "description": "Full document body text."
          }
        },
        "title": {
          "type": "text",
          "meta": {
            "description": "Document or article title."
          }
        }
      }
    }
  }
}


{
  "beir-nfcorpus": {
    "mappings": {
      "_meta": {
        "description": "NFCorpus: biomedical information retrieval corpus from NutritionFacts.org. Contains nutrition science and medical research documents on diet, disease, and health interventions. BM25-only index."
      },
      "properties": {
        "text": {
          "type": "text",
          "meta": {
            "description": "Full document body text."
          }
        },
        "title": {
          "type": "text",
          "meta": {
            "description": "Document or article title."
          }
        }
      }
    }
  }
}


{
  "beir-scifact": {
    "mappings": {
      "_meta": {
        "description": "SciFact: scientific fact-checking corpus of biomedical research abstracts used to verify factual claims in peer-reviewed literature. BM25-only index."
      },
      "properties": {
        "text": {
          "type": "text",
          "meta": {
            "description": "Full document body text."
          }
        },
        "title": {
          "type": "text",
          "meta": {
            "description": "Document or article title."
          }
        }
      }
    }
  }
}<p>Then, using the above convenience scripts, load a handful of documents into each index with the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"><code>_bulk</code></a> API.</p><p>Now imagine an agent with a question and the indices we’ve just created. The agent has no idea which one is relevant at the start. Without pre-computed context, it either performs exploratory lookups (mappings, test searches) to figure out which source to use, or searches all three and hopes the merged results contain something useful. Either approach costs tokens, and if you multiply that inefficiency across every query an agent makes, it adds up.</p><h2>Create your AI Index</h2><p>Before generating any KIs, you need an index to store them. We call this an <strong>AI Index</strong>.</p><p>The naming convention is what triggers automatic configuration. Any index whose name starts with <code>ai-index-idx-</code> is a regular index; <code>ai-index-ds-</code> is a data stream. You’ll want to choose data streams for observability use cases, time series data, and when recency is important. Conversely, standard indices are a good choice for static data that will exist for a long while, where recency is not as much of a concern, and may need to occasionally be updated on demand. This naming convention is required for AI indices. </p><p>When Elasticsearch sees the <code>ai-index-</code> prefixes, it automatically applies component templates that configure the right mappings and settings.</p><p>Creating an AI Index is a single call:</p>PUT ai-index-idx-my-corpus<p>To see exactly what the component templates applied, inspect the mappings:</p>GET ai-index-idx-my-corpus/_mapping<p>The response shows the fields every AI Index gets out of the box:</p>{
  "ai-index-idx-my-corpus": {
    "mappings": {
      "properties": {
        "@timestamp": {
          "type": "date"
        },
        "attributes": {
          "type": "flattened"
        },
        "content": {
          "type": "text",
          "fields": {
            "semantic": {
              "type": "semantic_text",
              "inference_id": ".jina-embeddings-v5-text-small"
            }
          }
        },
        "description": {
          "type": "text",
          "fields": {
            "semantic": {
              "type": "semantic_text",
              "inference_id": ".jina-embeddings-v5-text-small"
            }
          }
        },
        "references": {
          "properties": {
            "uri": {
              "type": "keyword"
            }
          }
        },
        "tags": {
          "type": "keyword"
        },
        "title": {
          "type": "text",
          "fields": {
            "semantic": {
              "type": "semantic_text",
              "inference_id": ".jina-embeddings-v5-text-small"
            }
          }
        },
        "type": {
          "type": "keyword"
        }
      }
    }
  }
}<p><code>title</code>, <code>description</code>, and <code>content</code> are each a <code>text</code> field with a <code>.semantic</code> sub-field of type <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">semantic_text</a>, supporting hybrid retrieval.</p><p>Data stream indices (<code>ai-index-ds-*</code>) additionally carry a default 90-day data retention policy. This blog uses a standard index (<code>ai-index-idx-*</code>).</p><h2>Index Metadata as a Knowledge Indicator</h2><p>The target use case for this example is how the <code>query-index-metadata-ki</code> skill can route an agent to the correct Elasticsearch index, even when index or field names are vague. This reduces mistakes from choosing the wrong index or formulating queries based on incomplete schema exploration.</p><p>Since we're creating KIs for our own indices, we can give the LLM a head start: annotate index mappings with human-written <code>_meta.description</code> content. The workflow generates better KIs with more context to work from.</p><p>To address this, we'll manually create a <a href="https://www.elastic.co/docs/reference/kibana">Kibana Workflow</a> that profiles each index and writes routing KIs into the AI Index. The workflow chains four steps:</p><p></p><p><strong>Step</strong></p><p><strong>Type</strong></p><p><strong>What it does</strong></p><p><code>get_mapping</code></p><p><code>elasticsearch.request</code></p><p>Read the mapping, including <code>_meta.description</code> and per-field descriptions.</p><p><code>sample_docs</code></p><p><code>elasticsearch.search</code></p><p>Pull a few real documents so the profile reflects actual value shapes.</p><p><code>profile_index</code></p><p><code>ai.agent</code></p><p>Generate a structured index profile as structured output.</p><p><code>sink_index_ki</code></p><p><code>elasticsearch.bulk</code></p><p>Write the profile into the AI Index as a KI.</p><p>Paste the following YAML into the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Workflows</a> editor:</p>version: '1'
name: beir-index-profile-ki
description: Profile an index into an index-selection Knowledge Indicator.
enabled: true
tags:
  - context-management
  - index-selection

triggers:
  - type: manual

consts:
  indices:
    - beir-fiqa
    - beir-nfcorpus
    - beir-scifact

steps:
  - name: loop_indices
    type: foreach
    foreach: '{{ consts.indices | json }}'
    iteration-on-failure:
      continue: true
    steps:
      - name: get_mapping
        type: elasticsearch.request
        with:
          method: GET
          path: '/{{ foreach.item }}/_mapping'

      - name: sample_docs
        type: elasticsearch.search
        with:
          index: '{{ foreach.item }}'
          size: 3
          query:
            match_all: {}

      - name: profile_index
        type: ai.agent
        timeout: 120s
        with:
          message: &gt;
            You are a data steward building an INDEX PROFILE for an enterprise
            data catalog. Downstream, an AI agent uses these profiles to decide
            WHICH Elasticsearch index to query for a given user question -- this
            is an index-SELECTION aid, not a place to answer the question itself.

            You are given (a) the index name, (b) its Elasticsearch mapping
            including human-written descriptions in `_meta.description` and each
            field's `meta.description`, and (c) a few sample documents. Produce a
            faithful, decision-useful profile. Rules:
            - Ground everything in the provided mapping + samples. Never invent
              fields, values, or purpose. If unknown, use an empty string/array.
            - Optimize for routing: make it obvious what kinds of questions this
              index can authoritatively answer, and what it canNOT.
            - Prefer concrete field names and real example values from the
              samples over vague phrasing.
            - For joins, surface shared keys (e.g. *_id fields) that link this
              index to sibling indices, since cross-index questions hinge on them.

            Index name: {{ foreach.item }}

            Elasticsearch mapping (JSON):
            {{ steps.get_mapping.output | json }}

            Sample documents (JSON):
            {{ steps.sample_docs.output.hits.hits | map: '_source' | json }}
          schema:
            type: object
            properties:
              display_name:
                type: string
                description: A concise human-readable name for what this index represents (&lt;= 8 words).
              purpose:
                type: string
                description: 2-4 sentences describing what this index stores and its role. PRIMARY semantic surface for matching a question to this index.
              answers_questions:
                type: array
                items:
                  type: string
                description: 3-7 representative natural-language questions this index can authoritatively answer.
              does_not_contain:
                type: array
                items:
                  type: string
                description: 1-4 things a searcher might wrongly expect here but that live elsewhere, to prevent mis-routing.
              key_fields:
                type: array
                items:
                  type: string
                description: 3-10 of the most query-relevant fields as "field_name - what it is".
              when_to_use:
                type: string
                description: A single crisp routing heuristic - when should an agent pick THIS index? (&lt;= 30 words).
              example_esql:
                type: string
                description: One realistic, runnable ES|QL query against this index answering one of answers_questions.
            required:
              - display_name
              - purpose
              - answers_questions
              - key_fields
              - when_to_use

      - name: sink_index_ki
        type: elasticsearch.request
        with:
          method: PUT
          path: '/ai-index-idx-my-corpus/_doc/{{ foreach.item | url_encode }}'
          body:
            '@timestamp': '{{ "now" | date: "%Y-%m-%dT%H:%M:%S.%LZ" }}'
            type: index_metadata_entry
            title: '{{ steps.profile_index.output.structured_output.display_name | default: foreach.item }}'
            tags:
              - index-profile
              - '{{ foreach.item }}'
            attributes:
              display_name: '{{ steps.profile_index.output.structured_output.display_name }}'
              purpose: '{{ steps.profile_index.output.structured_output.purpose }}'
              when_to_use: '{{ steps.profile_index.output.structured_output.when_to_use }}'
              answers_questions: '{{ steps.profile_index.output.structured_output.answers_questions | json }}'
              does_not_contain: '{{ steps.profile_index.output.structured_output.does_not_contain | json }}'
              key_fields: '{{ steps.profile_index.output.structured_output.key_fields | json }}'
              example_esql: '{{ steps.profile_index.output.structured_output.example_esql }}'
              source_index: '{{ foreach.item }}'
            content: &gt;
              === SOURCE / PROVENANCE ===
              This is an INDEX PROFILE for routing/index-selection.
              Backing Elasticsearch index: {{ foreach.item }}
              Inspect it directly with ES|QL:
              FROM {{ foreach.item }} | LIMIT 10
              === WHAT THIS INDEX IS ===
              {{ steps.profile_index.output.structured_output.purpose }}
              Questions this index can answer: {{ steps.profile_index.output.structured_output.answers_questions | join: " | " }}
              When to use this index: {{ steps.profile_index.output.structured_output.when_to_use }}
              Example query:
              {{ steps.profile_index.output.structured_output.example_esql }}
            description: &gt;
              Index profile: {{ steps.profile_index.output.structured_output.display_name }}.
              Does NOT contain: {{ steps.profile_index.output.structured_output.does_not_contain | join: "; " }}.
              Key fields: {{ steps.profile_index.output.structured_output.key_fields | join: "; " }}.<p>Let's walk through what this workflow does. We loop over three specified indices with a <code>foreach</code> loop. For each:</p><ol><li><p><code>get_mapping</code> fetches the Elasticsearch index mappings, including any <code>_meta.description</code> annotations we added earlier.</p></li><li><p><code>sample_docs</code> pulls 3 real documents. Concrete examples give the LLM much better signal than schema alone.</p></li><li><p><code>profile_index</code> calls <code>ai.agent</code> with the index name, mappings, and sample documents. The LLM returns structured output describing the index's purpose, key fields, and an example ES|QL query showing how to use it.</p></li><li><p><code>sink_index_ki</code> writes the result into the AI Index as a KI of type <code>index_metadata_entry</code>, keyed on the index name so re-runs are idempotent.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfe75c3b235a08332/6a7b387e28883919bf07de96/image1.png" alt="Kibana Workflow generating Knowledge Indicators: get_mapping, sample_docs, profile_index, and sink to AI Index" /><p>A few things to point out: </p><ul><li><p>This workflow hard-codes a specific set of indices. In practice, you could derive the list from an index pattern or a dynamic source. </p></li><li><p>The <code>foreach</code> loop also runs iterations sequentially, which is fine for this guide but slow in production because each iteration involves an LLM call. For scale, use <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/composition">workflow.executeAsync</a> or native parallel support. The <a href="https://www.elastic.co/docs/explore-analyze/workflows/reference/cheat-sheet">cheat sheet</a> has tips on both.</p></li><li><p>In the <code>profile_index</code> step, the agent prompt is the special sauce. This is what shapes the accuracy and usefulness of the KIs. </p></li><li><p>Using <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/ai-steps#ai-prompt"><code>ai.prompt</code></a> can improve workflow efficiency (and cost) if you don’t need to load other tools. </p></li><li><p>Cost can be controlled in multiple ways. Richer prompts and structured output often result in higher token utilization, and of course the model you choose significantly impacts total costs. The <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS) can be a great playground to test different models against the <code>profile_index</code>’s <code>ai.agent</code> step to compare how different models stack up against each other when generating KIs. </p></li></ul><h3>Query your AI Index to verify Knowledge Indicators</h3><p>Once the beir-index-profile-ki workflow runs, query the AI Index directly in the Discover tab using the following ES|QL query to confirm what got written:</p><p></p><p>This will result in the following output: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7a497f27ccbb343c/6a7b38b81f7b5adf6678fdf1/image2.png" alt="ES|QL query results from an AI Index showing three index metadata Knowledge Indicators in Kibana Discover" /><h3>Build a portable skill to retrieve AI agent context</h3><p>Retrieval is a critical component in an AI index. A KI is a document in the AI Index, and finding one is a single ES|QL query. We package that query as a small, portable skill so any agent can call it, regardless of the harness it runs in.</p><p>We write the skill as a SKILL.md: a YAML header with a name and description, followed by markdown instructions. This is the same Agent Skills format that many harnesses, including Claude Code, LangChain's Deep Agents, and others, load directly. </p><p>The harness reads the header content up front, and only pulls in the full instructions when a question matches the description. The one thing the skill asks of the harness is a way to run ES|QL against Elasticsearch.</p><p>Here is a sample <code>query-index-metadata-ki</code> skill: </p>---
name: query-index-metadata-ki
description: &gt;-
  Retrieve Knowledge Indicators (pre-computed context) from the Elasticsearch AI
  Index before answering. Use it to find which index to search (routing profiles).
  Trigger on any question that depends on choosing a data source.
allowed-tools: esql_query
---

# Retrieving Knowledge Indicators

Knowledge Indicators (KIs) live in Elasticsearch indices named `ai-index-*`.
Retrieve them by calling the `esql_query` tool with the query below. Substitute
the user's question for `&lt;query&gt;`, and `index_metadata_entry` as the `&lt;ki_type&gt;` for routing profiles.

```esql
FROM ai-index-idx-* METADATA _id, _index, _score
| WHERE type == "&lt;ki_type&gt;"
| FORK
    (WHERE MATCH(content, "&lt;query&gt;") OR MATCH(description, "&lt;query&gt;")
     | SORT _score DESC | LIMIT 20)
    (WHERE MATCH(content.semantic, "&lt;query&gt;") OR MATCH(description.semantic, "&lt;query&gt;")
     | SORT _score DESC | LIMIT 20)
| FUSE
| SORT _score DESC
| KEEP title, content, description, tags
| LIMIT 5
```

Ground your answer in what the query returns, and cite the KI titles you used. If
nothing relevant comes back, say so rather than guessing.<p>Let’s break down what the skill is doing: </p><ul><li><p>We’re defining <code>index-metadata-entry</code> as a KI type/use case.</p></li><li><p>We’re performing a hybrid ES|QL search on our AI indices, filtering by the appropriate <code>type</code> using RRF as the default method to fuse results.</p></li><li><p>The KI results will directly ground the agent’s answer when determining what indices are relevant to the query.</p></li></ul><p>Because the skill is just instructions plus a query, it travels wherever your agent does. You can point the same file at a Kibana Workflow agent, Claude Code, LangChain Deep Agents, or any other harness without changing a line of it.</p><h3>Connect your AI Index to an agent harness </h3><p>We want to demonstrate how you can use AI indices to query your data with any harness. For these examples, we’ll use LangChain Deep Agents and an OpenAI-compatible key, but any other agent harness can be easily substituted in, including Elastic Agent Builder.</p><p>First, let’s create a baseline to see how an agent will perform without using KIs:</p># Example question: Is there scientific evidence that vitamin D supplementation prevents cancer?
import os
import sys
import time
from elasticsearch import Elasticsearch
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent

if len(sys.argv) &lt; 2:
    sys.exit(f'Usage: python {sys.argv[0]} "your question"')

es = Elasticsearch(os.environ["ES_URL"], api_key=os.environ["ES_API_KEY"])


@tool
def esql_query(query: str) -&gt; list[dict] | str:
    """Execute an ES|QL query against Elasticsearch and return the matching rows.

    Args:
        query: A complete ES|QL query string, e.g. 'FROM beir-fiqa | LIMIT 5'.
               Full-text search syntax: WHERE MATCH(field, "value") — not field MATCH "value".
    """
    try:
        resp = es.esql.query(query=query, format="json")
        cols = [c["name"] for c in resp["columns"]]
        return [dict(zip(cols, row)) for row in resp["values"]]
    except Exception as e:
        return f"ES|QL error: {e}"


@tool
def get_mapping(index: str) -&gt; dict:
    """Return the field mapping for an Elasticsearch index or pattern."""
    return es.indices.get_mapping(index=index).body


baseline_agent = create_deep_agent(
    model=ChatOpenAI(  # any OpenAI-compatible endpoint; configure via LLM_* env vars
        base_url=os.environ.get("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
        model=os.environ.get("LLM_MODEL", "anthropic/claude-sonnet-4.5"),
        api_key=os.environ["LLM_API_KEY"],
    ),
    tools=[esql_query, get_mapping],
    system_prompt=(
        "You are a research assistant with access to three Elasticsearch indices: "
        "beir-fiqa, beir-nfcorpus, and beir-scifact. "
        "You do NOT know which index is relevant for a given question. "
        "Use get_mapping to inspect an index's description and fields, "
        "then query the most relevant one with esql_query. "
        "Ground your answer strictly in what the queries return."
    ),
)

start = time.perf_counter()
result = baseline_agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": sys.argv[1],
            }
        ]
    }
)
latency = time.perf_counter() - start

print("\n--- Tool calls ---")
for m in result["messages"]:
    if isinstance(m, AIMessage) and m.tool_calls:
        for tc in m.tool_calls:
            print(f"  [{tc['name']}] {str(tc['args'])[:120]}")
total = sum(
    len(m.tool_calls)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.tool_calls
)
print(f"Total: {total}\n")

print("--- Usage ---")
input_tokens = sum(
    (m.usage_metadata or {}).get("input_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
output_tokens = sum(
    (m.usage_metadata or {}).get("output_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
print(f"Tokens: {input_tokens + output_tokens} (input {input_tokens}, output {output_tokens})")
print(f"Latency: {latency:.2f}s\n")

print("--- Answer ---")
print(result["messages"][-1].content)<p>Here’s a modified example that could run the same agent, but now with the ability to search AI indices to return KIs: </p># Example question: Is there scientific evidence that vitamin D supplementation prevents cancer?
import os
import sys
import time
from elasticsearch import Elasticsearch
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent
from deepagents.backends.filesystem import FilesystemBackend

if len(sys.argv) &lt; 2:
    sys.exit(f'Usage: python {sys.argv[0]} "your question"')

es = Elasticsearch(os.environ["ES_URL"], api_key=os.environ["ES_API_KEY"])


@tool
def esql_query(query: str) -&gt; list[dict] | str:
    """Execute an ES|QL query against Elasticsearch and return the matching rows.

    Args:
        query: A complete ES|QL query string, e.g. 'FROM beir-fiqa | LIMIT 5'.
               Full-text search syntax: WHERE MATCH(field, "value") — not field MATCH "value".
    """
    try:
        resp = es.esql.query(query=query, format="json")
        cols = [c["name"] for c in resp["columns"]]
        return [dict(zip(cols, row)) for row in resp["values"]]
    except Exception as e:
        return f"ES|QL error: {e}"


backend = FilesystemBackend(root_dir=".", virtual_mode=False)

agent = create_deep_agent(
    model=ChatOpenAI(  # any OpenAI-compatible endpoint; configure via LLM_* env vars
        base_url=os.environ.get("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
        model=os.environ.get("LLM_MODEL", "anthropic/claude-sonnet-4.5"),
        api_key=os.environ["LLM_API_KEY"],
    ),
    tools=[esql_query],
    skills=["skills"],
    backend=backend,
    system_prompt=(
        "You are a research assistant with access to several Elasticsearch indices. "
        "You do NOT know which index is relevant for a given question. "
        "Before searching, always use the query-ki skill with type 'index_metadata_entry' "
        "to retrieve the routing profile for the right index, then query that index directly. "
        "Ground your answer strictly in what the queries return and cite the KI you used for routing."
    ),
)

start = time.perf_counter()
result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": sys.argv[1],
            }
        ]
    }
)
latency = time.perf_counter() - start

print("\n--- Tool calls ---")
for m in result["messages"]:
    if isinstance(m, AIMessage) and m.tool_calls:
        for tc in m.tool_calls:
            print(f"  [{tc['name']}] {str(tc['args'])[:120]}")
total = sum(
    len(m.tool_calls)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.tool_calls
)
print(f"Total: {total}\n")

print("--- Usage ---")
input_tokens = sum(
    (m.usage_metadata or {}).get("input_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
output_tokens = sum(
    (m.usage_metadata or {}).get("output_tokens", 0)
    for m in result["messages"]
    if isinstance(m, AIMessage) and m.usage_metadata
)
print(f"Tokens: {input_tokens + output_tokens} (input {input_tokens}, output {output_tokens})")
print(f"Latency: {latency:.2f}s\n")

print("--- Answer ---")
print(result["messages"][-1].content)<p>This agent will always query the KI indices to get the answer.</p><h2>How much do Knowledge Indicators reduce agent token usage?</h2><p>Since we’re using agents, the results of these scripts are non-deterministic. However, when I ran these results against the query <code>Is there scientific evidence that vitamin D supplementation prevents cancer?</code>, both agents led to the same conclusion, but they took different paths to get there: </p><p>
</p><p>Baseline (No AI Index)</p><p>With AI Index</p><p>Total tool calls</p><p>12</p><p>8</p><p><code>read_file</code> calls</p><p>0</p><p>2</p><p><code>get_mapping</code> calls</p><p>3</p><p>0</p><p><code>esql_query</code> calls</p><p>9</p><p>6 </p><p>Total indices queried</p><p>2 (bounced between <code>beir-scifact</code> and <code>beir-nfcorpus</code>)</p><p>1 (<code>beir-nfcorpus</code>)</p><p>Tokens consumed</p><p>167,763</p><p>92,711</p><p>Latency</p><p>39.58s</p><p>36.15s</p><p>Answer</p><p>Grounded, correct</p><p>Grounded, correct</p><p>The KI answers were both grounded and correct, but an interesting datapoint is the fact that the overall tool usage and token utilization was smaller when using KIs (latency was roughly equivalent). Here’s how both paths went, side by side: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcce26759ded49327/6a7b3983288839683507dea0/image5.png" alt="Agent comparison: 12 tool calls without KIs vs 8 with KIs, 45% fewer tokens, same grounded answer" /><h2>Run the full AI Index pipeline in Serverless</h2><p>This walkthrough offered a deep dive into the build-it-yourself version of AI indices and KIs. In production, you wouldn't hand-write these workflows; a setup agent would generate them, and a feedback loop would refine KIs from the agent's own traces. But the primitives are exactly what you just used: extract KIs with a workflow, store them in an AI Index, and retrieve them with a skill.</p><p>Managing context is key to a relevant and efficient agentic search system, and AI indices are a way to manage this context with the full power of the Elastic stack. Try it out in Serverless and let us know what you think in our <a href="https://discuss.elastic.co/top?period=monthly">Discuss forums</a> or the <code>#stack-kibana</code> channel in our <a href="https://elasticstack.slack.com/signup#/domain-signup">Community Slack</a>! </p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-index-building-context-agents</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-index-building-context-agents</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso,Matt Nowzari ,Apostolos Matsagkas,Peter Pisljar]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt82d8e9495cde2e30/6a7b37020c5aa95ac5f88c47/image3.png" length="0" type="image/png"/>
    <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Faster, cheaper support investigations with precomputed context]]></title>
    <description><![CDATA[Precomputed context cut input tokens by 58% and latency by 40% in Elastic’s support agent, making support investigations more efficient by reducing repeated retrieval.]]></description>
    <content:encoded><![CDATA[<p>When a support engineer is assigned a case, the questions can sound straightforward: What happened, what evidence supports the root cause, and what should happen next? The answers may be spread across a case record, a long conversation feed, linked engineering issues and comments, knowledge articles, and related cases. Precomputed context gathers and organizes evidence from those related records before the agent receives a question. The agent can then begin with the case relationships already identified, rather than reconstructing them during each response. In our evaluation, this approach resulted in lower input token use and latency, without a statistically significant reduction in factuality.</p><p>Before adding precomputed context, the Support team at Elastic used an agent for case investigation, root cause analysis (RCA), triage, and related work. To answer a question, the agent had to discover relevant indices, inspect schemas, issue several queries, reconcile conflicting updates, and assemble a response. Questions ranged from narrow state lookups to multisource investigations:</p><ul><li><p>What is the current status and priority of support case 1234567?</p></li><li><p>After client ABC’s cluster migrated certificates, it returned Secure Sockets Layer (SSL) handshake and certificate_unknown errors. What caused the failure, and how should it be fixed? Which knowledge base articles are relevant?</p></li><li><p>Client ABC’s cluster stopped processing indexing requests and returned 'rejected execution of primary operation' errors. What was the root cause, and how was service restored?</p></li></ul><p>The next question about the same case or about a related case often required much of the same orientation and synthesis work. That repetition consumed input tokens and added latency. It also increased the chance of invalid queries or wrong source selection.</p><p>Earlier work on <a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">precomputed context</a> provided a starting point. It described extracting useful context ahead of time and storing it as structured Knowledge Indicators (KIs). Then it explained how an agent retrieves those compact records before scanning raw documents.</p><p>For this support use case, we wanted to understand what context was worth preparing in advance and how the choice of context affected response efficiency and reliability. We also wanted to know what retrieval rules were needed when an agent could use several KI types. This article follows that design process and reports the findings.</p><h2><strong>Starting with index profiles</strong></h2><p>We began with index profile KIs. A workflow read each support index mapping and sampled a few documents. It generated a compact profile describing the index’s purpose, the questions it could answer, important fields, exclusions, verified joins, time coverage, example Elasticsearch Query Language (ES|QL) queries, and more. A representative KI document with selected fields looked like this:</p>{
  "_id": "index-profile-support-cases",
  "_source": {
    "type": "ki",
    "title": "Index profile: Support cases",
    "origin": { "uri": "ki://support-cases" },
    "tags": ["ki-kind:index-profile", "index-selection", "support-data"],
    "content": [
      "BACKING_INDEX: support-cases",
      "PURPOSE: Primary metadata and the customer-reported problem for enterprise support cases (status, product, severity, reported symptom).",
      "QUESTIONS_ANSWERED: What is the status of a case? | Which cases affect a given product or version? | What symptom was reported? | Which high-severity cases are still open?",
      "WHEN_TO_USE: Start here to find or filter cases by status, product, or severity before pulling the conversation thread or root-cause detail from other indices.",
      "KEY_FIELDS: case_number, subject, status, product, severity, created_at, resolved_at",
      "EXAMPLE_QUERY: FROM support-cases | WHERE product == \"elasticsearch\" AND status == \"open\" | KEEP case_number, subject, severity, created_at | LIMIT 20"
    ],
    "description": "Tells the agent which index holds support-case metadata and how to query it, so it can choose the right data source and narrow to relevant cases before deeper analysis."
  }
}<p>The profiles helped the agent choose a source and form a query. They reduced orientation work, especially when index or field names were unclear, but they didn’t remove the main cost of a case investigation. After selecting the indices, the agent still had to gather the case record and reconstruct the conversation. They also still had to follow links to engineering and knowledge sources and then reconcile the evidence. Routing was useful, but the repeated work was synthesis.</p><h2><strong>Changing the unit of context</strong></h2><p>That observation led to case-level KIs. The workflow materializes one evidence-aware snapshot per eligible case. It gathers the case record, recent material conversation events, linked engineering issues and comments, and linked knowledge articles. The resulting KI document contains a stable case identifier and source references. It also contains tags and freshness metadata.</p><p>The design became more detailed as we worked through support specific nuances. Support conversations contain provisional theories, later corrections, administrative status changes, and occasionally separate incidents inside one case. A single fluent summary can flatten those distinctions. The case schema therefore records incident phases, a hypothesis ledger with confirmed, inferred, contradicted, and unresolved states, root cause status and confidence, technical outcome, reusable learning, and more.</p><p>Since large language models (LLMs) can hallucinate some of these details, we added deterministic checks after generation. For example:</p><ul><li><p>An unresolved root cause must be empty and have low confidence. </p></li><li><p>An inferred root cause cannot have high confidence. </p></li><li><p>A case marked closed in a customer relationship management (CRM) system is evaluated separately from whether the technical issue was resolved. </p></li></ul><p>These checks repair structural contradictions without requiring another model call to reinterpret the evidence. A representative KI document with selected fields looked like this:</p>{
  "_id": "1234567",
  "_source": {
    "type": "ki",
    "title": "Case 1234567: Cluster writes blocked after disk flood-stage watermark breach",
    "origin": { "uri": "case://1234567" },
    "tags": [
      "ki-kind:case-intelligence",
      "support-data",
      "cohort:closed",
      "technical-outcome:resolved",
      "rca-confidence:high"
    ],
    "references": [
      { "uri": "case-number://1234567" },
      { "uri": "https://github.com/elastic/elasticsearch/issues/00000" }
    ],
    "content": [
      "CASE_NUMBER: 1234567",
      "STATUS: Closed",
      "PRIORITY: High",
      "SUMMARY: A production cluster stopped accepting writes after a data node crossed the disk flood-stage watermark, which put all indices into read-only mode; freeing disk and clearing the block restored writes.",
      "PROBLEM_AND_IMPACT: Indexing failed cluster-wide with 'FORBIDDEN/12/index read-only'; the customer's ingest pipeline was stalled for 1 hour.",
      "PRODUCTS_AND_COMPONENTS: Elasticsearch, disk-based shard allocation.",
      "INCIDENT_PHASES: Phase 1: disk usage crossed the 95% flood-stage watermark, indices auto-set to read-only. || Phase 2: disk freed and read-only block cleared, writes resumed.",
      "HYPOTHESIS_LEDGER: Flood-stage watermark breach auto-applied a read-only block (confirmed); node stats showed disk at 96% and cluster logs recorded the flood-stage event.",
      "ROOT_CAUSE: A data node exceeded the flood-stage disk watermark, so Elasticsearch automatically applied a cluster-wide read-only block to protect the nodes.",
      "ROOT_CAUSE_STATUS: confirmed",
      "ROOT_CAUSE_CONFIDENCE: high",
      "RESOLUTION_OR_CURRENT_STATE: Freed disk space (removed stale snapshots/indices), cleared the read-only block, and verified writes resumed; recommended more headroom plus watermark alerting.",
      "TECHNICAL_OUTCOME: resolved",
      "REUSABLE_LEARNING: When every index goes read-only at once, check disk watermarks first; the flood-stage block is applied automatically but must be cleared manually after space is freed."
    ],
    "description": "Distilled root cause of a single case: symptom, confirmed root cause with high confidence, resolution, and a reusable lesson so the agent can explain the fix and find precedent for similar cases."
  }
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt996a89c947c17745/6a7addd247f9e476c0c2bbe5/image3.png" alt="" /><h2><strong>Generating KIs only when they’re useful</strong></h2><p>After a few iterations, we realized that generating a KI for every case adds cost and noise. Many cases have little usable evidence. In some, there’s only a short intake message; in others, there’s no substantive feed or acknowledgement from a support engineer. So we added gates and filters to the workflow. For example:</p><ul><li><p>A case proceeds when it has linked engineering evidence or at least two material conversational events, including evidence of support participation.</p></li><li><p>The workflow compares the stored KI watermark with the newest timestamp across the case and its material feed. An unchanged case reuses its existing KI instead of regenerating it every time.</p></li><li><p>The workflow limits corpus selection by focusing generation on recent cases or those otherwise likely to be queried, especially when the source corpus is large.</p></li><li><p>The workflow also refreshes the case KI when linked sources can change independently, rather than relying on an incomplete watermark.</p></li></ul><p>Deferred cases remain available through raw lookup and can be reconsidered after new activity arrives. Provenance is part of the stored KI: References point back to linked sources, and the output records what was missing or unverifiable. The KI shortens routine investigation, while raw records remain available for current status, complete history, attachments, and evidence outside the generated snapshot.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf80a0849cde2fe70/6a7added0f118a90462d82fa/image2.png" alt="" /><h2><strong>The retrieval skill is part of the system</strong></h2><p>While workflows generate KIs, the agent requires retrieval guidance to use them effectively. To address this, we developed a retrieval skill which provides ES|QL templates for querying KIs stored in the index and guidance on when to use each retrieval method. For instance, a known case number is searched lexically because it’s an exact identifier. Questions about similar cases or precedents use hybrid lexical and semantic retrieval, while questions about official knowledge, engineering issues, comments, or other non-case entities begin with an index profile and then query the selected raw source.</p><p>The skill also assigns a clear role to each KI family. An index profile provides routing and field guidance, while a case KI provides a bounded snapshot of the evidence relevant to one case. If no case KI is available, the agent treats that as a cache miss and follows the index profile guidance back to the underlying data, rather than assuming that the available context is complete.</p><p>That distinction matters during a typical case lookup. For a question such as <em>What is the current priority of case 1234567?</em>, the agent retrieves the case KI by case <code>_id</code> to understand the investigation and its supporting evidence. It then checks the live case record for values that can change, including status and priority, along with updated_at. If the live record is newer than the KI’s last update, the live value takes precedence. The KI remains useful for durable evidence, while the source record remains the reference for current state. The next eligible refresh rebuilds the KI using timestamps from the case and its material feed, in addition to linked sources.</p><p>This separation was shaped by an evaluation failure. In an earlier version, the agent used the case KI to plan retrieval but issued a raw source query using an inferred schema instead of the fields supplied by the index profile. The query failed, causing retries and additional model work. Later versions of the skill made the precedence rules, source roles, field guidance, and recovery steps for mapping errors explicit.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt14369daa9b806298/6a7ade06288839337f07dced/image1.png" alt="" /><h2><strong>What we observed </strong></h2><p>We evaluated the agent using 20 distinct questions, with approximately 10 for each workflow: case investigation/postmortem summarization and shorter technical support Q&amp;A across several source types. We used three trials per question to measure variation from run to run. The examples in this post were tested on an Elastic Cloud Hosted deployment running Elasticsearch 9.4.2.</p><p>Different agent configurations used the same answering model and comparable tool conditions. They varied only in whether the agent had access to KIs and, if so, which ones: index-profile KIs, case-level KIs, or both. We measured factuality against expected answers, input and output tokens, latency, and tool execution failures. A separate LLM judge scored factuality.</p><p>Case-level KIs produced the clearest signal of operational efficiency in both workflows. Relative to the raw index baseline, observed input token use and latency changed as follows:
</p><p><strong>Workflow</strong></p><p><strong>Input tokens</strong></p><p><strong>Latency</strong></p><p>Case investigation</p><p>About 58% lower</p><p>About 40% lower</p><p>Technical support Q&amp;A</p><p>About 43% lower</p><p>About 17% lower</p><p>We noticed that index profiles shortened source orientation but left consolidation to the agent, whereas case-level KIs supplied a compact evidence bundle aligned with the requested output. That led to fewer raw queries and reduced the opportunity for schema- and partition-related tool errors.</p><p>Factuality varied by workflow, but we didn’t observe a statistically significant reduction in this evaluation.</p><p>A note on interpreting these results: The evaluation was performed on a small dataset, which is common and useful early in the agent development lifecycle when large labeled evaluation datasets aren’t yet available. We therefore treat the results as directional signals for comparing variants. This mirrors strategies outlined in engineering blogs by <a href="https://www.elastic.co/search-labs/blog/ai-agent-evaluation-elastic">Elastic</a> and other companies, such as <a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents">Anthropic</a>: Start with a small number of tasks drawn from real failures, and then expand the suite as effects become smaller and the product matures.</p><h2><strong>Why case-level KIs fit support work</strong></h2><p>The case-level strategy matched the shape of support investigation work in several ways:</p><ul><li><p>A case number is a stable retrieval anchor.</p></li><li><p>The expensive operation is repeated consolidation across the same source relationships.</p></li><li><p>Much of the evidence used for explanation is durable, while volatile states can be verified separately.</p></li><li><p>The KI schema mirrors the type of questions an engineer asks during case summary and RCA work.</p></li><li><p>Maturity and freshness controls limit generation to cases with enough evidence and likely reuse.</p></li></ul><p>Index profiles still have value. They help with source discovery, schema orientation, non-case questions, and fallback. For case investigation, they leave the cross-source reconstruction inside the response path. Case-level KIs remove part of that recurring work, which is the main reason we expected lower token use and latency in this domain.</p><h2><strong>The combined strategy exposed a composition problem</strong></h2><p>While evaluating the responses, we made a counterintuitive observation: Providing both index profiles and case KIs didn’t improve on using case KIs alone. Inspection of the trace showed that the agent understood some of the case context but lacked a dependable rule for composing the two KI families. It used a case-level KI for planning but then ignored the index profile’s schema guidance when querying raw data, leading to failures and suboptimal responses.</p><p>This resulted in a practical lesson for improving guidance in the retrieval skill: Context sources need roles and precedence. The agent must know which source can support an answer, which source only routes to evidence, when verification is required, and what to do after a cache miss or mapping error. Two individually useful context types can create additional work when those contracts are implicit.</p><h2><strong>What we learned about precomputed context</strong></h2><p>In this support workflow, the most useful unit of context was a case and the evidence connected to it: the case record, conversation, linked engineering work, and relevant knowledge. Preparing that evidence ahead of time meant that the agent didn’t have to reconstruct the same relationships for every response. Compared with the raw index baseline, the case-level approach was associated with lower observed input token use and latency, while factuality didn’t show a statistically significant reduction.</p><p>Because case state can change, the system still checks source data for values, such as priority and case status, and falls back to raw data when the available evidence doesn’t justify a case-level KI. Next, we plan to test how well this design holds with support data from external systems, such as Salesforce, and to identify any adjustments needed.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/reduce-token-usage-precomputed-context</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/reduce-token-usage-precomputed-context</guid>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Abhimanyu Anand]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte973a631a35f846f/6a7add9cc8b7acfe04521420/image4.png" length="0" type="image/png"/>
    <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How Elasticsearch's batched query phase improves search performance at scale]]></title>
    <description><![CDATA[The batched query phase can cut search execution time in half by reducing transport overhead and better distributing reduction work across the cluster.]]></description>
    <content:encoded><![CDATA[<p>The batched query phase, which is live in Elasticsearch Serverless and released in Elasticsearch 9.5.0, changes how search is coordinated across the cluster. The coordinating node now batches all queries for each data node into one, instead of a separate transport request per shard. Data nodes can then do partial result reduction themselves, rather than shipping everything back to the coordinating node. In coordination-bound workloads, this can cut search execution time in half.</p><h2>How a search is executed in Elasticsearch</h2><p>Let’s start with some Elasticsearch basics. When a search request lands on an Elasticsearch node, that node is called the <em>coordinating node</em>for the search. By default, any Elasticsearch node can act as a coordinating node. The coordinating node determines which shards need to be searched based on the index or indices specified by the search. The nodes that those shards live on are called <em>data nodes</em>. A coordinating node can also be a data node, as it may host shards that are relevant to the search.</p><p>Elasticsearch performs the search in two primary phases: the <em>query phase</em> (also known as the <em>scatter phase</em>) and the <em>fetch phase</em>(also known as the <em>gather phase</em>). The query phase is responsible for going to the data nodes and executing the query on each shard. Each shard responds with a set of document IDs (just the IDs, no data) and an associated score for each. These results are reduced. Next, the fetch phase goes back out to the data nodes to fetch the document <code>_source</code> (the data). </p><p>What exactly is a <em>reduction</em>in Elasticsearch? A reduction turns per-shard results from the query phase into a single merged result for the client. Suppose a search asks for the top five hits in a three-shard index, according to some relevance score. Elasticsearch must then get the top five hits from each targeted shard. Why? Because it’s possible that one shard contains the global top five, or, more likely, that the top five docs are spread across shards. </p><p>If three shards are being searched, the coordinating node will have 15 <code>(docID, score)</code> pairs after the query phase. These results are reduced: The documents with the top five scores are kept and the rest thrown away. Then the fetch phase reaches back out to the data nodes to get the documents’ <code>_source</code> data, which Elasticsearch then responds with.</p><p>shard1_results = [(id: 231, score: 0.871), (id: 445, score: 0.812), (id: 88, score: 0.754), (id: 312, score: 0.701), (id: 567, score: 0.643)]</p><p>shard2_results = [(id: 847, score: 0.921), (id: 76,  score: 0.843), (id: 125, score: 0.783), (id: 438, score: 0.729), (id: 590, score: 0.668)]</p><p>shard3_results = [(id: 512, score: 0.887), (id: 289, score: 0.798), (id: 74,  score: 0.741), (id: 631, score: 0.682), (id: 405, score: 0.619)]</p><p>// Take the top five scores from above (that’s the “reduction”)
reduced_result = [(id: 847, score: 0.921), (id: 512, score: 0.887), (id: 231, score: 0.871), (id: 76, score: 0.843), (id: 445, score: 0.812)]</p><h2>What is the batched query phase?</h2><h3>Without batching (shard fan-out)</h3><p>To understand the batched query phase, we must first understand how the query phase worked without batching. The following diagram represents a three-node Elasticsearch cluster with 12 index shards. Suppose a client search request lands on Node 1. That makes Node 1 the coordinating node for the search.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd8b116819f43dc50/6a79aa014423e189ba9fa08f/image3.png" alt="" /><p>The coordinating node first <em>fans out</em> to all shards, performing the client’s query on each shard. Each shard query is facilitated by a <em>transport request</em>, which is a protocol used for communication between Elasticsearch nodes. Notice that in this diagram, each shard gets its own transport request. Node 1 holds shards itself, but no network request is needed to query those shards.</p><p>After querying the shards, the coordinating node must reduce them, as described in the previous section. At this point, the coordinating node is holding 12 shard results. Once it reduces them all, it can proceed with the fetch phase and then respond back to the client.</p><h3>With batching</h3><p>So what does the batched query phase change? The batched query phase first takes effect before dispatching the shard query transport requests. Now the coordinating node batches the shards it needs to query for each data node and requests them all at once.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt44a9f6586a927f2b/6a79aa2b2888390ea907d7c2/image2.png" alt="" /><p>Notice that only one transport request was made to Node 2 and one to Node 3. This is the first change in the batched query phase. Each transport request induces some overhead, so this change alone buys us our first performance improvement, reducing latency and CPU cycles spent on overhead.</p><p>The second change in the batched query phase has to do with reducing shard results. Now partial reductions occur on the data nodes themselves. For instance, after querying shards 2, 5, 8, and 11, Node 2 then reduces those results. The coordinating node, upon receiving partially reduced results from Node 2 and Node 3, must then perform afinal reduction. This is the second major enhancement we get from the batched query phase: the spreading out of reduction work across the data nodes. This reduces memory pressure on the coordinating node, which we’ll see measured later in the benchmarking section.</p><h2>The benefits of batching</h2><p><em>Fan-out</em> (no batching) is how search worked for a very long time. It has advantages: each shard is a separate request that returns and can be retried independently from all the others. With many shards involved, though, each shard request causes overhead due to many round trips going between the coordinating node and the data nodes.</p><p>Also, the coordinating node doesn’t have enough information to be able to determine the pace at which to send requests to each data node. It sends a maximum of five concurrent requests per data node by default, where five is a bit of a magic number which allows for some parallelism, while at the same time preventing a single query from taking over an entire data node. At the same time, data nodes also don't make distinctions between the different shard requests they receive, for instance based on what parent search request they belong to. In reality, if all shard requests involving a search request are presented in one batch to each data node, the data node can then look at its internal state and adapt its pace dynamically, removing the five concurrent shard requests artifical limit mentioned above.</p><p>The batched query phase results in several benefits, including:</p><ul><li><p>Increased efficiency, thanks to fewer round trips: less CPU spent on transport overhead and fewer bytes going through the transport layer.</p></li><li><p>Spreading out the load of reductions: the coordinating node was previously the bottleneck for reductions, and now data nodes share the work.</p></li><li><p>Better resource usage: we may be able to better max out the data node’s CPUs.</p></li></ul><p>The average search against many shards can now be served much quicker, with lower latency and higher throughput.</p><h2>Batched query phase benchmarks: Latency and memory usage</h2><p>To measure the benefits of the batched query phase, we ran a couple of benchmarks. The first displays the benefit of reduced transport overhead. This benchmark was built off the “many-shards-quantitative” <a href="https://elasticsearch-benchmarks.elastic.co/">nightly benchmark</a>. It runs on a three-node Elasticsearch cluster, running batches of searches targeting 1,000, 5,000, and 20,000 shards. The queries are <code>match_all</code> queries with <code>size: 0</code>. That means querying the shards themselves is effectively a no-op. This benchmark is meant to isolate the work of coordinating the search across the cluster.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4a4a7aadc1632a8/6a79aa47dcb437094d2cf8da/image1.png" alt="" /><p>We ran the same benchmark with and without the batched query phase (by toggling the cluster setting <code>search.batched_query_phase</code>). At 5,000 shards, the batched query phase makes searches twice as fast. By 20,000 shards, it’s 2.2x faster. These results are a ceiling for what a realistic workload can expect to benefit; any real query work at the shard level will dilute the overall result. However, the gains are real, and the <em>coordination work</em> of your queries will benefit as shown. Note that this first benchmark did not attempt to measure any gain brought by spreading reductions across data nodes, as opposed to performing them only on the coordinating node.</p><p>Next, we benchmarked the benefits of the batched query phase on large reductions. In our benchmark, we ran a large terms aggregation over a data set called <code>http_logs</code> (which can be found in our <a href="https://github.com/elastic/rally-tracks">rally-tracks</a> repo). This data set has 247 million documents, which we indexed in seven indices each with 100 shards, again in a three-node Elasticsearch cluster. We ran a single <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-terms-aggregation">terms aggregation</a> query for the <code>clientip</code> field with <code>size: 1000</code> and <code>shard_size: 50000</code>. That means we’re asking for the top 1,000 terms, although each shard will return 50,000 buckets to be reduced to that 1,000. Requesting so many terms from each shard increases precision, but has a cost in terms of memory usage and reduction overhead, which helps highlight the gain provided by spreading incremental reductions across data nodes.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt978ed04cf2406b0e/6a79aa5a8bcd807043261d4b/image4.png" alt="" /><p>The <em>memory used</em> is measured by something called a <em>circuit breaker</em>, which accounts for memory usage in Elasticsearch. This field can be found in the <code>/_nodes/stats</code> response under <code>nodes.&lt;node_id&gt;.breakers.request.estimated_size_in_bytes</code>. It estimates the memory in use for processing in-flight search requests.</p><p>The memory benchmark shows how the work of reductions is now spread across the cluster. In blue is the memory used with <code>search.batched_query_phase: false</code>. We can see that the node elasticsearch-0 is the coordinating node, as it bears the entire reduction load itself. This effect is no longer the case with <code>search.batched_query_phase: true</code> in red. The node elasticsearch-1 is the coordinating node, but it uses far less memory. That’s because the data nodes elasticsearch-0 and elasticsearch-2 do reductions themselves, reducing the results before they’re returned to the coordinating node.</p><h2>Summary</h2><p>Batching shard queries per data node reduces transport overhead and allows each data node to perform partial results reduction locally. The result is lower latency for searches targeting many shards and better distribution of work across the cluster for reduction-heavy workloads. At the same time, the new model works well for all scenarios and opens the door for potential future enhancements which we’re excited to pursue, such as:</p><ul><li><p>Optimize out-of-the-box resource usage by introspecting data nodes' activity and adjusting the pace accordingly, which would replace the five concurrent shard requests per data node artifical limit.</p></li><li><p>Assign priorities to search requests and let each data node process shard requests accordingly.</p></li><li><p>Reduce the number of roundtrips further by folding the `can_match` phase into the query phase. Can match is a separate "batched" roundtrip used to shortcut the query against shards that can't possibly match based on index statistics. Now that the query phase is batched, both rounds can be executed in one go.</p></li></ul><ul><li><p>Discontinue support for minimize roundtrips in cross-cluster searches in favour of batched query execution; in hindsight, minimize roundtrips achieves a similar goal but is specific to cross-cluster execution, while batched execution is applicable to every search. </p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-batched-query-phase</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-batched-query-phase</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Ben Chaplin,Luca Cavanna]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec0235a331fb0c48/6a79a9e30da673867657b299/image5.png" length="0" type="image/png"/>
    <pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch as one platform: What a second data system really costs]]></title>
    <description><![CDATA[Running search, analytics, metrics, logs, and vector retrieval in five systems costs more than five licenses. Here's what one platform looks like in practice.]]></description>
    <content:encoded><![CDATA[<p>If you count the data engines in your stack, you’ll find that full-text search runs in one system, while analytics runs in a warehouse. You’ll also see that metrics live in a time series database and logs are in an aggregator. And vector retrieval sits in its own dedicated vector store. That’s five engines for five shapes of the same operational data, and the licenses are the cheapest part.</p><p>We wrote about the architecture behind consolidating those shapes in <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage">Why Elasticsearch is becoming a columnar database</a>. This post is about the other side of the ledger. What does the split actually cost, and what does "search and analytics on one platform" mean in terms clear enough to hold up in a proof of concept?</p><h2>What running five data engines actually costs</h2><p>Anyone who has kept two sets of books for the same business knows where the hours go. Writing the second ledger is quick, but making the two agree is what takes the week.</p><p>Each engine needs its own ingest path, so the same events get parsed and shipped twice. That gives you two sets of failure modes and two backlogs to drain when a broker slows down. It also gives you drift: A field rename lands in one copy before the other, and for a while, the two systems disagree about the same hour of data. Reconciling that disagreement is real engineering work that rarely appears in the business case.</p><p>Each engine also brings a query language, and the syntax is the small part. The cost is everything written in that language, including dashboards, alert rules, saved queries, runbooks, and the operational knowledge of the person on call this week. Two languages means two of all of it, maintained in parallel.</p><p>Then there’s correlation. You find the failing request in the log aggregator. You move to the warehouse to chart how often it happened this week and then to the metrics store to check whether the host was saturated at the time. Each of those moves is a join performed by hand, by a person under time pressure, and every one of them adds minutes to the incident.</p><p>Retention compounds all of this. Each system gets its own lifecycle policy, so the cheap system ends up keeping data that the expensive one dropped weeks ago. When you finally need the history, it lives in the engine that cannot answer your question quickly.</p><h2>Why search and analytics were split across two systems</h2><p>The split was a reasonable response to a real constraint. Document engines and columnar engines were built to answer different questions. A <em>document engine</em> is good at finding the records that match a query and ranking them by relevance, and a <em>columnar engine</em> is good at reading three columns out of 50 and aggregating them across billions of rows. For years, running both was the reasonable answer, because no single system was credible at both jobs. The introduction of a full columnar engine in Elasticsearch 9.5 makes the split optional rather than necessary. For the full history and what changed, check out the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage">columnar post</a>.</p><h2>What one platform means in practice</h2><p>Three things have to be true before "one platform" means anything.</p><ol><li><p>The first is one <em>query language</em>. Elasticsearch Query Language (ES|QL) runs across logs, metrics, traces, security events, and documents, which means a single skill set and one set of dashboards. Because it’s one language rather than a federation of several, queries compose: A time series aggregation can sit in the same query as <code>LOOKUP JOIN</code> or <code>INLINE STATS</code>, which systems built around PromQL alone cannot do.</p></li><li><p>The second is one <em>storage substrate</em>. Doc values, the column store that has been inside Elasticsearch since 2013, is what every index mode reads and writes underneath. Various modes tune that substrate for different shapes of data. Elasticsearch's time series engine (TSDB) went fully columnar in 9.4, which is the clearest evidence so far that the approach works. Columnar Mode and Columnar Logs, both in technical preview in Elasticsearch 9.5, extend the same treatment to analytical and log-shaped data.</p></li><li><p>The third is one <em>operational story</em>. It’s the same cluster, with the same APIs, integrations, agents, access control, backup, and upgrade path that you already run.</p></li></ol><p>Several index modes share one column store, and one language queries all of them inside a single cluster. That’s a narrower claim than "one engine for everything," and it’s the one that holds up when someone tests it.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc2d95f952dcd1adb/6a79ad4acbb9ed60cd53e4ce/image1.png" alt="" /><h2>When is a specialized database still the right choice?</h2><p>A specialized system will always win a specialized benchmark. If your workload is one shape with a single query pattern, there’s a purpose-built engine that beats us on it, and we would rather say so than pretend otherwise. The argument for consolidation applies to data that spans more than one shape, which describes most production estates.</p><p>That said, being the general-purpose platform doesn’t mean settling for second place on every shape. We’ve climbed this curve once already. TSDB has stored metrics since Elasticsearch 8.7, and the early work concentrated on storage efficiency rather than on competing with dedicated metrics stores.</p><p>A run of releases from Elasticsearch 9.1 through 9.4 then turned it into a columnar metrics engine: OpenTelemetry (OTel) metrics now land at 3.75 bytes per data point, down from 25 a year earlier, which is 2.5x less storage than Prometheus and 2x less than ClickHouse. Gauge average and counter rate queries run up to 30x faster than Prometheus and Mimir, and on the high cardinality benchmark, Elasticsearch scans four hours of data across half a million time series in under two seconds, where the other systems needed more than 30 seconds. The full methodology and per-query results are in <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>.</p><p>Columnar Mode is at the start of that same curve. Technical preview lands in Elasticsearch 9.5 and general availability (GA) in 9.6, with phased improvements to storage, ingest, and query performance in the releases that follow. Metrics took a year of that work to get where they are, and we expect logs and analytical data to follow the same path rather than a shorter one.</p><h2>Which workloads should stay on the document modes</h2><p></p><p>Some workloads should stay exactly where they are. </p><ol><li><p>Search-first applications, like product catalogs and knowledge bases, where the answer is the 10 most relevant documents, are what the existing document modes do well, and none of those modes are deprecated. </p></li><li><p>Frequent individual document updates - these workloads suit the document modes, too. </p></li><li><p>Nested data models - The same is true of data models that genuinely depend on nested structure, because Columnar Mode flattens fields into key/value pairs and doesn’t support the nested field type. In Elasticsearch 9.5, <code>semantic_text</code> and <code>dense_vector</code> fields aren’t available in Columnar Mode; a columnar profile for vector retrieval comes later.</p></li></ol><p>Adoption is per index and opt-in. Existing indices continue to behave as they do today, and your APIs don’t change. Plus, your dashboards don’t break.</p><h2>Where Columnar Mode is today: Elasticsearch 9.5 preview, 9.6 GA</h2><p>Storage and performance numbers are coming in a separate technical deep dive, and the public roadmap issues for <a href="https://github.com/elastic/roadmap/issues/290">Columnar Mode</a> and <a href="https://github.com/elastic/roadmap/issues/291">Columnar Logs</a> are the right place to tell us what your workload needs.</p><p>If you want a real number for your own five-tool tax, start by counting two things: ingest pipelines carrying the same events to more than one destination, and dashboards that answer the same question in two different query languages. In most estates, the second number is the one that surprises people.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/data-platform-consolidation-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/data-platform-consolidation-elasticsearch</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Columnar]]></category>
    <dc:creator><![CDATA[Yannis Roussos,Bharath Aleti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4696c73e3407b15b/6a79ae477724f2098d0460a0/good_oone.png" length="0" type="image/png"/>
    <pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One ES|QL query instead of two: WHERE IN subquery replaces the copy-paste loop in Elasticsearch]]></title>
    <description><![CDATA[ES|QL's WHERE clause can filter by another Elasticsearch subquery's results instead of a static ID list you copied by hand, with nested subqueries, NOT IN and compound conditions built in.]]></description>
    <content:encoded><![CDATA[<p>The <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/where"><code>WHERE</code></a> clause can now filter by the results of another query. If you've been running one query to find suspicious users or failing services, copying the IDs, then pasting them into a second query, you can stop. One ES|QL statement does the whole job: the subquery builds the filter list from live data, and it stays current every time you run it. The feature ships as a technical preview in Elasticsearch 9.5 and supports nesting, <code>NOT IN</code> and compound <code>AND</code><code>/</code><code>OR</code> conditions.</p>  The <code>WHERE IN</code> subquery may change or be removed in a future release. Elastic will work to fix any issues, but technical preview features aren’t subject to the support Service Level Agreement (SLA) of official general availability (GA) features.<h2>Static ID lists vs. dynamic filtering with ES|QL's WHERE clause</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt112e1ace022eb1a1/6a75c6d0b966e18d6563ef05/image1.png" alt="Infographic comparing manual ID copying across queries vs. ES|QL WHERE IN subquery dynamic filtering in a single pipeline" /><p></p><p><strong>Static ID list filtering</strong></p><p><strong>Dynamic filtering with WHERE IN subquery</strong></p><p>Run query A to find the IDs you care about</p><p>Outer query asks the main question</p><p>Copy the values by hand</p><p>Subquery builds the filter list from live data</p><p>Paste them into a static <code>WHERE IN</code> list</p><p><code>WHERE</code> field <code>IN</code> (subquery) applies the filter live</p><p>Run query B with the hardcoded list</p><p>Results stay current with the data</p><p>Repeat the whole process when the data changes</p><p>No copied list to maintain</p><p><em>Figure 1. The old copy-paste loop collapses into a single dynamic filter.</em></p><h2>How the WHERE IN subquery replaces static filter lists</h2><p>Traditional <code>IN</code> filtering is still useful when the list is small and static:</p>FROM logs-*
| WHERE status_code IN (401, 403, 429)<p>But many real investigations don’t start with a tidy list. They start with a question: <em>Which users are suspicious</em>, <em>which hosts are noisy</em>, <em>which services are failing</em>, or <em>which accounts crossed a threshold?</em></p><p>That’s where the <code>WHERE IN</code> subquery becomes useful. The list is produced by ES|QL rather than typed by hand.</p><h2>WHERE IN subquery example: filtering logs by suspicious users</h2>FROM logs-*
| WHERE user.name IN (
    FROM auth-logs-*
    | WHERE event.action == "login_failed"
    | STATS failed_attempts = COUNT(*) BY user.name
    | WHERE failed_attempts &gt;= 10
    | KEEP user.name
  )<p>Read it like this: <em>Show me log events for users who appear in the list of users with at least 10 failed login attempts.</em></p><p>The outer query asks the main question, and the subquery builds the dynamic filter list, eliminating the need to copy and paste.</p><p>The subquery can target a different index or index pattern from the outer query.</p><h2>Filtering without subqueries: the manual ID copy workflow</h2><p>Imagine that you want to inspect traffic for the top failing services. First you run:</p>FROM service-logs-*
| WHERE status_code &gt;= 500
| STATS failures = COUNT(*) BY service.name
| SORT failures DESC
| LIMIT 5<p>Then you copy the five service names and paste them into another query:</p>FROM service-logs-*
| WHERE service.name IN ("checkout", "payments", "search", "profile", "orders")<p>That’s fine once, but less fine when the top five change every hour.</p><h2>Dynamic filtering with a WHERE IN subquery</h2>FROM service-logs-*
| WHERE service.name IN (
    FROM service-logs-*
    | WHERE status_code &gt;= 500
      AND @timestamp &gt;= now() - 2 days
    | STATS failures = COUNT(*) BY service.name
    | SORT failures DESC
    | LIMIT 5
    | KEEP service.name
  )
  AND status_code &gt;= 500
  AND @timestamp &gt;= now() - 2 days
| KEEP @timestamp, service.name, status_code, message<p>The subquery finds the top failing services from the last two days, and the outer query returns the log events for those services. One query builds the full picture.</p><h2>Excluding values with NOT IN subqueries in ES|QL</h2><p>Sometimes the interesting question is about what doesn’t belong:</p>FROM access-logs-*
| WHERE user.name NOT IN (
    FROM known-users
    | WHERE user.name IS NOT NULL
    | KEEP user.name
  )<p>That pattern is useful for exclusion checks, gap analysis, and workflows that ask for the things outside an approved or expected set.</p><h2>Nested subquery chains in ES|QL's WHERE clause</h2><p>An <code>IN</code> subquery replaces the literal value list with a query in parentheses. The inner query runs first and returns a single column, and the outer <code>WHERE</code> filters against it. Because that inner query is a full pipeline, it can contain its own <code>IN</code> subquery, which lets you express a chain of lookups that would otherwise require three separate queries and two rounds of copy-paste.</p>FROM orders
| WHERE customer_id IN (
    FROM customers
    | WHERE region_id IN (
        FROM regions
        | WHERE tier == "priority"
        | KEEP region_id
      )
    | KEEP customer_id
  )
| STATS revenue = SUM(amount) BY customer_id<p>Read it inside out. The innermost query finds priority regions, and the middle query finds customers in those regions, while the outer query sums revenue for those customers. Each layer is a normal ES|QL pipeline, so each one can filter, aggregate, or sort on its own before handing a clean column up to the layer above.</p><h2>Combining WHERE IN subqueries with AND and OR conditions</h2><p>Because an <code>IN</code> subquery is a Boolean condition, it composes with <code>AND</code> and <code>OR</code> like any other predicate. You can require membership in two independent sets or accept membership in either:</p>FROM orders
| WHERE customer_id IN (FROM vip_customers | KEEP customer_id)
  AND product_id IN (FROM discontinued_products | KEEP product_id)
| KEEP order_id, customer_id, product_id, amount<p>The <code>AND</code> combination finds orders placed by VIP customers for products that are being discontinued. Swap <code>AND</code> for <code>OR</code>, and you get orders that match either condition. Each subquery runs its own pipeline, so the two sets are computed independently and then combined by the Boolean operator.</p><h2>Merging multiple indices into one WHERE IN subquery</h2><p>The query inside an <code>IN</code> subquery is a full pipeline, so its <code>FROM</code> command can reference more than one subquery. Each branch runs its own pipeline, and the <code>FROM</code> command merges the rows from all branches into one result set. <code>KEEP host_id</code> selects the single column that the outer filter needs. This is useful when the values you want to filter against live in several indices with different schemas. For more details on how subqueries in the <code>FROM</code> command handle indices with different schemas, see <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from">Three indices walk into a FROM clause: ES|QL subqueries in Elasticsearch</a>.</p>FROM alerts
| WHERE host_id IN (
    FROM
      (FROM prod_hosts    | WHERE region == "us-east"),
      (FROM staging_hosts | WHERE region == "us-east"),
      (FROM edge_hosts    | WHERE region == "us-east")
    | KEEP host_id
  )
| STATS alert_count = COUNT(*) BY host_id<p>The <code>IN</code> subquery combines matching host IDs from three indices, prod, staging, and edge, into one value list. The outer query then counts alerts for any host in that combined set. Adding a fourth source means adding one more branch, with no change to the outer query.</p><h2>Using an Elasticsearch subquery inside each FROM branch</h2><p>A <code>FROM</code> subquery gives each index its own branch with its own <code>WHERE</code>, and that <code>WHERE</code> can use an <code>IN</code> subquery. This is how you apply the same dynamic filter across several indices that each have their own schema.</p>FROM
  (FROM orders  | WHERE customer_id IN (FROM vip_customers | KEEP customer_id)),
  (FROM refunds | WHERE customer_id IN (FROM vip_customers | KEEP customer_id))
| STATS total_events = COUNT(*) BY customer_id<p>Each branch filters its index down to VIP customers before the two branches combine, so the final aggregation runs over a single normalized set of rows.</p><h2>When to use ES|QL WHERE IN subqueries</h2><ul><li><p>Investigations that start by finding risky users, hosts, accounts, or services.</p></li><li><p>Operational dashboards where the interesting entities change over time.</p></li><li><p>Top-N follow-up queries, such as events for the five noisiest services.</p></li><li><p>Set comparison workflows, especially with <code>NOT IN</code>.</p></li><li><p>Queries that would otherwise need glue code just to pass values from one step to the next.</p></li></ul><h2>Requirements and constraints for WHERE IN subqueries</h2><ul><li><p>Return exactly one column from the <code>IN</code> subquery.</p></li><li><p>Use <code>KEEP</code> at the end of the subquery so the comparison field is obvious.</p></li><li><p>Make sure the outer field and the subquery field have compatible types.</p></li><li><p>If the subquery uses <code>SORT</code>, add an explicit <code>LIMIT</code>, as unbounded <code>SORT</code> isn’t supported in ES|QL yet.</p></li><li><p>Use this for membership filtering. If you need columns from both sides, a <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-lookup-join">JOIN`</a> may be the better tool.</p></li></ul><h2>Why ES|QL dynamic filtering replaces manual query workflows</h2><p>The <code>WHERE IN</code> subquery turns a manual workflow into a declarative one. You can let one query build the filter for another query directly inside the <code>WHERE</code> command, instead of asking ES|QL for a list, copying it somewhere else, and hoping it stays fresh. </p><p>Your <code>WHERE</code> clause now has a better way to handle <em>Filter this by whatever that query finds.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/dynamic-filtering-esql-where-in-subquery</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dynamic-filtering-esql-where-in-subquery</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Fang Xing]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8588803197e341c5/6a75c709f124644e306fd73e/good_oone.png" length="0" type="image/png"/>
    <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One query, three data sources: ES|QL subqueries get FROM, TS and ROW]]></title>
    <description><![CDATA[Filter application logs by live metric behavior and combine indexed data with inline test values. Your filter lists pull from time-series data on the fly, so nothing is hard-coded.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language (ES|QL)</a> subqueries now support three source commands in Elasticsearch 9.5: <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/from"><code>FROM</code></a> for indexed data, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> for time-series metrics, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/row"><code>ROW</code></a> for inline literal values. You can use them individually or combine all three in a single query, filtering log data by live metric behavior or mixing real and synthetic rows without any index setup.</p><p>If you've been exploring ES|QL, you might have noticed that a <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">subquery</a> used to feel like it had exactly one front door: <code>FROM</code>. And it made sense. Most of the time, queries start with a simple directive to go fetch documents from an index. In an earlier <a href="https://www.elastic.co/search-labs/blog/dynamic-filtering-esql-where-in-subquery">post</a>, we taught the <code>WHERE</code> command a new trick: <code>IN</code> subqueries. And before that, <a href="https://www.elastic.co/search-labs/blog/esql-subquery-from">subqueries showed up in the <code>FROM</code> command</a> to combine data sources. In both instances, every subquery started the same way, with <code>FROM</code>.</p><p>But not every useful query starts with a bulk document fetch. Sometimes you need to evaluate time-series semantics. Other times, you just need to whip up a tiny inline row for testing. Sometimes, the absolute best input to a filter is a dynamic query that builds the list for you on the fly, rather than a static, hard-coded list.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4453fe9ed8dad10b/6a75c5db7724f2073a045965/image3.png" alt="Diagram of three ES|QL subquery source commands with log filtering flow for FROM union and WHERE IN placements" /><p></p><p><strong>Source command</strong></p><p><strong>Reads from</strong></p><p><strong>Best for</strong></p><p><strong>Requirements</strong></p><p>FROM</p><p>Indexes, data streams, aliases, views</p><p>Stored document lookups, live filter lists</p><p>None (works with any index)</p><p>TS</p><p>Time-series data streams</p><p>Metric aggregations with counter-reset handling</p><p>TSDS with <code>index.mode: time_series</code></p><p>ROW</p><p>Inline literal values</p><p>Test cases, seed values, synthetic placeholders</p><p>None (no index needed)</p><p>The <code>FROM</code>, <code>TS</code> and <code>ROW</code> source commands are generally available (GA) in Elasticsearch 9.5, while the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-in-subquery"><code>WHERE IN</code> subquery</a> remains in technical preview in 9.5.</p><h2>How ES|QL subquery source commands work</h2><p>Think of subqueries as having two specific placements and three different engines.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5542afed68fff9e7/6a75c5f6cd87c322296bcb39/image1.png" alt="ES|QL subquery source commands grid showing FROM, ROW and TS examples in both FROM union and WHERE IN filter placements" /><p></p><h3>Where subqueries go: FROM and WHERE placements</h3><ul><li><p><strong>Inside the </strong><strong><code>FROM</code></strong><strong> command:</strong> The subquery is an independent result source, contributing its rows to the outer query. Fields that exist in one source but not the other are gracefully filled with null values.</p></li><li><p><strong>Inside the </strong><strong><code>WHERE</code></strong><strong> command:</strong> The <code>IN</code> subquery contributes dynamic values to be used as a predicate or filter.</p></li></ul><h3>Three source commands for starting a subquery</h3><h4>Door #1: FROM (the classic door)</h4><p><code>FROM</code> is the familiar workhorse. You use it when the subquery should read stored data from indices, data streams, aliases, or views. One of its best use cases is the "stop-copy-pasting-IDs" pattern. Instead of running one query, manually copying the output values, and pasting them into the filter of another query, the subquery becomes your live filter list.</p>FROM employees
| WHERE emp_no IN (FROM high_value_accounts
                   | KEEP emp_no
                  )
| KEEP emp_no, first_name, last_name<h4>Door #2: ROW (the tiny door)</h4><p><code>ROW</code> is the lightweight option that requires absolutely no index setup. It allows you to build rows completely out of literal, inline values. This makes <code>ROW</code> useful for small seed values, test cases, allow/deny lists, or one-off "what if?" scenarios. In the query below, <code>ROW</code> is the perfect way to staple a synthetic sentinel row or placeholder directly onto real data.</p>FROM
(FROM access_logs
   | WHERE status == 500
| KEEP cluster, status),
  (ROW cluster = "synthetic", status = 0)
| SORT status
| KEEP cluster, status<h4>Door #3: TS (the time-series door)</h4><p>The <code>TS</code> command targets time-series data streams and enables time-series aggregation functions. Why not just use <code>FROM</code> for metrics? <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> is uniquely optimized for time-series data, and it natively handles tricky scenarios, like counter resets on process restarts and uneven metric publish intervals. Using TS as a subquery lets you filter your application logs by what your metrics are saying. For example, imagine asking ES|QL: <em>Show me log events only from clusters whose peak metric throughput crossed 800</em>:</p>FROM access_logs
| WHERE cluster IN (TS k8s_metrics
                    | STATS peak = MAX(bytes_in) BY cluster
                    | WHERE peak &gt; 800
| KEEP cluster
                   )
| SORT cluster, path
| KEEP cluster, status, path<p>In this scenario, the filter is reacting to live metric behavior rather than being hard-coded.</p><h2>Combining FROM, TS and ROW in one query</h2><p>Because subquery placements and source commands are independent, you can freely mix and match them. You can throw all three doors into a single <code>FROM</code> union to generate a cohesive table containing real logs, a live metrics summary, and a synthetic row.</p>FROM
(FROM access_logs
   | KEEP cluster, status),
  (TS k8s_metrics
   | STATS peak = MAX(bytes_in) BY cluster),
  (ROW cluster = "synthetic")
| STATS log_events = COUNT(status), peak = MAX(peak) BY cluster
| SORT cluster
| KEEP cluster, log_events, peak<h2>ES|QL subquery constraints</h2><p>A few constraints to keep in mind before using subqueries:</p><ul><li><p><strong><code>IN</code></strong><strong> subqueries demand one column:</strong> If a subquery feeds an <code>IN</code> operator, it must project exactly one column. Use the <code>KEEP</code> command to make that explicitly clear.</p></li><li><p><strong><code>TS</code></strong><strong> requires a time series data stream (TSDS):</strong> The <code>TS</code> command only works on data stored in a <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">TSDS</a>, which uses <code>index.mode: time_series</code>.</p></li></ul><h2>The takeaway: when to use each source command</h2><p>Subqueries in ES|QL provide a structured way to compose queries by using the results of one query as the input to another. Choosing the appropriate source command (<code>FROM</code>, <code>ROW</code>, or <code>TS</code>) lets you combine data and generate inline values. It also lets you filter dynamically without duplicating query logic. For more details and additional examples, see the ES|QL subqueries documentation.</p><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery">Subquery in FROM command</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-in-subquery">Subquery in WHERE command</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-subquery-source-commands</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-subquery-source-commands</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Fang Xing]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71cfd2ab01e3262e/6a75c5beaec90746295fd3b7/image2.png" length="0" type="image/png"/>
    <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From search to checkout in 20 lines of code: building a 4-stage conversion funnel with OpenTelemetry]]></title>
    <description><![CDATA[Add cart and purchase tracking to your search analytics pipeline and use ES|QL to answer the question every product manager asks: which search queries drive the most revenue?]]></description>
    <content:encoded><![CDATA[<p>Your product manager wants to know which searches drive the most revenue. You can tell them what users search for (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">check out our second blog</a>) and what they click (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">in our third blog</a>), but not what they buy. Two new span types, add-to-cart and purchase, complete a four-stage funnel from search query to checkout, built on the same search.* attributes and ES|QL queries you've been using since Blog 2. About 20 lines of code, and every relevance decision you make gets a revenue number attached to it.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Add conversion tracking (add-to-cart and purchase spans) with <code>search.*</code> attributes that tie back to the originating search.</p></li><li><p>Build a full search-to-revenue funnel: search → click → add-to-cart → purchase.</p></li><li><p>Write Elasticsearch Query Language (ES|QL) queries to calculate conversion rates, revenue per query, and average order value.</p></li><li><p>Identify where users drop off and which team should own each drop-off point.</p></li><li><p>Attribute revenue to specific search queries for prioritizing relevance work.</p></li></ul><h2>What you'll need</h2><ul><li><p>Click tracking from <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> (search + click spans flowing to Elastic via OTel-native ingestion).</p></li><li><p>Backend endpoints for add-to-cart and checkout events (example code provided).</p></li><li><p>Basic understanding of ecommerce conversion funnels.</p></li></ul><h2>Why search revenue attribution matters</h2><p>Your product manager walks into a meeting and asks, "Which searches are driving the most revenue?"</p><p>You can tell them what users search for (<a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blog 2</a>) and what they click (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). But you can't tell them what they <em>buy</em>. The gap between "clicked a result" and "purchased a product" is where the business case for search investment lives, and right now it's invisible.</p><p>This post closes the loop. By adding two more span types (add-to-cart and purchase), you get a full funnel from search to revenue, built on the same <code>search.*</code> attributes and ES|QL queries you've been using since <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blog 2</a>.</p><h3>What search conversion tracking answers for your team</h3><p>Here's what conversion tracking lets you answer and why each question matters to different people on your team.</p><ul><li><p><strong>Which searches drive revenue?</strong> This is the product manager's question. When you can attribute dollars to specific queries, you can prioritize relevance work by business impact. A query with mediocre click-through rate (CTR) but high conversion value is more important than a high-CTR query that never leads to a purchase.</p></li></ul><ul><li><p><strong>Where do users drop off?</strong> The funnel from search to purchase has four stages: search, click, add-to-cart, and purchase. Each drop-off points to a different problem. High click-to-cart drop-off suggests that product pages aren't convincing. High cart-to-purchase drop-off is checkout friction rather than a search problem. Knowing <em>where</em> users abandon tells you <em>which team</em> should fix it.</p></li></ul><ul><li><p><strong>Which queries to protect?</strong> Once you know that "laptop bag" generates $12,000/month in attributed revenue, you treat it differently. Any relevance change that touches high-revenue queries gets extra scrutiny. You can set up monitoring (Blog 6, coming soon) to alert when conversion rates drop for your top-earning searches.</p></li></ul><p>Two new instrumentation points total about 20 lines of code, and they follow the same pattern as you’ve used before. You add attributes to spans, and query them with ES|QL.</p><p><strong>Following along with code?</strong> The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has conversion tracking ready to enable. Uncomment the Blog 4 sections in <code>app.py</code> and <code>app.js</code>, restart, and then generate traffic with <code>python generate_traffic.py --blog 4</code>.</p><h2>The four-stage search conversion funnel</h2><p>Before we write any code, here's the shape of what we're building. Each stage is an instrumentation point, and each creates spans in <code>traces-generic.otel-default</code>:</p>search          →  click              →  cart.add         →  checkout.complete
(Blog 2)           (Blog 3)              (this post)         (this post)
query_id=abc       query_id=abc          query_id=abc        query_id=abc
user_query=...     click_position=1      product_id=...      order_total=$149
result_count=15    product_id=...        quantity=1           item_count=2<p>The thread running through the entire chain is <code>search.query_id</code><code>.</code> The same identifier you derived from the trace ID in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> and used to link clicks to searches in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> now carries through to add-to-cart and purchase events. This is what makes revenue attribution possible: You can trace a purchase back to the search that started the journey.</p><h2>Add conversion tracking spans with OpenTelemetry</h2><h3>Add-to-cart span instrumentation</h3><p>When a user adds a product to their cart from a search results page (or from a product detail page they reached via search), you create a <code>cart.add</code> span. This captures the moment that intent turns into action.</p>@app.post("/api/cart/add")
async def add_to_cart(event: AddToCartRequest):  # reference project uses CartEvent
    with tracer.start_as_current_span("cart.add") as span:
        span.set_attribute("search.action", "add_to_cart")
        span.set_attribute("search.result_click_id", event.object_id)
        span.set_attribute("search.result_click_position", event.position)
        span.set_attribute("search.query_id", event.query_id)
        span.set_attribute("enduser.pseudo.id", event.client_id)
        span.set_attribute("cart.quantity", event.quantity)
        if event.price is not None:
            span.set_attribute("cart.price", event.price)
        if event.user_query:
            span.set_attribute("search.query", event.user_query)<p>This follows the same pattern as click tracking in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>; that is, an independent span linked to the originating search via <code>query_id</code>. The new attributes are <code>cart.quantity</code> and <code>cart.price</code> which let you aggregate revenue at query time.</p><h3>Purchase span instrumentation</h3><p>When the user completes checkout, you create a <code>checkout.complete</code> span. This is the revenue event, the one that answers the product manager's question.</p>@app.post("/api/checkout")
async def checkout(event: CheckoutRequest):  # reference project uses CheckoutEvent
    with tracer.start_as_current_span("checkout.complete") as span:
        span.set_attribute("search.action", "purchase")
        span.set_attribute("checkout.order_id", event.order_id)
        span.set_attribute("checkout.total_amount", event.total_amount)
        span.set_attribute("checkout.item_count", len(event.items))
        span.set_attribute("enduser.pseudo.id", event.client_id)
        if event.query_id:  # last search in journey
            span.set_attribute("search.query_id", event.query_id)
            span.set_attribute("search.query", event.user_query)<p></p><ul><li><p><strong>Spans capture errors automatically.</strong> This is a side benefit of using OTel spans for conversion events. If an add-to-cart or checkout call throws an unhandled exception, the span's status is automatically set to <code>ERROR</code> and the exception details are recorded. These errors are business-critical (a broken checkout flow means lost revenue), and they show up immediately in Elastic APM's error tracking, service maps, and alerting. You get conversion analytics <em>and</em> operational monitoring from the same instrumentation, with no extra code.</p></li></ul><ul><li><p><strong><code>checkout.total_amount</code></strong><strong> is the revenue number.</strong> This is what you'll aggregate in ES|QL to get revenue-by-query. It represents the order total, not the price of a single item.</p></li></ul><ul><li><p><strong><code>search.query_id</code></strong><strong> is conditional.</strong> Not every purchase originates from search. Users browse categories and follow promotional links. Then they return to their cart days later. The <code>if event.query_id:</code> guard ensures that you only attribute purchases to search when there's a genuine connection. Purchases without a <code>query_id</code> still get recorded; they just don't appear in search attribution queries.</p></li></ul><ul><li><p><strong><code>search.query</code></strong><strong> is set on both cart and purchase spans.</strong> This is a deliberate denormalization. You could join back to searches via <code>query_id</code> to get the query text, but while ES|QL does support <code>LOOKUP JOIN</code>, it’s likely not the right choice here due to the need to optimize the lookup index. By putting the query text directly on conversion spans, your revenue-by-query and cart-by-query queries are single, straightforward aggregations.</p></li></ul><h3>Span attributes for cart and purchase events</h3><p><strong>Add-to-cart attributes:</strong></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p><code>"add_to_cart"</code></p><p><code>search.result_click_id</code></p><p>string</p><p>yes</p><p>Product document ID</p><p><code>search.result_click_position</code></p><p>int</p><p>yes</p><p>Position in results when added</p><p><code>search.query_id</code></p><p>string</p><p>yes</p><p>Links to originating search</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier (OTel Semantic Conventions  [SemConv])</p><p><code>cart.quantity</code></p><p>int</p><p>yes</p><p>Quantity added</p><p><code>cart.price</code></p><p>float</p><p>optional</p><p>Product price</p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for cart-by-query analysis)</p><p><strong>Purchase attributes:</strong></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p><code>"purchase"</code></p><p><code>checkout.order_id</code></p><p>string</p><p>yes</p><p>Unique order identifier</p><p><code>checkout.total_amount</code></p><p>float</p><p>yes</p><p>Order total</p><p><code>checkout.item_count</code></p><p>int</p><p>yes</p><p>Number of items purchased</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier (OTel SemConv)</p><p><code>search.query_id</code></p><p>string</p><p>recommended</p><p>Links to originating search (last search in journey)</p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for revenue-by-query analysis)</p><p>These follow the same <code>search.*</code> namespace from Blogs 2 and 3, with new <code>cart.*</code> and <code>checkout.*</code> prefixes for conversion-specific data. With OTel-native ingestion, all attributes are stored under <code>attributes.*</code> with dot notation preserved. That means no more mapping strings to <code>labels.*</code> and numbers to <code>numeric_labels.*</code>. A <code>search.query</code> attribute is queryable as <code>attributes.search.query</code>. Likewise, <code>checkout.total_amount</code> is queryable as <code>attributes.checkout.total_amount</code>.</p><h3>User identity: connecting search to purchase across sessions</h3><p>You'll notice that <code>enduser.pseudo.id</code> appears on every span type in this series. It's the <em>minimum identity level</em>; that is, a persistent identifier stored in the browser's localStorage that ties events to a device across sessions.</p><p>For conversion tracking, identity becomes more important. You need to connect a search on Monday to a purchase on Tuesday, or correlate cart additions across tabs. Our schema supports three identity levels, aligned with OTel semantic conventions:</p><p><strong>Attribute</strong></p><p><strong>Persistence</strong></p><p><strong>Purpose</strong></p><p><code>enduser.pseudo.id</code></p><p>Permanent (localStorage)</p><p>Device/browser identifier (OTel SemConv)</p><p><code>session.id</code></p><p>Per-visit (sessionStorage)</p><p>Groups events within a single visit (OTel SemConv)</p><p><code>user.id</code></p><p>Account (auth system)</p><p>Authenticated user (OTel SemConv)</p><p>For the funnel queries in this post, <code>enduser.pseudo.id</code> is sufficient. It links the journey from search to purchase within a browser. If your users authenticate, adding <code>user.id</code> enables cross-device attribution (searched on mobile, purchased on desktop) and richer personalization. <code>session.id</code> helps disambiguate when the same client has multiple active sessions.</p><p>All three are optional on interaction spans. Start with <code>enduser.pseudo.id</code>, and add the others when your use case requires them. The important thing is consistency: Use the same identifiers across search, click, and conversion spans so the joins work.</p><h3>Frontend integration</h3><p>The front end needs to propagate <code>query_id</code> through the user journey. When the user clicks a search result, you already have <code>query_id</code> from the search response (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). The key is carrying it forward.</p>// CLIENT_ID: persistent browser identifier from localStorage (set up in Blog 3)
// const CLIENT_ID = localStorage.getItem("search_client_id") || ...

// On add-to-cart from a search result page
fetch('/api/cart/add', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    object_id: product.id,
    position: product.resultPosition,  // from search results
    query_id: product.queryId,         // from search response
    client_id: CLIENT_ID,              // persistent browser identifier → enduser.pseudo.id
    quantity: 1,
    price: product.price,
  })
});

// On checkout completion
fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    order_id: order.id,
    total_amount: order.total,
    items: order.items,
    client_id: CLIENT_ID,              // same identifier as search and click spans
    query_id: lastSearchQueryId,       // last search in session
    user_query: lastSearchQuery,
  })
});<p>The front end sends <code>client_id</code> as an HTTP field name; and the back end maps it to the OTel semantic convention <code>enduser.pseudo.id</code> when setting span attributes. The <code>query_id</code> propagation is the critical piece. Store it alongside the product in the cart data structure so it survives navigation between pages. We'll discuss the design challenges of this in the attribution section below.</p><p></p><p><strong>Using the reference project?</strong> The reference app's <code>frontend/app.js</code> wires up add-to-cart buttons, but the checkout flow isn’t implemented in the browser UI. It goes through the traffic generator. To simulate conversion events, run: <code>python generate_traffic.py --blog 4 --sessions 100</code>. This sends a realistic mix of searches, clicks, cart additions, and purchases to all three backend endpoints.</p><h3>Verify that conversion events are arriving</h3><p>Before building funnel queries, confirm that both span types are flowing to Elastic:</p><p>Add-to-cart events:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "add_to_cart"
| KEEP attributes.search.result_click_id, attributes.search.query_id,
       attributes.cart.quantity
| LIMIT 5<p>Purchase events:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
| KEEP attributes.checkout.order_id, attributes.checkout.total_amount,
       attributes.checkout.item_count, attributes.search.query_id,
       attributes.search.query
| LIMIT 5<p>If these return rows, you have the full funnel instrumented. If not, check the same things as always: OpenTelemetry Protocol (OTLP) endpoint, auth token, and span export.</p><h2>Funnel analysis with ES|QL</h2><h3>Count search, click, cart and purchase events in a single query</h3><p>You can count all four funnel stages in a single ES|QL query using the same <code>COUNT(CASE(...))</code>pattern from the CTR query in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>:</p>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true
    OR attributes.search.action IN ("add_to_cart", "purchase")
| STATS
    searches  = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked   = COUNT(CASE(attributes.search.first_click == true, 1)),
    carts     = COUNT(CASE(attributes.search.action == "add_to_cart", 1)),
    purchases = COUNT(CASE(attributes.search.action == "purchase", 1))
| EVAL
    click_rate    = ROUND(100.0 * clicked   / searches, 1),
    cart_rate     = ROUND(100.0 * carts     / searches, 1),
    purchase_rate = ROUND(100.0 * purchases / searches, 1)<p><strong>Example output:</strong></p><p><strong>searches</strong></p><p><strong>clicked</strong></p><p><strong>carts</strong></p><p><strong>purchases</strong></p><p><strong>click_rate</strong></p><p><strong>cart_rate</strong></p><p><strong>purchase_rate</strong></p><p>146</p><p>41</p><p>28</p><p>12</p><p>28.1%</p><p>19.2%</p><p>8.2%</p><p>You get all four counts and three conversion rates from a single query. The <code>WHERE</code> clause pulls all four span types into one result set, and <code>COUNT(CASE(...))</code> counts each type separately. This is the same technique that made the CTR query in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> so clean.</p><p>Notice that we use <code>search.first_click</code>for the click stage rather than counting all click events. This gives you the number of searches that received at least one click (the same definition used for CTR in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). Without this, a search with three clicks would inflate the click count to three while only counting as one search, making the funnel numbers misleading. Each stage now represents a unique progression: how many searches happened, how many of those got clicked, how many led to a cart addition, and how many resulted in a purchase.</p><p>You can turn this into a funnel visualization using Kibana Lens. Run each stage count as a separate ES|QL query, save them as dashboard panels, and arrange them as a horizontal bar chart with the four stages on the y-axis and counts on the x-axis. The drop-off at each step becomes immediately visible.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc9d695520cd0aff/6a7458d85751aa8c3e7e0a4f/image2.png" alt="Kibana dashboard showing search analytics: conversion funnel, revenue by query, click-through rate and latency SLOs" /><p>The dashboard above (from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first blog in the series</a>) shows this in practice: The <strong>Conversion Funnel</strong> panel in the lower left uses a horizontal bar chart to visualize the four stages, and the <strong>Top Queries by Revenue</strong> table alongside it shows revenue attribution. You can build these directly from the ES|QL queries in this post.</p><h3>What drop-off rates tell you and who owns each bottleneck</h3><p>Each transition in the funnel tells you something specific:</p><ul><li><p><strong>Search to click (CTR):</strong> You measured this in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>. Low CTR means that results aren't compelling. This is a relevance problem.</p></li></ul><ul><li><p><strong>Click to cart:</strong> The user engaged with a result but didn't add it to their cart. This could mean that the product page isn't persuasive or the price isn't competitive. It could also mean that the item was out of stock. It's often <em>not</em> a search problem. It’s possible that the search worked (the user clicked), but something downstream lost them.</p></li></ul><ul><li><p><strong>Cart to purchase:</strong> The user committed to buying but didn't complete checkout. Complicated forms, unexpected shipping costs, and payment issues cause this <em>checkout friction</em>. This is almost never a search problem, but it's useful to know where the funnel leaks so you don't waste time optimizing relevance when checkout is the bottleneck.</p></li></ul><p>The diagnostic pattern is straightforward:</p><p><strong>Drop-off point</strong></p><p><strong>Likely cause</strong></p><p><strong>Who owns it</strong></p><p>Search to click</p><p>Relevance / ranking</p><p>Search team</p><p>Click to cart</p><p>Product page / pricing / availability</p><p>Product / merchandising</p><p>Cart to purchase</p><p>Checkout UX / payment / shipping</p><p>Checkout / growth</p><p>This is one of the most valuable things that full-funnel data gives you: the ability to point at the right problem. When the VP asks, "Why aren't searches converting?", you can show whether the bottleneck is relevance, product pages, or checkout.</p><h2>Revenue attribution by search query</h2><p>Here's the query your product manager actually wants:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchase_count = COUNT(*),
    total_revenue = SUM(attributes.checkout.total_amount)
  BY attributes.search.query
| SORT total_revenue DESC<p>This gives you a ranked list of queries by the revenue they generated. The <code>search.query</code> attribute on purchase spans (the denormalization we discussed earlier) makes this a single aggregation query, without joins or subqueries.</p><h3>How to act on search revenue  data</h3><ul><li><p><strong>Protect high-revenue queries.</strong> If "laptop bag" generates the most revenue, any relevance change that affects that query gets extra scrutiny. You might add it to a regression test suite or pin specific results with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/searching-with-query-rules">query rules</a>. You might even set up an alert when its conversion rate drops (Blog 6).</p></li></ul><ul><li><p><strong>Prioritize relevance investment.</strong> The queries at the top of this list are where relevance improvements have the most business impact. A 10% CTR improvement on a query that generates $500/month in revenue is worth more than a 50% improvement on one that generates $20.</p></li></ul><ul><li><p><strong>Identify missed opportunities.</strong> Cross-reference with the top-queries data from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>. A query with high search volume but no purchase attribution is either a browsing query (informational intent) or a conversion gap worth investigating.</p></li></ul><h3>Top revenue queries: where to focus relevance investment</h3><p>To focus your relevance team's efforts, pull the top revenue-generating queries:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchases = COUNT(*),
    revenue = SUM(attributes.checkout.total_amount)
  BY attributes.search.query
| SORT revenue DESC
| LIMIT 10<p>Cross-reference this with the per-query CTR from Blog 3. A query with high revenue but low CTR is underperforming; even small relevance improvements have outsized business impact. A query with high CTR but no purchase attribution might be informational (such as users browsing but not buying). The queries at the top of <em>both</em> lists deserve the most attention from your relevance team.</p><h3>Average order value by search query</h3><p>Average order value (AOV) tells you how much purchases are worth, in addition to which queries convert to those purchases. Queries with high AOV are your premium-intent searches; ranking improvements there have the biggest per-purchase impact:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchase_count = COUNT(*),
    total_revenue = SUM(attributes.checkout.total_amount),
    avg_order_value = ROUND(AVG(attributes.checkout.total_amount), 2)
  BY attributes.search.query
| SORT avg_order_value DESC
| LIMIT 10<p>A query with high AOV but low volume is a different opportunity than high volume + low AOV. The first means premium intent from a small audience (consider featured results or dedicated landing pages); the second means broad reach with budget buyers (price sensitivity may be limiting conversion more than relevance).</p><h2>Search revenue attribution: limitations and workarounds</h2><p>Revenue attribution from search is valuable, but it's imperfect. Understanding the limitations helps you set appropriate expectations and design around them.</p><h3>Last-touch attribution in multi-search journeys</h3><p>Users rarely search once and buy. A typical journey might look like:</p><ol><li><p>Search "laptop bag": Browse results and click a few.</p></li><li><p>Search "laptop bag leather": Refine the search.</p></li><li><p>Search "laptop sleeve 15 inch": Try a different angle.</p></li><li><p>Add to cart from the third search's results.</p></li><li><p>Purchase.</p></li></ol><p>With the instrumentation above, this purchase attributes to the third search, the one whose <code>query_id</code> was on the cart item. The first two searches contributed to the journey but get no credit.</p><p>This is <em>last-touch attribution</em>, and it's the simplest model that works within a single <code>query_id</code> linkage. It's not perfect, but it's concrete and unambiguous. The alternative, that is,tracking every <code>query_id</code> in a user's session and distributing credit, adds significant complexity to both instrumentation and analysis.</p><p>For most teams, last-touch is a good starting point. If you need multi-touch attribution later, the raw data is there. You can query all searches and clicks for a given <code>client_id</code> within a time window and reconstruct the full journey:</p>FROM traces-generic.otel-default
| WHERE attributes.enduser.pseudo.id == "client-abc-123"
  AND (name == "search"
    OR attributes.search.action == "click"
    OR attributes.search.action == "add_to_cart"
    OR attributes.search.action == "purchase")
| KEEP @timestamp, name, attributes.search.action,
       attributes.search.query, attributes.search.query_id,
       attributes.search.result_click_id
| SORT @timestamp ASC<p>This reconstructs a user's full search journey in chronological order. It's useful for debugging individual sessions, even if you don't build automated multi-touch attribution.</p><h3>Cross-session attribution limits with query_id</h3><p>A user searches for "wireless headphones" on Monday, clicks a few results, leaves, and comes back on Wednesday to buy. The <code>query_id</code> from Monday's search is long gone, since it was a property of that specific search request.</p><p>This is a fundamental limitation of <code>query_id</code>-based attribution. It works within a session (or more precisely, within the scope where the front end retains the <code>query_id</code>). It doesn't work across sessions.</p><p>For cross-session attribution, you'd need a different approach, typically a user-level event store where you associate product views, cart additions, and purchases with a persistent user ID and then look back in time to find the originating search. That's a more complex analytics pipeline and is outside the scope of what we're building here.</p><p>The practical impact is that your search-attributed revenue will be an <em>undercount</em>. Some purchases that were genuinely influenced by search won't carry a <code>query_id</code>. This is fine for relative comparisons (such as, <em>Which queries generate </em>more<em> revenue than others?</em>), even if the absolute numbers are conservative.</p><h3>How to persist query_id from search to checkout</h3><p>A few practical decisions affect how far your <code>query_id</code> propagation reaches:</p><ul><li><p><strong>Store </strong><strong><code>query_id</code></strong><strong> in the cart.</strong> When a user adds a product to their cart, persist the <code>query_id</code> alongside the item. This way, even if the user navigates away and comes back to checkout later (within the same session), the attribution survives.</p></li></ul><ul><li><p><strong>Don't overwrite </strong><strong><code>query_id</code></strong><strong> on re-search.</strong> If a user adds a product from search A and then searches again and adds another product from search B, each cart item should keep its own <code>query_id</code>. The purchase event carries the last search's <code>query_id</code> as a summary, but per-item attribution gives you richer data.</p></li></ul><ul><li><p><strong>Accept the limitations.</strong> Not every purchase will have search attribution. Direct navigation, category browsing, promotional links, and returning customers who go straight to their cart will all produce purchases without a <code>query_id</code>. That's correct behavior, not missing data.</p></li></ul><h3>Spans vs. log events for conversion tracking</h3><p>Conversion events can emit both an OTel span and a UBI-compatible log event, the same dual-signal pattern used for click tracking in Blog 3. The log event is actually richer for conversions. It can contain the full items list with per-item <code>query_id</code> attribution, which doesn't map cleanly to flat span attributes.</p><p>The span gives you the simple, aggregatable view (total revenue by query), and the log gives you the detailed, per-item view (which specific products from which specific searches). For the funnel queries in this post, spans are sufficient. If you need item-level attribution analysis, the log events in <code>logs-generic.otel-default</code> have the detail you need, and ES|QL queries them the same way, just against a different index pattern.</p><h2>What's next: turning conversion data into relevance improvements</h2><p>We now have the complete instrumentation picture, with four span types: <code>search</code> (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>), <code>search.result.click</code> (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>), <code>cart.add</code>, and <code>checkout.complete</code> (this post). These capture the full user journey from query to purchase. Every span lives in <code>traces-generic.otel-default</code>, and every metric is queryable with ES|QL. The <code>search.query_id</code> thread ties the entire funnel together.</p><p>But measuring the funnel is only half the story. The real payoff is using this data to make search better.</p><p>In a later blog in this series, we take everything we've built and turn it into relevance improvements. Click positions and conversion data become judgment lists for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a>, and per-query CTR and revenue become rank features for boosting. Plus, high-revenue queries get protective monitoring. And tools like <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> give you a visual interface for tuning the searches that matter most, using exactly the data you're now collecting.</p><p>The instrumentation you've built in Blogs 2–4 is a feedback loop, beyond analytics: Measure, improve, and measure again.</p><h2>Get started with search conversion tracking</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry for Python:</a> EDOT for Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic:</a> How to send OTel data to Elastic APM.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation:</a> Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI Standard:</a> Reference schema for search event structure.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-rules.html">Query rules:</a> Pin, boost, or exclude results for specific queries.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9492cc64bcc1d0dc/6a74587ff71efd999ea43f5e/image1.png" length="0" type="image/png"/>
    <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[The mystery stress your heap chart can't see: AutoOps now watches vector off-heap memory]]></title>
    <description><![CDATA[Dense vectors use off-heap memory your heap chart never shows. AutoOps detects memory pressure before vector RAM stress causes OOM.]]></description>
    <content:encoded><![CDATA[<p>AutoOps now raises a <strong>Vector memory pressure</strong> insight when dense vector off-heap footprint, heap heat, and operational stress converge on the same Elasticsearch node. We validated on a 4 GiB node under sustained k-nearest neighbor (kNN) ingest: The insight fired at ~75% heap with thread-pool stress, roughly an hour before saturation. Heap charts alone still looked moderate at that point. Dense vectors for kNN live outside the Java heap, so heap monitoring and circuit breakers never show the full vector RAM picture. Below, we walk through what the insight measures and why heap on its own misses this. We also discuss what to do when it fires.</p><h2>Why dense vectors create off-heap memory pressure that heap charts miss</h2><p>Semantic search and kNN rely on <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code> fields</a>. Elasticsearch stores much of that data in off-heap memory. It’s related to how the Java Virtual Machine (JVM) operates, but it isn’t the same thing as heap usage.</p><p>In production, the heap versus off-heap split shows up in a familiar pattern:</p><ul><li><p>Heap looks fine for weeks, while the dense vector off-heap footprint quietly grows.</p></li><li><p>Heap circuit breakers stay quiet or only spike late because the pressure sits outside the JVM.</p></li><li><p>kNN search and bulk ingest slow down, queues build, and nothing on the dashboard points at vector RAM as the cause.</p></li></ul><p>Heap limits protect Java allocations. They don’t tell you whether vector off-heap footprint still fits the RAM envelope that your deployment actually runs in. AutoOps already watches cluster health broadly; Vector memory pressure adds a focused read for vector-heavy nodes when memory and load signals line up.</p><h2>How AutoOps measures vector RAM, heap, and headroom</h2><p>AutoOps works from the same node stats metrics you already use for Stack Monitoring. For each node, it tracks three derived numbers:</p><p><strong>Symbol</strong></p><p><strong>Meaning</strong></p><p><strong>Source (typical)</strong></p><p><strong>Chart (see below)</strong></p><p><strong>V</strong></p><p>Vector off-heap footprint</p><p><code>indices.dense_vector.off_heap.total_size_bytes</code></p><p>First, green line</p><p><strong>A</strong></p><p>Available RAM in the product view</p><p>Delta between <code>os.mem.total_in_bytes</code> and <code>os.mem.used_in_bytes</code></p><p>Second, green line</p><p><strong>H</strong></p><p>Headroom</p><p><strong>A − V</strong> (headroom_bytes)</p><p>First, blue line</p><p>H &gt; 0 means there’s a modeled runway: Vector use still fits comfortably in that accounting. H ≤ 0 means that you’re in a <em>compression</em> regime: Vector footprint (V) meets or exceeds the free RAM (A) picture that AutoOps can align in telemetry. On small tiers, that can be common under load. The insight emphasizes trends, growth in vector off-heap footprint, and corroborating stress, not a single negative snapshot.</p><p>AutoOps also tracks a compression regime flag (fraction of recent samples where H ≤ 0), so brief flickers don’t dominate the story (see third chart below):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt437840e9160da903/6a73173af9a79dca2a61f13e/image4.png" alt="AutoOps charts showing vector off-heap footprint growth, shrinking RAM headroom, and compression regime over 48 hours" /><h2>How vector memory pressure detection works: Expansion and compression</h2><p>Vector memory pressure is a single HIGH severity AutoOps event, which adapts to the compression regime:</p><ul><li><p><strong>Expansion (</strong><strong>H &gt; 0</strong><strong>):</strong> Emphasize shrinking headroom, hence a growing vector off-heap footprint.</p></li><li><p><strong>Compression (</strong><strong>H ≤ 0</strong><strong>):</strong> Emphasize ΔV, heap context, I/O, and latency. "Hours until H hits zero" isn’t the main narrative when headroom is already gone.</p></li></ul><p>The detector requires three layers before it fires:</p><ol><li><p><strong>Memory carriers:</strong> Compression regime, shrinking headroom, or sustained growth in vector off-heap footprint.</p></li><li><p><strong>Operational latch:</strong> Search or indexing latency versus rolling baselines, filesystem read stress (paired with latency or heap), indexing throttle, thread-pool queues or rejections, segment creep, or heap circuit breakers when paired with other stress, as circuit breakers alone don’t provide enough evidence to be escalated without corroborating stress.</p></li><li><p><strong>Heap hot:</strong> Heap usage elevated versus a 24-hour rolling median on that node, so compression alone on a calm heap doesn’t fire the insight.</p></li></ol><p>That pairing is intentional. Vector pressure without load might be capacity planning, and load without vector pressure might be a different root cause. Together, vector memory, operational stress, and heap heat surface the vector RAM story when the node is actually in trouble, not on every compressed mapping while the heap stays normal.</p><h2>Validation: Memory pressure detection on a 4 GiB node under kNN load</h2><p>We stress-tested vector memory pressure detection on 4 GiB Elastic Cloud Hosted deployments with throttled dense-vector ingest (~2,000 docs per minute) and steady kNN search (~8 queries per second). Across <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector">Hierarchical Navigable Small World (HNSW)</a>, <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/bbq#bbq-hnsw">Better Binary Quantization (BBQ) HNSW</a>, and <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/bbq#bbq-disk">DiskBBQ</a> mapping profiles over 24–48 hours:</p><ul><li><p>Vector off-heap footprint grew from near zero to about 6 GiB on the tightest runs (more than 4.7 million vectors indexed) in both HNSW test runs.</p></li><li><p>Nodes spent most of each run in compression (H ≤ 0), which is expected when vector footprint exceeds total RAM in this model.</p></li><li><p>Vector memory pressure stayed off while heap held near 50%, even with compression and pool stress building.</p></li><li><p>On both HNSW and BBQ HNSW, the insight fired once heap climbed past ~75% with memory compression and thread-pool queue stress, roughly an hour before heap neared saturation. Node out of memory (OOM) and circuit breakers followed in the same window, as did slow search/indexing. As we can see on the dashboard below, performance drops drastically due to corroborating stress toward the end of the test run:</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8a99d9296a444dbb/6a73176d0da67336ea57999d/image2.png" alt="HNSW validation dashboard showing heap climbing past 75% with search and indexing performance degradation" /><ul><li>On DiskBBQ, where compression was chronic but heap stayed normal, the insight didn’t fire,since storage rather than memory was the limiting factor. Disk and watermark signals are the right path to follow for that profile. As we can see on the screenshot below, all indicators stayed steady with constant performance throughout the test, even though we filled up the disk with more than 70 million vectors on the same instance type:</li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3f30c606a7005a82/6a731789ef5befd2394f7896/image5.png" alt=" DiskBBQ validation dashboard showing stable heap, steady search and indexing performance over 10 days" /><p>That timing is the point: Operators get a vector-first read tied to real RAM stress, with subsystem context, rather than an alert on every compressed index or only a red heap chart after the node is fighting on every front.</p><h2>What to do when AutoOps raises vector memory pressure</h2><p>Here’s the insight that AutoOps now raises when it detects vector memory pressure:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6a5bcf4dd55dc5fb/6a7317abe35d0255150319be/image3.png" alt="AutoOps Vector memory pressure insight with detection summary and recommendations for an Elasticsearch node" /><p>Recommendations in the product map to concrete actions:</p><ol><li><p><strong>Reduce vector footprint</strong> where quality allows: Fewer dimensions, quantized mappings, archive or split indices, reindex with a leaner mapping.</p></li><li><p><strong>Tune kNN load:</strong> Lower <code>num_candidates</code>, reduce concurrent query rate, narrow filtered kNN where possible.</p></li><li><p><strong>Consider DiskBBQ</strong> when HNSW in RAM is the bottleneck (evaluate recall/latency trade-offs for your use case). If you’re already on DiskBBQ and the heap memory is calm, treat disk and watermark insights as the primary signals. Note that DiskBBQ requires an Enterprise license.</p></li><li><p><strong>Right-size RAM</strong> when vector off-heap footprint (V) trends up and headroom stays tight.</p></li></ol><p>AutoOps links affected nodes and summarizes regime and stress in plain language. Treat it as “act now, rather than waiting for red on every chart.”</p><h2>Where AutoOps vector memory pressure monitoring is available</h2><p>Vector memory pressure is available wherever AutoOps runs against Elasticsearch 9.2+, including:</p><ul><li><p>Elastic Cloud Hosted (ECH).</p></li><li><p>Elastic Cloud Serverless (coming soon).</p></li><li><p>Self-managed via <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/cc-autoops-as-cloud-connected">Cloud Connect</a>.</p></li></ul><p>AutoOps is included at all subscription levels for supported deployment types and doesn’t consume ECUs on ECH.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-memory-pressure-autoops</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-memory-pressure-autoops</guid>
    <category><![CDATA[AutoOps]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[Valentin Crettaz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1ec5b14f053bdc56/6a73170a89eb5c6c9bab24c6/image1.png" length="0" type="image/png"/>
    <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch ES|QL brings full-text search to data you never indexed]]></title>
    <description><![CDATA[MATCH and TO_TEXT bring full-text search to data you never indexed. Search computed columns, unmapped fields and federated sources in ES|QL.]]></description>
    <content:encoded><![CDATA[<p>ES|QL <code>MATCH</code> now runs full-text search on data you never indexed. Computed columns, unmapped fields, strings assembled on the fly, even federated data sitting in S3. The new <code>TO_TEXT</code> function tells ES|QL to treat any string as analyzable text, so <code>MATCH</code> can tokenize, case-fold and term-match values that exist only for the lifetime of a query. This goes beyond the <code>LIKE</code> and <code>RLIKE</code> pattern matching that most query engines offer for unindexed strings: it's real analysis. Available now in Elastic Cloud Serverless and as a technical preview in Elasticsearch 9.5.</p><h2>How MATCH and TO_TEXT enable full-text search on any ES|QL expression</h2><p>Let's start with a query that was impossible in Elasticsearch 9.4, which uses <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval">the <code>EVAL</code> command</a>:</p><p>In this example, <code>summary</code> has no mapping or analyzer configuration. It’s also not associated with any inverted index. It exists only for the lifetime of this query, but now you can search it anyway. Two additions make this work.</p><p>First, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions/match"><code>MATCH</code></a> now accepts any expression as its first argument, not just a mapped field. That includes columns produced by <code>EVAL</code> and function results used inline. It also includes unmapped fields loaded directly from the original document. Furthermore, all data types normally accepted by <code>MATCH</code> are supported in this new use case.</p><p>The second part of this is the new <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/type-conversion-functions/to_text"><code>TO_TEXT</code></a> function, which is the first ES|QL conversion function that produces output of type <code>text</code>. Until now, <code>text</code> columns could only come from indexed mapped fields, and all strings produced by ES|QL expressions were <code>keyword</code> values rather than <code>text</code>. The distinction matters because <code>MATCH</code> treats the two differently: <code>text</code> values are analyzed, while <code>keyword</code> values are compared exactly, mirroring how a <code>MATCH</code> query on an indexed keyword field rewrites to a term query. <code>TO_TEXT(x)</code> is how you tell ES|QL: <em>treat this string as full text</em>.</p><p>This ships as a technical preview in Elasticsearch 9.5, and as such, it has some limitations:</p><ul><li><p>It’s currently filtering only. A <code>MATCH</code> on an expression doesn't contribute to the relevance score yet; only matches on indexed fields affect the score.</p></li><li><p>Querying options like <code>fuzziness</code> and others aren't yet supported when matching an expression.</p></li><li><p>Runtime text is analyzed with the standard analyzer. This isn’t configurable yet.</p></li></ul><p>Work is underway to address these limitations.</p><h2>Why use full-text search instead of LIKE or RLIKE in ES|QL?</h2><p>ES|QL already had two ways to search strings without an index: <code>LIKE</code> (wildcard patterns) and <code>RLIKE</code> (regular expressions). Both work on any string expression, so it's fair to ask what <code>MATCH</code> adds. The answer is <a href="https://www.elastic.co/docs/manage-data/data-store/text-analysis">analysis</a>, a more advanced form of search which uses techniques such as stemming and synonyms. It also uses stopword handling.</p><p><code>LIKE</code> is simple substring matching, without any understanding of the words that comprise a string. Say, for example, you're looking for log messages about a fox:</p><p>This misses <code>"Fox spotted near the henhouse"</code> due to the capitalization, while matching <code>"Outfoxed by the competition"</code>, which isn't about a fox at all. It fails in both directions, with false negatives on capitalization and false positives on substrings buried inside other words.</p><p>Regular expressions can patch the case problem, but the word-boundary problem gets ugly fast. Something like:</p><p>And even that's not right yet. It misses a fox at the end of a sentence followed by <code>!</code> or <code>?</code>, and it says nothing about tabs, quotes, or parentheses. Each fix makes the pattern longer, and the next person to read the query has to reverse-engineer what it’s actually doing.</p><p><code>MATCH</code> makes the problem go away, because it runs both the query and the value through an <a href="https://www.elastic.co/docs/reference/text-analysis/analyzer-reference">analyzer</a>, which tokenizes text into lowercase terms and then matches term against term:</p><p>This query will match values like <code>"The quick brown fox"</code> and <code>"FOX spotted near the henhouse"</code> but not <code>"Outfoxed by the competition"</code> or <code>“FOXTROT protocol enabled"</code>, regardless of any punctuation surrounding the words. Of course, this all works for multi-term queries, like <code>MATCH(TO_TEXT(message), "brown fox")</code>, too, just the way you’d expect it to.</p><p>Work is underway to enable the use of the <a href="https://www.elastic.co/docs/reference/text-analysis/analysis-lang-analyzer">36 dedicated language analyzers</a>, with support for natural languages on data that was never indexed or mapped.</p><h2>Full-text search use cases for unindexed and unmapped data</h2><p>The examples above searched values computed from <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">mapped fields</a>. . The more interesting use cases for ES|QL <code>MATCH</code> on expressions involve data that was never searchable at all. Let's walk through a few.</p><h3>How to search unmapped fields in ES|QL without adding a mapping</h3><p>Sometimes you deliberately leave a field out of your mappings, such as a verbose stack trace or a raw request payload. You might even leave out a debug blob. Indexing one of these would cost disk and heap space on every document and wouldn’t be worth it for a field you might query once a quarter.</p><p>That decision has always been final, because <a href="https://www.elastic.co/search-labs/blog/esql-unmapped-fields">unmapped fields</a> were invisible to queries entirely. In Elasticsearch 9.5, you can use <code>SET unmapped_fields="load"</code> to make ES|QL load unmapped fields directly from the source document as keywords. Follow that up by wrapping it in <code>TO_TEXT</code>, and now you can run full-text search on it:</p><p>Here, <code>stack_trace</code> was never mapped. Every value is fetched from the original documents and analyzed on the fly. They’re matched row by row. That’s real work, and it will never be as fast as an <a href="https://www.elastic.co/docs/manage-data/data-store/index-basics">inverted index</a> lookup. But now, that field you didn't index is no longer unsearchable. You get to keep the mapping small for the everyday case and still answer the once-a-quarter question when it matters.</p><h3>Full-text search on a keyword field without reindexing</h3><p>Keyword fields can do a lot. They give you exact matching, fast aggregations, and sorting, which is why so many fields end up mapped that way. But mappings are decided when data arrives, and it’s easy to end up in a situation where you want to do something different with your data than you had originally intended. Maybe <code>product_name</code> was mapped as a <code>keyword</code> because the dashboards aggregate on it, and then, after receiving a year’s worth of product data, someone wants to be able to search within <code>product_name</code> values.</p><p>The old answer was to change the mapping to <code>text</code> (or add a multi-field) and reindex everything. This can be both time-consuming and costly, and in many cases, users simply won’t want to bother with it. The new answer is one function call:</p><p><code>TO_TEXT</code> converts the <code>keyword</code> values to <code>text</code> on the fly, so <code>MATCH</code> analyzes them instead of comparing them exactly. This allows you to query a <code>keyword</code> field without creating a mapping or reindexing the source document. If the search becomes an everyday query, indexing the field as <code>text</code> is still the right long-term move, but <code>TO_TEXT</code> gets you an answer today, without any extra work.</p><h3>Searching the same field across indices with different mappings</h3><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-multi-index">ES|QL can span many indices</a>, and the same field doesn't need to always look the same in all of them. When the same field has different types in different indices, ES|QL treats it as a union type, and a conversion function resolves the conflict. Let’s consider an example in which the <code>message</code> field has type <code>text</code> in this year's index template but was a <code>keyword</code> in last year's:</p><p>Every value is analyzed at query time, whether it came from the <code>text</code> index or the <code>keyword</code> one. The keyword values from the older indices are tokenized and lowercased like everything else, so "connection reset" finds “Connection RESET by peer”, no matter which index it lives in.</p><p>Another interesting case is when a field is mapped in only one index but also present (and unmapped) in the other:</p><p>There's a nuance worth calling out here. If <code>error_details</code> is mapped in <code>logs-2026</code> but not in <code>logs-2025</code>, Elasticsearch cannot push this query down to <a href="https://lucene.apache.org/">Lucene</a>, because the indices where the field is unmapped would silently return no matches. Instead, the planner notices that the field is potentially unmapped and evaluates the whole <code>MATCH</code> row by row, wherever the rows came from. You don't have to know which of your indices have the field mapped; the query just answers the question.</p><h2>How ES|QL analyzes text at query time without an inverted index</h2><p>When ES|QL plans a <code>MATCH</code> against an expression, it analyzes the query string once, up front, into a set of terms. How each row is then evaluated depends on the expression's type:</p><p><strong>Expression type</strong></p><p><strong>Processing</strong></p><p><strong>Matching behavior</strong></p><p><code>text</code> (via <code>TO_TEXT</code>)</p><p>Analyzer tokenizes value into lowercase terms</p><p>Token-against-token comparison; a row matches if any token equals any query term (<code>OR</code> semantics)</p><p><code>keyword</code>,<code>ip</code>, <code>date</code>, numeric</p><p>No analysis; query constant converted once to the native type</p><p>Exact comparison per row</p><p>Both paths bypass Lucene entirely and evaluate values row by row. The non-text path mirrors exactly what a match query does when pushed down to Lucene against those field types, so the semantics stay consistent regardless of whether your query hits an index.</p><p>An inverted-index lookup does its work at ingest time and never touches non-matching documents at query time. A runtime <code>MATCH</code> does that analysis at query time, for every row that reaches it. One is fast because the work already happened; the other is flexible because the data doesn't need to have been indexed at all.</p><h2>What's next for ES|QL full-text search</h2><p>Everything in this post is the first installment of a larger effort to make search in ES|QL work on anything, not just on what you indexed ahead of time. The limitations called out earlier are actively being worked on, and the roadmap goes further:</p><ul><li><p><strong>Scoring.</strong> Runtime matches will contribute to <code>_score</code>, so you can sort by relevance even when the data was never indexed.</p></li><li><p><strong><code>MATCH_PHRASE</code></strong><strong> on expressions.</strong> Already available in Elastic Cloud Serverless, and coming to the Elastic Stack in 9.6.</p></li><li><p><strong>Configurable analyzers.</strong> Analyzer support for <code>MATCH</code> and <code>MATCH_PHRASE</code> on expressions, enabling language analyzers, stemming, and synonyms at query time.</p></li><li><p><strong>Match options.</strong> Options like <code>fuzziness</code> and <code>operator</code> for runtime matches.</p></li><li><p><strong>Vector search.</strong> Generating embeddings per row and running k-nearest neighbors (kNN) on runtime <code>dense_vector</code> expressions, bringing semantic search to unindexed data, too.</p></li></ul><h2>Try ES|QL full-text search on expressions today</h2><p>You can try runtime search today. It's available now in Elastic Cloud Serverless, where new ES|QL capabilities land first, and it ships as a technical preview in Elasticsearch 9.5. Start with the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions">search functions</a> reference, and check the <a href="https://www.elastic.co/docs/reference/query-languages/esql/limitations">ES|QL limitations</a> page for the current boundaries. It's a technical preview because we want your feedback: If you search something that was never indexed and it surprises you, either positively or negatively, <a href="https://www.elastic.co/community">we'd love to hear about it</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/full-text-search-unindexed-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/full-text-search-unindexed-data</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Mappings]]></category>
    <dc:creator><![CDATA[Kevin Corcoran,Ioana Tagirta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a74e633d05585a8/6a730774b8c2e64c3ebe0fd4/image1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Kibana Dashboards API: A stable contract for every panel type, tested by 50+ teams before GA]]></title>
    <description><![CDATA[Manage Kibana dashboards as code: Commit to Git, promote across environments, and automate deployments with the Kibana API and Terraform.]]></description>
    <content:encoded><![CDATA[<p>The<a href="https://dashboardsapispec.kibana.dev/dashboards#tag/Dashboards"> Kibana Dashboards and Visualizations APIs</a> are production-ready in Elastic 9.5, available across all subscription tiers, with full backward compatibility. Define your dashboards as JSON, commit them to Git, and then deploy across environments using continuous integration and continuous deployment (CI/CD) pipelines,<a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard"> Terraform</a>, or whatever tooling you already have. Over 50 teams tested the API during<a href="https://www.elastic.co/search-labs/blog/kibana-dashboards-as-code-terraform-api"> technical preview in 9.4</a>, some already running it in production. Version 9.5 also adds new endpoints (in technical preview) for<a href="https://dashboardsapispec.kibana.dev/tags.html"> Tags</a>, with<a href="https://dashboardsapispec.kibana.dev/markdowns.html"> Markdown</a> and<a href="https://dashboardsapispec.kibana.dev/links.html#tag/Links"> Links</a> panel endpoints available now in Elastic Cloud Serverless and landing in 9.6.</p><h2>What backward compatibility means for the Kibana Dashboards API</h2><p>During technical preview, the API shape could change between releases.[1] That's no longer the case. General availability (GA) means:</p><ul><li><p><strong>Complete backward compatibility.</strong> New fields and panel types will be added over time, but existing fields and behavior remain unchanged. Any future breaking changes would be very carefully considered and would only be introduced in a new major stack version.</p></li><li><p><strong>Production-ready with full support.</strong> The API carries Elastic's full support guarantees. You can safely use it in production environments for automated deployments, environment promotion, and programmatic dashboard management.</p></li></ul><h2>New Kibana API endpoints for Tags, Markdown, and Links panels</h2><p>Elastic 9.5 also introduces a new  standalone endpoint for <a href="https://dashboardsapispec.kibana.dev/tags.html"><strong>Tags</strong></a>, which let you categorize and filter dashboards. Now you can manage them programmatically through dedicated CRUD endpoints, making it easier to organize dashboards at scale across environments.	</p><p>New <a href="https://dashboardsapispec.kibana.dev/markdowns.html"><strong>Markdown</strong></a> and <a href="https://dashboardsapispec.kibana.dev/links.html#tag/Links"><strong>Links</strong></a> panel endpoints are available now in Serverless and will land in the next stack release (9.6).</p><h2>What panel types does the Kibana Dashboards API support?</h2><p>The Dashboards API supports all <em>by-value</em> panels in 9.5 (those defined directly in a dashboard, as opposed to library panels saved for reuse). Every supported panel type has a typed, validated schema.</p><p><strong>Panel type</strong></p><p><strong>Status</strong></p><p>XY charts</p><p>Supported</p><p>Metrics</p><p>Supported</p><p>Pie</p><p>Supported</p><p>Gauge</p><p>Supported</p><p>Heatmap</p><p>Supported</p><p>Data tables</p><p>Supported</p><p>Treemap</p><p>Supported</p><p>Discover sessions</p><p>Supported</p><p>Controls</p><p>Supported</p><p>Markdown</p><p>Supported</p><p>Links</p><p>Supported</p><p>ML panels</p><p>Supported</p><p>Observability panels</p><p>Supported</p><p>Maps</p><p>Coming soon</p><p>Vega</p><p>Coming soon</p><h2>How to manage Kibana dashboards as code</h2><p>The Dashboards API enables a full dashboards-as-code workflow: Export a dashboard as clean, diffable JSON, commit it to Git as the source of truth, review changes in pull requests, and deploy the same definition across development, staging, and production. Once a dashboard is managed as code, treat Git as the single source of truth: Changes made directly in the UI are overwritten the next time you deploy.</p><p>The main challenge when moving a dashboard between spaces, clusters, or stages is that dashboards reference objects like data views and library visualizations by ID. Because these IDs are auto-generated and differ across environments, a dashboard exported from one environment can point at objects that don't exist in another. There are three ways to handle this, listed here from most to least automated:</p><ul><li><p><strong>Use Terraform.</strong> The <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard">Elastic Stack Terraform provider</a> tracks each resource and maps IDs per environment automatically, so references stay consistent as you promote a dashboard from development to production.</p></li><li><p><strong>Define by-value </strong><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql"><strong>Elasticsearch Query Language (ES|QL) panels</strong></a><strong>.</strong> The most portable way to build a panel is to define its visualization with ES|QL directly in the dashboard. An <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql-kibana">ES|QL</a> query reads from the indices you name in it, so the panel carries no external references to data views or library objects. The result is a fully self-contained, portable dashboard.</p></li><li><p><strong>Assign matching IDs.</strong> If you reference saved objects, like data views or library visualizations, create them with a chosen ID using <code>PUT</code> (upsert) rather than <code>POST</code> (which auto-generates an ID). Use human-readable IDs, like <code>logs-prod</code>, so they're easy to reuse and recognize across environments.</p></li></ul><p>For a detailed walkthrough of these portability patterns and the full dashboards-as-code workflow, see the <a href="https://www.elastic.co/docs/explore-analyze/dashboards/manage-dashboards-as-code#dashboards-as-code-portability">Manage dashboards as code</a> documentation.</p><h3>Create a Kibana dashboard with the Dashboards API using PUT</h3><p>Here's a quick example creating a dashboard with a metric panel using <code>PUT</code> instead of <code>POST</code> to assign a custom ID using the dashboard name (<code>service-health-overview</code>). The same logic works for creating standalone visualizations saved in the library.</p>PUT kbn:/api/dashboards/service-health-overview
{
  "title": "Service health overview",
  "description": "Key service metrics — managed via API",
  "tags": [
    "production",
    "sre-team"
  ],
  "panels": [
    {
      "type": "vis",
      "grid": {
        "x": 0,
        "y": 0,
        "w": 12,
        "h": 8
      },
      "config": {
        "title": "Error rate (5xx)",
        "type": "metric",
        "data_source": {
          "type": "esql",
          "query": "FROM logs-* | WHERE http.response.status_code &gt;= 500 | STATS error_rate=count(*) BY host.name"
        },
        "metrics": [
          {
            "type": "primary",
            "column": "count"
          }
        ]
      }
    }
  ]
}<h2>Kibana Dashboards API roadmap: Maps, Vega, and standalone endpoints</h2><p>We're actively expanding the API surface. Maps and Vega panel support is next, adding typed schemas for them. We're also building standalone CRUD endpoints for Discover sessions (beyond their existing support as dashboard panels), Vega, Maps, and Annotations, decoupled from the dashboard lifecycle.</p><p>For the full schema definitions, visit the <a href="https://dashboardsapispec.kibana.dev/dashboards#tag/Dashboards">Dashboards API documentation</a>. For Terraform users, the <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard">Elastic Stack Terraform provider</a> supports the GA Dashboards API.</p><h2>Note</h2><ol><li><p>The core endpoints are unchanged from the technical preview. If you built integrations against 9.4, they work in 9.5. The only breaking changes are two minor ones affecting dashboard listing and duration unit formats, documented <a href="https://www.elastic.co/docs/release-notes/kibana/breaking-changes">here</a>.</p></li></ol>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/dashboards-as-code-kibana-api</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dashboards-as-code-kibana-api</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[Developer Experience]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Teresa Alvarez Soler]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8ed7e33de291f255/6a730619c8b7ac02b251f9d3/image1.png" length="0" type="image/png"/>
    <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Close enough is fast enough: How ES|QL Fast mode makes Kibana dashboards up to 100x faster]]></title>
    <description><![CDATA[Fast mode samples a fraction of the data instead of scanning all of it. This release also brings click-to-filter for ES|QL charts, query-powered controls, and cleaner metric and bar chart layouts.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch Query Language (ES|QL) STATS queries on Kibana dashboards now run up to 100x faster. ES|QL Fast mode, in general availability (GA) in Kibana 9.5, samples a fraction of the data rather than scanning every row, and results stay within a 90% confidence interval. With Fast mode, ES|QL charts pick up click-to-filter and Discover drilldowns. Plus, controls can pull their values from an ES|QL query, and metric and bar chart defaults are cleaner. This builds on the dashboard improvements<a href="https://www.elastic.co/search-labs/blog/kibana-dashboards-improvements"> shipped in 9.4</a>. The <a href="https://www.elastic.co/search-labs/blog/dashboards-as-code-kibana-api">Dashboards API</a> and <a href="https://www.elastic.co/search-labs/blog/ai-dashboards-kibana-vega-lite">AI dashboards and Vega-Lite charts</a> also go GA in this release.</p><h2>ES|QL charts performance and interactivity in Kibana dashboards</h2><h3>How ES|QL Fast mode runs dashboard queries up to 100x faster</h3><p>For common analytical tasks, like trend tracking, top-host identification, and capacity overviews, trading a small margin of accuracy for dramatically faster results is the right call, especially since not every question needs an exact answer.</p><p><a href="https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-1">Elastic Search 9.4 introduced approximate ES|QL queries</a> as a syntax-only command in technical preview. Now, 9.5 makes approximation GA and adds<a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql-kibana#esql-kibana-fast-mode-toggle"> <strong>Fast mode</strong></a>, a UI toggle in Dashboards and Discover that enables<a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-query-approximation"> approximate ES|QL STATS queries</a> without writing any query syntax. This makes Kibana one of the first tools to offer smart sampling with automatic extrapolation as a simple switch.</p><p>Fast mode is an Enterprise-only feature and is off by default. Dashboard authors can save their preferred state with the dashboard, and individual queries can override the toggle with <code>SET approximation=true</code> or <code>false</code> inline.</p><p>When switched on, ES|QL STATS queries target a fixed sample size (defaulting to 1,000,000 rows for grouped aggregations and 100,000 rows otherwise) rather than scanning the full dataset.<a href="https://www.elastic.co/search-labs/blog/fast-approximate-esql-part-1"> Benchmarks show heavy aggregations running up to 100x faster</a> on large datasets, with results that are typically highlyaccurate, defaulting to a 90% confidence interval. Approximation only applies to STATS commands where results can remain accurate. When accuracy cannot be ensured (such as with small datasets or aggregations like MAX, MIN, or COUNT_DISTINCT), Kibana automatically falls back to exact execution, even with Fast mode enabled.</p><p>Further improvements to how charts communicate that results are approximate are coming in future releases.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt473f75b25868afa1/6a719ccf75ed4699484a85dc/image2.png" alt="Kibana Fast mode toggle set to ON showing the approximation tooltip on a dashboard with metric panels" /><h3>Click-to-filter and Discover drilldowns for ES|QL charts</h3><p>Two of the most popular interactions for data view charts are also landing now for ES|QL-based visualizations.</p><ul><li><p><strong>Discover drilldowns</strong> now work on ES|QL panels. When a user clicks a data point or uses Explore in Discover, filters are translated to ES|QL <code>WHERE</code> clauses and Kibana Query Language (KQL) queries are carried over automatically. </p></li><li><p><strong>Click-to-filter also works for renamed fields:</strong> if your query renames a column (<code>STATS BY node = k8s.node.name</code>), Kibana now resolves the alias back to the indexed field, so the filter applies correctly.</p></li><li><p><strong>Tooltips:</strong> When filtering genuinely can't work (for example, because the field was computed entirely within the query and doesn't exist in the index), Kibana now shows a tooltip explaining why, so users know that it's a query limitation.Beyond interactivity, ES|QL layers now have the same <strong>Use global filters</strong> toggle (gear icon on the layer header) as data-view-backed visualizations. When you turn it off, the layer's query runs independently of dashboard-level filters, just like form-based layers already do. This is useful for reference lines, thresholds, or baselines that shouldn't change when you filter the dashboard. And ES|QL metric charts now support a background chart, matching the styling option already available for data view metrics.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt960ef7848c551ff0/6a719cfc3931bc448f28de69/image9.gif" alt="Kibana dashboard in edit mode with Host, OS, Cloud Provider and Region controls populated by ES|QL queries" /><p>Beyond interactivity, ES|QL layers now have the same <strong>Use global filters</strong> toggle (gear icon on the layer header) as data-view-backed visualizations. When you turn it off, the layer's query runs independently of dashboard-level filters, just like form-based layers already do. This is useful for reference lines, thresholds, or baselines that shouldn't change when you filter the dashboard. And ES|QL metric charts now support a background chart, matching the styling option already available for data view metrics.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf557d2a4578741bc/6a719e42e35d0253ce0312ec/image8.gif" alt="Kibana dashboard showing metric charts with Default density layout and preset style templates applied" /><p></p><p>Upcoming releases aim to keep adding the remaining functionality to ES|QL visualizations, such as multilayer support and saving visualizations to the library.</p><h3>Identify which Kibana panels use an ES|QL variable</h3><p><a href="https://www.elastic.co/search-labs/blog/kibana-dashboard-interactivity-variable-controls-overview">Variable controls</a> are among the most popular ES|QL-only features, since they let you parameterize chart queries to switch between fields, time intervals, or groupings without duplicating panels. On a dashboard with many panels and controls, though, it can be hard to tell which visualizations a variable actually affects. In edit mode, you can now click an ES|QL variable control's label to identify all related panels that consume the variable. Variables with no related panels display a warning to make it easier to audit wiring before saving.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5e565e80cda5cfce/6a71a13eb966e1768c63d81b/image5.gif" alt="Kibana dashboard with Host, OS and Cloud Provider controls filtering CPU and memory charts in view mode" /><h2>Kibana metric and bar chart layout defaults</h2><h3>Metric chart preset layouts and density options</h3><p>The metric chart appearance panel now offers preset layouts: <strong>Top</strong>, <strong>Middle</strong>, <strong>Bottom</strong>, and <strong>Custom</strong>. When you pick a template, the layout snaps into place. If you need fine-grained control, switch to <strong>Custom</strong>.</p><p>Metrics used to pack values tightly, which is great for data-dense dashboards but hard to scan when a metric stands alone. Elastic Cloud 9.5 adds a <strong>Density</strong> style option under <strong>Style &gt; Details &gt; Other</strong>, with two presets: <strong>Compact</strong> (the previous layout) and <strong>Default</strong> (more padding, larger typography). Newly created metrics use <strong>Default</strong>, and existing saved charts keep <strong>Compact</strong> until you change them.</p><p><strong>Attribute</strong></p><p><strong>Compact</strong></p><p><strong>Default</strong></p><p>Padding</p><p>Tight, minimal spacing</p><p>More generous whitespace</p><p>Typography</p><p>Smaller text</p><p>Larger text</p><p>Best for</p><p>Data-dense dashboards with many metrics side by side</p><p>Standalone metrics or dashboards with fewer panels</p><p>New charts</p><p>Must be selected manually</p><p>Applied automatically</p><p>Existing charts</p><p>Preserved until changed</p><p>Must be selected manually</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7434a5894cd1a048/6a719e8478b5febf378f12bc/image6.gif" alt=" Kibana dashboard edit mode showing the Settings gear icon on an ES|QL metric panel with global filter controls" /><h3>Responsive bar chart labels in Kibana</h3><p>Labels in horizontal bar charts used to grow unchecked, so on smaller screens, a chart with long category names could become unreadable. Bar labels now get a max width and middle-truncate automatically, so the beginning and end of a label stay visible even when the full text doesn't fit. This works by default, with no configuration needed. In 9.6, we’re planning many more improvements to bar charts and labels.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt85aa55cba5eb3a78/6a719ea65f2918842f13c2cb/image7.gif" alt="Before and after comparison of Kibana horizontal bar chart labels truncating responsively on smaller screens" /><h2>Kibana dashboard controls populated by ES|QL queries</h2><p><a href="https://www.elastic.co/docs/explore-analyze/visualize/add-controls#create-and-add-options-list-and-range-slider-controls">Controls</a> are the most user-friendly way to filter a dashboard, and most dashboards use them. One of the longest-standing requests from users has been the ability to prefilter the values that a control shows. ES|QL queries make that possible and open a much wider set of possibilities, like chaining controls in new ways using variables. Regardless of how the values are populated, controls filter every panel on the dashboard, including ES|QL and data view visualizations.</p><p>Controls can now be populated from an ES|QL query instead of selecting a data view field directly. The <strong>Create control</strong> flyout adds a <strong>Select a field / Write a query</strong> toggle. You can write an ES|QL query that returns a single column and run it, and then the control derives its options from the result. Queries can reference dashboard <a href="https://www.elastic.co/docs/explore-analyze/visualize/add-variable-controls">variables</a> through the <code>?variable</code> syntax, enabling flexible chaining between controls.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltacb5b26c2b31f3e6/6a719ebdded0cff1f5f49290/image1.png" alt="Kibana Edit control flyout showing an ES|QL query populating a Host options list on a dashboard" /><h2>Coming soon: Progress bar visualization for Kibana tables</h2><p>A new progress bar visualization type is available in Elastic Cloud Serverless and is planned for general availability in 9.6. Progress bars show a value relative to a goal or maximum, which is useful for many O11y metrics, like CPUs, memory, or Service Level Agreement (SLA) tracking.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt991d8a3c7d03068d/6a719ed7c2c8edb0c708b82a/image4.png" alt="Kibana table visualization with progress bar cell decoration showing Average Bytes per request path" /><h2>What's next for Kibana dashboards and ES|QL visualizations</h2><p>Upcoming releases will keep pushing on better defaults, improving the ES|QL visualization experience, and adding new chart types. If you have a pain point or a feature request, select <strong>Submit feedback</strong> in the top menu; we're listening.</p><h2>How to try ES|QL Fast mode and the new Kibana dashboard features</h2><p>If you use <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a>, you may already be using these changes. Otherwise, upgrade to 9.5, and then create a dashboard or open an existing one. Many updates apply automatically to new visualizations, while layout and style options appear in edit mode. If you aren't on Elastic Cloud yet, <a href="https://cloud.elastic.co/registration">start a trial</a> and explore the latest Kibana dashboards there.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/kibana-dashboards-esql-fast-mode</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboards-esql-fast-mode</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Analytics]]></category>
    <dc:creator><![CDATA[Teresa Alvarez Soler]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8b0336f51b4694f0/6a719cab5e874b5b0e1ab976/image3.png" length="0" type="image/png"/>
    <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Prompt to dashboard in under a minute, 5x cheaper: AI dashboards and custom Vega-Lite charts in Kibana]]></title>
    <description><![CDATA[Describe your metrics in natural language and Kibana's AI chat generates ES|QL-backed dashboards and Vega-Lite charts, from scatter plots to conditional formatting and custom tooltips.]]></description>
    <content:encoded><![CDATA[<p>Kibana's<a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/chat"> AI chat</a> now builds full<a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql"> Elasticsearch Query Language–backed (ES|QL-backed)</a> dashboards from a natural-language prompt in under a minute. In Elastic 9.5, this moves to general availability (GA) (<a href="https://www.elastic.co/search-labs/blog/ai-dashboard-generation-elastic-agent-kibana">technical preview in 9.4</a>) with error recovery that retries failed queries, 5x lower ES|QL generation costs through tiered model routing, and interactive filter controls. This release also adds<a href="https://www.elastic.co/docs/explore-analyze/visualize/custom-visualizations-with-vega"> Vega-Lite</a> chart creation through natural language, including scatter plots, box plots, conditional formatting, and custom tooltips that you'd normally have to hand-code in JSON.</p><h2>What's new in Kibana's AI dashboard creation</h2><p><strong>Capability</strong></p><p><strong>Technical preview (9.4)</strong></p><p><strong>GA (9.5)</strong></p><p>Error handling</p><p>No retry on failed ES|QL queries</p><p>Automatic retry up to three times with query inspection and adjustment</p><p>ES|QL generation cost</p><p>All queries routed through the primary model</p><p>Tiered model routing, up to 5x cheaper</p><p>Time range</p><p>Fixed default window</p><p>Automatic selection based on data time distribution</p><p>Filter controls</p><p>Not supported</p><p>Automatically added for the most relevant fields</p><p>Vega-Lite charts</p><p>Not supported</p><p>Natural-language creation, including scatter plots, box plots, conditional formatting, custom tooltips</p><p>Chart editing</p><p>Not supported</p><p>Edit existing Vega-Lite panels through natural language</p><h3>Automatic error recovery for AI dashboard generation</h3><p>In the technical preview, the agent didn’t retry failed ES|QL queries. In 9.5, it detects query errors and retries up to three times, inspecting each error and adjusting the query before giving up. In practice, this eliminates the majority of empty-panel issues and produces dashboards that render correctly on the first try.</p><h3>Why is AI dashboard creation cheaper in Elastic 9.5?</h3><p>Not every step in dashboard generation needs the same level of reasoning. In 9.5, ES|QL generation routes through a lighter model by default and falls back to the primary model only when needed. If your <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/connectors">connector</a> uses Anthropic's Claude Opus 4.8, that means 5x cheaper for ES|QL generation across all panels.</p><h3>Automatic time range selection based on your data</h3><p>Dashboards are only useful when they show the right window of data. The agent now applies improved logic to pick a time range that makes sense for the data it's querying, unless the user asks for a specific time range. It considers the data's time distribution and adjusts accordingly, whether that means the last hour for a live incident or the last 90 days for a trend analysis, rather than defaulting to a fixed window.</p><h3>Automatic filter controls on AI-generated dashboards</h3><p>Dashboard creation now supports controls; that is, interactive filters that let viewers narrow a dashboard by field values without editing the underlying queries. When generating a dashboard, the agent automatically adds controls at the top for the fields most relevant to filter by.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a5520e66ae76001/6a719a218a155220ed6498e7/image3.png" alt="Kibana AI chat generating an ES|QL-backed host metrics dashboard with automatic filter controls in 71 seconds" /><h2>Vega-Lite charts from natural language: Chart types and formatting beyond the defaults</h2><p><a href="https://vega.github.io/vega/">Vega</a> and <a href="https://vega.github.io/vega-lite/examples/">Vega-Lite</a> support a wide range of chart types and customizations in Kibana. With 9.5, you can build them from plain language instead of writing the code yourself. </p><h3>Scatter plots, box plots, and more Vega-Lite chart types</h3><p>Scatter plots, box plot charts, faceted small multiples, bubble charts, and composition charts (like combining histograms with heatmaps), among many others, are supported by <a href="https://vega.github.io/vega-lite/examples/">Vega-Lite</a>. A prompt like <em>Show me a scatter plot of response time vs. request size, colored by service name</em> produces a Vega-Lite panel with the right data mappings. They use Kibana's default color palettes to blend with the rest of the dashboard.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf1651fe7ac903d47/6a719a49f124649f746fc1b4/image5.png" alt="Kibana dashboard with four Vega-Lite charts: box plot, bubble chart, faceted small multiples, and heatmap." /><h3>Conditional formatting, custom tooltips, and labels on standard charts</h3><p>Even for chart types that are already native to dashboards, like bar, line, or area, sometimes you need more control than the default capabilities offer. Vega-Lite through the chat fills that gap. Some examples include:</p><ul><li><p><strong>Conditional color formatting:</strong> Color data points above a threshold differently; for example, turning data points in a line or bars red when some metric spikes beyond your Service Level Objective (SLO). Ask the agent something like <em>Turn any points above 500ms red for my line chart.</em> </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc1211784e9033557/6a719a6ab966e1736163d7d1/image1.png" alt="Vega-Lite line and bar charts in Kibana with conditional colour formatting showing data points above a threshold in red" /><p></p></li><li><p><strong>Custom marks and labels:</strong> Add emojis, symbols, or inline text labels to data points for at-a-glance status indicators.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte3c59c3748484bc1/6a719aa02888394fdc07bac1/image2.png" alt="Lite horizontal bar chart in Kibana with emoji flag labels and custom tooltip showing requests by country" /><p></p></li><li><p><strong>Custom tooltips:</strong> Enrich hover states with additional metrics, context, or computed values that aren't part of the chart's axes. Ask something like <em>Add a tooltip that shows total record counts and the % per bar.</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt43f241f4382b5b90/6a719ab7ded0cf3367f49275/image4.png" alt="Vega-Lite stacked bar chart in Kibana with custom tooltip showing total records and percentage of total by extension" /><p></p></li></ul><p>This also works for editing existing Vega charts. If you have a Vega-Lite panel that needs a tweak like changing a color scale, adjusting an axis, or switching the mark type, describe the change in chat instead of digging into the JSON code.</p><h2>How we built natural-language Vega-Lite generation in Kibana</h2><p>Generating a Vega-Lite chart from a sentence is not a one-shot <em>ask the model for JSON</em> prompt. We built a small agentic pipeline that turns natural-language intent into a validated, data-backed chart.</p><p>When a request comes in, the agent first determines whether Vega-Lite is the right fit. For Vega-Lite requests, it grounds the visualization in a real ES|QL query against Elasticsearch and then uses a model to generate the Vega-Lite code. Before rendering, the result goes through a normalization layer that corrects the schema and binds the canonical query. It also applies render-safety transformations. </p><p>A few design choices make this workflow reliable:</p><ul><li><p><strong>Typed tool calling</strong>: Chart creation is a structured tool invocation rather than free-form Vega-Lite pasted into the conversation.</p></li><li><p><strong>Constrained generation</strong>: The model generates Vega-Lite code within a defined schema, making the output more predictable and easier to validate.</p></li><li><p><strong>Curated examples:</strong> Structural patterns, such as faceting, layered marks, and heatmaps, provide guidance without copying the underlying data.</p></li><li><p><strong>Execute-and-verify loops</strong>: Queries are executed before chart authoring, and validation failures trigger corrective retries for ES|QL generation.</p></li></ul><h2>Try AI dashboard creation and Vega-Lite charts in Kibana</h2><p>To try natural-language dashboard creation and Vega-Lite charts, upgrade to <strong>Elastic 9.5</strong> (or <a href="https://cloud.elastic.co/registration">start a free trial</a>), and open the <strong>chat</strong> in Kibana. Then ask it to build a dashboard from your data. For Vega-Lite, try asking for a chart type you've wanted but never built, like a scatter plot or a bubble chart. If the result isn't quite right, tell the agent what to change. It iterates with you.</p><p>This requires an Enterprise license. <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/chat#get-started">Get started</a>.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-dashboards-kibana-vega-lite</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-dashboards-kibana-vega-lite</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Marta Bondyra,Teresa Alvarez Soler]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt54406ad0378bc5fc/6a7199ffed03ccee0dac9d7c/image6.png" length="0" type="image/png"/>
    <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One field, every modality: how Elasticsearch's semantic field indexes and searches images, audio, video and PDFs automatically]]></title>
    <description><![CDATA[The semantic field turns images, audio, video, PDFs and text into multimodal embeddings at ingest time. Describe a scene and find the matching image or use a video frame to surface related clips, all from one Elasticsearch field.]]></description>
    <content:encoded><![CDATA[<p>Multimodal search in Elasticsearch now works the same way text search does: define a field, index your content, and query. The <code>semantic</code> field generates embeddings automatically at ingest time for images, audio, video, and PDFs. Every modality lands in one shared vector space, so you can retrieve an image with a text description, match audio to a phrase, or find a video with a still frame, all from a single field. Available in Elasticsearch 9.5 and serverless as a tech preview.</p><h2>The palette takes shape: how multimodal search in Elasticsearch evolved from semantic_text</h2><p>The <code>semantic</code> field is a convergence of several complementary features we've introduced over the past couple of years, bringing them together to create a cohesive multimodal search experience. Each solved an important piece of the semantic search puzzle on its own; together they enable native multimodal search.</p><p>The first brushstroke was <code>semantic_text</code>. Before it, running semantic search meant manually configuring mappings, wiring up ingest pipelines with an ML model, manually chunking content, and generating query-time embeddings yourself. The <code>semantic_text</code> field folds all of that away: it performs inference automatically at ingest time, chunks long documents for you, and simplifies the queries you write against it. <a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">Introduced in Elasticsearch 8.15</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-semantic-text-ga">released as GA in Elasticsearch 8.18</a>, it has become the foundation for semantic search on the platform.</p><p>Next came <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index">the model to power multimodal search</a>. <code>jina-embeddings-v5-omni</code> is our family of multimodal embedding models, capable of embedding text, images, video, audio, and PDFs into a shared vector space. Because those embeddings are semantically compatible across modalities, you can store diverse media in a single index and query across all of it at once, such as retrieving an image via a text description or matching audio against a written phrase, all without maintaining a separate pipeline for each content type. For more detailed information about how these embeddings are generated, see the <a href="https://jina.ai/models/jina-embeddings-v5-omni-small/">model documentation</a>.</p><p>We added the <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query#query-vector-builders-parameters">embedding query vector builder</a> in Elasticsearch 9.4 to handle multimodal inputs at query time. Query vector builders are general-purpose tools you can use to convert input to a vector at query time as part of your request. For example, we have the <code>text_embedding</code> query vector builder for text-only models and input, and the <code>lookup</code> query vector builder for getting a vector from an existing document. The <code>embedding</code> query vector builder is a new type that works with multimodal models and accepts multimodal input, including text or base64-encoded binaries. This allows you to pose a query in whatever modality fits, and Elasticsearch generates the matching vector on the fly.</p><p>The final piece was multimodal ingest. The <code>semantic_text</code> field brought automatic embedding to text; the <code>semantic</code> field extends that same automatic experience to images, audio, video, and PDFs from ingest through query.</p><h2>Painting the picture: creating an index with the semantic field</h2><p>Let’s create an index with a <code>semantic</code> field. This is as simple as setting the field type to semantic and defining the inference endpoint you want to use:</p>PUT example-index
{
  "mappings": {
    "properties": {
      "my_semantic_field": {
        "type": "semantic",
        "inference_id": ".jina-embeddings-v5-omni-small"
      }
    }
  }
}<p>In this example, we use the .<code>jina-embeddings-v5-omni-small</code> inference endpoint. This is our built-in <code>jina-embeddings-v5-omni</code> inference service, and it is available in all environments with access to the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS). This includes:</p><ul><li><p>Serverless.</p></li><li><p>Elastic Cloud Hosted (ECH).</p></li><li><p>Self-managed with <a href="https://www.elastic.co/docs/deploy-manage/cloud-connect">Cloud Connected Mode</a> (CCM).</p></li></ul><h3>Indexing images, audio, video and PDFs</h3><p>To index an image, provide an object with a <code>type</code> of <code>image</code> and a <code>value</code> containing the image as a base64-encoded <a href="https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data">data URL</a>:</p>PUT example-index/_doc/example_doc_1
{
  "my_semantic_field": {
    "type": "image",
    "value": "data:image/jpeg;base64,&lt;base64-encoded-image-bytes&gt;"
  }
}<p>Arrays of objects are also accepted, allowing you to index multiple images in a single field value:</p>PUT example-index/_doc/example_doc_2
{
  "my_semantic_field": [
    {
      "type": "image",
      "value": "data:image/jpeg;base64,&lt;base64-encoded-image-bytes&gt;"
    },
    {
      "type": "image",
      "value": "data:image/jpeg;base64,&lt;base64-encoded-image-bytes&gt;"
    }
  ]
}<p>The <code>semantic</code> field also supports text values, just like <code>semantic_text</code>. You can provide such values standalone or intermix them with image values:</p>PUT example-index/_doc/example_doc_3
{
  "my_semantic_field": "a cat on a windowsill"                                                                                                                                                                                                                }

PUT example-index/_doc/example_doc_4
{
  "my_semantic_field": [
    "a cat on a windowsill",
    {
      "type": "image",
      "value": "data:image/jpeg;base64,&lt;base64-encoded-image-bytes&gt;"
    },
    "a dog running in a park"
  ]
}<p>Text values are handled just like they are with <code>semantic_text</code>: long passages are chunked according to the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-field-reference#semantic-params">chunking settings configured on either the inference service or field mapping</a>. Multimodal values, such as images, are not chunked. Each multimodal value is represented as one chunk.</p><p>Other modalities are supported as well. Change the type value to match your content’s modality. Currently we support:</p><ul><li><p><code>image</code></p></li><li><p><code>audio</code></p></li><li><p><code>video</code></p></li><li><p><code>pdf</code></p></li></ul><p>For example, to index a video, the request would look like:</p>PUT example-index/_doc/example_doc_5
{
  "my_semantic_field": {
    "type": "video",
    "value": "data:video/mp4;base64,&lt;base64-encoded-video-bytes&gt;"
  }
}<p></p><h3>Image search and cross-modal retrieval with a text query</h3><p>To find multimodal content using a text description, run a <code>match</code> query on the <code>semantic</code> field:</p>GET example-index/_search
{
  "query": {
    "match": {
      "my_semantic_field": "a cat on a windowsill"
    }
  }
}<p>Just like with <code>semantic_text</code>, Elasticsearch automatically generates an embedding for the query text using the inference endpoint associated with the field. That query embedding is used to return semantically similar matches.</p><p>This query pattern enables easy text-to-image search. Just index an image and use a <code>match</code> query to retrieve it via text description! It also works for any other modality: index the multimodal input and search by description to retrieve it.</p><h3>Querying with images, video, and other multimodal inputs</h3><p>We can also search using a multimodal input by using the <code>knn</code> query with an <code>embedding</code> query vector builder. For example, we can search using an image:</p>GET example-index/_search
{
  "query": {
    "knn": {
      "field": "my_semantic_field",
      "query_vector_builder": {
        "embedding": {
          "input": {
            "type": "image",
            "value": "data:image/jpeg;base64,&lt;base64-encoded-image-bytes&gt;"
          }
        }
      }
    }
  }
}<p>The <code>input</code> object format is the same as when providing an image to index: set the <code>type</code> to <code>image</code> and <code>value</code> to a base64-encoded data URL.</p><p>Similar to when querying by text description, Elasticsearch automatically generates an embedding for the query image using the inference endpoint associated with the field. That query embedding is used to return semantically similar matches.</p><p>Just like with indexing, other modalities are supported, but are limited to those supported by your inference endpoint. For example, a search using a video clip would look like:</p>GET example-index/_search
{
  "query": {
    "knn": {
      "field": "my_semantic_field",
      "query_vector_builder": {
        "embedding": {
          "input": {
            "type": "video",
            "value": "data:video/mp4;base64,&lt;base64-encoded-video-bytes&gt;"
          }
        }
      }
    }
  }
}<h2>Extending the composition: highlighting, retrievers, and other semantic field features</h2><p>The <code>semantic</code> field didn't start from a blank canvas. It's built on the same foundation as <code>semantic_text</code>, inheriting its behavior and its ergonomics, and extending them to multimodal content. In practice, that means nearly everything you already know about working with <code>semantic_text</code> carries over unchanged. If you've built with <code>semantic_text</code> before, the <code>semantic</code> field will feel immediately familiar.</p><p>Here’s a selection of the features that come along for the ride. See <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-field">the documentation</a> for a complete list.</p><h3>Highlighting the best-matching chunks</h3><p>If you index multiple values in a <code>semantic</code> field, you may want to know <em>which</em> value best matches the query. The <code>semantic</code> highlighter can be used to return the most relevant chunks as highlight fragments:</p>GET example-index/_search
{
  "query": {
    "match": {
      "my_semantic_field": "a cat on a windowsill"
    }
  },
  "highlight": {
    "fields": {
      "my_semantic_field": {
        "number_of_fragments": 2,
        "order": "score"
      }
    }
  }
}<p>Setting <code>order</code> to <code>score</code> returns the fragments ranked by relevance, while <code>number_of_fragments</code> caps how many chunks come back. The response looks like:</p>{
  "hits": {
    "hits": [
      {
        "_index": "example-index",
        "_id": "example_doc_4",
        "_source": {...},
        "highlight": {
          "my_semantic_field": [
            "a cat on a windowsill",
            "data:image/jpeg;base64,&lt;base64-encoded-image-bytes&gt;"
          ]
        }
      }
    ]
  }
}<p>Note how highlighted multimodal values are represented using their data URLs.</p><h3>Controlling vector quantisation with index options</h3><p>The <code>semantic</code> field stores its embeddings in an underlying vector field, and <code>index_options</code> lets you control how that vector field is indexed. For example, choosing a non-default quantization strategy:</p>PUT example-index
{
  "mappings": {
    "properties": {
      "my_semantic_field": {
        "type": "semantic",
        "inference_id": ".jina-embeddings-v5-omni-small",
        "index_options": {
          "dense_vector": {
            "type": "int8_hnsw"
          }
        }
      }
    }
  }
}<h3>Multi-field retrievers</h3><p>The <code>semantic</code> field participates in the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers">multi-field query format</a> supported by the <code>linear</code> and <code>rrf</code> retrievers. Rather than hand-writing an inner retriever per field, you supply a single <code>query</code> and a list of <code>fields</code>, mixing lexical fields and semantic fields freely:</p>GET example-index/_search
{
  "retriever": {
    "linear": {
      "query": "a cat on a windowsill",
      "fields": ["title", "my_semantic_field"],
      "normalizer": "minmax"
    }
  }
}<p>The retriever automatically separates lexical fields from semantic fields, queries each group, and normalizes the results so that each group contributes equally to the final ranking, preventing lexical matches from drowning out semantic ones.</p><h3>Cross-cluster search</h3><p>The <code>semantic</code> field supports <a href="https://www.elastic.co/docs/solutions/search/cross-cluster-search">cross-cluster search (CCS)</a>, enabling use of the field in large, multi-cluster deployments. Simply list the indices to query using the standard <code>&lt;cluster&gt;:&lt;index&gt;</code> format:</p>GET example-index,remote-cluster:remote-index/_search
{
  "query": {
    "match": {
      "my_semantic_field": "a cat on a windowsill"
    }
  }
}<p>The fields queried across indices and clusters can use a mix of different inference endpoints that produce different query embeddings. The search request will automatically apply the proper query embedding to each individual field queried.</p><h2>Off the easel, into the world: optimising multimodal embeddings for production</h2><p>When you move multimodal search from experiment to production, the size of your multimodal inputs becomes a practical concern. Multimodal data is supplied as base64-encoded data URLs, and that data is stored in the index. Those strings can grow large in a hurry: a single high-resolution file can balloon into several megabytes of encoded text, which has several side effects:</p><ul><li><p>The index size on disk can increase significantly.</p></li><li><p>Requests and responses containing multimodal data are larger, increasing transmission time and ingress/egress costs.</p></li><li><p>Inference on larger multimodal inputs is slower.</p></li></ul><p>The good news is that you don’t need that much fidelity. Multimodal embedding models reduce each input to a compact representation before generating a vector anyway, so a smaller, lower-fidelity version of a multimodal input (such as a downscaled image or a lower-bitrate audio clip) produces a very similar embedding, and similar search quality, to its full-size original. This also applies to PDF input. PDFs are generally processed visually by multimodal models, so the quality only needs to be good enough to perform operations like image embedding and OCR. Long PDFs should be broken up into chunks of smaller inputs, so the embeddings generated more accurately represent each chunk. Feeding the model small inputs keeps your documents lean, trims index and response sizes, and speeds up ingestion, all without meaningfully affecting relevance. </p><p>Elasticsearch reinforces this practice with a guardrail: the <code>indices.inference.max_binary_input_size</code> cluster setting caps the size of each binary input, defaulting to 1 MB. Any individual value that exceeds the limit is rejected with a clear error, so oversized inputs surface as an actionable problem at index time rather than as silent bloat. This setting is adjustable in self-hosted and ECH through the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-cluster-put-settings">cluster settings API</a>. It is not adjustable in our serverless offering, where 1 MB is the hard limit for binary sizes.</p><p>When possible, it is also advised to use <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrieve-selected-fields#source-filtering">source filtering</a> to exclude <code>semantic</code> fields from responses. For example:</p>GET example-index/_search
{ 
  "_source": {
    "excludes": ["my_semantic_field"]
  },
  "query": {
    "match": {
      "my_semantic_field": "a cat on a windowsill"
    }
  }
}<p>This makes responses smaller, more performant, and easier to parse because multimodal data is not returned with each.</p><h2>Try out the semantic field</h2><p>The <code>semantic</code> field is available in Elasticsearch 9.5 and Serverless. <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;cta=cloudregistration&amp;tech=trial&amp;plcmt=cross%20module&amp;pg=search-labs">Start a free trial</a> and try it out today.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/semantic-field-multimodal-search-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/semantic-field-multimodal-search-elasticsearch</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Index Data]]></category>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[Mappings]]></category>
    <dc:creator><![CDATA[Mike Pellegrini]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltac28de8857eafbc4/6a6f090aca9a724b3c614914/image1.png" length="0" type="image/png"/>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[15 lines of click tracking code that tell you what search logs can't ]]></title>
    <description><![CDATA[Three ES|QL queries calculate click-through rate, mean reciprocal rank and click position distribution from your search click data, so you can pinpoint which queries need relevance tuning and where ranking improvements will have the most impact.]]></description>
    <content:encoded><![CDATA[<p>Search volume and latency tell you that search is working, not that it's useful. About 15 lines of OpenTelemetry (OTel) instrumentation lets you track clicks on search results and then query click-through rate (CTR), Mean Reciprocal Rank (MRR), and click position distribution with Elasticsearch Query Language (ES|QL) against the same traces index that your search spans already live in. You'll wire click tracking to your existing <code>search.query_id</code> and write the queries that show which searches need relevance tuning.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Add client-side click tracking that links clicks back to their originating search via <code>search.query_id</code>.</p></li><li><p>Calculate CTR; that is, the percentage of searches that produce at least one click.</p></li><li><p>Calculate MRR; that is, how far down the results users click on average.</p></li><li><p>Analyze click position distribution to see the full shape of user engagement.</p></li><li><p>Write ES|QL queries for all three metrics against  <code>traces-generic.otel-default</code>.</p></li><li><p>Identify which specific queries need relevance tuning.</p></li></ul><h3>What you'll need</h3><ul><li><p>A working OTel instrumentation setup from Blog 2 (search spans with <code>search.*</code> attributes flowing to Elastic via OTel-native ingestion).</p></li><li><p>A front end that can send click events (JavaScript example provided).</p></li><li><p>Familiarity with the <code>attributes.*</code> field mapping from Blog 2.</p></li></ul><h2>Why search logs alone can't measure search quality</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">second blog</a> of the series, we instrumented search requests and ran six ES|QL queries against the data. We can see what users search for, which queries return nothing, and how fast search is.</p><p>But there's a blind spot. A search that returns 15 results looks healthy from the server side. Every metric we have says it worked. But if nobody clicks any of those results, your ranking has a problem, and none of the queries from Blog 2 will tell you.</p><p>This is the gap between <em>results returned</em> and <em>results that are useful</em>. Search volume, zero-results rate, and latency measure the mechanics of search, but they don't measure whether search is actually helping users find what they need.</p><p>To answer that question, you need a second instrumentation point: <em>click tracking</em>.</p><p>If you’re following along with code, the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has click tracking ready to enable. Uncomment the Blog 3 sections in <code>app.py</code> and <code>frontend/app.js</code>, restart, and then generate traffic with <code>python generate_traffic.py --blog 3</code>.</p><h2>How click data measures search relevance</h2><p>Before we write any code, here's what click data lets you measure and why each metric matters:</p><ul><li><p><strong>CTR</strong> answers the most basic engagement question: <em>What percentage of searches result in at least one click?</em> If your CTR is low, users are seeing results but not finding them compelling enough to engage. Establish your own baseline once you have data; CTR varies considerably across product category, query type, and industry vertical.</p></li></ul><ul><li><p><strong>MRR</strong> goes deeper: <em>When users do click, where in the results are they clicking?</em> An MRR of 1.0 means every user clicks the top result (a perfect ranking). An MRR of 0.5 means the average click is at position 2. Low MRR with high CTR is particularly telling. It means that users are finding what they need, but your ranking is making them work for it.</p></li></ul><ul><li><p><strong>Click position distribution</strong> shows the full shape of where users click. A healthy search engine shows most clicks at position 1 with a sharp drop-off. A flat distribution across positions 1–5 means that your ranking isn't differentiating well. Per-query distributions reveal exactly which searches need relevance tuning.</p></li></ul><p>Together, these metrics move you from <em>Did search work?</em> (Blog 2) to <em>Did search work well?</em>, and they pinpoint exactly where to invest in relevance improvements. Later in the series, we'll show how to turn these metrics into concrete actions: building judgment lists for Learning To Rank (LTR), tuning relevance with tools like <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a>, and evaluating changes with the Rank Eval API. But first, you need the data.</p><p>All three metrics require just one new instrumentation point: about 15 lines of code.</p><h2>Add click tracking</h2><p>Click tracking captures what happens after the results appear. When a user clicks a search result, we create a new span with attributes describing the interaction, including which document they clicked, where it appeared in the results, and which search produced it.</p><p>Here's the code:</p># Track which query_ids have already received a click
_clicked_queries: set[str] = set()

@app.post("/api/events")
async def track_event(event: EventRequest):  # reference project uses ClickEvent
with tracer.start_as_current_span("search.result.click") as span:
        span.set_attribute("search.action", "click")
        span.set_attribute("search.result_click_id", event.object_id)
        span.set_attribute("search.result_click_position", event.position)
        span.set_attribute("search.result_click_type", event.object_id_type)
        span.set_attribute("search.query_id", event.query_id)
        span.set_attribute("enduser.pseudo.id", event.client_id)

# First click per search — enables single-query CTR
if event.query_id not in _clicked_queries:
            span.set_attribute("search.first_click", True)
            _clicked_queries.add(event.query_id)

if event.user_query:
            span.set_attribute("search.query", event.user_query)<p>Let's unpack what matters.</p><h3>Click spans are separate traces</h3><p>This is the key architectural difference from Blog 2. Search spans are created synchronously during the API request: The user searches, the span opens, Elasticsearch responds, and the span closes. Click spans are <em>asynchronous</em>. The user searches, gets results, browses the page, and might click 30 seconds later (or they might never click).</p><p>That means click spans aren't children of the search span's trace. They're independent traces, linked to the originating search through <code>search.query_id</code>. This is the same <code>query_id</code> we derived from the trace ID in Blog 2, and it now serves as the join key between searches and clicks across <code>traces-generic.otel-default</code>.</p><h3>Choosing the right OTel signal for clicks</h3><p>OTel gives you three signal types, and clicks could be modeled as any of them. Each has strengths:</p><p><strong>Signal</strong></p><p><strong>Index</strong></p><p><strong>Weight</strong></p><p><strong>Best for…</strong></p><p>Spans</p><p><code>traces-generic.otel-default</code></p><p>Full trace context</p><p>Same-index queries with search spans</p><p>Logs</p><p><code>logs-*</code></p><p>Lighter weight</p><p>Log-centric pipelines, high volume</p><p>Span events</p><p><code>logs-generic.otel-default</code></p><p>Lightest instrumentation</p><p>Attaching to existing spans</p><p></p><ul><li><p><strong>Spans</strong> land in <code>traces-generic.otel-default</code> alongside search spans, are fully queryable in ES|QL, appear in Kibana APM views, and carry timing information. Since our search spans are already in <code>traces-generic.otel-default</code>, using spans for clicks means you can query searches and clicks together in a single ES|QL statement, and no cross-index joins are needed.</p></li><li><p><strong>Log records</strong> are also independently queryable in ES|QL, living in <code>logs-*</code>. If you use the same <code>search.*</code> attribute names, the analytics queries are almost identical; just change the index pattern. Logs are lighter weight (no trace context overhead) and are a natural fit if your team already has a log-centric observability pipeline. One of Elastic's strengths here is that traces, logs, and metrics all land in the same platform and are all queryable with ES|QL, so choosing logs over spans doesn't mean giving up any query capability.</p></li><li><p><strong>Span events </strong>are lightweight at instrumentation time (attached to an existing span in the OTel API). In Elastic's OpenTelemetry Protocol (OTLP) ingestion pipeline, span events are written as separate documents to <code>logs-*</code> data streams (for example <code>logs-generic.otel-default</code>), and they’re independently queryable in the same way as logs. They’re a good, lightweight option but might require more code changes than logs, which can even pull in logs from legacy code.</p></li></ul><p>In this series, we use <em>spans </em>because they keep searches and clicks in the same index with the simplest query path. But if you're at high volume and want to optimize for cost, or if your organization already routes OTel logs to Elasticsearch, the log-based approach works well; the <code>search.*</code> attribute schema is the same either way, and ES|QL queries against <code>logs-*</code> follow the same patterns you'll see below.</p><h3>The <code>search.first_click</code> attribute</h3><p><code>search.first_click</code> is a Boolean set only on the first click for a given <code>query_id</code>. It exists for one reason: accurate CTR calculation without post-processing.</p><p>CTR is defined as the percentage of searches with at least one click. Without <code>search.first_click</code>, you'd need to deduplicate clicks by <code>query_id</code> at query time: grouping, counting distinct values, and subquerying. By marking the first click at instrumentation time, the ES|QL query becomes a simple count.</p><p>The set above is demo-only. It grows unbounded and breaks with multiple API replicas. The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference implementation</a> uses a thread-safe time-to-live (TTL) dict with a 30-minute expiry window (<code>_is_first_click()</code> in <code>app.py</code>). For production with multiple replicas, use a shared external cache (Redis, Memcached) keyed by <code>query_id</code> with a TTL matching your session window.</p><h3>Where to track first click: Front end vs. back end</h3><p>The <code>search.first_click</code> deduplication could live in either the front end or the back end. Both are valid, and here are the trade-offs:</p><ul><li><p><strong>Frontend tracking</strong> is simpler to implement. The browser already knows the current query and whether the user has clicked before. No server-side state is required, you don’t have to worry about multiple replicas, and it works without any backend changes. The downside is that browser state is ephemeral; a page refresh, multiple tabs, or an ad blocker can interfere with accurate tracking.</p></li></ul><ul><li><p><strong>Backend tracking</strong> (our approach) gives you a single source of truth. All click events flow through one place, so the deduplication is consistent regardless of what the client does. It also means that the analytics logic is colocated with the instrumentation code, which simplifies reasoning about data quality. The trade-off is that the back end needs to maintain state: the <code>_clicked_queries</code> set. For a single-instance API, this is trivial; for multiple replicas behind a load balancer, you'd use a shared TTL cache (Redis or similar).</p></li></ul><p>We chose backend tracking here because we want the analytics data to be authoritative. This click data will later feed into relevance tuning and judgment lists, where accuracy matters. But if you're starting simple or running a client-side–only setup, frontend tracking is a perfectly reasonable first step. The <code>search.first_click</code> attribute works the same way regardless of where you set it.</p><h3>Sending click events from the browser</h3><p>The browser sends click events to the back end when a user clicks a result. It needs three things from the search response: the document ID, the position, and the <code>query_id</code>.</p>// Generate a persistent client ID once per browser (stored in localStorage)
const CLIENT_ID = localStorage.getItem("search_client_id")
    || (() =&gt; {
const id = crypto.randomUUID();
        localStorage.setItem("search_client_id", id);
return id;
    })();

// On result click
fetch('/api/events', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    object_id: product.id,
    position: index + 1,       // 1-indexed
query_id: lastQueryId,     // from the most recent search response
client_id: CLIENT_ID,      // persistent browser identifier → enduser.pseudo.id
user_query: currentQuery,
    object_id_type: 'product', // optional; defaults to "product" on the backend
})
});<p><code>CLIENT_ID</code> is generated once and stored in <code>localStorage</code>. It survives page reloads and gives you a stable <code>enduser.pseudo.id</code> without requiring a login. The back end maps <code>client_id</code> → <code>enduser.pseudo.id</code> on the span.</p><p>Positions are 1-indexed; that is, the first result is position 1, not 0.</p><h3>Click tracking OTel attributes and ES|QL field mapping</h3><p></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p>Event type: <code>"click"</code></p><p><code>search.result_click_id</code></p><p>string</p><p>yes</p><p>Document ID clicked</p><p><code>search.result_click_position</code></p><p>int</p><p>yes</p><p>Position in results (1-indexed)</p><p><code>search.query_id</code></p><p>string</p><p>yes</p><p>Links to originating search</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier</p><p><code>search.first_click</code></p><p>boolean</p><p>recommended</p><p><code>true</code> if first click for this <code>query_id</code></p><p><code>search.result_click_type</code></p><p>string</p><p>recommended</p><p>Object type: <code>"product"</code>, <code>"article"</code></p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for queryability)</p><p>These follow the same <code>search.*</code> namespace we established in Blog 2. With OTel-native ingestion, attributes map directly to <code>attributes.*</code> fields in ES|QL:</p><p></p><p><strong>OTel attribute</strong></p><p><strong>ES|QL field</strong></p><p><code>search.action</code></p><p><code>attributes.search.action</code></p><p><code>search.result_click_id</code></p><p><code>attributes.search.result_click_id</code></p><p><code>search.result_click_position</code></p><p><code>attributes.search.result_click_position</code></p><p><code>search.query_id</code></p><p><code>attributes.search.query_id</code></p><p><code>search.first_click</code></p><p><code>attributes.search.first_click</code></p><p>With OTel-native ingestion, <code>search.first_click</code> is stored as a native Boolean; you query it with <code>== true</code>, not <code>== "true"</code>, and no string coercion is needed.</p><h3>Verify that clicks are arriving</h3><p>Before calculating metrics, confirm that click spans are flowing to APM:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
| KEEP attributes.search.result_click_id, attributes.search.result_click_position,
       attributes.search.query_id, attributes.search.query
| LIMIT 5<p>If this returns rows, you're ready for analytics. If not, check the same things as we looked at in Blog 2: OTLP endpoint, auth token, and span export.</p><p><strong>Note:</strong> Results in this post are illustrative, generated by running <code>python generate_traffic.py --blog 3 --sessions 50</code> on the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a>. Running Blog 3 traffic adds click events <em>and</em> additional search sessions on top of the 62 from Blog 2, so cumulative search counts will exceed 62. Your exact numbers will vary based on session count and the random nature of the traffic simulator. The metric calculations and ES|QL patterns are what to focus on.</p><h2>CTR</h2><p><strong>CTR</strong> is the primary signal for search relevance, answering the question that Blog 2 couldn't: <em>Are users engaging with the results?</em></p><p><strong>CTR = searches with at least one click / total searches * 100</strong></p><p>CTR is a binary per-search metric: Either a search got clicked or it didn't. The maximum is 100%.</p><h3>Overall search CTR with ES|QL</h3>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
OR attributes.search.first_click == true
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p><strong>Result:</strong> 41 clicked searches out of 146 total. <strong>CTR: 28.1%</strong></p><p>This is a single query that pulls both search spans and first-click spans from <code>traces-generic.otel-default</code>. The <code>OR</code> in the <code>WHERE</code> clause brings both into one result set. <code>COUNT(CASE(...))</code> counts each type separately, and <code>EVAL</code> does the division.</p><p>This works because of <code>search.first_click</code>. Without it, you'd be counting raw clicks (a user who clicks three results on one search would inflate the count). The deduplication happened at instrumentation time; the query stays simple.</p><h3>CTR by search query: Finding your worst relevance failures</h3><p>The overall number is useful for dashboards. The per-query breakdown is where you find problems.</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
OR attributes.search.first_click == true)
AND attributes.search.query IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
BY attributes.search.query
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
| SORT searches DESC
| LIMIT 20<p>This shows CTR broken down by query text. </p><h3>What CTR tells you (and what it doesn't)</h3><ul><li><p><strong>High searches + zero clicks:</strong> These are the worst relevance failures. Fix these first.</p></li><li><p><strong>High searches + low CTR:</strong> Results appear, but they aren't compelling. Check ranking.</p></li><li><p><strong>Low CTR + high zero-results rate:</strong> This is a double problem; either no results or bad results.</p></li><li><p><strong>CTR trend over time:</strong> This measures the impact of relevance changes.</p></li></ul><p>CTR doesn't measure satisfaction. A user who clicks position 1, bounces back, and then clicks position 3 still counts as one clicked search. For a fuller picture, you need to know <em>where </em>they're clicking. That's what MRR measures.</p><h3>CTR vs. clicks per search</h3><p><strong>CTR</strong> (what we just calculated) is capped at 100%. This is the industry-standard definition.</p><p><strong>Clicks per search</strong> is total clicks divided by total searches. It can exceed 1.0. For example, a search where the user clicks three results scores 3.0. It measures engagement depth, which is useful but different. If you need it, count all click spans (not just <code>first_click</code>) divided by search spans.</p><h2>MRR</h2><p><strong>MRR</strong> tells you <em>where users </em>click. It measures how far down the results list users go before finding something worth clicking.</p><p><strong>MRR = average of (1 / click_position) across all clicks</strong></p><p>The reciprocal rank transforms click positions into a 0–to–1 scale, where higher is better:</p><p></p><p><strong>Click position</strong></p><p><strong>Reciprocal rank</strong></p><p>1</p><p>1.000</p><p>2</p><p>0.500</p><p>3</p><p>0.333</p><p>5</p><p>0.200</p><p>10</p><p>0.100</p><p></p><h3>Overall search MRR with ES|QL</h3><p>MRR can be calculated two ways, depending on what you want to measure:</p><ul><li><p><strong>All-click MRR:</strong> Averages the reciprocal rank of every click and reflects overall click quality, including repeated interactions.</p></li><li><p><strong>First-click MRR:</strong> Averages only the first click per search (using <code>search.first_click == true</code>). It’s more comparable to traditional information retrieval (IR) evaluation and aligns with how you computed CTR.</p></li></ul><p>For consistency with CTR and alignment with judgment-list workflows in Blog 5, we prefer first-click MRR:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.first_click == true
| EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
| STATS mrr = ROUND(AVG(reciprocal_rank), 3)<p><strong>Result:</strong> MRR = <strong>0.495</strong></p><p>This is decent but shows room for improvement. An MRR of 0.495 means the average first click lands around position 2. It isn’t a crisis, but there are queries where ranking can be improved.</p><h3>MRR by search query: Finding your worst-ranked results</h3><p>Like CTR, the per-query breakdown is where the actionable data lives. To surface the worst-ranked queries first, sort ascending.</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.first_click == true
AND attributes.search.query IS NOT NULL
| EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
| STATS
    mrr = ROUND(AVG(reciprocal_rank), 3),
    clicks = COUNT(*)
BY attributes.search.query
| SORT mrr ASC
| LIMIT 20<p>This reveals which queries have the worst ranking. A query with multiple clicks and low MRR means that the ranking is consistently poor for that search; that is, users find results, but they have to dig for them.</p><h3>What is a good MRR score for search?</h3><ul><li><p><strong>MRR &gt; 0.8:</strong> This ranking is solid; users usually click position 1–2.</p></li><li><p><strong>MRR 0.5–0.8:</strong>  This is decent, but there’s room for improvement.</p></li><li><p><strong>MRR &lt; 0.5:</strong> This is a ranking problem, and users are scrolling past top results.</p></li><li><p><strong>MRR drop after a change:</strong> This is ranking regression that should be investigated immediately.</p></li><li><p><strong>Low MRR + high CTR:</strong> Users are finding things, but they have to work for it.</p></li></ul><p>That last pattern is particularly interesting. High CTR with low MRR means your results are relevant (users are clicking), but your ranking isn't surfacing the best results first. It's an optimization opportunity, not a crisis.</p><h3>MRR limitations: Position bias and multi-click sessions</h3><p>MRR is heavily influenced by the gap between position 1 and position 2 (1.0 versus 0.5). Positions 5 and beyond barely move the average. This means that MRR is most sensitive to whether your top result is good, which is often exactly what you want to optimize.</p><p>MRR also only measures clicks, not satisfaction. With <em>all-click MRR</em>, a user who clicks position 1, bounces, and then clicks position 3 contributes two data points, but only the second was useful. The <em>first-click MRR</em> queries above avoid this by counting only the first click per search via <code>search.first_click == true</code>.</p><h2>Click position distribution</h2><p>Click position distribution shows you the full picture of where in the results users are engaging.</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
| STATS click_count = COUNT(*) BY attributes.search.result_click_position
| SORT attributes.search.result_click_position ASC<p><strong>Results:</strong></p><p><strong>Position</strong></p><p><strong>Clicks</strong></p><p>1</p><p>21</p><p>2</p><p>10</p><p>3</p><p>7</p><p>4</p><p>4</p><p>5</p><p>3</p><p></p><p>This is a reasonable distribution: 21 of 45 clicks (47%) land on position 1, with a tapering tail. If you paste this query into Discover's ES|QL editor, Kibana auto-generates a bar chart that makes the shape immediately visible.</p><h3>How to read click position distribution for search relevance</h3><ul><li><p><strong>Sharp dropoff after position 1:</strong> The ranking is effective, and the top result is usually right.</p></li><li><p><strong>Flat across positions 1–5:</strong> The ranking isn't differentiating well, and all positions are equally likely to be clicked.</p></li><li><p><strong>Spike at position 3+ for specific queries:</strong> Those queries have ranking problems.</p></li><li><p><strong>No clicks beyond position 5:</strong> Users don't scroll far. Top 5 ranking matters most.</p></li></ul><h3>Click position distribution by search query</h3><p></p><p>To see the shape for specific queries:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.query IS NOT NULL
| STATS click_count = COUNT(*)
BY attributes.search.query, attributes.search.result_click_position
| SORT attributes.search.query, attributes.search.result_click_position<p>A query where all clicks land on position 1 has perfect ranking. A query where clicks spread across positions 1–5 needs relevance tuning.</p><h3>Position bias and click models</h3><p>One caveat: Click position distribution is influenced by <em>position bias</em>; that is, users see position 1 first, so it gets clicked more regardless of relevance. A click at position 1 isn't necessarily more relevant, just more visible.</p><p>This is a well-studied problem in information retrieval. <em>Click models</em> are statistical models that attempt to separate genuine relevance from position bias in click data. The foundational work by <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf">Joachims et al. (2005)</a> showed that users are significantly biased toward higher-ranked results, and, in proposed methods like skip-above analysis (if a user clicks position 3 but skips positions 1 and 2), those skipped results are likely less relevant for that query.</p><p>For the metrics in this post, you don't need to implement a full click model. The key insight is practical: Compare distributions <em>between queries</em> rather than treating absolute position counts as ground truth. If query A has 80% of clicks at position 1 and query B has clicks spread across positions 1–5, query B's ranking is worse, even accounting for position bias. Later in the series, when we look at building judgment lists for LTR, position bias correction becomes more important, and the click data you're collecting here is exactly what those models need as input.</p><h2>CTR, MRR, and click distribution: Reading search quality metrics together</h2><p>These three metrics offer three different angles on search result quality:</p><p></p><p><strong>Metric</strong></p><p><strong>What it measures</strong></p><p><strong>Our value</strong></p><p><strong>Interpretation</strong></p><p><strong>CTR</strong></p><p>Do users click at all?</p><p>28.1%</p><p>Moderate: Roughly a third of searches get engagement, but there’s room to improve.</p><p><strong>MRR</strong></p><p>Where do they click?</p><p>0.495</p><p>Decent: The average click is around position 2, but ranking can be improved.</p><p><strong>Distribution</strong></p><p>What's the shape?</p><p>47% at position 1</p><p>Reasonable drop-off: The top result wins most but isn’t dominant.</p><p>Together, they tell a coherent story. For our demo data, search is performing adequately: Users are engaging with results and can find what they need, but the ranking has room to improve. The CTR of 28% and MRR of 0.495 are realistic starting points for a new search implementation without tuning.</p><p>Where they're most valuable is in combination at the query level. The queries to fix first are those with <strong>high volume + low CTR + low MRR</strong>; that is, lots of users are searching, few are clicking, and those who do click are scrolling deep. That's where relevance investment has the highest return.</p><h3>Using click data for LTR and relevance tuning</h3><p>These metrics don't just tell you how search is performing; they're the foundation for making it better. The click data you're now collecting feeds directly into relevance improvement workflows:</p><ul><li><p><strong>Judgment lists for LTR:</strong> Click positions and frequencies become graded relevance labels for training machine learning (ML) ranking models. A document clicked at position 1 across many queries is a strong positive signal.</p></li><li><p><strong>Relevance tuning tools:</strong> Per-query CTR and MRR tell you exactly which queries to focus on in tools like <a href="https://elastic.github.io/relevance-studio/#/">Relevance Studio</a> or the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Rank Eval API</a>, which scores your ranking against expected results.</p></li><li><p><strong>Query rules and boosting:</strong> Zero-CTR queries with results are candidates for pinning, boosting, or synonym rules.</p></li></ul><p>We'll cover these applications in detail in Blog 5. For now, the important thing is that the instrumentation you've built here is doing double duty: It measures search quality <em>and</em> provides the training data to improve it.</p><h2>Next in the series: Conversion tracking from search to purchase</h2><p>We can now measure whether users find results (Blog 2) and whether they engage with them (this post). But a click isn't a conversion. A user who clicks a product and then abandons the page didn't get what they needed.</p><p>In the next post, we add <em>conversion tracking</em>, the third instrumentation point that closes the loop from search to purchase. It’s the same pattern: Add <code>search.*</code> attributes to add-to-cart and checkout spans, query with ES|QL, and answer the question your product manager actually cares about: <em>Which searches drive revenue?</em></p><h2>Get started</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series (clone, configure, and run).</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry Python:</a> EDOT Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic:</a> How to send OTel data to Elastic APM.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation:</a> Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI standard:</a> Reference schema for search event structure.</p></li></ul><p><em>This is the third post in a </em><a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry"><em>series on search analytics with OpenTelemetry and Elastic</em></a><em>. Next up: From clicks to conversions: Conversion tracking, funnel analysis, and revenue attribution.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3f04684f0d65c705/6a6efbf02888390e8607b2c2/image1.png" length="0" type="image/png"/>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[17% faster search, zero config: auto-calibrating vector quantization in Elasticsearch]]></title>
    <description><![CDATA[Automatic calibration at merge time picks vector quantization parameters for each segment by predicting recall from a small sample. Here's how we built it into Elasticsearch's merge path.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch's <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a> format (IVF clustering plus binary quantization, built for on-disk ANN search at scale) offers several knobs to shape the recall/cost tradeoff of an index. Automatic calibration seeks to optimize those knobs to achieve optimal performance.</p><p>In our <a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-elasticsearch">previous blog</a>, we laid out the statistical model behind that calibration: a manifold model for how nearest-neighbor distances scale with index size, a Gaussian error model for quantization noise, and a closed-form way to combine the two into an expected recall@k for a given rerank depth. If you haven't read it, the one thing you need going in is this: given a candidate quantization encoding and a rerank depth, we can predict recall@k without building an index and benchmarking it, by fitting two small models to a sample of the corpus.</p><p>In this post we’ll go through how to score candidate configurations cheaply and how to leverage that to make merge-time decisions that are themselves cheap, correct, and consistent across real, constantly-merging indexes. This led to some pretty impressive improvements: we see almost 17% average improvement in QPS across a broad range of datasets all while increasing recall (in one case by a factor of 3). What’s more you get this immediately by adding one line to your index options, <code>"auto_calibrate": true</code>, and our plan is to make this our default once it has had the chance to bake a bit.</p><h2>Why manual vector quantization tuning is unreliable</h2><p><code>bbq_disk</code> exposes several knobs: quantization bits for documents (1, 2, 4 or 7), a separate bit width for queries, an oversampling factor for reranking, and whether to <a href="https://www.elastic.co/search-labs/blog/elasticsearch-bbq-preconditioning-vectors">precondition</a> vectors before quantizing. None of these act independently, and their effect on recall depends on the data: a 4-bit/1-bit encoding might be plenty for one embedding model and clearly insufficient for another. A single index is also built out of many segments, merged over time, each with an eventually different vector distribution. Hand-tuning one configuration for an entire index is, at best, a compromise, which is the motivation for <a href="https://github.com/elastic/elasticsearch/pull/152894">automatic calibration</a>: let each segment have its own configuration, re-evaluated every time it is involved in a merge operation.</p><h2>How Elasticsearch runs auto calibration at merge time</h2><p>When a number of segments are merged and automatic calibration is enabled, Elasticsearch samples documents and queries from the vectors being merged and:</p><ol><li><p>fits the manifold model over a sequence of nested samples of the merged corpus;</p></li><li><p>fits the error model, predicting the quantization error's standard deviation for each candidate <code>(query bits, document bits, precondition)</code> combination;</p></li><li><p>sweeps candidate configurations in ascending cost order; the candidate encodings are <code>(1,1)</code>, <code>(4,1)</code>, <code>(4,2)</code>, <code>(4,4)</code> and <code>(7,7)</code> (query bits, document bits), each tried across oversampling factors of <code>1.25</code>, <code>1.5</code>, <code>1.75</code>, <code>2.0</code>, <code>2.5</code> and <code>3.0</code>;</p></li><li><p>estimates recall@10 for each candidate using the model described in our first post, and stops at the first (cheapest) configuration predicted to hit the target of 90% recall@10.</p></li></ol><p>The winning configuration (encoding, oversample factor, precondition flag) is stored directly in the segment's metadata, so it travels with the segment and is picked up automatically at query time unless a request explicitly overrides it.</p><p>Small segments skip this altogether: below 10,000 merged vectors, there isn't enough data to fit a reliable model, so Elasticsearch just uses the current DiskBBQ defaults (4-bit query / 1-bit document encoding, no preconditioning, 3x oversampling).</p><h2>How the vector quantization cost model works</h2><p>Following the principles described in our first post, we started by picking candidates with three nested loops that are essentially how you might imagine hand jamming a lookup table. Start with the quantization scheme as the outer loop, ordered cheapest to most expensive by document bits (<code>(1,1) → (4,1) → (4,2) → (4,4) → (7,7)</code>). Then we set the rerank depth within the middle loop, ordered shallow to deep (<code>1.25× → 3.0×</code>). Finally we set preconditioning within the inner loop (<code>off → on</code>).</p><p>That ordering has a cost model baked into it, it's just implicit rather than written down: exhaust every rerank depth at the current bit tier before ever trying more bits. Document bits were effectively the only resource priced as expensive; oversampling was treated as nearly free by comparison, since the sweep would always max out rerank depth on a cheap encoding before considering a pricier one.</p><p>The current implementation replaces that with an explicit, continuous cost function:</p>cost = document_bits + 1.3 × rerank_depth<p>Query bits still don't factor into cost at all, only document bits (which drive index size) and rerank depth (which drives how many candidates get rescored per query). Preconditioning also stays outside the formula: Elasticsearch runs the whole cost-ordered sweep once with preconditioning off, and only if nothing meets the recall target does it re-run the sweep with preconditioning on, treating it as a fallback lever rather than something priced bit-for-bit against the other two.</p><p>With this cost model, rerank depth costs noticeably more per unit than a document bit, so the sweep will often prefer stepping up a bit tier over pushing oversampling deeper.</p><p>The main reason for this is that once you're running in a serverless deployment, compute and storage are billed and scaled independently, on very different clocks. An extra document bit is mostly a one-time, indexing-time cost; it makes the segment marginally bigger on object storage, which is cheap and doesn't need to be pre-provisioned against a spike in query traffic. It does carry a smaller recurring cost too, since quantized vectors sitting in page cache or loaded for scoring take proportionally more RAM per document as bit width grows, but that scales linearly and predictably with corpus size, and doesn't spike with query load. Rerank depth is the opposite: it's a recurring, per-query cost. </p><p>Every extra unit of oversample factor means fetching and rescoring that many more full-precision candidate vectors from disk, on <em>every</em> search request, for as long as the index is queried. That's compute and DRAM pressure on the search-serving tier, which has to autoscale in close to real time to match query concurrency. It sits on the hot path of the latency-and-cost budget in a way storage capacity, and the RAM footprint of the bits themselves, does not. Weighting rerank depth higher than document bits in the cost formula is what makes the sweep reflect that asymmetry.</p><h2>Efficiently estimating vector quantization error</h2><p>The cost model above works with the premise that the recall estimate behind it is trustworthy. The manifold and error models need to be accurate for the recall assessment to be trustworthy. While the manifold model of the k-th to N-th nearest neighbors distance is cheap to compute, the standard deviation of the quantization noise for a given candidate encoding is a bit more expensive in principle.</p><p>DiskBBQ uses fixed count clusters to accelerate nearest neighbor queries. Our quantization procedure takes advantage of this by only quantizing the vector residuals from the cluster centroids. This means as the data scales, the magnitude of vectors we quantize relative to the various components of the similarity calculation shrinks. As such, quantization accuracy increases. We need to account for this when converting our sample estimates to the segment as a whole.</p><p>Clustering the corpus at several sample sizes and fitting how the error scales with cluster size requires re-clustering a real sample of the corpus at several different sizes and fitting a regression model to see how the error shrinks as the effective cluster size grows. We also add a conservative +3σ margin on top of the fitted estimate to guard against noise in the fit itself. This is accurate and appropriately cautious; however, while benchmarking on common dense retrieval datasets, we found that performing several hierarchical k-means passes per candidate was expensive.</p><p>To speed things up, we tried approximating residuals with a synthetic isotropic-Gaussian formula. Instead of clustering increasing-size samples, this approach generated synthetic residuals from the manifold model's local density estimate. It was fast and fit for background merges, with the full repeated clustering approach reserved for force-merges only. However, it turned out to inflate error when embeddings (residuals) are anisotropic (some directions carry a lot more variance than others). As a result, the estimated error could grow significantly on strongly anisotropic data (e.g., Fashion-MNIST-style image embeddings).</p><p>So instead we looked for a still fast but more accurate way of calculating residuals. We opted for using a single clustering pass over a smaller sample (2,048 vectors). The clustering runs once per merge and is then warm-started for every candidate encoding evaluated afterward, instead of re-clustering from scratch each time. To get the error's dependence on corpus size, which the baseline learns by re-clustering at multiple sizes, this approach instead reuses the manifold model's <code>invDim</code> as a <a href="https://web.stanford.edu/class/archive/stats/stats200/stats200.1172/Lecture17.pdf">plug-in</a> for that dependence, extrapolating from the single real measurement rather than fitting the size relationship separately. </p><p>We also trimmed the query sample used during calibration from 1,024 to 256 vectors, on the reasoning that a smaller sample is enough once the error is being measured from real data rather than synthesized (and validated by benchmarks). The net effect was comparable wall-clock cost to the synthetic residual formula it replaced, but grounded in real per-cluster residuals, accurate enough that force-merge and background merge could be unified onto one path.</p><p>As an example, we take five different benchmark datasets and calculate the quantization error <a href="https://en.wikipedia.org/wiki/Standard_deviation">standard deviation</a> (SD) by directly measuring the gap between exact and quantized dot products on a sample of real (or, for the synthetic residual formula, fabricated) residuals, then extrapolating that measurement to the full corpus size. They differ only in how much sampling and regression goes into that extrapolation: the multi-sample scaling fit sweeps fifteen sample sizes and fits how error scales with cluster size, the single-pass real residual measurement takes one larger real residual sample and reuses the manifold's intrinsic dimension to estimate the size dependency, and the synthetic residual formula skips real residuals altogether and samples from a synthetic Gaussian from the manifold's expected rank distance. We treat the multi-sample scaling fit as ground truth in this comparison because it's the most sample rich of the three, not because it's a zero variance measurement of the "true" corpus-wide error (it has its own sampling noise too). The table below summarises the methods and findings.</p><p>Method</p><p>How it works</p><p>Speed</p><p>Accuracy</p><p>When used</p><p>Multi-sample scaling fit</p><p>Clusters at 15 sample sizes, fits regression</p><p>Slow</p><p>	Gold standard</p><p>Ground truth baseline</p><p>Single-pass real residual</p><p>One clustering pass + manifold invDim plugin</p><p>Fast</p><p>Near gold standard</p><p>	Background + force merge</p><p>Synthetic residual formula</p><p>Gaussian from manifold density estimate</p><p>Fast</p><p>	Inflated on anisotropic data</p><p>Deprecated</p><p>In order to exchange methods, we only need to be confident that they agree. This question can be answered independently of the correctness of the actual estimates, which we verified in our <a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-elasticsearch">previous post</a> for the multi-sample scaling fit. The figures below report the predicted quantization SD and the predicted recall@10, which is influenced by how we estimate the error. We report the analytical recall the manifold model predicts as a function of the quantization parameters, given the estimated error distribution perturbing the true distance ordering. This way, we isolate the quantization error's effect on ranking from any separate recall loss the IVF index itself might introduce, which is a distinct error.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ab129decffc7de5/6a6a33f15af6b78d878d6898/8e38157b97d3ab6ff0b8e711e7586c408e2368a8-2048x766.jpg" alt="Bar charts comparing vector quantization error estimation methods across five datasets for predicted recall and error std" /><p>The single-pass real residual measurement's calculated error SD is closer to the multi-sample scaling fit (our gold standard), with respect to the synthetic Gaussian residuals. Consequently, the predicted recall is closer when using the single-pass + manifold plugin method. Indeed, we found the models to be essentially interchangeable regarding the indexing decisions they lead to. Critically, we lower the calibration overhead by an order of magnitude.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt92f5f35bd60f01ec/6a6a33f20a222b3ff8877f36/2198ab91820a1f90fc70005dc27d7ae95c7ddb91-1744x1170.jpg" alt="Bar chart comparing wall-clock calibration time across three vector quantization error estimation methods and five datasets" /><h2>Auto calibration overhead on indexing performance</h2><p>We compared the cost of auto calibration on indexing, when compared with ES defaults, over 18 public benchmarks. We noticed that more than 50% of the datasets report an auto calibration overhead below 2%. Three datasets report 16-27% overhead, while two datasets sit in the 31-35% overhead.</p><p>The merge overhead is larger for smaller datasets (Fashion-MNIST, FiQA) that get indexed in a few seconds; that is expected as the size of the vector samples being used for calibration is fixed and therefore more noticeable with tiny datasets. In fact, for larger datasets like DBPedia-Entity and HotpotQA (5M doc vectors) the overhead is sometimes not noticeable and within 11% in the worst case.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt344490ae214fc84c/6a6a33f315fc5c197b9e4941/d73ffb22c77f669a0b4205cc2825bda7611494b9-1424x1256.jpg" alt="Bar chart showing auto-calibration indexing time overhead as a percentage across 18 vector quantization benchmark datasets" /><h2>What quantization parameters does auto calibration choose?</h2><p>Looking at the encoding auto-calibration landed on for each of the real datasets:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt085423af7a4831fd/6a6a33f4f3dc0ea46a6b78a2/527d33a1ac915bd84700447a936cd0113e84a856-2048x996.jpg" alt="Auto-calibration quantization parameter choices across 18 datasets: document bit-width and oversample depth distribution" /><p>Query bits were 4 in every dataset. While query bits aren't priced into the cost formula, we still iterate through lower query bits first (e.g., at 1 bit doc vectors, we first evaluate recall for 1 bit query vectors, then for 4 bit query vectors); so it’s possible for some datasets to even choose symmetric 1-bit quantization. The center of mass is a 2-bit document encoding with somewhere between 1.5x and 1.75x oversampling; 4-bit only shows up for two genuinely harder datasets (Fashion-MNIST's image embeddings, GIST-1M), and 1-bit only for a handful of the text-embedding models that are most robust to quantization. In fact, our own models are among those that quantize best: we selected 1 bit documents for all three corpuses we tested with <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service">Jina v3</a>.</p><h2>Recall and QPS improvements from automatic calibration</h2><p>Auto-calibration is a broad win across the eighteen datasets: QPS improves in 15 of 18 cases (often substantially, double digits on about ten, and over +50% on FiQA GTE, Fashion MNIST, and Glove-200), and recall improves in 15 of 18 cases too, including a dramatic +295.7% rescue on Fashion MNIST. Most datasets see gains on both metrics simultaneously, and even the more modest cases still land solidly positive, recall improvements are commonly in the high single digits to double digits, QPS gains follow a similar pattern. Where either metric does dip, the drops are small and contained: the three QPS regressions all stay under 1.5%, and the three recall regressions all stay under 2%.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbb5e4929226cdbff/6a6a33f440a4946b5dca5c9e/517b55375a9a4bfb81ed2bcf8a2a24757f5b0373-2048x1140.jpg" alt="QPS and recall percentage change from auto-calibrated vector quantization vs Elasticsearch defaults across 18 datasets" /><h2>How to enable auto-calibrated vector quantization in Elasticsearch</h2><p>The feature is not enabled by default for now, and opt-in via <code>auto_calibrate</code> on <code>bbq_disk</code> index options:</p>"index_options": {
    "type": "bbq_disk",
    "auto_calibrate": true
}<p>With this set, you no longer need to guess at bits, oversampling, or preconditioning: each segment picks the cheapest configuration that's predicted to hit 90% recall@10 for its own vector distribution, and re-evaluates that choice every time it's merged.</p><h2>What's next for automatic vector quantization in Elasticsearch</h2><p>Our <a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-elasticsearch">first post</a> showed that recall could be predicted in closed form from a small sample. Turning that into something running inside a real merge path meant a second round of engineering decisions that the model itself doesn't answer: how to order a sweep over candidates so it's cheap in the common case, how to price oversampling against document bits given how each is actually paid for at query time, and how to estimate the error term itself cheaply without quietly wrecking its accuracy.</p><p>In the end, we have a feature that allows us to tailor indexing choices to the data characteristics, with less than 11% overhead to index time for large indices. This gives us the ability to accurately control recall while optimizing quantization and oversampling choices for query performance. We got an average increase of 16.7% in QPS when we enabled this feature compared to our previous default settings for DiskBBQ. All while reliably achieving our target recall. Taking away the configuration burden from the user actually allows us to make better choices; it is a win-win.</p><p>This is the beginning of a longer journey that we’re working on to bring automatic configuration based on a combination of better understanding of the operating environment and better understanding of the data characteristics. We look forward to sharing more of this work with you in the near future.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-diskbbq</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-diskbbq</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[ML Research]]></category>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <dc:creator><![CDATA[Tommaso Teofili,Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcd9291668fb96d26/6a6a33f58c87dc83b00d067e/6f40d849745ffb10d753d47d76c12b4639213c90-2382x1326.png" length="0" type="image/png"/>
    <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your agents have been keeping receipts: turning Elastic Agent Builder's built-in OTel traces into token cost dashboards in Kibana]]></title>
    <description><![CDATA[Your Agent Builder agents already log every LLM call as an OTel trace, and that agent tracing data can power token cost dashboards and budget alerts before one runaway conversation quietly wrecks your month.]]></description>
    <content:encoded><![CDATA[<p>Every Elastic Agent Builder conversation already generates a full OpenTelemetry trace. LLM calls, tool executions, token counts, all logged by default into Elasticsearch data streams you can query with ES|QL. Most teams don't look at this data until something breaks, which means they're sitting on usage trends, latency bottlenecks, and cost signals they could have caught earlier. This post covers how to build token cost dashboards in Kibana, set alerts that fire when a conversation blows past 256,000 tokens, and use the waterfall timeline to see exactly where your agent spent its time.</p><h2>What is an Agent Builder OTel trace and what does it capture?</h2><p>When your agent runs, Agent Builder records everything that happened as an <a href="https://opentelemetry.io/docs/concepts/signals/traces/">OpenTelemetry (OTel) trace</a>. Think of a trace as a receipt for a single conversation turn. Every LLM request, tool call, and agent action is recorded as an individual span in Elasticsearch, which is a unit of work or operation. When opted in, additional details like user prompts, LLM responses, tool outputs, and conversation IDs are captured as structured span attributes on the chat span. All of this is scoped to your Kibana space.</p><h2>How to enable agent tracing and privacy controls in Kibana</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc85524772be260c0/6a6a33e610787d6661a77fa1/a0200d33905eee1439500fc3b7bd0c6a74af1fc8-1999x1266.png" alt="Agent Builder Traces settings in Kibana showing tracing toggle and advanced privacy controls for OTel trace data" /><p>To begin capturing trace data, ensure the following toggles under <strong>Agent Traces</strong> in Gen AI Settings are active within your environment:</p><ul><li><p><strong><code>agentBuilder:tracing:enabled</code></strong> — This gen AI setting manages the collection of traces and is enabled by default.</p></li></ul><p>Advanced privacy controls, located under the default tracing toggle, also let you collect message content. While prompts and tool outputs are masked by default, you may choose to enable them to support more robust traces:</p><ul><li><p><strong><code>agentBuilder:tracing:includeUserPrompts</code></strong></p></li><li><p><strong><code>agentBuilder:tracing:includeLlmResponses</code></strong></p></li><li><p><strong><code>agentBuilder:tracing:includeToolDetails</code></strong></p></li><li><p><strong><code>agentBuilder:tracing:includeSystemPrompt</code></strong></p></li><li><p><strong><code>agentBuilder:tracing:includeRealNames</code></strong><strong>:</strong> Retains real agent/tool names instead of anonymizing to custom</p></li><li><p><strong><code>agentBuilder:tracing:includeRealIds</code></strong>: Retains the actual conversation identifiers instead of the default hashed versions. This means trace data collects original IDs, which can link traces to specific user sessions (PII).</p></li></ul><p>Only enable these if you understand what data your agents handle and have appropriate data governance in place.</p><h2>How Agent Builder stores OTel trace data in Elasticsearch</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltadcdb2eeb64bb453/6a6a33e515fc5c4fa99e4935/0b22d6473e530801a7433a662a77051ecbc22814-1999x1436.png" alt="Waterfall view of an Agent Builder OTel trace showing span hierarchy, LLM call durations, and tool executions" /><p>The Agent Builder utilizes OpenTelemetry semantic conventions. This results in a structured hierarchy of spans that provides a granular view of the agent's internal logic:</p><p>Span</p><p>Type</p><p>What it captures</p><p>`invoke_agent &lt;name&gt;`</p><p>CHAIN</p><p>Full turn lifecycle, from user input to final reply</p><p>`invoke_agent &lt;name&gt;`</p><p>AGENT</p><p>Single agent execution: reasoning, tool calls, reply</p><p>`chat &lt;model&gt;`</p><p>LLM</p><p>One LLM request: model, latency, token counts</p><p>`execute_tool &lt;toolName&gt;`</p><p>TOOL</p><p>Tool invocation: arguments, duration, result</p><p>Trace data is written to a dedicated data stream per Kibana space, keeping conversation data cleanly isolated. To query your traces in Discover, target the index for your space directly:</p><p>For the default space, that’s <code>traces-agent_builder.otel-default</code>. If the advanced privacy controls are turned on, then those span attributes will also be shipped to the traces data stream with the original spans. This index lets you query the raw message content to see what's actually being said in conversations. It is best practice to avoid using wildcards to prevent mixing data from unrelated spaces.</p><p>Agent Builder ships with a built-in skill called <code>agent-builder-traces</code>, installed automatically when<code>agentBuilder:tracing:enabled</code> is on. You can use it to ask questions directly about your trace data, making it easy to explore agent behavior without writing ES|QL from scratch.</p><h2>How to debug agent behaviour with the OTel trace waterfall view</h2><p>The trace waterfall shows every step of an Agent Builder session as a timeline. To open it, navigate to the specific turn in the conversation UI and select the trace icon.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c19c6699996667b/6a6a33e704e0aa0bbe5528f9/29984c0b4f0c290170ceb48e01e404bdc25db92d-602x158.png" alt="Trace icon in the Agent Builder conversation UI used to open the OTel trace waterfall view" /><p>This launches a waterfall timeline breaking down every step of your agent's execution. At the top level, you'll see the <code>invoke_agent</code> parent span with the full end-to-end duration of your agent run. Nested beneath it are chat spans, each representing a single LLM request and showing exactly how long the model took to respond. Alongside those are <code>execute_tool</code> spans, one per tool call, where you can see which tool was called, what arguments it received, and how long it ran. This allows you to trace the exact sequence your agent followed, pinpoint timing bottlenecks, and see where errors occurred.</p><h2>How to build token cost dashboards from trace data</h2><p>Discover gives you raw trace data, but most teams want answers to operational questions like "how many tokens did we burn today?", "which tool is called most often?", and "how many unique users interacted with the agent this week?". These would require a dashboard built directly against the trace data.</p><p>There is an Elastic-managed out-of-the-box dashboard called <em>[Elastic] Agent Builder Overview</em> that can be installed by clicking in the top-right corner of the <em>Agent Traces</em> section within GenAI Settings.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt21bd488325991b1f/6a6a33e8d57c1dc19ac13eec/805391d2c18083589b4e64f450e2bc33ba32be9a-1999x117.avif" alt="" /><p>It contains basic details spanning Token Usage and Cost, Conversation Volume and Latency, Agent Execution, and Tool Call Frequency and Errors.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5775187fb11ae16e/6a6a33e955755b13022bd23e/70b96d6ff2e96358831a1bf8a08aeecec8c41b0e-1999x1115.png" alt="Elastic Agent Builder Overview dashboard showing token usage, cost metrics, and LLM request counts" /><p>However, a custom dashboard may be more efficient. If you want something more tailored, build <a href="https://www.elastic.co/docs/explore-analyze/visualize/lens">Lens</a> panels directly against the OTel trace index. Some useful panels to start with:</p><ol><li><p>Most active conversations by token spend</p></li></ol><p>create a horizontal bar chart against <code>traces-agent_builder.otel-&lt;space-id&gt;</code>. Set the x-axis to <code>gen_ai.conversation.id</code> and sort descending and limit to the top 10. Set the y-axis to a sum of <code>gen_ai.usage.input_tokens</code>plus<code>gen_ai.usage.output_tokens</code>. Input the formula as: </p><p>Conversations with the most LLM round-trips</p><p>Option to create this visualization with a simple ES|QL query that would look like:</p><p>There is also a <code>dashboard-management</code> skill that can be used to help create traces visualizations using natural language.</p><h2>How to set token cost alerts for Agent Builder conversations</h2><p>Token consumption is the most direct cost lever for LLM-based agents. A single runaway conversation can blow through your monthly budget before anyone notices.</p><p>Elastic alerting lets you define a threshold rule directly against the trace data. Navigate toObservability &gt; Alerts &gt; Manage Rules &gt; Create Ruleand selectElasticsearch queryas the rule type.</p><p>A rule that fires when any single conversation exceeds 256,000 tokens looks like this as an ES|QL rule:</p><p>Set the schedule to run every 15 minutes and configure the action to send a Slack notification or open a PagerDuty incident. The <code>gen_ai.conversation.id</code> value in the alert payload gives you the exact conversation to inspect.</p><h2>What’s coming next for Agent Builder observability</h2><p>Agent traces give you visibility that goes far beyond debugging. Once you've built dashboards and configured alerts against Agent Builder trace data, you have a live pulse on how your agents are behaving in production. If you haven't already, spin up Agent Builder in your Kibana space, make sure tracing is enabled, and run a few conversations. Check Discover, pull up the waterfall view, and see what your agent is actually doing under the hood.</p><p>This is the first in a series of posts on Agent Builder observability. Coming up, we'll go deeper on using the<code>agent-builder-traces</code> skill to query your data conversationally, building custom evaluation pipelines from trace data, and using traces to feed conversation history back into your agents. Your agents have been keeping secrets. It's time to make them talk.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/opentelemetry-tracing-agent-builder</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/opentelemetry-tracing-agent-builder</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[Meghan Murphy,Pablo Neves Machado]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte3979255ddfc7f45/6a17e25ffaa913812f93c7cb/92c517a2e7b36122a18feee317a0215981b62b6b-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One prompt, a complete workflow: Elastic's AI agent writes your automation for you]]></title>
    <description><![CDATA[Elastic Workflows takes a plain-text prompt and generates YAML you can inspect, version and run against your Elasticsearch data. Now GA, with human-in-the-loop workflows in Slack, parallel execution, and 10 new connectors.]]></description>
    <content:encoded><![CDATA[<h2>One prompt, a complete workflow: Elastic's AI agent writes your automation for you</h2><p>Elastic Workflows now writes its own YAML. You type what you want automated in plain language, the Elastic AI Agent generates a complete workflow against a typed schema, and nothing runs until you've read it. YAML is why this works: it gives the model a constrained, well-typed target, so what comes back is actual building blocks you can edit and run.</p><p><strong>What is new in Elastic Workflows 9.5:</strong></p><ul><li><p><strong>Natural language authoring</strong> is GA and on by default: describe an automation, and the Elastic AI Agent writes the workflow, you review and run it.</p></li><li><p><strong>Versioning</strong> with diff and one-click rollback is GA.</p></li><li><p>Three new experimental previews (behind an advanced setting): a visual mode that renders a workflow as a graph, human-in-the-loop steps that reach people in Slack for input or approval, and parallel execution.</p></li><li><p>More to build on: new connectors, event triggers that react to Cases activity, token metering for AI steps, and a queue strategy for concurrency.</p></li></ul><p>Workflows is the automation engine built into the Elastic platform. It reached general availability in 9.4, enabled by default and running against your Elasticsearch data with the connectors and access controls you already have. This post walks through what 9.5 adds.</p><h2>Why YAML makes AI workflow automation work</h2><p>YAML is the authoring language for Elastic Workflows because it's declarative, version-controllable, diffable, and portable across environments. It reads the same in a pull request as it does in the editor.</p><p>It was also a bet. Large language models (LLMs) are very good at generating structured, well-typed content, and a workflow language is close to an ideal target for that. Ask for prose and a model can wander. Ask for a workflow against a typed schema, with named step types and validated inputs, and there is a right shape for the answer.</p><p>In 9.5 that bet pays off, and it is GA. Inside the workflow editor, you write what you want in plain language:</p><p>When a detection alert fires for a host, pull the last 24 hours of related logs, ask the AI step to summarize what happened, and post the summary to the on-call Slack channel.</p><p>The Elastic AI Agent generates the workflow: the trigger, the Elasticsearch query, the AI summarize step, the Slack step, wired together with the right inputs and outputs. You get inspectable, editable YAML back. Nothing runs until you read it, adjust it, and decide to run it. You can also point it at a workflow you already have and describe the change you want, and it edits in place.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt024bee0a140cf88c/6a6a33d32e4eb74ad5d64368/7cd5a718081ce86a246df7fda74d17a2e8b7613f-1999x1124.png" alt="AI workflow builder generating YAML from a natural language prompt in the Elastic Workflows editor with AI Agent panel" /><p>The output is worth reading because there is a rich, well-typed language underneath it. A short prompt expands into real building blocks:</p><ul><li><p><code>foreach</code> and <code>while</code> loops, with guardrails that stop runaway execution.</p></li><li><p><code>switch</code> for clean multi-way branching.</p></li><li><p>data steps like <code>data.filter</code> and <code>data.aggregate</code> for in-flight transforms.</p></li><li><p><code>on-failure</code> handling on every step, so you can retry, continue, or abort.</p></li><li><p><code>workflow.execute</code>, so one workflow can call another and you assemble new automation from pieces you have already tested.</p></li></ul><p>Natural language gets you the first draft fast; the language underneath is what makes that draft real.</p><h2>Workflow versioning with diff and one-click rollback</h2><p>Versioning is GA in 9.5. Every workflow now has version history: every change is tracked and diffable, and you can roll back to any prior version in one click. You see who changed what and when, compare any two versions side by side, and undo a bad edit without reconstructing it by hand. This is the change-control foundation teams asked for before they would run automation against production systems.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9901bbd63fbe380f/6a6a33dc99442c082ddf1d88/63eecf4cdb28702d03752e0dacd99c7ff9b8f285-1920x1080.gif" alt="Toggling between the Elastic Workflows YAML editor and the visual graph view of a workflow's steps and branches" /><p>Versioning pairs with the production controls already in place: granular role-based access control (RBAC) over who creates, edits, runs, and views workflows, every management action written to the security audit log, and import/export that moves workflows between environments with their connector references intact.</p><p>Versioning in the product is the near end of a longer arc. A workflow is a declarative YAML definition, plain text with a well-defined schema, which means it already fits the tooling built for code: it can be diffed, reviewed, and version-controlled. Where we are headed is full, bidirectional integration with the version control systems you already use, so that a workflow could live in your repository, move through review, and deploy the same way the rest of your software does. That is coming, and the same bet that made natural language authoring work, a declarative and well-typed language, is what will let you manage workflows as code.</p><h2>Visual mode, human-in-the-loop workflows, and parallel execution</h2><p>Three of the newest 9.5 additions ship in Experimental. To try them, turn on <strong>Elastic Workflows: Experimental Features</strong> in <strong>Stack Management → Advanced Settings</strong> (it requires a page reload). Here is what each one does.</p><h3>Visual workflow editor: see the logic as a graph</h3><p>You can now switch a workflow between the YAML editor and a visual mode that renders the workflow as a graph. The graph lays out your steps, branches, and flow control, so you can see the logic and the paths a run can take at a glance, alongside the YAML. It is read-only in 9.5: you still author in YAML, and the graph stays in sync as you edit. It is the first step toward a full drag-and-drop builder, which is coming next.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c3f9054539e5440/6a6a33e3c40efb720bd3094c/4f225a895d29df4d5af136167024f1403adfbc27-1920x1080.gif" alt=" Elastic Workflows YAML editor showing a security workflow with foreach loops, conditional logic and on-failure handling" /><h3>Human-in-the-loop workflows with approval steps in Slack</h3><p>Workflows could already pause for a person in 9.4 with <code>waitForInput</code>, which presents a schema-defined form and lets the response drive what happens next. 9.5 adds <code>waitForApproval</code> for the most common case of all, a binary approve or reject with labels you choose, and it extends both steps to their first external surface: Slack. Both pause the run until someone answers, with a timeout so a workflow never hangs forever. Not every decision should be fully automated, and this is how you put the human on exactly the steps that need one.</p><p><code>waitForInput</code> is the flexible one: define a schema for the input you want back, and the response comes through typed. Reach for it when the choice is more than yes or no. Here, an Observability workflow has caught a service-level objective (SLO) burn-rate alert on <code>payment-service</code> and asks the on-call engineer which mitigation to run:</p>- name: ask_sre
  type: waitForInput
  with:
    message: "payment-service is burning its error budget. Which mitigation should we run?"
    schema:
      type: object
      properties:
        mitigation:
          type: string
          enum: [restart, scale_up, monitor]
        reason:
          type: string
      required: [mitigation]
    channels:
      slack_api:
        connector-id: my-slack-connector
        channels: ["sre-oncall"]<p><code>waitForApproval</code> is the new one in 9.5, for a straight approve or reject. Here a security workflow has decided a host should be contained, but gates that destructive action on a human before it runs:</p>- name: request_containment_approval
  type: waitForApproval
  timeout: 24h
  with:
    message: &gt;
      Isolate {{ event.alerts[0].host.name }}? This will cut the host off from
      the network until it is manually released.
    approveLabel: Isolate host
    rejectLabel: Leave connected
    channels:
      slack_api:
        connector-id: my-slack-connector
        channels: ["soc-response"]
- name: act_on_decision
  type: switch
  expression: "{{ steps.request_containment_approval.output.response.approved }}"
  cases:
    - match: "true"
      steps:
        - name: isolate_host
          # ... run the containment action
    - match: "false"
      steps:
        - name: keep_monitoring
          # ... skip containment, keep watching<p>The new piece is the <code>channels</code> block. A wait step can now deliver to where people already are, and in 9.5 that means Slack: the workflow posts the request to a Slack channel, the person responds from Slack, and the workflow resumes with their answer. No one has to be sitting in Kibana for the automation to move. Slack is the first external surface, and richer delivery experiences across more channels are on the way.</p><h3>Parallel execution: run independent workflow steps at once</h3><p>By default a workflow runs one step after another, which is what you want when each step depends on the last. But plenty of work does not: enriching an alert from three sources, checking a file against several reputation services, investigating a handful of leads. Run those sequentially and the workflow is only as fast as the sum of its parts, when it could be as fast as the slowest one. The new <code>parallel</code> step lets you run independent work at the same time. It works two ways.</p><p>The first is when you know the work ahead of time. You define a fixed set of tasks, and they run at the same time, so you gather all the results in one step instead of waiting for each in turn. Enriching a security alert from two sources at once is the classic case:</p>- name: enrich
  type: parallel
  branches:
    - name: virustotal
      steps:
        - name: scan_hash
          type: virustotal.scanFileHash
          # ... pass the alert's file hash
    - name: ip_reputation
      steps:
        - name: check_ip
          type: abuseipdb.checkIp
          # ... pass the alert's source IP<p>The second is when you do not know the work ahead of time. You give the step a list, and it runs the same work once per item, all at the same time, up to a concurrency limit you set. Root cause analysis is a good example. An earlier AI step generates a set of hypotheses for why a service is degrading, and you do not know in advance how many there will be or what they are. Rather than investigate them one after another, you pass the list into a parallel step, and it investigates every hypothesis at once:</p>- name: investigate_hypotheses
  type: parallel
  foreach: "{{ steps.generate_hypotheses.output.hypotheses }}"
  concurrency:
    max: 5
  steps:
    - name: investigate_hypothesis
      type: ai.agent
      # runs once per hypothesis, up to 5 at a time
      # the agent gathers evidence for {{ foreach.item }} and scores it<p>You control how many run at once with <strong>concurrency</strong>, and the engine caps both the concurrency and the total number of parallel tasks so a workflow cannot spawn unbounded work. All the results are available to the next step, so the workflow runs the parallel work, then continues once every task finishes.</p><h2>More in Elastic Workflows: connectors, triggers, token metering and concurrency</h2><p>Beyond the headline features, 9.5 widens what a workflow can reach and react to.</p><h3>New connectors: BigQuery, Snowflake, HubSpot, Cortex XSOAR and more</h3><p>The connector catalog keeps growing, with native connectors added in 9.5 for:</p><ul><li><p>BigQuery</p></li><li><p>Snowflake</p></li><li><p>Box</p></li><li><p>Dropbox</p></li><li><p>OneDrive</p></li><li><p>Outlook</p></li><li><p>Azure Blob</p></li><li><p>Google Cloud Functions</p></li><li><p>HubSpot</p></li><li><p>Cortex XSOAR connector for security automation</p></li></ul><p>More are on the way, and when there is not a dedicated connector for the system you need, the <code>http</code> step is the escape hatch: it can securely call any API endpoint, with credentials supplied by a connector rather than written into the YAML.</p><h3>Event-driven triggers for Elastic Cases</h3><p>A workflow starts from a trigger, and 9.5 widens what a workflow can respond to. Cases now emit events a workflow can subscribe to:</p><ul><li><p>A case is created.</p></li><li><p>A case is updated.</p></li><li><p>Its status changes.</p></li><li><p>A comment is added.</p></li><li><p>An attachment is added.</p></li></ul><p>So a workflow can run the moment a case opens, to enrich it, tag it, or notify the right channel, or when its status flips to a state you care about, rather than polling for changes. Alert-triggered workflows also receive richer rule context now, including the rule's tags, type, and parameters, so the workflow has more to work with before it acts.</p><h3>Token usage and cost tracking for AI workflow steps</h3><p>Workflows can call AI steps: <code>ai.prompt</code> for a freeform prompt, <code>ai.classify</code> to sort something into categories, <code>ai.agent</code> to hand a task to an Agent Builder agent. In an automation that runs thousands of times a day, those calls add up. 9.5 now reports token usage for every AI step, input, output, cached, and total, both per step and for the whole run. You can see exactly what the AI in a workflow consumes, track it over time, and tune a prompt or a model choice with the numbers in front of you.</p><h3>Workflow concurrency: cancel, drop or queue</h3><p>A workflow's concurrency setting decides what happens when a new execution starts before the last one finishes. 9.5 adds a third strategy, so you can pick the behavior that fits the workflow:</p><p>Strategy</p><p>Use it when</p><p>Cancel-in-progress</p><p>Only the latest execution matters, like recomputing a current state</p><p>Drop</p><p>An execution already in flight covers the situation and extras are redundant</p><p>Queue (new in 9.5)</p><p>Every execution matters and order does, so they line up and run one after another, with a queue size and time-to-live you control</p><p>Queue is what you reach for when executions touch the same resource or should not overlap. Audit logging also covers more of the lifecycle in 9.5, including restoring a workflow from its version history.</p><h2>Get started with Elastic Workflows</h2><p>The fastest way to see this is to describe something you want automated. Open the workflow editor in 9.5, type it in plain language, and read the YAML that comes back. Natural language authoring is on by default. To try the visual mode, human-in-the-loop steps, and parallel execution, turn on <strong>Elastic Workflows: Experimental Features</strong> in <strong>Stack Management → Advanced Settings</strong>.</p><p>The theme across 9.5 is a shorter path from idea to running automation. You describe what you want and AI drafts it, you see it as a graph and version it as you go, you pause it for a person in Slack when a step needs judgment, and you run independent work in parallel. For the full details, see the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Workflows documentation</a>.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-workflow-automation-natural-language</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-workflow-automation-natural-language</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Tinsae Erkailo,Shahar Glazner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda47d75430c4fa7c/6a17f5cae3179149242d5963/d5d04bbcfc3925f48f3487ea4c7e0dd2205316d0-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[56% faster, up to 50% better retrieval performance: What's inside Jina's new 600 million parameter listwise reranker]]></title>
    <description><![CDATA[Jina Reranker 3.5 beats v3 by 50%+ on case law, closes the gap with models 7x its size on legal, medical, and financial benchmarks, and beats them outright on structured data. It's a drop-in replacement for v3, with no API changes.]]></description>
    <content:encoded><![CDATA[<p><code>jina-reranker-v3.5</code> is a 600 million parameter reranker that delivers major gains over its predecessor, <a href="https://jina.ai/models/jina-reranker-v3"><code>jina-reranker-v3</code></a>, on legal retrieval, and closes most of the gap to models seven times its size on legal, medical, and financial reranking. On long documents, it runs up to 56% faster than <code>jina-reranker-v3</code> and scores over 50% higher on case law retrieval. It also beats Qwen3-Reranker-4B on the STaRK structured data benchmark."It’s a drop-in replacement for v3 users and requires no changes to the code that accesses the model.</p><h2>What’s a reranker and how does it work?</h2><p>A <em>reranker </em>is an AI model used near the end of an information retrieval pipeline, after other modules have assembled a short list of candidate matches to a query. It’s trained to order the candidate list from best matching to least. Using a specialized model focused purely on ranking candidate matches can improve result quality dramatically.</p><p>Jina AI’s latest rerankers use a technique called <em>late interaction</em>, where queries and documents are encoded separately into lists of token embeddings that reflect each token’s semantics in context and then compared to each other.</p><p>This is an AI analog of lexical and grammatical disambiguation.</p><p>For example, consider the meaning of the word <em>match </em>in these two sentences:</p><ul><li><p>She looked for a match to light the candle.</p></li><li><p>She looked for a match on Tinder.</p></li></ul><p>The first sentence might be a match for queries about matchboxes; the second for queries about romance.</p><p>Transformer-based models do this kind of in-context disambiguation but bring much richer information into the token embeddings they produce. The word <em>match</em> might have a semantic embedding near to words like <em>fire</em> or <em>illumination</em> in the first sentence, while in the second, it might be closer to <em>smartphone</em> or <em>swipe</em>.</p><p>Late interaction rerankers generate these context-enriched token embeddings for both the query and the candidate documents and then compare them to produce sortable scores. They’re completely agnostic about how candidate match lists are created. The reranker works exactly the same when combined with lexical search schemes, like BM25, AI-driven semantic embeddings-based retrieval, or hybrid and federated search systems that may retrieve multiple candidate lists from different sources or using different algorithms. Of course, the results always depend on the quality of the candidates, so a reranker can’t fix bad first-stage retrieval, but it almost always improves whatever you’ve got.</p><p><code>jina-reranker-v3.5</code> is a <em>listwise</em> reranker using the <a href="https://jina.ai/news/jina-reranker-v3-0-6b-listwise-reranker-for-sota-multilingual-retrieval/#:~:text=query%2Ddocument%20interaction%20%22-,last%20but%20not%20late,-.%22%20It%27s%20%22last"><em>last-but-not-late</em></a> technique developed for <code>jina-reranker-v3</code>. The query and a list of candidate matches are passed into the model together and processed in one pass, returning a numerical score for each candidate. This enables the model to use context information from the query and the full candidate list to make sense of the entire input, producing better results because of the richer information available to it.</p><p><code>jina-reranker-v3</code> proved that listwise rerankers with last-but-not-late interaction can compete with the largest models on general reranking benchmarks. Only <code>jina-reranker-v3.5</code> and models with over four billion parameters beat it on <a href="https://mteb-leaderboard.hf.space/benchmark/MTEB(Multilingual%2C%20v2)">Massive Text Embedding Benchmark (MTEB) reranker tasks</a>. However, this approach places strict limits on candidate list sizes. The query and all candidate matches must fit in the input context window of the model.</p><h2>What problems does Jina Reranker v3.5 solve?</h2><p>Despite having frontier-level performance overall, <code>jina-reranker-v3</code> has some notable performance gaps:</p><h3>Domain-specific text retrieval</h3><p><code>jina-reranker-v3</code> was trained on general text corpora and, as a result, it underperforms on important use cases, particularly:</p><ul><li><p>Legal texts, like case law and contract clauses.</p></li><li><p>Medical literature, like clinical trials and patient records.</p></li><li><p>Financial datasets and other texts full of important numbers.</p></li><li><p>Computer programming and IT documentation.</p></li><li><p>Product catalogs full of technical terminology and specifications.</p></li></ul><h3>Structured data: Tables, JSON, and key-value records</h3><p>Vast quantities of essential, real-world data is encoded in spreadsheets, tables, key-value lists, and structured records, like JSON data. However, rerankers trained purely for textual comparison, like <code>jina-reranker-v3</code>, perform poorly on this kind of data.</p><h3>Compute costs for long candidate lists</h3><p>The self-attention architecture at the core of most text-processing AI models means that memory and compute requirements grow quadratically with the size of its input. This makes <code>jina-reranker-v3</code>, like other AI models, very computationally expensive to run with a full input context window. But, to make the most effective use of the model, we want to put as many match candidates as possible into its input. When it’s at its most useful, it’s also slower and more expensive to run.</p><p>We’ve developed <code>jina-reranker-v3.5</code> specifically to address these issues without reducing its performance on general purpose text retrieval.</p><h2>What’s new in Jina Reranker v3.5?</h2><p><code>jina-reranker-v3.5</code> contains a modified self-attention mechanism that enhances performance, increases processing speed, and reduces the resources required at inference time to process a full input context window. We’ve also introduced a new three-stage self-distillation training process better suited to the sliding-window architecture of large input context models.</p><p>We’ve also curated and used training data focusing on the performance gaps we identified in <code>jina-reranker-v3</code>, including:</p><ul><li><p>Multilingual legal texts drawn from diverse international sources.</p></li><li><p>Medical texts drawn largely from scientific literature and materials used for other AI projects, including a collection of Chinese medical question-answer pairs.</p></li><li><p>Financial industry data, including investment-related question-answer pairs, regulations, and tables with numbers and associated texts.</p></li><li><p>Structured data, especially from ecommerce sources and public corpora of tables.</p></li><li><p>Expanded multilingual and cross-language texts.</p></li></ul><p>For details on the data sources and technical innovations in <code>jina-reranker-v3.5</code>, see <a href="https://arxiv.org/abs/2607.18152">our technical report</a>.</p><h2>How Jina Reranker 3.5 performs on retrieval benchmarks</h2><p>Parameters</p><p>597 million</p><p>Input modalities</p><p>Text only</p><p>Context window size</p><p>131,072 tokens</p><p>Maximum number of candidate matches</p><p>No fixed limit, but all candidates and query must fit in the context window.</p><p>Languages</p><p>Training in 52 languages</p><h3>General text reranking performance (BEIR and MIRACL)</h3><p><code>jina-reranker-v3.5</code> improves on <code>jina-reranker-v3</code>’s performance on general text reranking benchmarks. On the English-language <a href="https://github.com/beir-cellar/beir">Benchmarking Information Retrieval (BEIR) benchmark</a>, the average score has increased enough to surpass the frontier <a href="https://huggingface.co/Qwen/Qwen3-Reranker-4B">Qwen3-Reranker-4B</a> and <a href="https://huggingface.co/Qwen/Qwen3-Reranker-0.6B">0.6B</a> models and Mixedbread AI’s rerankers.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2be1aaecef5d0cbd/6a6a33f854090e576714707e/52c2c0bd6269881eb4e2716a81442030dbf23458-2048x785.png" alt="Jina Reranker v3.5 BEIR benchmark results compared to Qwen3 and Mixedbread rerankers" /><p>We’ve also improved <code>jina-reranker-v3</code>’s multilingual reranking performance on the Multilingual Information Retrieval Across a Continuum of Languages (MIRACL) benchmark. Only the four billion parameter Qwen3 reranker regularly beats <code>jina-reranker-v3.5</code>’s score.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9d9b745a3c23efd/6a6a33f899442c66f1df1d8c/cc9ba1d219b1c77ef0840c86c038088a66b40f6d-2048x785.png" alt="Jina Reranker v3.5 MIRACL multilingual benchmark results across 18 languages" /><h3>Legal, medical, and financial reranking</h3><p>The <a href="https://huggingface.co/blog/rteb">Retrieval Embedding Benchmark (RTEB) suite</a> consists of diverse domain-specific retrieval benchmarks. <code>jina-reranker-v3.5</code> outperforms <code>jina-reranker-v3</code> on all RTEB tasks related to law, medicine, and finance. Only the large Qwen3 reranker, at almost seven times as many parameters, has better average performance in those three domains.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6024d5c8ac1355ac/6a6a33f9b62af41d832635d8/1426abb0397e0b9d7c8115334ca5c4f899fa8fb6-2048x693.png" alt="Jina Reranker v3.5 RTEB domain-specific benchmark results for legal, medical and financial retrieval" /><p>The new model shows particularly strong improvements for legal data, beating <code>jina-reranker-v3</code>’s score by over 50% on case law retrieval tasks.</p><p>Task</p><p>Reranker v3</p><p>Reranker v3.5</p><p>Improvement v3 to v3.5</p><p>AILA-Case</p><p>20.82</p><p>32.55</p><p>+11.73 (56%)</p><p>AILA-Statute</p><p>32.15</p><p>46.16</p><p>+14.01 (44%)</p><p>LegalQuAD</p><p>81.84</p><p>83.09</p><p>+1.25 (1.5%)</p><p>LegalSum</p><p>69.64</p><p>70.99</p><p>+1.33 (1.9%)</p><h3>Structured data reranking (Struct-IR and STaRK benchmarks)</h3><p>We evaluated <code>jina-reranker-v3.5</code>'s structured data reranking on two benchmarks: <a href="https://neurips.cc/virtual/2025/loc/mexico-city/poster/121702">Struct-IR</a> and <a href="https://stark.stanford.edu/">STaRK</a>. Both benchmarks contain AI-generated JSON text data covering a variety of applications, including product records, scientific papers, and biomedical knowledge bases.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1deca80ed81995d5/6a6a33f79b96f21e21baf2be/b847be39e6387c4d4eaf754c5d758fc59fad4349-2048x717.png" alt="Jina Reranker v3.5 structured data benchmark results on Struct-IR and STaRK" /><p><code>jina-reranker-v3.5</code> improves substantially on <code>jina-reranker-v3</code>’s score on the Struct-IR benchmark, once again only exceeded by Qwen3-Reranker-4B. On the STaRK benchmark, <code>jina-reranker-v3.5</code> beats all the other models we tested, of any size.</p><h3>Inference speed: Latency benchmarks for short and long documents</h3><p>The longer the candidate documents get, the more significant the architectural improvements we’ve brought to <code>jina-reranker-v3.5</code> are. To verify this, we used two retrieval datasets distinguished by large differences in the average document length:</p><p>Dataset</p><p>Avg. doc length</p><p>jina-reranker-v3</p><p>jina-reranker-v3.5</p><p>Speedup</p><p>BEIR Natural Questions</p><p>145.5 tokens</p><p>371.1 ms</p><p>305.3 ms</p><p>22%</p><p>RTEB AILAcasedocs</p><p>1,904.0 tokens</p><p>16,064.9 ms</p><p>10,290.9 ms</p><p>56%</p><p><code>jina-reranker-v3.5</code> is significantly faster in both cases. On the Natural Questions benchmark, there’s a 22% speedup compared to <code>jina-reranker-v3</code> with average request latency falling from 371.1 ms to 305.3 ms. Each query from the AILAcasedocs benchmark is much larger (more than 10 times larger on average) so it naturally takes longer to rerank on average: 16,064.9 ms for <code>jina-reranker-v3</code> and 10,290.9 ms for <code>jina-reranker-v3.5</code>. This represents a 56% speedup for the newer model, representing less latency for applications and lower computer costs.</p><h2>When should you use Jina Reranker v3.5?</h2><p>Reranking improves search precision in practically every case, and <code>jina-reranker-v3.5</code> has applications in a wide variety of information retrieval contexts. However, it has some limitations. The table below summarizes our best-practice advice:</p><p>Use case</p><p>Recommendation</p><p>General text retrieval in common international languages</p><p>Use `jina-reranker-v3.5`.</p><p>Legal, financial, and medical domain retrieval</p><p>Use `jina-reranker-v3.5`.</p><p>Semi-structured data, tables, product information texts for ecommerce</p><p>Use `jina-reranker-v3.5`.</p><p>Non-text or mixed-media data</p><p>Use `jina-reranker-m0`, which supports both text and image input.</p><h2>How to use Jina Reranker 3.5 with the Elastic Inference API</h2><p><strong><code>jina-reranker-v3.5</code></strong> is available via the <a href="https://jina.ai/reranker/">Jina API</a> with free tokens to try it out. It’s also available via the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/inference-api">Elastic Inference API</a> and <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a>.</p><p>If you’re already using <strong><code>jina-reranker-v3</code></strong>, all you have to do is change the name of the model in the <code>model</code> field of your request to the Jina API or <code>model_id</code> field when configuring an Elastic Inference API endpoint. The two models have completely identical interfaces.</p><p>You can install <strong><code>jina-reranker-v3.5</code></strong> as a <a href="https://www.elastic.co/search-labs/blog/on-prem-ai-jina-embedding-models">Jina On-Prem container</a> to get a completely self-contained server that runs on your own hardware. The model weights are also available to download for testing and research. Follow the instructions on the <a href="https://huggingface.co/jinaai/jina-reranker-v3.5">model’s page at Hugging Face</a>. In both cases, the model is available under a <a href="https://creativecommons.org/licenses/by-nc/4.0/deed.en">CC BY-NC-4.0 license</a>, so you’re free to try it out for testing, building prototypes, or doing scientific research. For commercial use, please contact Elastic sales.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/jina-reranker-35-legal-medical-structured-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-reranker-35-legal-medical-structured-data</guid>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Felix Wang,Scott Martens]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt29bbbf4463c73d8a/6a6a33fa55755baeaa2bd248/a6563ee307cc2d29722c490b043ee736c46974f3-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How Elasticsearch detects multiple change points in time series with 0.99 recall]]></title>
    <description><![CDATA[ES|QL's CHANGE_POINT command finds structural shifts, variance changes and spikes in any metric in ~1ms, without tuning anything per series.]]></description>
    <content:encoded><![CDATA[<p>The current generation of agentic models are remarkably good system troubleshooters. Given a hypothesis and the means to test it, they can reason about a failing service much the way a seasoned SRE does: form a theory, look for corroborating evidence, discard it when the data disagrees, and narrow in on a root cause. Their main limitation is not their ability to reason but their reach: they can only investigate what their tools let them see.</p><p>This is where Elasticsearch earns its place in the stack. It is already where a great deal of operational telemetry lives (logs, metrics, traces, events), and it exposes them through an expressive query and aggregation layer. That makes it a natural tool for an agent debugging a live system: it can slice by attribute, aggregate over time, correlate across signals, and drill from a symptom down to the documents that produced it.</p><p>We've been building an agentic layer on top of Elasticsearch that continuously monitors a system and root-causes issues as they arise. A recurring primitive in that workflow is time series event analysis. For example, given an error rate, a p99 latency, a queue depth and a throughput counter, tell me whether something happened, what it was and when. A transient spike in errors, a regime change in latency, and a step up in CPU usage are the signatures of the underlying fault, and they're typically what an agent examines first as it forms and tests hypotheses.</p><p>Elasticsearch has shipped a single-change-point aggregation for some time. It answers "did this series change?" with one verdict and the most significant change it found. That's a good fit for a dashboard, but less so for an agent, which often wants to interrogate a long window and enumerate everything of interest in it: the error spike at 02:14, the latency regime shift at 02:30, the throughput dip while the pod was being rescheduled. So we upgraded the capability to detect and report multiple events of multiple kinds in a single series. At the same time, we took the opportunity to further harden it to work reliably against whatever telemetry the agent points it at. This post describes how it works.</p><h2>Why single change point detection isn't enough for agents</h2><p>Concretely, we want a single entry point that takes a numeric time series and returns a small list of interesting events, each with a type, a location, a significance, and some key characteristics. We care about three classes of event, because they map onto three different kinds of underlying fault:</p><p>Event type</p><p>What changes</p><p>Detection channel</p><p>Example fault</p><p>Structural change</p><p>Level (step) or slope (trend) shifts to a new sustained regime</p><p>Value channel</p><p>Config push doubles baseline latency; memory leak turns a flat curve into a ramp</p><p>Distribution change</p><p>Noise level (variance) shifts while the mean holds steady</p><p>Dispersion channel</p><p>Service responds erratically at the same average latency</p><p>Point anomaly</p><p>Isolated spike or dip against a stable background</p><p>Value channel (pulse detector)</p><p>Single burst of errors; one-minute throughput drop during GC pause</p><p>The hard part is not detecting any one of these on clean, well-behaved data. The hard part is doing it on arbitrary telemetry without per-series tuning. The agent does not know in advance whether the series it is examining is near-constant, smoothly drifting, <a href="https://en.wikipedia.org/wiki/Homoscedasticity_and_heteroscedasticity">heteroscedastic</a> (quiet in places and noisy in others), sparsely populated, or has a magnitude of . It is not scalable to hand-pick parameters for every series it needs to analyze. Whatever we build has to be robust to all of that while maintaining excellent recall and precision. If it fails to detect important events it runs the risk of missing key corroborating evidence for a working hypothesis. Conversely, an analysis tool that reports an event for every minor fluctuation will pollute the context the agent reasons over.</p><p>The design goals, in priority order, are: correct on diverse data out of the box, parsimonious (report only what matters), and cheap enough to run interactively across many series.</p><h2>How PELT and BIC power change point detection</h2><p>Change-point detection is a well studied field. The classical offline formulation searches for the segmentation of a series that minimizes a penalized cost: a per-segment goodness-of-fit term plus a penalty for each added break to stop the optimizer from putting a boundary between every pair of points. Solved naively, this is combinatorial, but PELT (<a href="https://arxiv.org/pdf/1101.1438">Pruned Exact Linear Time, Killick et al.</a>) finds the optimal partition in roughly linear time by using a dynamic program to prune candidate boundaries that can never be optimal. On the labeling side, comparing nested models by an information criterion such as the Bayesian Information Criterion (BIC) gives a principled, scale-aware way to decide whether a candidate break is really important and what sort of change it constitutes.</p><p>These are good building blocks, and we use them. However, the textbook recipe assumes more than telemetry gives you. It typically assumes a single change type (a mean shift), a known and stationary noise level, and reasonably benign numerics. Real telemetry violates all three: variance changes matter as much as mean changes, the noise level is unknown, often heavy-tailed and changing, and the data spans extreme magnitudes and degenerate cases, such as perfectly constant segments, that wreck an ill-conditioned polynomial fit or a fixed-variance cost. Most of the engineering I describe below is about closing that gap.</p><h2>Splitting one time series into three detection channels</h2><p>Rather than trying to find one detector that does everything, we run three focused detectors and then merge their findings. Two of the three are the same structural detector applied to two different views of the data, or channels.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blted8fd73801bac283/6a6a33ed15fc5c4ade9e493d/02fa7892061a4343a7720c7269c8df82ef67603f-1508x1132.png" alt="Elasticsearch time series split into value and dispersion channels detecting step changes, spikes and variance shifts" /><p>Two detectors run on the value channel: a structural detector that flags step and trend changes, and a pulse detector that identifies point spikes and dips. The dispersion channel, a windowed measure of spread, is fed to a second copy of the structural detector, where a variance change shows up as a level shift and is relabeled a distribution change. The two channels are complementary by construction: a step is a level shift the value channel flags but only has a single large first difference the dispersion channel ignores, while a variance change is invisible to the value channel yet shows up clearly in the dispersion channel. A thin orchestration layer then merges and de-duplicates the streams.</p><p>Keeping the three concerns separate makes each one tractable. A mean-shift detector and a variance-shift detector pull in opposite directions if you try to fuse them; a point-anomaly detector and a regime detector need opposite robustness settings. Separated, each can be tuned to its job.</p><h3>PELT with a scale-free cost</h3><p>For the structural channel, we fit each candidate segment with a low-order polynomial (constant or linear) and score it with the profiled-variance Gaussian cost. If a segment  of length  has residual sum of squares , the cost is</p><p>This is the negative log-likelihood of a Gaussian segment after profiling out the variance, i.e., after substituting the maximum-likelihood estimate  back into the log-likelihood. The total objective PELT minimizes is the sum of segment costs plus a per-break penalty,</p><p>Here,  is the BIC complexity term (number of parameters times  where  is the number of values in the time series) and the scale factor lets us trade sensitivity against parsimony in one place.</p><p>Using the profiled cost rather than a cost against a fixed global variance is a deliberate and important choice. The global noise level of telemetry is unreliable: on a smoothly varying series the natural estimate (the spread of first differences) can collapse toward zero, and a fixed-variance cost then treats every wiggle as enormously significant and over-segments. The profiled cost depends only on the ratio , so it is invariant to the absolute scale and immune to that failure. A small floor on  keeps the logarithm finite, so a zero-residual segment is not rewarded without bound.</p><h3>Keeping the fit stable using robust local weighting</h3><p>Before PELT runs, we robustly down-weight points so that an excursion does not create spurious breaks or drag a segment boundary onto itself. Crucially, these weights enter into the weighted residual moments, so they shape not just the segment fit but its residual variance, and so the segment costs themselves. Each point gets a Cauchy weight, , of its residual  from a rolling-median baseline against a robust scale : points near the local median keep full weight and points far from it are progressively discounted. A nice side effect of measuring excursions from the median on a window centered on each point is that clean structural breaks do not get down-weighted at all, because the majority of values land on the same side of the break as the point whose residual is being computed. So a sustained regime keeps full weight, but a lone spike does not.</p><p>There is a nice justification for scoring a weighted Gaussian cost when what we really want is robustness to a heavy tail. The Cauchy weight is exactly the <a href="https://en.wikipedia.org/wiki/Iteratively_reweighted_least_squares">iteratively reweighted least squares</a> (IRLS) weight of its loss: , so the weighted normal equations  are identical to the Cauchy M-estimator's estimating equations . A weighted-mean (or weighted-line) fit at those weights is therefore a <a href="https://en.wikipedia.org/wiki/M-estimator">Cauchy M-estimate</a>, not a Gaussian one. The cost we actually evaluate inherits the same properties. Because  is concave in , its tangent at the current residual lies above it. This gives a pointwise bound  with  the weight at the tangent point; summing, the weighted residual sum of squares  is a tangent upper bound on the total Cauchy loss, touching it in both value and gradient at the weights' anchor point. Minimizing the weighted RSS is thus one step of a <a href="https://en.wikipedia.org/wiki/MM_algorithm">majorize–minimize scheme</a> that provably decreases the true Cauchy objective, and the profiled-variance cost we feed the BIC is that majorizer standing in for the Cauchy deviance. The only approximation is that we anchor the weights once, at the rolling-median baseline, rather than iterating IRLS to its fixed point; this is exact for the inliers that sit near the baseline, and correct for gross outliers, whose vanishing weight removes them from the cost wherever the bound is loosest.</p><p>Finally, the trick that makes this work on heteroscedastic data is that the residual is judged primarily against a <em>local</em> robust scale, not a <em>global</em> one: the MAD of residuals in the same sliding window. On a series that is quiet in one stretch and noisy in another, this means a spike in the quiet stretch that is multiple local sigmas is correctly suppressed. The local MAD can collapse on quiet stretches, so we use a backstop that is a fraction of a global composite of robust scales and a floor related to the quantization error for discrete series and numerical precision otherwise.</p><h3>From candidates to labeled events using BIC verification</h3><p>PELT gives a globally optimal penalized segmentation, so we take its boundaries as candidates and verify each one. For a candidate at index  we look at the window of length  spanning to its nearest neighboring candidates and compare a no-change null against step and trend alternatives by BIC,</p><p>where  counts the fitted parameters (the same parameter count as in the PELT penalty above). We map the BIC gain of an alternative over the null to a significance via , to turn a threshold into a decision boundary). We treat this  as a significance score for ranking and thresholding, not as a calibrated tail probability.</p><p>At this stage we allow higher-degree models to avoid splitting smoothly varying trends. These are problematic in PELT itself because it considers short segments, which they overfit. We keep the most parsimonious alternative that clears the significance threshold and survives a persistence check. The persistence check re-scores with the weights immediately around the candidate muted, and if the evidence collapses, the "change" was driven by a few extreme points – an excursion, not a regime change – and we reject it. The polynomial order is applied symmetrically to the null and to each side of the split, so the alternative is always the same model class merely split at the candidate, and therefore strictly more flexible. The whole process can be thought of as Bayesian model selection with a preference for the null.</p><p>When no candidate survives, we still say something useful: we report the series as "stationary" (best no-change model is a constant) or "non-stationary" (best model has a slope), with the trend direction. For an agent, "this series is cleanly trending up over the window" is itself a finding.</p><h3>Detecting distribution changes with a dispersion channel</h3><p>A variance change is indirectly visible to the mean channel – worse, the robust weighting there actively mutes the excursions that signal it. So we detect it on a separate dispersion channel and reuse the exact same structural detector, because on this channel a variance change is just an ordinary level change.</p><p>The channel is built from one sample per non-overlapping window. Within a window, we take the <a href="https://en.wikipedia.org/wiki/Interquartile_range">inter-quartile range</a> of the first differences, rescaled to a standard-deviation equivalent (), and pass it through :</p><p>Then . Three choices matter here. First-differencing cancels level and slope, so a mean step contributes a single large difference rather than inflating the whole window, and a steady ramp produces a flat channel. Non-overlapping windows keep the samples independent; overlapping windows <a href="https://en.wikipedia.org/wiki/Autocorrelation">autocorrelates</a> the channel and makes the segmenter over-detect. And the IQR is used rather than the median (which is too robust and will miss a window that is 40% noisy then flatlines) or the raw standard deviation (which is not robust enough since one spike's two large differences inflate the window). Because the dispersion channel is a fraction of the original length, the verifier there is restricted to a lower-order null so a genuine low-high-low variance bump is not absorbed.</p><p>The functional form  is worth dwelling on, because each part earns its place. The log makes the channel respond to ratios of noise level rather than absolute differences. Variance changes in telemetry are typically multiplicative: a regime is "twice as noisy". On a raw-scale channel, a doubling shows up as an enormous absolute jump at a high baseline and a negligible one at a low baseline, so an additive step-cost detector would find variance changes trivially in loud series and miss them in quiet ones. Under a log, a factor- change in scale is the same offset  wherever it occurs, which is exactly the additive-step behavior the structural detector is built for. The " is a soft floor. A bare  diverges to  as the scale goes to zero, which is precisely what happens on a near constant stretch, and would manufacture a huge spurious step at the first noisy window after it. Conversely,  is finite and smooth at zero, behaves linearly () while the noise is small, and recovers the multiplicative  behavior once the noise is appreciable. This gives graceful degradation instead of a singularity, and with no tuned epsilon to pick. Note that squaring the scale (using a variance instead) only doubles the dynamic range; it makes no difference to the detector either way, since  differs only by a constant the threshold absorbs.</p><h3>Detecting point anomalies as excursions from a local baseline</h3><p>Spikes and dips are detected as point excursions from the local rolling-median baseline. Working from the local residual rather than raw values means level structure is removed and smooth curvature is tracked; even for time series that change significantly the detector is sensitive to significant local deviations.</p><p>The pipeline is a generous proposer followed by a strict gate:</p><ol><li><p>Propose every point whose residual exceeds a threshold number of robust sigmas. The scale is the larger of the global first-difference noise (which stays meaningful on smooth data where most residuals are exactly zero) and a composite of robust scales of the residuals (which inflates once a frequent large-residual population appears).</p></li><li><p>Merge adjacent same-sign candidates into excursions, dropping any that span a full minimum segment: that is a regime, and is owned by the structural channels.</p></li><li><p>Rank and cap the excursions by peak <a href="https://en.wikipedia.org/wiki/Standard_score">z-score</a>, keeping the top , so a pathological series cannot drown the output.</p></li><li><p>Gate using one shared null: build a <a href="https://en.wikipedia.org/wiki/Kernel_density_estimation">Gaussian KDE</a> from the series with all of the retained excursions removed, and keep an excursion only if its peak's Bonferroni-corrected upper-/lower-tail probability under that null clears the threshold.</p></li></ol><p>Removing all the tested excursions from the single null at once is a key trick. The leave-one-out alternative – score each excursion against a null containing the others – lets the largest spike and dip mask everything else. Removing them together means several genuinely distinct excursions are each judged against the remainder and all survive, while a recurring population is still rejected.</p><p>The proposer and the gate ask deliberately different questions, and that distinction drives two further choices. The proposer works on residuals from the rolling median since it wants recall, and a residual is what tells you a point stands out from its local neighborhood. The gate is value-based: it asks "is this magnitude one we see at other times in the series?", so a spike to a level that recurs elsewhere — such as periodic batch jobs — is suppressed even though it is a large local residual. Those are the right semantics for an agent, but they expose a heteroscedasticity problem, because telemetry noise is almost always a function of magnitude. Periodic spikes can sit orders of magnitude above the background, and a single KDE bandwidth fitted to the whole value range is then far too narrow up in the high tail. So ordinary large values come back as significant, a steady source of false positives.</p><p>The fix is a <a href="https://en.wikipedia.org/wiki/Variance-stabilizing_transformation">variance-stabilizing transform</a>. We run the value gate in  space, where  is a robust measure of the spread of the background.  is linear for  and logarithmic for , which turns a multiplicative (magnitude-dependent) spread into a roughly constant one, so a single bandwidth is valid across orders of magnitude. It is also odd and finite at zero, so exact zeros and sign changes (dips below a small baseline) need no special handling, unlike a bare log. Crucially, it is monotone and so does not change what is tested (for any monotone function , ) so it only fixes the estimate of that tail probability.</p><p>One subtlety closes the loop. The KDE null and the kernel bandwidth are taken from different scales, on purpose. The null is the stabilized background values: so it models any mode in the data, which is what makes a recurring large magnitude unsurprising. However, the bandwidth is taken from the stabilized residuals, not the stabilized values, because a genuine level change makes the value distribution bimodal, and a bandwidth computed from that bimodal spread would balloon, masking a real spike sitting on top of a shifted regime. The residual removes the step, so the bandwidth always reflects within-regime noise and the gate stays sensitive to a deviation that is extreme relative to its own neighborhood if it is also outside the envelope for the series as a whole.</p><h3>Merging structural, distribution, and point anomaly events</h3><p>Finally, an orchestration layer merges the structural, distribution, and point-anomaly event streams. Structural and distribution events that mark the same regime boundary are de-duplicated to the more significant one (a boundary that shifts both level and spread is one event, not two). Pulses are a separate stream added after de-duplication, because a spike that lands on a structural boundary is a real, separate finding and must not be suppressed. Everything is then mapped back from the internal value-array index space to source-bucket indices.</p><h2>Handling extreme magnitudes and edge cases</h2><p>What makes this usable as an unattended tool is a collection of defensive choices for the cases that break naive implementations:</p><ul><li><p>Variance computed as  loses all precision at large magnitudes: a constant series at  can manufacture phantom change points purely from floating-point error. We center every PELT input by a constant offset first; the polynomial RSS is invariant to that shift in exact arithmetic, but the working magnitudes drop from  to .</p></li><li><p>Using raw indices as the regressor results in poor condition polynomial fits: the largest moment is , which is about  for a cubic over a 2000-point window. This trips the SVD singularity guard and silently degrades the fit. Mapping  affinely onto  leaves the fit identical (RSS is invariant under reparametrization) but every moment becomes .</p></li><li><p>Scale-free cost, as described, means the segmentation cost doesn't depend on tuning a noise estimate.</p></li><li><p>Down-weighting wants a primarily <em>local</em> scale: suppress whatever is anomalous in its own neighbourhood. It uses the maximum of MAD and a small global floor on the differences from the rolling median. Conversely, spike/dip detection wants a <em>global</em> scale: we care about global outliers. It uses a composite of robust scales of all differences. Using the wrong one in either place produces characteristic failures – irrelevant spikes in a quiet segment, or locally large excursions creating spurious breaks – and maintaining separate channels allows us to pick appropriately.</p></li><li><p>Using one p-value threshold, <a href="https://en.wikipedia.org/wiki/Bonferroni_correction">Bonferroni-corrected</a> by the number of candidates, applied consistently across all three detectors, means that "how surprised should I be" is consistent everywhere.</p></li></ul><p>The recurring theme is that the difference between a detector that works in a notebook and one that works on a firehose of production time series is mainly in handling the edge cases gracefully.</p><h2>Performance: ~1ms per series on a single core</h2><p>Detection cost is dominated by PELT. Its segment cost is a profiled-variance linear fit, which we evaluate in constant time from prefix-summed weighted moments rather than maintaining a regression per candidate boundary, so a single segment cost is a handful of array reads and a 2×2 solve. Cost grows a little faster than linearly with series length since PELT's pruned candidate set does not stay constant on noisy data. Therefore, to bound the worst case on very long series, we downsample ahead of detection: above a cap (2000 samples), the series is collapsed into macro-buckets, keeping two samples per bucket: the median and the largest local deviation. This is inspired by the <a href="https://www.vldb.org/pvldb/vol7/p797-jugel.pdf">M4 downsampling scheme</a>, but because we need only the median and the largest excursion for structural-change and outlier detection, respectively, we can then afford to double the bucket resolution. The downsampled series carries its original bucket indices, so every reported event still maps back to a real source bucket; below the cap it is a no-op. The whole analysis is a single pass over the (possibly downsampled) series with no per-series configuration. This lets the agent call it freely across many signals.</p><p>In absolute terms, this means the analysis is comfortably interactive. On a single core, post-warmup, a typical series of 140–350 buckets is analysed in about 1 ms (≈220,000 buckets/s), and a series long enough to hit the downsample cap (say 5,000 buckets, collapsed to 2,000) takes about 40 ms, which is the effective worst case per call. For an agent issuing a handful of these calls per investigation, and parallelising across the many series in a `BY` query, the latency is negligible.</p><h2>Evaluation on synthetic and production telemetry</h2><h3>Synthetic benchmark</h3><p>We evaluate first on a synthetic generator with known ground truth, because it lets us measure the things that matter precisely. The generator produces random time series with diverse behaviors, which we group into three families. The <strong>positive</strong> family injects known events of each type: clean and noisy step changes (up and down, SNR around 10, at several positions), trend onsets and ramps (including a flat–ramp–flat sequence with two boundaries), variance changes with a constant mean (single steps and a low–high–low bump), and isolated spikes and dips. The <strong>null</strong> family ideally produces no event: stationary noise, perfectly constant series, smooth quadratic drift and clean ramps, and periodic signals. (A variance change with a constant mean is not null: it is a distribution change, an abrupt step on the dispersion channel, and so it belongs in the positive family above. The related null requirement, that such a change does not surface on the value channel as a step, is checked separately.) The <strong>adversarial</strong> family stresses the robustness machinery: a perfectly flat series at a magnitude of  (for which a naive variance arithmetic manufactures phantom breaks here through catastrophic cancellation) and, conversely, a genuine spike or step in a noisy baseline as high as  (which must still be found and located, the high baseline notwithstanding); a spike on top of a step; a within-regime spike after a 100 level jump; a recurring train of equal peaks (a population, not individual spikes); wide sustained excursions (a structural change, not a spike or dip); and fuzzed random level-shift series. On these series we track recall per event type, precision and the false-positive rate on the null family, localization error, parsimony (events per series and adherence to the count limit), and invariance under constant offsets, rescaling and extreme magnitudes.</p><p>The table below summarizes what the suite tests.</p><p>Event family</p><p>Representative scenarios</p><p>Required outcome</p><p>Localization tolerance</p><p>Step</p><p>clean / noisy, up / down, single and multiple, several positions</p><p>detected</p><p>≤ 4–8 buckets</p><p>Trend</p><p>slope change and flat–ramp–flat, clean and in noise</p><p>detected</p><p>≤ 8–12 buckets</p><p>Distribution</p><p>variance step (mean constant), low–high–low bump</p><p>detected</p><p>≤ 1 dispersion window</p><p>Spike / dip</p><p>isolated, multiple distinct, on heavy-tailed and high-magnitude series, within-regime after a step</p><p>detected, capped at max(5, 2% of n)</p><p>≤ 2 buckets</p><p>Null series</p><p>stationary noise, constant, smooth drift / ramp, periodic</p><p>no event reported</p><p>n/a</p><p>Invariance / robustness</p><p>constant offset, rescaling, 10^5–10^9 magnitudes, spike-on-step, recurring population, wide excursion</p><p>result unchanged / no spurious event</p><p>n/a</p><p>Running this over 400 series per scenario, just over half a million buckets in total, and scoring with the same two categories we use for the real-data evaluation below (any regime change versus point spikes and dips) gives:</p><p>Event type</p><p>Recall</p><p>Precision</p><p>Median localization error</p><p>Structural change</p><p>0.994</p><p>0.664</p><p>0</p><p>Spike / dip</p><p>0.847</p><p>0.746</p><p>0</p><p>Two things stand out. When an event is detected, it is placed essentially exactly: the median localization error is zero buckets for both categories; and the point-wise accuracy, 0.998, is directly comparable to the 0.995 we report on real telemetry below: the overwhelming majority of buckets are correctly left unmarked.</p><p>The precision figures are lower than on real data, and understandably so. A sixth of this population is a <em>hostile</em> null family (periodic signals, smooth drift, clean ramps) chosen precisely because they tempt a detector into a spurious break, and every false alarm on them counts against precision. The resulting false-positive rate is 0.5% per bucket, with 15% of null series carrying at least one spurious event. We treat this as the conservative end of the range: on the real-telemetry mix below, where the null series are less adversarial, precision rises to 0.85 (structural) and 0.90 (spikes/dips). Finally, offset and scale invariance holds on all 400 series: the same events, to within a few buckets, whether the series is shifted by a constant or rescaled by up to three orders of magnitude.</p><p>That raw precision also misses how the result is consumed. The agent reads events most-significant-first, so a false positive only does harm if it outranks a genuine one, and by and large it does not. The median p-value of a true positive is about , against about  for a false positive: the genuine events are typically overwhelmingly more significant. Concretely, if we keep only the top  events per series by significance (with  the true count) precision rises to 0.94, and a randomly chosen true positive is more significant than a randomly chosen false positive 93% of the time. It is not a perfectly clean separation: a strong periodicity or a sharp curve genuinely can contain a significant-looking break, which is why that figure is 0.94 rather than 1. However, the ranking is reliable enough that an agent reading from the top, or applying a stricter significance cut-off, sees the real events first and the false alarms as a lower-significance tail. This is also why exposing the detector's significance to the agent (see <a href="https://www.elastic.co/search-labs/blog/change-point-detection-time-series-esql#whats-next-for-es|ql-time-series-analysis">What's next</a>) matters more than squeezing the raw precision higher.</p><h3>Real cloud telemetry</h3><p>To evaluate on real data, we scraped around 300 metrics from our production cloud environment. These cover HTTP status-code counts, failed memory allocations, memory usage, network usage, page faults, CPU usage, and throttling metrics, measured both per instance and aggregated across the fleet as a whole. Their values range over more than 12 orders of magnitude, and they display a variety of behaviors including ramps, periodicity, step changes, distribution changes, and trend changes. The figure below shows a sample of series together with the detections we make on them.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte0bc6fbc138cca1c/6a6a33ee065b162280702003/2c30fd3e2f97e32b5b47bf66a923f73b08ec015d-2510x1284.png" alt="Grid of 12 production cloud telemetry series with detected structural changes, distribution changes and anomalies marked by Elasticsearch's change point detector" /><p>To get a sense of the accuracy on this set data, we labeled a subset of 150 most interesting time series, marking the visually clearest features in each. This labeling is not necessarily optimized for our target use case, where false negatives are typically more problematic than false positives: an agent consumes these results as part of a broader investigation and can pull additional information to corroborate them. Even so, we find excellent agreement with human judgment on these series. Since each series comprises between 140 and 350 points, the point-wise accuracy, at 0.995, is extremely high: the great majority of points are correctly identified as neither a change, a spike, nor a dip. But the more telling metrics are recall and precision on the human-labeled points, shown in the table below. The human labels did not attempt to categorize each change, so we break the results down only into "structural changes" and "spikes / dips" — the same two categories, and the same point-wise accuracy and recall/precision metrics, as the synthetic benchmark above.</p><p>Event type</p><p>Recall</p><p>Precision</p><p>Structural change</p><p>0.94</p><p>0.89</p><p>Spike / dip</p><p>0.97</p><p>0.92</p><p>It is worth covering exactly why we get disagreements. These largely fall into three categories: spikes and dips in context, isolated breaks, and small-magnitude breaks in stable series. We deliberately do not try to detect spikes and dips that are unusual only in their immediate context; that is, not globally unusual but visually significant relative to an inferred periodicity in the data, for example. Trying to account for these without fully modeling the seasonality in the data hurt precision more than it helped recall, and we have a separate persistent anomaly-detection process that builds more complete models of baseline behavior over time. Isolated breaks are an artifact we decided to live with: PELT's cost function tends to isolate a change point with a few values intermediate between the two regimes, because absorbing it into either neighboring span inflates that span's cost. Humans are good at judging such situations visually and assign a single change point. Finally, small-magnitude changes are simply not visually obvious. We detect them deliberately and regard this as a strength of a quantitative approach, since they are often the early precursors of an incident whose later, larger effects drown them out.</p><h2>How the agent uses change point results in ES|QL</h2><p>To the agent, all of this is one tool call: it points it at a collection of time series and gets back a typed, located, ranked list of events. That list is small by construction, which means it drops cleanly into the model's context without crowding out everything else it is reasoning about. Because the result contains multiple events, a single call over a window can hand the agent the whole local story – "error spike at 02:14, latency regime change at 02:30, throughput dip at 02:31" – and let it correlate across signals to a root cause.</p><p>Operationally, we expose this through both the ES|QL <code>CHANGE_POINT</code> <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/change-point">command</a> and the <code>change_point</code> <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-change-point-aggregation">aggregation</a>. Currently, we have not extended the <code>change_point</code> aggregation to return multiple change points since it breaks backwards compatibility of the output schema. It just returns the most significant event. We don't have the same restriction for ES|QL since it returns change points annotated onto the table rows to which they apply. We do plan to revisit the output schema for both ES|QL and the aggregation in a later version. We'd like to migrate to optionally returning significance in log-space, which doesn't underflow, and including a short verbal description of each change, which we expect to help agents when seeing just the change points themselves.</p><p>ES|QL is Elasticsearch's piped query language, and <code>CHANGE_POINT</code> runs the detector as one stage in a pipeline. Its <code>BY</code> clause enables it to analyze many series at once (one per group) so the agent can, in a single query, segment every service's latency or every host's error rate side by side rather than issuing a call per series. The actual leverage, compared to the <code>change_point</code> aggregation, is composability: the events come back as ordinary rows in the pipeline, so the agent then has the entire ES|QL language to manipulate them downstream. It can filter to a window, join change points against deploy markers, count events per service, rank by significance, feed the survivors into a further aggregation, and so on.</p><p>For example, suppose the agent wants to find out whether any servers have recently seen a sudden CPU spike or a prolonged step change in CPU usage over the last 12 hours, and whether that might point to a load-balancing issue. It could use the following query:</p><p>Here it's using a <code>STATS ... BY host.pod</code> to see how the detected events cluster across other dimensions of the data, such as the Kubernetes pod, and so judge whether they share a common cause.</p><h2>What's next for ES|QL time series analysis</h2><p>As far as detecting events of interest in time series, the foundation is in place: a single, robust, parsimonious tool that turns a raw telemetry series into the short list of events that actually matter, which is exactly the kind of reach an agentic SRE needs. Going forward, we plan to explore the best mechanism for feeding the detector's uncertainty to the agent, so that a borderline event can be flagged as "worth a second look" rather than silently included or dropped. Also, this is the first of several analytical tools we plan to build into the ES|QL query language to enable agents to triage and RCA issues more effectively; so stay tuned for further updates.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/change-point-detection-time-series-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/change-point-detection-time-series-esql</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[ML Research]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt097721c03648a84e/6a6a33ef0a222b4c70877f32/8f6b95800c65fe389d3e8d8281e8e8dc351f734d-992x342.png" length="0" type="image/png"/>
    <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[On-prem in under 5 minutes: Jina embedding models now available for on-prem deployment]]></title>
    <description><![CDATA[All 28 Jina AI models, including rerankers, as ready-to-deploy Docker containers, with zero telemetry and no license server. Drop-in compatible with OpenAI, Cohere, Voyage AI and Elastic Inference Service APIs.]]></description>
    <content:encoded><![CDATA[<p>All 28 Jina AI embedding and reranking models now ship as fully offline Docker containers for on-prem deployment, including <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"><code>jina-embeddings-v5-omni</code></a><a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"> </a>and <a href="https://www.elastic.co/search-labs/tutorials/jina-tutorial/jina-reranker-v3"><code>jina-reranker-v3</code></a>. Download one, transfer it to an on-premises air-gapped or firewalled system, and local inference is running in under five minutes. The containers are completely self-contained and make no external connections. There’s no call to Hugging Face or any model registry. There’s also no license server or telemetry or logging endpoints. For regulated industries, data sovereignty requirements or environments where internet access is unreliable or simply unavailable, this removes the dependency on third-party AI services. Jina On-Prem supports Elastic Inference Service (EIS), OpenAI, Cohere, Voyage AI, and Gemini API schemas, so existing applications work without code changes.</p><p>The most powerful AI models run on remote cloud installations with access via a web API, meaning that you have to trust your AI service provider for security, service availability, and stable prices. You can’t easily align reasonable demands for reliability, privacy, manageable costs, and good data governance with increasingly powerful, sophisticated, and resource-intensive AI usage.</p><p>Government regulation, court rulings, and business considerations made in someone else’s interest have all recently resulted in restricting access to specific services. And even if you can switch to other services, AI models aren’t components that can just be swapped out whenever you want. Applications that use semantic embeddings depend on having access to the same models at query time as at data ingestion time. To lose access to your embedding model means your search system comes to a halt.</p><p>AI pricing models compound that risk. Recent financial disclosures from major AI vendors give customers good reason to be concerned about potential price hikes. Reliance on products with unpredictable costs adds more risk to capital-intensive AI investments that may not produce clear returns.</p><p>Jina On-Prem is Elastic’s answer to these challenges.</p><h2>Who needs on-premises AI?</h2><p>Local hosting and direct control over your AI models support a variety of technical demands, industry requirements, and business interests.</p><p>Local installation reduces what you pay your AI service providers, but it puts the cost of hardware and reliable access on your organization. Depending on your volume of use, it may simply be cheaper. But there are additional pressing reasons to consider running your own AI. If any of the issues described below concern your enterprise, consider a local AI solution like Jina On-Prem. This list is not exhaustive.</p><p>Use case</p><p>Why on-prem</p><p>Example</p><p>Air-gapped / high-security</p><p>No outbound data transmission; complete network isolation</p><p>Defence, intelligence, classified research</p><p>Regulatory compliance</p><p>Data sovereignty; no cross-border transmission or third-party exposure</p><p>Healthcare (Health Insurance Portability and Accountability Act [HIPAA]), finance, EU enterprises (General Data Protection Regulation [GDPR])</p><p>Latency-critical</p><p>Zero network dependency; no tolerance for connection failures</p><p>Robotics, edge computing, vehicles, ships</p><p>Cost predictability</p><p>Fixed infrastructure cost vs. per-token pricing with uncertain future rates</p><p>High-volume continuous inference workloads</p><p>Liability reduction</p><p>No third-party data exposure; maintains legal privilege and duty of care</p><p>Law firms, government agencies</p><h3>Why air-gapped and firewalled systems need on-prem AI</h3><p>Air-gapped and firewalled systems cannot use external AI APIs. Jina On-Prem runs entirely within your infrastructure with no outbound connections.</p><p>For organizations managing especially sensitive data, security and privacy considerations are paramount. It does little good to invest in protecting your sensitive data if you promptly turn it over to some remote third party that may have insufficient security in place or might be subject to the demands of a foreign government.</p><p>Employees in organizations that handle sensitive data often receive some training in secure data handling, but this isn’t very effective when they all have web browsers that may be open to any page on the internet while they handle that data. Isolation is the most effective security measure available, either through air-gapping or very restrictive firewalls, but that makes it difficult to use external services of any kind.</p><h3>On-prem AI for latency-sensitive and high-availability systems</h3><p>Software as a service and cloud computing represent a compromise between the cost of offering highly accessible, reliable services on your own computers and outsourcing the problem to someone else. But they come with variable latency, outages, and a complete loss of control when things go wrong. AI services aren’t the exception. If your search system goes offline when you can’t access your embedding model, it may no longer look like a good compromise.</p><p>Furthermore, relying on external AI will always involve risks that you can’t easily foresee or manage. Internet access and network latency can degrade without notice, as a result of political events, bad weather, or ships dragging their anchors over underwater fiber-optic cables. Governments can, and recently have, used export bans to suddenly block access to AI models. AI service providers sometimes withdraw models to induce you to switch to newer ones. The flexibility and managed costs of external services have to be balanced against the risks of dependency.</p><h3>On-prem AI for GDPR, HIPAA, and data sovereignty compliance</h3><p>Organizations that collect personal data are subject to increasingly stringent regulations which often differ between jurisdictions and may have contradictory requirements. Notably, <a href="https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index.html">HIPAA rules</a> place very strict data protections on American healthcare providers, and strong general data protection laws in <a href="https://laws-lois.justice.gc.ca/eng/acts/p-8.6/">Canada</a>, the <a href="https://gdpr-info.eu/">European Union</a>, and <a href="https://www.japaneselawtranslation.go.jp/en/laws/view/4241">many Asian jurisdictions</a> require all enterprises that handle personal information to do so securely and to limit the transmission of that data to other parties or other jurisdictions. These rules can even impose obligations on foreign entities if they have any customers in those jurisdictions. Financial institutions are frequently subject to even stricter rules and bear the same direct liability for information security that they have to protect against other forms of criminal activity.</p><p>Regulatory compliance can be incompatible with third-party AI services, especially if using them involves cross-border data transmission.</p><p>Furthermore, recent events show that rules restricting the physical location of data stores may not be a reliable source of protection when international cloud operators are subject to pressure from foreign governments. Local laws may conflict between jurisdictions, requiring local data storage and processing and making third-party services impossible to use. In some cases, the only solution is to take all the parts of your processes in house, including your AI systems.</p><h3>AI liability risks from third-party data transmission</h3><p>Data protection laws and recognized duties of care toward sensitive data routinely have liability implications, sometimes very severe ones. You can be liable for third-party service providers’ handling of your data. While courts and legal procedures might provide some retrospective protections from insecure service providers, those remedies are not available nor generally effective against national security actors, law enforcement, or criminal hackers.</p><p>For governments, there have already been instances of cross-border cloud service providers releasing sensitive state information to foreign actors.</p><p>But even if you don’t worry about foreign governments or hackers, and if your external AI service providers are themselves secure, just the fact that they’re external can create liabilities.</p><p>For example, in most jurisdictions, lawyers’ communications with their clients enjoy special legal protections, and law offices have strict liabilities when recording or storing this information. In the United States, this “attorney-client privilege” is so famous, it’s central to movie and TV plots. But one of the ways that privilege can be lost is by communicating information with someone who is not privileged, and recent developments suggest that external AI service providers might qualify.</p><p>It’s possible, at least in the United States, that just using third-party AI services over an internet API, like embedding models that provide indexing services, might violate critical confidentiality rules. A law firm might be sued, disciplined, or disbarred just for using externally hosted software, even if no security breach occurs.</p><h3>On-prem AI for offline, edge, and physically isolated systems</h3><p>Computer systems aren’t just isolated for security reasons. For example, moving vehicles cannot rely on internet access for any essential functions. Ships and aircraft have very extensive onboard computer systems that have to function without internet connections and therefore cannot use external AI services. Offshore platforms, remote facilities in wilderness areas, computer services in the Arctic, Antarctic and on small islands without adequate physical connections to global networks are all examples of installations that benefit from locally hosting all the services they need. As AI’s role in enterprise computing grows, these limitations become more important to address.</p><p>Emerging applications of AI to physical systems (robotics and other spatially confined or external-world–focused use cases, like logistics management systems or even supermarket checkouts) may be connected to the global internet, but they have no tolerance for connection failures or spikes in latency. If they rely on an AI system to operate, that AI system needs to be as local and reliable as possible.</p><h2>Who doesn’t need on-premises AI?</h2><p>Remote software services and off-site AI do have benefits. Running AI models can require expensive, power-hungry processors with notoriously short lifespans. Access to high-quality hardware is particularly difficult right now due to market factors and external economic shocks. Under the circumstances, it may make sense to pay by the token to use an external API instead of supporting the steep capital costs of local AI.</p><p>External APIs make the most sense for intermittent users. If you use AI models primarily to batch process data for analysis, rather than running a search system that has to be online all the time, it makes little sense to invest in capital-intensive hardware and local installations.</p><p>Furthermore, when your data processing is already cloud-based, for example, an ecommerce website hosted in the cloud for reliability and accessibility reasons, using AI services located in the same cloud infrastructure may provide a better value for money than introducing your own licensed AI model deployment. You’re already dependent on your cloud service provider, so being dependent on its AI services doesn’t add much risk.</p><p>If your use case sounds like it fits that description, Jina AI models are available on <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a>, <a href="https://aws.amazon.com/marketplace/seller-profile?id=seller-stch2ludm6vgy">AWS Marketplace</a>, and the <a href="https://console.cloud.google.com/marketplace/browse?q=jina">Google Cloud Platform</a> specifically to meet your needs.</p><p>The table below summarizes the key factors. Your answer depends on your data, infrastructure and usage pattern.</p><p>Factor</p><p>On-prem favored</p><p>Cloud API favored</p><p>Usage pattern</p><p>Continuous or high-volume inference</p><p>Intermittent or batch processing</p><p>Data sensitivity</p><p>Regulated, sovereign, or classified</p><p>No cross-border or third-party restrictions</p><p>Network environment</p><p>Air-gapped, firewalled, or unreliable</p><p>Stable, always-on internet</p><p>Existing infrastructure</p><p>Own or can procure GPU hardware</p><p>Already cloud-hosted with colocated AI</p><p>Cost model</p><p>Fixed hardware + license; predictable at scale</p><p>Per-token; lower up-front, variable long-term</p><p>Latency tolerance</p><p>None (robotics, edge, real-time)</p><p>Network variability is acceptable</p><p>Operational responsibility</p><p>Your team manages hardware and availability</p><p>Provider manages hardware and updates; you manage integration</p><p>You have to consider the costs and benefits in light of your particular circumstances and use cases, taking into account the issues highlighted in the previous section that apply to you. The cost-benefit analysis will doubtless change over time. We can’t predict the future of the AI industry or hardware prices even in the short term.</p><h2>Introducing Jina On-Prem</h2><p>For users who can benefit from local AI services, we’re introducing <a href="https://github.com/jina-ai/jina-on-prem/wiki/">Jina On-Prem</a>, a fully self-contained installation suite for Jina AI’s high-performance models.</p><p>Jina AI’s models match the accuracy of embedding models <a href="https://mteb-leaderboard.hf.space/benchmark/MTEB(Multilingual%2C%20v2)">many times their size</a>, reducing compute costs, memory footprints, and hardware requirements. This makes them an ideal choice for users who want or need to keep their AI on-premises. Commercial licenses are available with scalable, proportionately priced solutions for use cases of all sizes.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt190865fb3ebde472/6a6a33d0065b162508701ff9/02559ceca556a26c53eb703ae87d421452b27251-1374x1400.png" alt="MMTEB Multilingual v2 leaderboard showing Jina AI embedding model rankings: jina-embeddings-v5-omni-small and jina-embeddings-v5-text-small ranked 13th, jina-embeddings-v5-omni-nano and jina-embeddings-v5-text-nano ranked 19th, competing against models from Microsoft, Google, Tencent, NVIDIA and Qwen" /><h3>What API schemas does Jina On-Prem support?</h3><ul><li><p>Available as a complete collection of dependencies for local installation or as a <a href="https://www.docker.com/">Docker container</a> that you can install and run in minutes.</p></li><li><p>Jina On-Prem installations <em>do not</em> call out to outside systems.</p><ul><li><p>No call to Hugging Face Hub or any model registry (<code>HF_HUB_OFFLINE=1</code> and <code>TRANSFORMERS_OFFLINE=1</code> are baked in).</p></li><li><p>There’s no license server.</p></li><li><p>There are no telemetry or logging endpoints.</p></li></ul></li><li><p>Supports both CPU and GPU hardware, with GPU autodetection.</p></li><li><p>All 28 Jina AI models available, including the latest <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"><code>jina-embeddings-v5-omni</code></a> multimodal embedding models and <a href="https://www.elastic.co/search-labs/tutorials/jina-tutorial/jina-reranker-v3"><code>jina-reranker-v3</code></a>.</p></li><li><p>Access via standard AI API schemas: <a href="https://jina.ai/api-dashboard">Jina API</a>, OpenAI, Cohere, Voyage AI, and Gemini. Jina On-Prem is a drop-in solution for applications built on those schemas.</p></li><li><p>Drop-in replacement for models served by the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a>. Jina On-Prem integrates directly with <a href="https://www.elastic.co/blog/deploy-elastic-air-gapped-disconnected-environments">air-gapped Elastic deployments</a>.</p></li></ul><h2>Hardware requirements for Jina AI on-prem models</h2><p>The hardware requirements vary for different Jina models. The table below shows the recommendations for the most recent models using GPU settings. You don’t need anything more powerful than an NVIDIA L4 GPU, although an A100 is recommended for the v5 embedding models. Our latest embedding model currently requires a minimum of 8 GB of VRAM.</p><p>Model</p><p>Minimum VRAM</p><p>Recommended GPU</p><p>jina-embeddings-v5-text-nano</p><p>2 GB</p><p>T4 / L4</p><p>jina-embeddings-v5-text-small</p><p>3 GB</p><p>L4 / A10G</p><p>jina-embeddings-v5-omni-small</p><p>8 GB</p><p>L4 / A10G / A100</p><p>jina-reranker-v3</p><p>3 GB</p><p>L4</p><p>jina-clip-v2</p><p>4 GB</p><p>L4</p><p>jina-code-embeddings-1.5b</p><p>4 GB</p><p>L4</p><p>ReaderLM-v2</p><p>4 GB</p><p>L4</p><p>If you use more than one model at a time, the VRAM requirements will increase. Please see the <a href="https://github.com/jina-ai/jina-on-prem/wiki/Sizing-And-Hardware">Sizing and Hardware page</a> for more information.</p><h2>How to install Jina On-Prem with Docker</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt20265d09e2d8d0f4/6a6a33d1065b162105701ffd/ada9881af407168298b1940f8537ad71a5411c89-1999x1200.png" alt="" /><p>The quickest way to get started is to <a href="https://www.docker.com/get-started/">install Docker</a> (if you haven’t already) and follow the instructions on the <a href="https://github.com/jina-ai/jina-on-prem/wiki/QuickStart">Jina On-Prem Quick Start</a> page.</p><p>There are pre-composed Docker containers for all 28 Jina models. Download one and transfer it to your installation target, and you can have Jina AI models running in under five minutes.</p><p>For multimodal or custom builds, or to download the complete dependency set for installation outside of a container, follow the steps outlined in the <a href="https://github.com/jina-ai/jina-on-prem/wiki/Bundling-Guide">bundling guide</a>.</p><p>Your Jina On-Prem installation supports all Jina API and EIS functionality and embedding generation via OpenAI, Cohere, Voyage AI, and Gemini APIs, so it can integrate into preexisting applications using standard interfaces. See the <a href="https://github.com/jina-ai/jina-on-prem/wiki/API-Reference">API documentation</a> for more information.</p><p>Jina models, including models installed with Jina On-Prem, are available on various licensing terms, with the latest models free for noncommercial use under a <a href="https://creativecommons.org/licenses/by-nc/4.0/deed.en">CC BY-NC 4.0</a> license. To license Jina On-Prem for commercial use, please contact <a href="https://www.elastic.co/contact">Elastic Sales</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/on-prem-ai-jina-embedding-models</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/on-prem-ai-jina-embedding-models</guid>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Scott Martens]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt17731ab0c6ec66f6/6a6a33d140a4941014ca5c9a/09bc6dac4e6a86c7877f8ed78d68f5d581aeffa9-1999x1200.png" length="0" type="image/png"/>
    <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Faster Elasticsearch issue triage with redesigned AutoOps]]></title>
    <description><![CDATA[AutoOps introduces clearer severity, updated page layouts, and simpler issue triage for Elastic Cloud Hosted deployments and Cloud Connect clusters.]]></description>
    <content:encoded><![CDATA[<p>AutoOps has a redesigned experience for Elastic Cloud Hosted deployments and Cloud Connect clusters. The update adds a new Critical severity level and refreshes every page, including Template Optimizer, Nodes, Shards and Overview. Updated layouts and navigation make Elasticsearch issues easier to scan and triage. This post covers the redesigned UI and where AutoOps is headed next, including a headless, agentic experience.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9be3378b7b18d00/6a6a33c6d137a512563e106b/86ddf69cfc68919fb0f708eb190c18fdc2b9479a-1999x1200.png" alt="AutoOps Deployment view for an Elasticsearch cluster showing events over time, open events list and resource metrics including JVM memory, CPU and storage across hot and cold tiers" /><h2>Why AutoOps for Elasticsearch needs clearer prioritization</h2><p>Running Elasticsearch at scale requires administrators to monitor cluster health, performance, capacity, and configuration at the same time. AutoOps now provides a clearer way to distinguish conditions that threaten cluster functionality from significant but less urgent degradation. The redesigned interface also follows familiar Elastic Cloud Console patterns, making active issues easier to find and investigate.</p><h2>What changed in AutoOps: severity, navigation, configuration, and page design</h2><p>The monitoring engine remains the same. The redesigned layout, navigation, and workflows now follow familiar Elastic patterns.</p><h3>A clearer severity model</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbd9cd94b620ce367/6a6a33c70a222b3af3877f27/0304e67b9028d72ea31408712e6e778c866edfac-1780x632.png" alt="AutoOps events over time heatmap showing Critical Status Red, High Cluster Pending Tasks and Medium severity events including Unbalanced Shards and Template Optimization across an Elasticsearch deployment over 10 days" /><p>We added <strong>Critical</strong> as a new severity level for conditions that pose an immediate threat to cluster functionality and require urgent intervention. Several events previously classified as High are now Critical. Others are now Medium because they represent potential risk rather than active, significant degradation. The reclassified events are:</p><ul><li><p><strong>Promoted from High to Critical:</strong> Disk Watermark Flood Stage, Master Not Discovered, and Status Red.</p></li><li><p><strong>Demoted from High to Medium:</strong> Disk Watermark Low Threshold, Disk Watermark Low, and Disk Watermark Configuration Incorrect.</p></li></ul><p>Severity</p><p>What it means</p><p>Critical</p><p>Immediate threat to cluster functionality. Urgent intervention required.</p><p>High</p><p>Significant degradation to usability, performance, or stability.</p><p>Medium</p><p>Potential risk that can escalate if left unaddressed.</p><p>Low</p><p>Minor anomalies with minimal operational impact.</p><p>Info</p><p>Routine operational updates and configuration changes. No action required. (Coming in a near-future update).</p><p>Every severity level ships with an updated icon set and color palette. Levels are fixed so teams can build consistent runbooks and notification filters: route Critical and High events to PagerDuty or Slack, keep Medium and Low in the console for periodic review, and when Info arrives, use it for awareness without alert fatigue.</p><h3>Deployment view: open events and history, side by side</h3><p>The redesigned deployment view presents the existing Open events and Event history tabs in a clearer layout.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e5132e8519703e1/6a6a33c88c87dc30dc0d0678/54a6c72a7024dc46a6c458d801d85a066fd3af8f-1780x1664.png" alt="AutoOps Deployment view showing the Event history tab with an events over time heatmap for Critical, High and Medium Elasticsearch events including Status Red, Data Node Disconnected and Index Queue Size" /><h3>Event flyout: a clearer view of what matters</h3><p>The event detail flyout is redesigned around action. High-severity events include a notification callout and an interactive badge that shows whether alerts are configured and links directly to setup. Recommendations collapse by default so the core event stays in focus. Settings live in the flyout menu; share is a separate icon in the header. The Dismiss action appears only when your role has the required admin permissions and the event is dismissible.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt18f642f08729fef9/6a6a33c940a4941189ca5c96/c275a87fc7ba3125599f7be5dfa170915aa0c567-1999x1202.png" alt="AutoOps Deployment view showing an open High severity event flyout for a high index queue on an Elasticsearch node, with recommendations and event timeline" /><h3>AutoOps overview: triage active events across your Elasticsearch fleet</h3><p>The Overview page is reorganized around how operators scan an estate. Elasticsearch context sits directly under the page header, and active events appear as <strong>event ribbons</strong> below the deployments table. Each ribbon shows the latest active event in your selected time range; if the same event type is open on other deployments, a new badge lets you expand the view without opening each resource individually. Event search moved to the left for quicker filtering.</p><p>The “Events over time” chart moved off Overview to keep this page focused on fleet-level triage; open a single deployment when you need that timeline.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc8fbe2571e164c56/6a6a33cad57c1d09d8c13ee4/f73d8b413c66662f890232ecfc57383be35c3203-1999x1202.png" alt="AutoOps Overview page showing a fleet of 7 Elasticsearch deployments with ES status, priority events, node and shard counts, and a Top events list filtered by Critical, High and Medium severity" /><h3>Nodes, Shards, and Indices are designed with easier navigation and information hierarchy</h3><p><strong>Nodes view</strong> now uses updated chart components and the Elastic UI color scheme, with clear expansion indicators on accordion sections. Event and instance lists that duplicated deployment-level views were removed to reduce noise.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt56b42b5dd910a347/6a6a33cb820dedb2ad12936a/dbbdca29d142a23e46acf5ed2c150cdfc18a2ba3-1999x1203.png" alt="AutoOps Nodes view for an Elasticsearch deployment showing disk usage, shards count, segments count, and documents count charts across 24 nodes over a two-day period" /><p><strong>Shards view </strong>improves node selection and groups view controls in the upper-right corner. A horizontal scrollbar supports wider layouts, and the time slider now uses native Elastic UI components. Node selection in Shards view now works across larger clusters and presents up to 100 nodes at a time.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt40d54785dc72234b/6a6a33cc776a4d7a5b51ddc5/a00dc567f48eefa06839ba294e5592a41904ba8c-1999x1202.png" alt="AutoOps Shards view for an Elasticsearch cluster showing hot and cold tier nodes with an indexing rate tooltip for a specific index on instance-181, displaying 3K/sec indexing rate and 56 million documents" /><p><strong>Index view</strong> keeps the Indices table experience you already use, including sorting, time-range brushing, and chart zoom behavior tuned for meaningful ranges.</p><h3>Template Optimizer</h3><p>The <a href="https://www.elastic.co/guide/en/cloud/current/ec-autoops-template-optimizer.html">Template Optimizer</a> now provides a searchable list of templates ordered by the most recently identified recommendations. You can open each recommendation directly or expand the JSON panel to inspect the complete template.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9b0f64ce58e9783e/6a6a33cd065b160c08701ff5/22e5371e3174233cfe942094cca3dac9444a3550-1999x1202.png" alt="AutoOps Template Optimizer showing a codec compression recommendation alongside the full JSON template configuration for autoops_standard_index_settings" /><h3>Configure notifications and event settings</h3><p>Notification settings now include connector search, clearer filters, and a simpler connector editing flow. Event settings moved from a popup to a flyout, matching the pattern used across AutoOps. Notification reports retain the same 10-day history window with minor layout updates, and dismiss events use updated confirmation components aligned with Elastic UI.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb8d711e0f3814e95/6a6a33cec9699ab1cef4b1c8/11a3cb4646f1d1efa2a384b8ee82b6b736aede29-1999x1203.png" alt="AutoOps Events settings page showing the Edit event settings flyout with index filter pattern, empty indices threshold, and per-deployment configuration options" /><h3>Navigation and controls</h3><p>The deployment picker now shows deployment ID and real-time cluster status, with copy actions for deployment name and ID in the dropdown sub-menu. Node selection supports select-all, select-by-tier grouping, and clear master node indication. The date picker follows the same relative-range and custom-range model used in Kibana and other Cloud Console monitoring views.</p><h2>AutoOps roadmap: API, MCP, CLI, and agentic experience</h2><p>Looking ahead, we are building toward a headless, agentic AutoOps experience. A forthcoming public <a href="https://github.com/elastic/roadmap/issues/144">AutoOps API </a>will make insights and raw metrics available outside the AutoOps interface. Administrators and agents will be able to query the API directly or store its data in Elasticsearch. The API will also provide the foundation for integrations with MCP, Elastic Agent Builder, the Elastic CLI, Kibana, and native AutoOps chat.</p><ul><li><p><strong>Hosted MCP server: </strong>Make AutoOps insights available to MCP clients such as Claude and Cursor.</p></li><li><p><strong>Native Elastic Agent Builder tool</strong>: Use AutoOps insights in Elastic Agent Builder.</p></li><li><p><strong>Elastic CLI support:</strong> Access the AutoOps API through the Elastic CLI.</p></li><li><p><strong>AutoOps in Kibana:</strong> Surface relevant insights and metrics within Kibana.</p></li><li><p><strong>Native AutoOps chat</strong>: Investigate cluster issues through an agentic chat experience within AutoOps UI in Elastic Cloud Console.</p></li></ul><p>The application redesign is the foundation; these surfaces will meet operators where automation and AI already live. Read more about what is coming on the <a href="https://github.com/orgs/elastic/projects/2066/views/2?sliceBy%5Bvalue%5D=Monitoring+and+diagnostics">Elastic public roadmap</a>.</p><h2>How to start using the redesigned AutoOps in Elastic Cloud Console</h2><p>Sign in to <a href="https://cloud.elastic.co">Elastic Cloud Console</a>, open a deployment, project, or connected cluster, and select <strong>AutoOps</strong> from the navigation. Learn more in the <a href="https://www.elastic.co/guide/en/cloud/current/ec-autoops.html">AutoOps documentation</a>.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/autoops-elasticsearch-cluster-monitoring-redesigned</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/autoops-elasticsearch-cluster-monitoring-redesigned</guid>
    <category><![CDATA[AutoOps]]></category>
    <category><![CDATA[Elastic Cloud Hosted]]></category>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[Ori Shafir,Arnon Stern]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9be3378b7b18d00/6a6a33c6d137a512563e106b/86ddf69cfc68919fb0f708eb190c18fdc2b9479a-1999x1200.png" length="0" type="image/png"/>
    <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to instrument your search API with OpenTelemetry and query it with ES|QL]]></title>
    <description><![CDATA[Add custom attributes to OpenTelemetry spans and run six ES|QL queries that reveal your top searches, zero-result rate and slowest queries.]]></description>
    <content:encoded><![CDATA[<p>Instrument your search API with about 20 lines of OpenTelemetry (OTel) code, and Elasticsearch Query Language (ES|QL) can tell you what people are searching for, how often they get nothing back, and how fast search actually runs. This builds directly on <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first post in this series</a>, where we made the case for using OpenTelemetry over a bespoke analytics pipeline. Here, we wire up a FastAPI search endpoint with custom <code>search.*</code> attributes and run six ES|QL queries against the resulting trace data. On our demo cluster, 17.7% of searches came back empty, a gap we found within minutes of turning the instrumentation on. No separate logging pipeline is required. It's the same spans, attributes, and query language you're probably already running somewhere else in Elastic.</p><h3>What you'll discover</h3><p>In this post, you'll learn how to:</p><ul><li><p>Set up OpenTelemetry in an example Python FastAPI back end.</p></li><li><p>Add custom <code>search.*</code> attributes to your search spans in ~20 lines of code.</p></li><li><p>Understand how OTel-native ingestion maps attributes to queryable data.</p></li><li><p>Write six ES|QL queries against real trace data: top queries, zero-results rate, which queries return nothing, average and max latency, slow query investigation, and search volume over time.</p></li><li><p>Turn those queries into saved Kibana visualizations.</p></li></ul><h3>What you'll need</h3><ul><li><p>An Elastic Cloud deployment (or self-managed with OTel-native ingestion enabled). Examples tested on Elastic Stack 9.x.</p></li></ul><h2>From concept to code: Building the search analytics instrumentation</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">first post</a>, we made the case for using OpenTelemetry to capture search analytics. The idea: Add <code>search.*</code> attributes to your existing OTel spans, send them to Elastic APM, and query them with <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL</a>.</p><p>Now let's build it.</p><p><strong>Want working code?</strong> A <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">companion reference project</a> accompanies this series. It's a minimal FastAPI app with the exact instrumentation described below. Clone it, add your Elastic Cloud credentials, and you'll have search analytics data flowing in 10 minutes. Each blog stage maps to a commented-out code block you can enable as you progress.</p><h3>An OpenTelemetry primer for search API developers</h3><p>If you've been building search systems but haven't worked with OpenTelemetry before, here's the minimum you need to know.</p><p>OTel is an open standard for collecting observability data, traces, metrics, and logs from your applications. It's vendor-neutral: You instrument your code once, and send data to any compatible back end.</p><p>The core concept is the <em>span</em>. A span represents a single operation, for example, an API call, a database query, or a search request. Every span has a start time, an end time (the difference is the <em>span duration</em>), and <em>attributes</em>, which are key-value pairs that describe what happened.</p><p>Spans nest inside each other to form <em>traces</em>. A trace is a tree of spans that represents one end-to-end request. When a user searches, the trace might look like: browser request → API handler → search logic → Elasticsearch query. Each step is a span, and the parent-child relationships show you exactly where time was spent. This is <em>distributed tracing</em>, which works across services and network boundaries, so a single trace can follow a request from front end to back end to database and back.</p><p><strong>Why traces instead of logs?</strong> You could log <code>"search query=headphones results=15 took=120ms"</code> and parse it later. But a log line is flat; it can't show you that the 120ms Elasticsearch time sat inside a 250ms API call, revealing 130ms of overhead in your application layer. Traces give you hierarchy, timing, and correlation across services. For search analytics, that means you can see not just <em>what</em> happened but also <em>where</em> time was spent and <em>how</em> operations relate to each other.</p><p>For this post, we don't need to understand the full OTel ecosystem. We just need three things:</p><ol><li><p><strong>Create a span</strong> when a search request happens.</p></li><li><p><strong>Add attributes</strong> to that span, describing the search (such as <code>search.query</code> or <code>result_count</code>).</p></li><li><p><strong>Send the span</strong> to Elastic, where we can query it with ES|QL.</p></li></ol><p>That's it. If you can call <code>span.set_attribute("key", value)</code>, you can build search analytics.</p><h3>What you'll build</h3><p>By the end of this post, every search request in your API will emit an OTel span that looks like this:</p>span.name:                     "search"
search.query:                  "wireless headphones"
search.result_count:           15
search.query_id:               "e2afdb85eb63382e..."
search.took_ms:                165<p>And you'll run six ES|QL queries against real data to answer questions that your team is already asking, using about 20 lines of instrumentation code in total.</p><h2>Install the OTel SDK</h2><p>We're using Python and FastAPI here. The same pattern applies to any language with an OTel SDK; the concepts are identical, only the imports change.</p><p>Elastic provides the <a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry Python (EDOT)</a>, which bundles the standard OTel SDK with sensible defaults, early access to Elastic-contributed improvements, and a single <code>configure_opentelemetry()</code> call that handles all the boilerplate. We recommend it:</p>pip install elastic-opentelemetry \
    opentelemetry-instrumentation-fastapi \
    opentelemetry-instrumentation-elasticsearch<p>Three packages, two roles:</p><ul><li><p><code>elastic-opentelemetry</code> EDOT: The OTel API, SDK, and OpenTelemetry Protocol (OTLP) exporter in one package, preconfigured for Elastic.</p></li><li><p><code>opentelemetry-instrumentation-fastapi</code>: Auto-instruments HTTP endpoints (automatic spans for every request).</p></li><li><p><code>opentelemetry-instrumentation-elasticsearch</code>: Auto-instruments Elasticsearch client calls (automatic spans for every query).</p></li></ul><p>The auto-instrumentation packages are doing real work here. Without writing a single line of tracing code, you already get HTTP request spans and Elasticsearch query spans. What we're adding is the search-specific context that turns generic traces into analytics.</p><p><strong>Using the standard OTel SDK instead? </strong>Replace <code>elastic-opentelemetry</code> with <code>opentelemetry-api</code>, <code>opentelemetry-sdk</code>, and <code>opentelemetry-exporter-otlp-proto-http</code>. You'll need to wire up the <code>TracerProvider</code>, <code>OTLPSpanExporter</code>, and <code>BatchSpanProcessor</code> manually (about 10 extra lines). Everything else in this post works the same either way. See the <a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">Elastic OTel guide</a> for the full setup.</p><h2>Configure the connection</h2><p>OTel uses environment variables for connection configuration. You need four to get started:</p><p>Variable</p><p>Purpose</p><p>Example</p><p>`OTEL_EXPORTER_OTLP_ENDPOINT`</p><p>Managed OTLP (mOTLP) endpoint URL</p><p>`https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com`</p><p>`OTEL_EXPORTER_OTLP_HEADERS`</p><p>Authentication</p><p>`Authorization=ApiKey &lt;your-api-key&gt;`</p><p>`OTEL_SERVICE_NAME`</p><p>Service name (shown in Kibana APM)</p><p>`search-analytics-demo`</p><p>`OTEL_RESOURCE_ATTRIBUTES`</p><p>Other resource attributes</p><p>`service.version=1.0.0`</p><p>Where to find these values: In Elastic Cloud, your mOTLP endpoint follows the pattern <code>https://&lt;deployment&gt;.ingest.&lt;region&gt;.gcp.elastic-cloud.com</code>. You can find it in the Elastic Cloud console under your deployment's details or in Kibana at the APM integration page (<code>/app/home#/tutorial/apm</code>) under the <strong>OpenTelemetry</strong> tab. Your API key can be created from Kibana's Stack Management &gt; API Keys or via the Elasticsearch Create API Key API. For self-managed deployments, you can use the <a href="https://www.elastic.co/docs/reference/edot-collector">EDOT Collector</a> as an intermediary that receives OTLP and forwards to Elasticsearch.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb920fc0478908c55/6a6a3402c40efb7565d30950/86c9ed08f305c168429eff0c0ad059c4d8262197-1440x708.png" alt="Kibana APM integration page showing OpenTelemetry configuration settings for search API instrumentation" /><p>Set them in your environment or <code>.env</code> file:</p>export OTEL_EXPORTER_OTLP_ENDPOINT="https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey &lt;your-api-key&gt;"
export OTEL_SERVICE_NAME="search-analytics-demo"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.0.0"<h2>Initialize the tracer</h2><p>With EDOT and environment variables configured, initialization wires up three things: the tracer provider and two auto-instrumentation packages that automatically create spans for every HTTP request and every Elasticsearch query:</p>from opentelemetry import trace
from elastic_opentelemetry import configure_opentelemetry
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.elasticsearch import ElasticsearchInstrumentor

def init_otel(app):
    configure_opentelemetry()
    FastAPIInstrumentor.instrument_app(app)
    ElasticsearchInstrumentor().instrument()

tracer = trace.get_tracer("search-api")<p><code>configure_opentelemetry()</code> reads the <code>OTEL_*</code> environment variables and sets up the tracer provider, exporter, and batch processor with Elastic-optimized defaults, including using the HTTP exporter automatically, which is what the mOTLP endpoint requires. If you see connection errors with vanilla OTel, the most common cause is accidentally using the gRPC exporter instead of HTTP.</p><p>The two instrumentors patch the FastAPI and <code>elasticsearch-py</code> libraries at startup so every request and every Elasticsearch call automatically generates a span, without any code changes to individual endpoints.</p><h2>Instrument your search API</h2><p>Here's where it gets interesting. This is the code that turns a generic API endpoint into a search analytics source:</p>from opentelemetry import trace

tracer = trace.get_tracer("search-api")

@app.post("/api/search")
def search(request: SearchRequest):
    with tracer.start_as_current_span("search") as span:
        # Set attributes BEFORE the query
        # (available even if the query fails)
        query_id = format(span.get_span_context().trace_id, "032x")
        span.set_attribute("search.query", request.query)
        span.set_attribute("search.query_id", query_id)

        results = es.search(
            index="products",
            body=build_query(request)
        )

        # Set attributes AFTER the query
        total_hits = results["hits"]["total"]["value"]
        span.set_attribute("search.result_count", total_hits)
        span.set_attribute("search.took_ms", results["took"])

        # Include query_id in the response so the frontend can link
        # click and conversion events back to this search
        return {
            **format_response(results),
            "query_id": query_id,
        }<p>A few things worth unpacking.</p><ul><li><p><code>start_as_current_span</code> creates the span and sets it as the active span in the current context. This matters because the Elasticsearch client instrumentation picks up the active span and nests its own spans underneath it. You get a span hierarchy automatically.</p></li><li><p><code>search.query_id</code> is derived from the trace ID. Every trace already has a unique identifier, and we're reusing it as the query identifier. There’s no UUID generation or database sequence. When we add click tracking later, clicks will reference this same <code>query_id</code> to link back to the search that produced the results.</p></li><li><p><code>search.result_count</code> does double duty. It tells you how many results came back, and when it's zero, you know you have a content gap. There’s no need for a separate boolean flag: Just filter on <code>result_count == 0</code> in your queries.</p></li></ul><p>Note that the application name is no longer set as a span attribute; it's the <code>service.name</code> resource attribute, configured once via <code>OTEL_SERVICE_NAME</code>. This is the standard OTel approach: Resource attributes describe the service, and span attributes describe the operation.</p><h3>Normalize search queries before analyzing them</h3><p>Notice that we're storing <code>request.query</code> as is. That means "Laptop Bag", "laptop bag", and " laptop bag " will be counted as three different queries when you aggregate with <code>STATS ... BY attributes.search.query</code>.</p><p>For cleaner analytics, normalize before setting the attribute:</p>span.set_attribute("search.query", request.query.strip().lower())<p>Lowercasing and trimming whitespace is enough for most cases. If you need the original phrasing (for display or debugging), store it in a separate attribute, like <code>search.query.original</code>. But start simple. You can always add the raw version later if you find you need it.</p><h3>What a search API trace looks like</h3><p>Once this is running, a single search request produces this trace:</p>HTTP POST /api/search          (root — auto-instrumented by FastAPI)
└── search                     (our span — search.* attributes live here)
    ├── info                   (ES client — auto-instrumented)
    ├── query_rules.get_ruleset (ES client)
    └── search                 (ES client — the actual Elasticsearch query)<p>The auto-instrumented spans give you HTTP latency and Elasticsearch query detail. Your <code>search</code> span in the middle ties them together with the business context: what the user searched for, how many results came back, how long Elasticsearch took.</p><h3>The search span attributes you need to capture</h3><p>Here's the full set of attributes we're capturing on the search span:</p><p>Attribute</p><p>Type</p><p>When set</p><p>Purpose</p><p>`search.query`</p><p>string</p><p>Before query</p><p>The query as the user entered it</p><p>`search.query_id`</p><p>string</p><p>Before query</p><p>Unique identifier, derived from trace ID</p><p>`search.result_count`</p><p>int</p><p>After query</p><p>Total matching results (0 = zero-result search)</p><p>`search.took_ms`</p><p>int</p><p>After query</p><p>Elasticsearch execution time in milliseconds</p><p>`search.query_response_hit_ids`</p><p>string[]</p><p>After query</p><p>Document IDs returned (optional; enables per-result analytics)</p><p>`feature_flag.key`</p><p>string</p><p>Before query</p><p>A/B test flag name (optional; pair with `feature_flag.result.variant` for the assigned variant; enables per-variant click-through rate (CTR) comparison)</p><p>We use the <code>search.*</code> namespace following OTel's convention of domain-specific prefixes (<code>http.*</code>, <code>db.*</code>, <code>messaging.*</code>). While there aren't standardized search conventions in OTel yet, <code>search.*</code> is self-describing and vendor-neutral. The naming is informed by the <a href="https://www.ubisearch.dev/">User Behavior Insights (UBI)</a> Standard, which defines a schema for search events. We reference it for structure without coupling to it. Where established OTel conventions exist, like <code>feature_flag.key</code> (flag name) and <code>feature_flag.result.variant</code> (assigned variant) for A/B experiments, we reuse them rather than inventing custom attributes.</p><p>You'll notice that <code>enduser.pseudo.id</code> isn't in the search span table above. We don't need it for query analytics, but you'll add it as soon as you introduce click tracking in our third blog; it ties click events back to a specific browser session, enabling per-user CTR and Mean Reciprocal Rank (MRR). Blog 3 adds <code>enduser.pseudo.id</code> (browser-generated, persistent across sessions) to link clicks back to searches. Our fourth blog focussing on revenue attribution documents <code>session.id</code> and <code>user.id</code> as optional extensions for authenticated users who want cross-device attribution.</p><h2>How OpenTelemetry attributes become queryable ES|QL fields</h2><p>Before we start querying, you need to understand how OTel attributes map to Elasticsearch fields. With OTel-native ingestion into Elastic, the mapping is straightforward.</p><p>OTel attribute</p><p>Type</p><p>ES|QL field</p><p>`search.query`</p><p>string</p><p>`attributes.search.query`</p><p>`search.result_count`</p><p>int</p><p>`attributes.search.result_count`</p><p>`search.took_ms`</p><p>int</p><p>`attributes.search.took_ms`</p><p>`search.query_id`</p><p>string</p><p>`attributes.search.query_id`</p><p>`feature_flag.key`</p><p>string</p><p>`attributes.feature_flag.key`</p><p>With OTel-native ingestion, attribute names preserve their dot notation under <code>attributes.*</code>. All types live in the same namespace; there’s no split between string and numeric fields. Booleans are stored as native booleans, not strings. If you've used Elastic APM's classic ingestion before, you'll appreciate the simplicity: What you set in code is what you query.</p><h2>Running ES|QL queries in Kibana Discover</h2><p>With spans flowing to Elastic, open Kibana and go to <strong>Discover</strong> (in the left sidebar under <strong>Analytics</strong>, or use the global search bar and type "Discover"). By default, you'll see the KQL query bar, a filter language familiar from Kibana dashboards. Click <strong>Try ES|QL</strong> in the top right to switch to the ES|QL editor. Unlike KQL (which filters documents) or the JSON query DSL (which requires nested objects), ES|QL is a piped language: Each <code>|</code> step transforms the previous output, making aggregations like <code>STATS count BY field</code> read naturally from left to right.</p><p>The editor gives you a full-width text area where you type piped queries. Results appear as both a table and an auto-generated chart; Kibana picks a sensible visualization based on your query shape. For <code>STATS ... BY</code> queries, you'll get a bar chart automatically.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt839635c0a21e4916/6a6a3403d57c1d4bd1c13ef0/d284fdad7c58576f79209e72b7579b37f4ebbe63-1440x708.png" alt="Kibana Discover ES|QL query results showing search count and average results by search query" /><p>Set the time range wide enough to capture your data (top-right date picker). If you're just getting started, try "Last 30 days".</p><h2>Six ES|QL queries for search analytics</h2><p>Everything below runs against <code>traces-generic.otel-default</code>, the index where Elastic's OTel-native ingestion automatically stores trace data. You don't need to create this index; it's provisioned by Elastic when the first OTLP span arrives.</p><p>These queries ran against our live demo cluster: 62 searches, ~20 distinct queries, latency range 77ms–153ms.</p><p><strong>Note:</strong> Results below are illustrative. When you run the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> and generate traffic with <code>python generate_traffic.py --blog 2 --sessions 50</code>, your exact numbers and top queries will vary based on session count and random query selection. The query patterns and ES|QL syntax are what matter here.</p><h3>Query 1: Are spans arriving?</h3><p>Start simple. Count your search spans.</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS total_searches = COUNT(*)<p><strong>Result:</strong> 62.</p><p>If this returns zero, your spans aren't arriving. Check your OTLP endpoint and API key and that <code>init_otel()</code> is being called before any requests. The <code>name == "search"</code> filter ensures that you're counting your custom spans, not the auto-instrumented Elasticsearch client spans (which are also named "search").</p><h3>Query 2: What are users searching for?</h3><p>The first question every search team asks.</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND attributes.search.query != ""
  AND name == "search"
| STATS
    search_count = COUNT(*),
    avg_results = ROUND(AVG(attributes.search.result_count), 0)
  BY attributes.search.query
| SORT search_count DESC
| LIMIT 20<p><strong>Results:</strong></p><p>Query</p><p>Searches</p><p>Average results</p><p>laptop</p><p>9</p><p>8</p><p>headphones</p><p>7</p><p>12</p><p>running shoes</p><p>6</p><p>5</p><p>"laptop" was the most popular query, with nine searches. There were around 20 distinct queries total (including zero-result ones).</p><p>The <code>avg_results</code> column tells you whether popular queries are actually returning content. A query with high volume and low results is a relevance problem worth investigating. If your query has high volume and high results, check whether users are actually clicking. We'll get to that in the next blog focussing on measuring search quality with click data.</p><h3>Query 3: What percentage of searches return nothing?</h3>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    total = COUNT(*),
    zero_results = COUNT(CASE(attributes.search.result_count == 0, 1))
| EVAL zero_rate_pct = ROUND(100.0 * zero_results / total, 1)<p><strong>Result:</strong> 17.7% (11 out of 62 searches returned nothing).</p><p>We're using <code>attributes.search.result_count == 0</code>, a straightforward numeric comparison. No separate boolean attribute is needed when you already have the count.</p><p>A zero-results rate above 10% is worth investigating. Every zero-result search is a user who asked for something and got nothing back. Some of those are junk queries, but others reveal real content gaps or query parsing failures.</p><h3>Query 4: Which queries return nothing?</h3><p>The rate tells you there's a problem. This query tells you where.</p>FROM traces-generic.otel-default
| WHERE attributes.search.result_count == 0
  AND name == "search"
| STATS occurrences = COUNT(*) BY attributes.search.query
| SORT occurrences DESC
| LIMIT 20<p><strong>Results:</strong></p><p>Query</p><p>Occurrences</p><p>quantum physics calculator</p><p>4</p><p>unicorn saddle</p><p>3</p><p>holographic projector</p><p>2</p><p>time machine parts</p><p>2</p><p>Three different failure modes: "quantum physics calculator" and "unicorn saddle" are out-of-catalog queries you'll never be able to serve. This is useful to know but nothing to fix. "holographic projector" might be a real emerging category worth considering. "time machine parts" is probably noise. In a production catalog, these would be mixed with legitimate zero-result queries that <em>are</em> fixable, like missing synonyms, phrasing mismatches, or product gaps.</p><p>Repeated zero-result queries are the highest-priority fixes. One-off failures are usually noise.</p><h3>Query 5: How fast is search?</h3>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms)<p><strong>Result:</strong> Average 81ms, max 153ms.</p><p><code>search.took_ms</code> captures Elasticsearch's self-reported execution time, the <code>took</code> field from the search response. This is different from <em>span duration</em>, the wall-clock time from when the span started to when it ended (as we covered in the primer above). Span duration measures end-to-end time, including network round trips, serialization, and application logic. You want both: Comparing them tells you where overhead lives. If <code>took_ms</code> is 50ms but the span duration is 200ms, the extra 150ms is network or application overhead, not a query problem.</p><p>This attribute also keeps your analytics portable. If you're using OTel log records instead of spans (a lighter-weight alternative we mention in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first blog in the series</a>), there's no span duration. <code>took_ms</code> is the only timing signal you have.</p><p>We'll go deeper on search performance monitoring (Service Level Objectives [SLOs], alerting on latency regressions, and using this data for operational dashboards) in the last blog in the series focussing on Search Reliability Engineering.</p><p>Want to find the slow queries specifically?</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms),
    search_count = COUNT(*)
  BY attributes.search.query
| SORT avg_ms DESC
| LIMIT 20<p>A query with high average latency and high result count is hitting many documents; consider query optimization. High latency with low results might mean complex filters or slow aggregations. Outlier max values are often cold caches or cluster issues.</p><h3>Query 6: How does search volume change over time?</h3><p>Counts, rates, and latencies tell you the <em>what</em>. Volume over time tells you the <em>when</em>: W<em>hen did traffic spike, when did it drop, and when did that zero-results rate jump?</em></p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
| STATS searches = COUNT(*) BY bucket
| SORT bucket<p><code>DATE_TRUNC(5 minutes, @timestamp)</code> rounds each timestamp down to the nearest 5-minute boundary. The result is a time series that Kibana's Lens can render as a bar chart or line, showing your search traffic pattern for any time window.</p><p>Narrow the bucket for higher granularity (<code>1 minute</code>), widen it for trend analysis (<code>1 hour</code>, <code>1 day</code>). When you add this to a dashboard alongside your zero-results rate, you can answer: <em>Did zero-results spike because traffic changed or because something broke?</em></p><h2>Verify that your search API instrumentation is working</h2><p>If you're using the reference project, the full setup is:</p>git clone https://github.com/elastic/elasticsearch-labs.git
cd elasticsearch-labs/supporting-blog-content/search-analytics-otel
cp .env.example .env           # fill in ELASTICSEARCH_URL, ELASTIC_API_KEY,
                               # OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS
python3 -m venv venv &amp;&amp; source venv/bin/activate
pip install -r requirements.txt
python load_data.py             # index products into Elasticsearch
python app.py                   # starts on http://localhost:8000<p>Then trigger a search:</p>curl -X POST http://localhost:8000/api/search \
  -H "Content-Type: application/json" \
  -d '{"query":"laptop"}'<p>Wait 5–10 seconds for the <code>BatchSpanProcessor</code> to flush, and then open Kibana → Discover → switch to ES|QL mode and run:</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
| LIMIT 5<p>You should see rows with <code>attributes.search.query</code>, <code>attributes.search.result_count</code>, and <code>attributes.search.took_ms</code>.</p><p>If no rows appear, check in order:</p><ol><li><p><code>OTEL_EXPORTER_OTLP_ENDPOINT</code> points to the mOTLP endpoint, not your Elasticsearch URL.</p></li><li><p><code>OTEL_EXPORTER_OTLP_HEADERS</code> includes <code>Authorization=ApiKey &lt;your-key&gt;</code>.</p></li><li><p><code>OTEL_TRACES_SAMPLER=always_on</code> is set (default sampler may drop spans).</p></li><li><p>Kibana → Observability → APM → Services shows <code>search-analytics-demo</code> (confirms export is working).</p></li></ol><h2>Turn ES|QL results into Kibana visualizations</h2><p>The bar chart that Discover auto-generates from your ES|QL results is a good start, but you can customize it. Click the <strong>pencil icon</strong> in the top-right corner of the chart to open the inline Lens editor.</p><p>From here you can:</p><ul><li><p>Change chart type (bar, line, area, pie, table, metric).</p></li><li><p>Adjust axes and add breakdown dimensions.</p></li><li><p>Save the visualization to a dashboard.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e8869398e3cb98b/6a6a3404c40efb00b9d30954/e3db0028b0dff2ce5a524b62f600bfcfd8820f22-1440x708.png" alt="Kibana Lens configuration panel for a bar chart visualization of ES|QL search analytics data" /><p>This is the path from ad hoc ES|QL exploration to a persistent dashboard panel. You don't need to build visualizations from scratch; Discover and Lens handle the chart rendering from your query results.</p><p>Lens is Elastic's drag-and-drop visualization editor, and it's more capable than this quick workflow suggests. You can build multilayer charts, combine metrics with breakdowns, add reference lines, and design full dashboards that mix ES|QL panels with traditional aggregation-based visualizations. For search analytics, that means you can put top queries, zero-results trends, and latency percentiles side by side in a single view.</p><p>To go deeper:</p><ul><li><p><a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Lens documentation</a>: A full guide to the visualization editor.</p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL in Lens</a>: Using ES|QL queries as data sources for dashboard panels.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana Dashboards</a>: Building and sharing operational dashboards.</p></li><li><p>Use an <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-dashboards-and-visualizations">Agent built in Kibana to make visualizations for you</a></p></li></ul><p>We'll build a full search analytics dashboard in a later post.</p><h2>How sampling affects search analytics accuracy</h2><p>Most application performance monitoring (APM) configurations sample traces to control costs, capturing 10% or 25% of requests. For application monitoring, that's fine. For search analytics, it's a problem.</p><p>If you're sampling at 10%, your "total searches" count is 90% lower than reality. Your zero-results rate is still accurate (it's a ratio), but volume counts are off.</p><p>Two approaches:</p><p><strong>Approach 1: Configure 100% sampling for search endpoints.</strong> Your search API probably handles far fewer requests than your main application, so the data volume increase is manageable. The simplest way is through environment variables:</p># 100% sampling (capture every trace)
export OTEL_TRACES_SAMPLER=always_on

# Or sample a percentage (e.g. 50%)
export OTEL_TRACES_SAMPLER=traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.5<p>These are head-based sampling decisions made at the start of each trace. They apply globally to the service, which is fine if your search API is a dedicated service. If search shares a service with other endpoints and you need per-endpoint sampling rules, you can implement a custom <code>Sampler</code> in the OTel SDK that inspects the span name or attributes before deciding.</p><p>More sophisticated routing (sampling differently per endpoint, dropping noisy spans, or making decisions after a trace completes [tail-based sampling]) typically involves deploying an OTel Collector (such as the <a href="https://www.elastic.co/docs/reference/edot-collector">EDOT Collector</a>) as an intermediary between your application and Elastic. That's a valuable architecture pattern, but it's beyond the scope of this post. See the <a href="https://opentelemetry.io/docs/collector/">OTel Collector documentation</a> and <a href="https://www.elastic.co/docs/reference/edot-collector/modes">Elastic's EDOT Deployment</a> for more on collector-based sampling and routing architectures.</p><p><strong>Approach 2: Upscale in your queries.</strong> If you know the sampling rate, multiply: <code>EVAL estimated_total = total_searches * 10</code>. Ratios and averages stay correct; only absolute counts need adjustment.</p><p>For more on sampling strategies generally, see the <a href="https://opentelemetry.io/docs/concepts/sampling/">OTel sampling documentation</a>.</p><p>For the queries in this post, we used 100% sampling.</p><h2>What's next: Adding click tracking to search analytics</h2><p>The six ES|QL queries in this post answer: <em>What do users search for, what returns nothing, how fast is search, and when does traffic spike?</em> They're all derived from a single instrumentation point: the search span.</p><p>But they can't tell you whether users are finding what they need. A search that returns 15 results looks healthy from the server side. But if nobody clicks any of those results, your ranking has a problem.</p><p>In the next post, we add <em>click tracking</em>, a second span that captures which result the user clicked and where it appeared in the list. If you've been running the reference project, you already have 62 search spans; the next post builds directly on that data. With searches and clicks linked together, we'll calculate:</p><ul><li><p><strong>Click-through rate (CTR):</strong> What percentage of searches result in a click.</p></li><li><p><strong>Mean Reciprocal Rank (MRR):</strong> How far down the results users have to scroll.</p></li><li><p><strong>Click position distribution:</strong> The shape of where users click.</p></li></ul><p>The pattern is the same: You add attributes to spans and query them with ES|QL. You get a richer view of the same data without introducing new infrastructure.</p><h2>Resources to get started with search analytics on OpenTelemetry</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project</a>: Working code for the entire blog series; clone, configure, run.</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">EDOT Python</a>: Elastic distribution of OpenTelemetry for Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic</a>: How to send OTel data to Elastic APM.</p></li><li><p><a href="https://opentelemetry.io/docs/languages/python/">OpenTelemetry Python SDK</a>: Upstream SDK documentation and instrumentation guides.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a>: Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI Standard</a>: Reference schema for search event structure.</p></li></ul><p><em>This is the second post in a series on search analytics with OpenTelemetry and Elastic. Next up: Measuring search quality: Click tracking, CTR, MRR, and click position analysis.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf69e1ba73bd5d402/6a6a340599442cd9e0df1d94/1794a179d9536e693a0982634da59aff209c9d68-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[4 NVIDIA AI tasks, 1 Elasticsearch API: Embeddings, chat, completion, and rerank]]></title>
    <description><![CDATA[Set up NVIDIA hosted models in Elasticsearch with one API key and a model ID. No custom integration code needed.]]></description>
    <content:encoded><![CDATA[<p></p><p>Elasticsearch's <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">inference API</a> now connects directly to NVIDIA-hosted models. You get text embedding, completion, chat completion, and reranking, plus access to NVIDIA's catalog of NVIDIA Inference Microservices–optimized (NIM-optimized) retrieval and generative models, without writing any custom integration code.</p><p>In practice, that's vector search and retrieval augmented generation (RAG) applications built on NVIDIA-hosted embeddings. It's also multi-turn conversations through the chat completion API and reranking with NVIDIA's cross-encoder models to push relevance past keyword matching. All four task types run natively through the inference API, with support for both streaming and non-streaming responses. How do I get an NVIDIA API key?</p><p>NVIDIA offers a broad catalog of models designed for a wide range of use cases, all of which can be explored on the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. Throughout this article, we provide specific examples of high-performance models optimized for each inference task type. After identifying the model that best aligns with your application requirements, choose the deployment approach that fits your infrastructure and operational needs. This could mean running it on-premises for greater control or using a serverless option for faster experimentation and simplified scaling.</p><p>To get started quickly, you’ll first need access to NVIDIA’s model catalog and APIs. Create an account or log in at <a href="https://www.build.nvidia.com/">https://www.build.nvidia.com/</a> to explore available models, evaluate their capabilities, and compare which ones best fit your use case before proceeding toward full-scale deployment. This site provides a web-based interface for testing models, which is useful during evaluation and experimentation. For production-level requirements, you can use NVIDIA NIM to deploy endpoints on your own infrastructure.</p><p>To access NVIDIA models, you need to generate an API key. This key will serve as the authorization mechanism when making calls to NVIDIA's endpoints. You can create, access, and manage your API keys at <a href="https://build.nvidia.com/settings/api-keys">API keys</a>. To create a new key, click the <strong>Generate API Key</strong> link in the top right, and then specify a name and expiration period for the key. After generating the API key, select the appropriate model for your task and set up the corresponding Elasticsearch inference endpoint.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt238bdc3001941d8c/6a6119f21c28938e356e5cf7/ca6c4a6322433697ad76d28f68e78c03b59095a9-2048x1104.png" alt="" /><h2>Setting up Elasticsearch inference endpoints</h2><p>Once you have set up your NVIDIA account and obtained the necessary API keys, you can create an Elasticsearch inference endpoint.</p><p>Endpoint setup can be done directly in Kibana using the console, which allows you to input the required steps into Elasticsearch even without using an API. The following sections provide examples and details on how to create and use endpoints for <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-text-embedding">text embeddings</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-completion">completion</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-chat-completion-unified">chat completion</a>, and <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-rerank">reranking</a>.For more examples and detailed information, please consult the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-nvidia">Elasticsearch API reference documentation</a>.</p><h3>Creating and using a text embeddings inference endpoint</h3><p>To create a text embedding inference endpoint, you first select an appropriate model that can perform embedding operations. NVIDIA lists its models in the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. You can select the <strong>Text-to-Embedding</strong> or <strong>Retrieval Augmented Generation</strong> use case on the left to filter the appropriate models. You can also find NVIDIA’s text embedding models in the <a href="https://docs.api.nvidia.com/nim/reference/retrieval-apis">NVIDIA documentation</a>. NVIDIA’s retrieval APIs include <strong>text embedding</strong> and <strong>reranking</strong> models. When choosing a model, make sure it explicitly supports text embedding inference. Text embedding models typically include an API description, such as "Creates an embedding vector from the input text."</p><p>A good example of an embedding model is the <a href="https://build.nvidia.com/nvidia/nemotron-3-embed-1b">nvidia/nemotron-3-embed-1b</a> model. You can access <a href="https://build.nvidia.com/nvidia/nemotron-3-embed-1b/deploy">the deployment page for this model</a>, which allows you to deploy this model on-premises.</p><p>Once you have selected a suitable model, open its API reference page, where you’ll find the parameters required to create an Elasticsearch inference endpoint.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf15fb19a2bcf18a0/6a6119f38b1c7a7183893d18/207e483b7ac40b2acee268fc13b80819a9ecf9ed-1259x869.png" alt="NVIDIA embedding API reference showing the POST endpoint and model parameter for llama-nemotron-embed-1b-v2" /><p>Two parameters are relevant:</p><ul><li><p><strong><code>model_id</code></strong>(required): Specifies the NVIDIA model to be used for embedding inference. This parameter is named <code>model</code> on the NVIDIA side.</p></li><li><p><strong><code>url</code></strong>(optional): The endpoint URL used to send requests to the NVIDIA model (either deployed on-premises or in a serverless environment). It must be accessible from your Elasticsearch instance.</p></li></ul><p>For most text embedding models, the URL is static and NVIDIA differentiates models solely via the <code>model</code> parameter. If the <code>url</code> parameter isn’t provided during endpoint creation, the default text embedding task specific value <a href="https://integrate.api.nvidia.com/v1/embeddings">https://integrate.api.nvidia.com/v1/embeddings</a> will be used.</p><p>To generate text embeddings, set up an endpoint configured with the required NVIDIA model values in the service settings map:</p>PUT _inference/text_embedding/nvidia-text-embedding
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://integrate.api.nvidia.com/v1/embeddings", // optional
        "api_key": "&lt;api_key&gt;",
	 "model_id": "nvidia/nemotron-3-embed-1b"
    }
}<p>Upon sending this request, you should receive a successful <strong>200 OK</strong> response. This response confirms that the endpoint is functioning correctly and the settings are specified accurately, and it will detail your newly created Elasticsearch endpoint for the text embedding task type.</p>{
    "inference_id": "nvidia-text-embedding",
    "task_type": "text_embedding",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/nemotron-3-embed-1b",
        "url": "https://integrate.api.nvidia.com/v1/embeddings",
        "rate_limit": {
            "requests_per_minute": 3000
        },
        "dimensions": 2048,
        "similarity": "dot_product"
    },
    "chunking_settings": {
        "strategy": "sentence",
        "max_chunk_size": 250,
        "sentence_overlap": 1
    }
}<p>You can now use the newly created endpoint to generate embeddings. The request for this operation will be similar to the example shown below:</p>POST _inference/nvidia-text-embedding
{
    "input": [
        "First input.",
        "Second input."
    ]
}<p>The text embeddings will be returned, accompanied by a successful HTTP <strong>200 OK</strong> status.</p>{
    "text_embedding": [
        {
            "embedding": [
                -0.016174316,
                0.018432617,
                ...,
                -0.016723631
            ]
        },
        {
            "embedding": [
                -0.008995056,
                0.014381409,
                ...,
                -0.025314331
            ]
        }
    ]
}<p>This integration allows users to use the NVIDIA models directly within Elasticsearch, making advanced search and RAG applications easier to build. These production-ready models offer a reliable and robust foundation for enterprise-scale deployments.</p><h3>Creating and using a completion inference endpoint</h3><p>To create a completion inference endpoint, you first select an appropriate model.</p><p>NVIDIA lists its models in the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. You can search for the model there, but you can also find NVIDIA’s completion models in the left-hand navigation of this <a href="https://docs.api.nvidia.com/nim/reference/llm-apis">large language model (LLM) API documentation</a>.Each entry in the list links to a general description of the model. From there, you can navigate to a nested link that opens the API reference specific to the selected model. A good example of a completion model is the <a href="https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b">nvidia/nemotron-3-super-120b-a12b</a> model. You can access <a href="https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b/deploy">the deployment page</a> for this model, which allows you to deploy this model on-premises.</p><p>Once you have selected a suitable model, open its API reference page, where you’ll find the parameters required to successfully create an Elasticsearch inference endpoint.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt595e423887574160/6a6119f45144f7954cb98969/b3af7e17a0d310bf63c33339056e30f2960ea139-1495x779.png" alt="NVIDIA chat completions API reference showing the POST endpoint and default model nemotron-3-super-120b-a12b" /><p>Two parameters are relevant:</p><ul><li><p><strong><code>model_id</code></strong>(required): Specifies the NVIDIA model to be used for completion inference. This parameter is named <code>model</code> on the NVIDIA side.</p></li><li><p><strong><code>url</code></strong>(optional): The endpoint URL used to send requests to the NVIDIA model (either deployed on-premises or in a serverless environment). It must be accessible from your Elasticsearch instance.</p></li></ul><p>For most completion models, the URL is static, and NVIDIA differentiates between models using only the <code>model</code> parameter. If the <code>url</code> parameter isn’t specified during endpoint creation, the default value of <a href="https://integrate.api.nvidia.com/v1/chat/completions">https://integrate.api.nvidia.com/v1/chat/completions</a> will be used.</p><p>To use a generative model for the Elasticsearch inference completion task, you configure an endpoint that supports completion operations. The service settings map must include the required configuration for the selected NVIDIA model.</p>PUT _inference/completion/nvidia-completion
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://integrate.api.nvidia.com/v1/chat/completions", // optional
        "api_key": "&lt;api_key&gt;",
        "model_id": "nvidia/nemotron-3-super-120b-a12b"
    }
}<p>Upon success, you’ll receive a 200 OK response. This response provides the details of your new Elasticsearch endpoint, which is configured for completion tasks.</p>{
    "inference_id": "nvidia-completion",
    "task_type": "completion",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/nemotron-3-super-120b-a12b",
        "url": "https://integrate.api.nvidia.com/v1/chat/completions",
        "rate_limit": {
            "requests_per_minute": 3000
        }
    }
}<p>The created endpoint allows you to generate both streaming and non-streaming completions. These refer to how the endpoint delivers its output. <em>Non-streaming completions</em> wait until the entire response is generated before sending it back in a single block, resulting in a single, slower response time. In contrast, <em>streaming completions</em> send the generated text back in small, continuous chunks as they’re produced, which allows you to start reading the response immediately. This continuous delivery creates the perception of faster interaction and is essential for real-time conversational interfaces.</p><h4>Generating non-streaming completions</h4><p>To generate non-streaming completions, you call the newly created endpoint with a request similar to the following:</p>POST _inference/completion/nvidia-completion
{
    "input": "The sky above the port was the color of television tuned to a dead channel."
}<p>You'll receive a successful 200 OK response, with the completion result:</p>{
    "completion": [
        {
            "result": "This line uses a simile to describe the sky over a seaport."
        }
    ]
}<h4>Generating streaming completions</h4><p>To use the streaming functionality for the completion task type, you need to send the identical request used for non-streaming completions, but with <code>_stream</code> included in the URL path:</p>POST _inference/completion/nvidia-completion/_stream
{
    "input": "The sky above the port was the color of television tuned to a dead channel."
}<p>This command will initiate a continuous flow of events, delivering a sequence of outputs similar to the example provided below:</p>event: message
data: {"completion":[{"delta":"First"},{"delta":" Second"}]}

﻿event: message
data: {"completion":[{"delta":" Third"},{"delta":" Fourth"}]}

﻿event: message
data: [DONE]<p>This capability empowers users to easily integrate NVIDIA generative models directly into their Elastic applications, supporting both single-response and engaging streaming experiences for dynamic content generation.</p><h3>Creating and using a chat completion inference endpoint</h3><p>To enable more dynamic and flexible interactions than those supported by the standard completion inference endpoint, you configure a chat completion inference endpoint, specifically designed to handle chat-based completion tasks.</p><p>To identify the parameters required to construct the service settings map, refer to the completion inference endpoint section of this blog post. The same configuration principles apply to the chat completion inference endpoint.</p><p>The service settings map must include the required configuration settings for the selected NVIDIA model.</p>PUT _inference/chat_completion/nvidia-chat-completion
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://integrate.api.nvidia.com/v1/chat/completions", // optional
        "api_key": "&lt;api_key&gt;",
        "model_id": "nvidia/nemotron-3-super-120b-a12b"
    }
}<p>Upon success, you’ll receive a 200 OK response, which includes the details of your new Elasticsearch endpoint specifically for the chat completion task type.</p>{
    "inference_id": "nvidia-chat-completion",
    "task_type": "chat_completion",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/nemotron-3-super-120b-a12b",
        "url": "https://integrate.api.nvidia.com/v1/chat/completions",
        "rate_limit": {
            "requests_per_minute": 3000
        }
    }
}<p>You can now use the new endpoint to stream generated completions. Your request should resemble the following example:</p>POST _inference/chat_completion/nvidia-chat-completion/_stream
{
    "messages": [
        {
            "role": "user",
            "content": "What is deep learning?"
        }
    ]
}<p>The chat completion results will be delivered to you as a continuous stream of events, formatted as follows:</p>event: message
data: {
    "id": "cmpl-92346cfa1d004f65991eedf0765b622a",
    "choices": [
        {
            "delta": {
                "content": " first chunk"
            },
            "index": 0
        }
    ],
    "model": "nvidia/nemotron-3-super-120b-a12b",
    "object": "chat.completion.chunk"
}
﻿﻿event: message
data: {
    "id": "cmpl-92346cfa1d004f65991eedf0765b622a",
    "choices": [
        {
            "delta": {
                "content": " second chunk"
            },
            "finish_reason": "length",
            "index": 0
        }
    ],
    "model": "nvidia/nemotron-3-super-120b-a12b",
    "object": "chat.completion.chunk",
    "usage": {
        "completion_tokens": 10,
        "prompt_tokens": 8,
        "total_tokens": 18
    }
}

﻿event: message
data: [DONE]<p>The chat completion capability, distinct from the simpler completion API, allows users to build stateful, multi-turn conversational AI applications directly within the Elastic Stack, using the full flexibility of NVIDIA models for dynamic user interactions following Elasticsearch inference chat completion API.</p><h3>Creating and using a rerank inference endpoint</h3><p><em>Reranking</em> is the process of reordering the results from an initial search query to improve their relevance to your intent. Reranking is a second-stage relevance step that reorders the results returned by an initial retriever. In many cases, it uses a different model than the retriever itself, typically a cross-encoder model, which evaluates the query and each candidate document together to produce a more accurate relevance score. The output is a list of results ranked based on their relevancy, thereby drastically improving the quality and contextual accuracy of the search results.</p><p>To create a rerank inference endpoint, you first select an appropriate model that can perform reranking operations. NVIDIA lists its models in the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. You can use the <code>reranking</code> label to select the appropriate models. You can also find NVIDIA’s reranking models in the left-hand navigation of this <a href="https://docs.api.nvidia.com/nim/reference/retrieval-apis">retrieval APIs documentation</a>. NVIDIA includes rerankingand text embedding models in the Retrieval APIs section in its API documentation. When selecting a model, ensure that it explicitly supports rerank inference requests. Rerank models typically include an API description, such as “Ranks passages by their relation to a query.” This wording indicates that the model supports the rerank task type.</p><p>A good example of a reranking model is the <a href="https://build.nvidia.com/nvidia/llama-nemotron-rerank-vl-1b-v2">nvidia/llama-nemotron-rerank-vl-1b-v2</a>. You can access <a href="https://build.nvidia.com/nvidia/llama-nemotron-rerank-vl-1b-v2/deploy">the deployment page for this model</a>, which allows you to deploy this model on-premises.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt818a68e2286f5b73/6a6119f4f81792e7ea07f5fd/64cf92d4db85b4af88f84e2048c589457c3fb1c1-1495x772.png" alt="NVIDIA rerank API reference showing the POST endpoint and model parameter for llama-nemotron-rerank-vl-1b-v2" /><p>Once you have selected a suitable model, open its API reference page, where you’ll find the parameters required to create an Elasticsearch inference endpoint. Two parameters are relevant:</p><ul><li><p><strong><code>model_id</code></strong>(required): Specifies the NVIDIA model to be used for rerank inference.</p></li><li><p><strong><code>url</code></strong>(optional): The endpoint URL used by the inference endpoint to send requests to the NVIDIA service.</p></li></ul><p>For most models, the URL is static and NVIDIA differentiates between models using only the <code>model</code> parameter. If the <code>url</code> parameter isn’t specified during endpoint creation, the default value</p><p><a href="https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking">https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking</a> will be used. The <a href="https://build.nvidia.com/nvidia/llama-nemotron-rerank-vl-1b-v2">nvidia/llama-nemotron-rerank-vl-1b-v2</a> model requires a custom URL to be specified, and it will be used in the example below.</p><p>To perform reranking tasks, you configure an inference endpoint that executes reranking operations. The service settings map must include the required configuration for the selected NVIDIA model.
</p>PUT _inference/rerank/nvidia-rerank
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-vl-1b-v2/reranking", // optional
        "api_key": "&lt;api_key&gt;",
        "model_id": "nvidia/llama-nemotron-rerank-vl-1b-v2"
    }
}<p>The successful creation of your new Elasticsearch endpoint for the rerank task type will be confirmed by a 200 OK response, which will also provide the specific details of the endpoint.</p>{
    "inference_id": "nvidia-rerank",
    "task_type": "rerank",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/llama-nemotron-rerank-vl-1b-v2",
        "url": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-vl-1b-v2/reranking",
        "rate_limit": {
            "requests_per_minute": 3000
        }
    }
}<p>You can then start using the new endpoint to perform a ranking operation with a request like the one shown below:</p>POST _inference/rerank/nvidia-rerank
{
    "input": [
        "mercury",
        "venus",
        "earth",
        "mars",
        "jupiter",
        "saturn"
    ],
    "query": "which planet is third from the sun"
}<p>A successful HTTP 200 OK status will be returned, and the ranked entries will be included in the response. Since models are not deterministic, the results you receive may vary and may be ordered differently across calls, as the same outcome isn’t guaranteed each time.</p>{
    "rerank": [
        {
            "index": 2,
            "relevance_score": -8.5
        },
        {
            "index": 1,
            "relevance_score": -8.9453125
        },
        {
            "index": 4,
            "relevance_score": -8.984375
        },
        {
            "index": 3,
            "relevance_score": -9.0078125
        },
        {
            "index": 0,
            "relevance_score": -9.5546875
        },
        {
            "index": 5,
            "relevance_score": -10.53125
        }
    ]
}<p>Integrating the rerank capability with Elasticsearch and NVIDIA elevates search applications to deliver the most accurate, contextually relevant results. By using the NVIDIA reranking models within the search infrastructure of Elasticsearch, the system moves beyond simple keyword matching. This capability prioritizes the most relevant documents after the initial search, drastically improving the user experience and the utility of the data.</p><h2>NVIDIA and Elasticsearch: What's next</h2><p>The integration of Elasticsearch's inference API with NVIDIA marks a considerable step forward for users. By providing a standardized, simpler path to access NVIDIA's high-performance, optimized models, this integration significantly expands Elastic's capabilities. Users can now work with these models for key AI tasks, including generating text embeddings for vector search, generating and streaming content with completion models, building stateful conversational AI applications with chat completion, and drastically improving search result accuracy through reranking. This simplification streamlines the development of sophisticated AI-powered applications, from advanced RAG systems to dynamic conversational interfaces, making powerful AI more accessible for Elastic users.</p><p>Ready to get started?</p><ul><li><p>Explore the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-nvidia">Elasticsearch API reference documentation</a> to dive deeper into setup.</p></li><li><p>Browse the <a href="https://build.nvidia.com/models">NVIDIA model catalog</a> to see all available models.</p></li><li><p>Check out the <a href="https://docs.api.nvidia.com/">NVIDIA API Documentation hub</a> for integration guides and API references.</p></li><li><p>Start your journey by creating an <a href="https://build.nvidia.com/settings/api-keys">NVIDIA account and API key</a> to begin integrating the models today.</p></li></ul><h2>Frequently asked questions</h2><p><strong>How do I connect Elasticsearch to NVIDIA hosted models?</strong></p><p>Create an NVIDIA API key at <a href="http://build.nvidia.com">build.nvidia.com</a>, and then create an Elasticsearch inference endpoint using the <code>nvidia</code> service with your API key and a <code>model_id</code>. Elasticsearch's inference API supports four NVIDIA task types: text embedding, completion, chat completion, and reranking.</p><p><strong>What's the default endpoint URL for NVIDIA text embedding models in Elasticsearch?</strong></p><p>If no <code>url</code> is specified when creating the endpoint, Elasticsearch defaults to <code>https://integrate.api.nvidia.com/v1/embeddings</code> for text embedding tasks. Completion and chat completion tasks default to <code>https://integrate.api.nvidia.com/v1/chat/completions</code> instead.</p><p><strong>Can I use NVIDIA NIM models deployed on my own infrastructure with Elasticsearch?</strong></p><p>Yes. NVIDIA NIM supports on-premises deployment, and Elasticsearch's inference endpoint accepts a custom <code>url</code> parameter pointing to your self-hosted NIM endpoint instead of NVIDIA's serverless API.</p><p><strong>How do I stream chat completion responses from NVIDIA models in Elasticsearch?</strong></p><p>Append <code>_stream</code> to the chat completion endpoint's URL path (<code>POST _inference/chat_completion/{id}/_stream</code>). Elasticsearch returns results as a continuous event stream instead of a single blocking response, ending with a <code>[DONE]</code> event.</p><p><strong>What's the difference between the completion and rerank task types in Elasticsearch's NVIDIA integration?</strong></p><p>Completion and chat completion generate new text from a prompt. Reranking takes an existing list of documents and a query and then reorders them by relevance score using a cross-encoder model; it doesn't generate text, it rescores what you already retrieved.</p><p><strong>How do NVIDIA's reranking models improve Elasticsearch search results?</strong></p><p>NVIDIA's reranking models evaluate the query and each candidate document together, producing a relevance score used to reorder results beyond keyword matching. Elasticsearch's rerank endpoint returns each document's index and relevance score, so the highest-scoring passages surface first.</p><p><strong>Do I need a paid NVIDIA account to use hosted models with Elasticsearch?</strong></p><p>You need an NVIDIA account and an API key generated at <a href="http://build.nvidia.com">build.nvidia.com</a>; NVIDIA's build platform offers both free evaluation access and paid production tiers, depending on usage. Elasticsearch itself doesn't add separate licensing for the NVIDIA service beyond your existing NVIDIA account terms.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-nvidia-inference</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-nvidia-inference</guid>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <dc:creator><![CDATA[ Jan Kazlouski]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt88526af16bafdb7c/6a17d7807f6f15825dc0998d/d11e1ba058784ec92b8953fb8db62e1bad21c210-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How Elasticsearch auto-tunes vector quantization to hit your recall target]]></title>
    <description><![CDATA[Learn the geometric model that lets Elasticsearch predict recall with R² &gt; 0.98 accuracy and auto-select vector quantization parameters from a small data sample.]]></description>
    <content:encoded><![CDATA[<h2>What makes a good vector store?</h2><p>A vector store that achieves good performance without tuning is more valuable than one that requires expert tuning. In fact, our contention is a data store that can be coaxed to exceptional performance by an expert who spends a week hand-tuning it is less useful than one that beats it consistently out of the box. In other words, easily achieving good performance is a first class property, not a nice to have. We can see this clearly in our telemetry. The great majority of users will never tune the internals of vector search at all, and why should they: it is just an enabler for what they're trying to build.</p><p>This is the imperative behind features like auto-calibration. The system as a whole should look at your data and your quality target and choose good parameters for you. Indeed we think this is a win-win, since it has far more nuanced information available to it to make these choices than we expose.</p><p>To make "good performance" precise, it helps to name the three attributes that characterize any vector search system, because they trade off against one another and you can't talk about one without fixing the others:</p><ol><li><p>Performance: throughput (QPS), latency, and so on.</p></li><li><p>Hardware cost: a fair comparison always holds cost fixed. It's trivial to buy your way to more QPS or better recall by throwing hardware at the problem; the interesting question is what you achieve <em>per dollar</em>.</p></li><li><p>Search quality: recall, nDCG, and related measures of whether you're returning the right results.</p></li></ol><p>The three form a frontier. Push one and, at fixed budget, you pay in another. Any honest comparison of approaches pins two down and measures the third.  What we describe in this post is the mechanism we're introducing to pick quantization parameters for a fixed recall budget. It is a step on a longer journey towards a vector store that configures itself well across the board.</p><h3>Why recall is the right quality metric for vector search</h3><p>Search quality is tricky, because the "right" results depend on relevance labels you usually don't have at index time. So we lean on recall as a safe proxy. The argument is simple: recall measures how well the approximate index reproduces the results of exact search over the <em>same embeddings</em>. If recall is high, you have not degraded search quality relative to what the underlying model can do; you can be confident you’ve faithfully preserved the baseline. You might still wish for a better embedding model, we've got you <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">covered</a>, but that's a separate concern from the index not damaging what the model already gives you.</p><p>This is why controlling recall matters so much, and why you should be wary of any system that doesn't reliably control it. If a vendor can't control recall, they can silently degrade your search experience, achieving impressive QPS numbers while quietly returning worse results, and you'd have no way to know without a labeled evaluation set. The method in this post is about maximizing performance while keeping a firm, predictable grip on quality<strong>.</strong></p><h3>Why vector quantization parameters must be chosen at index time</h3><p>What makes the problem genuinely hard is that vectors are quantized <em>as they are indexed</em>, so the parameters that govern quality (how many bits, how deep to rerank, whether to <a href="https://www.elastic.co/search-labs/blog/robust-optimized-scalar-quantization">precondition</a>) have to be evaluated before we've seen the data laid out in its final form. We can't index everything, measure recall, and iterate; by then the quantization is baked in.</p><p>So we need to estimate what we'll need from a small sample, cheaply and in advance. Fortunately the Elasticsearch gives us natural moments to do this: segment merges are exactly such an opportunity. When segments are combined we have to rewrite the data anyway and can assess the data and (re)choose parameters. And as we'll see, models fit to small random samples give excellent estimates of the quantities we actually need to control. They’re typically good enough to set parameters once, with a small margin, and trust them as the index grows.</p><h2>How vector quantization affects nearest-neighbor recall</h2><p>With that motivation in place, let's start to dig into the details.</p><p>Vector quantization is a critical component for making approximate nearest-neighbor (ANN) search affordable at scale; it's an area we've <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-optimization">innovated</a> in the past. Instead of storing and comparing full-precision embeddings, we store a lossy, compressed representation and search over that. The catch is the one above: lossy representations move distances around, so the "nearest" neighbors under quantized distances are not always the true nearest neighbors and recall suffers.</p><p>The standard fix is to over-retrieve and rerank. We use the cheap quantized distances to pull back the top  candidates, then recompute exact distances for those  and keep the best . As long as the true top- are present somewhere in the retrieved top-, reranking recovers them exactly.</p><p>Reranking isn’t free, we have to fetch high precision vectors from disk. However, we can precisely characterize the performance of reranking based on hardware characteristics alone. This reframes the whole problem. The question is no longer "how much does quantization distort distances?" in the abstract, but something which relates back to the attributes we care about:</p>Given a quantization scheme with some error magnitude, and a rerank budget of  candidates, what recall@ should we expect. As an immediate consequence, what is the <em>cheapest</em> set of parameters that hits our recall target?<p>This post derives a model that answers exactly that. The core of it is a single, surprisingly clean idea: if we can characterize the <em>distribution of distances to the </em><em>-th nearest neighbor</em>, and we have a model of the <em>quantization error distribution</em>, then we can compute expected recall after reranking in closed form (up to a one-dimensional integral). Everything else – bit counts, rerank depth, whether to precondition – becomes a search over a model we can fit cheaply from a small sample, instead of an expensive empirical sweep over full indices built with those parameters.</p><p>We build it up to this in three stages: the geometry of nearest-neighbor distances, the scaling law that falls out of it, and then the recall model that ties quantization error to recall given a reranking budget. Be warned, the following gets a little bit involved, but to give you intuition about what is happening see the video below.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt78cc494d516dd35f/6a6119ed258cd202d9c16ec8/4c7a1499f27a3f9ed406e98565bdf8f9c6c7b823-900x506.gif" alt="Animation showing how vector quantization error displaces nearest-neighbor distances and how reranking to depth n recovers recall by re-scoring candidates with exact distances" /><h2>Quantization error vs. the nearest-neighbor distance gap</h2><p>Fix a query  and rank the database vectors by their true distance to it: , so  is the distance to the -th nearest neighbor. Reranking the top  succeeds for the true -th neighbor whenever it is not pushed past rank  by quantization noise.</p><p>Two competing quantities govern this:</p><ul><li><p>The quantization error that is essentially <em>fixed</em> for a given scheme and dataset: it depends on the embedding dimension, the vector distribution, and the number of bits, but not on how big the index is.</p></li><li><p>The criticality gap , which is the distance between the -th and the -th nearest neighbor. This is the margin we have to absorb error. Crucially, it <em>shrinks as the index grows</em>: pack more vectors into the same region and neighbors crowd together.</p></li></ul><p>There’s a detail here we’ll gloss over for the sake of presentation: for IVF style indices, we’re quantizing the residual from a cluster’s centroid. This does in fact couple the quantization error to the index size, but we can handle it much the same way we handle the distance to the -th nearest neighbor.</p><p>For reranking to recover the recall lost to quantization, we need the error to only rarely exceed the gap. If we can write down the distribution of  and the distribution of the error, we can make that statement quantitative. The first job is to estimate the distribution of nearest-neighbor distances.</p><h2>Deriving the nearest-neighbor distance distribution</h2><p>Real embeddings don't fill their ambient space; they concentrate on a lower-dimensional <a href="https://en.wikipedia.org/wiki/Manifold">manifold</a>. Near a query, though, we can make a mild local assumption: in a small neighborhood  around the query, the data density is roughly uniform. Here  is the intrinsic dimension of the manifold; it is unknown and generally far smaller than the embedding dimension. How to estimate it is the subject of Section 4.</p><p>Let  be the  vectors falling in , modeled as <a href="https://en.wikipedia.org/wiki/Independent_and_identically_distributed_random_variables">i.i.d.</a> uniform on , and define the distance from  to its nearest neighbor:</p><p>To get the distribution of  we use the standard order-statistics trick: rather than ask where the minimum is, ask for the probability it exceeds some radius . The event  is exactly the event that every point lands outside the -ball centered on the query .</p><p>A single point lands inside  with probability equal to the ratio of the ball's volume to the region's volume </p><p>where  is the <a href="https://en.wikipedia.org/wiki/Volume_of_an_n-ball">volume</a> of the unit -ball. (We assume  is large enough that the relevant  is small, so the ball doesn't spill outside  and boundary effects are negligible.) Because the points positions are assumed to be independent, the <a href="https://en.wikipedia.org/wiki/Survival_function">survival function</a> is</p><p>What we're really interested in is how R behaves on average. To compute this, we use the identity that the expectation of a non-negative random variable is the integral of its survival function, . Evaluating this with (2) gives the headline result:</p><p>(The exact integral carries an extra  factor; it's an  constant that we can fold into a fitted coefficient later, so we drop it here.)</p><h3>Glacial scaling: why neighbor distances barely change as your index grows</h3><p>It is interesting to consider what this formula tells us about how distances change with dataset size: . The exponent is , and in high intrinsic dimensions that is a <em>very</em> small number. This is a property the method leans on, so it's worth plugging in some numbers:</p><ul><li><p>If  then doubling  multiplies  by , so distances drop by ~30%.</p></li><li><p>If  then doubling  multiplies  by , so distances drop by a little over 1%.</p></li></ul><p>In high dimensions, neighbor distances barely move even if you add a lot of data; call it glacial scaling<strong>.</strong> It's the reason we can choose quantization parameters <em>once</em> from a tiny sample, with a small safety margin, and trust them to remain valid even after the index grows substantially before the next re-quantization.</p><h2>Expected distance to the k-th neighbor and the criticality gap</h2><p>We actually care about the whole sequence of order statistics , , not just the minimum. There's a simple way to get them.</p><p>Map each radius to the <em>cumulative volume</em> it encloses by defining</p><p>By (1), each  is exactly the probability of landing within radius , so the  are uniform on . The order statistics of uniforms are <a href="https://en.wikipedia.org/wiki/Order_statistic#Order_statistics_sampled_from_a_uniform_distribution">textbook</a>: the -th smallest of  uniforms follows a Beta distribution,</p><p>Inverting the volume map, , gives the scaling of the -th neighbor distance:</p><p>That's all we need for the expected gap:</p><p>The last form is the intuitive one: the gap between the -th and -th neighbors is the distance to the -th neighbor, scaled by . Widening the rerank depth  relative to  opens the gap; higher intrinsic dimension  closes it (the exponent  pushes  toward 1).</p><h3>Why the expected gap is sufficient to predict recall</h3><p>Working with an expectation is only legitimate if the gap doesn't fluctuate wildly around it. It doesn't because concentration of measure saves us. Applying the <a href="https://en.wikipedia.org/wiki/Delta_method">delta method</a> to  and using  from the Beta distribution, a little algebra gives</p><p>So the <a href="https://en.wikipedia.org/wiki/Coefficient_of_variation">coefficient of variation</a> is about . For any reasonable intrinsic dimension this is negligible, which justifies modeling only the expected distances. (If you're worried about the delta method approximation, you can check the results numerically: the delta-method variance and the resulting  coefficient of variation match the exact expressions to several significant figures.)</p><h3>Extending the model to cosine similarity and inner product search</h3><p>The derivation is for the Euclidean metric, but the other common metrics reduce to it:</p><ul><li><p>For cosine similarity, the equidistant surface is the intersection of a sphere around the query with the unit sphere. This is called a <a href="https://en.wikipedia.org/wiki/Spherical_cap">hyperspherical cap</a>, whose volume scales as  for small . Therefore, the analysis carries over unchanged up to constants, with the dimension reduced by one.</p></li><li><p>For MIPS (maximum inner product), some extra care is needed, because nearest neighbors aren't confined to a compact region. A distant vector can still win on inner product if its norm is large enough, so the gap is really governed by the tail of the norm distribution. However, there is a clean fix, which is to use the <a href="https://proceedings.mlr.press/v40/Neyshabur15.pdf">Neyshabur–Srebro transformation</a>. This lifts vectors onto a unit hypersphere in  dimensions. After this operation, it's just the cosine case.</p></li></ul><h2>Fitting intrinsic dimension and scale from a small sample</h2><p>Equation (3) has a known functional form but two unknown parameters: the intrinsic dimension  and the scale . Both are easy to fit, and it's more convenient to fit them from raw neighbor distances than from gaps directly.</p><p>Sample several subsets of database vectors  of sizes  and a set of query vectors . For each query  and each subset, measure , the distance to the -th nearest neighbor of  within . Taking logs of the scaling law  linearises it:</p><p>Specifically, this is linear in  and , so ordinary least squares recovers  and . Varying the subset size  is what makes it possible to estimate : it's precisely the rate at which distances shrink with data volume. With the fitted parameters, the whole-index expected gap is</p><p>Figure 1 shows how well this fits in practice (and it’s remarkably good): predicted versus actual average distance to the -th neighbor, across a range of datasets and metrics, have  between 0.996 and 0.999.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdf3e51da370c7099/6a6119eef2e1c4515ffd2a24/325063c65304dfbc414d211065073f6d3334864d-1622x1256.png" alt="Estimated vs actual nearest-neighbor distances across six datasets and metrics showing vector quantization distance model fit with R² between 0.996 and 0.999" /><h2>Modeling vector quantization error as Gaussian</h2><p>With the nearest-neighbor distance model established, the second component is the quantization error distribution. For every metric we use, the quantized distance estimate differs from the true distance by an error that is a sum of many independent per-dimension contributions. By the <a href="https://en.wikipedia.org/wiki/Central_limit_theorem">Central Limit Theorem</a> that sum tends to Gaussian, so we model the error as normal with a variance we estimate empirically:</p><p>where  is the quantized distance estimate using -bit vectors and  is the total number of (query, neighbor) pairs in our sample set. In other words: sample, quantize, measure the squared distance errors, average.</p><p>Figure 2 shows the empirical basis for the Gaussian assumption: measured quantization error densities against best-fit Gaussians across a variety of datasets. The fit is good, which is what lets the rest of the model stay analytic.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt596d6c8d2f751972/6a6119ef61ff792ec9cd608a/d48a34c93bac53a94a1b164b60362f631285c472-1614x1270.png" alt="Vector quantization error density histograms across six datasets at 1-bit precision with Gaussian fits overlaid, confirming the Central Limit Theorem prediction used in the recall model" /><p>We could stop here and take a <a href="https://en.wikipedia.org/wiki/Minimax">minimax</a> view: threshold the probability that the -th and -th neighbors swap, using the expected gap (4) against the error scale . But that controls a worst-case event, and what we actually want to control is average recall. The outcome would be overly conservative quantization parameters and we'd pay some performance. The next section estimates expected recall properly.</p><h2>Predicting expected recall after reranking</h2><p>Combining the distance model and the error model gives a closed-form estimate of expected recall after reranking. Model the <em>noisy</em> distance of the -th true neighbor as a Gaussian centered on its true distance:</p><p>The -th neighbor survives reranking, i.e., lands in the retrieved top , if fewer than  other vectors have a smaller noisy distance. Condition on  and count the competitors closer than :</p><p>Then the probability of recalling neighbor  integrates over where its own noisy distance lands:</p><p>The terms of  are independent Bernoullis but not identically distributed, since every neighbor  sits at a different true distance , so each has its own probability of intruding on the top- set:</p><p>with  the standard normal CDF. This makes  a <a href="https://en.wikipedia.org/wiki/Poisson_binomial_distribution">Poisson-binomial</a> variable. Since we sum many of them (because ), the Lyapunov CLT applies and we approximate</p><p>with the standard Poisson-binomial moments</p><p>The survival probability then has a clean closed form:</p><p>This is where the two halves of the post so far finally meet. We don't need to know the individual  because the manifold scaling law from Section 3 supplies them: . So the moments become explicit sums over ranks, which we truncate at a safe cutoff (say , since distant neighbors contribute negligibly):</p><p>Finally, average recall@ given rerank depth  sums the per-neighbor recall over the top :</p><p>Here  is the standard normal density. Each integral is smooth and one-dimensional, so Gauss–Legendre quadrature evaluates it in microseconds. The entire recall prediction for a set of candidate parameters costs a handful of quadrature evaluations, not index build and benchmark run.</p><p>Figure 3 validates the end-to-end model: predicted average recall against measured recall across many parameter settings and multiple datasets has .</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt16ee340b47ddffa8/6a6119ef1f595ca8d3fe72eb/2b6663f167a1f9f08558610c71ca538eef257cb5-1464x1442.png" alt="Predicted vs actual average vector quantization recall across four datasets with R² = 0.982, validating the end-to-end recall prediction model" /><h2>How the recall model selects vector quantization parameters</h2><p>With a fast recall predictor available, parameter selection becomes a cheap ordered search. Given a target recall and a rerank budget  (typically expressed as a multiple of ), we can find the <em>minimum</em> document and query bit counts, and other knobs, that clear the target. There are a few things to note that are practically important:</p><ol><li><p>Glacial scaling gives us some safety because  moves so slowly with  for even moderate intrinsic dimension. A small margin in the calculation means the chosen parameters stay valid if a lot of vectors are added before parameters are restimated.</p></li><li><p>Small  is the worst case if  is a fixed multiple of . The gap \mathbb{E}[R_{(k)}]( is smallest for small  so if a parameter choice satisfies the recall target at  then it will for larger  will too.</p></li><li><p>We can treat quantization as a black box because the error model only needs the empirical error variance. This means we can test <em>any</em> configuration, including preconditioning, the same way and we can simply order candidate parameter tuples by increasing index and query cost, and stop at the first choice that hits the target recall. For tuples of (query bits, doc bits, rerank depth, precondition) a sensible search sequence increases query precision first, then document precision , , , , , , , ,  and  each combined (via an outer product ) with rerank depths like  and precondition , exiting as soon as the target is met.</p></li></ol><h3>Results: auto-selected quantization parameters and recall across datasets</h3><p>In this section, we discuss the results of the initial experiments on the end-to-end behavior. We’ve made some further refinements as part of the work to fully integrate with Elasticsearch that we discuss in our other post.</p><p>The table below shows auto-selected parameters targeting recall 0.97, measured with brute-force search, so the number reflects loss due to quantization <em>alone</em> (64 query clusters, targeting document clusters of size 384, which matches the settings of <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a>).</p><p>Dataset</p><p>Query bits</p><p>Doc bits</p><p>Precondition</p><p>Depth</p><p>Recall</p><p>FiQA E5 small</p><p>4</p><p>2</p><p>false</p><p>30</p><p>0.97</p><p>FiQA arctic</p><p>2</p><p>2</p><p>false</p><p>30</p><p>0.95</p><p>FiQA GTE</p><p>2</p><p>1</p><p>true</p><p>30</p><p>0.98</p><p>MNIST</p><p>3</p><p>1</p><p>true</p><p>30</p><p>0.99</p><p>Fashion MNIST</p><p>3</p><p>1</p><p>true</p><p>30</p><p>0.99</p><p>Quora E5 small</p><p>2</p><p>2</p><p>false</p><p>30</p><p>0.99</p><p>Quora arctic</p><p>2</p><p>1</p><p>false</p><p>30</p><p>0.97</p><p>Quora GTE</p><p>1</p><p>1</p><p>false</p><p>30</p><p>0.98</p><p>Dbpedia E5 small</p><p>4</p><p>2</p><p>false</p><p>30</p><p>0.99</p><p>Dbpedia arctic</p><p>2</p><p>1</p><p>false</p><p>30</p><p>0.94</p><p>Dbpedia GTE</p><p>2</p><p>1</p><p>false</p><p>30</p><p>0.96</p><p>Wiki Cohere</p><p>2</p><p>2</p><p>false</p><p>30</p><p>0.99</p><p>Hotpot E5 small</p><p>4</p><p>2</p><p>false</p><p>30</p><p>0.97</p><p>Hotpot GTE</p><p>2</p><p>1</p><p>false</p><p>30</p><p>0.96</p><p>Glove 100</p><p>4</p><p>2</p><p>false</p><p>30</p><p>0.87</p><p>Glove 200</p><p>4</p><p>2</p><p>false</p><p>30</p><p>0.89</p><p>SIFT128</p><p>4</p><p>4</p><p>false</p><p>20</p><p>0.99</p><p>There are a few things worth highlighting:</p><ul><li><p>The recall is very sensitive to rerank depth. This is why we nearly always end up choosing the maximum depth available: a step up in rerank depth from 20 to 30 is typically what pushes us to hit the recall target for fewer bits and we prefer fewer bits. In the real system, we tuned this behavior based on a more representative reranking cost.</p></li><li><p>Glove underperforms partly we approximate the query distribution with random samples from the corpus, but Glove is also less well characterized by the model than the other datasets. A plausible explanation is that the approximately uniform local density assumption from Section 2 is less reliable for Glove embeddings, which would show up as higher recall variance between queries. However, Glove embeddings are not representative of the actual vectors we need to store.</p></li><li><p>The FiQA GTE preconditioning choice is a knife-edge case: preconditioning produced only a tiny expected recall improvement, but the prediction sat right at the recall cutoff and allows us to drop the query from 3 to 2 bits. If we'd rather only keep preconditioning where its benefit is clear-cut, we can enforce a minimum uplift threshold. This sort of fine-tuning of the decision logic leaves all the heavy lifting to estimate recall unaffected.</p></li></ul><h2>Key takeaways: auto-tuning vector quantization from first principles</h2><p>We presented a method to pick optimal quantization parameters to achieve a target recall. It rests on two models that compose cleanly:</p><ol><li><p>A geometric model of neighbor distances that follows from a local uniform density assumption. We use this to derive the nearest-neighbor distance, the  glacial scaling law of the expected distance, and the expected distance profile . We show that fitting  and  by a simple log-linear regression to average distances in small random samples from the corpus gives an extremely accurate predictive model.</p></li><li><p>A Gaussian quantization error model that is justified by the CLT. Its only parameter  is an empirical variance we estimate by comparing quantized and raw vector similarities for a sample of the corpus.</p></li></ol><p>Finally, we show that it is possible to feed the estimated distance model into a Poisson-binomial count of neighbors that intrude on the top- set. Applying the Lyapunov CLT the expected recall@ after reranking to depth  falls out as a one-dimensional integral we evaluate by quadrature.</p><p>The outcome is an accurate () predictive model of recall as a function of the quantization parameters. Choosing quantization parameters then becomes an ordered search with a predictive model telling us if we’ve hit the recall constraint. And nicely one that also comes with a built-in argument (glacial scaling) for why the chosen parameters remain safe even when estimated from a relatively small fraction of the data.</p><p>We’ve built this entire mechanism into Elasticsearch using segment merges as an opportunity to reassess our quantization choices. Aside from the peace of mind this brings (that you’ll achieve good recall whatever vectors you throw at it), it also allows us to chose near optimal parameters from a performance perspective. This closes the loop on our original objective: near optimal performance out of the box, at least as far as quantization goes. We’re pretty excited about the advantages that model based tuning can bring to vector search and look forward to sharing other work we have in this direction in the near future.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-elasticsearch</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Thomas Veasey,Tommaso Teofili]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt950edbc25d291821/6a6119f01b1d495dc56f181b/31783975126874424fc20c3c96bd95fe28d5f201-1280x720.png" length="0" type="image/png"/>
    <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI shopping agents: Why context comes before the query]]></title>
    <description><![CDATA[AI shopping agents that guess at your vocabulary make expensive mistakes. Pre-computed catalog context stops the guessing before the first tool call.]]></description>
    <content:encoded><![CDATA[<p>The race is on for retailers to match the evolving expectations of their customers to provide significantly richer online shopping experiences. Customers want to go beyond searching for products; they want interactive, personalized, and proactive guidance powered by AI. The challenge is that, although large language models (LLMs) can be extremely powerful, how do you construct a system that acts like a knowledgeable employee of your store in a fast, accurate, and cost-effective way? We’ll discuss the challenges of building these AI shopping assistants as well as emerging context engineering approaches to optimize how they work.</p><p>AI shopping agents fail not because the model is wrong but because the agent arrives at every conversation not knowing your catalog, vocabulary, or business rules. It has to discover all of this context through tool calls, and that discovery is the cost. Precomputing a structured context layer from signals you already hold (vocabulary, policies, user profiles, session behavior) cuts the exploratory work the agent does before it can answer and makes its behavior governed and predictable.</p><p>In the analogous document-retrieval case, precomputing context reduced input tokens by up to 75% on a controlled benchmark; we expect comparable savings in ecommerce because the exploration pattern is the same, though the exact figure will vary by catalog and query mix. The signals are richer in ecommerce than in almost any other domain, and most retailers already have them. The question is whether or not these signals are assembled in a form that the agent can use before it starts reasoning. Elastic’s broad mix of search capabilities, semantic, hybrid, keyword, filtering, and aggregations make it a compelling choice for not only building your core retrieval tools but also as the vital context engine.</p><h2><strong>Why AI shopping agents fail in production</strong></h2><p>Retailers investing in AI shopping assistants are discovering an uncomfortable gap between what the demos promise and what the first few months in production deliver.</p><p>The assistant takes four seconds to respond. It confidently recommends a product in a size range that doesn't exist for that item. It tells a returning customer about a coat style they bought 18 months ago and returned. It filters by a category name that doesn't match the internal taxonomy and returns zero results. The customer gives up and abandons the chat altogether.</p><p>These aren't model failures. The frontier models powering these agents are capable of extraordinary reasoning when they have the right information in front of them. The problem is that the agent arrives at the conversation knowing nothing about the retailer's catalog, customer, or business rules that govern what should and shouldn't be recommended. It has to learn all of this through the conversation itself, making exploratory tool calls to discover what departments exist, what filter values are valid, and what the brand's policies are on certain product types. Every one of those discovery calls costs latency and tokens before the agent has said a single useful thing to the customer.</p><p>Latency matters in ecommerce in a way it doesn't in many other contexts. Shoppers expect response times measured in seconds, not the minutes that enterprise knowledge-base agents routinely take. It’s well established that slower responses reduce engagement and conversion in online retail. An AI shopping assistant that thinks visibly for five seconds before answering a question about gift ideas isn't a feature; it's friction.</p><p>The fix isn't a faster model or a bigger context window. <strong>The agent's latency and cost problem is a context problem.</strong> This can be solved by carefully computing context before the agent call, not during it.</p><h3><strong>What an AI shopping agent knows before it searches</strong></h3><p>Imagine you’re the agent. You have a search tool connected to the catalog, and this query arrives:</p><p><code>"an outfit for an autumn wedding"</code></p><p>What do you actually do with that?</p><p>Start with what you don't know. An outfit for a man or a woman? Is "autumn" a color, a season, a style of fabric, or just when the wedding happens? And who is this shopper, someone who has bought from you for years, or a stranger? Do they buy expensive designer brands or do they always hunt out a bargain on sale? You have none of these answers. So you do what an agent does when it's working blind: You ask loads of follow-up questions, guess, or fire off a series of exploratory searches to find out what departments and filter values even exist, watching the seconds tick by before you've offered the customer anything at all.</p><p>Hold onto that feeling of working blind. The rest of this article is about what changes when the agent is handed the answers first.</p><h2><strong>Why ecommerce is different from general RAG</strong></h2><p>Most of the published work on reducing agent costs focuses on document retrieval: question-answering over corpora of articles, reports, or knowledge-base entries. A recent experiment from the Elastic team (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) demonstrated that pre-extracting structured facts from documents before the agent call reduced input token consumption by up to 75% and improved answer accuracy from 60% to 92% on a hard factual benchmark. That improvement came in stages, with the largest jump driven by feeding the agent's own wrong answers back into the extraction step rather than by precomputing context alone, which is a distinction we'll return to when we discuss governance.</p><p>Ecommerce applies the same principle to a fundamentally different structure. A product catalog isn't a document corpus. It's a highly structured index of items with strict field semantics, a domain-specific vocabulary of brand names, color codes, and category hierarchies, and a layer of business rules that override pure relevance in specific situations.</p><p>The failure modes that result are distinct from document retrieval augmented generation (RAG) failures:</p><ul><li><p><strong>Vocabulary mismatch:</strong> A customer asks for a "navy jumper." The agent constructs a filter against a field where the canonical value is NAVY and the category is stored as Knitwear &amp; Jumpers. Without a vocabulary mapping, the agent either guesses colors and categories and gets it wrong, or makes multiple exploratory calls to discover what values exist before it can filter correctly.</p></li><li><p><strong>Hallucinated filter values:</strong> Without knowing which filter dimensions are valid for a given query, agents can construct queries against fields that don't exist or with values that return zero results. A filter like category: knitwear looks reasonable; <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code> is what the index actually contains. If the agent doesn’t know, it either hallucinates or has to do a separate tool call to find out, causing another LLM loop, which costs time and tokens.</p></li><li><p><strong>Context-free personalization:</strong> The same query from two different customers, one who typically shops in the premium range and dresses for formal occasions, and one who buys primarily casualwear under £40, should return different results. Without profile context, the agent treats every query identically, which is worse than a well-tuned keyword search because it creates the impression of a personal assistant while delivering generic answers.</p></li></ul><h2><strong>The context layer: What signals it needs</strong></h2><p>The reason ecommerce is particularly well suited to precomputed context is that retailers already hold an unusually rich set of signals. The challenge isn't data availability; it's assembly.</p><p>The signals fall into two groups. Two of them, the catalog vocabulary and the business policies, are the genuinely original work and the heart of this approach. The rest, live facet state, user profiles, and session history, are valuable but closer to table stakes, signals that most teams already understand how to fetch. Here's what most mid-to-large retailers have and what each signal prevents.</p><p>Signal</p><p>What it prevents</p><p>Effort to build</p><p>Catalog vocabulary</p><p>Vocabulary mismatch and hallucinated filter values; the agent guessing at colors, categories, or brand names instead of resolving them to canonical field values</p><p>One-time engineering effort (full-catalog aggregation); incremental to maintain as new categories and brands are added</p><p>Business policies</p><p>Recommendations that ignore legal or trading requirements, for example, missing age verification on alcohol queries or routing that misses a gluten-free range</p><p>Human-authored and governed, not automated; ongoing review as new policy types are added</p><p>Live facet state</p><p>Recommending filters that return zero results or out-of-stock options for the current query</p><p>Runs in parallel with vocabulary and policy lookups; leans on existing catalog and retrieval infrastructure</p><p>User profile</p><p>Making a returning customer restate sizes, budget, or brand preferences they've already given</p><p>Fastest signal to retrieve, a single document lookup by user ID</p><p>Session and purchase history</p><p>Re-recommending an item the customer already dismissed or bought and returned</p><p>Most aspirational layer; depends on customer relationship management (CRM) and analytics integration, best added once the agent is already live</p><p>The reason we say <em>semantic metadata</em> and not just <em>metadata</em> is that we’re trying to match the semantic (meaning) of the intent rather than the exact words. If a user is searching for “teal,” we should be able to understand that this is a color and which colors exist in our products that are semantically similar to teal, even if none of them are actually teal. So, if we search for “teal,” we might want to return:</p><p><code>ProductColours = “aquamarine, turquoise”</code></p><p>Hopefully, you can see how this semantic metadata is bridging the gap between user intent and the agent’s knowledge of the products.</p><h3><strong>Catalog vocabulary: The foundation of context engineering</strong></h3><p>Catalog vocabulary is the layer that does the most work, and it's the one most worth getting right first.</p><p>A vocabulary index maps natural language to the exact field values and category paths used in the product index. It answers questions like: <em>What does "navy" map to?</em> <em>Which categories fall under "knitwear"?</em> <em>Is "Autograph" a brand or a range?</em> <em>What's the correct spelling of a competitor brand the agent might encounter in a query?</em></p><p>What makes this more than a synonym list is how it's queried. The interesting thing a shopper says is rarely an exact field value. They say "something cozy for fall," not <code>colour: NAVY</code> and <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code>. So the vocabulary index needs to resolve fuzzy, natural language intent into precise, exact-match filters, and that requires both kinds of matching at once: semantic search to understand that "cozy" leans toward knitwear and fleece, and exact keyword matching to pin the result to the canonical values the product index actually stores. A metadata index that supports both on the same documents is, in effect, a translation layer between how customers talk and how the catalog is structured.</p><p>This is also where the index earns the description "semantic metadata layer" rather than "lookup table." Each entry is a small natural language description of a facet value or schema concept, so the agent can match against meaning and then read back the exact filter to use. For a typical fashion retailer, this covers hundreds of color values, brand aliases, category synonyms, and size-range conventions. Building the semantic metadata layer from a full-catalog aggregation is a one-time engineering effort; maintaining it is incremental as new categories and brands are added. Without it, an agent encountering an unfamiliar term must either guess or make exploratory tool calls to discover what's there.</p><h3><strong>Business policies in the context layer</strong></h3><p>The second original layer is policy. Some queries carry implicit business requirements that pure relevance cannot handle. A query for "wine gift for a friend" should trigger an age-verification reminder in markets where it's legally required. A query for "gluten-free food gift" should route away from general confectionery toward the specific gluten-free range. A query mentioning "wedding guest outfit" in spring should apply different weighting than the same query in November.</p><p>These are policies, and most retail search teams already write them. They just call them boost rules, merchandising overlays, or synonym configurations. The difference in an agentic context is that instead of being applied silently as query modifications, they're surfaced as readable hints the agent can use when deciding how to frame its answer and which products to surface. The agent doesn't have to infer your trading rules from the catalog; it's handed them.</p><p>The critical point: These policies encode business intent, not just relevance. A policy that routes alcohol queries through an age-appropriate flow isn't a retrieval optimization; it's a trading requirement. That's why this layer must be human-authored and governed, not generated automatically from traffic patterns, a point we return to in the governance section.</p><p>Together, vocabulary and policy are what make the agent behave like it understands your business rather than just your data. The remaining three signals sharpen the experience, but they're more familiar engineering.</p><h3><strong>Facet, profile, and session signals in the context layer</strong></h3><ul><li><p><strong>Live facet state:</strong> Before the agent recommends filters, it should know which filters are available and how many results each returns for this specific query. An agent that suggests "filter by size 8" without knowing that size 8 is out of stock for this query undermines the customer's trust immediately. A facet state query against the product index, run in parallel with the vocabulary and policy lookups, returns the counts, ranges, and available values specific to the current query.</p></li><li><p><strong>User profile:</strong> A persistent profile (sizes, color preferences, budget range, brand affinities) lets a returning customer skip restating what they've already told you. It's typically the fastest signal to retrieve, a single document lookup by user ID.</p></li><li><p><strong>Session and purchase history:</strong> Within a session, the agent should know what the customer has already seen, dismissed, or added to their basket, so it doesn't re-recommend a dismissed item or repeat itself. Longer-term purchase history extends this, and signals like return history are richer still, but using them well depends on data most retailers hold in systems that aren't yet wired into their search path. This is the most aspirational layer and the one best approached last, once the earlier layers are delivering value. There must also be balance when building the initial context not to overinflate the size, which will slow down the first reply and increase token costs. There is, therefore, a careful balance to strike between providing information like purchase history in the initial context or providing it as a tool for the agent to use during conversation, but users will expect that, if they’re logged in, the agent should know what they’ve purchased. The precise optimal context is likely to be specific to each implementation and customer experience and will require careful testing.</p></li></ul><h2><strong>How context engineering works before the LLM call</strong></h2><p>The pattern that makes this work is simple to describe and moderately involved to implement:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt017dd46a26cf7bc6/6a6119f6f81792b87a07f605/e814160fc7e599aa3cd1603be366cc726114948f-1024x559.png" alt="Diagram of context engineering for AI shopping agents: a context layer resolves vocabulary, policy, facets, profile and session signals in parallel before the LLM call" /><p>Without this context build, the agent makes the same discoveries on its own, but through LLM-driven tool calls that each cost a full inference round trip. As a rough rule of thumb, an exploratory tool (for example, GetFilterValues) call tends to land somewhere in the region of 600ms to 1 second of latency in practice; an agent that discovers the vocabulary, checks policy hints, and retrieves facet state through three separate tool calls before it even begins answering can, therefore, add two to three seconds to the response time, and that's before it performs the actual product search. These are order-of-magnitude estimates, not benchmarked figures, and the real numbers depend heavily on the model, the network path, and how the tools are implemented.</p><p>Replacing those discovery calls with a parallel fetch (multiple context-building queries can be done in parallel) that runs before the LLM is invoked removes most of that cost. The context build takes roughly the same wall time as a single LLM tool call, but it replaces three or four of them. The LLM would either need to repeat failed searches or do its own context-building tool calls sequentially to get enough context to be successful. Precomputing context is also deterministic rather than subject to the model's tool-selection choices, which gives the business more control to fine-tune the experience.</p><p>The token reduction follows the same logic: Each exploratory tool call returns raw data the model must process. A preassembled context summary replaces that raw data with structured facts the model can consume in one pass. At the scale of usage possible in public-facing retail websites, this token cost saving can be significant. This work on document search (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) showed up to 75% input token reduction on a controlled benchmark. That benchmark was document retrieval rather than ecommerce, and its authors are explicit that the multiplier isn't a fixed number you can expect everywhere. We expect the direction to hold in ecommerce, because the exploration pattern is the same; it just runs against a structured catalog rather than a document corpus. The magnitude is something each team should measure against its own traffic.</p><h3><strong>The AI shopping agent with full context</strong></h3><p>Remember the query that left you guessing: an outfit for an autumn wedding. Run it again, but this time, before you have to think, you’re handed a precomputed context as a short brief:</p><ul><li><p>This shopper’s name is Sarah, female, age 32, and she buys womenswear, size 12.</p></li><li><p>She typically buys your mid-tier ranges.</p></li><li><p>"Autumn" here matches these specific color labels: “rust”, “burgundy”, “forest green”, “camel”.</p></li><li><p>Matching departments: “Womenswear”, “Menswear”.</p></li><li><p>Matching tags: "occasion dresses”, “trouser suits”, “wedding”.</p></li><li><p>She already has a burgundy bag in her basket.</p></li><li><p>House rule for wedding-guest looks: Complete the outfit. Show hats and accessories, not just the dress.</p></li></ul><p>Suddenly, you’re not guessing; you’re styling for this customer. And the interesting part is how those facts combine rather than just stack. The season proposes a whole autumn palette; the burgundy bag already in her basket narrows that palette to the few tones that coordinate with it; the house rule tells you to finish the look with a matching fascinator rather than stopping at the dress. Together, these facts let you answer like someone who knows both this customer and this shop, in a single pass, with nothing invented.</p><p>That short brief is exactly what the signal stack produces: the shopper's profile, the resolved vocabulary, the live basket, and the business policy. These are assembled in parallel and placed in front of the agent before its first move, so the "<em>What do I even do with this?</em>" problem never has to be solved one expensive tool call at a time.</p><h2><strong>Governing the context layer without automated drift</strong></h2><p>One difference between the approach described here and automated knowledge extraction systems is worth addressing directly: In ecommerce, the context index cannot self-update without human review.</p><p>The policies that govern how an agent responds to gift queries, alcohol queries, or queries from customers in certain age brackets aren't just relevance configurations; they're trading decisions with potential legal and brand implications. An automated system that generates new policies from traffic patterns, without review, is a compliance risk before it's a technical asset.</p><p>This is actually the right constraint for most retail organizations, and it aligns with how search teams already work. Merchandisers write boost rules. Search teams maintain synonym configurations. Content teams approve what language appears in automated recommendations. The context policy layer is the same kind of governed configuration; it just serves a different consumer, namely, the agent's reasoning step rather than the query pipeline.</p><p>It's worth noting where this differs from the document-retrieval work referenced earlier. In that experiment, the biggest accuracy gain came from an automated feedback loop that fed the agent's wrong answers straight back into the extractor. That works well for factual question-answering, where "right" and "wrong" are unambiguous. In ecommerce, the equivalent signals still surface automatically, but a human decides what to do with them, because the changes carry trading and compliance weight. The loop is the same shape; the publication step has a person in it.</p><p>The governance loop that works in practice has two tiers:</p><ul><li><p><strong>Automatic signal surfacing:</strong> Zero-result queries, repeated reformulations on the same topic, and sessions that end without a purchase after an agent interaction are all signals that something in the context layer is missing or wrong. These surface automatically as candidates for improving the experience, for example: a vocabulary term that didn't resolve or a policy that didn't fire on a query type it should have covered. To do this, you need a thorough log of conversations, including the reasoning and tool call trace in a platform like Elastic. This allows you to analyze the performance of the agent using both structured tools, for example, percentage increase in thumbs-down conversations and semantically. You could also run an automated review of conversations about "gifts" around December to characterize the thumbs-up/down ratio across an AB test of two agents.</p></li><li><p><strong>Human authorship and review:</strong> The search or merchandising team reviews candidates and authors the appropriate vocabulary entry or policy. Policies go through approval before publication. This typically mirrors the workflow that already exists for synonym changes or boost rule modifications; the tooling is the only new part.</p></li></ul><h2><strong>How to implement context engineering in phases</strong></h2><ul><li><p><strong>Phase 1: Vocabulary layer</strong> (highest leverage, bounded engineering task).</p></li><li><p><strong>Phase 2: Facet state and initial policies</strong> (leans on the same catalog and retrieval primitives).</p></li><li><p><strong>Phase 3: User profiles and session signals</strong> (requires CRM and analytics integration; best added when the agent is active).</p></li><li><p><strong>Phase 4: Governed feedback loop</strong> (shifts to organizational alignment; surfaces gaps for merchandising teams).</p></li></ul><p>The full signal stack described above doesn't need to be built at once, and the order isn’t arbitrary. The highest-leverage starting point is also the lowest in implementation complexity: the vocabulary layer. A semantic metadata index built from a full-catalog aggregation (canonical color values, brand aliases, category paths, field names) is a bounded engineering task, and an agent that can resolve "navy jumper" to color: NAVY, masterCategoryNames: "Knitwear &amp; Jumpers" before its first tool call is materially better than one that discovers this through trial and error. If you build nothing else, build this.</p><p>Facet state and the first set of policies can follow close behind, often in parallel, because they lean on the same catalog and the same retrieval primitives. The later layers, such as user profiles, session signals, and the governed feedback loop, are where the work shifts from search engineering to organizational alignment. To implement CRM system integration, merchandising workflow changes, and the analytics needed to surface gaps can take a significant amount of work. Those layers are more valuable once the agent is already in regular use and generating the traffic signals that make the governed loop worth running. The important feature is that each layer stands on its own, so a retailer gets real value from phase one without committing to phase six.</p><h2><strong>What infrastructure does a context layer need?</strong></h2><p>Precomputing context at the depth described here places specific requirements on the underlying platform. It's worth being explicit about these, because the temptation in early agent builds is to reach for the simplest available tool for each capability.</p><ul><li><p><strong>Semantic search</strong> to match natural language queries against the vocabulary index and surface the right canonical values. Fuzzy keyword matching alone won't resolve ambiguity between similar brand names or color terms.</p></li><li><p><strong>A percolator</strong> to implement the policy layer. A percolator reverses the usual search direction: Instead of matching a query against stored documents, it stores the queries and matches an incoming piece of text (here, the customer's message) against them. That's exactly what policy matching needs, because each policy is essentially a saved pattern that says "When a query looks like this, surface this hint."</p></li><li><p><strong>Real-time aggregations</strong> over the full product catalog to produce accurate facet state at query time. Precomputed facet snapshots go stale quickly in active catalogs; query-time aggregations are the more reliable source.</p></li><li><p><strong>Document retrieval by key</strong> for user profiles: fast, single-document lookups by user ID that must complete within the context build window.</p></li><li><p><strong>Structured and semantic logging and analytics</strong> over query traces and agent interactions, which are the raw material for the governed loop's automatic signal surfacing.</p></li></ul><p>These are standard capabilities of a mature search and analytics platform, rather than six separate systems, and Elasticsearch provides all of them in one place. That matters less as a procurement point than as an architectural one: When semantic matching, percolation, aggregations, profile lookups, and analytics all run against the same catalog in the same cluster, the context layer stays consistent with the search layer by construction. Splitting these across a separate vector store and a separate analytics platform is a legitimate choice, but it adds operational surface and introduces a consistency problem between two systems that are reasoning about the same products. The context infrastructure is simplest to run when it lives where the product data already lives and can be updated without a separate extract, transform, load (ETL) step.</p><h2><strong>Conclusion: Context engineering is a search team's job to own</strong></h2><p>The retailers who run effective AI shopping experiences at scale aren't the ones with the largest models or the most generous token budgets. They're the ones who have done the work to make their catalog, vocabulary, and tpolicies legible to an agent before it starts reasoning.</p><p>The good news is that most of the work is already done. The vocabulary is implicit in the catalog. The policies exist as merchandising rules and compliance guidelines. The user profiles are in the CRM. The session signals are in the analytics stream. The gap isn't data; it's the assembly layer that turns those signals into a structured context the agent can consume before it starts reasoning.</p><p>The search team already owns the vocabulary, policies, and merchandising workflows. The context layer is the right home for work the search team is already doing, in a form that serves the agent as well as the query pipeline. And, because it grows every time a gap is found and filled, it behaves less like a setup cost and more like an asset that compounds.</p><p>To begin building a context layer in Elastic, you can start a <a href="https://www.elastic.co/cloud?utm_campaign=G-TXT-EMEA-UK+CA-Core-EN-Lead_Gen-CloudTrials-BR&amp;utm_content=Brand-Cloud&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elastic%20cloud%20trial&amp;utm_id=701610000005lJVAAY&amp;gad_source=1&amp;gad_campaignid=22979576770&amp;gbraid=0AAAAADrDgoJnVYpNJwbmfVxoTcZSmr4S8&amp;gclid=CjwKCAjwx7LSBhB3EiwAjcodxPrvWRgCciehjf-6cu_sOb7FxbwDEJiS8Dpl95oQo7D2J61zXLJrgRoCtgQQAvD_BwE">trial of Elastic Cloud</a> or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">run locally</a>. You should become familiar with configuring <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a>, and if you’re interested in how to build, store, and match search policies at query time, you will enjoy <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">this blog</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt360319cdeac21a74/6a6119f81b1d49b36a6f1823/8aaf734953fe5ae677062bf06aef7352f186657c-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[A picture is worth 1.5x the words: What we learned benchmarking product search embeddings]]></title>
    <description><![CDATA[We benchmarked two embedding models on 5,000 real products and found that combining image and text beats either alone by up to 50%. Here's the data and the model that won.]]></description>
    <content:encoded><![CDATA[<p>A picture is worth 1.5x the words: What we learned benchmarking product search embeddings</p><p>Combining image and text into one embedding beats either alone, and the gap isn't small. In our tests, averaged image and text embeddings put the correct product in the top spot up to 1.5 times as often as image embeddings alone. We benchmarked 5,000 real apparel and footwear products in English and German across two Jina embedding models, <a href="https://jina.ai/models/jina-clip-v2/"><code>jina-clip-v2</code></a> and <a href="https://jina.ai/models/jina-embeddings-v5-omni-small/"><code>jina-embeddings-v5-omni-small</code></a>, to see which model and which indexing strategy actually wins for ecommerce search. The older, narrower Contrastive Language–Image Pre-training–style (CLIP-style) model beat the newer, more general one, and that wasn't what we expected. This post walks through the data, the method, and what we'd recommend doing with it.</p><h2>jina-clip-v2 vs. jina-embeddings-v5-omni-small: What's different</h2><p>Multimodal embedding models work by generating representative semantic vectors for inputs of different kinds in a single high-dimensional space.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf638d8046a86a96d/6a6119f95144f70015b98971/9fb7128bc42109876edbe403985a8d621d535ead-1326x973.png" alt="Diagram of a dog and a cat mapped as vectors in a shared embedding space, illustrating how multimodal embeddings represent product similarity" /><p>We used two models that do this:</p><p></p><p>jina-clip-v2</p><p>jina-embeddings-v5-omni-small</p><p>Architecture</p><p>Dual encoder (separate text + image towers)</p><p>Single shared backbone + frozen encoders</p><p>Parameters</p><p>~865M total</p><p>~1.74B total</p><p>Embedding dimensions</p><p>1024</p><p>1024</p><p>Max input</p><p>512×512 images, 8k tokens text</p><p>32k tokens</p><p>Language coverage</p><p>Broad multilingual</p><p>~100 languages</p><p>Modality handling</p><p>Text ↔ image alignment (purpose-built)</p><p>Text, image, audio, video via projectors</p><p><code>jina-clip-v2</code> is a CLIP-style dual encoder: a text tower (Jina XLM-RoBERTa, 561M parameters) and a separate image tower (EVA02-L14, 304M parameters), about 865M parameters in total. The two towers are independently trained but fine-tuned to output to a common semantic space. It produces 1024-dimensional embeddings, handles images up to 512×512 and up to 8k tokens of text, and has broad multilingual support. It has been engineered specifically to support text-to-image, image-to-text, and text-to-text matching.</p><p><code>jina-embeddings-v5-omni-small</code> has a broader scope. It extends the <a href="https://jina.ai/models/jina-embeddings-v5-text-small"><code>jina-embeddings-v5-text</code></a> model to support images, audio, and video by attaching frozen vision and audio encoders to the frozen text backbone. The encoders connect through <em>cross-modal projectors</em>, small trained layers that map each encoder's output into the text model's embedding space. These projectors are the only part of the model to receive additional training. The resulting model produces 1024-dimensional embeddings, supports a 32k-token input context, and covers roughly 100 languages. It encodes queries and documents asymmetrically: a query with the <code>retrieval.query</code> task, a document with <code>retrieval.passage</code>.</p><p>There’s an important functional difference between the two models: <code>jina-clip-v2</code> is really two separate models trained to work together, but <code>jina-embeddings-v5-omni-small</code> uses a single shared backbone that produces embeddings for all its supported media types. Every modality maps into one shared vector space. In principle, it can handle text, images, audio, or video, or combine materials of different media types into one input, yielding one embedding that encompasses all the data. However, there are two important caveats when using <code>jina-embeddings-v5-omni-small</code>: Combining image and text into one input is a documented weak spot for the model, and the Jina API only allows users to embed one modality per request. Theoretically, it can create a joint image and text vector, but in practice, you can’t with the API (and shouldn’t anyway).</p><h2>The ecommerce product dataset we used</h2><p>For this article, we downloaded the <a href="https://www.kaggle.com/datasets/paramaggarwal/fashion-product-images-dataset">Fashion Product Images</a> dataset from Kaggle. It contains roughly 44,000 catalog entries for products from a real fashion retailer, each with a high-resolution photo and structured metadata. We only used the <code>Apparel</code> and <code>Footwear</code> categories (about 30,600 products) and sampled 5,000 from them with a fixed random seed.</p><p>For each product, the dataset contains three records:</p><ul><li><p>Each product is pictured in a 1800×2400 JPEG against a clean background.</p></li><li><p>Metadata with the labels <code>gender</code>, <code>masterCategory</code>, <code>subCategory</code>, <code>articleType</code>, <code>baseColour</code>, <code>season</code>, <code>year</code>, <code>usage</code>, and <code>productDisplayName</code>.</p></li><li><p>A collection of additional informationwith labels like <code>Neckline</code>, <code>Pattern</code>, <code>Sleeve Length</code>, <code>Fit</code>, and <code>Fabric</code>, and a free-text description in English.</p></li></ul><p>For example, item #13885 is labelled "<em>Scullers Men Check Black Shirts</em>," with an accompanying image (see below) and a description that reads <em>"Black and white checked shirt, made of 100% cotton, full length buttoned placket, long sleeves with buttoned cuffs."</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaaf6af3ebb32e4bc/6a6119fbde9481709fd173c2/fcbc520fecf5f9decda6f6b95054ddae972b9283-1500x1999.png" alt="Product photo of a black and white checked men's shirt used as a sample item in a multimodal product search embeddings benchmark" /><h2>How we generated the search queries</h2><p>We generated our test queries without an AI language model, using rules and substitution lists. </p><p>For each product, we started from its color and article type and added one descriptive modifier, drawn at random from the product’s available metadata: neckline, pattern, sleeve length, length, surface styling, fit, fabric, season, or usage. We then reworded the query by substituting words from a fixed synonym table. This avoids making queries that reuse the catalog's own words. For example, ″<em>t-shirt″</em> becomes ″<em>tee″</em>, ″<em>regular fit″ </em>becomes ″<em>classic cut″</em>, ″<em>printed″</em> becomes ″<em>with graphic design″</em>, ″<em>sleeveless″</em> becomes <em>″no sleeves″</em>. The longest phrase with a synonym was replaced first, so we swapped <em>″sports shoes″</em> rather than <em>″shoes″</em>. Because the pipeline is rule-based and seeded, query production is reproducible and all variation is accounted for. German queries were generated the same way, using a German term table, and then a native speaker corrected them for natural retail phrasing. Some examples:</p># usage / occasion
    "in casual style": "for everyday wear",
    "in formal style": "for the office",
    "in sports style": "for working out",
    "in ethnic style": "in traditional wear",
    "in party style": "for a night out"

# article types
    "t-shirt": "tee",
    "trousers": "pants",
    "sweatshirt": "pullover",
    "sweater": "knit pullover",
    "kurta": "tunic",
    "capris": "cropped pants",
    "track pants": "joggers",
    "innerwear vest": "undershirt",
    "briefs": "underwear"
 
# colours (safe near-equivalents)
    "navy blue": "dark blue",
    "off white": "cream",
    "maroon": "deep red",<p>The German queries used their own term table, mapping the same catalog attributes to natural German retail phrasing (later checked by a native speaker). Some examples:</p># usage / occasion
"casual": "im Freizeit-Stil",
"formal": "im Business-Stil",
"sports": "zum Sport",
"party": "für die Party"

# article types
"heels": "Pumps",
"casual shoes": "Freizeitschuhe",
"track pants": "Jogginghose",
"wallets": "Geldbörse"

# patterns
"checked": "mit Karomuster",
"polka dots": "mit Punkten",
"solid": "unifarben"<h2>The six embedding configurations we tested</h2><p>We tested retrieval in six configurations, using the same text queries in each test condition and the same 5,000 product indexed dataset. For both <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code>, we tested three different ways of generating embeddings for indexing:</p><ol><li><p><strong>Image-only. </strong>We generated embeddings from the images alone without any other data.</p></li><li><p><strong>Text-only.</strong> We generated embeddings for the free text descriptions alone.</p></li><li><p><strong>Averaged image and text.</strong> For each product, we generated embeddings for the image and text description separately and then averaged the two vectors into one.</p></li></ol><p>We used the Jina API to generate document and query embeddings for product images and free text descriptions, as shown in the code below. All images were resized to fit into a 512x512px square before processing.</p>import requests

def embed(inputs, model, task=None):
    body = {"model": model, "input": inputs}
    if task:
        body["task"] = task
    response = requests.post(
        "https://api.jina.ai/v1/embeddings",
        headers={"Authorization": f"Bearer {JINA_API_KEY}"},
        json=body, timeout=120,
    )
    response.raise_for_status()
    return [d["embedding"] for d in response.json()["data"]]

# Query example using text
query_vec = embed([{"text": "T-Shirt in Grau für den Herbst"}],
                  "jina-embeddings-v5-omni-small", task="retrieval.query")

# Document example using an image. "
# image_base64" is the resized and base64 encoded PNG.
image_vec = embed([{"image": image_base64}],
                  "jina-embeddings-v5-omni-small", task="retrieval.passage")<p>We combined images and texts by embedding them separately, averaging the two vectors, and then normalizing the result so we can speed up calculating cosines. This works for multimodal models because both embeddings share the same semantic space. The sum of the two vectors is a new vector with the semantic features of both.</p><p>This is very easy to do using the numpy package in Python. We used the code below:</p>import numpy as np

def l2(x):  # includes row-wise L2 normalization
    return x / np.linalg.norm(x, axis=1, keepdims=True)

# image_vecs and text_vecs are embeddings of the same products.
# They share one space, so averaging them is meaningful.
combined = l2((l2(image_vecs) + l2(text_vecs)) / 2)<p>
For this article, we did exact retrieval, calculating the cosine between queries and all 5,000 stored product embeddings. In Elasticsearch, we would use a shortcut to approximate the same result. From the ranked results, we calculate Recall@1, Recall@5, Recall@10, Mean Reciprocal Rank (MRR), and Normalized Discounted Cumulative Gain at position 10 (nDCG@10).</p><p>Each query has exactly one correct answer, so Recall@K is the share of queries whose product lands in the top K. MRR scores the results by how close the correct answer is to the top. nDCG@10 is a standard metric that penalizes putting the best answer lower on the results list.</p><h2>Product search benchmark results</h2><p>The table below is the German cross-lingual run. We evaluated German queries to find products with English descriptions:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6dbfbca9f7c7884f/6a6119fcf2e1c472a0fd2a2c/3599b15b5a61b5be1de02ae6b8fedd8f1615410b-1500x700.png" alt="Table comparing jina-clip-v2 and jina-embeddings-v5-omni-small on German product search queries, showing Recall@1, Recall@5, Recall@10, MRR and nDCG@10" /><p>Query benchmark results for <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code> using German-language text queries and the images and English descriptions of garments.</p><p>The averaged image/text embeddings score the best, both using <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code>. Surprisingly, averaged vectors from <code>jina-clip-v2</code> lead the table. It ranks the correct product first about 1.5 times as often as the image-only setup and significantly more often than the text-only setup. Furthermore, it beats every <code>jina-embeddings-v5-omni-small</code> condition. Results for the same tests using English-language queries:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbb878895472badde/6a6119fdae3a7c168883ef02/5ced60965d12dd3c64961acc92317a74c17fe8eb-1500x700.png" alt="Table comparing jina-clip-v2 and jina-embeddings-v5-omni-small on English product search queries, showing Recall@1, Recall@5, Recall@10, MRR and nDCG@10" /><p>Query benchmark results for <code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code> using English-language text queries and the images and English descriptions of garments.</p><p>The English run, on the same 5,000 products, tells the same story. The main difference is that the <code>jina-embeddings-v5-omni-small</code> scores are significantly closer to those of <code>jina-clip-v2</code>, although still lower.</p><h3>What the benchmark scores actually mean</h3><p>The relatively low scores in the German and English benchmark tables above are to be expected. This is a real-world dataset full of near duplicates. A search for a <em>"black tee with classic cut"</em> has to sort through dozens of basic black T-shirts, and even on the best ecommerce sites, you would expect a result like this. The important thing to understand is the difference in scores between the different conditions, not their absolute values. Our key finding is that combining text and image embeddings yields better performance than either one alone, highlighting how multimodal AI-driven search can use different information sources to produce better performance than non-multimodal strategies.</p><h3>When a picture is worth a thousand words, and when it isn’t</h3><p>The gain from adding images to text embeddings isn’t evenly distributed. We did a deep dive to see if there was a pattern to the results and discovered a few things:</p><ul><li><p><strong>Images add a lot for visually distinctive items and attributes.</strong> For example, on footwear, image-only retrieval is on par with text-only: 0.029 versus 0.028 for Recall@1. Shoe styles have distinctive shapes, so the picture does the work (image-only 0.029 versus text 0.028). The same holds for visible attributes more broadly (color, pattern, sleeve length), where fusing image and text gives the biggest lift over text-only (0.085 versus 0.073).</p></li><li><p><strong>Images help the least with things the model can’t see.</strong> For example, if we query for fabric types, adding images to embeddings adds next to nothing. Humans and AI models alike struggle to see that something is or isn’t made of linen or polyester or some other fabric type. That information is only in the text description and metadata.</p></li></ul><p>As a rule of thumb, we find that searches for clothing lean more heavily on accurate text descriptions, while footwear leans more on the semantics of images. But in both cases, merging the two embeddings either improves results or doesn’t make them worse. This highlights how use-case–specific considerations drive optimal search strategies.</p><h3>Cross-language queries gain a lot from multimodal embeddings</h3><p>The gap in performance between text-only and combined image and text embeddings using <code>jina-clip-v2</code> is much larger for German queries (0.074 versus 0.065 Recall@1) than for English ones (0.076 versus 0.075). This implies that English queries can take advantage of being in the same language as the product descriptions. Whether that’s due to overlaps in the words or that the model is simply more competent with single-language semantics than cross-language doesn’t matter. But adding images to the text embeddings compensates almost completely for the model’s shortcomings in cross-language retrieval.</p><p>This gap is even larger for <code>jina-embeddings-v5-omni-small</code>. In any kind of cross-language or multilingual context, multimodal embeddings seem to significantly improve retrieval performance.</p><h3>Can AI-generated product descriptions replace human ones?</h3><p>AI-generated descriptions scored worse than human-written ones in our tests. We tried replacing human-authored product descriptions with ones written by <a href="https://jina.ai/models/jina-vlm"><code>jina-vlm</code></a> based on the image. For this test, we used a 1,000-product random subset. The results were much worse than with the original human text. This was what we expected: The automatically generated description was less accurate and less oriented toward the salient features of the product than the human authored one.</p><p>So it turns out that not everyone’s job can be replaced by AI. People who write blurbs for catalogs ought to be safe for now.</p><h2>How should you index your ecommerce data?</h2><p>Our tests aren’t totally scientifically rigorous, but they do offer some insights into the issues you might face if you have similar data. We offer the following as provisional conclusions:</p><ul><li><p><strong>If you have aligned texts and images (and most catalogs do), combine them in your embeddings.</strong> In every case, using a multimodal embedding model like the ones Jina AI by Elastic provides and then averaging the image and text embeddings significantly outperformed all other options. The combination adds no computing costs at inference time but does create additional costs at embedding time. For each product, you’ll need to generate two embeddings and combine them, roughly doubling the cost.</p></li><li><p><strong>Use the right model.</strong> You need to identify a model that supports all the modalities and languages you plan to use. All the inputs have to be embedded in the same semantic vector space or none of this will work. It won’t do to get two single-modality models or multiple language-specific text models, average their outputs, and hope for the best. Jina AI by Elastic currently supports texts in up to 100 languages, including computer code and technical terminology images of all kinds, such as scans and infographics, as well as audio and video data. You can change your mind about your models later, but only if you’re willing to reindex all your data.</p></li><li><p><strong>Whatever you pick, test it on your own data.</strong> The only way to know what the best model is for you, your data, and your use case is to try them out. We were very surprised that our older CLIP-style model outperformed our latest on this dataset, but it was trained almost specifically for this use case. Your data and use case could easily show the opposite. This result is from one catalog, with one style of photography and one kind of query. The ranking between two models can flip with a different domain, image style, or query mix. Run the same sort of benchmark on a sample of your own products before you commit. It’s the only way to know which model really fits your case.</p></li><li><p><strong>Use generated descriptions to fill gaps, not to replace good text. </strong>AI isn’t a replacement for good work done by conscientious people. Replacing human-made descriptions with machine-made ones made results worse. AI should only replace humans when it has to, like when data is missing or needs to be augmented and it’s impractical to have humans fill in the gaps. Yes, we have tools that work in those situations, but they aren’t necessarily good substitutes. They’re OK substitutes, sometimes.</p></li></ul><p><strong>Average your embeddings.</strong> Semantic embeddings are very robust, and averaging them is a relatively cheap solution that doesn’t affect inference-time costs at all. This is a real boon over methods that index each modality separately and require multiple queries to satisfy a single request. But they do require compatible multimodal models.</p><h2>Limitations of this product search benchmark</h2><p>A few things to keep in mind before generalizing too much from this experiment:</p><p>This article doesn’t perfectly match real-world use cases. Human users make messier queries and have more ambiguous matching criteria. The queries we used were generated specifically for this data. A test with actual customer-made queries from system logs would be a better one.</p><p>Embedding averaging isn’t the same as a true joint embedding. Embedding models rely on the different parts of their input to interact in order to extract a semantic representation of the whole. The approach used here is a bit of a hack, one that relies on the robust nature of semantic embedding spaces to get the job done. We expect future models from Jina AI to produce better embeddings by supporting more than one input modality at a time.</p><p>This is one dataset in one domain with distinctive features. Fashion photography is very foreground-focused and the descriptions are attribute-rich. Other kinds of materials, even for ecommerce, may look very different. It’s important to test as much as possible with your own data or something very similar.</p><h2>How to get started with multimodal product search embeddings</h2><p><code>jina-clip-v2</code> and <code>jina-embeddings-v5-omni-small</code> are available through the <a href="https://jina.ai/api-dashboard">Jina API</a>, <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS), and <a href="https://huggingface.co/jinaai">Hugging Face</a>. The omni models are free to download under a CC-BY-NC-4.0 license and free for noncommercial use, with commercial licensing through Elastic. If you use Elasticsearch, EIS exposes both models through the <code>semantic</code> field type, with non-text media in Base64 encoding.</p><p>The takeaway from this article is intended to be practical and actionable: For product search, a picture and its description aren’t the same signal. Both add information, and you don’t have to pick one. Average your multimodal embeddings, and benchmark the results with your own data to get a good picture of the kinds of results you can expect.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/multimodal-embeddings-ecommerce-product-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multimodal-embeddings-ecommerce-product-search</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Jina AI]]></category>
    <dc:creator><![CDATA[Sofia Vasileva]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93506c893fb19bb9/6a6119fea2ee17d20274edab/b08939addf8cfa92dd0b31211ff0a86511eb582c-1280x720.png" length="0" type="image/png"/>
    <pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[98.9% faster queries, 4x more indexing throughput: a systematic Elasticsearch performance diagnosis]]></title>
    <description><![CDATA[Use AutoOps, the Profile API and ES Rally together to find cluster hotspots, slow queries and index bottlenecks, with real benchmarks showing a 98.9% latency cut and 4x indexing gain.]]></description>
    <content:encoded><![CDATA[<p>Three Elastic tools (AutoOps, the Profile API and ES Rally) can systematically diagnose Elasticsearch performance problems at every layer of the stack. In a delivery logistics scenario, they revealed a shard imbalance causing 30-second search spikes, a deep-pagination query wasting 98.9% of its execution time, and index settings limiting bulk ingestion to a quarter of achievable throughput. This post walks through each tool, what it surfaces, and how to use the findings to fix the problem.</p><p>When dealing with dozens of users who actively connect and use your platform backed by an Elasticsearch cluster, it’s important to quickly grasp what potential bottlenecks are and how to overcome them. The main question is: Where to start? Here’s your potential decision path:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfdbd8c5fe90b986a/6a6119e2de948111c2d173b8/a7fd476e740648e7b92b0ddd27c69e47a4b37181-1240x1720.png" alt="Flowchart showing steps for diagnosing reported slowness. It starts with “Slowness reported” and then branches through decisions labeled “Cluster issue?,” “Slow query?,” and “Index settings?,” leading to boxes for AutoOps, Profile API, ES Rally, and Client-side bottleneck." /><p>The path starts with AutoOps to detect cluster-level problems, like resource pressure or bad shard distribution. If the cluster is healthy, the Profile API helps identify slow queries; candidates can come from the slow query log or application-side logging. Next, ES Rally benchmarks index settings that may limit throughput. If all three come back clean, the bottleneck is on the client side.</p><h2>How to detect cluster-level performance problems with AutoOps</h2><p>The first question is always the same: Is the cluster itself the problem?</p><p>AutoOps is designed to provide real-time cluster diagnostics, deliver tailored advice, and help you improve the health of your clusters quickly. It comes by default in Elastic Cloud, and it has been added recently as a <a href="https://www.elastic.co/blog/autoops-free">free option for self-hosted configurations</a>.</p><p>It’s very easy to set it up, and it takes no more than five minutes to see data flowing into a comprehensive list of graphs that AutoOps offers you. The idea behind it is to install an <a href="https://www.elastic.co/docs/deploy-manage/monitor/stack-monitoring/collecting-monitoring-data-with-elastic-agent">Elastic Agent</a> close to your cluster that reports back to the AutoOps platform which is connected with your Elastic Cloud account. Here’s the <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/cc-connect-self-managed-to-autoops">installation guide</a>.</p><p>What’s nice about it is that it provides you with instant warnings and suggestions that are hard to spot through existing monitoring cluster technology. What’s really useful is that it also shows solutions to the issues. </p><p>In our delivery logistics scenario, AutoOps surfaced one finding that explained the user-reported slowness: severe load imbalance across the cluster. Node es01 was handling nearly all traffic while the other three sat idle, with search latency spiking to 30 seconds, as we'll see in the graphs in the next section.</p><h3>How AutoOps surfaces node hotspotting and shard imbalance</h3><p>AutoOps node performance graphs revealed that es01 was handling nearly all indexing traffic (5.6 docs/sec) while es02, es03 and es04 were idle.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt65864a5d75b374a5/6a6119e33c3320410f0b60d1/634bea76e349915ed037b03e0fe7b6c868ed0267-1999x704.png" alt="Four line graphs, side by side, showing indexing and search rates and latencies for entities labeled es01, es02, es03, and es04 between 07:35 am and 07:45 am. Each graph has colored lines on a dark background, with legends listing rate and latency values in seconds and milliseconds." /><p>Below, we see that CPU usage was concentrated on a single node (es01) which was hosting the heavier index, while the other three nodes were mostly idle.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc9a24d81c58d7d2b/6a6119e4f817927d5d07f5f5/d50af8e45bf876144f8af0239a3e1d9e133baa69-706x1038.png" alt="Line graph labeled “CPU,” showing usage percentages for es01, es02, es03, and es04 between 04:25 pm and 04:35 pm. The es01 line rises to about 25% after 04:25 pm, drops near 0%, and then spikes to roughly 75% at 04:35 pm. The other lines remain flat at 0% throughout." /><p>The next signal of unusual behavior appeared in the search latency graph. Using the AutoOps per-node latency view, we uncovered some unexpected results.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3f72717314212aba/6a6119e5ae3a7c30f483eef6/3015134fd9f3684e57bff9593eb916990bc25ce7-706x1022.png" alt="Line graph titled “Search latency” with a dropdown set to “Max.” The vertical axis ranges from 0 ms to 40 sec, and the horizontal axis shows times from 04:25 pm to 04:35 pm. Four lines represent es01, es02, es03, and es04. The es01 line spikes twice to about 30 seconds, while the other three remain flat near 0 ms." /><p>While other nodes show no latency, the heavy-loaded node shows symptoms of latency that affects search apps. Not only was it a write node, but also it was the one serving all search requests. During analysis, we found that a delivery index was stored on only one node, instead of being distributed across all four. That was the root cause of the issue. By reindexing the data (with increased number of primary shards), we balanced the query load and eliminated the primary issue.</p><p>This article focuses on a single AutoOps finding to keep the diagnostic flow clear. For deeper dives into the kinds of issues AutoOps surfaces, see the dedicated articles on <a href="https://www.elastic.co/search-labs/blog/hotspot-elasticsearch-autoops">hotspotting</a>, <a href="https://www.elastic.co/search-labs/blog/elasticsearch-cpu-usage-high">high CPU usage</a>, and <a href="https://www.elastic.co/search-labs/blog/slow-search-elasticsearch-query-autoops">long-running queries</a>.</p><h2>How to find slow Elasticsearch queries using the Profile API</h2><p>Once the cluster is healthy, the next question is: Are there specific, expensive queries? Finding candidates is the first step. You can explore them either by using <a href="https://www.elastic.co/docs/deploy-manage/monitor/logging-configuration/slow-logs">Elasticsearch’s slow query log</a> or through application side logging, by measuring time required for every search request. In our case, infinite scroll on a tracking delivery screen was a problem while users would scroll deeper into results.</p><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-profile">Profile API</a> is a powerful tool for analyzing long-running queries and finding search bottlenecks. Getting started is simple: Just set <code>profile=”true”</code> in any of your search queries, and the responses will contain a profile section with detailed timing breakdown. It highlights which phase dominates execution time: the query phase, aggregations, or data fetching.</p><p>Let’s take a closer look at the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/paginate-search-results">deep pagination case</a>. In a case of infinite scroll in a mobile application, the user continues scrolling and the app responds by fetching 100 documents per request.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltccf8201277682a9c/6a6119e65144f742bab98961/f7470705f495daa62eee4a3ae7c956bcd02405b2-720x1280.jpg" alt="Vertical interface showing a product list with a search bar labeled “Search products…” and a cart icon. Each entry includes a square placeholder, product name, optional category text, and price. Items listed include prices from $1400 to $1990. A “Show more” button appears at the bottom." /><p>As users continues scrolling, the requests can reach very deep pagination levels, while the <code>from</code> parameter grows:</p>GET delivery-records/_search
{
    "profile": true,
    "from": 9000,
    "size": 100,
    "query": {
        "bool": {
            "filter": [
                {
                    "term": {
                        "payment_type": "1"
                    }
                },
                {
                    "range": {
                        "tax_amount": {
                            "gte": 5
                        }
                    }
                }
            ]
        }
    },
    "sort": [
        {
            "delivery_pickup_datetime": {
                "order": "desc"
            }
        }
    ]
}<p>The profile data, summarized below and converted from nanoseconds to milliseconds, makes the bottleneck clear: Most of the time is spent outside the query and fetch phases on coordination work caused by the large <code>from</code> offset. Elasticsearch must collect 9,100 matching documents, sort them, discard the first 9,000, and return only the requested 100.</p><p>Component</p><p>Phase</p><p>Time</p><p>ConstantScoreQuery contains</p><p>Query</p><p>34.4ms</p><p>BooleanQuery</p><p>Query</p><p>31.1ms</p><p>QueryPhaseCollector contains</p><p>Collect</p><p>22.1ms</p><p>SimpleFieldCollector (9,100-doc priority queue)</p><p>Collect</p><p>19.1ms</p><p>FetchPhase</p><p>Fetch</p><p>12.1ms</p><p>Other steps, like request parsing and deserialization, queue wait time, response building</p><p></p><p>1,885.4ms</p><p>Total</p><p></p><p>2,004.2ms</p><h3>Fixing deep pagination with search_after</h3><p>In this case, the <code>search_after</code> approach is up to twice as fast because it avoids scanning and discarding earlier results. Instead, it resumes from the last document returned on page 90, using its <code>delivery_pickup_datetime</code> value (in epoch milliseconds) as a cursor and fetching only the next 100 records.</p>GET delivery-records/_search
{
    "profile": true,
    "size": 100,
    "track_total_hits": false,
    "query": {
        "bool": {
            "filter": [
                {
                    "term": {
                        "payment_type": "1"
                    }
                },
                {
                    "range": {
                        "tax_amount": {
                            "gte": 5
                        }
                    }
                }
            ]
        }
    },
    "sort": [
        {
            "delivery_pickup_datetime": {
                "order": "desc"
            }
        }
    ],
    "search_after": [
        1451604674000
    ]
}<p>As shown in the comparison below, performances are much better with the <code>search_after</code> approach.</p><p>Component</p><p>Phase</p><p>Time</p><p>ConstantScoreQuery contains</p><p>Query</p><p>6.8ms</p><p>BooleanQuery</p><p>Query</p><p>6.2ms</p><p>QueryPhaseCollector contains</p><p>Collect</p><p>2.6ms</p><p>PagingFieldCollector</p><p>Collect</p><p>2.0ms</p><p>FetchPhase</p><p>Fetch</p><p>2.2ms</p><p>Other steps like request parsing and deserialization, queue wait time, response building</p><p></p><p>9.4ms</p><p>Total</p><p></p><p>29.2ms</p><h3>Performance comparison</h3><p>Component</p><p>Deep pagination (from:9000)</p><p>search_after</p><p>Time saved</p><p>% Saved</p><p>ConstantScoreQuery</p><p>34.4ms</p><p>6.8ms</p><p>27.6ms</p><p>80.2%</p><p>BooleanQuery</p><p>31.1ms</p><p>6.2ms</p><p>24.9ms</p><p>80.1%</p><p>QueryPhaseCollector (total)</p><p>22.1ms</p><p>2.6ms</p><p>19.5ms</p><p>88.2%</p><p>FieldCollect</p><p>19.1ms</p><p>2.0ms</p><p>17.1ms</p><p>89.5%</p><p>FetchPhase</p><p>12.1ms</p><p>2.2ms</p><p>9.9ms</p><p>81.8%</p><p>Total</p><p>2,004.2ms</p><p>29.2ms</p><p>1,975ms</p><p>98.5%</p><p>The total query time drops from 1,004 ms to 29 ms (a 98.5% improvement) almost entirely because Elasticsearch no longer has to build and discard a 9,000-document priority queue on every request.</p><h2>How to benchmark Elasticsearch index settings with ES Rally</h2><p>With the cluster healthy and queries optimized, the final question is: Are the index settings themselves a bottleneck?</p><p><a href="https://github.com/elastic/rally">ES Rally</a> is the official benchmarking tool made by Elastic. Its key strength is reproducibility: You run the same workload against two configurations on the same cluster and hardware, so any difference in results is purely down to the settings you changed. ES Rally is able to measure improvements under identical conditions: same cluster, hardware, and dataset.</p><h3>Why default Elasticsearch index settings limit bulk indexing throughput</h3><p>In many cases, slow indexing is caused by default index settings that are suitable for development but not production. For instance, the default <code>refresh_interval</code> of 1 second increases resource usage because every <a href="https://www.elastic.co/docs/manage-data/data-store/near-real-time-search">refresh creates a new searchable segment</a>, and a replica count of 1 doubles the number of write operations per indexing request.</p><p>Our delivery logistics platform, for example, ingests thousands of new records daily in bulk, exactly the kind of workload where these defaults hurt. Setting <code>refresh_interval</code> to -1 disables refreshing during the bulk loading phase, and temporarily dropping replicas to 0 halves the write operations. Both settings are restored after the import is complete.</p><p>In this demo, we won’t focus on how to <a href="https://www.elastic.co/blog/creating-custom-es-rally-tracks-guide">prepare custom data</a> for benchmarking, but it’s worth mentioning a nice article about it.</p><p>For benchmarking purposes, you set up two folders: one with the current settings and another with the contender settings. The sample dataset in this case contains 1 million records. All the files described below can be found in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/how-to-benchmark-and-diagnose-your-applications/">this repository</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte0a5ea88c39596e9/6a6119e71b1d4915a86f180f/7cf2cc18c22707bdc9e19e9da6d03fba05599c78-402x345.png" alt="File directory view labeled “rally-tracks,” showing two folders. Each folder contains four files, with icons indicating compressed and JSON file types" /><p>Both track folders contain an <code>index-settings.json</code> file. These files let you adjust mappings, shard counts, replica settings, and field types; for example, converting a field from <code>double</code> to <code>scaled_float</code> or from <code>text</code> to <code>keyword</code>. Because ES Rally tracks can be rerun quickly, it’s easy to experiment with different configurations and evaluate new optimization ideas as you iterate.</p><p>The next step is to run race commands (for current and contender race) to gather stats.</p>esrally race \
--track-path="delivery-records-current" \ #(name of current setup folder)
--target-hosts="es01:9200" \ #(location of es cluster)
--pipeline=benchmark-only \
--report-format=csv \
--report-file="current.csv" \
--race-id="run-current" \
--on-error=abortesrally race \
--track-path="delivery-records-contander" \
--target-hosts="es01:9200" \
--pipeline=benchmark-only \
--report-format=csv \
--report-file="contander.csv" \
--race-id="run-contander" \
--on-error=abort<p>Quick tip: Using meaningful <code>--race-id</code> values (rather than the auto-generated ones) makes the comparison command much easier to run.After both races are complete, compare the results:</p>esrally compare --baseline=run-current --contender=run-contander<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt41dd2371798de2f6/6a6119e81c2893300b6e5ceb/1c4bb14953cffcfa07542c1ae73528645388a50a-1620x1736.png" alt="Terminal-style table titled “Final Score,” comparing performance metrics between baseline and contender configurations. Columns include Metric, Task, Baseline, Contender, Diff, Unit, and Diff %. Rows list indexing, merging, refreshing, flushing, garbage collection, dataset size, and throughput data. Green text indicates improvements, and red text indicates regressions." /><p>Additionally, reports are generated into csv files that can be easily manipulated to extract important data.</p><p>Metric</p><p>Current settings</p><p>Contender</p><p>Change</p><p>Cumulative indexing time</p><p>0,826517</p><p>0,755967</p><p>+8.54%</p><p>Mean throughput</p><p>11,090 docs/s</p><p>50,921 docs/s</p><p>+359 %</p><p>Median throughput</p><p>10,821 docs/s</p><p>51,304 docs/s</p><p>+374 %</p><p>Min throughput</p><p>10,335 docs/s</p><p>41,387 docs/s</p><p>+300 %</p><p>Max throughput</p><p>13,006 docs/s</p><p>55,342 docs/s</p><p>+326%</p><p>The contender configuration delivers roughly 3–4× faster indexing throughput, purely from adjusting refresh_interval and replica count during the bulk load phase.</p><p>In practice, a single comparison is rarely enough. Common follow-up experiments include converting double fields to float or scaled_float, changing text fields to keyword, adjusting shard count, and tuning the refresh interval. Because ES Rally tracks rerun quickly, iterating through these options is straightforward.</p><h2>What to do when Elasticsearch isn't the bottleneck</h2><p>If AutoOps shows a healthy cluster, the Profile API shows fast queries, and ES Rally confirms that index settings are not the limiting factor, the bottleneck is on the client side. The Elasticsearch side is no longer the place to look. Common application-layer causes:</p><ul><li><p><strong>Network latency</strong> between the client and the cluster, especially across regions, VPNs, or proxies.</p></li><li><p><strong>Client-side deserialization</strong> overhead on large response payloads. Use <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-source-field#include-exclude"><code>_source exclude</code></a> or the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrieve-selected-fields#search-fields-param"><code>fields</code></a> parameter to return only what the client actually needs.</p></li><li><p><strong>Client-side queueing</strong> before requests reach the cluster. The Elasticsearch client uses an HTTP connection pool. Under concurrent load, requests wait in line for a free connection. The Profile API never sees this wait because the request hasn't left the client yet.</p></li><li><p><strong>Single search calls</strong> where <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-msearch"><code>_msearch</code></a> would batch independent queries into one network round trip.</p></li><li><p><strong>Single-document indexing</strong> where the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"><code>_bulk</code></a> API would amortize per-request overhead across many documents.</p></li></ul><p>The diagnostic value of AutoOps, the Profile API, and ES Rally is precisely that they let you definitively rule out the cluster, the query, and the index settings before turning to the application code. When the three tools come back clean, the investigation moves out of Elasticsearch and into the client.</p><p>For investigating these on the application side, <a href="https://www.elastic.co/observability/application-performance-monitoring">Elastic APM</a> is the natural next tool: It traces request paths through the client and surfaces exactly the kind of pre-cluster wait time the Profile API can't see.</p><h2>Conclusion</h2><p>Going back to the original problem, slow search on a delivery logistics platform, we traced the issue from the infrastructure level down to the query and settings level:</p><ul><li><p>AutoOps revealed an uneven shard distribution that concentrated all query load on a single node, causing latency spikes of up to 30 seconds for end users.</p></li><li><p>The Profile API showed that deep pagination was the source of the slow queries. Switching from from/size to <code>search_after</code> eliminated 98.9% of the latency.</p></li><li><p>ES Rally confirmed that optimizing index settings during bulk ingestion, specifically <code>refresh_interval</code> and replica count, can increase throughput by 3-4×.</p></li></ul><p>Each tool answers a different question. AutoOps gives you the "Is something structurally wrong?" view. The Profile API answers "Why is this specific query slow?" And ES Rally validates "Do these changes actually improve things, and by how much?" Used together and in this order, they cover the full diagnostic surface for the Elasticsearch side of the application. When all three come back clean, the next step is to look at the client, as outlined in the previous section.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-performance-diagnosis</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-performance-diagnosis</guid>
    <category><![CDATA[Operations]]></category>
    <dc:creator><![CDATA[Aleksandar Panov]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4be28a2b56f0f75b/6a6119e91b1d4902a46f1813/2997670e3ebae815e16cb1a336542a9ddd0de77e-1999x1072.png" length="0" type="image/png"/>
    <pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[The disk that never woke up: what actually decided our Qdrant vector search benchmark rematch]]></title>
    <description><![CDATA[On the same hardware, Elasticsearch and Qdrant land in the same range at 56 QPS. The io_uring disk scorer and memory claims turned out to be the two things that mattered least.]]></description>
    <content:encoded><![CDATA[<p>Vector search vendors like a good benchmark, and lately Elasticsearch and Qdrant have been trading them. Earlier this year we <a href="https://www.elastic.co/search-labs/blog/vector-search-benchmark-elasticsearch-vs-qdrant">published one</a> comparing Elasticsearch's <code>bbq_disk</code> against Qdrant on a disk-rescore workload, where full-precision vectors live on disk and get read back to rescore the top candidates. Qdrant <a href="https://qdrant.tech/blog/benchmark-elastic-diskbbq/">replied</a> with better numbers on their side and a set of explanations for why: an io_uring-based async disk scorer, and remarks about Elasticsearch needing more memory.</p><p>We didn't want to fire back with another round of numbers. Reproducing a benchmark is the easy part; understanding why it produces the numbers it does is the useful part, and it is the part both original posts skipped. So we stood up Qdrant's exact setup on our own cluster, loaded the same <a href="https://huggingface.co/datasets/kenhktsui/wiki_dpr_e5">21 million vectors</a>, reproduced their result, and then traced every number back to its cause.</p><p>Here is what we found. The result comes down to a few setup choices: how many segments you build, whether the data is warm in memory, how you return the result ids, and which hardware you run on. Control those, and it cuts both ways: matched to Qdrant's setup the two engines are on par on query speed, and with fast default ingestion Elasticsearch is faster at search and faster to index. The big multipliers in both posts, our 7x and their reply, are artifacts of those choices, not a verdict on either engine. The two reasons Qdrant leaned on hardest, an io_uring disk scorer and Elasticsearch's memory use, turn out to be the two that mattered least: the disk is never read during the run, and the memory gap is a difference in labels, not in bytes.</p><p>We hold our own original post to the same standard. Both sides changed several things at once and reported the result rather than the reason. One rule for the rest of this post: every claim gets a number and a mechanism, or it doesn't ship.</p><h2>Vector search benchmark setup: hardware, config and query set</h2><p>Here is exactly what each side ran.</p><p></p><p></p>﻿<p></p><p></p><p>Elastic (original post)</p><p>Qdrant (response)</p><p>This post (apples-to-apples)</p><p>Nodes</p><p>3 × n4-standard-8 (7 vCPU / 26 GB), GCP</p><p>3 × m6g.large (2 vCPU / 8 GB), AWS</p><p>3 × m6g.large (2 vCPU / 8 GB)</p><p>﻿</p><p></p><p></p><p>Elasticsearch</p><p><code>bbq_disk</code> 2-bit, <code>replicas: 1</code></p><p>(cited original)</p><p><code>bbq_disk</code> 2-bit, <code>vectordb_document</code>, bfloat16, <code>replicas: 0</code></p><p>﻿</p><p></p><p></p><p>Qdrant</p><p>2-bit, async scorer off, RF=2</p><p>TurboQuant 4-bit, async scorer on, RF=1</p><p>TurboQuant 4-bit, async scorer on, RF=1</p><p>﻿</p><p></p><p></p><p>Copies per shard</p><p>2 = 2 (matched)</p><p>1</p><p>1 = 1 (matched)</p><p>﻿</p><p></p><p></p><p>Query set</p><p>10k fixed, recall@100</p><p>10k fixed, recall@100</p><p>10k fixed, recall@100</p><p>Three things are worth pinning down, because all three have been used as talking points.</p><ol><li><p>Replica count was matched in both rounds: An Elasticsearch index with <code>replicas: 1</code> keeps two copies of each shard, which is exactly what Qdrant's RF=2 does. In this round, both sides ran a single copy. It was never a thumb on the scale in either direction.</p></li><li><p>We changed a few things on our side on purpose, and we will own them: <code>vectordb_document</code> index mode, float32 to bfloat16 for stored full-precision vectors (3,072 down to 1,536 bytes each), and a merge policy tuned for vector data. Those choices matter later, so we are flagging them up front rather than burying them.</p></li><li>Memory is compared like for like. Qdrant's post sets Elasticsearch's JVM heap allocation against Qdrant's total memory use, but those are different accounting categories, since heap size is not total memory consumption. On these 8 GB nodes we give the JVM heap 2 GB, 25% of node RAM, deliberately: that follows Elastic's Vector Search Optimized profile, which leaves the other 75% for the OS page cache where the vectors actually live, rather than the general 50%-heap upper bound. The honest comparison is resident index plus runtime plus active page cache, and on an 8 GB node both engines live inside the same envelope.</li></ol><p>Look at the middle column, though. Between our original run and their response, Qdrant changed almost everything at once: new hardware, 4-bit quantization instead of 2-bit, the async scorer on, a different replication factor. Then it credited the win to one of those changes. Drawing meaningful conclusions from many simultaneous changes is hard, and pinning down which change actually moved the number is the whole job of the sections that follow.</p><h2>What does disk rescore cost in a vector search benchmark?</h2><p>In this benchmark, the answer is almost nothing, because the disk is never read. Two back-of-the-envelope budgets show why, and the live measurements later in the post confirm it.</p><p>The design under test is built around one idea: keep a small quantized copy of each vector in RAM for approximate search, keep the full-precision originals on disk, and read a handful of those originals back to rescore the top candidates for accuracy.</p><p>Two budgets determine performance: disk I/O for rescore reads and RAM for what has to stay resident. Both are computable on the back of an envelope, so let us compute them before measuring anything.</p><h3>The disk I/O budget for vector search rescoring</h3><p>Rescoring reads the top 100 candidates per query. A 768-dimensional float32 vector is 768 × 4 = 3,072 bytes. Because those reads are not page-aligned, the real cost per vector is closer to an 8 KB page read. Each query needs roughly 800 KB and 100 random reads. Scale that up:</p><p>target QPS (cold)</p><p>rescored vectors/s</p><p>reads/s (IOPS) per node</p><p>MB/s per node</p><p>10</p><p>1,000</p><p>333</p><p>3</p><p>100</p><p>10,000</p><p>3,333</p><p>27</p><p>1,000</p><p>100,000</p><p>33,333</p><p>273</p><p>10,000</p><p>1,000,000</p><p>333,333</p><p>2,731</p><p>Two things fall out immediately: First, cold rescore is an IOPS problem, never a bandwidth one. Even at 10,000 QPS, you need only about 2.7 GB/s per node, but a third of a million IOPS per node. Second, the total working set for the benchmark is tiny: <code>100 candidates × 10,000 queries × 8 KB = 8 GB across the cluster, or 2.7 GB per node</code>. That fits in the free page cache on an 8 GB node with room to spare. The first time the benchmark cycles through its 10,000 queries, the originals it touches are pulled into page cache, and every read after that is a memory copy.</p><p>Put a ceiling on it too. On gp3 at baseline (3,000 IOPS/node), if the reads did go to disk, the workload would top out around 90 QPS from IOPS alone; provisioned gp3 or local NVMe would push that to roughly 480 or 3,000. Hold that number. It is the ceiling that would matter in a benchmark that actually touched the disk.</p><h3>The RAM budget for a disk-based vector index</h3><p>What has to stay resident is the quantized copy plus the search structure. Everything else is page cache. At 4-bit quantization and an HNSW graph with m=16:</p><p>component</p><p>bytes/vector</p><p>per node @ 21M</p><p>4-bit quantized vectors (<code>always_ram</code>)</p><p>384</p><p>2.69 GB</p><p>HNSW graph, m=16 (2m links × 4 B)</p><p>136</p><p>0.95 GB</p><p>resident total</p><p>520</p><p>3.64 GB</p><p>raw float32 originals (on disk)</p><p>3,072</p><p>21.5 GB (on disk)</p><p>We measured the resident footprint on the running nodes at 3.4 to 3.9 GB, sitting right on the 3.64 GB estimate. The point to hold is that at 21 million vectors, the entire searchable index (quantized vectors plus graph) is about 3.6 GB per node and it fits in RAM, leaving a couple of gigabytes to spare. Nothing in this benchmark forces the disk-backed design to actually use the disk. We come back to that at the end, because it is the real story.</p><h2>Does io_uring make vector search faster?</h2><p>Qdrant's reply credits two levers it says we omitted: a two-stage prefetch-and-rescore retrieval pattern and an async, io_uring-based disk scorer. They are two faces of the same rescore step, and the same evidence answers both. Take the async scorer first, since it is the one Qdrant makes the centerpiece: io_uring to parallelize disk reads during rescoring. It is a good feature, and in this benchmark it had exactly one job: parallelize the disk reads during rescoring. It got to do none of them. We verified that in three independent ways.</p><p>First, the arithmetic. From the RAM budget, the quantized index and graph are pinned in RAM. From the I/O budget, the rescore working set is 2.7 GB per node and lives in page cache after the first pass. That leaves no disk reads on the hot path to accelerate.</p><p>Second, their own methodology guarantees it. Each operating point in the harness runs a full 10,000-query recall pass, followed by a timed throughput window that cycles through the same 10,000 queries again. By the time the stopwatch starts, every vector those queries will touch is already resident. The measurement is warm by construction.</p><p>Third, and this is the part we insisted on doing rather than arguing, we measured it. We were careful here because it is easy to accidentally test io_uring in the off state and not notice. Running Qdrant in Docker, we found that io_uring was not even initializing: the default seccomp profile blocks the io_uring syscalls, so it silently fell back to synchronous reads (<code>failed to initialize io_uring instance: Operation not permitted</code>). We fixed that, ran the container with seccomp unconfined, confirmed zero io_uring errors in the logs, and re-ran. This honestly means we have both states:</p><ul><li>Async scorer effectively off (synchronous fallback): 31.6 QPS at ef=50.</li><li>Async scorer on (io_uring confirmed working): 35.8 QPS at ef=50.</li></ul><p>A 13% move, and even that is within the run-to-run noise for a measurement whose ceiling is set by CPU, not disk. Put plainly, switching on the feature that the entire result was credited to changed about as much as running the benchmark a second time. To close the loop, during the actual throughput window we watched the block device on all three nodes with <code>iostat</code>: read throughput held at 0 MB/s and 0 IOPS, while CPU sat at 60-70% and climbed to 100% as we added concurrency. The bottleneck is the CPU doing <a href="https://www.elastic.co/search-labs/blog/bbq-vector-comparison-simd-instructions">quantized distance computations</a> and graph traversal. It is not the disk, because the disk is asleep.</p><p>The two-stage prefetch-and-rescore pattern, the lever Qdrant lists first, is answered by the same run. Every Qdrant number here comes from Qdrant's own reproduction script, which uses their two-stage query throughout, so two-stage was on for the whole benchmark, including the io_uring comparison, and the disk still held at 0 IOPS. It is also not something we omitted: prefetch is the approximate search over the quantized vectors, which lives in RAM, and rescore reads the originals, which live in page cache here. That is the disk-rescore workload, and <code>bbq_disk</code> runs the identical shape. The one part of two-stage that is not about disk, how many candidates you rescore, is CPU rather than I/O; it is already included in these numbers and available to both engines, so it is not a hidden lever either.</p><p>There is a smaller detail worth noting. Qdrant's post does not say which disk they ran on. That would normally matter in a benchmark whose premise is disk access, but here it does not, for the same reason the async scorer does not: you cannot be bottlenecked on a device you never read from. The disk is left unspecified, and as it happens it is also beside the point.</p><p>Here's what I've seen, as an engineer who has sat through a lot of these conversations: io_uring gets treated as a universal fix far more often than it is the bottleneck. It is excellent when the working set overflows RAM, and the cold I/O budget above shows exactly that case, where parallelizing reads buys you throughput. This benchmark never enters that regime. Async disk reads are a real answer to a real question, and this benchmark just never asked it. Of everything that changed between the two runs, io_uring is the one that got the headline and moved the number the least.</p><h2>Why does faster ingestion mean slower vector search queries?</h2><p>This is where reproducing the number got interesting, and where the honest driver of the gap turned out to live.</p><p>Our first load produced 128 segments and roughly half the throughput we expected. Qdrant's published provenance, right there in <a href="https://github.com/qdrant-labs/wiki-dpr-disk-rescore-benchmark/blob/main/results/published/qdrant_4vcpu16gb.json">their results JSON</a>, records 67 segments. That one difference explained almost everything. When we merged our collection down to match their segment count, all three metrics moved together toward their numbers:</p><p>segments</p><p>recall@100</p><p>QPS</p><p>avg latency</p><p>128 (our first, parallel load)</p><p>0.9745</p><p>35.8</p><p>112 ms</p><p>66 (merged to match theirs)</p><p>0.9531</p><p>53.3</p><p>75 ms</p><p>67 (Qdrant published)</p><p>0.9596</p><p>67.2</p><p>59.5 ms</p><p>Look at what moves. Recall falls as segments drop, because with fewer segments each query examines fewer total candidates. That is the tell that segment count, not tuning, was inflating our recall. And throughput rises, because HNSW query cost is very sensitive to the number of segments: every query fans out across every segment's graph, so twice the segments is close to twice the per-query work, plus the contention of doing all that on two cores. The small remaining gap between our 53 and their 67 is warm-cache completeness in our shorter run, not the engine.</p><p>Here is the part worth stating carefully, because it is architectural and it cuts against us as much as for us. We use the same segmented design; this is not a Qdrant quirk. The question is how you get to few segments. You get there by ingesting single-threaded, so data concentrates into fewer, larger segments. Fewer segments means faster queries, but you pay for it with slow ingestion. Our parallel load was fast and produced many segments; their single-threaded load was slower and produced few. It is a real tradeoff, and it is the same one on both sides.</p><p>Where the two designs genuinely differ is how much that tradeoff hurts. Building an HNSW graph is expensive, and its query cost scales poorly with segment count. On these 2-vCPU nodes, constructing the graph at <code>ef_construct=256</code> took about 1.6 hours pinned at 100% CPU. The IVF layout behind <a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-101"><code>bbq_disk</code></a> is cheap to build and far less sensitive to segment count, so we can ingest with many threads while keeping query latency low. We do not have to choose. That is not a benchmark trick. It is the IVF vs. HNSW tradeoff, stated honestly.</p><h2>Why document retrieval, not vector search, explains the rest of the latency</h2><p>Now turn the same lens on our own result, because the same accounting applies to our own numbers too.</p><p>At the search-light end of the sweep, a large share of the per-query time is not vector search at all. It is the fetch phase assembling the response. <code>vectordb_document</code> returns full source documents, which is exactly what you want for retrieval and hybrid search, where you actually need the document back. A pure vector benchmark only needs the top-N ids, and today <code>_id</code> lives in the same stored-fields column as <code>_source</code>, the text and the vector. So fetching an id pulls that entire compressed block through the decompressor for every hit. When we isolate the id from that column, Elasticsearch steps straight up into Qdrant's low-segment throughput range. That gap is document retrieval, not vector search.</p><p>The fix is structural, not a tuning flag: give <code>_id</code> its own doc-values field so returning an id never touches <code>_source</code>. That is what the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage"><code>vectordb_columnar</code></a> mode we are building automatically does. We are calling it out here because good benchmarking means naming your own costs as clearly as anyone else's, and this one is a document-retrieval cost sitting inside a vector-search score.</p><h2>Elasticsearch vs Qdrant vector search benchmark: our numbers, on the same box</h2><p>Elasticsearch on the same three m6g.large nodes, <code>vectordb_document</code> with bfloat16, warm, shown both as it runs today and with the id isolated:</p><p>visit %</p><p>recall@100</p><p>QPS (default)</p><p>QPS (id-isolated)</p><p>1</p><p>0.894</p><p>44</p><p>89</p><p>2</p><p>0.939</p><p>39</p><p>73</p><p>3</p><p>0.956</p><p>35</p><p>56</p><p>5</p><p>0.970</p><p>31</p><p>40</p><p>The id-isolated column is measured by dropping the stored-fields fetch entirely, which is a near-upper-bound proxy for a doc-values <code>_id</code> (the doc-values read is cheap but not literally free). At around 0.96 recall, id-isolated Elasticsearch does about 56 QPS, sitting right alongside Qdrant's 53 to 67 on identical hardware. Same ballpark. The gaps in both posts came from setup and document retrieval, not from the vector engine: segment counts, the id fetch, the hardware. Their reply we can account for in full here; the larger 7x from our own first post we cannot yet, and we take that up below. When you control for those, two well-built systems doing the same work on the same box land close together, which is what you would expect.</p><p>So here is the plain claim: performance depends on the setup. Match Qdrant's slow, single-threaded ingestion, the one that gives them their 67-segment configuration, and the two engines are on par on the search itself: id-isolated Elasticsearch does about 56 QPS against their 53 to 67 at the same recall. But that low segment count is bought with slow ingestion. Let both engines ingest fast, which is the natural default, and Qdrant lands back at 128 segments and about 35 QPS, while Elasticsearch degrades less as the segment count climbs, because the IVF layout behind <code>bbq_disk</code> is less sensitive to segment count than HNSW. So with default, fast ingestion, Elasticsearch is faster at search too, and it reached a queryable index faster to begin with. The one place we still trail is returning the ids, which is document retrieval rather than search, and it is exactly what <code>vectordb_columnar</code> removes.</p><p>We deliberately did not run the 4 vCPU / 16 GB tier. It lifts both engines together and shows the identical trend, so it would add cost without adding insight.</p><h2>Our original 7x Elasticsearch vs Qdrant benchmark number, explained</h2><p>The rule in this post is that every claim gets a number and a mechanism. That rule was also applied to our <a href="https://www.elastic.co/search-labs/blog/vector-search-benchmark-elasticsearch-vs-qdrant">original benchmark</a>.</p><p>That post measured a 7x throughput advantage for Elasticsearch, and the number is real for that configuration. The setup was disclosed in full, including that Qdrant does better on NVMe. But the post attached a mechanism to the number: Qdrant was bottlenecked by random disk reads of the original vectors during rescoring, a problem made worse by network-attached storage. The arithmetic in this post undercuts that explanation. Those round-1 nodes had 26 GB of RAM, more headroom than the 8 GB nodes here where we watched the disk sit at 0 IOPS, so if the disk was idle here, it was almost certainly idle there too.</p><p>So what actually held round-1 Qdrant to 4.5 QPS at 0.97 recall? We cannot claim to know yet, but the original post's own configuration points the way: it ran Qdrant on 2-bit quantization with oversampling pinned at 1. Two-bit codes are coarse, and with oversampling fixed at 1 the only lever left to recover recall is ef. Reaching 0.97 recall on ef alone means a very large ef, and a very large ef makes each query expensive on its own, before disk enters the picture at all. That is still a hypothesis, but the mechanism the original post named, random disk reads during rescore, does not survive the same arithmetic we just applied to Qdrant.</p><h2>Benchmarking, not benchmarketing</h2><p>The numbers reproduce; the explanations don't. A benchmark that made "disk reads" its headline ran with the disk asleep, and we watched it sit at 0 IOPS for the whole window. The honest differences we could actually find were a segment count (an ingest-speed-versus-query-speed tradeoff where the IVF layout lets <code>bbq_disk</code> ingest fast and query fast) and a stored-field retrieval cost on our side (document retrieval, not vector search, and something <code>vectordb_columnar</code> removes). None of it is io_uring, and none of it is "Java is heavy."</p><p>The original and arguably bigger point got lost in the io_uring discussion: putting vectors on disk was never really about the rescore, it is about memory. The key was keeping the searchable index, the quantized vectors and the IVF or HNSW structure, compact and disk-resident enough to serve more vectors per gigabyte of RAM than a design that pins everything in memory. Qdrant's setup pins the 4-bit vectors with <code>always_ram</code> and keeps the HNSW graph in RAM, about 3.6 GB per node for 21 million vectors. <code>bbq_disk</code> keeps the quantized IVF on disk. At 21 million vectors on 8 GB nodes, everything fits in RAM either way, which is precisely why this benchmark cannot tell the two designs apart. It is measuring the case where the interesting variable has been held constant.</p><p>The interesting question is what happens when the searchable index stops fitting. Scale the corpus until the quantized vectors and the structure exceed page cache, and the two designs diverge: one keeps serving from disk, the other needs more RAM per node. That is the regime <code>bbq_disk</code> was built for, and it is the one neither post has measured. The rescore-under-disk-pressure case, many more distinct queries than fit in cache, is worth measuring too, but it is the second question, not the first.</p><p>We tried to get there, and our first attempt still fit in cache, because 21 million vectors on these nodes do not spill. So we are not done. We are building a benchmark with a corpus large enough that the searchable index no longer fits in RAM, and we will publish those numbers, with the reasons attached and checked the same way.</p><p>The ask here is to the reader, not to Qdrant: when a benchmark hands you a clean multiplier, chase every number back to a cause before you believe the story around it. Half the time it is a warm cache or a segment count.</p><p>The Qdrant team built a good engine, and we're not questioning that. The honest verdict is not about who is faster: matched to their setup, the two are on par, and on the fast default path, Elasticsearch is quicker for both indexing and searching. The numbers were fine. The reasons attached to them were the part worth checking, starting with our own. And the benchmark that would actually stress a disk-backed index, a working set that does not fit in RAM, is still to be written. We will bring the numbers.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-search-benchmark-elasticsearch-qdrant</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-search-benchmark-elasticsearch-qdrant</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Jim Ferenczi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt39d5755c76e56db3/6a7084c1c2c8ed02a308b1bb/image1.png" length="0" type="image/png"/>
    <pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How BBQ shrinks Jina v5 embeddings by 29x without losing recall in Elasticsearch]]></title>
    <description><![CDATA[A hands-on test comparing BBQ and float32 vector indices in Elasticsearch, measuring memory, disk and recall@10 across five languages.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch">BBQ quantization</a> cuts the memory footprint of <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">Jina embeddings v5</a> vectors by 29x in Elasticsearch. Recall@10 holds at 0.994 against a full-precision <code>float32</code> baseline. We tested this on a multilingual news corpus across five languages, using <code>jina-embeddings-v5-text-small</code> to build a raw <code>float32</code> index and a <code>bbq_hnsw</code> index from the exact same <a href="https://www.elastic.co/what-is/vector-embedding">vectors</a>. Then we measured memory, disk usage and retrieval quality on both. Disk usage came out nearly identical between the two indices. In-memory footprint is the number that actually decides whether your cluster fits the corpus, and it dropped from 12.71 MB to 0.44 MB for this test set. Jina v5's quantization-aware training is why the recall held.</p><h2>Prerequisites</h2><ul><li><p>Elasticsearch 9.x with <code>jina-embeddings-v5-text-small</code> inference endpoint available.</p></li><li><p>Python 3.10+,</p></li><li><p>Elasticsearch API key,</p></li></ul><h2>What is quantization?</h2><p>An <em>embedding </em>is a list of numbers. By default, each number is a <code>float32</code>, which uses 4 bytes. <em>Quantization </em>stores each number with fewer bits, trading precision for space.</p><p>Like a JPEG, a <em>quantized vector</em> is a smaller, lower-fidelity copy of the original that still gets the job done.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1ddbaed3b3d80b67/6a54faf78f017d26529ee65c/175105f9a5059885aaf92268c2ab70b2e4e3dd6f-519x600.png" alt="Cat photo at decreasing JPEG quality, illustrating the quantization trade-off between size and detail" /><p>Name</p><p>Bytes / dim</p><p>1024-d vector</p><p>Compression</p><p>`Float` (Baseline)</p><p>4</p><p>4096 B</p><p>1x</p><p>`int8`</p><p>1</p><p>1024 B</p><p>4x</p><p>`int4`</p><p>0.5</p><p>512 B</p><p>8x</p><p>`bbq`</p><p>~0.14</p><p>142 B</p><p>~29x</p><h2>What is BBQ?</h2><p>Better Binary Quantization (BBQ) is Elasticsearch's 1-bit quantization mode for dense vectors. Each dimension of the vector is stored as a single bit, plus a few corrective bytes per vector. Then, a rescoring step is applied at query time. This keeps the final retrieval quality close to a full precision search.</p><p>For the math behind each level, see <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-101">Scalar quantization 101</a>, <a href="https://www.elastic.co/search-labs/blog/optimized-scalar-quantization-elasticsearch">Optimized Scalar Quantization</a>, and the <a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch">BBQ deep dive</a>.</p><h3>How does BBQ preserve search accuracy?</h3><p>Plain 1-bit quantization leads to too high a search quality degradation on its own. BBQ maintains high retrieval quality through three mechanisms:</p><ol><li><p><strong>Asymmetric precision:</strong> Stored vectors use 1 bit per dimension.</p></li><li><p><strong>Corrective factors:</strong> A few floats per vector record the rounding error and correct distances at scoring time.</p></li><li><p><strong>Oversample and rescore:</strong> BBQ scans candidates with the bits and then reranks the top ones with higher precision. Fetching the top 10 means scanning about 30 candidates.</p></li></ol><p>The result is the vectors that are roughly 32x smaller, with retrieval quality close to full precision. In the next section of the article, we’ll measure the memory savings and the recall on a real corpus.</p><h2>How Jina embeddings v5 works</h2><p>Jina embeddings v5 is a multilingual embedding model with quantization-aware training, which makes it a natural fit for BBQ in Elasticsearch: The 1024-dimensional vectors from <code>jina-embeddings-v5-text-small</code> sit above the dimensional floor where binary quantization stays accurate, and the model is trained so that 1-bit quantization loses little quality. Its main features are:</p><ul><li><p><strong>One model for many tasks:</strong> v5 uses small <a href="https://arxiv.org/abs/2106.09685">Low-Rank Adaptation (LoRA) adapters</a> on top of a single base model, one for each task: <em>retrieval</em>, <em>text-matching</em>, <em>clustering</em>, and <em>classification</em>. Elasticsearch <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text#getting-started">picks the right adapter automatically</a> at index and query time.</p></li><li><p><a href="https://arxiv.org/abs/2205.13147"><strong>Matryoshka dimensions:</strong></a> v5 is trained so you can truncate the vector (1024, 512 to 256) and minimize search quality reduction. This is another way to shrink vectors, independent of quantization.</p></li><li><p><strong>Quantization-aware training:</strong> v5 is trained to work with BBQ, so its 1-bit vectors lose little accuracy.</p></li></ul><p>We use <code>jina-embeddings-v5-text-small</code>. This model is available through <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS) and outputs 1024 dimensions with 32k token context and is multilingual across 93 languages. That puts it above the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-quantization">384-dimension threshold</a>, below which Elasticsearch no longer defaults to <code>bbq_hnsw</code>.</p><p>Full model details are in the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">Jina v5 article on Search Labs</a>.</p><h2>Setting up the BBQ vs. float32 comparison</h2><p>We’ll create two indices: Both share mappings, and what changes is the <code>index_options.type</code> parameter, which tells Elasticsearch how to store the dense vector field (as raw <code>float32</code> HNSW or as 1-bit BBQ):</p><p>Index</p><p>`index_options`</p><p>Loaded into memory</p><p>`vectors-float32`</p><p>`hnsw`</p><p>Raw `float32` with no quantization (baseline)</p><p>`vectors-bbq`</p><p>`bbq_hnsw`</p><p>1-bit BBQ quantization + corrective factors</p><p>We then embed the corpus once with Jina v5, index those same vectors into both, and compare them on disk usage, memory footprint, and recall. You can follow along with the full <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/quantizing-jina-embeddings-v5-bbq/quantization-jina-embeddings.ipynb">supporting blog content notebook</a>.</p><h3>Connect to Elasticsearch</h3>from elasticsearch import Elasticsearch, helpers

es_client = Elasticsearch(
    ELASTICSEARCH_URL, api_key=ELASTICSEARCH_API_KEY, request_timeout=120
)
es_client.info()<h3>Create the two indices</h3>DIMS = 1024
FLOAT_INDEX = "vectors-float32"
BBQ_INDEX = "vectors-bbq"


def create_index(name, index_options):
    if es_client.indices.exists(index=name):
        es_client.indices.delete(index=name)

    es_client.indices.create(
        index=name,
        mappings={
            "properties": {
                "text": {"type": "text"},
                "lang": {"type": "keyword"},
                "embedding": {
                    "type": "dense_vector",
                    "dims": DIMS,
                    "index": True,
                    "similarity": "cosine",
                    "index_options": index_options,
                },
            }
        },
    )


create_index(FLOAT_INDEX, {"type": "hnsw"})       # raw float32 baseline
create_index(BBQ_INDEX,   {"type": "bbq_hnsw"})   # 1-bit BBQ<p><em>Note: In production, you can use </em><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><em><code>semantic_text</code></em></a><em> to let Elasticsearch manage the mapping and inference endpoint automatically.</em></p><h3>Point at the Jina v5 inference endpoint</h3><p>We call the model <code>jina-embeddings-v5-text-small</code> directly (no need to create an <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put">inference endpoint</a>) to turn text into vectors.</p>INFERENCE_ID = ".jina-embeddings-v5-text-small"


def embed(texts, batch_size=16):
    out = []

    for i in range(0, len(texts), batch_size):
        batch = texts[i : i + batch_size]

        try:
            resp = es_client.inference.text_embedding(
                inference_id=INFERENCE_ID, input=batch
            )
        except AttributeError:  # older client versions
            resp = es_client.inference.inference(inference_id=INFERENCE_ID, input=batch)
        out.extend(item["embedding"] for item in resp["text_embedding"])

    return np.array(out, dtype=np.float32)


embed(["hello world"]).shape # testing<p>As result of the test, we got:</p>(1, 1024)<h3>Load a multilingual news dataset</h3><p>We stream real news articles from <a href="https://huggingface.co/datasets/hotchpotch/multilingual_cc_news">hotchpotch/multilingual_cc_news</a>, a parquet mirror of CC-News. We take about 1,000 articles from five languages (around 3,000 docs total), plus a small held-out set of headlines to use as search queries. Using multiple languages also lets Jina v5 show its multilingual strength.</p>from datasets import load_dataset

LANGS = ["en", "de", "ja", "pt", "ru"]
PER_LANG_DOCS = 1000
PER_LANG_QUERIES = 20

docs, queries = [], []
for lang in LANGS:
    ds = load_dataset(
        "hotchpotch/multilingual_cc_news", lang, split="train", streaming=True
    )
    rows = [
        r
        for r in ds.take(PER_LANG_DOCS + PER_LANG_QUERIES)
        if r.get("maintext") and r.get("title")
    ]

    for row in rows[:PER_LANG_DOCS]:
        text = (row["title"] + ". " + row["maintext"]).replace("\n", " ").strip()
        docs.append({"text": text[:1000], "lang": lang})

    for row in rows[PER_LANG_DOCS:]:
        queries.append({"text": row["title"], "lang": lang})  # headlines as queries

print(f"Corpus: {len(docs)} docs | Queries: {len(queries)}")

# RES: Corpus: 3102 docs | Queries: 18<h3>Generate the embeddings and bulk index</h3><p>We embed the corpus a single time and feed those exact vectors into both indices.</p>doc_vectors = embed([d["text"] for d in docs])
query_vectors = embed([q["text"] for q in queries])def index_docs(name):
    actions = (
        {
            "_index": name,
            "_id": i,
            "_source": {
                "text": d["text"],
                "lang": d["lang"],
                "embedding": doc_vectors[i].tolist(),
            },
        }
        for i, d in enumerate(docs)
    )
    helpers.bulk(es_client, actions, refresh=True)


for name in (FLOAT_INDEX, BBQ_INDEX):
    index_docs(name)
    es_client.indices.forcemerge(index=name, max_num_segments=1)
    es_client.indices.refresh(index=name)<p>We <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-forcemerge">force-merge</a> to a single segment so the storage numbers are stable and comparable.</p><h2>Results: Disk versus memory</h2><p>The <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-disk-usage">disk usage API</a> reports how many bytes each index spends on vectors (<code>knn_vectors</code>).</p>def vector_disk_bytes(name):
    du = es_client.indices.disk_usage(index=name, run_expensive_tasks=True)
    field = du[name]["fields"]["embedding"]
    knn = field.get("knn_vectors")
    if isinstance(knn, dict):
        return knn["size_in_bytes"]
    return field["knn_vectors_in_bytes"]


float_disk = vector_disk_bytes(FLOAT_INDEX)
bbq_disk = vector_disk_bytes(BBQ_INDEX)

N = len(docs)
float_mem = N * DIMS * 4
bbq_mem = N * (DIMS // 8 + 14)

print(f"On disk   -&gt; float32: {float_disk/1e6:6.2f} MB | BBQ: {bbq_disk/1e6:6.2f} MB")
print(f"In memory -&gt; float32: {float_mem/1e6:6.2f} MB | BBQ: {bbq_mem/1e6:6.2f} MB  ({float_mem/bbq_mem:.0f}x smaller)")<p>Result:</p>On disk   -&gt; float32:  12.80 MB | BBQ:  13.25 MB
In memory -&gt; float32:  12.71 MB | BBQ:   0.44 MB  (29x smaller)<p>On disk, the two indices are about the same size. A quantized index still keeps the raw <code>float32</code> vectors (needed for rescoring and requantization during merges) and adds the 1-bit vectors on top, so BBQ ends up slightly larger on disk.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt480f8d535f7a67cd/6a54faf95beed09a3ec5f836/c54fff3fe10273895d7fc16e3c8f215c3538d717-583x250.png" alt=" Float32 stores near-continuous values; BBQ quantization rounds each dimension to one of two levels" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfc4b17790ac39e76/6a54fafb9eff160936b24e85/5cede02da4a8380ef5420d993f5addac25d925b5-590x249.png" alt="BBQ quantization reduces vector storage from 4,096 bytes to 142 bytes per vector, a 29x reduction" /><p>The real savings is in memory. The HNSW scan only needs the 1-bit vectors in RAM, while the raw floats are read from disk to rescore the top candidates. We size that footprint using the documented <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/approximate-knn-search">kNN memory formulas</a>: <code>float</code> uses <code>num_vectors × dims × 4</code> and <code>bbq</code> uses <code>num_vectors × (dims/8 + 14)</code>.</p><p>BBQ's extra bytes on disk should match the 1-bit payload we computed for memory. Here, that’s <code>13.25 - 12.80 = 0.45 MB</code> versus the computed <code>0.44 MB</code>. They line up.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb1ae4f838c0b1c44/6a54fafdffefbe0991dd3dea/bf231b0290bb0661d0425384d69cb90d0222b44d-740x440.png" alt="BBQ quantization in Elasticsearch: similar disk usage, but memory drops from 12.7 MB to 0.4 MB" /><h2>Results: Recall</h2><p>To check whether the quantized index returns results similar to the float baseline, we use recall:</p><p><code>recall@k = | BBQ top-k ∩ float32 top-k | / k</code>, averaged over all queries.</p><p>We vary the oversampling factor (<code>num_candidates / k</code>) that’s the number of candidates BBQ scans with 1-bit vectors before reranking the top ones against the original floats to find the lowest value that still matches <code>float32</code>.</p>def search_ids(index, qvec, k=10, num_candidates=10):
    resp = es_client.search(
        index=index,
        size=k,
        _source=False,
        knn={
            "field": "embedding",
            "query_vector": qvec.tolist(),
            "k": k,
            "num_candidates": num_candidates,
        },
    )

    return [h["_id"] for h in resp["hits"]["hits"]]


K = 10

# Ground truth: full-precision float32 with a wide candidate list (~exact)
ground_truth = [
    set(search_ids(FLOAT_INDEX, qv, k=K, num_candidates=2000)) for qv in query_vectors
]

oversamples = [1, 2, 3, 5, 10]
recalls = []
for f in oversamples:
    num_candidates = max(K * f, K)
    hits = 0
    for gt, qv in zip(ground_truth, query_vectors):
        got = set(search_ids(BBQ_INDEX, qv, k=K, num_candidates=num_candidates))
        hits += len(got &amp; gt)
    recalls.append(hits / (len(query_vectors) * K))
    print(f"oversample {f:&gt;2}x -&gt; recall@{K} = {recalls[-1]:.3f}")<p>As a result, we have:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt96c062ee246abfd4/6a54faff600d773c12e424d3/e8604775a3f9bb47c473f2ac4686b926413bf4ad-640x440.png" alt="Recall@10 for BBQ quantization stays near 0.989 versus float32 across oversample factors 1x to 10x" /><p>BBQ starts at 0.994 recall@10 at 1x oversampling, holds there up to 3x, and then settles at 0.989 at higher factors, meaning it returns at least 98.9% of the same top-10 documents as float32 across all oversampling values. For more on how recall varies across datasets under quantization, see <a href="https://www.elastic.co/search-labs/blog/recall-vector-search-quantization">Fast vs. accurate: Measuring the recall of quantized vector search</a>.</p><h2>BBQ quantization results summary</h2><p>The same vectors, two storage formats, and one experiment:</p><ul><li><p><strong>Disk:</strong> Roughly the same (<code>12.80 MB</code> versus <code>13.25 MB</code>). BBQ keeps the raw floats around for rescoring and merging.</p></li><li><p><strong>Memory:</strong> 29x smaller (<code>12.71 MB</code> versus <code>0.44 MB</code>). This is the number that decides whether your cluster fits the corpus.</p></li><li><p><strong>Recall@10:</strong> <code>0.994</code> at 1x oversampling. Quantization-aware training pays off.</p></li></ul><p>When to enable BBQ: If your dimension count is above the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-quantization">384-dim floor</a>, if your vectors are the dominant memory cost, and if you can afford a few extra candidates to rescore. For Jina v5 specifically, the model is trained for it, so the recall hit on most corpora is small.</p><h2>Further reading on BBQ and vector quantization</h2><ul><li><p>Run the full notebook from this article in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/quantizing-jina-embeddings-v5-bbq/quantization-jina-embeddings.ipynb">supporting blog content repo</a>.</p></li><li><p>For the math behind BBQ, see <a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch">Better Binary Quantization in Lucene and Elasticsearch</a>.</p></li><li><p>For more on Jina v5's architecture, see <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">Jina embeddings v5 on Search Labs</a>.</p></li><li><p>For a broader walkthrough on adopting BBQ, see <a href="https://www.elastic.co/search-labs/blog/bbq-implementation-into-use-case">How to implement BBQ into your use case</a>.</p></li><li><p>For the original research behind BBQ, see the paper <a href="https://arxiv.org/abs/2405.12497">RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound for Approximate Nearest Neighbor Search</a>.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/bbq-quantization-jina-embeddings-v5</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/bbq-quantization-jina-embeddings-v5</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt99fb16c79484a00f/6a54fb02600d7743b9e424d9/43df5ec915eae1b9f1534d3acaf2e58732733d9b-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Why Elasticsearch is becoming a columnar database]]></title>
    <description><![CDATA[Elasticsearch is becoming a first-class columnar database. Columnar Mode ships in 9.5, storing data once alongside the existing modes and cutting storage footprints while speeding up analytical queries.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch is becoming a first-class columnar database. In 9.5, we’re introducing <strong>Columnar Mode</strong>, a new index mode that stores data once, in columnar form, with no redundant copies and no indexes the workload doesn't need.</p><p>Columnar Mode targets the workloads where data is written in volume, queried analytically, and retained for a long time: logs and observability, security telemetry, metrics and traces, business analytics on operational data, and AI retrieval at scale. For these workloads, it means <strong>meaningfully smaller storage footprints</strong> on day one, and the foundation for faster ingest, faster analytical queries, and longer retention as the surrounding work matures.</p><p>Columnar Mode ships <strong>alongside</strong> the existing modes, not in place of them. There are no changes to APIs, dashboards, applications, or downstream integrations. It's a new index mode that you can adopt where it helps, on the data where it fits.</p><p>The result is that Elasticsearch becomes a <strong>world-class search engine and columnar analytics engine</strong>, on the same data, in the same cluster, under the same operational umbrella. This is how Elastic stays the most useful place to put operational data as the economics of that data are rewritten by columnar architectures.</p><p>The rest of this post explains why we're doing it, along with what it changes and what it doesn't.</p><h2>Why Elasticsearch is adding a columnar mode</h2><p>Almost every general-purpose data system worth caring about has, over the last 15 years, made the same architectural decision: When the job is to read, aggregate, and reason over large volumes of data, you organize that data <strong>by column</strong>, not by row or document.</p><p>Elasticsearch is now making that decision, too</p><p>In addition to its document model, search heritage, and place at the heart of observability, security, and search applications across the industry, it’s now adding a second way to think about data inside the same platform. We're calling it <strong>Columnar Mode</strong>. It positions Elasticsearch to be the only major platform that does <strong>search</strong>, <strong>retrieval</strong>, and <strong>analytics</strong> at the same level, on the same data, with one operational story.</p><p>To get there we need to point out three things: One, how Elasticsearch came to be what it is today and why the document model was the right call. Two, how the rest of the data world, in parallel, converged on a completely different shape and why that movement has been so successful. And three, why these two worlds are now converging inside our users' systems and why putting columnar inside Elasticsearch is the right answer.</p><h2>How Elasticsearch became a document database (and why that was the right call)</h2><p>To understand where we're going, you have to understand where we started.</p><p>Sixteen years ago, when Elastic’s founder Shay Banon wrote the first version of Elasticsearch, the data world was in the middle of a quiet revolution. JSON was eating the wire. Web applications were sending and receiving data not as carefully typed rows in clearly defined tables, but as flexible, semi-structured documents (a customer order, a chat message, a product listing, a log line, a recipe), each a self-contained little world of fields, sub-fields, arrays, and nested objects.</p><p>The relational databases of the time were built around a very different assumption. Define a strict schema up front. Decompose your data into normalized tables. Use joins at query time to reassemble it. This worked beautifully for the use cases it was designed for (transactional systems, banking, enterprise resource planning systems), but it created enormous friction for the new generation of web and mobile applications. Adding a field meant a migration, and storing variable-shaped data meant either a brittle schema or a tangle of nullable columns.</p><p>In response, a new category of system emerged: document databases. The bet was simple. Take the data as it arrives, and store it in something close to its native shape. Don't make developers fight the database to express their domain. MongoDB became the flag-bearer for this idea on the operational side, and Elasticsearch carried the same banner on the search side.</p><p>For Elasticsearch, the document model was not just a developer-experience choice. It was a deep architectural commitment. The engine was designed around the idea that a record is a document, a document has fields, fields have types, and you should be able to put almost anything in and get useful behavior out (full-text search, structured filtering, sorting, aggregations, ranking) without having to model your data perfectly in advance.</p><p>This is what we mean when we talk about Elasticsearch's "document behavior":</p><ul><li><p>The engine remembers what you sent it. The original record is stored and can always be returned exactly as it was.</p></li><li><p>Every field is, by default, made queryable in multiple ways: optimized for fast exact lookups, fast text search, fast range queries, fast sorting, and fast grouping.</p></li><li><p>Schemas are flexible. New fields are absorbed at write time. The cost of getting your data model "wrong" is low.</p></li><li><p>The unit of work is the document. Indexing, retrieval, and most APIs are framed in terms of "given a document, do X" or "given a query, return documents."</p></li></ul><p>This is a model born from search. Apache Lucene, the storage library Elasticsearch is built on, was designed to make one workload remarkably fast: Take a query, find the small number of documents that match it, rank them by relevance, and return them. To do that well, you build <strong>inverted indexes</strong>: structures that turn the question around, so instead of asking "What's in this document?" you can ask "Which documents contain this term?" and get an answer in milliseconds. You also keep the original document around, because once you've found it, you usually want to show it or retrieve more information from it.</p><p>For the workloads Elasticsearch grew up serving (site search, log search, application search, security event lookup, "find this in a haystack") this was, and still is, the right shape. There's a reason Elasticsearch is used in production at hundreds of thousands of organizations worldwide.</p><p>But the document model has a built-in cost, and that cost is the starting point for everything that follows.</p><p>To keep its promise of fast search, flexible schemas, and faithful storage of the original record, the engine ends up storing data multiple times, in multiple shapes, each optimized for a different question. The original document is stored, and the text in each field is indexed for search. The values in each field are also stored separately, in a per-field columnar store known as <em>doc values</em>, so that they can be aggregated, sorted, and grouped. The system maintains all of these in parallel, by default, on every field, because at write time it doesn't know which of these capabilities you'll need at read time.</p><p>For the kinds of datasets Elasticsearch was originally designed for (relatively rich, relatively low-volume documents where every read is valuable), this trade-off is excellent. You pay a modest storage and ingestion tax for an enormous capability surface.</p><p>For the kinds of datasets Elasticsearch increasingly stores today (billions of log lines, trillions of metric points, oceans of telemetry, most of it written once and read rarely), that trade-off starts to look very different.</p><p>That’s the friction we’re setting out to resolve.</p><h2>The other story: How the data world went columnar</h2><p>While Elasticsearch was perfecting the document model, a parallel revolution was happening in analytics. It started in academia, became commercial, and is now the default architecture for almost every system built to read and reason over large datasets. To position Columnar Mode clearly, we have to tell that story.</p><p>The bet behind columnar storage is so simple it almost sounds like a trick. Instead of storing your data <em>row by row</em> (record one, record two, record three…), store it <em>column by column</em> (all values of <code>timestamp</code>, then all values of <code>host_name</code>, then all values of <code>status_code</code>…).</p><p>That single decision changes everything.</p><p>It started in the early 1990s, with research systems like MonetDB out of the Centrum Wiskunde &amp; Informatica in Amsterdam. It was sharpened in the mid-2000s with C-Store, a project led by Michael Stonebraker at MIT, which became the basis for Vertica. Sybase IQ carried the same idea into the early commercial market. By the early 2010s, every serious analytics vendor on earth had a columnar story. Today, the lineage runs through Amazon Redshift, Google BigQuery<strong>,</strong> and Snowflake in the cloud warehouse world; through open file formats, like Apache Parquet and Apache ORC that have become the lingua franca of the data lake; through in-memory standards like Apache Arrow; and through fast operational columnar engines, like ClickHouse and DuckDB.</p><p>Why has this approach won so completely?</p><p>Because when your job is to read and reason about a lot of data, columnar storage turns nearly every dimension of cost and performance in your favor.</p><p><strong>You only read what you need.</strong> Real analytical queries almost never want every field; they want three fields out of 50, or aggregate one field out of 200. A row-shaped store has to walk past all the other fields on the way to the ones you care about. A columnar store reads only the columns the query touched. On wide datasets, this routinely cuts the I/O of a query by 90% or more.</p><p><strong>You compress dramatically better.</strong> When you put all the values of one field next to each other, those values are, by definition, of the same type, often with repetition and similar structure; for example, a column of timestamps from the same hour, a column of status codes that are almost always 200, or a column of hostnames drawn from a small set. Compression algorithms thrive on this kind of homogeneity. Where row-shaped stores typically achieve 1.5–3x compression, columnar systems routinely achieve 5–10x, while on low-cardinality fields, it's common to see 20–30x. That’s not just a tuning improvement; that’s a different economic regime.</p><p><strong>You can skip vast amounts of data without reading it.</strong> Because data is organized in blocks of values from the same column, the engine can carry tiny pieces of metadata for each block (minimum, maximum, count, distinct values) and use them to prune entire blocks at query time. Looking for errors in the last hour? Skip every block whose timestamp range is older. Looking for a specific hostname? Skip every block where it doesn't appear. The system avoids work it doesn't need to do by knowing in advance that the work is pointless.</p><p><strong>You can fully leverage the power of modern CPUs.</strong> Modern processors are designed to operate on long, regular, predictable arrays of values; that's where the cache, pipeline, and vector units do their best work. A column is exactly such an array. Columnar engines run queries by passing batches of values through tight, vectorized operators, instead of looking up one record at a time. The speed gains aren’t incremental: Across academic benchmarks reaching back to Abadi et al.'s seminal <em>Column-Stores vs. Row-Stores</em> (SIGMOD 2008) and the comprehensive surveys that followed, columnar engines have consistently shown one to three orders of magnitude advantage over row-shaped systems on analytical workloads.</p><p><strong>You delay materializing the original record for as long as possible.</strong> Because the data is already organized by what queries actually do (read this column, filter on that column, aggregate this column), there's no need to reconstruct a full row until you absolutely have to. Most of the time, you don't have to at all, especially if you run analytical queries that only care about aggregating a few columns.</p><p>The cumulative effect of these properties is the reason every cloud data warehouse is columnar and why every modern data lake stores its files in Parquet or ORC. It’s also the reason new operational analytics engines have, almost without exception, chosen this shape. It’s the structural answer to the question "How do you make queries over large data cheap and fast?" and the answer turns out to be the same almost everywhere.</p><p>We should also note what columnar systems give up. They aren’t designed for "Give me one specific record and update it in place" or for transactional consistency at the level of individual rows. In general, they aren’t designed to do full-text relevance ranking the way a search engine does. They’re optimized for a different shape of workload, and that's the point. Different workloads, different defaults.</p><p>For a long time, the world worked because users could pick one tool for one workload and another tool for the other. That world is ending.</p><h2>Why search and analytics are now converging</h2><p>Three things have happened in the last five years that change the calculus.</p><p><strong>First, the volume of data users generate has exploded.</strong> Driven by microservices, containers, OpenTelemetry, AI workloads, and security telemetry, industry estimates put observability and log data ingest at large enterprises in the multiple-terabytes-per-day range, with annual growth rates that have run well into triple digits for several years.</p><p><strong>Second, the cost of storing and querying that data has become a top-line concern.</strong> Industry surveys and practitioner reports consistently flag observability and telemetry as one of the largest and fastest-growing line items in modern infrastructure budgets, and a meaningful share of that spend goes toward data that’s written, retained, and then never queried.</p><p>These first two pressures have already produced their answer in the market: Modern columnar systems built for analytics (most visibly ClickHouse on web logs and the cloud warehouses on broader analytics) have redefined what users expect to pay per terabyte stored and per query run. Elasticsearch is now meeting that bar without giving up what makes it Elasticsearch.</p><p><strong>Third, the boundary between search workloads and analytical workloads has dissolved.</strong> Virtually every modern operational use case wants both: find me this specific event, and then aggregate everything around it; show me this log line, and then chart the trend that produced it; retrieve this document, and then group what else matches.</p><p>The users who put their logs, metrics, traces, security events, and search corpus into Elasticsearch aren’t asking us to be only a search engine or only an analytics engine. They’re asking us to be one engine that does both, on the same data, with one operational story, at the cost basis they expect from a modern columnar system.</p><p>That’s a high bar, and meeting it requires us to change something fundamental about how Elasticsearch organizes data.</p><p>For a long time, Elasticsearch has had columnar storage <em>underneath</em>. We’ve stored per-column data in our engine since 2013, but we’ve always treated it as a secondary structure layered on top of a document model. That made sense when the document model was the source of truth and the columnar layer was an optimization. It makes less sense when, for an enormous and growing share of our users' data, the columnar shape <em>is</em> the source of truth and the document model is the optimization most don’t need.</p><p>This is what Columnar Mode is for.</p><h2>Columnar Mode: Elasticsearch with a second way of thinking about data</h2><p>The decision we’re making is deliberately conservative in its surface area and ambitious in its impact.</p><p>We aren’t building a new product, asking users to migrate, or changing the API, the query language, the management surface, the integrations, the visualization layer, or anything else that touches their operational reality. Elasticsearch is still Elasticsearch.</p><p>What we are doing is introducing a new mode that users can apply, index by index, that turns Elasticsearch into a first-class columnar system for the data where that's the right shape.</p><p>In Columnar Mode, the engine flips its defaults:</p><ul><li><p><strong>Data is stored once, in our columnar store (doc values).</strong> No parallel copy of the original document and no secondary structures built by default. Each field is responsible for its own storage, and the engine doesn't pay for capabilities the workload doesn't need.</p></li><li><p><strong>Search indexes are built only where they earn their keep.</strong> The message field of a log is still indexed by default to enable free-text search. A numeric field used only in aggregations means no index, extra storage, or additional write-time cost. The engine becomes leaner by default and lets you opt in to capability where it's worth paying for.</p></li><li><p><strong>The original record can be regenerated on demand</strong> from the column store, instead of being kept as a redundant copy. Users who want to keep the stored copy for query convenience can; and users operating at petabyte scale can opt to drop it entirely for further storage savings.</p></li><li><p><strong>The data model is genuinely columnar.</strong> Fields are flat key/value pairs, not nested object trees. Multi-valued fields, cardinality, and nullability are first-class mapping concepts, the same primitives that make pure columnar systems efficient.</p></li><li><p><strong>Specialized profiles ship on top.</strong> The first one is <strong>Columnar Logs</strong>, a logs-oriented variant of the mode with indexing enabled on log messages and the right defaults for time-ordered data. We’ll follow with profiles for other workloads, including, eventually, a columnar profile for vector retrieval, using the same building blocks.</p></li></ul><p>All of this is new for Elasticsearch though not conceptually new in the industry.</p><p>The reason this matters is that we’re doing this <strong>without leaving anything behind</strong>. The same query that runs against a Columnar Mode index runs, unchanged, against a document-mode index. Plus, the same dashboards, agents and integrations, Service Level Objectives (SLOs), alerts, rules, and machine learning jobs work as usual. Users who want the document behavior can always keep it on the indexes where it makes sense. Users who want columnar efficiency on the indexes where <em>that</em> makes sense (typically, the largest indexes in their cluster) get it.</p><p>This is the difference between us and a pure columnar engine. A pure columnar engine does one of these jobs and asks you to bring another system for the other. Elasticsearch is one platform that does both.</p><h2>What Columnar Mode is for</h2><p>Columnar Mode is built for workloads where data is written in volume, queried analytically, and retained for a long time, rather than a universal default. These work categories include:</p><p><strong>Logs and observability at scale.</strong> Users running tens or hundreds of terabytes of logs per day spend most of their observability budget on storage and on the aggregation queries that power dashboards, SLOs, and rate calculations. Columnar Logs is the first specialized profile precisely because this is where the impact lands hardest. At petabyte scale, Columnar Mode changes what is economically possible, that is, longer retention, more raw fidelity, and fewer compromises forced by cost.</p><p><strong>Security event stores and threat hunting.</strong> Security telemetry has the same shape as logs, but with a heavier emphasis on faceted exploration, ad hoc correlation, and deep historical lookups for indicators of compromise. Columnar Mode keeps full-fidelity storage affordable while preserving the search-relevance behavior security analysts need for lookup and pivoting, using the same engine to handle both jobs.</p><p><strong>Metrics, traces, and the unified observability substrate.</strong> Time-series data is already columnar in TSDB, Elasticsearch’s time series database. A future columnar profile will share the same storage building blocks across logs, metrics, and traces, letting all three sit in one engine on the same substrate without any of them compromising on workload-specific behavior. The unified observability platform becomes economically practical, not just architecturally elegant.</p><p><strong>Operational and business analytics on application data.</strong> Beyond observability, this means dashboards over millions of orders, transactions, user events, and Internet of Things (IoT) readings. Many of the workloads that have historically pushed users to ship the same data into a separate analytical warehouse, purely to make the queries affordable, can, with Columnar Mode, stay where the data already is.</p><p><strong>AI retrieval at scale.</strong> Vector workloads are inherently columnar. A future columnar profile for vector retrieval will combine the dense storage efficiency of the general mode with the indexing structures retrieval needs, putting retrieval augmented generation (RAG) and semantic-search workloads on the same cost basis as every other shape of data in the cluster.</p><p>The thread running through all of these is that Columnar Mode is the right answer when data is append-mostly, queries are analytical, and access patterns are per-column. For the workloads where those conditions don't hold, like search-first applications where the document is the unit of value, transactional flows that update individual records, or anything that depends on rich document structure as user-facing semantics, the document-oriented modes remain the right default, and they’ll continue to be invested in.</p><p>From the start, the principle has been that different workloads require different defaults, and Columnar Mode is what makes that real.</p><h2>What changes for Elasticsearch users with Columnar Mode?</h2><p>For Elasticsearch users, here’s what changes:</p><ul><li><p><strong>Costs go down meaningfully.</strong> Columnar Mode only builds inverted indexes where they earn their keep and stops storing data multiple times on every field. The result is a smaller storage footprint for the same workloads. Users can choose between two shapes of the mode: Columnar Logs keeps an inverted index on the message field, where the bulk of log queries actually look; the pure Columnar Mode variant drops that default entirely on string-type fields. A follow-up technical deep dive will publish the benchmarks and exact storage savings. The savings compound at scale and show up in operational reality immediately on self-managed clusters in the disks and machines the cluster no longer needs, and on Elastic Cloud in the bill.</p></li><li><p><strong>The architecture pays off on reads and writes, too.</strong> The same choices that make Columnar Mode storage-efficient (data stored once, indexes only where they earn their keep, and vectorized execution over columns) are the ones that shape its behavior on the read and write path, as well. The workloads that benefit most are the ones that dominate observability, security, business intelligence, and AI retrieval.</p></li><li><p><strong>The model gets simpler.</strong> Columnar Mode is opinionated by default. The right behavior for analytical workloads is what you get out of the box, like what we’ve done for metrics with TSDB, but for all types of data. The configuration surface contracts in places it should have contracted years ago.</p></li><li><p><strong>The path is nondisruptive.</strong> Users can adopt Columnar Mode index by index. The cluster, applications built on top of Elasticsearch, and dashboards don't change. The model is additive: a new way of behaving that’s available where it helps, alongside the behavior they already trust.</p></li></ul><p>The result is an Elasticsearch that’s leaner and faster, with no changes where it has been working all along.</p><h2>What doesn't change when you turn on Columnar Mode</h2><ul><li><p><strong>The document-oriented modes aren’t deprecated.</strong> Every existing mode remains available, supported, and invested in. Users who depend on document behavior (typical search applications, application search, security workflows, and anything where the original record's structure is the point) keep that behavior.</p></li><li><p><strong>Existing modes get faster, not slower.</strong> The work behind Columnar Mode is, at its core, work on doc values, Elasticsearch's columnar store, which every mode uses under the hood. Compression, encoding, query-execution, and vectorization improvements that ship with Columnar Mode benefit document-mode indexes alongside columnar ones for fields that reside on doc values. LogsDB, TSDB, and standard indexes get meaningfully faster analytical queries as a side effect of this work, with no user action or migration required. Investing in Columnar Mode is investing in every mode.</p></li><li><p><strong>The APIs don’t change.</strong> Every interface a user or partner integrates with (the REST APIs, query languages, management UI, data collection agents, integrations, and SDKs) keeps working exactly as it does today. Columnar Mode is an index setting, not a fork in the product, and downstream components don’t need to learn anything new.</p></li><li><p><strong>Search relevance, vector search, and semantic retrieval aren’t affected.</strong> These remain first-class capabilities of the engine, and they benefit from many of the same query and indexing performance investments that Columnar Mode rides on.</p></li><li><p><strong>There’s no migration cliff.</strong> Existing indexes keep working, and new indexes can be created in whichever mode fits their workload. Users move at the pace that makes sense for their organization.</p></li><li><p><strong>Elastic Cloud Serverless gets columnar the same way.</strong> On Serverless, where users manage projects rather than indices, Columnar Mode becomes part of the project type configuration. Observability and log-heavy project types will move to columnar defaults as the mode matures, with no user-facing change in how projects are created or managed.</p></li></ul><p>This matters because the temptation, when a company makes a big architectural bet, is to oversell it as the new gospel and undersell the engine's existing strengths. We don't have to make that trade. Elasticsearch's existing modes aren't legacy; they’re a mature, production-hardened document-oriented search engine that has been refined by more than a decade of use at scale. Columnar Mode is what they've been missing for the workloads they were never designed for.</p><h2>Why columnar storage matters for the future of Elasticsearch</h2><p>For most of the last decade, the dominant pattern has been fragmentation; for example, a search engine for search, a warehouse for analytics, a time-series database for metrics. This also includes a log aggregator for logs, a vector database for AI retrieval, and a graph database for relationships. Each one has its own ingestion pipeline, query language, operational story, and bill. The complexity tax this imposes on users is enormous, and the spend it generates for the industry is even larger.</p><p>The pattern that’s starting to replace it is <em>convergence</em>, the realization that the right architecture for a modern data engine is one that takes data once, stores it efficiently, and exposes it to whatever workload the application happens to need, whether that’s search, retrieval, aggregation, ranking, or vector similarity. That convergence is the most important trend in the data infrastructure industry, and it’s the trend on which Elasticsearch's future depends.</p><p>Columnar Mode is our central move in that direction, along with the work happening alongside it on indexing throughput, Elasticsearch Query Language (ES|QL, Elasticsearch’s analytical query language), vector retrieval, and observability and security as integrated solutions. It’s the architectural decision that unlocks the others, because without a first-class columnar layer, Elasticsearch's economics simply don’t compete on the workloads where convergence is most valuable.</p><p>With Columnar Mode, Elasticsearch becomes the only major platform in the industry that can credibly be <strong>the best search engine you can run</strong> and <strong>a competitive columnar analytics engine</strong> on the same data, in the same cluster, under the same operational umbrella. Although there will always be a specialized system that wins a specialized benchmark for individual workloads, Elasticsearch is where operational data belongs when it spans more than one shape and workload.</p><p>That’s the bet, and we’re confident it’s the right one.</p><h2>The bottom line on Elasticsearch as a columnar database</h2><p>Sixteen years ago, the right call for a search engine was to behave like a document store. We made that call and built one of the most widely used data platforms in the industry.</p><p>Today, the right call for the next decade of operational data (observability, security, AI retrieval, application search, business analytics) is to give that same engine a columnar way of thinking as a first-class mode users can choose, on the data where it fits, without giving up any of the things they already rely on us for.</p><p>That’s what Columnar Mode is. It’s the most important architectural change we’ll make to Elasticsearch this decade, and it’s the move that makes Elasticsearch the engine our users will still be reaching for when their current tool of choice has been displaced.</p><p>We’re going columnar. The reasoning is sound, and the path is nondisruptive. Columnar Mode reaches technical preview in Elasticsearch 9.5 and general availability (GA) in Elasticsearch 9.6, and every product team at Elastic is building on top of this foundation. We aren’t waiting for the future of data systems to arrive; we’re building it.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-columnar-storage</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[Columnar]]></category>
    <dc:creator><![CDATA[Yannis Roussos]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt52c0c0ae90e62a4d/6a55eb9f28aa67a917e60bba/0efd01fd06a0b70a30b1ec74c4995c4acadf98e7-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to build search analytics on Elastic using OpenTelemetry, no extra pipeline required]]></title>
    <description><![CDATA[How to instrument your search application to use modern Open Telemetry standard to drive insights in to your search and users.]]></description>
    <content:encoded><![CDATA[<p>Know which searches drive revenue and which ones lose customers, no separate analytics pipeline required. Add search behavior as attributes to your existing application requests using OpenTelemetry (OTel), and query click-through rate, zero-result queries, and conversion funnels in Elastic and unlock modern Application Performance Monitoring at the same time. If you already run Elastic, you have everything you need. This post shows you how.</p><h2>What you'll discover</h2><p>In this post, you'll learn:</p><ul><li><p>The value of a comprehensive search analytics stack.</p></li><li><p>The case for using a modern observability standard for search analytics.</p></li><li><p>The benefits of combining search analytics and observability.</p></li><li><p>Practical first steps for getting started.</p></li></ul><h2>The search analytics challenge</h2><p>Your product manager wants to know which searches are driving purchases and which ones are losing customers. To answer, you have to grep through 5GB of Nginx logs, join it with a CSV of purchase data, and pray that the timestamps align. There has to be a better way.</p><p>The questions seem simple:</p><ul><li><p>Which searches lead to revenue, and which ones lose customers?</p></li><li><p>Are users finding what they're looking for?</p></li><li><p>Which queries are failing?</p></li><li><p>How do you connect search behavior to business outcomes?</p></li></ul><p>Getting answers is harder. Traditional approaches involve stitching together custom event pipelines, third-party analytics tools, and hand-rolled dashboards. You end up with data silos: search logs here, click events there, and business metrics somewhere else entirely.</p><p>What if there was a simpler way?</p><h2>Enter OpenTelemetry (and why it fits search)</h2><p>If you come from a search engineering background, you may not have crossed paths with <em>application performance monitoring (APM)</em><strong>,</strong> which is the practice of instrumenting your code to understand how it behaves in production. The key idea behind modern APM is the <em>trace</em>, a structured record that follows a single request as it flows through your system, from the initial API call through every database query and service hop. Unlike logs, which are isolated lines of text, traces connect the dots across your entire stack.</p><p>OpenTelemetry (OTel) has become the industry standard for producing these traces. Born from the merger of OpenTracing and OpenCensus, it provides a vendor-neutral way to collect traces, metrics, and logs. The core idea is simple: Instrument your code once, and send data anywhere.</p><p>Elastic has invested heavily in this standard as a contributor. Elastic actively aligns its <a href="https://www.elastic.co/guide/en/ecs/current/ecs-reference.html">Elastic Common Schema (ECS)</a> with <a href="https://opentelemetry.io/docs/specs/semconv/">OTel Semantic Conventions</a> so that field names are consistent across both, and it donated the Universal Profiling Agent to the OTel project to make profiling a core OTel signal.</p><p>Because of this alignment, Elastic natively understands OTel data. OTel transmits data using the OpenTelemetry Protocol (OTLP). On Elastic Cloud, the managed OTLP endpoint (mOTLP) accepts OTLP spans directly from your SDK, without requiring an intermediate collector. For self-managed deployments, the <a href="https://www.elastic.co/docs/reference/edot-collector">Elastic Distribution of OpenTelemetry (EDOT) Collector</a> provides the same path. This lets you use standard OTel traces for search analytics, without any additional infrastructure.</p><h3>How traces become analytics</h3><p>A <em>trace</em><em></em>is a collection of spans, where each span represents one operation (an API call, a database query, or a search request). Each span can carry arbitrary <em>attributes</em><em></em>(key-value pairs that describe what happened).</p><p>This is where it gets interesting for search. Search behavior can ride on the same spans OTel already generates for APM, with no new attributes required beyond what you add to existing calls.</p><ul><li><p><code>search.query</code>: What the user searched for.</p></li><li><p><code>search.result_count</code>: How many results came back.</p></li><li><p><code>search.result_click_position</code>: Which result they clicked.</p></li></ul><p>By extending OTel with attributes like these, we can capture rich behavioral data using the same infrastructure that powers application monitoring. You don’t need a new pipeline or a new vendor; you’re simply adding new attributes to existing spans.</p><h2>A unified approach: OTel + Elastic + ES|QL</h2><p>If you're building search, there's a good chance you're already running Elasticsearch. It powers search for thousands of ecommerce sites, content platforms, and enterprise applications. It’s often chosen for its speed, flexibility, and features like vector search, Learning To Rank (LTR), and query rules. What's less well-known in the search community is that Elastic also has over a decade of investment in observability. Elastic APM, logging, and infrastructure monitoring are used at scale across industries.</p><p>Running search and observability on the same platform is what makes this approach work. The same platform that runs your search engine can also analyze how people use it. Elastic natively ingests OTel data via its managed OTLP endpoint, and Elasticsearch Query Language (ES|QL), Elasticsearch's piped query language, makes it easy to explore and aggregate that data in Kibana.</p><p>Together, they offer a compelling approach to search analytics:</p><ul><li><p>Instrument once: Add OTel attributes to your search requests and user interactions.</p></li><li><p>Store centrally: Traces flow to Elastic via mOTLP alongside your other application telemetry.</p></li><li><p>Query flexibly: Use ES|QL in Kibana to calculate metrics, slice by any dimension, and explore patterns.</p></li></ul><p>You don’t need a separate analytics pipeline or a dedicated click-tracking service. You just need your search application with the proper instrumentation.</p><h2>What you can measure</h2><p>With the right instrumentation in place, you can answer the questions that matter:</p><h3>Search quality metrics</h3><ul><li><p>CTR: What percentage of searches result in a click? Low CTR might indicate poor relevance or unappealing result presentation.</p></li><li><p>Mean Reciprocal Rank (MRR): When users click, how far down the results list do they go? An MRR of 1.0 means every click is on position 1. If everybody clicks on the second position result, your MMR would be 0.5. Higher is better.</p></li><li><p>Zero Results Rate: What percentage of searches return nothing? These are your content gaps or query parsing failures. Every zero-result search is a missed opportunity.</p></li></ul><h3>Query-level analysis</h3><p>Beyond aggregate metrics, you can drill into specific queries:</p><ul><li><p>Top queries by volume: What are users actually searching for?</p></li><li><p>Queries with low CTR: Where is relevance failing?</p></li><li><p>Zero-result queries: What content is missing from your index?</p></li><li><p>Click position distribution: Are clicks concentrated at the top, or are they scattered?</p></li></ul><h3>Business impact</h3><p>Search doesn't exist in a vacuum. By tracking the journey from search to conversion, you can connect relevance to revenue:</p><ul><li><p>Which searches lead to add-to-cart events?</p></li><li><p>What's the conversion rate for searches versus browsing?</p></li><li><p>Which queries drive the most revenue?</p></li></ul><h3>Training data for machine learning</h3><p>The same click data that measures search quality can also train models to improve it. Click positions and frequencies can be transformed into judgment lists for LTR models, turning user behavior into relevance signals.</p><h3>Search analytics dashboard example in Kibana</h3><p>Here's what this looks like in practice. This Kibana dashboard is powered entirely by OTel traces and ES|QL, with no custom pipeline or separate analytics service:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9ed7033e646aa70d/6a55ea11924ca930505b6656/2d6413dd8646aab9b59eed49e1814cc07a7c75ac-2048x1989.png" alt="Search analytics dashboard in Kibana showing click-through rate, zero-result rate and revenue metrics from OpenTelemetry traces" /><p>Every panel on this dashboard comes from the same index. The headline metrics use value-based coloring to surface health at a glance: CTR and MRR are green (<em>healthy</em>), while the Zero Results Rate is red (<em>needs attention</em>). Below, you can see which queries drive the most clicks, which ones return nothing, and how searches convert through the funnel to revenue.</p><p>Notice the SLO cards at the bottom, with targets like "99% of searches under 250ms" that let you track reliability as a measurable commitment. That's a hint at something bigger.</p><h2>How does search analytics data flow into Elastic?</h2><p>The data flow is straightforward:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt29684c7db676ddcb/6a55ea1491ee6601e308f364/efadb0c12fae9cfc21a6fad2ace0fd256b2f3a69-1076x312.png" alt="" /><ul><li><p>Browser<strong>:</strong> User searches and clicks. The front end can send events directly via OTel or relay them through the back end.</p></li><li><p>Back end: Your search API, instrumented with OTel. Each search request becomes a span, with attributes like <code>search.query</code>, <code>search.result_count</code>, and <code>search.query_id</code>.</p></li><li><p>Elastic (mOTLP): Receives and stores the OTel traces via the mOTLP endpoint. Point <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> at your Elastic deployment with <code>ApiKey</code> auth. This doesn’t require a collector or transformation.</p></li><li><p>Kibana: Where you explore and visualize your data. Use ES|QL in Discover to run ad hoc queries, use the APM UI to inspect individual traces, build dashboards for ongoing monitoring, and set up alerts when metrics degrade.</p></li></ul><h2>How OTel attributes become queryable data</h2><p>Before we query, you need to understand how Elastic stores these OTel attributes, because it affects how you write your ES|QL.</p><p>With OTel-native ingestion, custom attributes are stored directly under <code>attributes.*</code>, and their original dot notation is preserved. There’s no underscore translation and no type splitting, and strings, numbers, and booleans all live in the same namespace.</p><p>OTel attribute</p><p>Type</p><p>ES|QL field</p><p>`search.query`</p><p>string</p><p>`attributes.search.query`</p><p>`search.result_count`</p><p>number</p><p>`attributes.search.result_count`</p><p>`search.first_click`</p><p>boolean</p><p>`attributes.search.first_click`</p><p>This mapping is as simple as it looks: The attribute name in your code is the field name in your query. We'll cover more examples in the next post, when you're writing queries hands-on.</p><h2>ES|QL: Your search data in five lines</h2><p>ES|QL is Elasticsearch's piped query language. You start with a data source, and then, one step at a time, pipe it through filters, aggregations, and calculations.</p><p>Here's a single query that calculates your CTR, the percentage of searches that result in at least one click:</p>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p>Reading from the top down: Pull all search spans and first-click spans from <code>traces-generic.otel-default</code>, count each type separately with <code>COUNT(CASE(...))</code>, and then divide to get CTR. We label the first click on each search with <code>search.first_click</code> at instrumentation time, so the query just counts it and no deduplication is needed. One query provides one counted result, which gives you the CTR.</p><p>That's the pattern. ES|QL reads from the top down, and each pipe step transforms the data. In the next post, we'll run six queries like this, including top queries, zero-results analysis, search performance, and volume over time, all against real data. Stay tuned for it!</p><h2>From search analytics to full-stack observability</h2><p>By choosing OTel and Elastic for search analytics, you're investing in infrastructure that serves multiple needs.</p><p>The same traces that calculate your CTR also give you operational visibility, without requiring any extra instrumentation:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd35ddad1dffaa94d/6a55ea1663060bba00985895/f360b6c519b59586416c55467c480f670e16aec8-2048x1033.png" alt="Kibana SLO dashboard showing search latency, quality and availability targets alongside p50, p95 and p99 latency from OpenTelemetry traces" /><p>The search dashboard's SLO cards track three targets: your search latency target (99% under 250ms), search quality (85% returning results), and availability (99.9% success rate). Below them, latency percentiles use the same value-based coloring. Green means <em>healthy</em>, yellow means <em>watch it</em>, and red means <em>needs attention</em>.</p><p>The operational charts break down where time is spent (Elasticsearch query time versus application overhead) and how latency trends over time. All of this comes from the same <code>search.*</code> attributes you added for analytics, and no extra instrumentation is required.</p><p>This is the practical advantage of the OTel approach:</p><ul><li><p>Search latency: How long are queries taking? Where are the slow ones?</p></li><li><p>Error rates: Are searches failing? If so, why?</p></li><li><p>Dependencies: How does Elasticsearch performance affect your search API?</p></li><li><p>SLOs: Set targets for search latency or Zero Results Rate.</p></li><li><p>Alerting: Get notified when metrics degrade.</p></li><li><p>Anomaly detection: Let machine learning (ML) spot unusual patterns in search behavior.</p></li></ul><p>Start with search analytics, and you can extend that foundation into full-stack observability. It’s a single instrumentation investment that results in multiple returns.</p><h2>What OTel attributes do you need for search analytics?</h2><p>To get started, you'll want to capture a few key attributes on your search spans:</p><p>Attribute</p><p>Type</p><p>Purpose</p><p>`search.query`</p><p>string</p><p>The user's search terms</p><p>`search.result_count`</p><p>number</p><p>How many results returned (0 = zero-result search)</p><p>`search.query_id`</p><p>string</p><p>Unique query identifier (derived from trace ID)</p><p>`search.result_click_id`</p><p>string</p><p>Which result was clicked</p><p>`search.result_click_position`</p><p>number</p><p>Position of the clicked result (1-indexed)</p><p>For click tracking, add these attributes to click event spans:</p><p>Attribute</p><p>Type</p><p>Purpose</p><p>`search.result_click_id`</p><p>string</p><p>Document ID that was clicked</p><p>`search.result_click_position`</p><p>number</p><p>Position in results (1-indexed)</p><p>`search.action`</p><p>string</p><p>Event type: click, impression, add_to_cart, purchase</p><p>`search.query_id`</p><p>string</p><p>Links this interaction back to the originating search</p><p>`search.first_click`</p><p>boolean</p><p>`true` on the first click per search (for CTR without deduplication)</p><p>We use the <code>search.*</code> namespace following OTel's convention of domain-specific prefixes (<code>http.*</code>, <code>db.*</code>, <code>messaging.*</code>). While OTel doesn't yet have standardized search conventions, <code>search.*</code> is self-describing and vendor-neutral. Our naming is informed by the <a href="https://www.ubisearch.dev/">User Behavior Insights (UBI)</a> standard, which defines a detailed schema for search events. We reference it for event structure without coupling our instrumentation to it.</p><p>If you don't have APM set up yet, you can capture the same <code>search.*</code> attributes as OTel log records instead of spans. The analytics concepts are identical; you just query <code>logs-generic.otel-default</code> instead of <code>traces-generic.otel-default</code>, and the <code>attributes.*</code> field paths are the same. See the <a href="https://www.elastic.co/docs/solutions/observability/logs/stream-any-log-file-using-edot-collector">OpenTelemetry logs with Elastic</a> documentation for setup details.</p><h2>What's next in this search analytics series</h2><p>This post introduced the concept of using OTel instrumentation and ES|QL queries to build search analytics on Elastic. The rest of the series goes from concept to production. Stay tuned for it!</p><h2>Get started with search analytics on Elastic</h2><p>Ready to add search analytics to your application?</p><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project</a>: Clone, configure, and have search analytics flowing in 10 minutes.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/open-telemetry.html">OpenTelemetry and Elastic</a>: How to send OTel data to Elastic via the managed OTLP endpoint.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a>: Learn the query language.</p></li><li><p><a href="https://www.elastic.co/observability">Elastic Observability</a>: The broader platform.</p></li></ul><p><em>This is the first post in a six-part series on search analytics with OpenTelemetry and Elastic. Next up: Instrument your search API: Add search attributes to your back end, and run your first ES|QL queries.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3e9a759480aa699d/6a55ea197f36cfa01cbe8ebe/0c4cf4b77045dd306e6e5b1254b34d7abd30f0e0-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your compliance posture just got an upgrade: Elasticsearch now supports FIPS 140-3]]></title>
    <description><![CDATA[Elastic 9.4 brings FIPS 140-3 support for Elasticsearch and Kibana to GA. Here's what changes for federal, defense and regulated deployments, and how to migrate from 140-2.]]></description>
    <content:encoded><![CDATA[<p><strong>The latest National Institute of Standards and Technology (NIST) cryptographic standard is fully supported in Elastic 9.4, so your compliance posture and your software can move forward together.</strong></p><p><a href="https://csrc.nist.gov/pubs/fips/140-3/final">FIPS 140-3</a> support for Elasticsearch and Kibana is generally available in Elastic 9.4 for self-managed deployments. NIST has stopped accepting new FIPS 140-2 submissions, with existing certificates winding down in September 2026. Every layer of your infrastructure needs to catch up, and your search and analytics platform is no exception. Federal programs, defense integrators and regulated enterprises are actively moving procurement requirements to 140-3. With Elastic 9.4, your stack can answer "yes."</p><h2>Two problems, one deadline: why FIPS 140-2 is no longer enough</h2><p>If you've been running Elasticsearch in FIPS 140-2 mode, you already know the value of having a compliant Elastic Stack. But two pressures are converging:</p><ul><li><strong>The standard is sunsetting.</strong> FIPS 140-2 certificates are being phased out. Procurement officers, auditors, and authorization bodies are shifting their requirements to 140-3. A stack that only supports the old standard is a stack with an expiration date on its compliance story.</li><li><strong>Auditors don't accept "close enough."</strong> It's not sufficient to run FIPS-approved algorithms. Your application layer needs to be explicitly configured for FIPS mode, with nonapproved algorithms disabled and cryptographic boundaries clearly documented. Partial compliance is noncompliance.</li></ul><p><em>Can't I just put Elasticsearch behind a FIPS-compliant load balancer or run it on a FIPS-hardened OS?</em> No, and here's exactly why: FedRAMP's network security requirements apply to cryptographic operations at the application layer, not just the network boundary. Intranode communication, keystore encryption, and credential hashing all need to happen inside a validated module. A compliant perimeter around a noncompliant application layer isn't a FIPS deployment; it's an audit finding waiting to happen.</p><h2>What FIPS 140-3 mode does in Elasticsearch 9.4</h2><p>When you flip FIPS mode on, Elasticsearch and Kibana restrict every cryptographic operation to FIPS-approved algorithms and delegate all of it to the validated module in your runtime. Here's what that covers and how.</p><ul><li><strong>High-grade security Transport Layer Security (TLS) everywhere, no exceptions.</strong> Node-to-node transport, the REST API over HTTPS, Kibana talking to Elasticsearch: All of it uses only FIPS-approved cipher suites. Noncompliant suites aren't deprioritized. They're rejected.</li><li><strong>PBKDF2 replaces bcrypt for password hashing.</strong> Bcrypt isn't FIPS-approved, so in FIPS mode, Elasticsearch switches to PBKDF2 for the native realm, file realm, and any stored credentials. Your users and service accounts stay protected with an algorithm that the auditor won't flag.</li><li><strong>Keystore encryption stays inside the boundary.</strong> Secrets in the Elasticsearch keystore, API keys, repository credentials, and encryption keys for Kibana's saved objects are wrapped with FIPS-approved key derivation and encryption. No gaps between <em>the cluster is compliant</em> and <em>the secrets are compliant</em>.</li><li><strong>You supply the cryptographic module.</strong> FIPS 140-3 support in Elasticsearch is built on the <a href="https://www.bouncycastle.org/fips-java/">Bouncy Castle FIPS Java API 2.0</a>, a FIPS 140-3 validated cryptographic module that runs inside the Java Virtual Machine (JVM). Elasticsearch delegates all cryptographic operations to Bouncy Castle; it doesn't implement its own crypto functions. Elasticsearch uses your own FIPS 140-3 JVM and Bouncy Castle FIPS provider, giving you full control over your cryptographic boundary and module versioning.</li></ul><p>Kibana takes a different path, running in a Node.js environment configured with a FIPS-compliant OpenSSL 3 provider. Together, both components operate within clearly defined cryptographic boundaries clean enough to diagram for an auditor.</p><h2>Who needs FIPS 140-3 support in Elasticsearch</h2><ul><li><strong>Federal and defense teams</strong> operating Elastic inside FedRAMP boundaries, Cybersecurity Maturity Model Certification–scoped (CMMC-scoped) environments, or Defense Information Systems Agency (DISA) Security Technical Implementation Guide–hardened (STIG-hardened) infrastructure. You can now upgrade to 9.x without punching a hole in your authorization documentation. Your Authorization to Operate (ATO) package references FIPS 140-3, not a soon-to-expire 140-2 certificate.</li><li><strong>Financial services and healthcare organizations</strong> where Payment Card Industry Data Security Standard (PCI DSS), Sarbanes‑Oxley Act (SOX), or Health Insurance Portability and Accountability Act (HIPAA) audits ask how your search infrastructure handles cryptography. FIPS mode gives your compliance team a one-word answer instead of a three-paragraph explanation.</li><li><strong>Anyone fielding </strong><em>Do you support FIPS 140-3?</em><strong> in a procurement questionnaire.</strong> That question is showing up in enterprise requests for proposal (RFPs), partner security assessments, and insurance underwriting checklists. With 9.4, the answer is <em>yes</em>.</li></ul><h2>Migrating from FIPS 140-2 to FIPS 140-3</h2><p>If you're running FIPS 140-2 on Elastic 8.x today, you don't have to jump to 9.x on day one. <strong>Elastic 8.19 continues to support FIPS 140-2, and FIPS 140-3 support is available starting in 8.19.15.</strong> That gives you two paths:</p><ul><li><strong>Stay on 8.19.15+, and upgrade the standard in place.</strong> If you're not ready to move to 9.x, you can switch from FIPS 140-2 to 140-3 on your current major version. Your cluster stays put, and your compliance posture moves forward. FIPS 140-2 certificates remain valid through September 2026, so you have a window. But the earlier you transition, the less you're depending on a sunset timeline, and you can also benefit from new features!</li><li><strong>Move to 9.4, and land on 140-3 directly.</strong> If you're planning a major version upgrade anyway, 9.4 gives you FIPS 140-3 from the start, along with everything in the 9.x line: Elasticsearch Query Language (ES|QL) latest functionalities, improved ingest, updated security detections, and performance improvements. No compliance trade-off required.</li></ul><p></p><p></p><p>Stay on 8.19.15+</p><p>Upgrade to 9.4</p><p>FIPS standard</p><p>140-3 (from 8.19.15)</p><p>140-3 (from the start)</p><p>Major version change</p><p>No</p><p>Yes</p><p>New 9.x features</p><p>No</p><p>Yes</p><p>FIPS 140-2 certificates valid until</p><p>September 2026</p><p>N/A (lands on 140-3 directly)</p><p>Kibana covered</p><p>Yes</p><p>Yes</p><p></p><p>Either way, the configuration model will feel familiar. You enable FIPS mode in your YAML config, point to a FIPS-validated JVM, and the stack handles the rest. And Kibana is covered on both paths: Your visualization and analytics layer operates within the same compliant boundary as Elasticsearch.</p><h2>How to enable FIPS 140-3 in Elasticsearch 9.4</h2><p>FIPS 140-3 support is available now in Elastic 9.4 for self-managed deployments with a Platinum or Enterprise subscription, the same licensing tier as FIPS 140-2. For setup instructions, supported JVM versions, configuration details, and known limitations, see the <a href="https://www.elastic.co/docs/deploy-manage/security/fips">FIPS compliance documentation</a>.</p><p><a href="https://www.elastic.co/downloads/elasticsearch"><strong>Download Elastic 9.4</strong></a>, and give your compliance team some good news.</p><p>Questions? Your Elastic account team can help scope a FIPS deployment, or drop into the <a href="https://discuss.elastic.co/">Elastic community forums</a> to compare notes with other operators running in regulated environments.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p><h2>Frequently asked questions</h2><p><strong>Does Elasticsearch support FIPS 140-3?</strong></p><p>Yes. FIPS 140-3 support for Elasticsearch and Kibana is generally available in Elastic 9.4 for self-managed deployments. All cryptographic operations are delegated to the Bouncy Castle FIPS Java API 2.0, a FIPS 140-3 validated module. A Platinum or Enterprise subscription is required.</p><p><strong>When does FIPS 140-2 expire?</strong></p><p>NIST stopped accepting new FIPS 140-2 module submissions. Existing FIPS 140-2 certificates remain valid through September 2026. Organizations running Elasticsearch in FIPS 140-2 mode should plan their migration before that deadline to avoid gaps in their compliance documentation.</p><p><strong>Can I migrate from FIPS 140-2 to FIPS 140-3 without upgrading to Elasticsearch 9.x?</strong></p><p>Yes. FIPS 140-3 support is available in Elastic 8.19.15 and later. You can switch from FIPS 140-2 to FIPS 140-3 on your current major version without moving to 9.x. Alternatively, upgrading directly to Elastic 9.4 lands you on FIPS 140-3 from the start.</p><p><strong>What cryptographic changes does FIPS mode make in Elasticsearch?</strong></p><p>When FIPS mode is enabled, Elasticsearch restricts all operations to FIPS-approved cipher suites, replaces bcrypt with PBKDF2 for password hashing, and delegates all cryptographic functions to the Bouncy Castle FIPS provider. Non-approved cipher suites are rejected, not just deprioritized.</p><p><strong>Does Kibana support FIPS 140-3?</strong></p><p>Yes. Kibana operates in a Node.js environment configured with a FIPS-compliant OpenSSL 3 provider. Both Elasticsearch and Kibana run within clearly defined cryptographic boundaries when FIPS mode is enabled.</p><p><strong>Why isn't a FIPS-hardened OS or load balancer sufficient for FedRAMP compliance?</strong></p><p>FedRAMP's network security requirements apply to cryptographic operations at the application layer, not just at the network boundary. Intranode communication, keystore encryption and credential hashing must all occur inside a validated cryptographic module. A compliant perimeter around a non-compliant application layer is an audit finding, not a FIPS deployment.</p><p><strong>Is Elasticsearch FIPS 140-3 support available on Elastic Cloud?</strong></p><p>Elastic 9.4 FIPS 140-3 support covers self-managed deployments. Cloud deployment availability is not covered in this announcement. Contact your Elastic account team or check the Elastic documentation for cloud FIPS roadmap details.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/fips-140-3-elasticsearch-kibana</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/fips-140-3-elasticsearch-kibana</guid>
    <category><![CDATA[Operations]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Fabio Busatto]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1092d0334a5b571c/6a572545f71286c608af57e4/3e67c4eeaa3d9411e97ee8b2ae74078a8177a911-2048x1143.avif" length="0" type="image/*"/>
    <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Piping Hot: Bringing ES|QL to Your Grafana Dashboards Using the Elasticsearch Plugin]]></title>
    <description><![CDATA[You can now write ES|QL queries in Grafana with the Elasticsearch plugin. Learn how to enable it and write pipe-based queries directly in the Grafana UI.]]></description>
    <content:encoded><![CDATA[<p>The Elasticsearch data source is one of the most popular plugins in the Grafana ecosystem, and it now ships ES|QL support as an experimental feature, available starting in Grafana 13.0. ES|QL is Elasticsearch's modern pipe-based query language that enables querying logs, metrics, and traces, and using Elasticsearch as a native <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Prometheus PromQL</a> data source, all directly from the Grafana query editor. Built by Elastic in collaboration with Grafana Labs and contributed upstream to the Grafana open source project, this integration is enabled through a single feature flag (<code>elasticsearchESQLQuery = true</code>) that unlocks a Monaco-powered editor with syntax highlighting, autocompletion, and inline error messages. We'll walk through how to enable it and write your first queries for log analysis, time series visualization, and metrics aggregation.</p><p></p><h2>Context</h2><p>Elasticsearch is one of the top plugins used with the Grafana UI. Until now, the Elasticsearch plugin only supported Lucene and raw Query DSL for querying. <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> is Elastic's modern pipe-based query language, designed for analytics on log, metrics, and trace data. Its intuitive syntax makes it easier to filter, aggregate, and transform data compared to Query DSL or Lucene.</p><p>This has been tracked as a community request since 2024: <a href="https://github.com/grafana/grafana/issues/81765">grafana/grafana#81765</a>.</p><h2>How to enable ES|QL support in Grafana</h2><p>The feature is behind the <code>elasticsearchESQLQuery</code> feature flag. To turn it on, add the following to your <code>grafana.ini</code>:</p>[feature_toggles]
elasticsearchESQLQuery = true<p>Restart Grafana after saving. The feature is available starting with Grafana 13.0.</p><h2>ES|QL in the Grafana query editor</h2><p>Once the flag is enabled, the Elasticsearch query editor gains a <strong>Query language</strong> selector. You can switch between Lucene, Raw DSL, and ES|QL from the same editor panel.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc54a93910c32b6cf/6a4774fa74bff774dfa0697c/c7e83f6b498d8dbd8755e5d3086d0f44ad52e529-799x192.webp" alt="" /><p>When you select ES|QL, the editor switches to a Monaco-powered code editor, the same engine that powers VS Code. You get syntax highlighting, error highlighting, and basic autocompletion out of the box.</p><p><strong>Smart index pre-population</strong> makes getting started quick: if an index pattern is configured in your data source settings, clicking into the ES|QL editor for the first time auto-inserts <code>FROM &lt;index&gt;</code>. You can change or delete it freely. If no index is configured, the <code>FROM</code> clause is left blank.</p><h2>Running your first queries</h2><h3>Count log entries by severity</h3><p>This is a good first query to confirm ES|QL is working and to get a feel for the syntax.</p><p>Run it in the <strong>Raw Data</strong> panel type to see a table with counts per log level:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd6dea511499d72fd/6a4774fd2d406b4255ba39d1/74740e7fd1033c6e2d4c87b0c57f049360b9fcba-734x788.webp" alt="" /><h3>Browse the latest errors</h3><p><code>WHERE</code>, <code>KEEP</code>, <code>SORT</code>, and <code>LIMIT</code> make it easy to filter down to exactly the fields and rows you care about.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta276e207b97fe2d0/6a477500bfe8a289994da165/68b3dca2777f2dc81cb67bc28dc34fa8a961186f-726x909.webp" alt="" /><h3>Log volume over time</h3><p>Use <code>BUCKET</code> to group log counts into hourly intervals. This works well with the <strong>Metrics</strong> panel type, which can render it as a time series graph.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta68256304a4f0fec/6a4775023eae5d83abd13f90/510dee6a05a2de4af72b0b768411f6e54bd25a6f-722x865.webp" alt="" /><h3>Top hosts by log activity</h3><p>Identifying the most active hosts is a common operations task. <code>STATS</code> with <code>BY</code> and <code>LIMIT</code> makes it concise.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc01282b3689f3db9/6a47750531bdbbb2898b436a/e6be9497d82a46fc97d20417f0b6d85b6e418558-762x777.webp" alt="" /><h3>The TS command for time series metrics</h3><p>For metrics data in <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">Time Series Data Streams (TSDS)</a>, the <code>TS</code> source command in ES|QL enables metrics analytics and time series aggregation.</p><p>Examples:</p><ul><li><p><code>RATE()</code>: rate of change over time</p></li><li><p><code>AVG_OVER_TIME()</code>: average value over a sliding window</p></li><li><p><code>INCREASE()</code>: total increase over a period</p></li><li><p><code>DELTA()</code>: difference between first and last value</p></li><li><p><code>LAST_OVER_TIME()</code>: most recent value in a window</p></li></ul><p>The pattern follows a two-level aggregation: an inner function applied per individual time series, then an outer function aggregating across groups (for example, per host or per service).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt747d48504b7d826c/6a4775084887b866f94256f0/56fa77cdc4c1aa061fc27b54a781d4f8f5ad97ca-719x988.webp" alt="" /><p>You can also use <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions/avg_over_time"><code>AVG_OVER_TIME()</code></a> to compute the average value of a metric over a sliding window, then split the results by host and 10-minute buckets:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt411e15b564d3fcb9/6a47750b043a1f264c499021/5737d00f387383ec9a8d03a7d2588b28bd8cad00-708x908.webp" alt="" /><p><code>TS</code> also runs queries through the ES|QL vectorized compute engine. Internal benchmarks show performance improvements of an order of magnitude or more compared to equivalent Query DSL queries for TSDS-backed data.</p><p>Reference:</p><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts">TS command documentation</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">Time series aggregation functions</a></p></li></ul><h2>Inline error messages</h2><p>ES|QL queries run against the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-rest"><code>/_query</code></a> <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-rest">HTTP endpoint</a> on Elasticsearch. If your query has a syntax error or references a non-existent field, Elasticsearch returns a structured error response. The plugin surfaces this directly in the query editor as an inline message, so you see exactly what went wrong right in the Grafana UI.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb34a3ab9edbe00a1/6a47750dae1fcb770098685b/8e4b1a1ab78fe95c948f6e1f5ff8fe4a67507766-723x360.webp" alt="" /><p>In the example above, <code>host.nam</code> is missing the final <code>e</code>. Elasticsearch catches this as a verification exception and returns the field name that could not be resolved. That message appears inline, right below the query editor.</p><h2>Technical details</h2><p>Under the hood, the plugin handles ES|QL and other query types on separate code paths. ES|QL queries go to the <code>/_query</code> endpoint with <code>Content-Type: application/json</code>. Lucene and Query DSL queries continue to use <code>/_msearch</code> with <code>Content-Type: application/x-ndjson</code>.</p><p>This separation is intentional: <code>/_query</code> returns a different response shape that the plugin parses independently before passing data to Grafana panels.</p><h2>Try it out!</h2><p>That also means this is a good moment to try it and give feedback.</p><p>The <a href="https://github.com/grafana/grafana/pull/117798">upstream PR</a> and the <a href="https://github.com/grafana/grafana/issues/81765">original tracking issue</a> are public. If you run into problems or have requests, both are open for comments.</p><h2>Next steps</h2><ul><li><p>Enable the feature in your Grafana 13.0 instance with <code>elasticsearchESQLQuery = true</code></p></li><li><p>Try the example queries above against your own indices</p></li><li><p>For metrics data, give the ES|QL <code>TS</code> command a spin, against an Elasticsearch 9.2 or Serverless data source.</p></li><li><p>Read the full <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL overview</a> to explore what else the language can do</p></li></ul><p>If you are not yet on Elasticsearch, you can start a free trial at <a href="https://cloud.elastic.co/registration">Elastic Cloud</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-grafana-elasticsearch-plugin</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-grafana-elasticsearch-plugin</guid>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Cauê Marcondes]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ec58a4e301413fb/6a477510bfe8a2121e4da169/17625309931e2620ff7c0584556f8bd62033483d-1376x768.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 01 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch DiskBBQ delivers 7x faster vector search than Qdrant on network-attached storage]]></title>
    <description><![CDATA[Elasticsearch DiskBBQ achieves up to 7x higher vector search throughput than Qdrant at comparable recall on network-attached storage. Explore the benchmark methodology and full results.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch DiskBBQ delivers up to 7x higher throughput than Qdrant at comparable recall, tested on network-attached persistent storage, the topology most managed-cloud deployments actually use. The gap is consistent across recall levels from 0.93 to 0.97, and it widens as recall increases. DiskBBQ keeps latency nearly flat as search breadth grows; Qdrant's latency rises sharply as <code>hnsw_ef</code> increases, driven by random reads of original vectors from disk during rescoring. If you're running vector search in Kubernetes or a managed cloud environment, this is what the tradeoff looks like.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf417e30d37bbe73a/6a46976e151035764d202f02/057e4d34719f4ca87c86f1b2a36b06d0839ad275-800x500.png" alt="Bar chart comparing throughput in queries per second between Elasticsearch 9.4.1 and Qdrant 1.18.1 at recall levels 0.93, 0.95, 0.96 and 0.97, showing Elasticsearch delivering approximately 7x higher throughput across all recall levels." /><p>Vector search is a critical foundation for large language model (LLM) applications, retrieval augmented generation (RAG), and other AI workloads. In this benchmark, Elasticsearch achieved up to 7x higher throughput than Qdrant at comparable recall on the same storage topology. Elasticsearch as a vector database offers strong vector search performance even when network-attached persistent storage remains on the query path.</p><p>The difference reflects how the two systems interact with disk. Elasticsearch DiskBBQ is designed to keep vector search efficient when persistent storage remains on the query path, using a compact quantized representation and limiting costly access to full precision vectors during search. In this setup, Qdrant relies on a graph-based search path with rescoring against original vectors stored on disk. On network-attached persistent storage, that random access cost becomes much more significant, which is why the performance gap widens as recall increases. This benchmark therefore focuses specifically on network-attached persistent storage, a common deployment model in managed cloud and Kubernetes environments.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte01d96b1db40d0e9/6a46977131bdbb595d8b33e7/3a39012cb09a841468a5295e226955769c4d18f0-800x500.png" alt="Line chart showing recall versus average latency in milliseconds for Elasticsearch 9.4.1 and Qdrant 1.18.1. Elasticsearch maintains low latency between 120 and 150ms across all recall levels, while Qdrant latency rises steeply from 315ms to 900ms as recall increases." /><p>The key pattern in the latency curve is not only the size of the gap but also its shape. Elasticsearch latency remains comparatively flat as recall increases, suggesting that higher recall doesn’t require a dramatic increase in expensive storage activity. Qdrant’s latency rises sharply as <code>hnsw_ef</code> increases, which is consistent with broader candidate exploration leading to more rescoring work against original vectors on disk.</p><h2>Full results table</h2><p>The table below shows the full parameter sweep for both Elasticsearch and Qdrant. Because the two engines expose different tuning controls for vector search, the results are reported using each engine’s full parameter key rather than attempting a one-to-one mapping between settings.</p><p>A few notes on the metrics:</p><ul><li><p>ParamKey: The complete parameter setting used for a given run.</p></li><li><p>Recall: Recall@100 against a ground-truth top-100 result set for the benchmark queries. Values range from 0 to 1, and higher is better.</p></li><li><p>Latency_Avg: The average end-to-end latency per query measured from the benchmarking client across the full run, in milliseconds. Lower is better.</p></li><li><p>Latency_P95: The 95th percentile query latency, in milliseconds, showing the upper range of typical slow queries. Lower is better.</p></li><li><p>Throughput: The average number of queries processed per second across the full run. Higher is better.</p></li></ul><p>Engine</p><p>ParamKey</p><p>Recall</p><p>Latency_Avg</p><p>Latency_P95</p><p>Throughput</p><p>qdrant</p><p>hnsw_ef=50, oversampling=1, size=100</p><p>0.8694</p><p>315.7849</p><p>503.4754</p><p>12.629</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=1</p><p>0.8789</p><p>135.0802</p><p>218.494</p><p>29.343</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=1.5</p><p>0.9123</p><p>127.8286</p><p>195.2318</p><p>31.1107</p><p>qdrant</p><p>hnsw_ef=100, oversampling=1, size=100</p><p>0.9287</p><p>895.9933</p><p>1213.0448</p><p>4.4493</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=2</p><p>0.9317</p><p>124.846</p><p>183.6314</p><p>31.8225</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=2.5</p><p>0.9444</p><p>123.517</p><p>180.4831</p><p>32.1883</p><p>qdrant</p><p>hnsw_ef=150, oversampling=1, size=100</p><p>0.9518</p><p>884.7236</p><p>1195.2603</p><p>4.5066</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=3</p><p>0.9532</p><p>123.276</p><p>183.8379</p><p>32.2364</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=3.5</p><p>0.9599</p><p>122.5559</p><p>184.2858</p><p>32.4469</p><p>qdrant</p><p>hnsw_ef=200, oversampling=1, size=100</p><p>0.964</p><p>883.2114</p><p>1188.6597</p><p>4.5143</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=4</p><p>0.965</p><p>122.7946</p><p>184.9058</p><p>32.3635</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=4.5</p><p>0.9689</p><p>122.7062</p><p>182.9559</p><p>32.3976</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=5</p><p>0.9722</p><p>122.5761</p><p>187.3536</p><p>32.4221</p><p>qdrant</p><p>hnsw_ef=256, oversampling=1, size=100</p><p>0.9722</p><p>881.9643</p><p>1185.4948</p><p>4.5192</p><p>elasticsearch</p><p>k=100, oversample=1, size=100, visit_percentage=5.5</p><p>0.9747</p><p>122.5609</p><p>184.5128</p><p>32.4176</p><p>Each row pairs the closest measured Elasticsearch and Qdrant configurations in the sweep by achieved recall.</p><h3>Matched comparisons at similar recall</h3><p>To make the comparison fair, speedup is calculated only between configurations that achieve similar recall. This avoids comparing settings that trade off accuracy very differently.</p><p>Recall band</p><p>Elasticsearch recall</p><p>Elasticsearch Latency_Avg</p><p>Elasticsearch throughput</p><p>Qdrant recall</p><p>Qdrant Latency_Avg</p><p>Qdrant throughput</p><p>Throughput speedup</p><p>~0.87</p><p>0.8789</p><p>135.0802</p><p>29.343</p><p>0.8694</p><p>315.7849</p><p>12.629</p><p>2.32x</p><p>~0.93</p><p>0.9317</p><p>124.846</p><p>31.8225</p><p>0.9287</p><p>895.9933</p><p>4.4493</p><p>7.15x</p><p>~0.95</p><p>0.9532</p><p>123.276</p><p>32.2364</p><p>0.9518</p><p>884.7236</p><p>4.5066</p><p>7.15x</p><p>~0.96</p><p>0.9599</p><p>122.5559</p><p>32.4469</p><p>0.964</p><p>883.2114</p><p>4.5143</p><p>7.19x</p><p>~0.97</p><p>0.9722</p><p>122.5761</p><p>32.4221</p><p>0.9722</p><p>881.9643</p><p>4.5192</p><p>7.17x</p><p>This matched-recall view is the clearest expression of the underlying systems difference. At similar recall levels, Elasticsearch delivers both lower latency and much higher throughput, and the gap widens as recall rises. The recall-throughput pattern matters because higher recall in this benchmark requires broader search. DiskBBQ absorbs that increase with relatively little additional cost, while Qdrant’s graph plus rescoring path becomes much more constrained by random access to original vectors on persistent storage.</p><h2>Benchmark methodology</h2><p><a href="https://github.com/elastic/jingra">Jingra</a>, the benchmarking tool used for these tests, was originally written in Python and has since been rebuilt as a Java project. For these tests, Jingra runs in a Kubernetes pod within the same cluster as the engine being measured. This helps reduce external network variability and keeps the test environment consistent across runs. For each run, Jingra executed the query set at a fixed client concurrency, recorded end-to-end client-side latency and throughput, and computed recall against a precomputed ground-truth top-100 set.</p><p>This benchmark was intentionally run on network-attached persistent storage rather than local NVMe. For the published results, the storage used the baseline performance allocation for a 200 GiB GCP Hyperdisk Balanced volume, with no explicit IOPS or throughput provisioning. We chose this topology on purpose because it’s a relevant cloud deployment model and because it keeps storage efficiency materially on the query path.</p><p>Qdrant often performs better on local NVMe, so deployments using local NVMe should expect different results than the ones shown here. This benchmark specifically tests network-attached persistent storage because that’s a common managed-cloud deployment model and because it makes storage-path efficiency visible in end-to-end query performance.</p><p>Because Elasticsearch and Qdrant expose different query parameters for controlling vector search behavior, there’s no clean one-to-one mapping between their tuning settings. Instead of comparing equivalent parameter values directly, we use recall as the primary point of comparison. The matched comparisons below therefore pair configurations that achieve similar recall, rather than configurations with superficially similar parameter values.</p><p>Recall cannot be known in advance for a given parameter setting, so we sweep across a range of search configurations for each engine and then compare results at similar recall levels. In the published results, oversampling was fixed at 1 for both engines so that recall was primarily tuned via search breadth rather than rescoring expansion.</p><h3>How does Elasticsearch configure vector search?</h3>{
  "query": {
    "knn": {
      "field": "embedding",
      "query_vector": "{{query_vector}}",
      "k": "{{k}}",
      "visit_percentage": "{{visit_percentage}}",
      "rescore_vector": {
        "oversample": "{{oversample}}"
      }
    }
  },
  "size": "{{size}}",
  "_source": false
}<ul><li><p><code>query_vector</code>: The input vector used for similarity search. Elasticsearch compares this vector against the stored vectors in the field.</p></li><li><p><code>k</code>: The number of nearest neighbors to retrieve.</p></li><li><p><code>visit_percentage</code>: Controls how much of the DiskBBQ, Elasticsearch’s disk optimized vector index, is explored during the approximate search phase. Higher values usually improve recall but increase latency.</p></li><li><p><code>oversample</code>: Controls how many extra candidate vectors are passed into rescoring relative to k. Higher values can improve recall, but usually at additional cost.</p></li><li><p><code>size</code>: The number of hits returned in the final response.</p></li><li><p><code>_source: false</code>: Disables returning the document _source field, reducing response size and avoiding extra retrieval overhead during benchmarking.</p></li></ul><p>Example</p>{
  "query": {
    "knn": {
      "field": "embedding",
      "query_vector": [ -0.0095683, 0.0072035934, ... ],
      "k": "100",
      "visit_percentage": "3",
      "rescore_vector": {
        "oversample": "1"
      }
    }
  },
  "size": "100",
  "_source": false
}<p>Params</p>  recall@100:
    - { size: 100, k: 100, visit_percentage: 1, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 1.5, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 2, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 2.5, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 3, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 3.5, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 4, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 4.5, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 5, oversample: 1 }
    - { size: 100, k: 100, visit_percentage: 5.5, oversample: 1 }<p>We keep <code>k = size = 100</code> so the search request is aligned with the benchmark target: returning the top 100 results. To improve recall, we tune <code>visit_percentage</code> rather than inflating the final result count, while keeping <code>oversample = 1</code> fixed across runs.</p><h3>How does Qdrant configure vector search?</h3>{
  "vector": "{{query_vector}}",
  "limit": "{{size}}",
  "with_payload": false,
  "with_vector": false,
  "params": {
    "hnsw_ef": "{{hnsw_ef}}",
    "quantization": {
      "rescore": true,
      "oversampling": "{{oversampling}}"
    }
  }
}<ul><li><p><code>query_vector / vector</code>: The input vector used for similarity search. Qdrant compares this vector against the stored vectors in the collection.</p></li><li><p><code>size / limit</code>: The number of nearest neighbor results returned in the response.</p></li><li><p><code>with_payload: false</code>: Disables returning payload fields, reducing response size and avoiding additional retrieval overhead during benchmarking.</p></li><li><p><code>with_vector: false</code>: Disables returning stored vectors in the response, again reducing response size and keeping the benchmark focused on search performance.</p></li><li><p>hnsw_ef: Controls the number of candidates explored during HNSW search. Higher values usually improve recall but increase latency. Like visit_percentage in Elasticsearch, it affects search breadth, but the two controls are engine-specific and not directly equivalent.</p></li><li><p><code>quantization.rescore: true</code>: Enables rescoring of the candidate set using the original vectors after quantized search.</p></li><li><p><code>oversampling</code>: Controls how many extra candidates are considered during rescoring relative to the final result count. Higher values can improve recall, but usually at additional cost.</p></li></ul><p>Example</p>{
  "vector":  [ -0.0095683, 0.0072035934, ... ],
  "limit": "100",
  "with_payload": false,
  "with_vector": false,
  "params": {
    "hnsw_ef": "150",
    "quantization": {
      "rescore": true,
      "oversampling": "1"
    }
  }
}<p>Params</p>  recall@100:
    - { size: 100, hnsw_ef: 50, oversampling: 1 }
    - { size: 100, hnsw_ef: 100, oversampling: 1 }
    - { size: 100, hnsw_ef: 150, oversampling: 1 }
    - { size: 100, hnsw_ef: 200, oversampling: 1 }
    - { size: 100, hnsw_ef: 256, oversampling: 1 }<p>We keep <code>size = 100</code> so that each request is aligned with the evaluation target, in this case top 100 retrieval. Recall is then tuned by sweeping <code>hnsw_ef</code>, which controls how many candidates are explored during search. Higher <code>hnsw_ef</code> values generally improve recall but also increase latency and reduce throughput. We keep <code>oversampling = 1</code> fixed across runs so that the main tuning variable is the search breadth rather than the rescoring expansion.</p><h2>Cluster setup and DiskBBQ configuration</h2><p>We ran the benchmark on GCP using three n4-standard-8 nodes, with each pod allocated 7 vCPUs and 26 GB of RAM, and using 200 GiB GCP Hyperdisk Balanced volumes at baseline performance allocation. The corpus contains 21 million vectors, (see dataset section below for more details and download links), which account for about 60.1 GiB of raw float vector data. With 2-bit quantization, the vector payload drops to roughly 3.8 to 4.0 GB. However, the full index footprint is much larger once graph and other index structures are included. That means the workload remains meaningfully sensitive to network-attached storage performance, especially because exact vector values still need to be read from disk during rescoring.</p><p>We chose this node size intentionally to keep the benchmark in a regime where network-attached persistent storage remains on the query path rather than allowing the full working set to remain comfortably memory-resident. Each system was therefore configured using the best-performing setup we identified for this workload within the tuning scope described in this post. In Elasticsearch, this meant <code>bbq_disk</code>. In Qdrant, the original vectors were stored on disk, while the 2-bit quantized representation used for approximate search was kept in RAM with <code>always_ram: true</code>. Because the two systems expose different search strategies and tuning controls, we compare them at matched recall rather than trying to map parameters one to one.</p><p>Elasticsearch was configured to use DiskBBQ, its disk-optimized approach for approximate nearest neighbor vector search, with 2-bit quantization. DiskBBQ uses aggressive quantization to keep the searchable index compact and then rescores with the original vectors to preserve accuracy. This helps maintain strong recall while keeping disk-based search efficient.</p><p><code>bbq_disk</code> is an Elasticsearch Enterprise feature. We used it here because the goal of this benchmark was to compare the strongest disk-oriented vector search configuration available in each engine for this workload, rather than licensing tiers or default features.</p><p>We didn’t include <code>bbq_hnsw</code> in this comparison because the benchmark was specifically designed to evaluate disk-oriented vector search under a disk-sensitive workload.</p><p>This storage topology matters because Qdrant’s rescore step reads the original <code>float32</code> vectors from disk with random access on each query. On local NVMe, those reads are much faster, and Qdrant correspondingly performs better. On network-attached persistent storage, the results are consistent with that random-read rescore path becoming a more important bottleneck. Qdrant latency rises sharply as <code>hnsw_ef</code> increases, while Elasticsearch remains comparatively flat across the same recall progression.</p><p>We chose 2-bit quantization because Qdrant couldn’t reach the target recall range with 1-bit binary quantization. Since the two systems expose different disk-oriented vector search strategies, we tuned each one to the strongest configuration available within its current feature set.</p><p>Both systems were configured with three shards distributed across the three nodes and with two total copies of each shard in the cluster. In Elasticsearch, <code>number_of_shards: 3</code> and <code>number_of_replicas: 1</code> means one primary plus one replica, for two total copies. In Qdrant, <code>shard_number: 3</code> and <code>replication_factor: 2</code> also means two total copies, since Qdrant’s replication factor refers to the total number of copies rather than the number of additional replicas. So although the field names differ, the effective replication level was the same in both systems.</p><p>Setting</p><p>Elasticsearch</p><p>Qdrant</p><p>Shards</p><p>number_of_shards: 3</p><p>shard_number: 3</p><p>Copies</p><p>number_of_replicas: 1 (1 primary + 1 replica = 2 total)</p><p>replication_factor: 2 (2 total)</p><p>Elasticsearch mapping</p>{
  "mappings": {
    "properties": {
      "embedding": {
        "type": "dense_vector",
        "element_type": "float",
        "dims": 768,
        "index": true,
        "similarity": "cosine",
        "index_options": {
          "type": "bbq_disk",
          "bits": 2
        }
      }
    }
  },
  "settings": {
    "number_of_shards": "3",
    "number_of_replicas": "1"
  }
}<p>Qdrant mapping</p>{
  "vectors": {
    "size": 768,
    "distance": "Cosine",
    "on_disk": true
  },
  "shard_number": 3,
  "replication_factor": 2,
  "hnsw_config": {
    "m": 16,
    "ef_construct": 256
  },
  "quantization_config": {
    "turbo": {
      "bits": "bits2",
      "always_ram": true
    }
  }
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6ff77646baa598e4/6a4697742d406b1032ba2bd9/8d6f3e8d3e2c620187d8d2841cc09b813cd77e57-881x401.png" alt="Architecture diagram showing the benchmark cluster setup on GCP. Two Kubernetes clusters side by side: the left contains three Elasticsearch nodes behind an ES Service, with Jingra as the benchmarking client. The right mirrors this with three Qdrant nodes behind a QD Service, also driven by Jingra." /><h2>Dataset</h2><p>For this benchmark, we used the <a href="https://huggingface.co/datasets/kenhktsui/wiki_dpr_e5"><code>kenhktsui/wiki_dpr_e5</code></a> dataset from Hugging Face, a large-scale Wikipedia passage retrieval dataset designed for dense vector search. The corpus contains 21 million embedded passages, each represented as a 768-dimensional float32 vector, or 3,072 bytes per vector. That corresponds to about 60.1 GiB of raw vector data, before accounting for additional fields and file format overhead in the source dataset. The downloadable <code>data.parquet</code> file is larger at 85.2 GB for that reason.</p><p>We chose this dataset because it reflects a common production pattern in LLM, RAG, and retrieval systems: searching a large corpus of semantically embedded text while balancing recall, latency, and throughput. At 21 million vectors and roughly 60 GiB of raw vector data, it’s large enough to make disk-based vector search a relevant operating mode to evaluate.</p><p>Both engines used 2-bit quantization, reducing each vector from 3,072 bytes to 192 bytes, a 16x reduction that brings the quantized vector corpus to around 4 GB. In Qdrant, that quantized representation was kept in RAM for search, while the original vectors remained on disk. Even so, the workload remained meaningfully sensitive to network-attached storage performance because rescoring still required access to the original vectors on disk.</p><p>You can download the dataset and query files from the links below:</p><ul><li><p><a href="https://storage.googleapis.com/elastic-benchmark-datasets/wiki-dpr-e5-768/data.parquet">data.parquet</a></p></li><li><p><a href="https://storage.googleapis.com/elastic-benchmark-datasets/wiki-dpr-e5-768/queries.parquet">queries.parquet</a></p></li></ul><h2>Jingra and recreating the benchmark</h2><p>For this benchmark, we used <a href="https://github.com/elastic/jingra/releases/tag/v0.2.3">Jingra v0.2.3</a> with the configurations described <a href="https://github.com/elastic/competitive-benchmarking-studies/tree/main/es-9.4-vs-qd-1.18-vector-search">es-9.4-vs-qd-1.18-vector-search</a>. Jingra handled data loading, query execution, parameter sweeps, and metric collection for both Elasticsearch and Qdrant, making the benchmark repeatable and easier to compare.</p><p>To reproduce the experiment, you need the published dataset, query set, engine configurations, and comparable cluster hardware. With those in place, Jingra can rerun the benchmark and generate similar recall, latency, and throughput measurements shown in this post.</p><h2>Conclusion</h2><p>At comparable recall levels, Elasticsearch DiskBBQ consistently delivered faster vector search than Qdrant in this benchmark, with higher throughput and lower latency across the recall range we tested. These results are especially notable because the comparison was made on network-attached persistent storage, where efficient storage-aware vector search becomes critical. Elasticsearch as a vector database allows organizations to achieve high recall with lower latency and higher throughput on slower persistent storage.</p><p>Just as importantly, this benchmark highlights the value of comparing engines at matched recall rather than by nominal parameter settings. Elasticsearch and Qdrant expose different controls, so the fairest comparison isn’t parameter to parameter but outcome to outcome. Across the recall range tested here, Elasticsearch maintained a clear advantage in both latency and throughput.</p><p>If you want to reproduce the experiment yourself, we’re publishing the dataset and query set used in this benchmark so others can validate the results and build on them.</p><p>Further reading:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">Introducing a new vector storage format: DiskBBQ</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-bbq-osq-vs-turbo">Elasticsearch BBQ vs TurboQuant</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-search-benchmark-elasticsearch-vs-qdrant</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-search-benchmark-elasticsearch-vs-qdrant</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Sachin Frayne]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf58ffc7bd7f3c826/6a469777945073eeed30d267/0fa30e54796aeb49baaa760590fa6dd3ee863c2d-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[jina-clip-v2 brings text-to-image search across 89 languages to Elasticsearch, no GPU needed]]></title>
    <description><![CDATA[Run multimodal search across 89 languages inside Elasticsearch with jina-clip-v2: one embedding space for text and images, with no separate model infrastructure to manage.]]></description>
    <content:encoded><![CDATA[<p><a href="https://jina.ai/news/jina-clip-v2-multilingual-multimodal-embeddings-for-text-and-images/"><code>jina-clip-v2</code></a> (865M parameters) is now available on <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a>: multilingual multimodal embeddings for text and images across 89 languages, running inside Elasticsearch with no separate model hosting or GPU infrastructure to manage.</p><p>Text queries retrieve images, screenshots retrieve documentation, and PDFs, charts, and infographics index into the same vector space. The model supports Matryoshka truncation, so you can drop from 1,024 to 512 or 256 dimensions when storage matters, with minimal quality loss.</p><p><code>jina-clip-v2</code> is one of several Jina embedding models now available on EIS. For workloads that also span video and audio, <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"><code>jina-embeddings-v5-omni</code></a> covers all four modalities in a single index: nearly 100 languages and a 0.67B parameter base small enough to run on conventional GPU servers. <code>jina-clip-v2</code> remains the focused option for cross-modal retrieval between text and images.</p><h2>How multimodal search works in jina-clip-v2</h2><p><code>jina-clip-v2</code> is a dual-encoder model where separate text and image encoders produce embeddings in the same vector space. This allows text and images to be retrieved interchangeably. A query like “red sports car” can return matching images, an image can surface relevant product descriptions or documentation, and screenshots can map directly to tickets, dashboards, or logs. This isn’t a stitched pipeline of models. It’s a single, shared embedding space across modalities, combining a multilingual <a href="https://huggingface.co/jinaai/xlm-roberta-flash-implementation">Jina-XLM-RoBERTa</a> text encoder with an EVA02-L vision encoder.</p><h3>Multilingual and document-aware by design</h3><p>Unlike traditional CLIP models that focus primarily on short English captions, <code>jina-clip-v2</code> is trained on multilingual text-text and text-image pairs, across 89 languages, and on visually complex datasets at progressively higher resolutions.</p><p>EIS allows you to run managed models directly inside Elasticsearch. There’s no separate model hosting layer to provision, no GPU infrastructure to manage, and no external embedding service to maintain.</p><p>With <code>jina-clip-v2</code> on EIS, you can:</p><ul><li><p>Generate text and image embeddings where your data already lives.</p></li><li><p>Index multimodal vectors alongside structured and unstructured content.</p></li><li><p>Combine vector search with BM25 using hybrid retrieval.</p></li><li><p>Power multimodal retrieval augmented generation (RAG) pipelines grounded in images and documents.</p></li></ul><h3>How to run multimodal search with jina-clip-v2 on EIS</h3><p>The <code>jina-clip-v2</code> endpoint is preconfigured on Elastic Inference Service. To generate embeddings, call the inference endpoint from the Elasticsearch dev console:</p>POST _inference/embedding/.jina-clip-v2
{
 "input": [
     {
         "content": {
             "type": "image",
             "value": "data:image/jpeg;base64,..."
         }
     },
     {
         "content": {
             "type": "text",
             "value": "Some text to create an embedding"
         }
     }
 ]
}<p>This is the response:</p>{
 "embeddings": [
   {
     "embedding": [
       -0.0189209,
       ...
       0.05419922
     ]
   },
   {
     "embedding": [
       -0.01379395,
       ...
       0.0246582
     ]
   }
 ]
}<h3>Using jina-clip-v2 embeddings in a search query:</h3><h4>Get endpoint config</h4>GET /_inference/embedding/.jina-clip-v2<h4>Basic text request</h4>POST _inference/embedding/.jina-clip-v2
{
  "input": [
    "This is a test"
  ]
}<h4>Multimodal batch (text + image as separate vectors)</h4><p>The example below shows how to send both a text and an image input as separate items, each producing its own embedding:</p>POST _inference/embedding/.jina-clip-v2
{
  "input": [
    { "content": { "type": "text",  "value": "A small blue square" } },
    { "content": { "type": "image", "format": "base64", "value": "&lt;BASE64_IMAGE_DATA&gt;" } }
  ]
}<h4>Create custom endpoint with minimum dimensions</h4>PUT _inference/embedding/jina-clip-v2-64d
{
  "service": "elastic",
  "service_settings": {
    "model_id": "jina-clip-v2",
    "dimensions": 64
  }
}<h2>Multimodal search in Elasticsearch, from text to images to RAG</h2><p>By making <code>jina-clip-v2</code> available on EIS, multimodal search becomes a first-class capability inside Elasticsearch.</p><p>Text and images can be indexed into the same vector space. Queries can retrieve across modalities and languages. Hybrid search can combine lexical precision with multimodal semantics. RAG systems can ground responses in charts, screenshots, and document layouts, not just plain text.</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><p>
</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/multimodal-search-elasticsearch-jina-clip-v2</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multimodal-search-elasticsearch-jina-clip-v2</guid>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <dc:creator><![CDATA[Kapil Jadhav,Ranjana Devaji,Brendan Jugan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb6f656e5a5e8a29e/6a46839e41321c5992fc8348/c875531c44d7778e165c403221a9580d3739ccbf-1672x941.png" length="0" type="image/png"/>
    <pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your FAQ bot doesn't need a PhD: LLM query routing with Elastic Workflows]]></title>
    <description><![CDATA[Route LLM queries by complexity using Elasticsearch search metadata: Mistral Small for FAQ questions, Claude Sonnet for multi-source synthesis.]]></description>
    <content:encoded><![CDATA[<p>Sending every customer support query to a large model means your simple FAQ answers are as slow and as expensive as your most complex ones. This post shows how to build a two-model routing system in <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a>: <a href="https://mistral.ai/news/mistral-small-4/">Mistral Small</a> handles straightforward questions directly from a single FAQ article; <a href="https://www.anthropic.com/claude/sonnet">Claude Sonnet</a> synthesizes answers across multiple knowledge base sources when the query needs it. The routing decision is made from search metadata alone, keeping classification cheap and fast on every query.</p><h2>Prerequisites</h2><ul><li><p><a href="https://www.elastic.co/cloud">Elastic Cloud</a> deployment running Elasticsearch 9.3+ or <a href="https://cloud.elastic.co/registration">start a free trial</a></p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/workflows/get-started#workflows-prerequisites">Workflows enabled</a> (Advanced Settings)</p></li><li><p>Python 3.9+</p></li><li><p>A <a href="https://console.mistral.ai/">Mistral API key</a></p></li></ul><h2>How LLM query routing works in this system</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4665bd674708a1cb/6a3e41fab0216c28c26945d6/71973e55db974ef7eca637539c3c49859759d2a3-1280x720.png" alt="Flowchart diagram showing how customer queries are processed. It begins with “Customer Query” and then moves to “Search Knowledge Base (Elasticsearch)” and “Classify Query with ES context.” From there, two branches appear: one labeled “simple FAQ match” leading to “Direct answer from FAQ snippet (Mistral Small) Fast &amp; cheap,” and another labeled “complex (needs synthesis)” leading to “Synthesize from multiple articles (Claude Sonnet).” Both paths converge at “Final Response" /><p>We're going to build a two-stage system: a router that decides how to answer, and an answering model that produces the response.</p><p>The router looks at the query and the metadata of the top search hits, like scores, categories, and complexity labels. From that, it picks one of two strategies: Answer directly from the top FAQ article, or synthesize across multiple articles with citations. That decision can be made from structured signals alone, so a small, fast model handles it.</p><p>The answering step varies. A single-article answer is bounded work that a small model does well and returns quickly. A multisource synthesis with citations benefits from a more capable model, and the extra time is worth it. Matching each query to the model that fits keeps simple answers fast and complex answers good.</p><h3>Why use a small model for routing instead of the large model?</h3><p>Because the router runs on every query, including the simple ones. A slow router makes every answer slow, even the ones a small model could have produced in a fraction of the time.</p><p>The key design choice is that the routing step only sees metadata, not full documents. A query like "my OTG isn't heating evenly" only needs to know that the top hits are in the "Product Troubleshooting - Appliances" category with <code>issue_complexity: medium</code>, not the full conversation transcripts. This keeps the classification prompt tiny (a few hundred tokens) and cheap. The full article content is only loaded in the response step once.</p><h2>Set up AI connectors</h2><p>We use two AI connectors for the workflow:</p><p>Connector</p><p>Model</p><p>Type</p><p>Role</p><p>Mistral Small</p><p>mistral-small-latest</p><p>Custom (OpenAI-compatible)</p><p>Classify query complexity from metadata, answer simple FAQ-style questions</p><p>Anthropic Claude Sonnet 4.6</p><p>Claude Sonnet</p><p>Elastic Managed LLM</p><p>Synthesize complex answers from multiple articles, with citations</p><p>Both connectors are billed per million tokens, with the smaller model costing significantly less. Routing simple queries to it saves money on top of the latency win. To learn more about Elastic Managed LLM, see this <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/elastic-managed-llm">documentation</a>.</p><p>The Claude Sonnet connector is already available as an Elastic Managed large language model (LLM). We only need to create a custom connector for Mistral using the <code>.gen-ai</code> connector type, which supports any <a href="https://developers.openai.com/api/reference/overview">OpenAI-compatible API</a>. You can also <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/ai-connector#set-up-an-ai-connector">create it through the Kibana UI</a>.</p><p>All the setup code in this article is available in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/routing-queries-right-model-elasticsearch/notebook.ipynb">companion notebook</a>. You can run each section there as you follow along.</p>SMALL_LLM_CONNECTOR = "Mistral Small"

headers = {
    "Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}",
    "kbn-xsrf": "true",
    "Content-Type": "application/json",
}

mistral_connector_payload = {
    "connector_type_id": ".gen-ai",
    "name": SMALL_LLM_CONNECTOR,
    "config": {
        "apiProvider": "Other",
        "apiUrl": "https://api.mistral.ai/v1/chat/completions",
        "defaultModel": "mistral-small-latest",
    },
    "secrets": {
        "apiKey": MISTRAL_API_KEY,
    },
}

response = requests.post(
    f"{KIBANA_URL}/api/actions/connector",
    headers=headers,
    json=mistral_connector_payload,
)
result = response.json()
MISTRAL_CONNECTOR_ID = result.get("id")<p>The connector ID is auto-generated by Kibana. We let the platform handle this instead of trying to set it manually.</p><p>Once created, the connector appears in the Connectors UI:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt690e9245c0f19580/6a3e41fdb0216c2b556945dc/77179f792bb585dd060deac4ac9bc1c87ec1027a-1999x872.png" alt="Screenshot of a Connectors dashboard showing third‑party integrations for alerting data. The table lists AI connectors including Google Gemini 2.5 Pro, Google Gemini 3.0 Flash, Google Gemini 3.1 Pro (Preview), Mistral Small, and OpenAI GPT‑4.1. Each row displays type, compatibility, and authentication method. The focus is on the Mistral Small row." /><h2>Load and index the dataset</h2><p>We use the <a href="https://huggingface.co/datasets/rjac/e-commerce-customer-support-qa">e-commerce-customer-support-qa</a> dataset from Hugging Face. It contains 1,000 real customer support interactions from an ecommerce platform (BrownBox) with customer questions, agent solutions, issue categories, complexity levels, and customer sentiment.</p><p>The index mapping uses <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html"><code>semantic_text</code></a> with the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text"><code>.jina-embeddings-v5-text-small</code></a> model from <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a>. This field handles <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> end-to-end: <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">embedding generation</a>, <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">chunking</a>, and querying. We use <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> to aggregate the conversation and QA summary into a single searchable field:</p>es_client.indices.create(
    index="support-knowledge-base",
    mappings={
        "properties": {
            "conversation": {
                "type": "text",
                "copy_to": "semantic_content",
            },
            "qa": {
                "type": "text",
                "copy_to": "semantic_content",
            },
            "issue_area": {"type": "keyword"},
            "issue_category": {"type": "keyword"},
            "issue_complexity": {"type": "keyword"},
            "product_category": {"type": "keyword"},
            "semantic_content": {
                "type": "semantic_text",
                "inference_id": ".jina-embeddings-v5-text-small",
            },
        }
    },
)<h2>Defining the query routing workflow in Elastic Workflows YAML</h2><p>The routing workflow has four steps: semantic search, metadata-only classification, conditional branching, and a model-appropriate response step.</p><p>We use <a href="https://www.elastic.co/search-labs/blog/elastic-workflows-automation">Elastic Workflows</a> to encapsulate this routing logic. Workflows let us:</p><ol><li><p><strong>Expose the triaging as a tool</strong> in <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic Agent Builder</a>, so a conversational agent can call it.</p></li><li><p><strong>Trigger it directly</strong> via manual execution, schedules, or alerts.</p></li></ol><p>This flexibility means the same logic serves both programmatic and conversational interfaces without duplicating code.</p><p>Workflows are defined in YAML and configured directly in the Workflow UI (<strong>Elasticsearch &gt; Workflows &gt; Create a New Workflow</strong>). Each step can query Elasticsearch, call Kibana APIs, or prompt an LLM.</p><p>Here’s the complete workflow definition:</p>name: support_query_router
description: &gt;
  Routes customer queries to the appropriate LLM based on complexity.
  Searches the KB, classifies using only result metadata (cheap),
  then routes to a small or large model depending on complexity.
enabled: true

inputs:
  - name: query
    type: string
    description: The customer support query
    required: true

consts:
  indexName: support-knowledge-base

triggers:
  - type: manual

steps:
  # Step 1: Search the knowledge base using semantic search
  - name: search_es
    type: elasticsearch.search
    with:
      index: "{{ consts.indexName }}"
      query:
        semantic:
          field: semantic_content
          query: "{{ inputs.query }}"
      size: 5

  # Step 2: Classify using only METADATA (Mistral Small - cheap)
  # We deliberately do NOT pass the full documents here. The routing
  # decision only needs to know the shape of the results: which
  # categories they hit, their complexity labels, and their scores.
  - name: classify_query
    type: ai.prompt
    with:
      connectorId: Mistral Small
      prompt: &gt;
        You are a support query classifier. Based on the customer query
        and the metadata of the top knowledge base hits below, decide
        how this query should be handled.

        Return ONLY a JSON object with:
        - "complexity": "simple" if the top hit clearly matches a single
          FAQ (high score, low-complexity category, single product area),
          or "complex" if the query spans multiple categories, the top
          hits have medium/high complexity labels, or the results are
          weakly matched.
        - "reasoning": one-line explanation.

        Customer query: {{ inputs.query }}

        Top 5 results (metadata only):
        1. score={{ steps.search_es.output.hits.hits[0]._score }}, category={{ steps.search_es.output.hits.hits[0]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[0]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[0]._source.product_category }}
        2. score={{ steps.search_es.output.hits.hits[1]._score }}, category={{ steps.search_es.output.hits.hits[1]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[1]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[1]._source.product_category }}
        3. score={{ steps.search_es.output.hits.hits[2]._score }}, category={{ steps.search_es.output.hits.hits[2]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[2]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[2]._source.product_category }}
        4. score={{ steps.search_es.output.hits.hits[3]._score }}, category={{ steps.search_es.output.hits.hits[3]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[3]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[3]._source.product_category }}
        5. score={{ steps.search_es.output.hits.hits[4]._score }}, category={{ steps.search_es.output.hits.hits[4]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[4]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[4]._source.product_category }}

  # Step 3: Route based on complexity
  - name: route_by_complexity
    type: if
    condition: "${{ steps.classify_query.output.complexity == 'simple' }}"
    steps:
      # Simple: answer directly from FAQ snippet (Mistral Small)
      - name: answer_from_faq
        type: ai.prompt
        with:
          connectorId: Mistral Small
          prompt: &gt;
            You are a customer support agent. Answer the customer's question
            using ONLY the FAQ article below. Be concise, friendly, and
            include specific steps if applicable.

            Customer query: {{ inputs.query }}

            FAQ article:
            {{ steps.search_es.output.hits.hits[0]._source | json }}
    else:
      # Complex: synthesize from multiple articles (Claude Sonnet)
      - name: synthesize_answer
        type: ai.prompt
        with:
          connectorId: Anthropic Claude Sonnet 4.6
          prompt: &gt;
            You are a senior customer support specialist. The customer's query
            requires careful analysis across multiple knowledge base articles.

            Provide a detailed, empathetic response that:
            1. Addresses all aspects of the customer's question
            2. Cites specific articles from the knowledge base (reference them
               by their question/title)
            3. Provides clear resolution steps
            4. Notes if any part of the query isn't covered by the KB

            Customer query: {{ inputs.query }}

            Knowledge base articles:
            {{ steps.search_es.output.hits.hits | json }}<p>The workflow has four key parts:</p><p></p><ol><li><p><strong><code>search_es</code></strong> uses <code>elasticsearch.search</code> with a <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-semantic-query">semantic query</a> to find the five most relevant articles.</p></li><li><p><strong><code>classify_query</code></strong>sends the customer query plus <strong>only metadata</strong> from the search results to Mistral Small. The prompt includes scores, categories, complexity labels, and product categories. This keeps the classification step cheap, preventing the use of large amounts of tokens.</p></li><li><p><strong><code>route_by_complexity</code></strong> uses an <code>if</code> step to branch based on the classifier's output.</p></li><li><p><strong>The response step</strong> depends on the route. For simple queries, Mistral Small gets the top FAQ article and rephrases it. For complex queries, Claude Sonnet gets all five articles and synthesizes a detailed response with citations. This is the only step where full document content is loaded.</p></li></ol><h2>Using the workflow as a tool in Agent Builder</h2><p>Beyond the default triggers (manual, schedule, alerts), workflows can also be exposed as <strong>tools in </strong><a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder"><strong>Agent Builder</strong></a>. This adds a conversational layer where users interact through a chat interface, and the agent decides when to call the workflow.</p><p>We use the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/kibana-api">Agent Builder APIs</a> to create the tool and the agent. After creating the workflow in the Kibana UI, copy its ID and use it to register the workflow as a tool:</p>WORKFLOW_ID = "workflow-aaf77e41-37cf-48a8-973b-c853f71e4fae"

# Create the workflow tool
workflow_tool_payload = {
    "id": "run_support_query_router",
    "type": "workflow",
    "description": (
        "Routes a customer support query through the triage workflow. "
        "Searches the knowledge base, classifies query complexity, and "
        "generates a response using the appropriate model. Use this tool "
        "whenever a customer asks a support question."
    ),
    "tags": ["support", "triage", "workflow"],
    "configuration": {
        "workflow_id": WORKFLOW_ID,
    },
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/tools",
    headers=headers,
    json=workflow_tool_payload,
)<p>Then create an agent that uses the tool:</p>agent_payload = {
    "id": "support-query-agent",
    "name": "Support Query Agent",
    "description": "Customer support agent that routes queries through a multi-model workflow.",
    "labels": ["support", "e-commerce"],
    "configuration": {
        "instructions": (
            "You are a customer support assistant for BrownBox, an e-commerce platform. "
            "When a customer asks a support question, use the `run_support_query_router` tool "
            "to process it. The tool will search the knowledge base, classify the query, "
            "and generate an appropriate response.\n\n"
            "Present the response to the customer in a friendly, professional tone."
        ),
        "tools": [{"tool_ids": ["run_support_query_router"]}],
    },
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/agents",
    headers=headers,
    json=agent_payload,
)<p>The agent is now available in the <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Agent Builder</a> UI in Kibana. You can also create the agent and its tools directly through the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-agents#custom-agents">Agent Builder UI</a>.</p><p>Once created, the agent appears in the Agent Builder UI with the workflow tool assigned:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec33d305e3d75727/6a3e4200517ae220fcf2fbfa/cc1b7c60969568bfe96d12ecc0c7174869144a75-1999x961.png" alt="" /><h2>Testing simple vs. complex query routing</h2><h3>Simple query</h3>"How do I track my order?"<p>The workflow searches the knowledge base, finds a direct match in the FAQ articles about order tracking, classifies it as <strong>simple</strong>, and routes to Mistral Small. The response is concise and drawn directly from the matched article: instructions for using the "My Orders" section or the tracking number from the confirmation email.</p><h3>Complex query</h3>"I ordered an OTG last week and it arrived damaged. I also noticed I was
charged twice on my credit card. I want a replacement for the OTG and a
refund for the duplicate charge. Also, my account shows the wrong delivery
address - can you update it?"<p>This query involves three distinct issues (damaged product, duplicate charge, address update) across different support categories. The workflow classifies it as <strong>complex</strong> and routes to Claude Sonnet, which synthesizes information from multiple knowledge base articles, addresses each issue separately, cites the relevant articles, and provides clear resolution steps for each.</p><h2>Conclusion</h2><p>Routing LLM queries by complexity in Elasticsearch reduces latency and cost for simple queries without sacrificing quality on complex ones. The small model answers FAQ-style queries in a fraction of the time the larger model would take, and the larger model is reserved for the queries that actually benefit from its capabilities. Cost savings come along for the ride: Simple queries routed to the smaller model are cheaper, too.</p><p>The pattern that makes this work is searching the knowledge base before routing. Without that context, the router is guessing based on surface-level cues. With it, the structure of the search results, like scores, categories, and complexity labels, tells the router whether the answer lives in a single article or needs synthesis across several. That's the actual signal for how to handle the query.</p><p>Elastic Workflows makes this possible without writing orchestration code. The entire routing logic lives in YAML inside Kibana, using native steps for search, LLM prompts, and conditional branching. Combined with Agent Builder, the same workflow serves programmatic triggers and conversational interfaces.</p><h2>Next steps</h2><ul><li><p>Try the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/routing-queries-right-model-elasticsearch/notebook.ipynb">notebook</a> with the complete implementation.</p></li><li><p>Add <a href="https://www.elastic.co/search-labs/blog/llm-monitoring-openrouter-agent-builder">LLM monitoring with OpenRouter</a> to track cost per routing tier.</p></li><li><p>Explore <a href="https://www.elastic.co/search-labs/blog/elastic-workflows-automation">Elastic Workflows</a> for other automation patterns.</p></li><li><p>Learn more about <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Agent Builder</a> and how to expose workflows as conversational tools.</p></li><li><p>Read about <a href="https://www.elastic.co/search-labs/blog/ai-agentic-workflows-elastic-ai-agent-builder">building AI agentic workflows</a> with Elastic Agent Builder.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/llm-query-routing-elastic-workflows</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/llm-query-routing-elastic-workflows</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt469309ae05518d1c/6a3e4203d473dd14db0cb146/5a9cb32bda53bcb51b45e0bcf8a64ac184d45588-1672x941.png" length="0" type="image/png"/>
    <pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[137,000 people, zero human decisions: agentic disaster response with Elasticsearch]]></title>
    <description><![CDATA[Find out how a Kibana detection rule, a workflow and an AI agent automatically relocated 137,000 military personnel across seven installations when a hurricane hit, no dispatcher required.]]></description>
    <content:encoded><![CDATA[<p>Elastic just coordinated the automated evacuation of 137,000 military personnel across seven installations, with no human in the loop. A Category 4 hurricane hits the Hampton Roads coastline. Elasticsearch's geospatial enrichment identifies every facility in the impact zone at index time. A Kibana detection rule fires. A workflow starts an AI agent conversation. The agent reasons through capacity, distance and branch compatibility, then dispatches 16 evacuation and intake notifications in a single pass. From raw GDACS event to coordinated action, automatically.</p><p>Every year, natural disasters force emergency managers, military commanders, and public safety officials to make high-stakes decisions in compressed time frames. These decisions traditionally rely on phone trees, spreadsheets, and institutional knowledge spread across dozens of people. The coordination overhead alone costs critical time.</p><p>This post demonstrates how Elastic can power a responsive, agentic coordination system for disaster response that detects a threat, reasons through the logistics, and takes action automatically. To make it concrete, we built a simulation: a fictitious Category 4 hurricane threatening the Hampton Roads coastline triggers the automated relocation of over 137,000 personnel across seven military installations.</p><p><strong>Disclaimer:</strong> <strong>This is an entirely fictitious scenario built for demonstration purposes. </strong>Hurricane ELARA-26 doesn’t exist. Installation locations are based on real, publicly available geographic data (the U.S. Department of Defense [DoD] Military Installations, Ranges, and Training Areas [MIRTA] dataset), but all operational data, like personnel counts, housing capacity, assets, contact emails, and mission profiles, are completely fabricated. Nothing in this demo reflects actual military readiness, capability, or operational procedures.</p><h2>Why automated disaster response requires geospatial and agentic coordination</h2><p>When a natural disaster threatens critical infrastructure, the coordination challenge is immediate:</p><ul><li><p>Which facilities are in the impact zone?</p></li><li><p>How many personnel need to move?</p></li><li><p>Where can they go, and do those facilities have capacity?</p></li><li><p>Who needs to be notified right now?</p></li></ul><p>These questions don't wait. Neither should the answers.</p><h2>Deploy the pipeline: prerequisites and setup</h2><p>Follow the instructions <a href="https://github.com/tehbooom/elastic_natural_disaster/blob/main/README.md">here in the example repo</a> to deploy a local Elastic cluster with Elastic Inference Service (EIS) via <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/connect-self-managed-cluster-to-eis#set-up-eis-with-cloud-connect">Cloud Connect</a>.</p><h2>How the Elasticsearch agentic disaster response pipeline works</h2><p>The pipeline has seven layers that work together end to end:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf09bfae87ab35bec/6a4693ef31bdbbe3ef8b33ae/61814cddea0409162fb057c2113e0a496c105238-1999x275.png" alt="Pipeline flowchart Alt text: Horizontal flowchart with seven labeled boxes connected by arrows: GDACS feed, ingest pipeline, enrich (geo_shape), detection rule, workflow, AI agent, and email." /><ol><li><p><strong>Data ingestion:</strong> Global Disaster Alert and Coordination System (GDACS) disaster events sent to Elasticsearch</p></li><li><p>I<strong>ngest pipeline</strong>: GeoJSON is ingested and normalized to Elastic Common Schema (ECS).</p></li><li><p><strong>Geospatial enrichment:</strong> The event's affected area polygon is matched against indexed military installation boundaries.</p></li><li><p><strong>Alerting:</strong> A Kibana detection rule fires when a disaster intersects any installation.</p></li><li><p><strong>Workflow automation:</strong> The alert triggers a Kibana workflow that starts an AI agent conversation.</p></li><li><p><strong>AI reasoning:</strong> The agent reasons through affected facilities, their assets, and nearest supporting facilities to determine relocation of all assets and personnel.</p></li><li><p><strong>Email notifications</strong>: The agent dispatches emails to all recipients for incoming and outgoing personnel and or assets.</p></li></ol><p>Let's walk through each layer.</p><h2>Step 1: Indexing military installations with geo boundaries</h2><p>The foundation is the DoD MIRTA dataset from <a href="https://source.coop/seerai/hifld/military-installations-ranges-and-training-areas-mirta-dod-sites---boundaries">source.coop/seerai/hifld</a>. This dataset provides a <code>geo_shape</code> of type <code>Point</code> for each installation; centroid coordinates rather than full boundary polygons.</p><p>Each installation document in the <code>mitra-facilities</code> index is enriched with operational profile data (all fictitious), beyond what MIRTA provides:</p>{
  "entity_name": "Naval Station Norfolk",
  "branch_of_service": "Navy",
  "mission_function_type": "fleet_support",
  "personnel_count": 50000,
  "housing_capacity": 55000,
  "temporary_housing_capacity": 10000,
  "logistics_capabilities": ["fuel", "airlift", "sealift", "medical"],
  "available_assets": [
    { "type": "helicopters", "count": 24 },
    { "type": "transport_vehicles", "count": 150 }
  ],
  "contact_email": "norfolk.ops@navy.mil.gov.fake",
  "operational_status": "act",
  "is_joint_base": false,
  "entity_geo_location": { "type": "polygon", "coordinates": [...] }
}<p>This rich index is what enables the AI agent to make intelligent allocation decisions; not just "here are nearby bases," but "here are bases with available housing capacity, compatible mission types, and the logistics to receive incoming assets."</p><h2>Step 2: Ingesting and normalizing GDACS events</h2><p>GDACS publishes real-time GeoJSON for earthquakes, tropical cyclones, floods, wildfires, volcanoes, and droughts. We ingest this feed into a data stream (<code>logs-gdacs.events-*</code>) using a custom ingest pipeline that normalizes the raw GeoJSON to ECS fields.</p><p>The GDACS ingest pipeline does several things worth noting:</p><p><strong>Geometry extraction:</strong> The centroid is stored as a <code>geo_point</code> for map display, and the impact polygon is stored as a <code>geo_shape</code> in <code>gdacs.affected_area</code>, which is the field used for intersection queries later.</p><p><strong>Severity normalization:</strong> Each disaster type has a different severity scale. A tropical cyclone is measured in km/h wind speed; an earthquake in Richter magnitude. The pipeline maps all of them to a normalized 0–100 score:</p>// Painless snippet from the ingest pipeline
if (type == 'TC') {
  norm = Math.min(100.0, Math.max(0.0, (val - 40.0) / 2.6));
} else if (type == 'EQ') {
  norm = Math.min(100.0, Math.max(0.0, (val - 4.0) * 20.0));
}<p>The normalized severity score then maps to a <code>severity_level</code> label (<code>low</code>, <code>medium</code>, <code>high</code>, <code>critical</code>) used for alert severity mapping in the detection rule.</p><p><strong>ECS alignment:</strong> <code>event.kind: alert, event.category: threat</code>, timestamps mapped to <code>event.start/event.end</code>, and a stable fingerprint-based <code>_id</code> for deduplication.</p><h2>Step 3: Geospatial enrichment: Finding affected facilities at index time</h2><p>Elasticsearch's geo_match enrich policy matches the disaster polygon against every installation boundary at index time, with no query-time join required. Instead of querying at search time, we use an <strong>enrich processor</strong> in the ingest pipeline to match the disaster's impact polygon against every installation boundary <em>as the document is indexed</em>.</p><p>The enrich policy is a <code>geo_match</code> policy:</p>{
  "geo_match": {
    "indices": "mitra-facilities",
    "match_field": "entity_geo_location",
    "enrich_fields": [
      "entity_name",
      "entity_type",
      "entity_station_number",
      "entity_geo_city_name",
      "entity_geo_region_name"
    ]
  }
}<p>The processor runs at the end of the ingest pipeline:</p>{
  "enrich": {
    "policy_name": "facilities-geo",
    "field": "gdacs.affected_area",
    "target_field": "affected_facilities",
    "shape_relation": "INTERSECTS",
    "max_matches": 128
  }
}<p><code>INTERSECTS</code> catches any installation whose boundary touches or overlaps the disaster polygon and even partial intersections. The result is that every GDACS event document is stored with an <code>affected_facilities</code> nested array that tells us exactly which installations are in the impact zone. No join query needed.</p><h2>Step 4: Detection rule: Alerting on facility impact</h2><p>A Kibana detection rule watches the <code>logs-gdacs.events-*</code> data stream and fires when a GDACS event has been enriched with at least one affected facility:</p>Query: affected_facilities: { entity_name: * }<p>The rule runs on an hourly schedule (covering a <code>now-1h</code> to <code>now</code> window) and uses dynamic severity mapping; the <code>gdacs.severity_level</code> field computed by the ingest pipeline drives the alert severity automatically.</p><p>Alert severity also drives the risk score via field mapping:</p>"risk_score_mapping": [
  {
    "field": "gdacs.normalized_severity",
    "operator": "equals",
    "value": ""
  }
]<p>When the rule fires, it passes the full alert context, including the enriched <code>affected_facilities</code> array with installation names, types, and locations, downstream to a Kibana workflow.</p><h2>Step 5: Workflow automation: Bridging alert to agent</h2><p>Kibana Workflows handle the handoff from detection to response. The natural disaster response workflow is triggered by the alert:</p>triggers:
  - type: alert
steps:
  - name: start_convo
    type: kibana.request
    with:
      method: "POST"
      path: "/api/agent_builder/converse"
      body:
        agent_id: "mitra.response"
        input: "New Natural Disaster Alert: {{ event.alerts | json }}"<p>The entire alert payload (disaster type, severity, affected area, and the list of impacted installations) is forwarded to the AI agent as its initial context. The agent takes it from there.</p><h2>Step 6: The AI agent: From data to coordinated action</h2><p>The <code>mitra.response</code> agent takes the full alert payload and, in a single agentic loop, assesses scope, finds receiving facilities, allocates personnel and dispatches evacuation and intake notifications, all without human intervention.</p><p>The agent has two tools available:</p><ul><li><p><strong><code>mitra.nearest_facility</code></strong>queries the <code>mitra-facilities</code> index using a geo_shape query, sorted by distance from a given coordinate, returning up to 50 nearby active installations with available capacity.</p></li><li><p><strong><code>mitra.send_email</code></strong> iterates over a JSON array of facility objects and dispatches formatted evacuation or receiving notifications.</p></li></ul><p>The agent's instruction set defines a clear workflow:</p><ol><li><p><strong>Assess the situation.</strong> Parse the alert, identify affected facilities, and determine disaster scope.</p></li><li><p><strong>Inventory what needs to move.</strong> Personnel counts, critical assets, housing requirements per facility.</p></li><li><p><strong>Find destination facilities.</strong> Call <code>mitra.nearest_facility</code> for each affected installation, filtering out facilities still in the danger zone.</p></li><li><p><strong>Make allocation decisions.</strong> Reason through single versus multi-facility solutions, branch compatibility, housing capacity, asset support.</p></li><li><p><strong>Send coordination emails.</strong> Dispatch evacuation orders to source facilities and intake notifications to receiving facilities.</p></li><li><p><strong>Produce a summary report. </strong>Produces a short summary of all affected facilities, total personnel, assets moved, destination facilities, and any concerns to the chat for review.</p></li></ol><p>The agent's allocation logic follows real-world constraints: Don't exceed housing capacity, prefer same-branch relocations when possible, use joint bases for multi-branch overflow, and prioritize distance to minimize transit time.</p><h3>The nearest facility tool</h3><p>The underlying workflow query uses <code>geo_shape</code> with a circle filter and <code>_geo_distance</code> sorting:</p>"query": {
  "bool": {
    "filter": [
      {
        "geo_shape": {
          "entity_geo_location": {
            "shape": {
              "type": "circle",
              "coordinates": [{{ inputs.lon }}, {{ inputs.lat }}],
              "radius": "5000km"
            },
            "relation": "intersects"
          }
        }
      },
      { "term": { "operational_status.keyword": "act" } }
    ]
  }
},
"sort": [
  {
    "_geo_distance": {
      "entity_geo_point": { "lat": {{ inputs.lat }}, "lon": {{ inputs.lon }} },
      "order": "asc",
      "unit": "km"
    }
  }
],
"script_fields": {
  "available_capacity": {
    "script": {
      "source": "Math.max(0, doc['housing_capacity'].value - doc['personnel_count'].value)"
    }
  }
}<p>Available capacity is computed at query time via a script field which calculates housing capacity minus current personnel count. The agent uses this to allocate personnel across destinations without exceeding limits.</p><h2>Hurricane ELARA-26: agentic coordination of 137,000 personnel, end to end</h2><p>Hurricane ELARA-26 is a Category 4 storm (213 km/h maximum winds) projected to make landfall in the Hampton Roads area of Virginia. When the GDACS event is ingested, the affected area polygon intersects seven major military installations in the region. The detection rule fires. The workflow kicks off an agent conversation.</p><p>Within a single agentic loop, the agent:</p><ul><li><p>Identified seven facilities in the impact zone, with a combined 137,372 personnel.</p></li><li><p>Called <code>mitra.nearest_facility</code> to find receiving facilities outside the storm track.</p></li><li><p>Distributed personnel across nine receiving facilities based on available housing capacity and distance.</p></li><li><p>Generated and dispatched evacuation orders to all seven affected installations.</p></li><li><p>Generated and dispatched intake notifications to all nine receiving facilities.</p></li><li><p>Produced a full coordination summary, similar to below:</p></li></ul><p><strong>Facilities evacuated:</strong></p><p>Facility</p><p>Personnel</p><p>Naval Station Norfolk</p><p>50,000</p><p>Joint Expeditionary Base Little Creek-Fort Story</p><p>18,000</p><p>Naval Air Station Oceana</p><p>15,355</p><p>Naval Air Station Oceana Dam Neck Annex</p><p>17,509</p><p>NG State Military Reservation Camp Pendleton</p><p>9,707</p><p>Joint Base Langley-Eustis</p><p>15,000</p><p>Naval Weapons Station Yorktown</p><p>11,801</p><p><strong>Receiving facilities:</strong></p><p>Facility</p><p>Distance</p><p>Incoming personnel</p><p>Fort Gregg-Adams</p><p>97 km</p><p>~40,000</p><p>Marine Corps Base Quantico</p><p>148 km</p><p>~30,000</p><p>Naval Support Facility Indian Head</p><p>151 km</p><p>~30,000</p><p>Joint Base Andrews</p><p>180 km</p><p>~30,000</p><p>Naval Air Station Patuxent River</p><p>141 km</p><p>~10,000</p><p>NG MTA Camp Butner</p><p>174 km</p><p>~5,000</p><p>NG Bethany Beach Training Site</p><p>209 km</p><p>~4,707</p><p>Rivanna Station</p><p>140 km</p><p>~7,500</p><p>Def Gen Supply Center</p><p>22 km</p><p>~6,000</p><p>Assets relocated include transport vehicles, helicopters, patrol boats, medical units, engineering vehicles, generators, water trailers, shelter kits, and communication systems.</p><h3>Automated email notifications</h3><p>Once the agent finalized its allocation plan, it called <code>mitra.send_email</code> and dispatched 16 emails in a single pass; that is, evacuation orders to all seven affected installations and intake notifications to all nine receiving facilities. Each message included destination facility, incoming personnel count, assets to move, and a coordination contact. What would have taken hours of phone trees completed automatically the moment the agent finished reasoning.</p><h3>Extending agentic disaster response with RAG and policy grounding</h3><p>This demo is purely from structured data, like capacity numbers, distances, and operational status. Elastic's semantic search and retrieval augmented generation (RAG) capabilities can make the agent significantly smarter, with two additions:</p><p><strong>Historical response retrieval:</strong> Index past after-action reports, Federal Emergency Management Agency (FEMA) incident summaries, and disaster response records as vector embeddings. When a new event fires, the agent can semantically retrieve how similar events were handled, informing allocation decisions with institutional knowledge rather than capacity math alone.</p><p><strong>Policy and doctrine grounding:</strong> Index DoD emergency management directives, installation continuity of operations plans, and commander guidance. The agent can retrieve and cite the actual policies governing a response, ensuring every decision is grounded in doctrine rather than inference.</p><p>Both follow the same Elastic-native approach:; An inference pipeline generates embeddings at index time, and a semantic search tool is exposed to the agent. The coordination pipeline stays the same. The agent just gets smarter.</p><h2>Why Elasticsearch is the right platform for agentic public sector response</h2><p>This isn’t a chatbot. It isn’t a dashboard. It's a responsive agentic workflow system, one that detected a threat, reasoned through a complex logistics problem, and coordinated the relocation of 137,000 people without a human in the loop. That kind of outcome is only possible because every capability it depends on lives in a single, unified platform.</p><p>Elasticsearch's geospatial support (<code>geo_point</code>, <code>geo_shape</code>, enrichment policies, and distance-based sorting) handles the spatial reasoning that makes intersection detection and facility lookup possible at scale. Semantic search and vector embeddings ground agents in truth, ensuring AI reasoning is based on what's actually in your data rather than hallucinated assumptions. Kibana's detection engine, Workflows, Agent Builder, and Agent Builder tools wire it all together into a pipeline that goes from raw event to coordinated action with no external glue code required.</p><p>No other platform brings this together the way Elastic does. The combination of real-time indexing, geospatial precision, semantic retrieval, and agentic orchestration, all in one stack, with enterprise-grade security and observability built in, is what separates Elastic from tools that do one of these things well but require you to stitch the rest together yourself.</p><h2>Agentic geospatial response for emergency management, fire, law enforcement and public health</h2><p>The same architecture applies wherever people, facilities, and real-time events intersect. The specific data changes. The pipeline doesn't.</p><p><strong>Emergency management:</strong> FEMA and state offices of emergency management can map shelter locations, staging areas, and vulnerable populations against incoming National Weather Service (NWS) severe weather polygons, triggering automated resource pre-positioning before a storm makes landfall.</p><p><strong>Fire and emergency medical services:</strong> Fire departments can overlay unit locations and response zones against wildfire perimeters or structure fire clusters, automatically routing mutual aid requests to the nearest available units with the right equipment.</p><p><strong>Law enforcement:</strong> Agencies can correlate active incident locations with school zones, critical infrastructure, and officer positions, triggering geo-aware lockdown notifications or resource dispatch without waiting for manual triage.</p><p><strong>Public school safety:</strong> School districts can monitor real-time threat feeds against campus boundaries. When a threat intersects a school's perimeter, an agent can immediately notify administration, initiate lockdown communications, and coordinate law enforcement response, all before a dispatcher picks up a phone.</p><p><strong>Public health:</strong> Health departments can match disease surveillance data or environmental hazard zones against clinic locations, population density layers, and supply depot inventories to route resources where they're needed most.</p><p>Sector</p><p>Use case</p><p>Elastic capability</p><p>Emergency management</p><p>Match shelter locations against NWS severe weather polygons</p><p>geo_shape enrichment + Kibana Workflows</p><p>Fire and EMS</p><p>Overlay unit locations against wildfire perimeters</p><p>geospatial routing + nearest-facility query</p><p>Law enforcement</p><p>Correlate incidents with school zones and officer positions</p><p>geo-aware alert rules + agent dispatch</p><p>Public school safety</p><p>Monitor threat feeds against campus perimeters</p><p>detection rules + automated notification</p><p>Public health</p><p>Match hazard zones against clinic locations and supply depots</p><p>semantic search + geospatial enrichment</p><p>The data is different in every scenario. The underlying pattern of ingest, enrich at index time, detect intersection, trigger agentic response, and act is all the same. Elastic gives public sector organizations the platform to build it once and adapt it everywhere.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-agentic-disaster-response</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-agentic-disaster-response</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Kibana]]></category>
    <dc:creator><![CDATA[Alec Carpenter]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt969cad2694920de4/6a4693f37746672ad42675b5/cb292a501835472598dee30bef25c77afc54db6c-720x420.png" length="0" type="image/png"/>
    <pubDate>Thu, 04 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[6 resources, 1 command: fully automated Elastic anomaly detection with Terraform]]></title>
    <description><![CDATA[Build and manage Elastic anomaly detection jobs entirely in Terraform (job config, datafeed, lifecycle state and environment promotion) with a modular, ready-to-clone example.]]></description>
    <content:encoded><![CDATA[<p>Anomaly detection jobs created by hand don't version, don't review and don't promote cleanly across environments. This post shows how to manage the full AD lifecycle (job, datafeed and operational state) as Terraform code. Six resources, one terraform apply, and your job is running. One variable change promotes it from dev to production. terraform destroy tears it all down in the correct order.</p><p>The complete, ready-to-clone code is available at <a href="https://github.com/elastic/terraform-ad-example/">github.com/elastic/terraform-ad-example</a>.</p><h2><strong>Prerequisites</strong></h2><ul><li><p>An Elastic Cloud account with an organization-level API key. See<a href="https://www.elastic.co/docs/reference/cloud/cloud-hosted/ec-regions-templates-instances"> Elastic Cloud regions, deployment templates, and instances</a> for available regions and templates.</p></li><li><p>Terraform installed (&gt;= 1.0.0). The Elastic Stack provider version 0.14.0 or later is required for anomaly detection (AD) job and datafeed resource support. See <a href="https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli#install-terraform">Install Terraform</a>.</p></li><li><p>A terminal opened in a directory containing the clone of the git repo <a href="https://github.com/elastic/terraform-ad-example/"><strong>github.com/elastic/terraform-ad-example</strong></a>.</p></li><li><p>A valid index suitable for AD jobs (that is, with a timestamp field) should exist in the Elasticsearch cluster once it’s deployed. In this example, the index is <code>filebeat-nginx-elasticco-full</code>.</p></li></ul><h2><strong>Terraform project structure for anomaly detection</strong></h2><p>A modular layout means that each resource has its own module, so job configs, datafeeds and state controllers can be shared and reused across teams independently.</p>.
├── main.tf                        # Root: providers, variables, module calls
└── modules/
    ├── job/
    │   ├── main.tf                # AD job resource
    │   ├── variables.tf           # Job parameters
    │   └── outputs.tf             # Exports job_id
    ├── datafeed/
    │   ├── main.tf                # Datafeed resource
    │   ├── variables.tf           # Datafeed parameters
    │   └── outputs.tf             # Exports datafeed_id
    ├── job_state/
    │   ├── main.tf                # Job state resource (open / close)
    │   ├── variables.tf           # State parameters
    │   └── outputs.tf             # Exports state
    └── datafeed_state/
        ├── main.tf                # Datafeed state resource (start / stop)
        ├── variables.tf           # State parameters
        └── outputs.tf             # Exports state<p>Outputs are key: They allow modules to be chained so that the datafeed automatically receives the <code>job_id</code> from the job module, and Terraform derives the correct creation and destruction order from this dependency graph.</p><h3>Why separate state from config in Terraform ML jobs?</h3><p>Note how the <strong>state modules are separate from the configuration modules</strong>. This reflects a real operational pattern in machine learning (ML): You’ll frequently need to stop a datafeed (for example, to reindex data) or close a job (for example, to reset a model after a pipeline incident) without changing the job's configuration at all. Keeping them separate means operational actions don't create noisy diffs in your config resources.</p><h2><strong>Configuring the Elastic Cloud deployment in Terraform</strong></h2><h3><strong>Providers and deployment</strong></h3><p>We use two providers: <code>elastic/ec</code> to provision the Elastic Cloud deployment; and <code>elastic/elasticstack</code> to manage the ML resources within it. The <code>elasticstack</code> provider's connection details are derived directly from the <code>ec_deployment</code> resource, so credentials are never hard-coded:</p>terraform {
  required_version = "&gt;= 1.0.0"

  required_providers {
    ec = {
      source  = "elastic/ec"
      version = "~&gt; 0.9"
    }
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~&gt; 0.14.3"
    }
  }
}

variable "ec_api_key" {
  type        = string
  description = "Elastic Cloud API key (account-level)."
}

variable "ec_region" {
  type        = string
  default     = "us-east-1"
  description = "Elastic Cloud region (e.g. us-east-1, gcp-us-central1)."
}

variable "deployment_template_id" {
  type        = string
  default     = "aws-cpu-optimized-faster-warm-arm"
  description = "Elastic Cloud deployment template ID."
}

variable "job_id" {
  description = "The ID of the anomaly detection job."
  type        = string
  default     = "nginx"
}

variable "datafeed_id" {
  description = "The ID of the datafeed."
  type        = string
  default     = "datafeed-nginx"
}

variable "indices" {
  description = "A list of indices for the datafeed (may include wildcards)."
  type        = list(string)
  default     = ["filebeat-nginx-elasticco-full"]
}<p>The deployment itself provisions Elasticsearch (with a dedicated ML node) and Kibana:</p>provider "ec" {
  apikey = var.ec_api_key
}

data "ec_stack" "latest" {
  version_regex = "latest"
  region        = var.ec_region
}

resource "ec_deployment" "demo" {
  name                   = "ml_terraform_example"
  region                 = var.ec_region
  version                = data.ec_stack.latest.version
  deployment_template_id = var.deployment_template_id

  elasticsearch = {
    hot = {
      autoscaling = {}
    }
    ml = {
      size          = "1g"
      size_resource = "memory"
      zone_count    = 1
      autoscaling   = {}
    }
  }

  kibana = {
    topology = {}
  }
}

provider "elasticstack" {
  elasticsearch {
    username  = ec_deployment.demo.elasticsearch_username
    password  = ec_deployment.demo.elasticsearch_password
    endpoints = [ec_deployment.demo.elasticsearch.https_endpoint]
  }

  kibana {
    endpoints = [ec_deployment.demo.kibana.https_endpoint]
  }
}<p>The <code>ml</code> block within <code>elasticsearch</code> is essential; it provisions a dedicated ML node. Without it, ML jobs cannot be opened. Here we allocate 1 GB of memory in a single availability zone, which is sufficient for this example. Depending on the characteristics of your AD job and your data, you may need to size your ML node differently.</p><p>Because the <code>elasticstack</code> provider references <code>ec_deployment.demo</code> directly, Terraform understands the dependency: It will provision the deployment first and then use the resulting credentials and endpoints automatically.</p><p><strong>Wiring the modules together</strong></p>module "job" {
  source = "./modules/job"
  job_id = var.job_id
}

module "datafeed" {
  source      = "./modules/datafeed"
  datafeed_id = var.datafeed_id
  job_id      = module.job.job_id
  indices     = var.indices
}

module "job_state" {
  source = "./modules/job_state"
  job_id = module.job.job_id
  state  = "closed"
}

module "datafeed_state" {
  source      = "./modules/datafeed_state"
  datafeed_id = module.datafeed.datafeed_id
  state       = "stopped"

  depends_on = [module.job_state]
}<p>The output references (<code>module.job.job_id</code>, <code>module.datafeed.datafeed_id</code>) create an implicit dependency graph: Terraform will always create the job before the datafeed, and the datafeed before its state resource. On destroy, the order is automatically reversed.</p><p>In the diagram below, solid arrows represent the <strong>implicit dependency graph</strong> created when one module’s outputs feed into another’s inputs. In contrast, the dotted arrow between <code>job_state</code> and <code>datafeed_state</code> denotes the explicit <code>depends_on</code> defined in the root <code>main.tf</code>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e7a48a93410f8ac/6a467b2bd8b87a37c9ac40b8/8d098e10becad172ef1fa1643737852379d8c71a-713x467.png" alt="Diagram titled “Module dependency graph (create order top → bottom)” showing dependencies among four modules." /><p>The explicit <code>depends_on</code> on <code>module.datafeed_state</code> deserves explanation: There's no data flow between the datafeed state and job state modules, but the Elasticsearch API requires the job to be open before a datafeed can start. Without this dependency, Terraform would attempt both in parallel, which would fail.</p><p>We start with the job <code>"closed"</code> and the datafeed <code>"stopped"</code>. We'll open and start them in later steps to demonstrate lifecycle management.</p><h2><strong>Module: Anomaly detection job</strong></h2><p>The job module defines the AD job configuration. Here's the resource itself (<code>modules/job/main.tf</code>):</p>resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" {
  job_id          = var.job_id
  description     = "Anomaly detection for network traffic"
  custom_settings = jsonencode(var.custom_settings)

  analysis_config = {
    bucket_span = "15m"
    detectors = [
      {
        function             = "count"
        detector_description = "count"
      },
      {
        function             = "mean"
        field_name           = "nginx.access.body_sent.bytes"
        detector_description = "mean(\"nginx.access.body_sent.bytes\")"
      }
    ]
    influencers        = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"]
    model_prune_window = "30d"
  }

  analysis_limits = {
    model_memory_limit            = var.analysis_limits.model_memory_limit
    categorization_examples_limit = var.analysis_limits.categorization_examples_limit
  }

  data_description = {
    time_field  = "@timestamp"
    time_format = "epoch_ms"
  }

  model_snapshot_retention_days             = var.model_snapshot_retention_days
  daily_model_snapshot_retention_after_days = var.daily_model_snapshot_retention_after_days
}<p>There are a few things worth noting regarding module reuse:</p><ul><li><p>The Elasticsearch job API stores arbitrary metadata in a JSON object called custom_settings. In this module, that object is whatever you pass in as the Terraform variable <code>custom_settings</code>: The resource sets <code>custom_settings = jsonencode(var.custom_settings)</code>, so the cluster receives the JSON encoding of that map. The default value that is defined in <code>variables.tf</code> is therefore exactly the default metadata (<code>created_by = "terraform" and department = "ITOps</code>) unless a caller overrides <code>custom_settings</code> when invoking the module (for example, to record ownership when importing a legacy job that was created outside Terraform).</p></li><li><p>The same pattern applies to the other tunables: <code>analysis_limits</code>, <code>model_snapshot_retention_days</code>, and <code>daily_model_snapshot_retention_after_days</code> are variables with defaults so the module works out of the box, while teams can override them at the call site (for instance, raising <code>model_memory_limit</code> for a higher-cardinality job).</p></li></ul><p>Variable</p><p>Default</p><p>Purpose</p><p>custom_settings</p><p>created_by = "terraform"</p><p>Arbitrary job metadata; override to record ownership</p><p>analysis_limits.model_memory_limit</p><p>(see variables.tf)</p><p>Tune up for higher-cardinality jobs</p><p>model_snapshot_retention_days</p><p>(see variables.tf)</p><p>Retention period for model snapshots</p><p>The full variable definitions and outputs are in the <a href="https://github.com/elastic/terraform-ad-example/">GitHub repo</a>.</p><h2><strong>Module: Datafeed</strong></h2><p>The datafeed module connects an index pattern to an AD job and is the primary parameterization point for service teams. (<code>modules/datafeed/main.tf</code>):</p>resource "elasticstack_elasticsearch_ml_datafeed" "this" {
  datafeed_id = var.datafeed_id
  job_id      = var.job_id
  query = jsonencode({
    bool = {
      must = [{ match_all = {} }]
    }
  })
  indices = var.indices
}<p>The <code>indices</code> variable is the key parameterization point; each service team passes its own index pattern when calling the module.</p><h2><strong>Modules: Job state and datafeed state</strong></h2><p>Job state and datafeed state are managed by separate modules, so operational actions (stopping a datafeed, closing a job) don't require a config plan to execute.</p># modules/job_state/main.tf
resource "elasticstack_elasticsearch_ml_job_state" "this" {
  job_id      = var.job_id
  state       = var.state       # "opened" or "closed"
  job_timeout = var.job_timeout  # default: "30s"
}

# modules/datafeed_state/main.tf
resource "elasticstack_elasticsearch_ml_datafeed_state" "this" {
  datafeed_id      = var.datafeed_id
  state            = var.state            # "started" or "stopped"
  force            = var.force            # default: false
  datafeed_timeout = var.datafeed_timeout  # default: "60s"
}<h2><strong>How to run and apply the anomaly detection Terraform config</strong></h2><h3><strong>Set your API key and initialize</strong></h3>export TF_VAR_ec_api_key="your-elastic-cloud-api-key-here"
terraform init<p>The repo also includes an <code>elastic-env.sh</code> helper for managing secrets. See the <a href="https://github.com/elastic/terraform-ad-example/blob/main/README.md">README</a> for details.</p><h3><strong>Plan and create the resources</strong></h3>terraform plan<p>The plan shows all six resources that will be created:</p><ul><li><p>The Elastic Cloud deployment.</p></li><li><p>The AD job.</p></li><li><p>The datafeed.</p></li><li><p>The two state resources.</p></li><li><p>A scoped API key for bulk ingestion.</p></li></ul><p>Review the output carefully; this is one of Terraform's greatest strengths. Here's the key section:</p>Plan: 6 to add, 0 to change, 0 to destroy.<p>Once satisfied, apply:</p>terraform applyec_deployment.demo: Creating...
ec_deployment.demo: Creation complete after 1m54s
elasticstack_elasticsearch_security_api_key.bulk_ingest: Creating...
module.job.elasticstack_elasticsearch_ml_anomaly_detection_job.nginx: Creation complete after 0s
elasticstack_elasticsearch_security_api_key.bulk_ingest: Creation complete after 0s
module.job_state.elasticstack_..._job_state.this: Creation complete after 0s
module.datafeed.elasticstack_..._datafeed.this: Creation complete after 0s
module.datafeed_state.elasticstack_..._datafeed_state.this: Creation complete after 0s

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.<p>Notice the creation order:</p><ul><li><p>The deployment provisions first (~2 minutes).</p></li><li><p>Then the API key.</p></li><li><p>Then the AD job.</p></li><li><p>Then the datafeed and job state in parallel.</p></li><li><p>And finally the datafeed state.</p></li></ul><p>Terraform derived this order automatically from the dependency graph:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e7a48a93410f8ac/6a467b2bd8b87a37c9ac40b8/8d098e10becad172ef1fa1643737852379d8c71a-713x467.png" alt="Diagram titled “Module dependency graph (create order top → bottom)” showing dependencies among four modules." /><p>At this point, you can confirm the job exists in Kibana's ML UI; the <code>nginx</code> job will be visible in the closed state.</p><p><strong>Load sample data</strong></p><p>The initial <code>terraform apply</code> also creates a scoped Elasticsearch API key for bulk ingestion. We can use it to load some test data. The <a href="https://github.com/elastic/terraform-ad-example/">repo</a> includes a file (<code>sample_data.ndjson</code>) with a few sample documents matching the job's expected fields (<code>@timestamp</code>, <code>nginx.access.body_sent.bytes</code>, and the <code>influencer</code> fields: <code>nginx.access.geoip.city_name</code> and <code>nginx.access.user_agent.build</code>). It can be loaded into the deployment using the Elasticsearch <code>_bulk</code> API:</p>ES_URL=$(terraform output -raw elasticsearch_https_endpoint)
ES_API_KEY=$(terraform output -raw elasticsearch_api_key 2&gt;/dev/null)

curl -s -XPOST "${ES_URL}/_bulk" \
  -H "Authorization: ApiKey ${ES_API_KEY}" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @sample_data.ndjson<p>In practice, you'd want many more documents (hundreds to thousands across weeks/months) for the anomaly detection model to learn meaningful baselines; this sample is enough to verify that the pipeline works end to end.</p><h3><strong>Open the job</strong></h3><p>Change the state parameter in the <code>job_state</code> module call (defined in the top level <code>main.tf</code> file):</p>module "job_state" {
  source = "./modules/job_state"
  job_id = module.job.job_id
  state  = "opened"    # was "closed"
}terraform apply<p>Terraform updates only the job state resource; the job configuration and datafeed are untouched:</p>Apply complete! Resources: 0 added, 1 changed, 0 destroyed.<h3><strong>Start the datafeed</strong></h3><p>Similarly, update the datafeed state:</p>module "datafeed_state" {
  source      = "./modules/datafeed_state"
  datafeed_id = module.datafeed.datafeed_id
  state       = "started"    # was "stopped"

  depends_on = [module.job_state]
}terraform apply<p>The datafeed is now running. Since we haven't specified start or end times, it will process all available data in its indices and will continue polling for new data in real time.</p><h3><strong>Cleaning up</strong></h3><p>When you're done, a single command tears everything down in the correct reverse order:</p><ul><li><p>Datafeed state first.</p></li><li><p>Then job state.</p></li><li><p>Then datafeed.</p></li><li><p>Then job.</p></li><li><p>Then API key.</p></li><li><p>And then the deployment:</p></li></ul>terraform destroyDestroy complete! Resources: 6 destroyed.<h2><strong>How do you promote anomaly detection jobs from dev to production with Terraform?</strong></h2><p>With this modular structure, promoting a job from dev to production becomes a variable change rather than a manual migration. The platform team validates the job against a dev cluster and then updates a single variable:</p># terraform.tfvars (or a workspace-specific file)
ec_region = "us-west-2"         # production region
indices   = ["filebeat-nginx-prod-*"]<p>The same Terraform configuration, the same modules, the same reviewed workflow, just different parameters.</p><p>In practice, you'd use separate <a href="https://developer.hashicorp.com/terraform/cloud-docs/workspaces/best-practices">Terraform workspaces</a> or <code>.tfvars</code> files per environment, feeding into a continuous integration and continuous deployment (CI/CD) pipeline.</p><h2><strong>How do I import existing anomaly detection jobs into Terraform?</strong></h2><p>If you already have AD jobs running that were created through the UI or API, the provider supports importing them into Terraform state:</p>terraform import module.job.elasticstack_elasticsearch_ml_anomaly_detection_job.nginx &lt;deployment_id&gt;/nginx<p>This lets you gradually shift legacy jobs under Terraform management without recreating them.</p><h2><strong>What's next</strong></h2><p>Future releases of the Elasticsearch Terraform provider will add support for ML calendars and filters resources. In the meantime, this modular pattern can be extended to manage other Elasticsearch resources alongside your AD jobs.</p><p>To experience the full benefits, upgrade to 9.3 (or later) or <a href="https://www.elastic.co/cloud/cloud-trial-overview">start your Elastic Security free trial</a>. If you're also managing detection rules, see<a href="https://www.elastic.co/security-labs/managing-rules-with-terraform"> Managing Elastic Security Detection Rules with Terraform</a>.</p><h3><strong>Resources</strong></h3><ul><li><p><a href="https://github.com/elastic/terraform-ad-example">Full example code on GitHub</a></p></li><li><p><a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/elasticsearch_ml_anomaly_detection_job">Anomaly Detection in Terraform documentation</a></p></li><li><p><a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs">Elastic Stack Terraform Provider documentation</a></p></li><li><p><a href="https://registry.terraform.io/providers/elastic/ec/latest/docs">Elastic Cloud Terraform Provider documentation</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/cloud/cloud-hosted/ec-regions-templates-instances">Elastic Cloud regions, deployment templates, and instances</a></p></li></ul><h3>Frequently Asked Questions</h3><p><strong>How do I manage Elastic anomaly detection jobs with Terraform?</strong></p><p>Use the Elastic Stack Terraform provider (v0.14.0 or later), which includes native resources for anomaly detection jobs (<code>elasticstack_elasticsearch_ml_anomaly_detection_job</code>), datafeeds and their operational state. A single <code>terraform apply</code> provisions the job, datafeed and all state resources in the correct dependency order.</p><p><strong>Why should I separate anomaly detection job state from job configuration in Terraform?</strong></p><p>Job state (open/closed) and datafeed state (started/stopped) change frequently during normal operations (reindexing, model resets, pipeline incidents) without any change to the underlying configuration. Keeping them in separate Terraform modules means operational actions don't produce diffs in your config resources and don't require a full config plan to execute.</p><p><strong>Can I import existing Elastic anomaly detection jobs into Terraform without recreating them?</strong></p><p>Yes. The Elastic Stack Terraform provider supports <code>terraform import</code> for existing AD jobs. Run <code>terraform import module.job.elasticstack_elasticsearch_ml_anomaly_detection_job.nginx &lt;deployment_id&gt;/nginx</code> to bring a job created through the Kibana UI or Elasticsearch API under Terraform management without deleting and recreating it.</p><p><strong>How do I promote an anomaly detection job from a dev to a production cluster with Terraform?</strong></p><p>With a modular Terraform layout, environment promotion is a variable change. Update <code>ec_region</code> and <code>indices</code> in your <code>.tfvars</code> file or workspace variable, then run <code>terraform apply</code> against the production cluster. The same reviewed configuration runs in both environments: no manual migration, no UI steps.</p><p><strong>What size ML node do I need for Terraform-managed anomaly detection on Elastic Cloud?</strong></p><p>The example allocates 1 GB of memory in a single availability zone, which is sufficient for low-cardinality AD jobs. Higher-cardinality jobs or larger datasets require a larger <code>size</code> value in the <code>ml</code> block of the Elasticsearch resource and the <code>model_memory_limit</code> variable in the job module is the primary tuning point.</p><p><strong>Why does the Elasticsearch Terraform provider require an explicit </strong><strong><code>depends_on</code></strong><strong> between datafeed state and job state?</strong></p><p>The Elasticsearch API requires a job to be open before its datafeed can start. Because there is no data flow between the two state modules, Terraform would otherwise attempt to start both in parallel and fail. The explicit <code>depends_on = [module.job_state]</code> in the root <code>main.tf</code> enforces the required sequencing.</p><p><strong>What is the difference between the Elastic Cloud Terraform provider and the Elastic Stack Terraform provider?</strong></p><p>The <code>elastic/ec</code> provider provisions Elastic Cloud infrastructure (deployments, node topology, regions). The <code>elastic/elasticstack</code> provider manages resources within a running Elasticsearch cluster (ML jobs, datafeeds, security API keys, index settings). A typical setup uses both: <code>ec</code> to create the deployment, <code>elasticstack</code> to configure it, with credentials passed automatically between them.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/anomaly-detection-terraform-lifecycle</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/anomaly-detection-terraform-lifecycle</guid>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Ed Savage]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt81a160baf05cdd3a/6a467b2e477436c868d3edc1/2051a88b1d927bfb310d11ae9d6c238de86f13f7-1920x1080.png" length="0" type="image/png"/>
    <pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate>
  </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>
    <content:encoded><![CDATA[<p>We've brought simdvec, Elasticsearch'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'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's where vector search will be fastest. Here'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's memory-mapped regions, in exactly the right form, but there was no path connecting it to the scoring engine.</p><p>We'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'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'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've published, and it'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'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'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're reclaimed. On stateful, those decisions are left entirely to the OS.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd17ed30cb6a59275/6a46944fde977731a4ca54b5/e7a1b50ef019d1b5a12d49c7457d63a026e1edd0-727x496.png" alt="Diagram showing data flow between Remote Object Storage and simdvec. The top box labeled “Remote Object Storage” lists S3, GCS, and Azure Blob, with an arrow marked “fetch on miss” pointing to a larger box labeled “Blob Cache.” Inside the Blob Cache are regions numbered 0–5 plus two empty slots, each 16 MB. Regions 0, 1, 3, 4 are green and labeled “cached,” Region 2 is blue and labeled “in use,” Region 5 is yellow and labeled “evicting,” and two gray boxes are labeled “empty.” A legend explains the color codes. A downward arrow labeled “direct memory” connects Blob Cache to a dark box labeled “simdvec – native SIMD scoring.”" /><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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltadef4930fe865721/6a469452a65a6b4e1dbeff0f/fd9583ffce724665018af46ce0409dc4e0825078-828x259.png" alt="Side‑by‑side comparison diagram labeled “Before” and “After.” The “Before” section shows four boxes: blue “Stateful – local mmap,” green “simdvec – native SIMD ✓,” yellow “Serverless – blob cache,” and red “Java scorer – no SIMD ✗.” Arrows indicate a green “direct ptr” from Stateful to simdvec and a red “heap copy” from Serverless to Java scorer, with the caption “two paths, two implementations.” The “After” section shows three boxes: blue “Stateful – local mmap,” yellow “Serverless – blob cache,” and green “simdvec – native SIMD ✓,” with two green arrows labeled “direct” pointing to simdvec and the caption “one engine, one code path, all tiers.&quot;" /><p>This gave us a single scoring entry point across all tiers:</p><ol><li><p><strong>Stateful</strong> (local disk): The scorer extracts a native pointer from the OS memory map.</p></li><li><p><strong>Blob cache</strong> (serverless, frozen tier): The scorer borrows a direct memory slice from a cache region.</p></li><li><p><strong>Fallback</strong>: The scorer copies bytes to the heap. Rare in practice.</p></li></ol><p>The scorer doesn't know which tier it's running on, and it doesn'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'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'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'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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt277d31993bac780c/6a4694559450737b7530d222/fc152731c4b99b85a448e8bd4e01915fefcb55a3-919x533.png" alt="Bar chart titled “Search Throughput — Baseline vs Zero‑Copy (Median ops/s).” It compares median throughput between Baseline (heap‑copy) and Zero‑Copy (DirectAccessInput) across eight knn configurations. Each group shows a gray Baseline bar and a taller green Zero‑Copy bar with percentage improvements labeled above. The y‑axis shows median throughput in operations per second, ranging up to 900. The subhead notes that percentage labels indicate improvement." /><p><strong>Tail latency.</strong> The direct memory path transformed tail latency under load:</p><ul><li><p><em>p99.9</em> dropped from 237 ms to 30 ms (87% reduction).</p></li><li><p><em>p99.99</em> dropped from 9.1 seconds to 55 ms (99.4% reduction).</p></li></ul><p><em>p100</em> dropped from 11.4 seconds to under 100 ms.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6002a4d4bf8cbd8f/6a469458c71ec49c21b98453/003874d85261507d95274504a83bc016af0beb13-818x555.png" alt="Line graph titled “Tail Latency Collapse — knn‑10‑10 Multi‑Client.” The chart compares Baseline (heap‑copy) and Zero‑Copy (DirectAccessInput) latency across percentiles p50 to p100 on a logarithmic scale. The red Baseline line rises sharply, while the green Zero‑Copy line remains low. Labels mark key points. The caption notes Rally benchmark details and log scale." /><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're just getting started.</p><h2>Beyond parity: what Elasticsearch Serverless can do for vector search that stateful can'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><p><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're needed, with no risk of harming concurrent random reads that simply aren't happening on that node.</p></li><li><p><strong>Cache-aware prefetching.</strong> simdvec already prefetches at the CPU cache-line level. On serverless, we can coordinate this with the blob cache'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.</p></li><li><p><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'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.</p></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'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'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'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>
    <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[Vector Database]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Chris Hegarty,Lorenzo Dematte]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd17ed30cb6a59275/6a46944fde977731a4ca54b5/e7a1b50ef019d1b5a12d49c7457d63a026e1edd0-727x496.png" length="0" type="image/png"/>
    <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'll start by explaining why the <code>_id</code> field is expensive for time-series workloads. We'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'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'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><p>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.</p></li><li><p>Accept duplicates during indexing, deduplicate at query time: This preserves correctness but adds overhead to every query, degrading dashboard responsiveness.</p></li><li><p>Deduplicate during segment merges: Duplicates would eventually be removed, but queries on unmerged segments would still return results with duplicates.</p></li><li><p>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.</p></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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt387a8573301f120e/6a3e41e975bd4076e6a77b5d/61ef279f09c5447009d3695f154129fba6fe510d-1048x1462.png" alt="Flowchart on a dark background showing document indexing steps using synthetic IDs, bloom filters, and duplicate handling paths." /><ol><li><p><strong>Compute the synthetic </strong><strong><code>_id</code></strong>: Elasticsearch calculates <code>_id</code> as a combination of <code>_tsid || @timestamp</code>.</p></li><li><p><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.</p></li><li><p><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's timestamp.</p></li><li><p><strong>Check the bloom filter</strong>: For each candidate segment, we test whether the <code>_id</code> might exist using the bloom filter.</p></li><li><p><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.</p></li><li><p><strong>Index the document</strong>: If no existing version is found, the document is indexed. The <code>_id</code> is added to the segment's bloom filter, but no inverted index is built and the field value is never stored.</p></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'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'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:'2026-03-16T00:00:00.000Z',to:'2026-03-19T23:30:00.000Z'))&amp;_a=(viewMode:view)">nightly benchmarks</a>:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte73bd65d8545658d/6a3e41eca48ab9170737c817/32b5b068ef6dd7cd512f12dc9d50d8769f2930d4-1999x509.png" alt="Line graph titled “nightly‑tsdb‑indexing‑throughput,” showing nightly benchmark results for document indexing rates in docs per second over three days, with four colored lines representing different indexing operations." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3cb1cf931ecebba9/6a3e41efb8c8ed6dec833e9d/0c0ac1f2c9716a6f99b6e055535db0def77bf930-1898x956.png" alt="Line graph titled “Disk usage” showing TSDB and downsampling data from March 15 to March 23, 2026, with disk usage measured in gigabytes per 24 hours. A teal line for TSDB drops near March 19 and stabilizes around 1.9 GB, while a brown line for downsampling decreases to 2.3 GB after the same date." /><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:'2026-03-16T00:00:00.000Z',to:'2026-03-19T23:30:00.000Z'))&amp;_a=(viewMode:view)">showed an even better reduction</a>, from 3.0 GiB to 2.0 GiB (34%):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd66d7617467e01c0/6a3e41f2809064d8e0e00d7f/2a243db0f3ad95e761fb334ffd6fea47f7787eee-1999x447.png" alt="Line graph titled “Dataset size,” showing two turquoise lines that decline from March 16 to 19, 2026, each representing a different TSDB metric. The x‑axis marks daily timestamps, and the y‑axis shows dataset size decreasing." /><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><p><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'll cover this optimization in detail in an upcoming blog post.</p></li><li><p><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.</p></li></ul><p>Stay tuned!</p>]]></content:encoded>
    <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[Inside Elastic]]></category>
    <dc:creator><![CDATA[Tanguy Leroux,Francisco Fernández Castaño,Anton Persson]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe7de1872f1527e3/6a17de303e9e452974ba1374/a70c5403064d5bbceff66a17373332362227f13c-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt904a5d6c185bda3a/6a4695ca4887b899aa4248ef/c44a895e29152640ee1fe82fb2b5a9ddeb82bb8b-1999x1125.png" alt="Andreas Koukounas (left) and Georgios Mastrapas (right) presenting Jina-VLM at the poster session." /><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'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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb5061331fe59ab44/6a4695cdcbd14e5302a12bf5/537af5b77e73da88cfdaccda6c6a42c2541dd054-1100x1100.png" alt="Gallery of photos from Rio de Janeiro" /><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'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'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'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'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><a href="https://arxiv.org/abs/2509.24291"><em>Let LLMs Speak Embedding Languages: Generative Text Embeddings via Iterative Contrastive Refinement</em></a>advances the proposition that LLMs should learn to "speak an embedding language," 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'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 "superhuman" 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're already looking forward to seeing where the field is next year.</p><p>
</p>]]></content:encoded>
    <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[Jina AI]]></category>
    <dc:creator><![CDATA[Andreas Koukounas,Georgios Mastrapas,Scott Martens]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt14665f5adef8d917/6a4695c72d406b3bb9ba2bb2/3668b433275c8d75fbc0729346123ea87307d012-1999x1125.png" length="0" type="image/png"/>
    <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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 "one centroid does everything for a posting list." The new model splits responsibilities:</p><ul><li><p>Document centroids (fine-grained): Still used for posting-list structure and document centering.</p></li><li><p>Query centroids (coarser): A parent centroid reused across multiple document centroids.</p></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><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,  and , we rewrite the dot product  as  and expand. We can perform exactly the same operation but using different centroids for the query vector  and document vector . Specifically,</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd01f666c5dfa4f24/6a4695b015103586c4202edb/f395654cf3cc965254dfc8d7e66b57743d29cf41-1566x206.png" alt="" /><p>As for standard Better Binary Quantization (BBQ), we quantize  and  in order to estimate the per (document, query) pair component of the dot product. The quantities   and  are scalars so just two extra additions per dot product we compute. For , we compute naturally when finding the nearest centroid. For , 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  clusters, for  and  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  and define this mapping to the query centroids as →.</p><p>Since there’s a unique query centroid for each document centroid, we only need to cache one value for  per quantized document vector, that is, for each document vector  in posting list , we need to cache  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  and we compute  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  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  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  and treat the  term exactly as above. In fact, there’s a slightly nicer form. Substituting, we have that:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf7cc1ab13a3dc512/6a4695b274bff735d8a05b44/8faf7cd146c3221f3a3929e07286ceb82ac95a04-1598x122.png" alt="" /><p>We can rewrite this as follows:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltce5ca0055196ceff/6a4695b62d406b3c77ba2bac/91d700d38d0e7842f2efef5d7778a6a34142c384-1172x362.png" alt="" /><p>The corrective terms are the norm of query vector  minus the document centroid , the norm of the document vector  minus the query centroid , and the norm of the difference of query and document centroids. As before,  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 "centroid ordinal + centroid score" 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><p> &lt;- This is the distance from a “query vector ” to “document centroid ”. We already gather this when we find the nearest centroids during querying. No new work.</p><p> &lt;- This is the distance from “document vector ” to “query centroid ”. 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> &lt;- This is just the distance between query centroid  and document centroid . 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  being stored instead of .</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's possible that query  is very close to query centroid  but not quite as close as . That said, the actual nearest three document centroids could have a relative order: . 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1aeb75c07434e77f/6a4695b9c71ec47ccbb9846c/d0df2be8b601fb46005667bfa81fc89b2fdaee48-1538x1092.png" alt="Diagram showing a blue circle labeled “q” connected by colored arrows to two dashed oval regions. The green oval contains orange circles labeled dc_0–dc_2 and a green diamond labeled qc_0; and the purple oval contains pink circles labeled dc_3–dc_5 and a purple diamond labeled qc_1. Arrows illustrate relationships between q and the cluster components." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8e2b9d73c7ab998a/6a4695bc91c425d3b5732cb1/a17c3618a29b83d4196088d8422a7ede6eba5c3e-1999x655.png" alt="Flame graph showing computational costs using symmetric quantization, with stacked colored blocks labeled for Elasticsearch and JDK vectorization functions. Each block’s width represents relative processing time, and the tooltip highlights quantization activity within Elasticsearch query code." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7bfd2b99e2fec276/6a4695bf5f1d903f44e3ba97/bb47abf8a7c7da5f5e8990beaaf3319741abfc52-1999x661.png" alt="Flame graph showing reduced computational time spent on quantization after introducing asymmetric quantization, with stacked colored blocks labeled for Elasticsearch and JDK vectorization functions. Each block’s width represents relative processing time, and a tooltip highlights quantization activity within Elasticsearch query code." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltefa647d89e1eb323/6a4695c1a3096d631b9ce7bf/92ec17f1d4ca18a98d3c20b430af72cdf8d8be8a-1260x900.png" alt="Line chart titled “Latency vs Recall Pareto (sec = 16),” comparing asymmetric and symmetric quantization. The blue asymmetric line shows higher recall at each latency value than the red symmetric line, indicating improved latency with minimal recall impact. Axes are labeled “Latency (ms)” and “Recall.”" /><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'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>
    <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[Vector Database]]></category>
    <dc:creator><![CDATA[Benjamin Trent,Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9fe4355576b40f9a/6a4695a774bff7d11ba05b40/265ce999fd38f21943d91e29c0bc49ab01f0196d-1999x1546.png" length="0" type="image/png"/>
    <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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 ("Knowledge Indicators", 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'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't scale. They had to layer in human annotations, institutional knowledge, and learned corrections before it was reliable enough for daily use. We'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><p><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.</p></li><li><p><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.</p></li><li><p><strong>Product Support:</strong> assisting customers with questions on products using internal documentation, building insights across multiple documents.</p></li></ul><p>This post covers a strategy we'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'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>Document:
  docid: &lt;docid&gt;
  url:   &lt;url&gt;
  text:  &lt;body text&gt;
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":            "&lt;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.\")&gt;",
    "description": x     "&lt;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.&gt;",
    "subject":          "&lt;canonical entity name&gt;",
    "predicate":        "&lt;precise snake_case relation, ≤32 chars&gt;",
    "object":           "&lt;the value of the fact, plain prose&gt;",
    "evidence_span":    "&lt;verbatim 1-3 sentence quote, substring of the doc text above&gt;",
    "confidence":       &lt;0..100 integer&gt;,
    "tags":             ["&lt;entity/topic/year tags, lowercase, alphanumeric+hyphen&gt;", ...]
  }
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.<p><em>The extraction prompt was created by the agent. You can find earlier prompts created by the agent </em><a href="https://gist.github.com/joemcelroy/2f7fcd749d42473f2caa9859f75e0fe7"><em>here</em></a><em>.</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'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>{
  "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"
  }
}<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't scale past a demo.</p><p>The shape of the loop, end to end:
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt56b71270db2cf0c7/6a170ac0dc55de2dc0e00dcf/274c07441dd4574c9c4591b2832c97db359b871e-1999x1428.png" alt="Flow diagram showing a four-step feedback loop centred on a Setup Agent. Step 1: Gather inputs (source and eval dataset) feeds into the Setup Agent. The Setup Agent drives three further steps: Step 2 (Generate extractor pipeline: write prompt, build KIs) to the right, Step 3 (Test KIs: second agent runs eval) below, and Step 4 (Analyse traces: find failures, revise) to the left. A dashed blue arrow arcs from Step 4 back to Step 2, labelled 'refine and re-extract', showing the loop continuously improving the extractor based on failures." /><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't need to know which source it'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't need to know the schema, the retrieval strategy, or which source the answer is coming from.</p>POST /api/_get_context
{
  "query":        "Wilkinson 2014 creatine review rheumatoid arthritis article title",
  "size":         10,
  "execute":      true
}<p>And this gets transformed by the use of an LLM to the following:</p>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<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'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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta8b7858ceb212c4d/6a170ac167045b518a45c1ce/4c5b5a59856044218b048ed238cd00d8ecfaa302-1999x975.png" alt="Architecture diagram with two connected systems. Left: a production agent harness where an LLM and orchestration layer query a Metadata Index via get_context and execute_esql APIs, logging traces. Right: a setup agent build pipeline that analyses source data and an eval dataset, extracts Knowledge Indicators, runs test evaluations, and feeds failures back into the extractor to improve it. Both systems share a common Sources layer at the bottom (Docs, CRM, Code, Logs, Databases)." /><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><p><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.</p></li><li><p><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's not a win for any real deployment.</p></li><li><p><strong>Whether the agent converges within the step budget.</strong> A wrong-but-committed answer and a "ran out of steps and gave up" answer are both failures, but they fail for different reasons and the fix is different. We track timeouts separately.</p></li></ul><p>We're not chasing leaderboard accuracy. We'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's scripts. The only thing that changes between runs is which skill is loaded. We use Claude Sonnet 4.6 as the agent'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'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><p><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.</p></li><li><p><code>get_by_doc_id</code> is the escape hatch for fetching a full body when the snippets don't cover the answer.</p></li></ul><p>A caveat on the baseline: this is the standard search-and-fetch pattern most teams use today, but it'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><p><code>get_context</code> POSTs a natural language question to query relevant KIs.</p></li><li><p><code>execute_esql</code> is available as a raw fallback against the corpus.</p></li></ul><p>The skill'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'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'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><p>metric</p><p>baseline RAG</p><p>with-context</p><p>change</p><p>exact match</p><p>4 / 15 (26.7%)</p><p>9 / 15 (60.0%)</p><p>+33 pp</p><p>F1</p><p>0.39</p><p>0.62</p><p>+0.24</p><p>input tokens</p><p>9.0M</p><p>6.3M</p><p>−30%</p><p>output tokens</p><p>47.9k</p><p>43.9k</p><p>−8%</p><p>wall time</p><p>1,475s</p><p>1,448s</p><p>flat</p><p>The headline isn'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta8b9bbb0e652ce1c/6a170ac3acf0882c0fbe9b2a/77eb38566a179dc0932dc55b8263d2c7b0e5d43a-1538x916.png" alt="Bar chart comparing input token usage per question across baseline RAG (grey), with-context (green), and timed-out baseline runs (red). Where the baseline timed out, it consumed up to 1.5M tokens; with-context answered the same questions for a fraction of the cost. Token usage is broadly comparable on questions both approaches answered." /><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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt07cf04f1690d5cd3/6a170ac5a929cf2fb4ae0998/4c5289bdfda0a19572cce060a75feb8e1866e332-1999x1766.png" alt="Terminal trace showing a with-context agent resolving a 5-anchor multi-hop question in 22 steps and 237K tokens for $0.16, using chained get_context calls to lock each anchor and arriving at the answer: Iranian Ministry of Culture and Higher Education." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt22c6fe238d035b37/6a170ac714b2704394e3c60b/e2fe00a6a4c8aa698c88c2ec0364e6f6da58c545-1999x1901.png" alt="Terminal trace of a baseline RAG agent failing the same 5-anchor question at the 43-step limit, consuming 2.86M tokens at $0.43. It identifies the right candidate at step 14 but exhausts its budget cycling through wrong scholarship guesses, emitting no answer." /><p>However, it wasn'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'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><p>metric</p><p>baseline RAG</p><p>with-context</p><p>change</p><p>judge correct</p><p>60 / 96 (62.5%)</p><p>67 / 96 (69.8%)</p><p>+7.3 pp</p><p>F1</p><p>0.561</p><p>0.624</p><p>+0.063</p><p>input tokens</p><p>174.8M</p><p>48.3M</p><p>−72%</p><p>output tokens</p><p>373k</p><p>345k</p><p>−7%</p><p>timeouts (43-step limit)</p><p>28 / 96</p><p>37 / 96</p><p>+9</p><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'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'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'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'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't committed it yet. 21 of with-context'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'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'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's a balance between two levers: the agent's instructions — when to keep retrieving, when to commit, when to fall through to body search — and the context it's working from — whether the KIs in front of it are sharp enough to commit on. Getting that balance right isn't a one-time fix. It'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><p><strong>Wrong-twin commits (19 of 29).</strong> The agent retrieved KIs that were topically right but couldn't disambiguate between near-neighbours, and picked the closer-looking one. q79's <em>University of Aberdeen</em> came back as <em>University of Edinburgh</em>. q193's <em>Secret Oral Teachings in Tibetan Buddhist Sects</em> came back as <em>The Mystic Spiral</em>. q775's <em>Boston</em> came back as <em>Jerusalem</em>. Same failure mode each time — confident commit to the wrong entity.</p></li><li><p><strong>Wrong-value commits (3 of 29).</strong> Same mechanism, applied to numbers. q209's <em>9</em> came back as <em>7</em>. q624's <em>65%</em> came back as <em>26%</em>. q1090's <em>500 Egyptian pounds</em> came back as <em>$1,500</em>.</p></li><li><p><strong>No-candidate failures (7 of 29).</strong> The agent's organic retrieval did not find relevant candidates and the force-commit safety net produced a guess that didn't survive judging.</p></li></ul><p>In all three cases, the agent retrieved <em>something</em>, the something didn'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'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>"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."</em></p><p>A guardrail rejected any KI whose title didn'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><p>metric</p><p>baseline RAG</p><p>with-context</p><p>with-context + disambig</p><p>change vs no-disambig</p><p>judge correct</p><p>60 / 96 (62.5%)</p><p>67 / 96 (69.8%)</p><p>88 / 96 (91.7%)</p><p>+21.9 pp</p><p>F1</p><p>0.561</p><p>0.624</p><p>0.827</p><p>+0.203</p><p>input tokens</p><p>174.8M</p><p>48.3M</p><p>42.6M</p><p>−12%</p><p>timeouts (43-step limit)</p><p>28 / 96</p><p>37 / 96</p><p>27 / 96</p><p>−10</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3a6969a0de41c4d1/6a170ac860084b3e943c4544/a8808fe44ff08040e6e8329d86903a4dba31302a-2048x1501.png" alt="Bubble chart comparing three retrieval approaches by input token cost (x-axis) and accuracy (y-axis), with bubble size representing F1 score. Baseline RAG sits far right at 62.5% accuracy and 174.8M tokens with F1 0.56. With-context moves left and up to 69.8% accuracy and 48.3M tokens with F1 0.62. With feedback loop reaches the top-left at 91.7% accuracy and 42.6M tokens with F1 0.83, showing the highest accuracy at the lowest cost." /><p>Force-commits landed correct on 22 of 28 fires this run, which is why timeouts dropped <em>and</em> accuracy jumped.</p>{
  "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"]
}<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'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's Stage-2 disambig separated Hooker from Rauwolf, but this run committed Francisco Hernández, who isn'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'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'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't tell it apart from a plausible twin. That's a retrieval problem, and text similarity alone won'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't going to help, because the problem wasn't missing facts. It was the agent picking the wrong one of two that looked alike, and tuning the extractor alone couldn'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't separate, and which one the agent picked instead. That'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'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's next</h2><p>This is just the first strategy we're excited to share — bottom-up extraction into knowledge indicators — and there are more we're hoping to explore that leverage our platform for retrieval, data management and agent feedback. We think there's real room to make agents dramatically cheaper and sharper when tokens are tight, and we can't wait to show you where this goes next.</p>]]></content:encoded>
    <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[Agentic AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Joe McElroy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3a6969a0de41c4d1/6a170ac860084b3e943c4544/a8808fe44ff08040e6e8329d86903a4dba31302a-2048x1501.png" length="0" type="image/png"/>
    <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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><p>makes data loading resilient to network turbulence</p></li><li><p>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</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt093ed1a718d2f2ed/6a1710c28b73cb568118a109/2f44064a2e627866e129eb626f68ddd62230a4e2-1999x719.png" alt="Diagram showing traditional polling in Kibana. The Kibana timeline shows a short connection-open period after query submission, followed by a long sleeping period, then a brief poll for status, then results delivered. The Elasticsearch timeline runs a query that completes midway through Kibana's sleep period, illustrating the coordination gap that causes lost time." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3685326f1aa90a1b/6a1710c30c4857892b01ab7a/ad8b80d9e8d6d774065d62e352aad47c3ddb4686-1999x719.png" alt="Timeline diagram showing the performance cost of traditional polling. The Kibana timeline shows a connection-open period, a long sleep period, a poll for status, then results delivered. The Elasticsearch timeline shows the query completing midway through Kibana's sleep, followed by a red lost-time segment - the wasted duration before Kibana wakes up and retrieves the results." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt37fb76cf22647ee5/6a1710c5a929cf6fcaae0abd/77665de4a0fd166b533a2c2c55f6f28e38cf37ce-1200x338.png" alt="Horizontal bar chart showing Kibana's polling interval backoff schedule by query duration. Queries under 1.5 seconds use an interval of roughly 0.5 seconds; 1.5–5 second queries use 1 second; 5–20 second queries use approximately 2.5 seconds; queries over 20 seconds use a 5-second polling interval." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4e81306b0c9f259/6a1710c147d49c34c42d8aec/d5cd43366f62de628c3a054ffb7bf0b05d7a1390-1500x600.png" alt="Line chart showing lost time in seconds versus query completion time in seconds, forming a sawtooth pattern where lost time rises then drops to zero repeatedly, with peaks growing from under 1 second to 5 seconds as query duration increases from 0 to 30 seconds." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4566eaaec79150c7/6a1710c78b73cb1df318a10d/7edc138528dfe1310df42f393ee7279fe3c4703b-1999x713.png" alt="Timeline diagram showing continuous polling in Kibana. The Kibana timeline is entirely blue - the connection stays open from query submission through two connection refreshes until results are delivered, with no sleep periods. The Elasticsearch timeline shows the query completing and results delivered immediately. The legend shows Lost time struck through, indicating it has been eliminated." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltede864738a88e1cb/6a1710c947d49c178d2d8af2/517378a73d36bd95927b81b1f912ce465b7adf4c-800x412.gif" alt="Animated screen recording of a Kibana dashboard loading the Sample Logs Data dataset, showing multiple panels populating in sequence, including a response codes time series, a US map of total requests, unique visitors count, HTTP error rate metrics, and a Sankey chart of machine OS and destination data." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5cddb4d6767a1ccf/6a1710cb839dfa5056dcffcd/f60f3a37baf5ec16c9f1773f08855e7f9e3a7491-1536x1024.png" alt="Diagram comparing HTTP/1 and HTTP/2 for Kibana continuous polling. HTTP/1 requires one TCP connection per request, exhausting the browser's connection pool across six connections. HTTP/2 multiplexes multiple polling requests over a single TCP connection, avoiding pool exhaustion and maintaining performance." /><p>So, on HTTP/2+ continuous polling is a virtue but on HTTP/1 it becomes a vice.</p><p></p><p>HTTP/1</p><p>HTTP/2+</p><p>TCP connections</p><p>One per HTTP request</p><p>Multiplexed (many requests share connections)</p><p>Continuous polling behaviour</p><p>Degrades performance (connection pool exhaustion)</p><p>Full benefit (results delivered immediately)</p><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc61ff0abfe101eec/6a1710cd2867144b8893e412/13e38001fc4bd3fc60cfc69114fb9098fd745906-1970x786.png" alt="Architecture diagram showing three components in a horizontal chain: Kibana Server on the left, an optional Proxy in the centre, and Kibana Client on the right. The server-to-proxy hop is labelled with kibana.yml server.protocol, indicating the known protocol. The proxy-to-client hop is labelled with three question marks, indicating the protocol on that final hop is unknown and may differ." /><p>If we based our optimization on the server protocol, we could get things wrong in one of two ways.</p><ol><li><p>Apply continuous polling when we shouldn’t and degrade the experience.</p></li><li><p>Fail to apply continuous polling when we should and miss out on the optimization.</p></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>new PerformanceObserver((list) =&gt; {
  const entries = list.getEntries();
  const entry = entries.find(({ name }) =&gt; name.includes('/internal/search/'));
  if (entry) {
    this.protocolSupportsMultiplexing = ['h2', 'h3'].includes(entry.nextHopProtocol);
  }
});<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba4af840d8324923/6a1710ceb339d52fe076a0b6/5f19fb45c5307a7491037fe1ad1a302728793203-1200x742.png" alt="Bar chart showing lab test results for Kibana continuous polling: time saved versus traditional polling across query durations from 1 to 23 seconds. Savings vary between near-zero and 4.9 seconds depending on where queries complete relative to polling interval boundaries, confirming the sawtooth latency pattern predicted by the backoff schedule." /><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>
    <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[Kibana]]></category>
    <dc:creator><![CDATA[Drew Tate,Matthias Wilhelm]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4e81306b0c9f259/6a1710c147d49c34c42d8aec/d5cd43366f62de628c3a054ffb7bf0b05d7a1390-1500x600.png" length="0" type="image/png"/>
    <pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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><p><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.</p></li><li><p><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>.</p></li><li><p>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.</p></li><li><p>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.</p></li><li><p>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.</p></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 "this time series has observed 1,000 requests so far", not "1,000 requests happened since the previous sample". 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><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 "how many requests have ever happened on this host", not how many happened in the bucket. The right computation is "how much did this counter increase per second on each host, then sum across hosts." <code>FROM</code> cannot express that operation directly. It can group rows by fields, but it has no built-in notion of "the same time series over time" 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><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's make that concrete with a query that calculates the request rate per data center:</p><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte5b1f3d827f95ad1/6a1706f0961e6910afc4ce92/1d3d765ab8e27e539ac6bedf3e7444632a4bbe7e-3050x900.png" alt="Inner and outer aggregation phases of a TS|STATS query" /><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 "grouping" 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><p><code>RATE(request_count)</code> is the inner aggregation. It runs per time series.</p></li><li><p><code>SUM(...)</code> is the outer aggregation. It combines time series within the same <code>datacenter</code> and bucket.</p></li><li><p><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>).</p></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><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 "this host is at 42% CPU at this time". For a gauge, the natural "value in this bucket" 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><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><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><p>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.</p></li><li><p>Use <code>FROM</code> for events: logs, traces, audit records, transactions. Each row is independent. There is no time series context.</p></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><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><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 "row" 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><p>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.</p></li><li><p>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.</p></li></ul><p><strong>You cannot sort, limit, lookup join, or enrich before</strong> <strong><code>STATS</code></strong><strong>.</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'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>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Felix Barnsteiner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt81635cd2cc7703b3/6a1706ef1949f7a977e7a95c/e2eb1ba006612a352f1317c1621e4ebc5b2a12b6-1376x768.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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>SET approximation = true;
FROM logs-*
| STATS count = COUNT(*) BY time = BUCKET(@timestamp, 5 MINUTE)
| SORT time<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4ce2803349f14b4c/6a1710bb964cea663b08bcb3/fd32f0fddea4dd09a1ceddb9b9e226ac83794117-1999x1551.png" alt="" /><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><p><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.</p></li><li><p><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 "statistically proven" and "best estimate."</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7f8356b68f5b8b31/6a1710bc0c48574b5501ab72/db6da3424aa6ee8b789d975f591afe7ca3a92501-1999x392.png" alt="" /><p>This is a deliberate design choice: Consumers that don'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't need to know it'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're exploring data to form hypotheses, for example, "Which services have the highest error rates this week?", 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc957f508a88f6244/6a1710bed7c022a893de6586/a9bcb929a90d1b3f5777fbe121d01e078cf99b9c-1999x1574.png" alt="" /><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>SET approximation = {"rows": 500000, "confidence_level": 0.95};
FROM logs-*
| STATS count = COUNT(*), avg_duration = AVG(duration) BY service.name<ul><li><p><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.</p></li><li><p><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.</p></li><li><p><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>.</p></li></ul><h2>What'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>
<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'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 = {"confidence_level": 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'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><p>Dataset size. <strong>Larger datasets produce larger speedups</strong>, for the reason described above (exact scans grow with N; sampled scans don’t).</p></li><li><p>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.</p></li><li><p>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.</p></li><li><p>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.</p></li><li><p>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.</p></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'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's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Aris Papadopoulos]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc957f508a88f6244/6a1710bed7c022a893de6586/a9bcb929a90d1b3f5777fbe121d01e078cf99b9c-1999x1574.png" length="0" type="image/png"/>
    <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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 "chocolate" (where shoppers are open to suggestion) but backfires for "baby formula" (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 "for chocolate queries, prioritize margin" and "for baby formula queries, prioritize popularity", 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><p><strong><code>margin</code></strong><strong>:</strong> The product's profit margin as a percentage (0 to 200 in our dataset).</p></li><li><p><strong><code>popularity</code></strong><strong>:</strong> A relative sales volume metric (0 to 10,000 in our dataset), such as weekly average units sold.</p></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'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><p><strong>Calibrated range.</strong> Each signal'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.</p></li><li><p><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.</p></li><li><p><strong>Multiplicative, not additive.</strong> The business-signal boost is applied multiplicatively against BM25 (<code>boost_mode: "multiply"</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.</p></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 "chocolate" is browsing. They'll be satisfied by many chocolate-related products. The retailer'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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt356d92cab8ecddb8/6a170bbdcf4f25c6ffb2d1a4/12b36c0c2104fc30c87c993e751f469522a876b2-660x845.png" alt="A data interface displays two chocolate products, each with pricing, nutritional details, and metadata, with a focus on the margin and popularity fields for both products." /><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 "chocolate" 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt12ba9b66b64d51e1/6a170bbe8b73cbf32f18a061/a70dcbffc8fdf04d9161b327f79008548646478c-1097x1052.png" alt="A web interface titled “Edit rewrite policy” shows configuration fields for a search rewrite rule, with a focus on the Margin Boost Weight field." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt56e66350d60078ec/6a170bc0cf4f252b77b2d1a8/d22245cbc5cd2198d1f27067718c4c8719d096fe-660x940.png" alt="A data interface shows two chocolate products, with pricing, nutritional details, and metadata, with a focus on the margin and popularity fields for both products." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8e95da391643811c/6a170bc27d8d678f0170e74e/e8f1121de8f5982882f6ed8cef2359b2bb76e45f-651x752.png" alt="A data interface shows two infant formula products, with pricing, product details, and metadata, with a focus on the margin and popularity fields for both products." /><h3>Setting a popularity boost on queries for “baby formula”</h3><p>A "baby formula" policy sets <code>popularity_boost_weight: 5.0</code> and <code>margin_boost_weight: 0</code>; formula searches prioritize what's popular, completely ignoring margin.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25023505bc9263ef/6a170bc4e8fbcec01639fc7f/72cdf6ac8bc24dc93d3a540a6d359a5b03145cef-1083x1042.png" alt="A web interface titled “Edit rewrite policy” shows configuration fields for a search rewrite rule, with a focus on the Popularity Boost Weight field." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta626161c2174e6e7/6a170bc5cdacbfd25a7d2a2c/2ff438b375cf8bf21fc5fe27ee311afe510220df-662x732.png" alt="A data interface shows two baby formula products, with pricing, product details, and metadata, with a focus on the margin and popularity fields for both products." /><h2>Disabling business signals: Clearance</h2><p>Not every query benefits from economic boosting. A shopper searching for "clearance" 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 "clearance" 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'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'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 "cheap chocolate" 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><p>The "cheap" policy extracts the price constraint and removes "cheap" from the query.</p></li><li><p>The "chocolate" policy sets <code>margin_boost_weight: 3.0</code> and constrains results to chocolate categories.</p></li><li><p>The “Christmas campaign” policy (higher priority) overrides the category constraint with seasonal categories and adjusts the price ceiling.</p></li><li><p>The “vegan cohort” policy applies a soft boost to vegan-certified products.</p></li><li><p>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×).</p></li><li><p>The shopper's purchase history boosts are applied as the outermost scoring layer.</p></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's perspective. Personalization adjusts ranking further from the shopper's perspective.</p><h2>Tuning guidance</h2><p>The factor values in the baseline <code>function_score</code> are calibrated for the demo dataset'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>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Alexander Marquardt,Honza Král,Taylor Roy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt272ceff311a51c5e/6a170bc7e8fbce29bb39fc83/44a9dc320fa5f36f263e48c7ab2a050955e1d071-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p></p><p>Slow logs</p><p>Query logs</p><p>What they’re for</p><p>Finding hot shards / slow index operations on specific indices and classic performance tuning inside one cluster.</p><p>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.</p><p>Granularity</p><p>Per shard (and per phase) for searching slow logs: One user search can produce many lines across shards/replicas.</p><p>Per coordinator-level query: One query maps to one log event.</p><p>Scope of work</p><p>Query + indexing</p><p>Query only; indexing will come soon.</p><p>What you learn</p><p>“This shard on this index exceeded N ms in query/fetch phase.”</p><p>“This query (full text), this duration, this outcome, and (when relevant) federation/cross-cluster summary fields.”</p><p>Query types</p><p>DSL only</p><p>ES|QL, DSL, SQL, and EQL</p><p>Threshold model</p><p>Often tiered (for example, multiple time thresholds per log levels) and per index.</p><p>Single duration gate at the cluster level (for example, “log if duration ≥ 500ms”)</p><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc2ae00bd7a37f99/6a170ae4c1e8a57104f882c4/f913f860b7a7235fea9d4eeff935bd2e2aa61c0f-1999x1406.png" alt="Dashboard showing Elasticsearch query performance metrics, including P95 and P99 latency values, pie charts for query type distribution, success versus failure, user versus system queries, and hits versus aggregations, line and bar charts for query latency and volume over time, and tables listing top indices, top users, and top error types." /><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>elasticsearch.querylog.enabled: true<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>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true"
  }
}<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>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.threshold": "1s"
  }
}<h3>Include user/audit information</h3><p>If you use the Security plugin and want to see <em>who</em> ran each query:</p>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.include.user": "true"
  }
}<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>PUT _cluster/settings
{
  "persistent": {
    "elasticsearch.querylog.enabled": "true",
    "elasticsearch.querylog.include.system_indices": "true"
  }
}<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 afailed 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>{
  "@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"
  }
}<p><strong>Failure (EQL query):</strong></p>{
  "@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]]"
  }
}<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>
    <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[Inside Elastic]]></category>
    <category><![CDATA[Basics]]></category>
    <category><![CDATA[Query Languages]]></category>
    <dc:creator><![CDATA[Najwa Harif,Valentin Crettaz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4cb4463cec71b906/6a170ae6ab7f0834cfdb9e73/31f1d882d6c0b62bd5ba320c89bda5700434c25c-1672x941.png" length="0" type="image/png"/>
    <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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's time series data streams (TSDS), so there'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><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></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><p><code>GET /_prometheus/api/v1/query_range</code> evaluates a PromQL expression over a time window (matrix results).</p></li><li><p><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.</p></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'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="api"}</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>GET /_prometheus/api/v1/series?match[]=http_requests_total{job="api"}GET /_prometheus/api/v1/labels?match[]=http_requests_totalGET /_prometheus/api/v1/label/instance/values?match[]=http_requests_total{job="api"}<p>The first returns all series for <code>http_requests_total</code> where <code>job="api"</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>GET /_prometheus/api/v1/metadata?metric=http_requests_total<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>GET /_prometheus/metrics-prod-*/api/v1/query_range?query=up&amp;start=...&amp;end=...<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><p><code>POST /_prometheus/api/v1/write</code> ingests time series via the Prometheus Remote Write v1 protocol. v2 is not yet supported.</p></li></ul><p>Remote Write writes into Elasticsearch'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 "what labels exist?" or "what metric types are defined?" 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><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>
    <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[Integrations]]></category>
    <dc:creator><![CDATA[Felix Barnsteiner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt12b4e100d5bbb7f0/6a16f7a22b835ff747f4afdd/c7b333bd73e8a1f4e18486b2d692ba742788dcfd-1376x768.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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 "exponential" 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's start by collecting some histogram metrics to show how they can be stored and analyzed in Elasticsearch using ES|QL.</p><p>We'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'll spin up a JVM running a <a href="https://renaissance.dev/">Renaissance benchmark</a> to put it under stress. We'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'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>OTEL_EXPORTER_OTLP_ENDPOINT: https://&lt;elasticsearch url&gt;/_otlp
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey &lt;base64 API key&gt;"<p>Note that you don'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're bringing your own app:</p>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM
OTEL_INSTRUMENTATION_RUNTIME_TELEMETRY_ENABLED: "true"<p>Let's step through them:</p><ul><li><p><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.</p></li><li><p><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.</p></li><li><p><em>Runtime Telemetry enabled</em>: This tells the agent to actually collect the detailed JVM metrics, which include <code>jvm.gc.duration</code>.</p></li></ul><p>Now we are ready to go! We'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's open up Kibana and navigate to "Discover". There we'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><p>As a response, we now see the metric panel shown below. If you don'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt00f1fc071c411284/6a170849acf08862a9be9a98/b863b2e272ac6584ac193661a6c4419abffdd243-729x190.png" alt="ES|QL metric panel showing the total count of jvm.gc.duration samples" /><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><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc0fc971c91c0f5b9/6a17084b6f7f04323b914792/eda37c5fa244a42258bb452d18f5cbab3ff76eaf-717x190.png" alt="ES|QL metric panel showing the sum of jvm.gc.duration values in the selected time range" /><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><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbfd0b056ac38d0b2/6a17084c60084b3be93c44d5/9fc5d601604b05378beeb4e6e94b613f95fe2fbc-712x188.png" alt="ES|QL metric panel showing the average jvm.gc.duration value" /><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><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcedc19c4c217bb91/6a17084e14b2708f3ce3c5a4/535d44cdb2ec3ed2ac9afe6b259d7b69c9167bbd-989x476.png" alt="ES|QL bar chart showing the average jvm.gc.duration grouped by jvm.gc.action" /><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't also achieve by just using counters. Let's now use histograms to understand the actual distribution of the GC latency. We'll look at the data over time (by grouping using <code>TBUCKET</code>) and focus on the major garbage collections:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1c18e5836bec77a4/6a17084f47d49cc38e2d8937/ce62a4498d5a6e3fcc3bc85ea78f45a18f6e7576-1016x477.png" alt="ES|QL line chart of min, median, p99 and max jvm.gc.duration for major garbage collections" /><p>The graph now shows us the minimum, maximum, median and 99th percentile for major garbage collections. Note that we aren't bound to only querying the median and the 99th percentile. We can query any percentile we'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><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'll update this blog post and add a note on the Elastic Cloud Serverless release notes when that happens.</p>]]></content:encoded>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Jonas Kunz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteb9d15d1bec33675/6a1708511949f75484e7a985/f44560ece4dcc46e6a01826b597e094169e99691-848x477.png" length="0" type="image/png"/>
    <pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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 "organic Pink Lady apples", 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's intent and typically lead to high bounce rates. However, a "No results" 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><p><strong>The logic:</strong> A search for "organic navel oranges" returns only products containing all three terms.</p></li><li><p><strong>Application:</strong> This tier provides the highest precision. When a customer types a precise product name, such as "organic navel oranges", they’re typically seeking that exact item rather than an alternative.</p></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><p><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's minimum_should_match</a> logic.</p></li><li><p><strong>Application:</strong> Relaxed matching maintains lexical grounding. A search for "organic navel oranges" might surface "navel oranges" (missing the "organic" term) or "organic oranges" (missing the "navel" term). These represent intuitive, keyword-based alternatives for the shopper.</p></li></ul><h3><strong>Lowest tier: Semantic matching</strong></h3><ul><li><p><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.</p></li><li><p><strong>Application:</strong> A search for "organic navel oranges" might surface "mandarins" or "clementines”. This serves as the final retrieval tier, intended to provide relevant options when literal keyword matches are unavailable.</p></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 "bucket filling" 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 ("oranges")</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>Strict tier:   [##########]##### (&gt;= 10 found: Exact matches)
Relaxed tier:  [          ]      (Tier bypassed)
Semantic tier: [          ]      (Tier bypassed)<h3>Scenario 2: Specific but limited results ("organic blood oranges")</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>Strict tier:   [####      ]       (4 found)
Relaxed tier:  [    ######]###### (&gt;= 6 found)
Semantic tier: [          ]       (Tier bypassed)<h3>Scenario 3: Abstract or intent-based search ("high vitamin C snacks")</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>Strict tier:   [##        ]             (2 found)
Relaxed tier:  [  ###     ]             (3 found)
Semantic tier: [     #####]######################...<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 "vegan" 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 "mandarins" for an orange search, the results remain compliant with the user'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 "chocolate" yields 12 strict results, the sidebar filters might show "dark" and "milk". If a user selects "dark" and the result count drops, a naive system might trigger the semantic tier to fill the page, which could suddenly introduce "red wine" 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><p><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.</p></li><li><p><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.</p></li><li><p><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).</p></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 "no results" scenarios.</p><h2><strong>What'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, <a href="https://www.elastic.co/contact"><strong>Contact Elastic Professional Services</strong></a><strong>.</strong></p>]]></content:encoded>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Alexander Marquardt,Honza Král,Taylor Roy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69260cf1c6f964e3/6a17db030b0bed6f52dd346e/5d64716981e76396b401fd069d0a635b6929ba94-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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 </a><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-subquery"><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><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><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't change.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad1d619d46212715/6a170d64e8fbce71fd39fcd8/f210b98824cb34c514b6cdb2d44b2bc81a51bb7b-1999x1084.png" alt="Diagram comparing two approaches. The left side is labeled “Before: EVAL + CASE: Sequential &amp; Brittle” and shows a vertical sequence with components titled “Complex Filtering (OR Conditions),” “EVAL source = CASE (dataset == …),” “Sequential Processing Chains,” “Linear Transformation,” and “Final Aggregation,” with callouts for “Choke Point” and “Latency Bottleneck.” The right side is labeled “After: Subqueries: Parallel &amp; Optimized” and shows three parallel branches for “weblogs-,” “applogs-,” and “securitylogs-*,” each with its own WHERE clause and EVAL source assignment, feeding into a “Unified Output Stream (UNION ALL Semantics).” A code block appears below the branches." /><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>PUT _query/view/error_triage
{
  "query": "FROM (FROM svc-gateway-* | WHERE ...) , (FROM svc-payments-* | WHERE ...) , (FROM svc-auth-* | WHERE ...)"
}<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's 15 copies of the same logic today; with a view, it'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><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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6dd3fd7c3afbea5a/6a170d66b0367d56f572bd97/b5728fa2ce08df32b67ef9368c4d75d04b74ab95-1999x992.png" alt="Chart titled “Subqueries vs. FORK” with two columns comparing Subqueries and FORK across four rows: Data Sources, Processing, Mental Model, and Use Case. The Subqueries column shows different indices labeled Index A and Index B, independent FROM pipelines labeled pipeline 1 and pipeline 2, the mental model “Many inputs → one combined result,” and a use case combining web, app, and security logs with corresponding icons. The FORK column shows the same input data labeled Index A, transforms applied to the same rows labeled Transform A and Transform B, the mental model “One input → many analyses,” and a use case of full‑text search and KNN on the same index with magnifying glass and graph icons." /><p>Different indices → subqueries. Same data, different analyses → FORK.</p><h2>How this compares</h2><p>If you're coming from other query languages, here'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'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'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><p>Capability</p><p>Splunk SPL/SPL2</p><p>SQL UNION ALL</p><p>Dashboard-layer merge</p><p>ES|QL subqueries</p><p>Independent filters per source</p><p>SPL2 `union` merges datasets; optimization is scheduler-level</p><p>Yes</p><p>N/A (separate queries)</p><p>Yes; parallel with pushdown</p><p>Schema mismatch handling</p><p>Manual field normalization</p><p>Strict column matching</p><p>Manual in panel config</p><p>Automatic null-padding</p><p>Engine-level reuse</p><p>Text macros (parse-time expansion)</p><p>Database-scoped views</p><p>Dashboard variables</p><p>Cluster-level views with RBAC</p><p>Works for alerts + API</p><p>Limited (summary indexing)</p><p>Yes</p><p>No; display only</p><p>Yes</p><p>Add a source</p><p>Edit every macro/saved search</p><p>Add a UNION branch</p><p>Add a panel query</p><p>Add a branch; existing branches unchanged</p><h2>Current constraints</h2><p>In the Tech Preview release, subqueries are non-correlated; branches run independently and can't reference the outer query. They're supported in <code>FROM</code> only (not <code>TS</code>), and <code>FORK</code> can'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's next for subqueries</h2><p><a href="https://github.com/elastic/roadmap/issues/60"><code>WHERE</code></a><a href="https://github.com/elastic/roadmap/issues/60"> 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'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's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Tyler Perkins]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltddbbebe635e925fd/6a170d681949f7318ae7aaa5/2eb755dd2b2b69b8e0e8867a0da85940eb744176-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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't store data; they re-execute on every read, so results always reflect the current data and the current definition. If you'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><p>Now anyone can write <code>FROM error_triage</code> without knowing the index pattern or filter condition:</p><p>The query is defined once. Consumers reference a name.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdab7918cab2c71e5/6a1706e8cf4f2555e2b2d0d2/68ff5a52b0f3ed3dfaa07d2af6e7f08a8c9c0f55-1999x702.png" alt="Dashboard interface showing a query editor at the top with the text “FROM error_triage | STATS error_count@error_triage | SORT error_count DESC.” A bar chart displays error counts for three services: payments, gateway, and auth. Below the chart, a results table lists the same services with error counts of 194 for payments, 37 for gateway, and 19 for auth." /><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><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcd475eb5e0a666f8/6a1706ea6234e0e4dedb1973/aad2b22fd4d45f8db1140ae93429c6b9ca345031-1999x440.png" alt="Side‑by‑side comparison with the headings “Without Views” on the left and “With Views” on the right. Under “Without Views,” a central document connects to multiple window icons with tangled red arrows and the caption “Manual find‑and‑replace across saved objects.” Under “With Views,” a central document labeled “critical_errors” connects to similar window icons with green arrows and green check marks, along with the caption “Change once, update everywhere automatically.”" /><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><p>Security teams query <code>FROM security_overview</code> without knowing the underlying data model. They'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt74d422d44d683f0e/6a1706ec67045b3abd45c13e/fd8f17e0a5bd80a73737f0fe08f9e3480d70ad68-1822x642.png" alt="Diagram titled “From Text to Topography,” showing three stacked layers. The bottom layer, labeled “Bedrock – Raw Indices,” contains “svc-auth-*” and “threat-intel.” The middle layer, labeled “Base Views,” contains “suspicious_ips” and “known_threats.” The top layer, labeled “Operational View,” contains “security_overview.” Arrows point upward from each lower layer to the next. A text box on the right states that consumers only query the top layer and changes below cascade upward instantly." /><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><p>Consumers just write:</p><p>Two indices, two independent pipelines, one name. To add a third service later, add a third branch; existing branches don'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'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'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'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><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't appear inside view definitions; the caller applies them when querying. Subquery-based views can't be nested inside other multisource <code>FROM</code> expressions. See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-views#esql-views-limitations">views documentation</a> for the full list.</p><h2>What'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 "Save as View" 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'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's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Tyler Perkins]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5417d25f64c77933/6a1706ed1949f76551e7a958/852bff427ac62b79974d88e27ce9670dc132bc46-1280x720.png" length="0" type="image/png"/>
    <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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't share tokens with your documents. In this article, you’ll measure exactly where BM25 falls short. We'll use Elasticsearch'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>
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5ffd147b13705680/6a170a6fe8fbce11a539fc22/b13af2a5d0ca055535d8bfe3dfe4b3d1093ee6da-1457x796.png" alt="Venn diagram illustrating how Recall@10 is calculated by showing the overlap between all relevant documents and the top 10 results retrieved by BM25, resulting in a Recall@10 score of 0.40." /><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 = </code><strong><code>0.40</code></strong>.</p><h2>Prerequisites</h2><p>Let'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><p>Elasticsearch 9.3+</p></li><li><p>Python 3.10+</p></li></ul>pip install elasticsearch pandas plotly python-dotenv<ul><li><p>A <code>.env</code> file with your Elasticsearch credentials</p></li></ul>ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-key<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><p>Field</p><p>Type</p><p>`title`</p><p>text</p><p>`description`</p><p>text</p><p>`brand`</p><p>keyword</p><p>`category`</p><p>keyword</p><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 "running shoes" will match "Running Shoes" and likely "run" as well.</p><p>This works well for a large class of queries:</p><ul><li><p>"running shoes" immediately matches products with those exact tokens in the title.</p></li><li><p>"bluetooth speaker" surfaces portable audio products because the tokens appear verbatim.</p></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><p><strong>"skincare routine":</strong> The word "routine" doesn’t appear in any product title. BM25 can partially match on "skincare," but face serums, body oils, and moisturizers are described using terms like "vitamin C," "retinol," or "brightening," 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.</p></li></ul>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<ul><li><p><strong>"pet travel accessories":</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 "travel accessories." BM25 matches "pet" broadly but has no signal to distinguish travel-specific products from the rest of the pet catalog.</p></li></ul>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<p>This is a <strong>recall problem</strong>. The relevant documents exist in your index. BM25 just cannot find them because the user's words and the document'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>Recall@k = (relevant documents found in top k) / (total relevant documents)<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>Precision@k = (relevant documents in top k) / k<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'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>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"},
]<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'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>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"<p>Now build the <code>rank_eval</code> request for BM25. Each request in the list combines a query with its ratings:</p>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"])<p>Result:</p>BM25 Recall@10: 0.43<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: "skincare routine" misses face serums and body oils because "routine" never appears in product titles, and "pet travel accessories" retrieves off-topic pet products while missing carriers and crates described in terms of portability and safety rather than "travel accessories."</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. "Gym equipment" and "dumbbell set" 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>INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"<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>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}")<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>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)<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; "running shoes" 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><p>BM25 handles exact and near-exact queries with high precision.</p></li><li><p>Semantic search handles intent-based and multilingual queries with high recall.</p></li><li><p>RRF combines the two ranked lists into a single ranking.</p></li></ul><p>The RRF formula assigns each document a score based on its rank in each result list:</p>score = sum(1 / (rank_constant + rank))<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>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"])<p>Result:</p>Hybrid Recall@10: 0.75<p>Hybrid improves substantially over BM25 (<code>0.43</code>) and preserves precision for exact-match queries like "running shoes."</p><h2>Results: Before and after</h2><p>Here’s the full comparison across all three approaches:</p>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))<p>Result:</p><p>Method</p><p>Recall@10</p><p>BM25 (Lexical)</p><p>0.43</p><p>Hybrid (BM25 + Vectors)</p><p>0.75</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a1d72b57056fe64/6a170a71c1e8a56c58f882ab/e49f6c10516b0a48a0ad75962c6590ee07311407-700x500.png" alt="Bar chart comparing Recall@10 between BM25 lexical search and hybrid search combining BM25 with vectors, showing hybrid search achieving significantly higher recall." /><p>Breaking it down by query:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt871347f754c866d0/6a170a73839dfa40abdcfeb4/40e36dcb7b34cbf4649c512bcb60cef60f1778a6-700x500.png" alt="Grouped bar chart comparing Recall@10 between BM25 lexical and hybrid search across four product queries, showing hybrid search consistently outperforming lexical search for each query." /><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' actual queries, run <code>rank_eval</code> as a baseline, add <code>semantic_text</code>, and measure again. You'll know exactly what improved and by how much.</p><h2>Next steps</h2><ul><li><p>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</p></li><li><p>Add reranking for even better precision on the top results</p></li><li><p>Explore <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html">Elasticsearch hybrid search documentation</a></p></li><li><p>Read more about the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html"><code>rank_eval</code></a><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html"> API</a></p></li></ul>]]></content:encoded>
    <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[Hybrid Search]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt37c9d2971b5a2db3/6a170a75cf4f254223b2d149/492c9b5432a2b9e40cebb3b60f0df019a8c7bf6d-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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're asking Elasticsearch to compare the user’s search string against one or more stored fields in these product documents. Elasticsearch'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>POST products/_search
{
  "query": {
    "match": {
      "title": "oranges"
    }
  }
}<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt806e1c8c115bc9b6/6a170dba67045b634645c266/ba758f25616f2106d245ce0d47926c174766e028-642x318.png" alt="A diagram comparing an incoming search string to stored product titles, showing matches for three titles containing “orange” and no match for two titles that do not." /><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'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 "oranges", 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 "oranges", 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1e7e2966bf46474d/6a170dbba929cf500aae0a57/1e6348531d1c0be57b385f51d248488cf58489ff-642x279.png" alt="A diagram showing several stored query patterns tested independently against an incoming search string, with “oranges” producing a match and all other patterns returning no match." /><p>For governance, the "stored query patterns" 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'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 "oranges" finds its policy</h2><h3>The policy</h3><p>A merchandiser has authored a policy that matches if a user searches for exactly "oranges" 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>{
  "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
}<p>The <code>percolator</code> field contains the pattern that defines when this policy should fire. In this case, it matches the phrase <code>"START oranges END"</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 "oranges" 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>POST policies/_search
{
  "query": {
    "percolate": {
      "field": "percolator",
      "document": {
        "query": "START oranges END"
      }
    }
  }
}<p>The string <code>"START oranges END"</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's the percolator. The user'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>POST products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "oranges" } }
      ],
      "filter": [
        { "terms": { "categories": ["Fruits"] } }
      ]
    }
  }
}<p>The user searched for "oranges”. The product catalog receives a query for "oranges" constrained to the Fruits category. Because of this constraint, orange juice, orange marmalade, and orange soda are excluded.</p><h3>Why "orange marmalade" does NOT trigger the oranges policy</h3><p>Suppose a different user searches for "orange marmalade”. The control plane wraps the string and percolates: <code>"START orange marmalade END"</code>. The oranges policy's pattern is <code>match_phrase: "START oranges END"</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 "oranges" could accidentally fire on a query like "orange marmalade". By wrapping the user's search string with <code>START</code> and <code>END</code> and including those markers in the policy's pattern, we ensure that the policy only fires when "oranges" is the complete search string, without any other words. This matches both the shoppers and the merchandiser's intent.</p><h2>A second policy: "olive oil" 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>{
  "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
}<p>This policy's pattern matches against <code>query.stemmed</code> instead of <code>query</code>. When the user'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 "olives" and "olive" both reduce to the same stem, as do "oils" and "oil"). The policy'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 "olive oil" 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 "under $4" contains a currency value), and other kinds of analysis.</p>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" }
    }
  }
}<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'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>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" } }
  ]
}<p>The highlight configuration uses <code>"query"</code> as the field key with <code>"query.stemmed"</code> in <code>matched_fields</code>. This tells Elasticsearch'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's search string triggered each match.</p><p>The response for an "olive oil" search may look as follows:</p>{
  "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": ["&lt;em&gt;START olive oil END&lt;/em&gt;"]
        }
      }
    ]
  }
}<h2><strong>Why highlights matter</strong></h2><p>Notice the highlight in the response: <code>"&lt;em&gt;START olive oil END&lt;/em&gt;"</code>. Elasticsearch is telling us exactly which words in the user'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 "cheap" 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'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: "Cheap chocolate" during a Christmas campaign</strong></h3><p>Suppose the system has two active policies: the "Cheap chocolate" policy (priority 210) and the "Christmas chocolates" 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 "cheap chocolate." The control plane wraps the search string as <code>"START cheap chocolate END"</code> and sends it to the percolator. Two policies match: The "Cheap chocolate" policy's pattern matches on the phrase "cheap chocolate"; and the "Christmas chocolates" policy's pattern matches on "chocolate" 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' → [Policy B] → state'' → 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><p>Adds a category hard filter: "Christmas foods and drinks," "Christmas sweets".</p></li><li><p>Adds a price filter: less than $7.</p></li><li><p>Adds a category soft boost: "Advent calendars" (3x).</p></li></ul><p>The “Cheap chocolate” policy (priority 210) applies next against the modified state:</p><ul><li><p>Attempts to add a category hard filter: "Chocolates," "Milk chocolates"; but the Christmas policy already set this field with <code>on_conflict: override</code>, so the Cheap chocolate categories are dropped.</p></li><li><p>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.</p></li><li><p>Removes "cheap" from the search string.</p></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>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"
    }
  }
}<p>The original search string was "cheap chocolate”. The query that reaches the product catalog is a governed, intent-aware retrieval plan: The word "cheap" 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'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>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Alexander Marquardt,Honza Král,Taylor Roy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt19fcc31ad093ad30/6a170dbd7d8d67301070e799/5e485cdd52d78419ff0ac30a4192b953f6d70c61-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb84f89f4d9029df7/6a170f806234e077cddb1ab6/4e2cd5244ef8b9a05af6337a4825252f321a9a43-1377x768.png" alt="Image with two headings, “Policies as code” on the left and “Policies as data” on the right. The left side shows conditional code blocks defining query‑handling rules with notes about deployment, change cycles, and sequential evaluation. The right side shows JSON policy objects with titles, match terms, actions, filters, and priorities, along with notes about storage in an Elasticsearch index, update behavior, and indexed matching." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0585c90830d63d02/6a170f82964cea7a5908bc8b/5562da5de521f3c83ed55a13e9be87ca7fa70109-546x489.png" alt="Diagram illustrating two search flows through a governed control plane: one where a text query for “oranges” is rewritten with a category constraint before product lookup, and another where a semantic query for “gift for grandpa” is rewritten and routed to retrieve matching products from a product catalog." /><p>A control plane pipeline consists of:</p><ol><li><p><strong>User query: </strong>A user enters a string of what they’re looking for, such as “oranges” or “gift for grandpa”.</p></li><li><p><strong>Policy lookup: </strong>Match the user query against the policy index.</p></li><li><p><strong>Return matching policies:</strong> Policies that match the user query are returned from the policy index.</p></li><li><p><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).</p></li><li><p><strong>Execute:</strong> The modified <em>intent-aware</em> Elasticsearch query is passed to the application to be executed against a product catalog index.</p></li><li><p><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.</p></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><p><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.</p></li><li><p><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.</p></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 "oranges" to the produce category, treating "without peanuts" as an exclusion, and routing "gift for grandpa" 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't touch the application code, and an engineer optimizing the product search doesn'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'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've seen what policies do conceptually, let'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltada4d46e2ab26208/6a170f836f7f04f91f914924/bbcd66b20fc3aa861b5880ca67daf8e809698717-1002x890.png" alt="Interface showing a rule configuration with a match phrase for “cheap chocolate,” category and price filters, a phrase‑removal field, and priority settings." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt368bdfbb9a6e5a5e/6a170f8566c4f975a1f8c10f/3f373af9a985864315d7639440a416e45a882a1b-1133x1146.png" alt="Interface showing a rule configuration with a match phrase for “cheap chocolate,” category and price filters, a phrase‑removal field, and priority settings." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta3428d211f2d8304/6a170f86839dfa0049dcffb3/8f1179342d0e05cf78266d142b046021a3694368-1007x941.png" alt="Screenshot of an Elasticsearch rule query interface showing a match_phrase query for “chocolate,” filter rules based on categories and price, conflict handling options, and rule priority settings." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e7f1fe91b2cadcd/6a170f8866c4f90b0af8c113/662b0e40cb3a9291c17816c33169e9ff5b68f98d-1129x1085.png" alt="Search results page showing a query for “chocolate” with category and brand filters on the left and a list of chocolate advent calendar products with images, prices, categories, and descriptions on the right." /><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. "Cheap chocolate" 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><p><strong>Order of application:</strong> If a negation policy removes "without peanuts" from the query, does the price policy still see the original text or the modified text?</p></li><li><p><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?</p></li><li><p><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?</p></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' → [Policy B] → state'' → ... → 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'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><p><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 </a><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query#score-bool-filter"><code>bool.filter</code></a> clause. This is useful for excluding unrelated products entirely. For example, restricting a search for "oranges" to the produce category eliminates hits such as orange juice and orange marmalade. Non-matching documents are completely excluded from results.</p></li><li><p><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 </a><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query"><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.</p></li></ul><h3>Conflict strategy</h3><p>What happens when a lower-priority policy sets the same field:</p><ul><li><p><code>override</code>: This high-priority policy's value wins; the lower-priority value is dropped entirely. Valid for all field types.</p></li><li><p><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.</p></li><li><p><code>merge</code>: Combine both values into a union. Valid for non-numeric fields only.</p></li><li><p><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.</p></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><p>Field type</p><p>Available strategies</p><p>Default</p><p>Negation fields (__not, __match__not)</p><p>override, merge</p><p>override</p><p>Numeric range fields (__max, __min, __gt, __lt)</p><p>restrict, override, soft_boost</p><p>restrict</p><p>All other fields (keyword, text)</p><p>soft_boost, override, merge</p><p>soft_boost</p><p>Negation fields cannot be soft-boosted because exclusions are binary. Converting "never show canned foods" to "slightly prefer not-canned-foods" fundamentally changes the semantics; a product from "canned foods" would still appear, just ranked slightly lower, which defeats the purpose of the exclusion.</p><h2>A concrete example: Searching for "cheap chocolate" 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf930b42611a6126c/6a170f8aacf088ae28be9c1b/0405e193522172bde283180df96ed3651178fafc-529x447.png" alt="Screenshot showing a transformation pipeline where an initial query “cheap chocolate” is modified by multiple rules, including added category and price filters, conflict resolution behavior, rule priorities, and a final transformed query of “chocolate.”" /><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><p>Only products from the “Christmas foods and drinks” and “Christmas sweets” categories will be shown.</p></li><li><p>Within those categories, if the products are also tagged as being in the “Advent calendars” category, they’ll be boosted up by 3x.</p></li><li><p>A price filter for $2 is applied, which came from the lower-priority policy (because the higher-priority policy specified to “Restrict” on conflict).</p></li><li><p>The word “cheap” is removed, only returning products matching “chocolate”.</p></li></ul><p>With both of these policies enabled, “cheap chocolate” returns results similar to the image shown below:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7e3c2ee36f963e8c/6a170f8ccdacbf5be17d2ac2/01bbab1c5bd3d0fd37e39c25973d60141f9796e9-1126x1123.png" alt="Search results page showing a query for “cheap chocolate,” with category and brand filters on the left and a list of chocolate advent calendar products with images, prices, and product details on the right." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbb7393566aeab705/6a170f8db0367d5b6472bde2/45e88311014d67933ca8cf8381d8f91de090e2b4-1090x103.png" alt="User interface showing a search policy rule with field set to Categories, operator set to Equals, values “Christmas foods and drinks” and “Christmas sweets,” conflict handling set to Soft with priority 1, and filter mode set to Hard filter." /><p>After this modification, the query rewriter transformation pipeline execution for “cheap chocolate” looks as follows:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b4453b35b5f8ef0/6a170f8fb339d5ba9b76a09a/396b360e48327421c2c38bcf4a039fb1a6d5a8e0-519x445.png" alt="Screenshot of a transformation pipeline showing how the initial query “cheap chocolate” is modified by multiple rules, including category filters, price limits, soft boost and hard filter modes, conflict handling outcomes, rule priorities, and a final query of “chocolate.”" /><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><p>Because “On conflict” is specified as “Soft boost” on the higher-priority policy, the conflicts will be converted to boosts as follows:</p><ul><li><p>Products from the “Christmas foods and drinks” and “Christmas sweets” categories will have a boost of 1x applied to them.</p></li><li><p>Products from the “Chocolates” and “Milk chocolates” categories will have a boost of 3x applied to them.</p></li></ul></li><li><p>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.</p></li><li><p>As in the previous example, a price filter for $2 is applied.</p></li><li><p>The word “cheap” is removed, only returning products matching “chocolate”.</p></li></ul><p>With relaxed filtering, results look as follows:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0288336675c509ef/6a170f917d8d6723bc70e808/7a68c54d878dadfe8b1821dd3860b7b60f9ce45f-1126x1123.png" alt="Search results page for the query “cheap chocolate,” showing category and brand filters on the left and a product list on the right, with multiple chocolate items, prices, categories, and a total of 6,895 results indicated at the top." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltae1b40d312cf59e6/6a170f92cdacbfa1277d2ac6/c2621e6513281f545b84eb77362f2b93e1c46a1f-996x70.png" alt="User interface showing a search policy rule with field set to Price, operator set to Less than, value set to 7, conflict handling set to Override, and filter mode set to Hard filter." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a47aa71c925b4a3/6a170f94ab7f0863d3db9f6d/d50da7900beb3c08439e9fd79cbe2ddd98196441-511x389.png" alt="Screenshot of a transformation pipeline detailing how the initial query “cheap chocolate” is processed by two filter rules, showing added category and price filters, hard filter and soft boost modes, conflict handling outcomes, rule priorities, and removal of a price filter due to a conflict." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2b9ac1a62437c967/6a170f96839dfa3f58dcffb9/635ee6353ba84727486e7e053764788fb26b6f44-1134x1079.png" alt="Search results page for the query “cheap chocolate,” showing category and brand filters on the left and a list of chocolate products on the right, including multiple advent calendars with images, prices, categories, and a displayed total of 10,000 results." /><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 "without peanuts" from the query, a lower-priority policy that also matched on "without" 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 "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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdaa3ff1bb3afaa36/6a170f97acf088954bbe9c1f/6dccd5b8a94bfa81f68e3d1c4ad8929ce8cc4e5e-990x378.png" alt="Diagram showing a search query flowing from a user through an application server and control plane, where matching rules are looked up, the query is rewritten with semantic intent including category and price constraints, and results are retrieved from a product catalog with non-matching products excluded." /><p>With that constraint in place, the control plane applies pragmatic routing logic:</p><ul><li><p><strong>Lexical</strong> for navigational and head queries where deterministic precision matters.</p></li><li><p><strong>Semantic</strong> for descriptive discovery queries where concept matching helps.</p></li><li><p><strong>Hybrid</strong> selectively, when constraints have already been enforced and the business accepts broader recall.</p></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'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>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Alexander Marquardt,Honza Král,Taylor Roy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb84f89f4d9029df7/6a170f806234e077cddb1ab6/4e2cd5244ef8b9a05af6337a4825252f321a9a43-1377x768.png" length="0" type="image/png"/>
    <pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate>
  </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>
    <content:encoded><![CDATA[<p>We're pleased to announce that the Elasticsearch Query Language (ES|QL) query builder is now available for JavaScript and TypeScript. It'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've ever built an ES|QL query in JavaScript, you've probably written something like this:</p>const query = `FROM logs-*
| WHERE status_code &gt;= ${minStatus}
  AND host.name == ${hostname}
  AND @timestamp &gt;= "${startDate}"
| STATS error_count = COUNT(*) BY status_code
| SORT error_count DESC
| LIMIT 10`<p>It looks fine until <strong><code>hostname</code></strong> is<strong><code>O'Brien's server</code></strong> and the whole thing blows up with a parse error. Or until a user passes <strong><code>"; DROP INDEX logs</code></strong> into a search field and you realize you've been building queries with raw string concatenation this entire time.</p><p>There's a better way. The ES|QL query builder for JavaScript and TypeScript lets you write queries like this instead:</p>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)<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'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>npm install @elastic/elasticsearch-esql-dsl<p>Here’s a minimal query:</p>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())<p>This renders:</p>FROM employees
| WHERE still_hired == true
| SORT last_name ASC
| LIMIT 10<p>To run it against Elasticsearch:</p>import { Client } from '@elastic/elasticsearch'

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

const errors = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .limit(100)FROM logs-*
| WHERE status_code &gt;= 400
| LIMIT 100<h3><strong>Step 2: Add a computed column</strong></h3><p>Your timestamps are in milliseconds, but you want response time in seconds:</p>const errors = ESQL.from('logs-*')
  .where(E('status_code').gte(400))
  .eval({ response_secs: E('response_time_ms').div(1000) })
  .limit(100)FROM logs-*
| WHERE status_code &gt;= 400
| EVAL response_secs = response_time_ms / 1000
| LIMIT 100<h3><strong>Step 3: Aggregate errors by status code</strong></h3>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())FROM logs-*
| WHERE status_code &gt;= 400
| STATS error_count = COUNT(*), avg_response = AVG(response_time_ms) BY status_code
| SORT error_count DESC<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'd use <strong><code>E()</code></strong>.</p><h3><strong>Step 4: Use date functions for time-based analysis</strong></h3>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'))FROM logs-*
| WHERE status_code &gt;= 400
| EVAL hour = DATE_TRUNC(@timestamp, "1 hour")
| STATS error_count = COUNT(*) BY hour
| SORT hour<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>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)<p>Three different queries, one shared base. Change the filter on <strong><code>base</code></strong><strong>,</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's the same WHERE clause written three different ways:</p><p><strong>Raw strings:</strong> When you're writing a quick one-off:</p>.where('status_code &gt;= 400 AND host.name == "web-01"')<p><strong>The </strong><strong><code>E()</code></strong><strong> expression builder: </strong>When you want type safety and autocomplete:</p>import { and_ } from '@elastic/elasticsearch-esql-dsl'

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

const minStatus = 400
const host = 'web-01'
.where(esql`status_code &gt;= ${minStatus} AND host.name == ${host}`)<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>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],
  })
}<p>The <strong><code>?</code></strong> placeholder is replaced server-side by Elasticsearch, so the user's input never touches the query string. No escaping, no injection risk.</p><h2><strong>Beyond the basics</strong></h2><p>Once you'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>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)<p><strong>Data enrichment:</strong></p>const enriched = ESQL.from('logs-*')
  .enrich('ip_lookup')
  .on('client.ip')
  .with('geo.city', 'geo.country')<p><strong>Conditional aggregation:</strong></p>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(),
  })<p><strong>AI/machine learning (ML) integration:</strong></p>const summarized = ESQL.from('docs')
  .completion('Summarize this document')
  .with({ inferenceId: 'my-llm' })<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'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're actively developing this and want to build what JavaScript and TypeScript developers actually need.</p>]]></content:encoded>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Margaret Gu]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta47c29c9d8d76fbf/6a170f30b0367d006972bdd6/d8cc9dc5b2bcae4c589b402d62a5b7c8c6d63fb7-720x420.png" length="0" type="image/png"/>
    <pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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'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><p><strong>Campaign launch.</strong> Due to an ordering error, there’s an oversupply of in-house branded turkeys. Therefore any query for "turkey" must boost the in-house brand.</p></li><li><p><strong>Product recall.</strong> A supplier has recalled a product line. Queries that would surface those products shouldn’t be shown.</p></li><li><p><strong>Seasonal reinterpretation.</strong> Queries for "stocking" are returning women's hosiery and tights. During the holiday season, "stocking" should resolve to Christmas stockings and stocking stuffers. Once the season ends, the policy can be reverted in minutes.</p></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'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 "spaghetti" 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt24667eaf8ada7e24/6a170b0367045bee5f45c1e2/fc4d7ea5545512667552af429023fcd7fb316e82-1408x768.png" alt="Traditional workflow Alt text: An infographic titled “The traditional model: search logic in application code,” showing an eight‑step software development workflow that includes a merchandiser describing an urgent requirement, a Jira ticket being created, engineering investigating the request, development making code changes, code review and regression testing, staging testing, staging deployment, and a production release." /><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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8cc94199fe15c1f2/6a170b05cf4f25da6db2d175/13d47992fb1d1f3f3887f3800f5ddc83742e9c9c-1408x768.png" alt="An infographic titled “The governed model: policies as data,” showing a three‑step workflow in which a merchandiser drafts a business policy, a peer reviews it for logic and conflicts, and the policy is published to take effect on the next query, with notes about versioned, auditable, and reversible policies and same‑day deployment." /><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't change. The retrieval engine doesn'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4486a76d20df9592/6a170b07b339d515be769fbe/281e0fb915a7723a5619b5bd08f855abb4e2c530-966x1412.png" alt="A screenshot of a user interface for editing a rewrite policy, showing a Policy section with ID, title, description, and toggles; a Trigger section defining a match_phrase condition for the query “oranges”; and a Rule section configuring a filter on the Categories field with additional parameters and boost weights. Show less" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf3f97533429e058c/6a170b0847d49c4e242d8a02/3c8cd24f24320f5661800e34686719bc7d6c78e2-1005x959.png" alt="A screenshot of a rewrite rule editor showing a rule with an ID, title, description, and an enabled toggle, a rule query defined with a match_phrase condition for “START oranges END,” and a filter on the Categories field set to “Oranges,” with additional settings for conflict handling and filter mode." /><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'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 "zero-deploy" promise. It doesn't mean "no process." 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 "zero-deploy" matters for revenue-critical queries</h2><p>The economics of ecommerce search are asymmetric. A small number of high-volume queries ("milk," "bread," "oranges," "diapers") 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><p>The merchant notices the problem.</p></li><li><p>The merchandiser files a ticket with engineering.</p></li><li><p>Engineering investigates, identifies the cause, and writes a fix.</p></li><li><p>The fix goes through code review, staging, and release.</p></li><li><p>Production is updated.</p></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><p>The merchant notices the problem.</p></li><li><p>The merchandiser drafts a policy fix (or modifies an existing policy).</p></li><li><p>The policy goes through review and is published.</p></li><li><p>The fix is live.</p></li></ol><p>The difference isn't just speed. It's ownership. The person closest to the business context (the merchandiser who understands why "oranges" 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'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><p>Did the "cheap laptops" price threshold policy improve conversion for that query class, or did it suppress it?</p></li><li><p>What was the click-through rate impact of the holiday campaign boost?</p></li><li><p>When we rolled back the "oranges" category constraint last Thursday, what happened to add-to-cart rates?</p></li></ul><p>This turns search governance into a data-driven discipline. Instead of vague "relevance tuning," 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 'Sponsored' 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't need engineering to write it.</p><p>This doesn'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'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't slow down software delivery; they make it safe to ship frequently. Access control doesn'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't that the code change is complex; it'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'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>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Alexander Marquardt,Honza Král,Taylor Roy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt76fe825555fe3842/6a170b0a66c4f927f4f8c033/dc802d2ca828ba41d6ff2a0ea1ba67eb0e3bcd10-1280x720.png" length="0" type="image/png"/>
    <pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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><u><strong>Elasticsearch data tiers</strong></u></p><ul><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7aa17311023e1ae3/6a170feaa929cf24fbae0aa9/7d41594682b601a30a9544b8db678f118b0146ab-2048x720.png" alt="Interface showing a logs index pattern with a mapping conflict warning and a list of field types. Focus is on View conflicts and on field type conflict." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb4106cf39e1be69b/6a170feb6f7f047b74914932/41ad800daa6fc244a1123ba7538820bff5de6788-747x182.png" alt="Table row showing the field name log.offset with types keyword and long marked as a conflict." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdcec2cd42e2e858a/6a170feda929cff2e4ae0aad/9973c1935aa52292c1ace09a8e9c0b31ad99e7a2-2048x1085.png" alt="Screen showing the field log.offset with a warning about differing types and a table listing the indices for each type." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc22eb597f97cbb24/6a170feed7c02291c5de657c/3c77d0a1520bd1ad17e7ffa1480ecf5e224953e1-418x360.png" alt="" /><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'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'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><strong>:</strong> field delimiter.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4b1966dbd35b7264/6a170ff00c4857919a01ab52/781f63b34a9abd427ceb896484da29af446e3326-2048x1063.png" alt="Screen showing the field log.offset with a warning about a type conflict, plus a table listing the indices for each type." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdf3bb65faeb1e94b/6a170ff214b2701bfbe3c6c3/b7b0cb847c1694ab605c61a538722f5be004ec86-2048x909.png" alt="Screen showing a time‑based histogram and a table of log entries with timestamps and log.offset values." /><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><p>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.</p></li><li><p>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's <code>@custom</code> component template. Create this template if it doesn't exist yet, and make sure to specify the correct mapping type for the field.</p></li><li><p>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.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt17b6cb8aa8f905c2/6a170ff4964cea702708bc97/bea7cb172227bebc28146e3f2f016e112f34cba5-2048x720.png" alt=" Screen showing an index template with its summary, index pattern, priority value, data stream setting, and a list of component templates, with the focus on the logs‑filestream.generic@custom entry." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf5b67199089093f4/6a170ff5d7c0220575de6580/e8f63a2e396efbe7f1e62dc08a137a22700be484-2048x296.png" alt=" Screen showing the Index Management interface with the &quot;Component Templates&quot; tab selected and a note that the custom template doesn't exist. Focus is on “Create component template.&quot;" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltca2924bc41cac541/6a170ff7dc55decfd6e00ec5/822f1d864302aa4be438c13756b8372f43fa1b0d-2048x1275.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltee1067cb282ae0af/6a170ff82b835f55bff4b2e5/affa2f1214af516a5a6b571ab813628ed7649275-2048x1235.png" alt="Template mappings" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltee1067cb282ae0af/6a170ff82b835f55bff4b2e5/affa2f1214af516a5a6b571ab813628ed7649275-2048x1235.png" alt="Screen showing the component template creation interface with the “Mappings” step selected in the workflow." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt611a10bd7567d211/6a170ffa964cea257008bc9d/ea2975ee4e40ac0e10c4170d2a23125101f7f8da-2048x1136.png" alt=": Screen showing the component template creation interface with the “Mappings” step selected in the workflow" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3a0c86021cc53e0e/6a170ffcc1e8a57703f8839f/bdf8b8290b0c064c9d88990194b15232ffe85709-2048x1027.png" alt=" Template review Elasticsearch" /><h2>Creating the new backing index structure</h2><p>The new backing index needs to have the existing mappings from the data stream’s  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's <code>@package</code> mappings. (Go to <strong>Stack Management -&gt; Index Management -&gt; Component Template -&gt; </strong><strong><code>logs-filestream.generic@package</code></strong><strong> -&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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1de7b9b0e375867e/6a170ffd8b73cb61f318a0f7/402b0431b0e19374e9b28a4374ed51dfa5fa44ba-2048x897.png" alt="Screen showing the component template creation interface with the Review step selected in the workflow. " /><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><p>The appended <code>-1</code> signifies a reindex and won’t conflict with the default ILM rollover settings, which are based on the index's creation date.</p></li></ul><p><strong>2. Settings:</strong> Remove the line <code>"template"</code> (line 3), as well as the very last closing brace for the entire JSON payload; Line 3 should start with <code>"settings": {</code>.</p><ul><li><p>Replace the inner contents of the settings section with <code>"index.codec": "best_compression"</code>. This action will apply Elastic's best compression to the index upon creation.</p></li><li><p>Add in <code>"index.lifecycle.name": "logs"</code>, as well as a line for <code>"index.lifecycle.rollover_alias": ""</code>.</p><ol><li><p>The <code>"index.lifecycle.name": "logs"</code> entry will apply the logs ILM policy to the new backing index. Modify the ILM policy name if you aren’t using logs.</p></li><li><p>The <code>"index.lifecycle.rollover_alias": ""</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.</p></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>"mappings": {</code>, you should find <code>"dynamic_templates"</code> and a <code>"properties"</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><p>Remove all sections in <code>"dynamic_templates"</code> except for the second section titled <code>"_embedded_ecs-data_stream_to_constant": {</code>.</p></li><li><p>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.</p><ul><li><p>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>"_embedded_ecs-data_stream_to_constant": {</code> entry. The <code>dynamic_templates</code> section should look very similar to the below sample contents in Dev Tools.</p></li></ul></li><li><p><strong>If </strong><strong><code>dynamic_templates</code></strong><strong> 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>"properties"</code> section under <code>"mappings"</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.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfb7494f882d7e358/6a170fffa6c2b93e46e797d2/24e972cd0fc8eadf943b21cfdd80a5d435e705aa-2048x994.png" alt="Split‑screen code editor showing Elasticsearch commands on the left and index mappings on the right. An arrow points to the text field type in the mapping, and another arrow points to the keyword subfield type." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcaca899f8d92e72b/6a1710010c485745e901ab5a/aac13fbe882516e5ed5b5b1b5271c0ae34e80b04-1890x2048.png" alt=": Screen showing the index pattern page for logs-* with a warning about mapping conflicts, with focus on the “keyword, text” type listings for agent.ephemeral_id and agent.id in the fields table." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2c692786ab74f1ca/6a171002964ceaa53c08bca1/c43d6f61c8ece4de2d51657f239a0c34ced07cdb-1928x1452.png" alt="Screen showing the index pattern page for logs-* with a warning about mapping conflicts. An arrow points to the type listing “ip, text” for the host.ip field." /><p><strong>5. Metadata removal:</strong> Delete the last section labeled <code>"_meta"</code>, as well as the section labeled <code>"version"</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.
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0906ffe338df1a9d/6a1710046f7f042aac914936/ebe1573647500de75315e7655256a0db9604c40d-2048x1402.png" alt="Code editor showing Elasticsearch index settings and mappings. A dropdown menu is open on the right, and an arrow points to the “Auto indent” option in the menu." /><p><strong>7. Mapping change:</strong> Navigate to the <code>"properties"</code> section, find <code>"log"</code>, and then locate <code>"offset"</code> nested underneath. Change the type from <code>keyword</code> to <code>long</code>, and remove the line entry (comma included) labeled <code>"ignore_above": 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>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"
      }
    }
  }
}<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>POST &lt;full data stream name&gt;/_rollover<p>For example: </p>POST logs-filestream.generic-default/_rollover<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e0ae084fe6ade43/6a171006a6c2b91078e797d6/22abc1a2f6de0420aa0d56ac498894111df7f4fd-2048x330.png" alt="Rollover result" /><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>POST _reindex?wait_for_completion=false
{
  "source": {
    "index": "&lt;source backing index&gt;"
  },
  "dest": {
    "index": "&lt;new backing index&gt;-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"
  }
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb30fd97a045b6008/6a171007cf4f2566d6b2d22b/22f9b1f762802ecd20faa7c7c1f76c9d1444aba5-2048x530.png" alt=" Task output" /><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>"completed": 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d40766e48cc0813/6a17100960084ba9043c4642/dbf0fb0a560a78236440b8c3de68cdf5c83e6d7a-2048x824.png" alt="Task summary" /><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>GET &lt;backing index&gt;-1/_mapping<p>For example:</p>GET .ds-logs-filestream.generic-default-2026.04.13-000001-1/_mapping<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc156907e9635e4a9/6a17100b60084b59673c464e/db5c12c0a651e804a916d517e6e260e49a8b835a-2048x1121.png" alt=" Mapping focus" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc27c4da42ddc33d/6a17100c7d8d67e0ad70e816/a0e49ac79edb0abf9fe99d0e6fd35e96d0e3e0e5-2048x880.png" alt="" /><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><p>The return should be an acknowledgment of true, if successful.</p></li></ul>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"
      }
    }
  ]
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1bb6db6c761628fb/6a17100e7d8d67533770e81a/0aa3233377c0175258d37eaa661d56cf9f310d5e-2048x1288.png" alt="" /><p>Verify the new backing index is added with the following command, making sure the <code>ilm_policy</code> is correct:</p>GET _data_stream/logs-filestream.generic-default<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7f4208ae6d7bf331/6a171010961e696241c4cfeb/af8b75cf260f6f088c28a78da86ad31527e0bfd5-2048x839.png" alt="" /><p>Check the ILM status of the backing index next with the following command:</p><ul><li><p>It’s normal to see that the index is in hot, as it was created very recently (review line 8 or 10).</p></li></ul>GET .ds-logs-filestream.generic-default-2026.04.14-000001-1/_ilm/explain<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt953e1a062a040311/6a171012acf0885905be9c29/cd181a31001c7a3ee2b0599a7388909ce5b50baf-2048x972.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd398451578d070c9/6a1710140e2e492dc041a204/20f6e7632804f173533e655f0292c3c540f26597-2048x894.png" alt="" /><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>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" 
  }
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt246a6538585234f1/6a1710160c4857ddc001ab60/7ae60b900ce1d0b46ce26ec301901bc8a9ef750c-2048x1249.png" alt="" /><ul><li><p>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.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf77bba4d9b495048/6a17101867045b3b7d45c2c1/58a460cf2ec443223ea68ba7e7166a7cf9d8c97a-2048x915.png" alt="" /><p>Once the following conditions are met, you can safely delete the original backing index that had mapping conflicts:</p><ol><li><p>A new backing index has been successfully created.</p></li><li><p>Documents have been moved to the new index, and the document counts match.</p></li><li><p>Mappings have been corrected (both data stream specific and ECS).</p></li><li><p>The data stream incorporates the new backing index.</p></li><li><p>The ILM policy has been applied and has moved the index out of the hot phase.</p></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>DELETE .ds-logs-filestream.generic-default-2026.04.14-000001<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt835a79be274513a3/6a17101aa929cf43c6ae0ab1/09d661b20a44929b4736a43eaa3df84180b25f30-2048x1295.png" alt="" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt127cb539b70acada/6a17101ba929cfbc66ae0ab5/1c3bb7029c99aa4bc6b0931f39f5648654b35ccd-2048x1204.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaa1eb678773c23d9/6a17101d4a531b00a636aa3b/0af1b1aa3a031c207aa5eb083696dd081d941e67-2048x1001.png" alt="" /><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><p><a href="https://www.elastic.co/docs/reference/ecs/ecs-field-reference">ECS field reference</a></p></li><li><p><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-reindex">Reindex documents</a></p></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>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Lisa Larribas]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9654eb32edb4a44a/6a17101fcdacbf0ac17d2ad8/2f2573aa3d29b3a628e4fce606c803add2641501-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 24 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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><p>Model</p><p>Type</p><p>Parameters</p><p>Key capability</p><p>Status on Model Garden</p><p>`jina-embeddings-v3`</p><p>Text embedding</p><p>572M</p><p>Proven multilingual workhorse, 8K context, 1024 dim output, truncatable to 32</p><p>Available now</p><p>`jina-embeddings-v5-text-small`</p><p>Text embedding</p><p>677M</p><p>State-of-the-art sub-1B multilingual, 32K context, 1024 dim output, truncatable to 32</p><p>Coming soon</p><p>`jina-embeddings-v5-text-nano`</p><p>Text embedding</p><p>239M</p><p>Best-in-class under 500M params, 8K context, 768 dim output, truncatable to 32</p><p>Coming soon</p><p>`jina-reranker-v3`</p><p>Reranker</p><p>600M</p><p>Listwise reranker, 131K context, up to 64 documents</p><p>Coming soon</p><p>`jina-clip-v2`</p><p>Multimodal embedding</p><p>900M</p><p>Text + image in shared space, 89 languages, and 8K text context, 512×512 images</p><p>Coming soon</p><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><p><strong>Four task-specific LoRA adapters:</strong> Retrieval, text-matching, classification, clustering. Selecting an appropriate adapter via <code>task</code> parameter at inference time.</p></li><li><p><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.</p></li><li><p><strong>Binary quantization:</strong> Compress 1024-dim embeddings from 2KB to 128 bytes with binarization. Special training makes this compression minimal losses.</p></li><li><p><strong>Multilingual: </strong>119 languages (small) and 93 (nano).</p></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><p><strong>89 languages</strong> for text-image retrieval.</p></li><li><p><strong>512×512 image resolution.</strong></p></li><li><p><strong>8K token text input.</strong></p></li><li><p><strong>Matryoshka truncation</strong> from 1024 to 64 dimensions for both modalities.</p></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>
    <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[Jina AI]]></category>
    <dc:creator><![CDATA[Sa Zhang]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1d9669e8c7a62bf6/6a170ee7a929cf0371ae0a87/42f72633f1e5453dbfd47730b5f776429f9f633e-721x420.png" length="0" type="image/png"/>
    <pubDate>Wed, 22 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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  we can think of the true value of a statistic as its value computed for a random variable with uniform discrete distribution on . In the following we denote this quantity ; it can be things like <code>AVG</code>, <code>MEDIAN</code>, and so on. If we make  independent draws from , denoted , such that each value is selected with probability , we have  independent copies of this random variable. The property we rely on means that a sample statistic value  computed from  approaches  as  becomes large. For example, if  is the mean of some metric values then  as  becomes large. Indeed, for many statistics the limiting error distribution is known to be normal. Furthermore, it only depends on the distribution of , the size of the sample  and the type of the aggregation . This means supported <code>STATS</code> queries can be approximated with fixed accuracy independent of the index size .</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'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'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've chosen to represent error is by a confidence interval: the -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>, , of the statistic being estimated. Specifically, it's the interval which contains the true value of the statistic with probability  whose endpoints are  and . 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><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'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'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 . However, they're computed with fewer values than our estimate . 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'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 . Here,  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  times for  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  out  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  which, when applied to a distribution sample normalizes its distribution:</p><p>Here, ,  and  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  is only constrained to be monotonic. (You can think of  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  never needs to be explicitly computed, and there exist standard approaches for estimating the parameters  and  from the distribution samples.</p><p>To handle  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  then , where  is the estimated statistic value, and as before  is the standard normal distribution function. Typically,  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.  with  and  their empirical mean and standard deviation, respectively.</p><p>To complete the procedure, one can rearrange (1) to derive  quantiles for  as follows:</p><p>where  is the standard normal z-score for quantile . Typically, one uses the inverse empirical cumulative density estimate of  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, , where  is the number of distribution samples we use. This is just applying the usual scaling of variance by .</p><p>Efron showed that in the case  is distributed as , i.e. that it depends only on the true value , then the acceleration  can be estimated without any knowledge of . In particular, . By assumption, our statistics tend to normal distributions with mean . Since skew is translation and scale invariant, this gives that , 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  and so we also adjust acceleration to be  to account for the count mismatch between the samples  and estimate .</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've been unable to confirm  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'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'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><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'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 , the distribution of the count of  failures for  tests is  for the case the null hypothesis, that the estimate is trustworthy, is true. For example, for the majority vote assuming  and  then the significance of the test is , 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' 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6e7b7df23a7e1faa/6a170ca0c1e8a505d7f88312/fb088c17f9755c0d1b3173fb917f0af2c0f83847-1712x950.png" alt="" /><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  samples to ensure that you have enough samples in the appropriate tail.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte33fb7bc54844a83/6a170ca2839dfa7a19dcff38/02a5375025e811ba18c4e823e1d984261bbf6f42-631x763.png" alt="" /><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' 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' actual errors.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3d4bc25885b66945/6a170ca360084b472d3c45b4/2d4ab88a07910edac7e8406ae4942694751f0090-1000x600.png" alt="" /><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' 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c4f729eb4c1127c/6a170ca5961e69c63cc4cf66/c0cd6d42f5d061aac15767539209a7c443ed1acd-1000x600.png" alt="" /><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.

</p>]]></content:encoded>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Thomas Veasey,Jan Kuipers]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c4f729eb4c1127c/6a170ca5961e69c63cc4cf66/c0cd6d42f5d061aac15767539209a7c443ed1acd-1000x600.png" length="0" type="image/png"/>
    <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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><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><p>The results might look something like this:</p>item_category        | count
---------------------+------
Household Essentials | 5165
Kitchen              | 2132
Storage              | 1121
Home Decor           | 877
Furniture            | 357<p>Approximating this query introduces some extra columns for each quantity that’s estimated:</p>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<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><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'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><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><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>SELECT SUM(AdvEngineID), COUNT(*), AVG(ResolutionWidth) FROM hits<p>becomes:</p><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><p>Queries which are rewritten to use index summary statistics (three queries).</p></li><li><p>Queries that perform well (13 queries).</p></li><li><p>Queries with high cardinality partitioning (seven queries).</p></li><li><p>Queries with restrictive filters (12 queries).</p></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've estimated the sample probability. Because we'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 faster if estimating the values and computing confidence intervals and 72 faster if just estimating the values, which you can select as follows: <code>SET approximation={"confidence_level":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><p>Query</p><p>Baseline / ms</p><p>Approximate with CI / ms</p><p>Approximate without CI / ms</p><p>3</p><p>1725</p><p>145</p><p>15</p><p>10</p><p>4340</p><p>1721</p><p>56</p><p>13</p><p>32912</p><p>6106</p><p>3821</p><p>21</p><p>46739</p><p>3284</p><p>2139</p><p>22</p><p>252505</p><p>6478</p><p>5019</p><p>Here are the corresponding queries:</p><p>We'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f6acbc3a2c9e58f/6a170ee34a531bbd2c36aa17/9ab83c13f42f88253a242d78339356f4a7c48700-2094x1358.png" alt="approximation-of-clickbench-query-13" /><p>For category 3, we get an average speedup of . 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><p>Query</p><p>Baseline / ms</p><p>Approximate with CI / ms</p><p>Approximate without CI / ms</p><p>15</p><p>8256</p><p>1187</p><p>124</p><p>17</p><p>70641</p><p>2109</p><p>982</p><p>Here are the corresponding queries:</p><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><p>Queries using the <code>TS</code> source command.</p></li><li><p>Queries using the <code>FORK</code> or <code>JOIN</code> processing command.</p></li><li><p>Pipelines which use two or more <code>STATS</code> commands.</p></li><li><p>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.</p></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><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'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' 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><p>Occasionally, functions significantly alter the distribution characteristics of the quantities they act on. A contrived example is the following:</p><p>If the variation in the estimate <code>sl</code> is much larger than  we expect the distribution of <code>csl</code> to be mainly flat in the interval  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>
    <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[ES|QL]]></category>
    <dc:creator><![CDATA[Jan Kuipers,Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba968f10a7cac60c/6a170ee50c48571b5301ab34/17afc59be8a46957a341faec1f44c9cb0a221894-1918x1176.png" length="0" type="image/png"/>
    <pubDate>Thu, 16 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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></a><a href="https://www.elastic.co/docs/explore-analyze/machine-learning/anomaly-detection/ml-configuring-aggregation"> 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't wizard territory.</strong> The standard Kibana job wizards (Single Metric, Multi-Metric, Population) don't expose aggregation configuration. To create an aggregation-based job, you need either the Elasticsearch API or Kibana'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's no schema validation that catches a misnamed aggregation key or a <code>fixed_interval</code> that doesn'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 "actual" data curve by re-querying the index, but it can'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't rely on the Single Metric Viewer'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><p>Function</p><p>Pattern</p><p>`count`, `mean`, `high_mean`, `low_mean`, `sum`, `max`, `min`</p><p>Standard: `date_histogram` → `terms` → metric aggregation</p><p>`time_of_day`, `time_of_week`</p><p>Minimal: plain `date_histogram`, no `terms` or metric needed</p><p>`rare`, `freq_rare`, `info_content`</p><p>Composite: top-level composite with `date_histogram` as a source</p><p>`categorization`</p><p>`terms` on the `.keyword` subfield of the categorization field</p><p>`lat_long`, `varp`</p><p>Scroll only</p><p><code>lat_long</code> and <code>varp</code> are the genuine exceptions. If you want to use these detectors, you are required to use the scroll-based datafeed configuration.</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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4144dac3e60cccbf/6a16fa0514b2704508e3c412/77cd16165133374a04dbcf71210ea8d36f66b54f-1999x924.png" alt="Flowchart illustrating how to configure Elasticsearch ML datafeed aggregations, showing steps for summary fields, bucket topology, timestamp handling, field mapping, and detector metrics." /><p><strong>Step 1: Add </strong><strong><code>summary_count_field_name: </code></strong><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-doc-count-field"><strong><code>"doc_count"</code></strong></a><strong> 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 </strong><a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-max-aggregation"><strong><code>max</code></strong></a><strong> aggregation on </strong><strong><code>@timestamp</code></strong><strong>.</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 </strong><a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-terms-aggregation"><strong><code>terms</code></strong></a><u><strong> aggregation</strong></u>, 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><p>ML function</p><p>Elasticsearch aggregation</p><p>`mean` / `high_mean` / `low_mean`</p><p>`avg`</p><p>`sum`</p><p>`sum`</p><p>`max`</p><p>`max`</p><p>`min`</p><p>`min`</p><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbd1e470b4b641b8a/6a16fa072d2f505cd7c3c0e4/75b692b9f38017cd7e4e221d2e89a14f75d3b9dc-1999x1905.png" alt="Elastic “Add data” page showing sample datasets, including ecommerce orders, flight data, and web logs, with the web logs option highlighted." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb47f8491b3a60622/6a16fa09acf088a614be98f6/b5bb2f2770a76fd535db22b97fc4f72471c43ca7-1999x587.png" alt="Kibana interface showing the “Create job” step for anomaly detection, with a panel listing available data views and the “Kibana Sample Data Logs” option selected." /><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><p><strong>High mean</strong> of <code>bytes</code>.</p></li><li><p><strong>Split data by</strong> <code>geo.dest</code>.</p></li><li><p><strong>Bucket span:</strong> <code>1h</code>.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcd0c896c1510795c/6a16fa0bab7f084ea8db9c62/b3055c91c881e4011521ba0c17cc36c6138595ee-1999x1540.png" alt="Kibana anomaly detection job summary showing a multi‑metric chart split by geographic destination and a configuration panel listing job ID, bucket span, split field, influencers, memory limit, and time range." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfc768a5bb02a3a12/6a16fa0d92262a59d61cc0a5/32a1650958525b03a6052d480152933341acdd41-1999x1392.png" alt="Side‑by‑side JSON showing an Elasticsearch ML job configuration and its matching datafeed configuration, including detectors, influencers, index selection, query, and runtime mappings." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt883a9c3cfbe84597/6a16fa0f839dfae25edcfce1/388d279820a639c08b119753f064b3a948ace8c6-1999x1591.png" alt="Kibana multi‑metric anomaly detection job summary showing a line chart split by geographic destination and a configuration panel with job settings, time range, and creation options." /><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>{
  "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"]
}<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>{
  "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" }
          }
        }
      }
    }
  }
}<p>A few notes on this config:</p><ul><li><p><strong>Step 2:</strong> The <code>date_histogram</code> uses <code>fixed_interval</code>: <code>"1h"</code>, matching <code>bucket_span</code> exactly. A mismatch produces incorrect bucket timing.</p></li><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a6a2a786c4bb21a/6a16fa11a6c2b90ab5e794e7/638eeb2ae5b854195dec0e468887300b9afd2c58-1999x1254.png" alt="Three‑panel view showing an ML job configuration JSON, a matching datafeed JSON with aggregations, and a datafeed preview listing timestamped, bucketed results with fields like geo.dest, bytes, and doc_count." /><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>“Analysis_config”: {
  "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"]
}<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, next to the <code>date_histogram</code>:</p>"aggregations": {
    "buckets": {
      "composite": {
        "size": 1000,
        "sources": [
          { "timestamp": { "date_histogram": { "field": "timestamp", "fixed_interval": "1h" } } },
          { "geo.dest": { "terms": { "field": "geo.dest" } } },
          { "machine.os": { "terms": { "field": "machine.os.keyword" } } }
        ]
      },
      "aggregations": {
        "timestamp": { "max": { "field": "timestamp" } },
        "bytes": { "avg": { "field": "bytes" } }
      }
    }
  }<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><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.
<strong>Analysis config:</strong></p>{
  "bucket_span": "1h",
  "summary_count_field_name": "doc_count",
  "categorization_field_name": "message",
  "detectors": [
    {
      "function": "count",
      "by_field_name": "mlcategory"
    }
  ],
  "influencers": ["mlcategory"]
}<p><strong>Datafeed aggregations:</strong></p>{
  "buckets": {
    "date_histogram": {
      "field": "@timestamp",
      "fixed_interval": "1h"
    },
    "aggregations": {
      "@timestamp": {
        "max": { "field": "@timestamp" }
      },
      "message": {
        "terms": {
          "field": "message.keyword",
          "size": 1000
        }
      }
    }
  }
}<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 </strong><strong><code>time_of_day</code></strong><strong> and </strong><strong><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></a><a href="https://www.elastic.co/docs/reference/machine-learning/ml-time-functions"> and </a><a href="https://www.elastic.co/docs/reference/machine-learning/ml-time-functions"><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.
<strong>Analysis config:</strong></p>{
  "bucket_span": "15m",
  "summary_count_field_name": "doc_count",
  "detectors": [
    { "function": "time_of_day" }
  ]
}<p><strong>Datafeed aggregations:</strong></p>{
  "time": {
    "date_histogram": {
      "field": "@timestamp",
      "fixed_interval": "15m"
    },
    "aggregations": {
      "@timestamp": { "max": { "field": "@timestamp" } }
    }
  }
}<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 </strong><strong><code>rare</code></strong><strong>, </strong><strong><code>freq_rare</code></strong><strong>, and </strong><strong><code>info_content</code></strong></h2><p><a href="https://www.elastic.co/docs/reference/machine-learning/ml-rare-functions"><code>rare</code></a><a href="https://www.elastic.co/docs/reference/machine-learning/ml-rare-functions">, </a><a href="https://www.elastic.co/docs/reference/machine-learning/ml-rare-functions"><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>{
  "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" } }
    }
  }
}<p>A few notes:</p><ul><li><p>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.</p></li><li><p>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>.</p></li><li><p>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.</p></li><li><p><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.</p></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'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'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'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>
    <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[ML Research]]></category>
    <dc:creator><![CDATA[Valeriy Khakhutskyy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt584a9fa4d6ed3889/6a16fa13a6c2b97c15e794eb/023e3e6cb25891f789129d496c181113cc570f1f-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p>IDE: Intellij IDEA version &gt;= 253 (community or ultimate)</p></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>// ES|QL
String query = """
""";<p>If you see the Elastic logo appearing on the left:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt987846e694123956/6a170bc8dc55decc5ce00e21/620d47b0c241271ab9bf727c37d3ab5f4137ca44-417x55.png" alt="Code editor showing an ES|QL comment and a string variable being initialized for a query, with the Elastic icon in the gutter." /><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><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5069560e513f3ff7/6a170bcaa29299108cd0104e/9f5109542c921ad523458b9156551bf1fca7d41a-418x269.png" alt="Elasticsearch connection panel showing a “local” dropdown, status marked “Not connected,” and a Connect button in a dark-themed interface." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf73cededd56ba9cc/6a170bcba6c2b93aabe79739/9fe37a53d97f67cbdd8623793bce768e8e2f9ced-577x298.png" alt="Dialog box for adding an Elasticsearch connection, showing fields for name, URL, API key, refresh rate, and buttons to test or confirm the connection." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt229d9a3bc33091f4/6a170bcc60084b84183c4590/987a927ab0e682bb1f9d07c934dd4254a769db20-584x252.png" alt="Java editor showing an ES|QL query with an autocomplete menu listing keywords like WHERE, STATS, DISSECT, FORK, and KEEP." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1359cdac9ed83fa4/6a170bce961e6982e1c4cf4e/5f8e379ea1cca345b38d0b7a0c2a873db6de624f-584x252.png" alt="Java editor showing an ES|QL query with an autocomplete panel listing field suggestions such as field, name, title, vector, and string." /><h3>Syntax check</h3><p>The plugin will highlight errors in queries, explaining what to fix:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc935a693e4fcf2eb/6a170bcf67045b724745c216/15e91eab6c00fb49cfa5c1c6e270beeba534afc3-812x252.png" alt="Java editor showing an ES|QL query with an invalid keyword after a pipe operator and a tooltip explaining the syntax error." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4aca4151e89f1d1b/6a170bd167045b597945c21a/c78295103c2188a3615edb2e001acbaf17523656-1072x627.png" alt="ava editor showing an ES|QL query alongside a documentation panel explaining how the WHERE clause works, including syntax, parameters, and examples." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt96849d8f88f75832/6a170bd38b73cb7e2118a066/bff17bee7269dfb6f30140c9cd78dc203990f21c-1130x441.png" alt="IDE window showing an ES|QL query in a text file and a results panel below it listing returned book records with columns like author, title, year, and ID." /><p>Or if you’re writing an application, you can use the Java client like so:</p>// ES|QL
String query = """
	FROM my-index
| SORT year DESC
| LIMIT 10
""";

try (ElasticsearchClient client = ElasticsearchClient.of(e -&gt; e
                .host(serverUrl)
                .apiKey(apiKey))) {

client.esql().query(QueryRequest.of(qr -&gt; qr.query(query)));

}<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>
    <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[ES|QL]]></category>
    <category><![CDATA[Java]]></category>
    <dc:creator><![CDATA[Laura Trotta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt050f40a347c2ebb8/6a170bd414b2706a45e3c644/91366de35a1b66860ce0d126c8a83e5b25b678f0-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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's query and the retrieval engine. This layer performs the following functions:</p><ul><li><p>Classifies query intent: Is this navigation ("oranges") or discovery ("gift for grandpa")?</p></li><li><p>Applies business constraints: What category boundaries, eligibility rules, availability constraints, or merchandising policies apply?</p></li><li><p>Routes to the appropriate strategy: Should this use lexical retrieval, semantic retrieval, or hybrid?</p></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 "spaghetti" 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdd7b33454d925cfd/6a1710f1e8fbce25ee39fd4d/f532b099ee103458e15563a711dae92952f8df02-1024x765.png" alt="Comparison of hard‑coded application logic and Elasticsearch, showing how Elasticsearch simplifies ranking and retrieval without complex if‑then rules." /><p>This approach can provide desired search results as shown above; however, it creates significant operational friction:</p><ul><li><p><strong>Engineering dependency:</strong> Business users and merchandisers cannot modify search behavior without engineering tickets and long deployment cycles that often span several weeks.</p></li><li><p><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.</p></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><p><strong>Deterministic, high-intent navigation</strong> ( "oranges", “milk”, “chocolate without peanuts”, “cheap olive oil”).</p></li><li><p><strong>Exploratory discovery</strong> ("jacket for hiking in the mountains", "gift for a 12-year-old who likes robotics").</p></li><li><p><strong>Operational constraints</strong> (availability, size, price, color).</p></li><li><p><strong>Merchandising and campaigns</strong> (boost, bury, seasonal campaigns).</p></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't recognize this as a governance gap, they respond with the only lever they have: more tuning.</p><h2>Why "relevance tuning" can become cyclical</h2><p>Without a routing layer, “relevance” often turns into a never-ending backlog:</p><ul><li><p>Why is this query showing accessories above the core product?</p></li><li><p>Why did this head query suddenly start surfacing related items?</p></li><li><p>Why did results change after we added synonyms, adjusted analyzers, or enabled hybrid?</p></li><li><p>Why does the business team need an engineering release to fix a single query?</p></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><p>Single-item intent ("oranges", "milk", "bread").</p></li><li><p>Exact brands or product families ("iPhone 15 Pro", "Diet Coke").</p></li><li><p>SKUs, model numbers, sizes ("ABC123", "air max 270").</p></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><p>"Gift for grandpa who has a sweet tooth"</p></li><li><p>"Jacket for hiking in the mountains"</p></li><li><p>"Shoes for standing all day"</p></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><p><strong>Oranges:</strong> Lexical retrieval for “oranges” plus a category constraint, such as “Fruits” or “Produce”, eliminating orange marmalade, orange juice, and orange soda.</p></li><li><p><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.</p></li><li><p><strong>Comfortable shoes for work:</strong> Semantic retrieval for contextual intent plus a category constraint limiting results to shoes.</p></li></ul><p>These queries can't be handled by a single approach:</p><ul><li><p><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.</p></li><li><p><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.</p></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><p><strong>Over-constraining:</strong> Using lexical retrieval for semantic requests (for example, "gift for grandpa").</p></li><li><p><strong>Under-constraining: </strong>Using semantic queries for high-intent head queries (for example, “oranges”).</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b4595242ea6eb05/6a1710f35091684ba3e1bbd0/99abc7a46f9c56a26a68d0a089d7ab830b9b5568-1560x814.png" alt=" Illustration showing how a single query for “oranges” returns different related results, such as marmalade, fresh oranges, and orange soda." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltff1aba60c7b13fc8/6a1710f58b73cb3cef18a117/c9de86363ecbed499fe48259f47b3c5b2c26bc43-1568x796.png" alt="Diagram showing how a query for “oranges” routes to different fruit categories, including apples, oranges, and mixed fruit." /><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><p>Similar or related items appearing alongside what the user actually wanted.</p></li><li><p>Blurred category boundaries ("beverages" versus. "produce").</p></li><li><p>Inability to implement seasonal boosts or campaigns.</p></li><li><p>Unpredictable and unexplainable results.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt373bd838e1751998/6a1710f74a531b1e5436aa57/88c3d0f9731a128d73a765dcdffed897308110a6-2680x766.png" alt="Diagram showing how different queries route through a control plane to BM25 or semantic search results." /><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><p>Is this query likely navigation versus discovery?</p></li><li><p>Is it a known head query (milk, bread, bananas)?</p></li><li><p>Is there a known product, brand, or category interpretation (for example, “oranges” should resolve to produce).</p></li><li><p>Is the query an SKU-like pattern?</p></li><li><p>Does the query fall under an active campaign or seasonal policy (for example, during Christmas, boost turkey-related results)?</p></li><li><p>Does the query imply constraints (category, attributes, exclusions, price/size/color)?</p></li></ul><p><strong>2. Apply governance and business policies</strong></p><ul><li><p>Enforce deterministic constraints first (category/attribute/negation/availability).</p></li><li><p>Apply active merchandising policies (boost/bury/pin/override).</p></li><li><p>Resolve conflicts with precedence rules (for example, campaign overrides versus global policies).</p></li></ul><p><strong>3. Route to the appropriate retrieval strategy</strong></p><ul><li><p>Lexical (fast, deterministic) for navigational/high-intent head queries.</p></li><li><p>Semantic retrieval for true discovery queries.</p></li><li><p>Hybrid where combined lexical and semantic signals add value under explicit business constraints.</p></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><p>Shopper query</p><p>Governed interpretation</p><p>Example retrieval plan</p><p>“chocolate without peanuts”</p><p>Product-oriented query with a hard exclusion constraint</p><p>Lexical retrieval for chocolate plus an exclusion filter for products containing peanuts</p><p>“cheap olive oil”</p><p>Product/category query with a price constraint</p><p>Lexical retrieval for olive oil plus a price filter capped at the retailer’s threshold for cheap</p><p>“fruit high in vitamin C under $4”</p><p>Discovery query requiring semantic understanding plus hard constraints</p><p>Semantic retrieval for nutritional intent, constrained to the fruit category and filtered to products priced under $4</p><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't the engine; it'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>.  </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>
    <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[Operations]]></category>
    <dc:creator><![CDATA[Alexander Marquardt,Honza Král,Taylor Roy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt840c7a0a5b92080f/6a1710f967045b3e5445c2cd/3793259b01a5653a7520393a2f006610de0d21e7-1280x720.png" length="0" type="image/png"/>
    <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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't provide a built-in "diff" 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'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><p><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.</p></li><li><p><strong>IDs are generated</strong>: Documents were ingested through different pipelines that assigned random or sequential IDs. You can't compare by <code>_id</code>; you need to match on content.</p></li></ol><p>Let'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's a typical <code>_search</code> call with raw <code>curl</code>:</p>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"<p>With <code>escli</code>, the same request becomes:</p>./escli search --index my-index-000001 &lt;&lt;&lt; '{"query":{"term":{"user.id":"kimchy"}}}'<p>The credentials live in a <code>.env</code> file that escli sources automatically — no <code>-H "Authorization: ..."</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>./escli count --index index-a
./escli count --index index-b<p>The <code>_count</code> API returns:</p>{ "count": 1000000 }<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><p>The same document always gets the same <code>_id</code>, regardless of which pipeline ingested it.</p></li><li><p>You can easily update or delete documents by ID.</p></li><li><p>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.</p></li><li><p>No client-side scanning or comparison is needed.</p></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>./escli reindex &lt;&lt;&lt; '{
  "source": { "index": "index-a" },
  "dest":   { "index": "index-b", "op_type": "create" },
  "conflicts": "proceed"
}'<p>The response tells you exactly what happened:</p>{
  "total": 1000000,
  "created": 49594,
  "version_conflicts": 950406,
  "failures": []
}<ul><li><p><code>created</code>: Documents that were missing from <code>index-b</code> and have now been added.</p></li><li><p><code>version_conflicts</code>: Documents that already existed in <code>index-b</code> and were left untouched.</p></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'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: "abc123"</code> in one index and <code>_id: "xyz789"</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 "missing" 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>./escli open_point_in_time index-a 5m
# → { "id": "46ToAwMDaWR..." }./escli search &lt;&lt;&lt; '{
  "size": 10000,
  "_source": ["first_name", "last_name", "birth_date"],
  "pit": { "id": "46ToAwMDaWR...", "keep_alive": "5m" },
  "sort": [{ "_shard_doc": "asc" }]
}'<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>./escli close_point_in_time &lt;&lt;&lt; '{"id": "46ToAwMDaWR..."}'<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>./escli msearch &lt;&lt; '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<p>The response contains one entry per subquery, in the same order:</p>{
  "responses": [
    { "hits": { "total": { "value": 1 } } },
    { "hits": { "total": { "value": 0 } } }
  ]
}<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><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's <code>mapping.json</code> includes this.<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>[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<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><p>Strategy</p><p>Compare</p><p>Reindex</p><p>Total</p><p>How it works</p><p>op_type</p><p></p><p>6s</p><p>6s</p><p>Full _reindex server-side, skips existing</p><p>business-key</p><p>1m 38s</p><p>4s</p><p>1m 42s</p><p>PIT scan + _msearch by business key</p><p>split-by-date</p><p>32s</p><p>4s</p><p>36s</p><p>Same as business-key, 5 slices in parallel</p><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>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)<h2>Conclusion</h2><p>Elasticsearch doesn't offer a native index diff command, but the right strategy depends on your data model:</p><ul><li><p><strong>Use functional</strong> <strong><code>_id</code></strong><strong>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.</p></li><li><p><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.</p></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>
    <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[Basics]]></category>
    <dc:creator><![CDATA[David Pilato]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt87c19debc74944e3/6a170e09286714495393e3ae/099abf465250360ab741a5aa13931fa8884ded34-1376x768.png" length="0" type="image/png"/>
    <pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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>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; }
}<p>With the type in place, a query looks like this:</p>var minPrice = 100.0;
var brand = "TechCorp";

await foreach (var product in client.Esql.QueryAsync&lt;Product&gt;(q =&gt; q
    .From("products")
    .Where(p =&gt; p.InStock &amp;&amp; p.Price &gt;= minPrice &amp;&amp; p.Brand == brand)
    .OrderByDescending(p =&gt; p.Price)
    .Take(10)))
{
    Console.WriteLine($"{product.Name}: ${product.Price}");
}<p>The provider translates this into the following ES|QL:</p><p>A few details to note:</p><ul><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></li></ul><p>You can also inspect the generated query and its parameters without executing it:</p>var query = client.Esql.CreateQuery&lt;Product&gt;()
    .Where(p =&gt; p.InStock &amp;&amp; p.Price &gt;= minPrice &amp;&amp; p.Brand == brand)
    .OrderByDescending(p =&gt; p.Price)
    .Take(10);

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

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

var parameters = query.GetParameters();
// { "minPrice": 100.0, "brand": "TechCorp" }<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>// IEnumerable: the lambda is a compiled delegate
IEnumerable&lt;Product&gt; local = products.Where(p =&gt; p.Price &gt; 100);

// IQueryable: the lambda is an expression tree, a data structure
IQueryable&lt;Product&gt; remote = queryable.Where(p =&gt; p.Price &gt; 100);<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt521838e8b9c36649/6a1705b1839dfa5f40dcfdfe/f864cd18a390831f8d28503a29b5835efb1842f7-1000x720.png" alt="Expression tree for the example query." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt930670a505dd61ea/6a1705b3b339d58a54769ecf/2a2c772b63d720f61fc9a28b2f85668fa2db8d38-1999x1036.png" alt="Translation pipeline overview." /><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><p>Visitor</p><p>Translates</p><p>Into</p><p>WhereClauseVisitor</p><p>.Where(predicate)</p><p>WHERE condition</p><p>SelectProjectionVisitor</p><p>.Select(selector)</p><p>EVAL + KEEP + RENAME</p><p>GroupByVisitor</p><p>.GroupBy().Select()</p><p>STATS ... BY</p><p>OrderByVisitor</p><p>.OrderBy() / .ThenBy()</p><p>SORT field [ASC\|DESC]</p><p>EsqlFunctionTranslator</p><p>EsqlFunctions.*, Math.*, string methods</p><p>80+ ES|QL functions</p><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></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt788c9936976f2f62/6a1705b50e2e4910da419ff0/2adc349b6cf655b96b7b3e826a134e8a17fe42fd-1999x1036.png" alt="Query model and command pattern." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt662bd0dd8861b6b6/6a1705b7a929cf7086ae08a2/41b8aae860ecdc2480edcb1c1d4cc9b03cfb78c9-1999x1036.png" alt="Package architecture." /><p><em>Package architecture.</em>
<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>// Translation-only: no Elasticsearch connection needed
var provider = new EsqlQueryProvider();
var query = new EsqlQueryable&lt;Product&gt;(provider)
    .From("products")
    .Where(p =&gt; p.InStock)
    .OrderByDescending(p =&gt; p.Price);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE in_stock == true | SORT price_usd DESC<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>var stats = client.Esql.Query&lt;Product, object&gt;(q =&gt; q
    .GroupBy(p =&gt; p.Brand)
    .Select(g =&gt; new
    {
        Brand = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p =&gt; p.Price),
        MaxPrice = g.Max(p =&gt; p.Price)
    }));

// -&gt; FROM products | STATS COUNT(*), AVG(price_usd), MAX(price_usd) BY brand<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>var query = client.Esql.CreateQuery&lt;Product&gt;()
    .Select(p =&gt; new { ProductName = p.Name, p.Price, p.InStock });

// -&gt; FROM products | KEEP name, price_usd, in_stock | RENAME name AS ProductName<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>.Where(p =&gt; p.Name.Contains("Pro"))       // -&gt; WHERE name LIKE "*Pro*"
.Where(p =&gt; EsqlFunctions.CidrMatch(      // -&gt; WHERE CIDR_MATCH(ip, "10.0.0.0/8")
    p.IpAddress, "10.0.0.0/8"))<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>var enriched = client.Esql.Query&lt;Product, object&gt;(q =&gt; q
    .LookupJoin&lt;Product, CategoryLookup, string, object&gt;(
        "category-lookup-index",
        product =&gt; product.Id,
        category =&gt; category.CategoryId,
        (product, category) =&gt; new { product.Name, category!.CategoryLabel }));<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>var results = client.Esql.Query&lt;Product&gt;(q =&gt; q
    .Where(p =&gt; p.InStock)
    .RawEsql("| EVAL discounted = price_usd * 0.9"));<h3>Server-side async queries</h3><p>For long-running queries, submit them for background processing on the server:</p>await using var asyncQuery = await client.Esql.SubmitAsyncQueryAsync&lt;Product&gt;(
    q =&gt; q.Where(p =&gt; 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);<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><p><strong>Elastic.Clients.Elasticsearch v9.3.4</strong> (9.x branch)</p></li><li><p><strong>Elastic.Clients.Elasticsearch v8.19.18</strong> (8.x branch)</p></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><p>Method</p><p>Returns</p><p>Use case</p><p>Query&lt;T&gt;(...)</p><p>IEnumerable&lt;T&gt;</p><p>Synchronous execution</p><p>QueryAsync&lt;T&gt;(...)</p><p>IAsyncEnumerable&lt;T&gt;</p><p>Async streaming</p><p>CreateQuery&lt;T&gt;()</p><p>IEsqlQueryable&lt;T&gt;</p><p>Advanced composition and inspection</p><p>SubmitAsyncQueryAsync&lt;T&gt;(...)</p><p>EsqlAsyncQuery&lt;T&gt;</p><p>Long-running server-side queries</p><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'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>
    <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[ES|QL]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Florian Bernd,Martijn Laarman]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdfa35fbcbbf4959f/6a1705b9dc55de19a4e00d07/e54132e915217063e9ed0ec45059c6cfc38e31dd-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p>Elasticsearch 8.15+ (for <code>::</code> cast operator support; core ES|QL features available from 8.11)</p></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>PUT my-index/_mapping
{
  "runtime": {
    "full_address": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['address'].value + ':' + doc['port'].value)"
      }
    }
  }
}<p>This works, but comes with trade-offs:</p><ul><li><p><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.</p></li><li><p><strong>Performance cost:</strong> Runtime fields evaluate per document at query time. Elasticsearch classifies them as "expensive queries" that <a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields#runtime-compromises">can be rejected</a> by cluster settings.</p></li><li><p><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.</p></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'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>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" }
    }
  }
}<p>Now index some sample documents:</p>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" }
<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>"US-EAST"</code> instead of <code>"us-east"</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>GET server-logs/_search
{
  "runtime_mappings": {
    "endpoint": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['host'].value + ':' + doc['port'].value)"
      }
    }
  },
  "fields": ["endpoint"],
  "_source": false
}<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>POST _query
{
  "query": """
    FROM server-logs
    | EVAL endpoint = CONCAT(host, ":", port)
    | KEEP host, port, endpoint
    | LIMIT 1
  """
}<p>Response:</p>{
  "columns": [
    { "name": "host", "type": "keyword" },
    { "name": "port", "type": "keyword" },
    { "name": "endpoint", "type": "keyword" }
  ],
  "values": [
    ["web-01", "8080", "web-01:8080"]
  ]
}<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 </em><em><code>POST _query { "query": "..." }</code></em><em> 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's regex <a href="https://docs.oracle.com/javase/8/docs/api/java/util/regex/Matcher.html">Matcher</a> class:</p>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
}<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><p>Response:</p>{
  "columns": [
    { "name": "user", "type": "keyword" },
    { "name": "log_level", "type": "keyword" },
    { "name": "action", "type": "keyword" },
    { "name": "duration", "type": "keyword" }
  ],
  "values": [
    ["alice", "INFO", "login", "230ms"], ...
  ]
}<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><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>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
}<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><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><p>Response:</p>{
  "columns": [
    { "name": "host", "type": "keyword" },
    { "name": "response_time", "type": "keyword" },
    { "name": "response_ms", "type": "long" }
  ],
  "values": [
    ["web-01", "145", 145]
  ]
}<p>Both produce the same result. The key difference from Painless: <strong>Failed conversions return </strong><strong><code>null</code></strong><strong> instead of throwing exceptions</strong>. The document with <code>"not_available"</code> simply gets <code>null</code> for <code>response_ms</code>, and ES|QL emits a warning.</p><p>Common conversion functions include:</p><p>Function</p><p>Converts to</p><p>`TO_LONG()`</p><p>Long integer</p><p>`TO_INTEGER()`</p><p>Integer</p><p>`TO_DOUBLE()`</p><p>Double</p><p>`TO_DATETIME()`</p><p>Date</p><p>`TO_BOOLEAN()`</p><p>Boolean</p><p>`TO_IP()`</p><p>IP address</p><p>`TO_VERSION()`</p><p>Version</p><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'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>"dynamic": "runtime"</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>{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": { "type": "date" }
    }
  }
}<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>PUT dynamic-logs/_mapping
{
  "runtime": {
    "custom_field": {
      "type": "keyword",
      "script": {
        "source": "emit(params._source['custom_field'])"
      }
    }
  }
}<p>Once defined, ES|QL can query it:</p><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>"US-EAST"</code> versus <code>"us-east"</code>)?</p>GET server-logs/_search
{
  "runtime_mappings": {
    "region": {
      "type": "keyword",
      "script": {
        "source": "emit(params._source['region'].toLowerCase())"
      }
    }
  },
  "fields": ["region"],
  "_source": false
}<p>This overrides the indexed <code>region</code> field for all queries. Every search, aggregation, and Kibana visualization will see the lowercase version.</p><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><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><p>This single query:</p><ul><li><p><strong>Extracts</strong> fields from raw text (<code>GROK</code>).</p></li><li><p><strong>Converts</strong> the duration to a number (<code>EVAL</code> with cast).</p></li><li><p><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>).</p></li><li><p><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>).</p></li><li><p><strong>Aggregates</strong> by region (<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS</code></a>).</p></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><h2>Conclusion</h2><p>What we covered:</p><ul><li><p>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.</p></li><li><p>Failed type conversions in ES|QL return <code>null</code> instead of throwing exceptions, making it safer for real-world data.</p></li><li><p>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.</p></li><li><p>Runtime fields remain valuable for persistent computed fields, field shadowing across all queries, and as a bridge for unmapped data in ES|QL.</p></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><p><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL documentation</a></p></li><li><p><a href="https://www.elastic.co/docs/manage-data/data-store/mapping/runtime-fields">Runtime fields reference</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/esql-timeline-of-improvements">ES|QL timeline of improvements</a></p></li><li><p><a href="https://www.elastic.co/blog/getting-started-with-elasticsearch-runtime-fields">Getting started with runtime fields</a></p></li><li><p><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></p></li></ul>]]></content:encoded>
    <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[Query Languages]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt087dcbc58050f3f6/6a170a27964cea446908bb35/657ec44d182de78e6ddabb6632c6844b5a36774d-720x420.png" length="0" type="image/png"/>
    <pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p><a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">Elastic Agent Builder MCP server for Elasticsearch 9.2+</a></p></li><li><p><a href="https://github.com/elastic/mcp-server-elasticsearch?tab=readme-ov-file#elasticsearch-mcp-server">Elasticsearch MCP server for older versions (Python)</a></p></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'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'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'll use Elasticsearch for retrieval, OpenAI'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad9133cb083ad352/6a170c19b0367d411e72bd5b/ec5771a874cf9740d4cac6888622cbe8cd6aede7-1999x1133.png" alt="Creating an Elastic MCP server with TypeScript and Claude Desktop." /><h2>Prerequisites:</h2><ul><li><p>Node.js 20 +</p></li><li><p>Elasticsearch</p></li><li><p>OpenAI API key</p></li><li><p>Claude Desktop</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt06fd7a02042094e1/6a170c1b14b2700024e3c651/66eb0b11473347b6cf2d85718251eeac38d6249d-1999x1491.png" alt="Claude 4.5 Sonnet page, with the note, &quot;Coffee and Claude time? How can I help you today?&quot;" /><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><p><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></p></li><li><p><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></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#test-it-out">Test it out.</a></p></li></ol><h3>Configure MCP server for Elasticsearch</h3><p>To begin, let's initialize a node application:</p>npm init -y<p>This will create a <code>package.json</code> file, and with it, we can start installing the necessary dependencies for this application.</p>npm install @elastic/elasticsearch @modelcontextprotocol/sdk openai zod &amp;&amp; npm install --save-dev ts-node @types/node typescript<ul><li><p><strong>@elastic/elasticsearch</strong> will give us access to the Elasticsearch Node.js library.</p></li><li><p><strong>@modelcontextprotocol/sdk</strong> provides the core tools to create and manage an MCP server, register tools, and handle communication with MCP clients.</p></li><li><p><strong>openai</strong> allows interaction with OpenAI models to generate summaries or natural language responses.</p></li><li><p><a href="https://zod.dev/"><strong>zod</strong></a>helps define and validate structured schemas for input and output data in each tool.</p></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>{
    "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"]
}<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>// 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";<p>Also, let’s initialize the clients to handle the Elasticsearch and OpenAI calls:</p>const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
});

const _client = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: {
    apiKey: ELASTICSEARCH_API_KEY,
  },
});<p>To make our implementation more robust and ensure structured input and output, we'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>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&lt;typeof DocumentSchema&gt;;
type SearchResult = z.infer&lt;typeof SearchResultSchema&gt;;<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>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",
});<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><p><strong><code>search_docs</code></strong><strong>: </strong>Searches for documents in Elasticsearch using full-text search.</p></li><li><p><strong><code>summarize_and_cite</code></strong><strong>:</strong> Summarizes and synthesizes information from previously retrieved documents to answer a user question. This tool also adds citations referencing the source documents.</p></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><p><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.</p></li><li><p><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't used in this MCP server, it can be useful if you want to develop other tools or process the results programmatically.</p></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>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 }) =&gt; {
    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) =&gt; {
        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) =&gt;
            `[${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,
      };
    }
  }
);<p><em>We configure </em><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-fuzzy-query"><em><code>fuzziness</code></em></a><em><code>: “AUTO”</code></em><em> to have a variable typo tolerance based on the length of the token that’s being analyzed. We also set </em><em><code>title^2</code></em><em> 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>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 }) =&gt; {
    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) =&gt;
            `[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) =&gt; ({
        id: r.id,
        title: r.title,
        tags: r.tags,
        relevance_score: r.score,
      }));

      const citationText = citations
        .map(
          (c: any, i: number) =&gt;
            `[${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,
      };
    }
  }
);<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>const transport = new StdioServerTransport();
server.connect(transport);<p>Now compile the project using the following command:</p>npx tsc index.ts --target ES2022 --module node16 --moduleResolution node16 --outDir ./dist --strict --esModuleInterop<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>{
  "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"
      }
    }
  }
}<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt395a7337021f9820/6a170c1c67045bb74d45c228/172981c2a54adabc70d5819013c3007670935605-1999x1002.png" alt="Claude 4.5 Sonnet page, with the note, &quot;Good afternoon, Jeff. How can I help you today?&quot;" /><p>Finally, let’s test the MCP server from the Claude Desktop chat and start asking questions:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf4ac458dc0206271/6a170c1e66c4f91328f8c072/03654c0f8c53c714f801fba8b25747071179209b-1999x1353.png" alt="User search request in Claude Desktop chat for documents about authentication methods and role-based access control, along with Claude's responses." /><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>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.<p>The response is, “Great! I found 5 relevant documents about authentication methods and role-based access control. Here'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbaf48a9468770ce2/6a170c21509168bffee1bb14/25ff4c7e9563d99752f95540dafdc7fd211a66e3-800x530.gif" alt="Claude 4.5 Sonnet page, with scrolling responses that include the five relevant documents about authentication methods and role-based access control." /><p>It’s also possible to chain multiple tools in a single interaction. In this case, Claude Desktop analyzes the user'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta46ff45274e64192/6a170c230c4857a91501aac1/e6a8a46acb4236e77058f18bcd2f0737b5882c05-1999x1101.png" alt="Claude Desktop chat, with the note, &quot;Jeff returns,&quot; plus a new user question, &quot;What are the main recommendations to improve authentication and access control across our systems? Include references.&quot;" /><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>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.<p>As in the previous step, we can see the response from each tool for this question:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8f633c518e708a99/6a170c25ab7f082991db9ed6/cb606d356b2f7d5e4878a5eff71bc881869ac0ee-800x585.gif" alt="Claude Desktop chat page, with scrolling text that includes the response from each tool for the question, “What are the main recommendations to improve authentication and access control across our systems? Include references.”" /><p><em>Note: If a submenu appears asking whether you approve the use of each tool, select </em><em><strong>Always allow</strong></em><em> or </em><em><strong>Allow once</strong></em><em>.</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6627ee0bff1862df/6a170c266f7f040f6f91488c/aea942ba9b0037526ea215bec65690f1a5c3099c-1522x250.png" alt="Claude Desktop &quot;Always allow&quot; and &quot;Allow once&quot; options for a user to choose from." /><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>
    <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[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5600198cb47666a5/6a170c28509168ce3ae1bb18/0bb24c05fff391f42070c2883182ea6fe9cb9680-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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's needs.</p><h2>What does "building context" 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><p><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's bash tool</a>, <a href="https://docs.openclaw.ai/tools/exec">OpenClaw's exec tool</a>, and <a href="https://docs.langchain.com/oss/python/integrations/tools/bash">LangChain's shell tool</a>.</p></li><li><p><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.</p></li><li><p><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>.</p></li><li><p><strong>Web search tools</strong> can retrieve information from the web.</p></li><li><p><strong>Memory tools</strong> store and recall from long-term memory (regardless of how it’s stored).</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2c5d083815149773/6a170acb964cea61a108bb80/115f20c8ded259e508f51524b2c06bdc702d70ab-1999x1050.png" alt="Diagram showing how an agent uses different context‑retrieval tools to access local files, proprietary data, the web, and long‑term memory." /><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><p><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.</p></li><li><p><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's context to guide correct tool usage (for example, <a href="https://github.com/elastic/agent-skills">Elastic Agent Skills for Elasticsearch</a>).</p></li><li><p><strong>Web: </strong>The agent can execute web searches via a curl command through a search provider’s API.</p></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'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't choose whether to use a database since it'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 "Find all files that import <code>matplotlib</code>" is fast, precise, and cheap. But when the agent needs to handle conceptual queries, such as "How does our app handle failed authentication?", 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'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 'security'?" or "Find issues where someone reported a bug and later someone submitted a PR claiming to fix it?") [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'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'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">"low floor, high ceiling" principle</a> for deciding which tools make the cut. High-ceiling tools don't limit the agent'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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt72deecc6781e3499/6a170acd5091682f4fe1baba/e6d1b973be4b0a0a25c99c74f02a47e98395a3f7-1200x630.png" alt="Diagram comparing three agent‑design approaches (high floor/high ceiling, low floor/low ceiling, and low floor/high ceiling), showing how different tool strategies affect how agents handle ambiguous, versatile, and predictable queries." /><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'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'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'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 "bash is all you need"</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>
    <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[Agentic AI]]></category>
    <dc:creator><![CDATA[Leonie Monigatti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b9bbbff55c09fa4/6a170acecdacbff1167d29fd/f91e4d07915ba7bf3b7abf15fac8fab3350f7df2-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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're hosting a pizza party. You'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's happy. But then word spreads about your sourdough pizzas. The doorbell keeps ringing. Guests pour in. Soon, there'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><p><strong>Not all pizzas are equally popular.</strong> Some are in high demand, others have fewer takers. You don't need extra "copies" of the unpopular ones. You need extras of the ones with queues.</p></li><li><p><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've waited too long. Better to get an extra pizza when you see a crowd forming.</p></li><li><p><strong>Don't throw away pizzas too quickly.</strong> Just because the crowd around the pepperoni thinned out for five minutes doesn't mean the rush is over. Maybe they'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.</p></li><li><p><strong>You can only hand out as many pizzas as you have friends who are helping.</strong> If you'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.</p></li><li><p><strong>When a friend leaves, take their pizza.</strong> If one of your friends needs to head out, grab their pizza immediately. You can't have pizzas sitting unattended. Hand it to someone else, or put it away.</p></li></ul><h2>From pizzas to replicas</h2><p>Let'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'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'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>desired_replicas = min(ceil(L × N / (S × X)), N)<p>Where:</p><ul><li><p><strong>L</strong> = the index's relative search load (between 0 and 1).</p></li><li><p><strong>N</strong> = the number of desired search nodes in your project.</p></li><li><p><strong>S</strong> = the number of shards in the index.</p></li><li><p><strong>X</strong> = a threshold to avoid hot spots (default: 0.5).</p></li></ul><p>An example: four search nodes, one index with two primary shards receiving 80% of search traffic:</p>desired_replicas = min(ceil(0.8 × 4 / (2 × 0.5)), 4)
                 = min(4, 4)
                 = 4<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't wait until a replica is completely overwhelmed; we scale up when it'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'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'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><p><strong>Search autoscaling</strong> adjusts the number of search nodes (how many friends are helping).</p></li><li><p><strong>Replicas for search load balancing</strong> distribute traffic by adjusting replica counts per index (how many pizzas of each kind we need).</p></li><li><p><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>).</p></li></ul><p>An important design principle: Replicas for load balancing don'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't need to predict which indices will be popular. You don't need to manually adjust replicas when traffic patterns change. You don'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'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>
    <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[Elastic Cloud Serverless]]></category>
    <dc:creator><![CDATA[Andrei Dan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte3b371b70b12b9ef/6a170f240e2e49999441a1de/3c4c1e99b892f026b7aba098973593f8298e2ea6-1280x717.png" length="0" type="image/png"/>
    <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9094997548bd70f8/6a170d6a839dfa0ad6dcff54/7ddadf1976421a860a7d62087239adb9150d808b-1999x1388.png" alt="Scatter plot showing several small language models plotted by model size (in billions of parameters) on the x‑axis and win rate (percentage) on the y‑axis. SmolLM3‑3B appears near the top of the efficiency trend, with a higher win rate than other models of similar size." /><h2>Prerequisites</h2><ul><li><p><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.</p></li><li><p><strong>Python 3.12: </strong>Download Python <a href="https://www.python.org/">here</a>.</p></li><li><p><strong>Hugging Face </strong><a href="https://huggingface.co/docs/hub/en/security-tokens">access token</a>.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf217b7b7db4e1e6c/6a170d6ca929cf8022ae0a3b/1dfbc2323438feaaa42e13ab242dd1f7166f74aa-1200x676.png" alt="Flow diagram showing an Elasticsearch index feeding semantic search results into an inference endpoint, which returns article recommendations." /><p>In this article, we’ll test <strong>SmolLM3-3B </strong>capacity tocombine 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt20e69b9a06fecd65/6a170d6e839dfa6f97dcff58/8d3b86b212f28ff279f2da67a33e6134039f0e4e-1999x949.png" alt="UI of an article site with an AI recommendations generation system, listing three examples, with text in English and titles in either English or Spanish." /><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>PUT _inference/chat_completions/hugging-face-smollm3-3b
{
    "service": "hugging_face",
    "service_settings": {
        "api_key": "hugging-face-access-token", 
        "url": "url-endpoint" 
    }
}<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>POST _inference/chat_completion/hugging-face-smollm3-3b/_stream
{
  "messages": [
      { "role": "user", "content": "&lt;user prompt&gt;" }
  ]
}<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta7bdfa43d6766324/6a170d6fb339d59e5476a039/b816e9fba1fe172687bf58f5143fb1f838c1077f-549x331.png" alt="Interface view of a model catalog filtered to “smoll3,” showing one model named “smollm3‑3b” with text generation, vLLM, GPU 1× Nvidia L4, and a listed price of $0.8, plus a note suggesting extending the search to all Hugging Face models." /><p>Let’s pick the <strong>SmolLM3-3B</strong> model:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdb0a2e6ffd7deb20/6a170d710c48574b7401aafc/610d3aba0429f3666c2df3616d513eb6a4397c0c-502x478.png" alt="Interface for creating an endpoint for the SmolLM3‑3B model, showing the model name, a &quot;verified by Hugging Face&quot; note, an endpoint name field, a cost of $0.80 per hour per running replica, a cURL option, and a &quot;Create Endpoint&quot; button." /><p>From here, grab the Hugging Face endpoint URL:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25714021711ed6ff/6a170d72c1e8a54853f88336/025094ddb2cfbd1f0f216a5ec4e119b0f4fa2c42-646x328.png" alt="Dashboard view of a Hugging Face inference endpoint named “smollm3‑3b‑pnz,” showing a green Running status, one active replica, zero requests in the last hour, navigation tabs, and the displayed endpoint URL." /><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>https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions<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>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"]
)<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>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"],
            },
        },
    )<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>// 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."
  }<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>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)<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>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)}")<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>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 []<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>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"]) &gt; 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)}"<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>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<p>Finally, we need to extract the information and format it to be printed:</p>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 &lt;think&gt; tags
        cleaned_text = re.sub(
            r"&lt;think&gt;.*?&lt;/think&gt;", "", 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 + "┘")<p>Let’s test this by asking a question about the security blog posts:</p>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)<p>Here we can see the cards in the console generated by the workflow:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4aa221a08a51aeb3/6a170d7460084be1413c45d6/730d35212594bb3db30447c3ea7e2a92857287b7-1999x1515.png" alt="Section titled “Recommended Articles” showing five boxed article summaries, including topics on an authentication system vulnerability, migration risks, REST API v2 performance and authentication improvements, notification system changes, and a complete guide to the new API." /><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>
    <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[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f961af4cb26ec97/6a170d767d8d6790c770e790/1417d6ff033712206c9bd4bcc22074ee3437ce96-1999x1125.png" length="0" type="image/png"/>
    <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p><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.</p></li><li><p><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.</p></li></ol><p>But optimization often comes with a tax: <strong>accuracy</strong>.</p><p>The fear is valid: "If I compress my data and take shortcuts during the search, will I miss the best results?" "Does this optimization degrade the relevance of my search engine?"</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></a><a href="https://huggingface.co/datasets/fancyzhx/dbpedia_14"> 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><p><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).</p></li><li><p><strong>Exact search (flat):</strong> Sometimes called the "brute force" method. The search engine scans every single document in an index and calculates the distance.</p><ul><li><p><em>Pros:</em> 100% perfect recall.</p></li><li><p><em>Cons:</em> Computationally expensive and slow at scale.</p></li></ul></li><li><p><strong>Approximate search (HNSW):</strong> The "shortcut" 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.</p><ul><li><p><em>Pros:</em> Extremely fast and scalable.</p></li><li><p><em>Cons:</em> You might miss a neighbor if the graph traversal stops too early.</p></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 "Film" 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><p><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.</p></li><li><p><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.</p></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><p>If the <strong>exact (flat)</strong> search says the top 10 neighbors are IDs [1, 2, 3... 10].</p></li><li><p>And the <strong>approximate (HNSW)</strong> search returns IDs [1, 2, 3... 9, 99].</p></li><li><p>We found nine out of the top 10 correctly. The score is <strong>0.9</strong>.</p></li></ul><p>Here’s the mapping we used:</p># 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"
        }
    }
}<p><strong>The results: The "flat line" 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><p>Documents</p><p>Recall@10 score</p><p>1,000</p><p>1.000 (100%)</p><p>5,000</p><p>0.998 (100%)</p><p>10,000</p><p>0.992 (99.4%)</p><p>20,000</p><p>0.999 (99.0%)</p><p>40,000</p><p>0.992 (98.8%)</p><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8168a0a4946bade7/6a170e154a531b61b536a9eb/a4bfacb1d0cce6fdf6df0e1a9d4fc5d4007a66da-1999x1209.png" alt="vector search stability: Recall vs  Index Size" /><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'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><p><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.</p></li><li><p><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.</p></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't a "one size fits all" 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>
    <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[Vector Database]]></category>
    <dc:creator><![CDATA[Jeff Vestal]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt198c7085db96aa04/6a170e17cdacbfe88c7d2a86/09f03b9239d66c36763cdab3fafcdac207ff6d83-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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>&lt;dependency&gt;
  &lt;groupId&gt;org.testcontainers&lt;/groupId&gt;
  &lt;artifactId&gt;testcontainers-elasticsearch&lt;/artifactId&gt;
  &lt;version&gt;2.0.3&lt;/version&gt;
  &lt;scope&gt;test&lt;/scope&gt;
&lt;/dependency&gt;<p>And, if you’re using JUnit Jupiter integration:</p>&lt;dependency&gt;
  &lt;groupId&gt;org.testcontainers&lt;/groupId&gt;
  &lt;artifactId&gt;testcontainers-junit-jupiter&lt;/artifactId&gt;
  &lt;version&gt;2.0.3&lt;/version&gt;
  &lt;scope&gt;test&lt;/scope&gt;
&lt;/dependency&gt;<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>var container =
    new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:9.3.1");

container.start();<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>try (var client = ElasticsearchClient.of(c -&gt; c
     .host("https://" + container.getHttpHostAddress())
     .usernameAndPassword("elastic", ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD)
     .sslContext(container.createSslContextFromCa())
)) {<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>var health = client.cluster().health();

Assertions.assertEquals("docker-cluster", health.clusterName());
Assertions.assertEquals(HealthStatus.Green, health.status());<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>@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 -&gt;
                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());
        }
    }
}<p>you’ll notice that it reads less like a configuration script and more like a short narrative:</p><ul><li><p>We define the container.</p></li><li><p>We start the container.</p></li><li><p>We build a client.</p></li><li><p>We call a real API.</p></li><li><p>We assert the outcome.</p></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>
    <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[Java]]></category>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Piotr Przybyl]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc31c3bf0d453d846/6a170e12cdacbfdf6a7d2a7e/3ae41b1f2876d2ad11c8e2b79bbf79955d6902aa-1440x840.png" length="0" type="image/png"/>
    <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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 </em><em><strong>many different kinds</strong></em><em> 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><p>Cultural naming conventions.</p></li><li><p>Title-based references.</p></li><li><p>Business relationships and historical name changes.</p></li><li><p>Multilingual and cross-script mentions.</p></li><li><p>Compound challenges that mix several of the above.</p></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><p><strong>50 entities</strong>, spanning people, organizations, and institutions.</p></li><li><p><strong>~60 articles</strong>, with varying structure and linguistic complexity.</p></li><li><p><strong>51 distinct challenge categories</strong>, grouped broadly into:</p><ul><li><p>Cultural naming conventions.</p></li><li><p>Titles and professional context.</p></li><li><p>Business and organizational relationships.</p></li><li><p>Multilingual and transliteration challenges.</p></li><li><p>Combined and edge‑case scenarios.</p></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><p>A title like “the president” is meaningless without geopolitical and temporal context.</p></li><li><p>A company name may refer to a parent, a subsidiary, or a former brand depending on when the article was written.</p></li><li><p>A person’s name may appear in different orders, scripts, or transliterations, depending on language and culture.</p></li><li><p>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.</p></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><p>Elasticsearch narrows the candidate space efficiently and transparently.</p></li><li><p>The LLM is used only where judgment is required and is forced to explain itself.</p></li><li><p>Retrieval and reasoning remain distinct steps.</p></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><p>We did <strong>not</strong> add special logic for Japanese names.</p></li><li><p>We did <strong>not</strong> add custom rules for Arabic patronymics.</p></li><li><p>We did <strong>not</strong> add hard-coded mappings for historical company names.</p></li></ul><p>Instead, the system relied on the same core ingredients introduced earlier in the series:</p><ul><li><p>Context-enriched entities indexed for semantic search.</p></li><li><p>Hybrid retrieval (exact, alias, and semantic) in Elasticsearch.</p></li><li><p>A small, well-defined set of candidate matches.</p></li><li><p>LLM judgment constrained by function calling and minimal schemas.</p></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><p><strong>Precision:</strong> ~91%</p></li><li><p><strong>Recall:</strong> ~86%</p></li><li><p><strong>F1 Score:</strong> ~89%</p></li><li><p><strong>LLM acceptance rate:</strong> ~72%</p></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><p>Cross-script matching (Cyrillic, Korean, Chinese business entities).</p></li><li><p>Hebrew scenarios (patronymics, professional titles, religious titles, transliteration).</p></li><li><p>Business hierarchies (aerospace, diversified manufacturing, multidivision corporations).</p></li><li><p>Professional titles (academic, military, political, religious).</p></li><li><p>Combined Japanese scenarios involving multiple writing systems.</p></li></ul><p><strong>Strong performance (80–99% F1 score)</strong> included:</p><ul><li><p>International political figures (98%).</p></li><li><p>Historical name changes (90%).</p></li><li><p>Complex business hierarchies (89%).</p></li><li><p>Japanese company names (93%).</p></li><li><p>Cross-script transliteration (86%).</p></li><li><p>Arabic patronymics (86%).</p></li></ul><p><strong>More challenging areas</strong> included:</p><ul><li><p>Advanced transliteration (Chinese, Korean): 0% F1.</p></li><li><p>Certain Japanese scenarios (honorifics, name order, writing system variation): ~67% F1.</p></li><li><p>Some Arabic scenarios (company names, institutional references): ~40% F1.</p></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><p><strong>Preparation matters more than clever matching. </strong>Enriching entities with context up front dramatically reduces ambiguity later.</p></li><li><p><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.</p></li><li><p><strong>Reliability enables accuracy. </strong>Function calling didn’t just clean up JSON; it unlocked recall that was already latent in the retrieval step.</p></li><li><p><strong>Generalization beats specialization. </strong>A small number of well-chosen abstractions handled dozens of challenge types without custom logic.</p></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.
</p>]]></content:encoded>
    <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[AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc58be329ffebcd60/6a17043e47d49c0bc62d88ab/70fb0ff949f6db9ac9b8a28ecb4329ab915ebf46-720x420.png" length="0" type="image/png"/>
    <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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>curl -fsSL https://elastic.co/start-local | sh<p>After starting, you'll have:</p><ul><li><p>Elasticsearch at http://localhost:9200.</p></li><li><p>Kibana at http://localhost:5601.</p></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 </strong><a href="https://www.elastic.co/downloads/elasticsearch"><strong>official documentation</strong></a><strong> 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>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")
}<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>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");<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>public record Movie(
    String movie_id,
    String movie_name,
    Integer year,
    String genre,
    String description,
    String director
) {
}<p>Now we can create an instance of <code>CsvSchema</code> mapping the CSV structure and read the file into an iterator:</p>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&lt;Movie&gt; it = csvMapper                     
    .readerFor(Movie.class)                               
    .with(schema)                                         
    .readValues(new InputStreamReader(csvContentStream)); <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>EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
    .baseUrl(ollamaUrl)
    .modelName(ollamaModelName)
    .build(); <p>And then the Elasticsearch vector store, which needs an instance of the Elasticsearch Java RestClient:</p>RestClient restClient = RestClient
    .builder(HttpHost.create(elasticsearchServerUrl))
    .setDefaultHeaders(new Header[]{
        new BasicHeader("Authorization", "ApiKey " + elasticsearchApiKey)
    })
    .build(); 

EmbeddingStore&lt;TextSegment&gt; embeddingStore = ElasticsearchEmbeddingStore.builder()
    .restClient(restClient)
    .build(); <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>List&lt;Embedding&gt; embeddings = new ArrayList&lt;&gt;();
List&lt;TextSegment&gt; embedded = new ArrayList&lt;&gt;();<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>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
    }
}<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>embeddingStore.addAll(embeddings, embedded);<h2>Step 3: Querying</h2><p>Our goal is to find movies with time loops in the plot, so our prompt will be:</p>String query = "Find movies where the main character is stuck in a time loop and reliving the same day.";<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>ElasticsearchContentRetriever contentRetrieverVector = ElasticsearchContentRetriever.builder()
                .restClient(restClient)
                .configuration(ElasticsearchConfigurationKnn.builder().build())
                .maxResults(5)
                .embeddingModel(embeddingModel)
                .build();

List&lt;Content&gt; vectorSearchResult = contentRetrieverVector.retrieve(Query.from(query));

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

List&lt;Content&gt; hybridSearchResult = contentRetrieverHybrid.retrieve(Query.from(query));

System.out.println("Hybrid search results:");
hybridSearchResult.forEach(v -&gt; System.out.println(v.textSegment().metadata().getString(
            "movie_name")));Hybrid search results:
Edge of Tomorrow
The Witch: Part 1 - The Subversion
Boss Level
Divinity
The Maze Runner<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><p>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.</p></li><li><p>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.</p></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>
    <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[Hybrid Search]]></category>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[Java]]></category>
    <dc:creator><![CDATA[Laura Trotta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt63218799e944e16d/6a1710ef6f7f04ee68914952/93d8e0d84fb4cfbf5e51df85df7ec2e600d9dcc7-1088x607.png" length="0" type="image/png"/>
    <pubDate>Wed, 11 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8eb543674b064eee/6a170e562b835f4412f4b2bc/ec61e65f54b96b83975b52b2d88305170001d9bd-1999x1445.png" alt="Chart showing GitHub star history, from 2020 to 2026, for four different open‑source automation and AI‑agent frameworks: OpenClaw, LangChain, CrewAI, and n8n-io." /><p>Let'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3b365123e76e74dd/6a170e587d8d67349c70e7d2/ca8dc124a7410ba036ddf887eee011c42125cdf3-1270x680.png" alt="SearchClaw (OpenClaw and Elasticsearch) solution architecture, with the OpenClaw Gateway Runtime as the central hub. It loads skills for context and then interacts directly with each back end." /><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><p><strong><code>fresh_produce</code></strong><strong>:</strong> 10 products with semantic search (ecommerce scenario)</p></li><li><p><strong><code>app-logs-synthetic</code></strong><strong>:</strong> 30 log entries across four services (observability scenario)</p></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><p><strong>Agents:</strong> Independent AI instances, each with its own configuration, workspace, and set of skills. You can run multiple agents for different purposes.</p></li><li><p><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.</p></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><p><strong><code>Elasticsearch-openclaw</code></strong><strong> (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.</p></li><li><p><strong><code>Weather</code></strong><strong> (community skill, used for composition demo):</strong> A skill that fetches current weather conditions from external APIs.</p></li></ul><p>Later in the tutorial, we'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><p><strong>Skill design:</strong> No write operation patterns in <code>SKILL.md</code> or reference files.</p></li><li><p><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.</p></li><li><p><strong>Agent instructions:</strong> <code>AGENTS.md</code> explicitly states "You can SEARCH, FILTER, and AGGREGATE data, but you can NEVER write, update, or delete."</p></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><p>Docker Desktop installed and running (Docker Engine with Compose V2).</p></li><li><p>Elasticsearch running locally via <code>start-local</code>. (We’ll set this up in the next section.)</p></li><li><p>Jina API key (free): <a href="https://jina.ai/embeddings">https://jina.ai/embeddings</a>.</p></li><li><p>OpenClaw installed: <a href="https://openclaw.ai">https://openclaw.ai</a>.</p></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>git clone https://github.com/salgado/elasticsearch-openclaw-start-blog
cd elasticsearch-openclaw-start-blog<p>The repository contains:</p>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<p><em><strong>Note:</strong></em><em> The </em><em><code>devtools*.md</code></em><em> 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><p><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).</p></li><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></li><li><p><strong>Credential rotation:</strong> Rotate API keys periodically. OpenClaw stores credentials locally, so treat the machine’s security as the perimeter.</p></li><li><p><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.</p></li><li><p><strong>Keep the installation up to date.</strong></p></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># Global gateway installation
curl -fsSL https://openclaw.ai/install.sh | bash<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>curl -fsSL https://elastic.co/start-local | sh<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><p><strong>Step 1:</strong> Replace <code>YOUR_JINA_API_KEY</code> with your actual Jina API key (free).</p></li><li><p><strong>Step 2:</strong> Save the encoded field immediately; it cannot be retrieved later.</p></li></ul><p>The key commands in the Dev Tools file are:</p><p><strong>Create the Jina inference endpoint:</strong></p>PUT _inference/text_embedding/jina-embeddings-v5
{
  "service": "jinaai",
  "service_settings": {
    "api_key": "YOUR_JINA_API_KEY",
    "model_id": "jina-embeddings-v5-text-small"
  }
}<p><strong>Create the index with </strong><strong><code>semantic_text</code></strong><strong>:</strong></p>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"
      }
    }
  }
}<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>GET /fresh_produce/_search
{
  "query": {
    "semantic": {
      "field": "semantic_content",
      "query": "healthy colorful meals"
    }
  },
  "size": 3,
  "_source": ["name", "description", "category"]
}<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>POST /_security/api_key
{
  "name": "openclaw-readonly",
  "role_descriptors": {
    "reader": {
      "cluster": ["monitor"],
      "indices": [
        {
          "names": ["fresh_produce", "app-logs-synthetic"],
          "privileges": ["read", "view_index_metadata"]
        }
      ]
    }
  }
}<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>cp openclaw-workspace-elastic-blog/.env.example 
openclaw-workspace-elastic-blog/.env<p>Edit the .env file with these two values:</p>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)<p>Example .env file:</p>ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_API_KEY=VnVaRmxLSDRCQxxxxxxxxbGVfa2V5<h4>Step 2: Install the skill from ClawHub</h4><p><a href="https://clawhub.ai/">ClawHub</a> is OpenClaw'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>Install the elasticsearch-openclaw skill from https://clawhub.ai/salgado/elasticsearch-openclaw<p>OpenClaw will:</p><ul><li><p>Fetch the skill from ClawHub.</p></li><li><p>Install it in the appropriate directory.</p></li><li><p>Confirm when ready to use.</p></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>openclaw agents add elasticsearch-agent \
  --workspace ~/path/to/elasticsearch-openclaw-start-blog/openclaw-workspace-elastic-blog \
  --non-interactive

openclaw gateway restart<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltffac27a4e3fd8fe9/6a170e5a964ceac34508bc5d/abc051a513b0cc7dff4a7f02493d51e220c72ad4-1999x1095.png" alt="OpenClaw web chat screen, with the focus on &quot;Find products, in my Elasticsearch, that would be good for a fresh salad.&quot;" /><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><p>Without skill</p><p>With `elasticsearch-openclaw` skill</p><p>Agent has no knowledge of Elasticsearch query syntax.</p><p>Agent knows semantic, full-text, filtered, and aggregation patterns.</p><p>Agent might attempt write operations.</p><p>Agent is instructed to never write, update, or delete.</p><p>Agent guesses field names and types.</p><p>Agent inspects mappings first and then constructs appropriate queries.</p><p>Generic curl commands with trial and error.</p><p>Structured query templates with best practices for Elasticsearch 9.x.</p><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>“Find products that would be good for a healthy summer salad.”<p>Result:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a0015d9a2d3abfd/6a170e5b1949f754dce7aad9/d5b4bbe71ad56af5462bccc1475bd10d5233abd9-1011x557.png" alt="OpenClaw web chat page with &quot;Semantic search working&quot; message, along with a list of salad ingredients." /><p>Others ideas to explore:</p><ul><li><p><strong>Index exploration:</strong> &gt; “What indices do I have in Elasticsearch? Show me the fields of <code>fresh_produce</code>.”</p></li><li><p><strong>Filtered search:</strong> &gt; “Show me all products on sale under $15.”</p></li><li><p><strong>Aggregations:</strong> &gt; “What’s the average price by category?”</p></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><p><strong><code>Create ingest pipeline</code></strong><strong>:</strong> Adds @timestamp to log entries automatically.</p></li><li><p><strong><code>Create index mapping</code></strong><strong>:</strong> Defines the <code>app-logs-synthetic</code> structure (classic fields only, no <code>semantic_text</code>).</p></li><li><p><strong><code>Bulk insert logs</code></strong><strong>:</strong> Loads 30 synthetic log entries across four services.</p></li><li><p><strong><code>Count query</code></strong><strong>:</strong> Verify 30 documents were indexed.</p></li><li><p><strong><code>Sample search</code></strong><strong>:</strong> Quick test to confirm that data is queryable.</p></li></ul><h4>How to run:</h4><ol><li><p>Open Kibana Dev Tools: http://localhost:5601 → Dev Tools.</p></li><li><p>Copy each numbered block from the .md file.</p></li><li><p>Paste into the Dev Tools console.</p></li><li><p>Press <em><strong>Ctrl/Cmd+Enter</strong></em> to execute.</p></li><li><p>Wait for a successful response before continuing to the next block.</p></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>Show me the distribution of HTTP status codes across all services.<p>Result:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8600731ee46ee0f8/6a170e5d961e69607fc4cfae/d35fc1c0ea6d647f1c85163eb0ab8e268c6c4f89-1002x565.png" alt="OpenClaw web chat with &quot;the full picture across 30 logs,&quot; listing &quot;ok,&quot; &quot;bad requests,&quot; &quot;server errors,&quot; and more." /><p>Other ideas to explore:</p><ul><li><p>“How many 500 errors do I have in <code>app-logs-synthetic</code>? Which services are failing?”</p></li><li><p>“Which endpoints have the slowest response times?”</p></li><li><p>“What happened with the <code>payment-service</code> in the last 24 hours?”</p></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>Install the weather skill.<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>“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.”<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb362465ddf7556fe/6a170e5f509168515ce1bb8a/14fa4303bb2f1eb19530d8844f09c99948b3c752-1965x1079.png" alt="SearchClaw results for &quot;products on sale that match today's weather,&quot; including images of watermelon and avocado." /><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>
    <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[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Alex Salgado]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltee8bba6ac4830abd/6a170e60cdacbf48277d2a92/ce3248c3cb7a352e3fdafef4ac8116ab998ab4f4-1950x1137.png" length="0" type="image/png"/>
    <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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><strong>Do we still need a search engine at all?</strong><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><p>Rank results across systems.</p></li><li><p>Understand relevance across heterogeneous data.</p></li><li><p>Normalize schemas or metadata.</p></li><li><p>Data transformations or enrichments at scale.</p></li><li><p>Apply consistent security and permissions.</p></li><li><p>Optimize for latency, cost, or scale.</p></li></ul><p>In other words, <strong>MCP tells agents </strong><em><strong>how</strong></em><strong> to ask for data, not </strong><em><strong>which</strong></em><strong> 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><p>Send the user’s query to multiple systems in parallel (SharePoint, GitHub, Jira, customer relationship management [CRM]).</p></li><li><p>Collect the responses.</p></li><li><p>Merge and present the results.</p></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><p><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.</p></li><li><p><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.</p></li><li><p><strong>Context is shallow and incomplete: </strong>Federated systems typically expose only what’s directly accessible through an API call.They rarely surface:</p><ul><li><p>Usage signals, like clicks, dwell time, recency of access, popularity, or authority.</p></li><li><p>Relationships between documents across different systems to correlate the insights.</p></li><li><p>Organizational knowledge beyond a single silo.

This strips agents of the broader context required for high-quality reasoning.
</p></li></ul></li><li><p><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.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6f82101d3ef2019b/6a170ca96f7f04f0219148ac/25bb778f4da9a3cb4f0d4e10af66221b8af73900-1376x768.jpg" alt="Federated search workflow" /><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><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></li><li><p><strong>Permission-aware search:</strong> Enterprise AI cannot compromise on security. Indexed search allows:</p><ul><li><p><a href="https://www.elastic.co/docs/reference/search-connectors/document-level-security">Document-level security.</a></p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/user-roles#roles">Role-based access control.</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/rag-and-rbac-integration">Permission-aware retrieval for RAG and agents.</a></p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb88e668cb4814bee/6a170cab8b73cb5d6918a090/8785e7806616273d086a90b3540273fb26d045ae-1392x768.jpg" alt="Essential components of context engineering, highlighting the Elasticsearch role in the indexed search layer." /><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><p>Consistent relevance logic</p></li><li><p>Mandatory security filters</p></li><li><p>Cost and latency guardrails</p></li><li><p>Business-specific ranking rules</p></li><li><p>Explicit index and field scope boundaries</p></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><p>Query understanding — rewriting, expansion, entity resolution</p></li><li><p>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.</p></li><li><p>Execution governance — templates, security enforcement, and performance guardrails</p></li><li><p>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.</p></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><p>Users or agents may not know the exact terminology</p></li><li><p>Knowledge is distributed across silos</p></li><li><p>Important context is implied rather than explicitly stated</p></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>,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><p><strong>Connectors and crawlers</strong> ingest data continuously from enterprise systems.</p></li><li><p><strong>Semantic and vector search</strong> enable intent-based retrieval.</p></li><li><p><strong>Hybrid search</strong> blends lexical precision with semantic understanding.</p></li><li><p><strong>RAG workflows</strong> ground LLMs in authoritative, permission-aware data.</p></li></ul><p>Elastic does not compete with agents or MCP. It <strong>makes them effective</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt950398e2b4ab71f8/6a170cac2b835fb826f4b26b/193da239544ce858416db845f9fc34c7c0e9b6f9-1920x1080.png" alt="AI-native experiences powered by tools and agents, enabled by the platform, and built on enterprise data." /><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><p>Higher latency.</p></li><li><p>Higher cost.</p></li><li><p>Lower precision due to noise.</p></li><li><p>A higher propensity for confusion, context clash, and context poisoning.</p></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><p>Context quality determines answer quality.</p></li><li><p>Indexes create that context.</p></li><li><p>Search is the foundation, not the legacy.</p></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>
    <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[Inside Elastic]]></category>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Dayananda Srinivas]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1caa3ee906789415/6a170cae2b835f9a90f4b26f/5b8af1c3ca51f2c038406c714eb9a71b696bbc5a-1999x1091.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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'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's structured output API. With it, we can define schemas for LLM responses, so we always know exactly what we'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'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 "Always return parsable JSON". We tried this exact technique in the prototype's prompt, but we'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'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><p>Metric</p><p>Prompt-based</p><p>Function calling</p><p>Improvement</p><p>Error rate</p><p>30.2%</p><p>0.0%</p><p>100% elimination</p><p>Precision</p><p>83.8%</p><p>90.3%</p><p>+6.5pp</p><p>Recall</p><p>62.6%</p><p>90.8%</p><p>+28.2pp</p><p>F1 score</p><p>71.7%</p><p>90.6%</p><p>+18.9pp</p><p>Acceptance rate</p><p>44.8%</p><p>60.2%</p><p>+15.4pp</p><p>True positives</p><p>129</p><p>187</p><p>+45.0%</p><p>False negatives</p><p>77</p><p>19</p><p>-75.3%</p><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'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's next: The ultimate challenge</h2><p>Now that we'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'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't covered in this learning-focused prototype.</p>]]></content:encoded>
    <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[AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta3b4daaf48985d75/6a170cf360084b0d2c3c45be/b2afa90c1b863c716008f3f5bbdd2866fa1c3577-720x420.png" length="0" type="image/png"/>
    <pubDate>Wed, 04 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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'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><p><strong>Context preservation:</strong> Each subagent uses its own context window.</p></li><li><p><strong>Specialized expertise:</strong> Each subagent is designed for a specific task.</p></li><li><p><strong>Reusability:</strong> You can reuse a subagent in different sessions and projects.</p></li><li><p><strong>Flexible access:</strong> You can limit the subagent access to specific tools.</p></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>---
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.<p>You can call subagents implicitly by talking about the task they run, and Claude will call them automatically. For example, you can say, "I want to plan my new functionality."</p><p>You can also call them explicitly by directly asking Claude Code to use a subagent and telling it, "Use the planning subagent to plan my new functionality."</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't want to clutter the main chat window. Considering our example of coding, the most common subtasks include:</p><p>Subtask type</p><p>Description</p><p>Typical tools</p><p>Exploration / research</p><p>Searching and analyzing code without modifying it.</p><p>Read, grep, glob</p><p>Planning</p><p>Running deep analysis to create implementation plans.</p><p>Read, grep, glob, bash</p><p>Code review</p><p>Reviewing quality, safety, and best practices.</p><p>Read, grep, glob, bash</p><p>Code modification</p><p>Writing and editing code.</p><p>Read, edit, write, grep, glob</p><p>Testing / debugging</p><p>Running tests and analyzing issues.</p><p>Bash, read, grep, edit</p><p>Retrieval</p><p>Getting information from external sources (APIs, databases).</p><p>MCP tools, bash</p><p>Claude Code includes three built-in agents that showcase these use cases:</p><p></p><ul><li><p><strong>Explore:</strong> Quick agents for read-only search in the codebase. It's great for answering questions like, "Where are the client's errors handled?"</p></li><li><p><strong>Plan:</strong> Research agent that activates in plan mode to analyze the codebase before proposing changes.</p></li><li><p><strong>General-purpose:</strong> The most capable agent for complex tasks that require multiple steps and can include modifications.</p></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><p>Do NOT say, "Review the code."</p></li><li><p>SAY, "Review the changes to src/auth/index.ts, focusing on JWT token validation."</p></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><p>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.”</p></li><li><p>SAY, “Fix the token refresh bug in src/auth/index.ts that's throwing AUTH_TOKEN_EXPIRED unexpectedly.”</p></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># For just an analysis agent
tools: Read, Grep, Glob

# For an agent that needs to modify the code
tools: Read, Edit, Write, Grep, Glob<p>If you don'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># First call
&gt; Use the code-analyzer agent to review the authentication module
[Agent completes the analysis and returns agentId: "abc123"]

# Continue with previous context
&gt; Resume agent abc123 and now analyze the authorization module
[Agent continues with the context from the previous chat]<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'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># 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<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt421eabae16c6057f/6a170cb06234e07fcedb1a43/74a3a376600cd1b7cdd2dddddfed2f00ab131eed-896x94.png" alt="Sequential (or chaining) subagents, each feeding the next in a sequence of tasks." /><p>Call example:</p>&gt; First use the planning agent to design the feature,
&gt; then use the coding agent to implement it,
&gt; finally use the reviewer agent to check the code<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt43666c7c597cf666/6a170cb228671458ed93e375/84eca68d29bf79cf978a8089d3c18972738cd2c1-595x272.png" alt="A parallel subagent pattern, with the main Claude Code agent invoking three subagents." /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc869c00340b0ac97/6a170cb3e8fbce396239fcb7/93bb2cc55c435f509b426fbcc090a67c53021684-595x272.png" alt="A hub-and-spoke subagent pattern, where the main agent acts as an orchestra" /><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'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 "What's our most visited page?" just run the query directly. The agent adds value when your question requires:</p><ul><li><p><strong>Multiple queries that build on each other:</strong> The answer from query 1 informs query 2.</p></li><li><p><strong>Cross-index reasoning:</strong> Correlating data from different sources.</p></li><li><p><strong>Ambiguity resolution:</strong> The agent interprets and follows leads.</p></li><li><p><strong>Synthesis:</strong> Combining quantitative data with qualitative knowledge.</p></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'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'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'll use the latter.</p><h3><strong>Prerequisites</strong></h3><ul><li><p><a href="https://code.claude.com/docs/en/setup">Claude Code</a> 2.0.76+</p></li><li><p>Elasticsearch 9.2</p></li><li><p>Elasticsearch <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys">API key</a></p></li></ul><h3><strong>The scenario: Technical debt sprint planning</strong></h3><p>You'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><p><strong>User impact:</strong> How many users hit this issue?</p></li><li><p><strong>Business impact:</strong> Does it affect paying customers? Enterprise tier?</p></li><li><p><strong>Severity:</strong> Errors? Performance? Just ugly code?</p></li><li><p><strong>Effort:</strong> Quick win or rabbit hole?</p></li><li><p><strong>Dependencies:</strong> Does fixing A unlock fixing B?</p></li><li><p><strong>Strategic alignment:</strong> Does it align with Q1 priorities?</p></li></ul><p>A single query like, "What's the most important tech debt item?" fails because this requires:</p><ol><li><p>Reading <code>TECH_DEBT.md</code> to understand what the 12 items even are.</p></li><li><p>For EACH item, querying <code>error_logs</code>to get error frequency.</p></li><li><p>Cross-referencing with <code>customer_data</code> to see tier breakdown.</p></li><li><p>Checking <code>support_tickets</code>to see complaint volume.</p></li><li><p>Reading <code>engineering_standards</code> in the knowledge base to see whether any items violate core principles.</p></li><li><p>Reading <code>Q1_roadmap</code> to check strategic alignment.</p></li><li><p>Synthesizing all of this into a prioritized recommendation.</p></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'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><p><strong>Kibana Dev Tools:</strong> Using the Elasticsearch requests provided below.</p></li><li><p><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.</p></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's an example of the knowledge index structure and data to be indexed:</p>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"}<p>Full requests for all indices:</p><ul><li><p><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></p></li><li><p><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></p></li><li><p><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></p></li><li><p><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></p></li></ul><p>The raw JSON files with the dataset are also available:</p><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/subagents-with-elastic-agent-builder/dataset/knowledge.json">knowledge.json</a></p></li><li><p><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></p></li><li><p><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></p></li><li><p><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></p></li></ul><h2>Local project files</h2><p>Create the following Markdown (MD) files in your project. These files look like this:</p># 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 &gt;10k rows
- **Estimate**: 2 days

...<p>Full files:</p><p></p><ul><li><p><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.</p></li><li><p><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.</p></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'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'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'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>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"
          ]
        }
      ]
    }
  }'<p>You’ll get this response if everything went OK:</p>{
  "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.",
  ...
}<p>The agent will be available in Kibana, so you can now chat with it if you want:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb05460b667943ed6/6a170cb52867142a3193e379/c655ec6b9b1cc2fa1ab3cc13d289e7b96a543284-815x784.png" alt="Chat with a new agent in Kibana, creating a chart with clients sorted by monthly recurring revenue." /><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's add the MCP server to Claude Code using the already-generated API key:</p>claude mcp add --transport http agentbuilder https://${KIBANA_URL}/api/agent_builder/mcp --header "Authorization: ApiKey ${API_KEY}"<p>We can check the connection status using <code>claude mcp get agentbuilder</code>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d9034e6312b1aa3/6a170cb6c1e8a59e95f88318/ba5fbc144f9e29151b8628dffd33dc74b12deece-499x177.png" alt="Code for &quot;Claude MCP get agentbuilder." /><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'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: "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."</p><p>6. In “Select tools,” choose <strong>Advanced options</strong> and select the tools we defined on the agent creation.</p>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)<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'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc9ab15478a2f3a77/6a170cb8b0367d3ed472bd75/f01ac4c9f30fbcbed7fc69881aae9ff72c4616a0-869x521.png" alt="Code for creating a new subagent" /><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>&gt; Based on TECH_DEBT.md, which items should we prioritize for our 2-week sprint?
&gt; Use the tech-debt-analyzer agent to check error frequency, customer impact,
&gt; support ticket volume, and alignment with engineering standards.<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt81a604f43091d27a/6a170cb90e2e49471441a165/d76d972ab5b07e6d35bdf3036cb5ee3c080c7156-749x239.png" alt="Code to demonstrate testing the agent with a complex prioritization question." /><p>Watch how the agent orchestrates multiple queries:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt093a3a5425cccb68/6a170cbb7d8d671c6570e775/c49b56c366576406586ba03f694d2bfb09d30895-875x96.png" alt="Code to demonstrate how the agent orchestrates multiple queries." /><p>And will give you a comprehensive analysis of the local files combined with Elasticsearch data:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8d9193f977e5fbbc/6a170cbdc1e8a5779ff8831c/084c532b4c9e993e53810738ae1da1fd4af1f025-1228x693.png" alt="A comprehensive analysis of the local files combined with Elasticsearch data." /><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 "fix highest error count" approach.</p><p>By typing <code>/context</code>, we can see how much context each of the MCP tool's definitions uses and our subagent's prompt. Keep an eye on this overhead when creating subagents.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8c6475d26fa91b2d/6a170cbfd7c02246d5de64e5/3a6f528a9cae7b7fbf17f1b97e13c51c78c1b8b4-666x391.png" alt="Code that shows how much context each of the MCP tool's definitions uses and our subagent's prompt." /><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>"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."<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt76067b358ead61bf/6a170cc04a531bb59136a9ba/cfa5c6c44425d6e73355116e08082a33699915a3-961x873.png" alt="Data analysis results that provide an implementation plan prioritized based on actual business data rather than on assumptions." /><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><p><a href="https://code.claude.com/docs/en/sub-agents">Claude Code Subagents</a></p></li><li><p><a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a></p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Agent Builder MCP</a></p></li></ul>]]></content:encoded>
    <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[Agentic AI]]></category>
    <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt75bf7ebc5c2c72a8/6a170cc26f7f04f6ba9148b4/bfeb78b687bd930371364ee7dd0341ae90004349-1280x720.png" length="0" type="image/png"/>
    <pubDate>Tue, 03 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p>A fixed maximum number of nodes is visited.</p></li><li><p>A fixed timeout is reached.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfd0d692b9beb476a/6a170ef4dc55debf0be00e97/a9d07c5153ea64a2426c82487c36846030692bb9-1600x945.png" alt="Adaptive Early Termination for HNSW " /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93509c251a1b641e/6a170ef6dc55dea2b3e00e9b/dac56125c4b16d1b596c9876b6ca9ac7b2dc87fa-1600x944.png" alt="Adaptive Early Termination for HNSW es" /><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><p>Over-exploration for easy queries.</p></li><li><p>Premature termination for hard queries.</p></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   (how many new neighbors were introduced for a query <em>q</em>, in the last visit <em>i</em>) together with rolling mean  and standard deviation  of such a discovery rate during the graph visit (using <a href="https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford'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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdbfb1e123f1b026d/6a170ef7cf4f25d9bab2d216/1958be7ca4425ade66eaf621ada3533173183598-694x118.png" alt="" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d4d91f464a9dc9d/6a170ef8d7c0223420de656a/f7ee4a55c24853b657df26052b275e8bd76cf0f9-654x156.png" alt="" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteab7ba53ae14da0e/6a170ef9961e69e072c4cfd5/2a906997d9a25d74c7038bd9661bc97581e7258e-1600x938.png" alt=" adaptive strategy and the default HNSW behavior" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6fb9e672d3698200/6a170efb67045b7b2b45c2ab/3a114911e232c351dbb814cea20e8b0f1415a717-1600x925.png" alt="" /><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>
    <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[Vector Database]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Tommaso Teofili]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt27b746cc1995e6b7/6a170efda29299de8ad010c6/e6d3186f609dd56dc5ffe33d70fa9e5cfa05b51f-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9daec868eed84658/6a170bef6234e0c322db1a19/d5a52a07773f0942c2baa732dacfe782aac0f415-1600x683.png" alt="OpenSearch vs. Elasticsearch​: Throughput for filtered vector search benchmark" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt718c72fb858e4c98/6a170bf00e2e496e7241a132/54ac476ff20a3cf93484298c9ae47612c12fc110-800x417.png" alt="Context engineering turns a large context pool into a limited LLM context window." /><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><p>Filters: In stock, deliverable to the user’s postcode, delivery by Friday, price under $60, valid listing</p></li><li><p>Vector query: “Carry-on backpack 15-inch laptop water resistant”</p></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><p>“travel backpack carry on laptop sleeve”</p></li><li><p>“water resistant commuter backpack 15 inch”</p></li><li><p>“lightweight cabin backpack”</p></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><p>Material and water resistance wording</p></li><li><p>Dimensions and laptop compartment fit</p></li><li><p>Return policy or warranty constraints</p></li><li><p>Alternate options if inventory is low</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb562b30a600cc8e1/6a170bf2cf4f253582b2d1b2/c50d1df00968cac18149a2799e6242fbe49b66a0-1600x990.png" alt=" Graph 2: Recall versus average latency (rescore 1)." /><h4>Some key insights</h4><ul><li><p><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</p></li><li><p>Recall: Measured Recall@100 for that configuration</p></li><li><p>Avg latency (ms): Average end-to-end latency per query</p></li><li><p>Throughput: Queries per second</p></li><li><p>Recall %: Relative recall lift of Elasticsearch versus OpenSearch (Elasticsearch minus OpenSearch) / OpenSearch</p></li><li><p>Latency Xs: OpenSearch average latency divided by Elasticsearch average latency</p></li><li><p>Throughput Xs: Elasticsearch throughput divided by OpenSearch throughput</p></li></ul><p>Engine</p><p>`s_n_r_value`</p><p>Recall</p><p>Avg Latency (ms)</p><p>Throughput</p><p>Recall %</p><p>Latency Xs</p><p>Throughput Xs</p><p>Elasticsearch</p><p>100_250_1</p><p>0.7704</p><p>25</p><p>534.75</p><p>9.70%</p><p>2.28</p><p>1.91</p><p>OpenSearch</p><p>100_250_1</p><p>0.7023</p><p>57.08</p><p>279.58</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_500_1</p><p>0.8577</p><p>25.42</p><p>524.14</p><p>7.20%</p><p>2.4</p><p>2</p><p>OpenSearch</p><p>100_500_1</p><p>0.8001</p><p>60.9</p><p>262.12</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_750_1</p><p>0.8947</p><p>29.67</p><p>528.09</p><p>5.72%</p><p>2.25</p><p>2.21</p><p>OpenSearch</p><p>100_750_1</p><p>0.8463</p><p>66.76</p><p>239.11</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_1000_1</p><p>0.9156</p><p>29.65</p><p>534.5</p><p>4.66%</p><p>2.46</p><p>2.44</p><p>OpenSearch</p><p>100_1000_1</p><p>0.8748</p><p>72.88</p><p>219.01</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_1500_1</p><p>0.9386</p><p>31.84</p><p>497.3</p><p>3.38%</p><p>2.71</p><p>2.68</p><p>OpenSearch</p><p>100_1500_1</p><p>0.9079</p><p>86.16</p><p>185.4</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_2000_1</p><p>0.9507</p><p>34.69</p><p>457.2</p><p>2.57%</p><p>2.98</p><p>2.96</p><p>OpenSearch</p><p>100_2000_1</p><p>0.9269</p><p>103.36</p><p>154.55</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_2500_1</p><p>0.9582</p><p>37.9</p><p>418.43</p><p>1.99%</p><p>3.28</p><p>3.26</p><p>OpenSearch</p><p>100_2500_1</p><p>0.9395</p><p>124.29</p><p>128.53</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_3000_1</p><p>0.9636</p><p>41.86</p><p>379.4</p><p>1.62%</p><p>3.46</p><p>3.44</p><p>OpenSearch</p><p>100_3000_1</p><p>0.9482</p><p>144.67</p><p>110.34</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_4000_1</p><p>0.9705</p><p>50.28</p><p>316.21</p><p>1.06%</p><p>3.87</p><p>3.85</p><p>OpenSearch</p><p>100_4000_1</p><p>0.9603</p><p>194.36</p><p>82.22</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_5000_1</p><p>0.9749</p><p>58.77</p><p>270.91</p><p>0.73%</p><p>4.43</p><p>4.41</p><p>OpenSearch</p><p>100_5000_1</p><p>0.9678</p><p>260.33</p><p>61.38</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_6000_1</p><p>0.9781</p><p>66.75</p><p>238.59</p><p>0.52%</p><p>4.91</p><p>4.89</p><p>OpenSearch</p><p>100_6000_1</p><p>0.973</p><p>327.44</p><p>48.81</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_7000_1</p><p>0.9804</p><p>74.64</p><p>213.49</p><p>0.38%</p><p>5.28</p><p>5.27</p><p>OpenSearch</p><p>100_7000_1</p><p>0.9767</p><p>394.24</p><p>40.53</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_8000_1</p><p>0.9823</p><p>82.28</p><p>193.59</p><p>0.27%</p><p>6.86</p><p>6.83</p><p>OpenSearch</p><p>100_8000_1</p><p>0.9797</p><p>564.14</p><p>28.33</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_9000_1</p><p>0.9837</p><p>90.08</p><p>176.96</p><p>0.16%</p><p>7.63</p><p>7.61</p><p>OpenSearch</p><p>100_9000_1</p><p>0.9821</p><p>687.25</p><p>23.25</p><p></p><p></p><p></p><p>Elasticsearch</p><p>100_10000_1</p><p>0.9848</p><p>97.64</p><p>163.31</p><p>0.08%</p><p>8.38</p><p>8.36</p><p>OpenSearch</p><p>100_10000_1</p><p>0.984</p><p>818.64</p><p>19.53</p><p></p><p></p><p></p><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>GET &lt;INDEX_NAME&gt;/_search
{
  "query": {
    "knn": {
      "&lt;DENSE_VECTOR_FIELD_NAME&gt;": {
        "vector": [...],
        "k": &lt;NUMBER_OF_CANDIDATES&gt;,
        "method_parameters": {
          "ef_search": &lt;NUMBER_OF_CANDIDATES&gt;
        },
        "rescore": {
          "oversample_factor": &lt;OVERSAMPLE&gt;
        },
        "filter": {
          &lt;SOME_FILTER&gt;
        }
      }
    }
  },
  "size": &lt;RESULT_SIZE&gt;,
  "_source": {
    "excludes": [
      "&lt;DENSE_VECTOR_FIELD_NAME&gt;"
    ]
  }
}<ul><li><p><code>"size": &lt;RESULT_SIZE&gt;</code>: Number of hits returned to the client. In this benchmark, result size is 100 to compute Recall@100.</p></li><li><p><code>"k": &lt;NUMBER_OF_CANDIDATES&gt;</code>: The number of nearest neighbor candidates.</p></li><li><p><code>"ef_search": &lt;NUMBER_OF_CANDIDATES&gt;</code>: The number of vectors to examine.</p></li><li><p><code>"oversample_factor": &lt;OVERSAMPLE&gt;</code>: How many candidate vectors are retrieved before rescoring.</p></li></ul><p>Elasticsearch</p>GET &lt;INDEX_NAME&gt;/_search
{
  "query": {
    "knn": {
      "field": "&lt;DENSE_VECTOR_FIELD_NAME&gt;",
      "query_vector": [...],
      "k": &lt;NUMBER_OF_CANDIDATES&gt;,
      "num_candidates": &lt;NUMBER_OF_CANDIDATES&gt;,
      "rescore_vector": {
        "oversample": &lt;OVERSAMPLE&gt;
      },
      "filter": {
        &lt;SOME_FILTER&gt;
      }
    }
  },
  "size": &lt;RESULT_SIZE&gt;,
  "_source": {
    "excludes": [
      "&lt;DENSE_VECTOR_FIELD_NAME&gt;"
    ]
  }
}<ul><li><p><code>"size": &lt;RESULT_SIZE&gt;</code>: Number of hits returned to the client. In this benchmark, result size is 100 to compute Recall@100.</p></li><li><p><code>"k": &lt;NUMBER_OF_CANDIDATES&gt;</code>: Number of nearest neighbors to return from each shard.</p></li><li><p><code>"num_candidates": &lt;NUMBER_OF_CANDIDATES&gt;</code>: Number of nearest neighbor candidates to consider per shard while doing <code>knn</code> search.</p></li><li><p><code>"oversample": &lt;OVERSAMPLE&gt;</code>: How many candidate vectors are retrieved before rescoring.</p></li></ul><p>Example</p><p><code>Knn</code> query, (<code>100_500_1</code>), would be as follows:</p><p>OpenSearch</p>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"
    ]
  }
}<p>Elasticsearch</p>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"
    ]
  }
}<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7c7bdd567395d5b7/6a170bf3c1e8a56ee2f882f6/f81002c9186e4c2d3e92f49d72418fee9860fc5e-761x401.png" alt="Cluster setup for Elasticsearch and for OpenSearch benchmarks" /><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><p>A 128-dimensional dense vector embedding used for approximate kNN retrieval.</p></li><li><p>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.</p></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><p><a href="https://www.elastic.co/search-labs/blog/context-engineering-overview">What is context engineering?</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/series/context-engineering-hybrid-search-evolution">The evolution of hybrid search and context engineering</a></p></li><li><p><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></p></li></ol>]]></content:encoded>
    <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[Vector Database]]></category>
    <dc:creator><![CDATA[Sachin Frayne]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4b38e5114bbf098c/6a170bf560084b3a6c3c459d/fb7ee623925ca6696d643e437ce8efe5fe749079-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 25 Feb 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc99617fc3eed538d/6a170ea9964cea459d08bc67/e14d9f98d4eccaa08a335d5bd23d88e5debbb344-1600x1103.png" alt="Dependency management at Elastic" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0d205fd379e3c07a/6a170eab961e691e1fc4cfca/45ade5bda08f82bed0b3d0d3736cbd6f056e7a4e-1312x816.jpg" alt="Dependency management problem" /><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's unique scale and security needs</strong></h3><p>While we considered commercial options, including <strong>Mend'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's specific, nonnegotiable requirements:</p><ol><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3548539ab52fbb79/6a170eac0c48573da601ab26/5560ed20e2bd9ecdd574a9c835126d12b24c332f-1600x1157.png" alt="Overview of dependency management workflows in Kubernetes" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6451ff19da4db511/6a170eaec1e8a562e0f88378/2b3d4046c05bb261e45d40c59f864eb51fb9eaa9-1217x1600.png" alt="Components for dependency management in Kubernetes" /><h3><strong>Workflows Operator (Go/Kubebuilder)</strong></h3><p>A Kubernetes operator managing workflow lifecycle through three Custom Resource Definitions (CRDs):</p><ul><li><p><strong>RepoConfig CRD:</strong> Single source of truth for repository configuration.</p></li></ul><p>This is how RepoConfig is defined in the operator:</p>// 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"`
}<p>And this is what an instance of RepoConfig would look like:</p>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<ul><li><p><strong>Parent CRD:</strong> Manages CronWorkflows for scheduled scans.</p></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>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",
	}<p>It makes sure a mutex configmap is up to date to prevent similar workflows from running together:</p>	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)<p>Then it creates a Workflow Manager that’s the struct which will create or update the CronWorkflows and the Workflow Templates:</p>	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<ul><li><p><strong>Child CRD:</strong> Manages WorkflowTemplates with per-repository resources.</p></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>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
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta735156ba6e370ef/6a170eaf7d8d6706fd70e7e4/7ac70492a1266ba02cb8afbafc5a486cb38a0edc-1600x1290.png" alt="Workflows for dependency management in Kubernetes" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7e748ceb93c13a5a/6a170eb1a6c2b908f8e797b4/4828625456cbd6efa8020a20f10d23f294f98a02-1306x1600.png" alt="dependency dashboard interactions on Kubernetes" /><p>This gateway enables integration with GitHub Apps by:</p><ul><li><p>Verifying incoming GitHub webhook signatures for security.</p></li><li><p>Forwarding valid events to the Argo Events EventSource with all relevant headers and authentication.</p></li><li><p>We also configure an authSecret on the EventSource and provide this as a Bearer header in forwarded requests.</p></li><li><p>Providing logging, metrics, and retry logic.</p></li></ul><p>It performs various validations on each GitHub Event request.</p><p>It makes sure some HTTP attributes are present:</p>// 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
}<p>While it also validates the signature of each request and its organizsation:.</p>// 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, &amp;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
}<p>Finally, it routes to Argo Events based on event type:</p>	// 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<p>On the Argo Events side of things, 10 sensors watch the Argo Events EventBus for new events:.</p>apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
  name: {{ .Values.sensors.packageUpdateOnDefaultBranch.name }}
  namespace: {{ .Release.Namespace }}
spec:
  eventBusName: {{ .Values.eventBus.name }}<p>Then the script applies each sensor’s logic:</p>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<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>repoMap := make(map[string]map[string]interface{})
			for i := range entities {
				entity := &amp;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, &amp;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, &amp;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)<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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c1b5b840854ddf5/6a170eb47d8d67694e70e7e8/908d19278face3ce1119dbee9146c1264b6e2f30-1600x873.png" alt=" Foundational components for dependency management on Kubernetes" /><h2><strong>Self-service configuration</strong></h2><p>Teams configure their repositories declaratively through Backstage:</p>spec:
  renovate:
    enabled: true
    config:
      resourceGroup: LARGE      # SMALL | MEDIUM | LARGE  
      runFrequency: "0 */4 * * *"  # Every 4 hours<p>Resource groups allocate CPU and memory based on repository size:</p><ul><li><p><strong>SMALL:</strong> 500m CPU, 1Gi memory.</p></li><li><p><strong>MEDIUM:</strong> 1000m CPU, 2Gi memory.</p></li><li><p><strong>LARGE:</strong> 2000m CPU, 4Gi memory.</p></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><p><strong>Parent workflow:</strong> Lightweight CronWorkflow running on schedule. Encrypts secrets, determines whether a scan should run, passes configuration to the child.</p></li><li><p><strong>Child workflow:</strong> Ephemeral pod where Renovate CLI runs. Allocated resources dynamically, decrypts secrets in isolation, terminates after completion.</p></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><p><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.</p></li><li><p><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.</p></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><p>Metric</p><p>Scenario A (Workflows)</p><p>Scenario B (The long-running single pod)</p><p>Setup</p><p>8,000 pods (1 vCPU / 2GB)</p><p>1 pod (8 vCPU / 16 GB)*</p><p>Duration</p><p>10 minutes each</p><p>12 days continuous</p><p>Total work time</p><p>1,333 compute hours</p><p>288 compute hours</p><p>Total cost</p><p>$65.83</p><p>$113.75</p><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><p>Metric</p><p>Scenario A (Mixed swarm)</p><p>Scenario B (The long runner)</p><p>Strategy</p><p>8,000 pods (mixed sizes)</p><p>1 pod (8 vCPU / 16 GB)*</p><p>Duration</p><p>10 minutes each</p><p>12 days continuous</p><p>Total cost</p><p>$52.66</p><p>$113.75</p><p>Savings</p><p>$61.09 (54% cheaper)</p><p>—</p><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><p>Ephemeral GitHub tokens (minutes of exposure versus days).</p></li><li><p>Namespace isolation with Role-Based Access Control (RBAC) boundaries.</p></li><li><p>Secret encryption at rest in parent workflows.</p></li><li><p>Removed direct vault access.</p></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><p><strong>Why parent-child workflows?</strong></p></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>"Shai Hulud"</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 "parent" workflows act as lightweight orchestrators with a minimal footprint, while the "child" 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><p><strong>Why self-serviceable?</strong></p></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><p><strong>Why Kubernetes Operator pattern?</strong></p></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><p><strong>Why design a GitHub Events Gateway?</strong></p></li></ul><p>Adopting an <strong>event-driven architecture (EDA)</strong> was essential for the platform'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><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></li><li><p><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.</p></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:
</p><ul><li><p><strong>Increase and guardrail the adoption of auto-merge</strong></p></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.
</p><ul><li><p><strong>Improve observability around end-user experience</strong></p></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><p><strong>Remove barriers for greater adoption</strong></p></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>.
</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><p>CRDs for configuration.</p></li><li><p>Operators for lifecycle management.</p></li><li><p>Event-driven architecture for responsiveness</p></li><li><p>GitOps for deployment.</p></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><p><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>]</p></li><li><p><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>]</p></li><li><p><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>]</p></li><li><p><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>]</p></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>
    <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[Developer Experience]]></category>
    <dc:creator><![CDATA[Nikos Fotiou]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6033995d6660d149/6a170eb5839dfa63f1dcff9b/00519840e6eec7101c1fb096afcae976ee0c454e-1280x720.png" length="0" type="image/png"/>
    <pubDate>Thu, 19 Feb 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b9673440a589d20/6a170e620e2e490ba741a1ce/2484b1f7ce600fbbf75b76a12a67cdfdf9b6e6ab-800x600.jpg" alt="Text analysis for complex languages" /><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><p><strong>Algorithmic analyzers</strong> (like Snowball) strip suffixes based on patterns.</p></li><li><p><strong>Dictionary analyzers</strong> (like Hunspell) look up words in a list.</p></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><p><strong>Word:</strong> <code>בצל</code> (B-Tz-L).</p></li><li><p><strong>Context A:</strong> “The soup tastes better with <strong>onion</strong> (<em>batzal</em>).”</p></li><li><p><strong>Context B:</strong> “We sat <strong>in the shadow</strong> (<em>ba-tzel</em>) of the tree.”</p></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 "onion" into "shadow." If it’s conservative and leaves it alone, a user searching for "shadow" (<em>tzel</em>) will fail to find documents containing "in the shadow" (<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><p><strong>Word:</strong> <code>Wachstube</code>.</p></li><li><p><strong>Split A:</strong> <code>Wach</code> (guard) + <code>Stube</code> (room) = guardroom.</p></li><li><p><strong>Split B:</strong> <code>Wachs</code> (wax) + <code>Tube</code> (tube) = wax tube.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc0b12fffeaaf9b52/6a170e64cdacbf69597d2a98/eefee9dc6206452d362f8f58dc35c793021dcb1e-800x524.jpg" alt="Compound words in search" /><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 "onion" or "in shadow" 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><p><strong>External model service:</strong> A simple Python service (for example, FastAPI) hosts the model.</p></li><li><p><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-custom"><strong>Elasticsearch inference API</strong></a><strong>:</strong> Defines this service as a custom model within Elasticsearch.</p></li><li><p><strong>Ingest pipeline:</strong> Sends text to the inference processor, which calls your Python service.</p></li><li><p><strong>Index mapping: </strong>Create a <code>whitespace</code> target field for the analyzed text.</p></li><li><p><strong>Indexing:</strong> The service returns the cleaned text, which Elasticsearch stores in the target field.</p></li><li><p><strong>Search:</strong> Queries are analyzed via the inference API before matching.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3e93cf10d9a93e21/6a170e66839dfa475cdcff90/5c3055a1594f267c676347da36b1b8b2b187220c-1600x1248.png" alt=" Text analysis for complex languages in Elasticsearch architecture" /><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><p>Python 3.10+.</p></li><li><p>Elasticsearch 8.9.x+.</p></li></ul><p>Install the Python dependencies:</p>pip3 install fastapi uvicorn torch transformers<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><p>Receives text from the Elasticsearch inference API.</p></li><li><p>Passes it through the neural model.</p></li><li><p>Returns analyzed text in a format Elasticsearch understands.</p></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'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>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) -&gt; 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: &lt;word&gt;" 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" -&gt; "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"}<p>Save the code above to a file (for example, <code>analyzer_service.py</code>), and run:</p>python3 -m uvicorn analyzer_service:app --port 8000<p>Wait for “<em>Models loaded successfully!</em>” (takes ~30–60 seconds for models to download on first run).</p><p>Test locally:</p>#Hebrew
curl -X POST http://localhost:8000/analyze/hebrew \
 -H "Content-Type: application/json" \
 -d '{"input": "הילדים אכלו גלידה בגינה"}'#German
curl -X POST http://localhost:8000/analyze/german \
 -H "Content-Type: application/json" \
 -d '{"input": "Donaudampfschifffahrt"}'<p>Expected output:</p>- Hebrew: `{"choices":[{"message":{"content":"ילד אוכל גלידה גינה"}}]}`
- German: `{"choices":[{"message":{"content":"Donau Dampf Schiff Fahrt"}}]}`<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"> </a><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># Install ngrok (macOS) (Or download from https://ngrok.com/download)
brew install ngrok<p>Expose your local service:</p>ngrok http 8000<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> 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"     
       }                               
     }                                 
   }                                   
 }<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> 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"  
       }                             
     }                               
   }                                 
 }<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>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"
       }
     }
   }
 ]
}<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>PUT /my-hebrew-index
{
 "mappings": {
   "properties": {
     "content": {
       "type": "text",
       "analyzer": "standard"
     },
     "content_analyzed": {
       "type": "text",
       "analyzer": "whitespace"
     }
   }
 }
}<h3><strong>Step 5: Indexing</strong></h3><p><strong>Option A: Single document.</strong></p>POST /my-hebrew-index/_doc?pipeline=hebrew_analysis_pipeline
{
"content": "הילדים אכלו גלידה בגינה"
}<p><strong>Option B: Reindex existing data.</strong></p><p>If you have existing data in another index, reindex it through the pipeline:</p>POST _reindex
{
 "source": {
   "index": "my-old-index"
 },
 "dest": {
   "index": "my-hebrew-index",
   "pipeline": "hebrew_analysis_pipeline"
 }
}<p><strong>Option C: Set pipeline as default for index.</strong></p><p>Make all future documents automatically use the pipeline:</p>PUT /my-hebrew-index/_settings
{
"index.default_pipeline": "hebrew_analysis_pipeline"
}<p>Then index normally (no <code>?pipeline=</code> needed):</p>POST /my-hebrew-index/_doc
{
"content": "הילדים אכלו גלידה בגינה"
}<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> POST _inference/completion/hebrew-analyzer
 {
   "input": "הילדים אכלו גלידה בגינה"
 }<p><strong>B. Search with the result.</strong></p> GET /my-hebrew-index/_search
 {
   "query": {
     "match": {
       "content_analyzed": "ילד אוכל גלידה גינה"
     }
   }
 }<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><p><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>.</p></li><li><p><strong>German: </strong>Generative decompounding. Supports 56 languages, including Dutch, Swedish, Finnish, and Turkish. <a href="https://huggingface.co/benjamin/compoundpiece">benjamin/compoundpiece</a>.</p></li><li><p><strong>Arabic:</strong> BERT-based disambiguation and lemmatization for Modern Standard Arabic. <a href="https://github.com/CAMeL-Lab/camel_tools">CAMeL Tools</a>.</p></li><li><p><strong>Polish:</strong> Case-sensitive lemmatization for Polish inflections. <a href="https://huggingface.co/amu-cai/polemma-large">amu-cai/polemma-large</a>.</p></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></p><p><strong>References</strong>:</p><ul><li><p><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></p></li><li><p><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></p></li><li><p><a href="https://ngrok.com">https://ngrok.com</a></p></li><li><p><a href="https://huggingface.co/dicta-il/dictabert-lex">https://huggingface.co/dicta-il/dictabert-lex</a></p></li><li><p><a href="https://huggingface.co/benjamin/compoundpiece">https://huggingface.co/benjamin/compoundpiece</a></p></li><li><p><a href="https://arxiv.org/pdf/2305.14214">https://arxiv.org/pdf/2305.14214</a></p></li></ul>]]></content:encoded>
    <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[AI]]></category>
    <dc:creator><![CDATA[Noam Schwartz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09154d73719c99b2/6a170e68ab7f080f19db9f3e/a572f9832d8ebc603b70743ac8f2d6e4ea8d2e11-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 18 Feb 2026 00:00:00 GMT</pubDate>
  </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>
    <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>./scripts/build_plugin_linux.sh<p>Install in Elasticsearch:</p>/path/to/elasticsearch/bin/elasticsearch-plugin install file:///path/to/heb-lemmas-embedded-plugin-&lt;ES_VERSION&gt;.zip<p>Test:</p>curl -k -X POST "https://localhost:9200/_analyze" \
  -H "Content-Type: application/json" \
  -u "elastic:&lt;password&gt;" \
  -d '{"tokenizer":"whitespace","filter":["heb_lemmas","heb_stopwords"],"text":"הילדים אוכלים את הבננות"}'<h2>
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><p>בית (a house)</p></li><li><p>בבית (in the house)</p></li><li><p>לבית (to the house)</p></li><li><p>בבתים (in houses)</p></li><li><p>לבתים (to houses)</p></li></ul><p>Without lemmatization, these become different surface tokens; lemmatization normalizes them toward the same lemma (בית), improving recall:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbce7cc588b978eca/6a170e4facf0888338be9bec/d3dfd40569b3f4bfc79df639a49ae995e92b0bc1-1600x983.png" alt="Hebrew analyzer for Elasticsearch lemmatization" /><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><p>Runs a Hebrew lemmatization model inside Elasticsearch.</p></li><li><p>Produces better normalized tokens for Hebrew text.</p></li><li><p>Supports stopwords and standard analyzer pipelines.</p></li></ul><h2>The result: Fast, reliable lemmatization</h2><p>This analyzer is optimized for real‑world throughput:</p><ul><li><p>ONNX Runtime in‑process inference.</p></li><li><p>INT8-quantized model for lower latency and memory footprint.</p></li><li><p>Java Foreign Function Interface (FFI) for high‑performance native inference.</p></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><p>Metric (search)</p><p>Task</p><p>Value</p><p>Unit</p><p>Min throughput</p><p>hebrew-query-search</p><p>409.75</p><p>ops/s</p><p>Mean throughput</p><p>hebrew-query-search</p><p>490.65</p><p>ops/s</p><p>Median throughput</p><p>hebrew-query-search</p><p>491.85</p><p>ops/s</p><p>Max throughput</p><p>hebrew-query-search</p><p>496.13</p><p>ops/s</p><p>50th percentile latency</p><p>hebrew-query-search</p><p>7.02242</p><p>ms</p><p>90th percentile latency</p><p>hebrew-query-search</p><p>10.7338</p><p>ms</p><p>99th percentile latency</p><p>hebrew-query-search</p><p>19.0406</p><p>ms</p><p>99.9th percentile latency</p><p>hebrew-query-search</p><p>27.165</p><p>ms</p><p>50th percentile service time</p><p>hebrew-query-search</p><p>7.02242</p><p>ms</p><p>90th percentile service time</p><p>hebrew-query-search</p><p>10.7338</p><p>ms</p><p>99th percentile service time</p><p>hebrew-query-search</p><p>19.0406</p><p>ms</p><p>99.9th percentile service time</p><p>hebrew-query-search</p><p>27.165</p><p>ms</p><p>Error rate</p><p>hebrew-query-search</p><p>0</p><p>%</p><h2>Open source and Elastic‑ready</h2><p>The plugin is fully open source and works on:</p><ul><li><p>Elastic open‑source distributions.</p></li><li><p>Elastic Cloud.</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt63c562ad175596ae/6a170e517d8d6737f070e7ca/e2e8731aeb884e91624f2c8c0998cf8de08a16e3-1402x1600.png" alt="Open source and Elastic‑ready for Hebrew analyzer for Elasticsearch lemmatization" /><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><p><a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin">GitHub repo</a></p></li><li><p><a href="https://github.com/liladler/elasticsearch-analysis-hebrew-plugin/releases">Releases</a></p></li></ul>]]></content:encoded>
    <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[Relevance]]></category>
    <dc:creator><![CDATA[Lily Adler]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte49ad43c2e28fd37/6a170e530e2e490f5741a1bd/8d9f79cec59d89f4e14657db7df846ed3104a2da-1024x565.png" length="0" type="image/png"/>
    <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
  </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>
    <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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc2f2b97b3d6911e6/6a170e2e67045b22db45c280/b0f8d88cfb8c696b8ef805424d7dc7a242023484-913x394.png" alt="bfloat16" /><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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1570058a6fed8296/6a170e30b0367d770272bdb1/d7d2459cead1b8d29d107a1b6bbbe21be1fc7315-1600x990.png" alt="bfloat16 in Elasticsearch" /><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>
    <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[Vector Database]]></category>
    <dc:creator><![CDATA[Simon Cooper]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b9dfebf4a2e59f2/6a170e31a6c2b95f67e7978e/f80f988d758f53742f6b4cd13b04d0cb27af7a17-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate>
  </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>
    <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 "Laptop Refresh" 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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc15ebf6d2dbf924a/6a170e28a6c2b91dcbe79788/eb42459bfae9c2ac95f2012882ce826db5526705-1600x1000.png" alt="Agent Builder &amp; Workflows architecture: Laptop Refresh automation" /><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><p><strong>Intent detection</strong></p><ul><li><p><strong>Mechanism:</strong> System prompt instruction.</p></li><li><p><strong>Implementation:</strong> The agent is explicitly told its single purpose in the <code>MISSION</code> statement. It doesn’t need to "detect" other intents because it’s scoped strictly to IT provisioning.</p><ul><li><p><em><strong>Code reference</strong></em><em>:</em> <code>MISSION: You are a specialized agent designed to collect complete employee onboarding information...</code></p></li></ul></li><li><p><strong>Constraint:</strong> If a user asks about non-IT topics (for example, "What is the weather?"), the <code>MISSION</code> implies that the agent should pivot back to data collection or decline, depending on the large language model’s (LLM's) default safety alignment.</p></li></ul></li><li><p><strong>Slot filling (data collection)</strong></p><ul><li><p><strong>Mechanism:</strong> Phased conversation flow.</p></li><li><p><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.</p><ul><li><p><em><strong>Code reference:</strong></em><code>PHASE 1: Personal information, PHASE 2: Employment Details, and so on.</code></p></li></ul></li><li><p><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.</p></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, "Is the laptop &gt; 3 years old?"), 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 "brain" of the operation using <strong>Agent Builder</strong>. We need an agent that acts strictly within the bounds of IT provisioning. We don'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'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>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 &amp; 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.<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>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<h3><strong>Interacting with ServiceNow</strong></h3><p>The workflow executes a series of HTTP steps. Crucially, we first need to identify the user'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>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<p>2. Adding to cart</p><p>Once we have the asset details and the user's preferences, we don't just create a generic ticket. We use the ServiceNow Service Catalog API to programmatically add the specific item to a cart.</p>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 }}"
            }
        }<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>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 }}"<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><p><strong>The user</strong> chats naturally with the agent to provide details.</p></li><li><p><strong>The agent</strong> structures this unstructured conversation into a JSON object.</p></li><li><p><strong>Workflow</strong> receives the JSON, validates the user's current hardware via ServiceNow, creates the order, and indexes the result.</p></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><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>
    <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[Agentic AI]]></category>
    <dc:creator><![CDATA[Sri Kolagani,Ziyad Akmal]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9cd2f96f1ccdd81f/6a170e2a961e69b254c4cfa0/80e98ed860633a0a20abcc55ad10b2854a4e8df0-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 13 Feb 2026 00:00:00 GMT</pubDate>
  </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>
    <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><p>Lexical retrieval: “Do these documents contain the words I typed (or related words)?”</p></li><li><p>Semantic retrieval: “Do these documents mean something similar to what I typed?”</p></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://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf0bfbf25638698c9/6a170d5f964cea3fa308bc25/a36692581ec5adb54d3c517e171b6d2f372efd92-1249x514.png" alt="BM25 example flow for hybrid search" /><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><p>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.</p></li><li><p>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.</p></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 <a href="https://www.elastic.co/what-is/hybrid-search"><strong>What is hybrid search?</strong></a>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'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>curl -fsSL https://elastic.co/start-local | sh<p>After starting, you'll have:</p><ul><li><p>Elasticsearch at http://localhost:9200.</p></li><li><p>Kibana at http://localhost:5601.</p></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><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>pip install langchain-elasticsearch langchain-ollama<p><strong>In JavaScript:</strong></p>npm install @langchain/community @langchain/ollama @elastic/elasticsearch csv-parse<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><p>Where Elasticsearch is <code>(ES_LOCAL_URL)</code>.</p></li><li><p>How to authenticate <code>(ES_LOCAL_API_KEY)</code>.</p></li><li><p>Which demo index name to use <code>(INDEX_NAME)</code>.</p></li><li><p>Which CSV file we’ll ingest <code>(scifi_1000.csv)</code>.</p></li></ul>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")<p><strong>In JavaScript:</strong></p><p>Notes for JavaScript:</p><ul><li><p>JavaScript uses <code>process.env</code> instead of <code>os.getenv</code>.</p></li><li><p>Path resolution requires <code>fileURLToPath</code> and <code>dirname</code> for Elasticsearch modules.</p></li><li><p>The class is called <code>ElasticVectorSearch</code> (not <code>ElasticsearchStore</code> as in Python).</p></li></ul>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");<p>We can now also create the client.</p><p>In Python:</p>es = Elasticsearch(ES_URL, api_key=ES_LOCAL_API_KEY)<p>In JavaScript:</p>const client = new Client({
  node: ES_URL,
  auth: ES_API_KEY ? { apiKey: ES_LOCAL_API_KEY } : undefined,
});<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><p><code>texts</code>: The actual text that will be embedded + searched.</p></li><li><p><code>metadata</code>: Structured fields stored alongside the document.</p></li><li><p><code>ids</code>: Stable IDs (so Elasticsearch can dedupe if needed).</p></li></ul><p><strong>In Python:</strong></p># --- 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)<p><strong>In JavaScript:</strong></p>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);
  }<p><strong>What’s important here:</strong></p><ul><li><p>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.</p></li><li><p>The same text is what the lexical side uses, too (in hybrid mode), because it’s indexed as searchable text.</p></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>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)<p><strong>In JavaScript:</strong></p>  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) =&gt; ({
      pageContent: text,
      metadata: metadatas[i],
    })),
    { ids }
  );<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 <em><strong>vector-only</strong></em> search and <code>hybrid=True</code> is <em><strong>hybrid search</strong></em> (BM25 + kNN, fused with RRF).</p><p><strong>In Python:</strong></p># 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),
)<p><strong>In JavaScript:</strong></p>  // 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"
    }),
  });<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." </em>and compare the results from hybrid search and vector search.</p><p><strong>In Python:</strong></p>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]}")<p><strong>In JavaScript:</strong></p>  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) =&gt; {
    console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
  });

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

main().catch(console.error);<p><strong>Example output</strong></p>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)<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><p>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.</p></li><li><p>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.</p></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>
    <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[Hybrid Search]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Margaret Gu,Eyo Eshetu]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe53f88c9e39c39e/6a170d61a6c2b9013be79762/9159af2b07b88f288e5c7cb719c8dcbe5d3b37d6-1080x608.png" length="0" type="image/png"/>
    <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Speed up vector ingestion using Base64-encoded strings]]></title>
    <description><![CDATA[Introducing Base64-encoded strings to speed up vector ingestion in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>We’re improving the ingestion speed of vectors in Elasticsearch. Now, in <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> and in v9.3, you can send your vectors to Elasticsearch encoded as Base64 strings, which will provide immediate benefits to your ingestion pipeline.</p><p>This change reduces the overhead of parsing vectors in JSON by an order of magnitude, which translates to almost a 100% improvement on indexing throughput for DiskBBQ and around 20% improvement for hierarchical navigable small world (HNSW) workloads. In this blog, we’ll take a closer look at Base64-encoded strings and the improvements it brings to vector ingestion.</p><h2>What’s the problem?</h2><p>At Elastic, we’re always looking for ways to improve our vector search capabilities, whether that’s enhancing existing storage formats or introducing new ones. Recently, for example, we added a new disk-friendly storage format called <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a> and enabled vector indexing with <a href="https://www.elastic.co/search-labs/blog/elasticsearch-gpu-accelerated-vector-indexing-nvidia">NVIDIA cuVS</a>.</p><p>In both cases, we expected to see major gains in ingestion speed. However, once these changes were fully integrated into Elasticsearch, the improvements weren’t as large as we had hoped. A flamegraph of the ingestion process made the issue clear: JSON parsing had become one of the main bottlenecks.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d4cd9d8628b64b3/6a170c5b2867148d5e93e353/a286408afc85ff1cd3dd448b8fdf59dd3e11d599-1600x675.png" alt="Vector ingestion before using Base64-encoded strings  " /><p>Parsing JSON requires walking through every element in the arrays and converting numbers from text format into 32-bit floating-point values, which is very expensive.</p><h3>Why Base64-encoded strings?</h3><p>The most efficient way to parse vectors is directly from their binary representation, where each element uses a 32-bit floating-point value. However, JSON is a text-based format, and the way to include binary data in it is by using <a href="https://en.wikipedia.org/wiki/Base64">Base64</a>-encoded strings. Base64 is just a binary-to-text encoding schema.</p>{
  “emb” : [1.2345678, 2.3456789, 3.4567891]
}<p>We can now send vectors encoded as Base64 strings:</p>{
  “emb” : ”P54GUUAWH5pAXTwI”
}<p>Is it worth it? Our benchmarks suggest yes. When parsing 1,000 JSON documents, using Base64 encoded strings instead of float arrays resulted in performance improvements of more than an order of magnitude, at the cost of a small encode/decode trade-off (client-side Base64 encoding and a temporary byte array on the server for decoding) in exchange for eliminating expensive per-element numeric parsing.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9a1662fdf7d5849/6a170c5d839dfaf624dcff29/86e5a926e13b07bb3b0abe80bd4930464e8f6f9b-1200x742.png" alt="Base64 vs. Float32 parsing time" /><h3>Give me some ingestion numbers</h3><p>We can see these improvements in practice when running the <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/README.md"><code>so_vector</code></a> rally track with the different approaches. The actual gains depend on how fast indexing is for each storage format. For <code>bbq_disk</code>, indexing throughput increases by about 100%, while for <code>bbq_hnsw</code>, the improvement is closer to 20%, since indexing is inherently slower there.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte35ffc920ae863f5/6a170c5e509168f193e1bb1c/4277057ee59cb84d068176b56bb7fa00b66e1cb3-1200x742.png" alt="Base64 vs Float32 indexing throughput" /><p>Starting with Elasticsearch v9.2, <a href="https://www.elastic.co/search-labs/blog/elasticsearch-exclude-vectors-from-source">vectors are excluded from </a><a href="https://www.elastic.co/search-labs/blog/elasticsearch-exclude-vectors-from-source"><code>_source</code></a> by default and are stored internally as 32-bit floating-point values. This behavior also applies to Base64-encoded vectors, making the choice of indexing format completely transparent at search time.</p><h2>Client support</h2><p>Adding a new format for indexing vectors might require changes on ingestion pipelines. To help this effort, in v9.3, Elasticsearch official clients can transform vectors with 32-bit floating-point values into Base64-encoded strings and the other way around. You might need to check the client documentation for the specific implementation.</p><p>For example, here’s a snippet for implementing bulk loading using the Python client:</p>from elasticsearch.helpers import bulk, pack_dense_vector

def get_next_document():
    for doc in dataset:
        yield {
            "_index": "my-index",
            "_source": {
                "title": doc["title"],
                "text": doc["text"],
                "emb": pack_dense_vector(doc["emb"]),
            },
        }

result = bulk(
    client=client,
    chunk_size=chunk_size,
    actions=get_next_document,
    stats_only=True,
)<p>The only difference from a bulk ingest using floats is that the embedding is wrapped with the <code>pack_dense_vector()</code> auxiliary function.</p><h2>Conclusion</h2><p>By switching from JSON float arrays to Base64-encoded vectors, we remove one of the largest remaining bottlenecks in Elasticsearch’s vector ingestion pipeline: numeric parsing. The result is a simple change with outsized impact: up to 2× higher throughput for DiskBBQ workloads and meaningful gains even for slower indexing strategies, like HNSW.</p><p>Because vectors are already stored internally in a binary format and excluded from <code>_source</code> by default, this improvement is completely transparent at search time. With official client support landing in v9.3, adopting Base64 encoding requires only minimal changes to existing ingestion code, while delivering immediate performance benefits.</p><p>If you’re indexing large volumes of embeddings, especially in high-throughput or serverless environments, Base64-encoded vectors are now the fastest and most efficient way to get your data into Elasticsearch.Those interested in the implementation details can follow the related Elasticsearch issues and pull requests: #<a href="https://github.com/elastic/elasticsearch/issues/111281">111281</a> and #<a href="https://github.com/elastic/elasticsearch/issues/135943">135943</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/base64-encoded-strings-vector-ingestion</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/base64-encoded-strings-vector-ingestion</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Jim Ferenczi,Benjamin Trent,Ignacio Vera Sequeiros]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc5ffc7ac4c2b9d93/6a170c5f839dfa007ddcff2d/4c1ebbd7a1071e8e1721a9871cba87f6aed140e9-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Cookbook for a production-grade generative AI sandbox]]></title>
    <description><![CDATA[Exploring the recipe for a generative AI sandbox, giving developers a secure environment to deploy application prototypes while enabling privacy and innovation.]]></description>
    <content:encoded><![CDATA[<p>Building generative AI (GenAI) applications is all the rage, and c<em>ontext engineering</em>, that is, providing the prompt structure and data needed for a large language model (LLM) to return specific, relevant answers to a question without filling in the blanks itself, is one of the most popular patterns that has emerged in the past 24 months. One particular subset of context engineering, retrieval-augmented generation (RAG), is being used widely to bring additional context to LLM interactions by using the power of natural language-based search to surface the most relevant results in private datasets based on meaning rather than on keywords.</p><p>As context engineering is exploding, ensuring that rapid prototype projects don’t expose business- or mission-critical data to unauthorized recipients is a significant concern. For audiences interested in technology and policy alike, I've championed the concept of a <em>privacy-first GenAI sandbox</em>, which I’ll simply refer to as a sandbox from here on. In this article, the term <em>sandbox</em> refers to a self-service, secure prototyping space (much like a child's sandbox, where the wooden edges prevent sand from escaping), allowing organization members to test their custom context engineering applications safely, without risking exposure of confidential data.</p><h2>Production-grade GenAI sandboxes = enabling privacy <em>and</em> innovation</h2><p>GenAI, from text-generating tools, like ChatGPT, Claude, and Gemini, to image creators, such as Google’s Nano Banana, OpenAI’s DALL-E, and Midjourney, has sparked discussions everywhere: in classrooms, at dinner tables, in regulatory circles, in courts, and in boardrooms over the past two years.</p><p>I’ve had the privilege of sharing Elastic’s approach to context engineering, and particularly RAG, with customers, including developers and C-suite executives, and with contacts of mine, ranging from friends and family to legislators. Think of context engineering as a librarian that looks up and then serves contextual data to augment text, audio, or image GenAI apps that they don’t have in the data they were trained on for their intended tasks; for example, looking up sports scores and headlines to help a text-generation application answer the question, “What happened in the National Hockey League yesterday?”</p><p>Elasticsearch Labs has fabulous primers on context engineering <a href="https://www.elastic.co/search-labs/blog/context-engineering-overview">here</a> and RAG <a href="https://www.elastic.co/search-labs/blog/articles/retrieval-augmented-generation-rag">here</a>, if you’re unfamiliar with the concept and would like to do more reading.</p><p>A <em>privacy first</em> approach ensures that context engineering supplies the GenAI app with protected, selected, or delicate data, fostering responses that are better informed and more pertinent than what might be generated using solely public information. An example of this would be providing a GenAI-powered, interactive text chat experience (chatbot) for university students to obtain financial aid and scholarship information relevant to their personal background, without risking exposure of personally identifiable information (PII), such as their Social Security number or birthdate, to malicious actors extracting information via common vulnerabilities, as per the <a href="https://genai.owasp.org/resource/owasp-top-10-for-llm-applications-2025/">OWASP Top 10</a>, or the LLM itself.</p><p></p><p>The core tenets of the logic behind deploying a sandbox are as follows:</p><ol><li><p>Users will find a way to incorporate GenAI into their daily workflow, regardless of whether one’s organization provides the tools. Even in organizations where preventing such “shadow IT” is, realistically, impractical or impossible, providing and monitoring access to prevent disclosure of an organization’s sensitive data still remains imperative; a sandbox is just the place to turn such tools loose.</p></li><li><p>Providing a sandbox to deploy applications with Application Performance Monitoring (APM) and information security (InfoSec) best practices embedded allows an organization to derive insights into potential use cases for GenAI while also safeguarding privacy, enabling audit and accountability of GenAI use, and establishing centralized cost management.</p></li><li><p>An organization’s sandbox should allow either self-service or low-touch deployment of peer-reviewed GenAI applications to permit maximum experimentation with minimum friction by those inclined to develop their own applications. </p></li><li><p>If properly implemented and contained within the organization’s controlled perimeter, the sandbox allows leveraging data assets available to the organization without triggering the liabilities that could attach to unauthorized or unintended external sharing or other leakage of protected data such as PII – think California CCPA, or the EU/UK GDPR for instance.</p></li></ol><p>This article will not focus on building a GenAI app; there are numerous excellent examples here on Elasticsearch Labs. Instead, I’ll be focusing on the <em>recipe</em> necessary for deploying a sandbox that provides the security and availability needed to implement principle #3 above.</p><h3>Foundational ingredients</h3><p>For a sandbox to be considered <em>production grade</em>, the following foundational ingredients should be considered:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt220f947ab7e0786e/6a170c62a929cfb7bbae0a0a/c53aaa04503baf654ccee274f012d7c1ddc2f643-1380x1600.png" alt="Generative AI (GenAI) sandbox foundation" /><p>Let's explore why each ingredient plays a crucial role in our sandbox recipe. As we do, please note that brand-name decisions I’ve listed below are based on personal experience and aren’t an endorsement of one technology or another by Elastic. As with any recipe, these then form my preferred ingredients. You can, of course, substitute in each area to make the recipe to your liking:</p><h4>1. Containerization platform</h4><p>The first ingredient in our sandbox recipe is the selection of a <strong>containerization platform</strong>. These platforms, while conceptually similar to the virtual machines that have been a staple of enterprise IT for the past 15+ years, represent a significant evolution in how applications are packaged and deployed. They’re designed for rapid deployment, upgrades without service disruption, and native distribution across both on-premises and cloud computing environments, while also providing increased testability, validation of infrastructure, and auditability. The platform you choose, often managed through <strong>infrastructure as code</strong> (IaC) to ensure reproducibility and consistency, is the foundation that enables agility and scalability for your GenAI applications.</p><p>Key components of a containerization platform</p><p>A robust containerization platform is built on several key components:</p><ul><li><p><strong>Container runtime:</strong> The software that executes containers and manages their lifecycle. A popular example is <strong>Docker</strong>, which provides the tools to build, share, and run container images.</p></li><li><p><strong>Image build infrastructure:</strong> This is the process and tooling used to create container images from your application's source code. Tools like <strong>Dockerfiles</strong> provide a clear, repeatable way to define the environment, dependencies, and application code within an image, ensuring consistency across development, testing, and production environments.</p></li><li><p><strong>Orchestration engine:</strong> For a production-grade environment, you need a system to automate the deployment, scaling, and management of containers. <strong>Kubernetes</strong> (k8s) is the industry-standard for this, providing powerful features for load balancing, self-healing, and service discovery. More on that below in ingredient #2.</p></li></ul><p><strong>1. 1 Infrastructure as code:</strong></p><p>To ensure the reproducibility and maintainability of your sandbox, a <strong>containerization platform</strong> should be managed using <strong>IaC</strong> principles. This means that instead of manually configuring your platform, you define your infrastructure (for example, Kubernetes clusters, networking rules, security policies) in code files (for example, using <strong>Terraform</strong> or <strong>Pulumi</strong>). This approach provides several benefits:</p><ul><li><p><strong>Version control:</strong> Your infrastructure can be treated like any other code, allowing you to track changes, revert to previous versions, and collaborate with your team using Git.</p></li><li><p><strong>Consistency:</strong> IaC dramatically reduces manual errors and ensures that your sandbox environment can be recreated identically in any cloud or on-premises location.</p></li><li><p><strong>Automation:</strong> It enables you to automate the entire setup and teardown process, making it easy to create temporary sandboxes for specific projects or testing.</p></li></ul><h4>2. Hosting and orchestration</h4><p>As we introduced in the "Containerization platform" section, a powerful orchestration engine is needed to manage our containers at scale. For this, k8s is the de facto standard for orchestrating a production-grade sandbox. If you’re unfamiliar, check out the Cloud Native Computing Foundation (CNCF) primer on k8s available <a href="https://kubernetes.io/docs/tutorials/kubernetes-basics/">here</a>. Whether running in the cloud or on-premises, Kubernetes provides the robust framework needed to deploy, scale, and manage the lifecycle of containerized applications. Major cloud providers, like Google Cloud (Google Kubernetes Engine [GKE]), Amazon Web Services (Elastic Kubernetes Service [EKS]), and Microsoft Azure (Azure Kubernetes Service [AKS]), all offer mature, managed Kubernetes services that handle the underlying complexity, including in particular contractually assured and independently certified compliance with statutory privacy and information security mandates, allowing your teams to focus on building and deploying applications.</p><p>For a GenAI sandbox, Kubernetes is particularly valuable because it can efficiently manage and scale GPU resources, which are often necessary for two key components of the GenAI stack: 1) privately hosted LLMs; and 2) the inference processes that power them (discussed in more detail in ingredients #6 and #7). Its ability to automate deployments and manage resources ensures that rapid prototypers can experiment with different models and applications without needing to become infrastructure experts, all within the secure and isolated area, called a <em>namespace</em> in k8s, that you define. This abstraction is key to the sandbox's success, empowering innovation while maintaining centralized control.</p><h4>3. Code repository / image repository</h4><p>A centralized code repository is an essential element of a secure and collaborative GenAI sandbox. It provides a single, controlled environment for developers to store, manage, and version their code, preventing the proliferation of sensitive information across disparate, unsecured locations. By establishing a centralized repository, organizations can enforce security policies, monitor for vulnerabilities, and maintain a clear audit trail of all code changes, which is critical for maintaining data privacy and integrity within the sandbox environment.</p><p>For instance, a service like GitHub, when integrated with your organization's identity and access management (IAM) and single sign-on (SSO) solutions (see ingredient #4 below), becomes a powerful tool for enforcing the principle of least privilege. This integration ensures that only authenticated and authorized developers can access specific code repositories. You can create teams and apply granular permissions, restricting access to sensitive projects and preventing unauthorized code modifications. This is especially important in a GenAI context where code might contain proprietary algorithms, sensitive data connectors, or even, in some cases, organization or user-level credentials or other confidential information.</p><p>Furthermore, modern repository platforms offer automated security scanning features. These tools continuously scan code for known vulnerabilities, insecure coding practices, and exposed secrets. If a developer accidentally commits a password or an API key, the system can automatically flag it and notify the security team. This proactive approach to security is essential for preventing data breaches, enforcing legal requirements and contractual commitments of confidentiality, and ensuring the overall integrity of the GenAI applications being developed to deploy in the sandbox. By mandating that all development occurs in a centralized and secured repository, you create a transparent, auditable, and secure foundation for innovation, allowing your developers the freedom to experiment without compromising organizational security.</p><h4>4. Identity and access management</h4><p>IAM is a core component of a secure, privacy-first grounded AI environment. It provides the foundation for ensuring that only authorized individuals and services can access sensitive data and powerful AI models. A robust IAM framework enforces the principle of least privilege, granting the minimum level of access necessary for a user or service to perform its function.</p><p><strong>4.1 Single sign-on:</strong></p><p>SSO streamlines user access by allowing users to authenticate once and gain access to multiple applications and services without re-entering their credentials. In a sandbox environment, SSO simplifies the user experience for developers, data scientists, and business users who need to interact with various components of the AI ecosystem, such as data repositories, modeling workbenches, and deployment pipelines. By centralizing authentication, SSO also enhances security by reducing the number of passwords that can be compromised and providing a single point for enforcing authentication policies. Importantly, it also lowers the barrier to entry for less-experienced developers to properly protect the data they are using in the sandbox, in turn preventing the inadvertent disclosure of sensitive information to insiders and outsiders alike.</p><p><strong>4.2 Role-based access control: </strong></p><p>Role-based access control (RBAC) is a method of restricting network access based on the roles of individual users within an organization. In the context of a GenAI sandbox, RBAC is used to define and enforce permissions for different user personas. For example, a data scientist role might have read/write access to specific datasets and the ability to apply machine learning models, while a business analyst role may only have read-only access to the outputs of those models. This ensures a clear separation of duties and prevents unauthorized access to or modification of sensitive data and AI assets.</p><p><strong>4.3 Attribute-based access control:</strong></p><p>Attribute-based access control (ABAC) provides a more granular and dynamic approach to access control than traditional RBAC. ABAC makes access decisions based on a combination of attributes of the user, the resource being accessed, and the environment. For instance, access to a particularly sensitive dataset could be restricted to users who are on the data scientist team (user attribute), accessing a resource tagged as PII (resource attribute), and are doing so from a corporate network during business hours (environment attributes). This level of granular control is critical in a GenAI sandbox for enforcing complex data governance and privacy requirements. We’ll come back to this later, when discussing the search AI datastore.</p><p><strong>4.4 Access auditability:</strong></p><p>A robust IAM framework also ensures that the granting, use, review and revocation of all access permissions is granularly logged, discoverable and auditable, so that in case of any suspected or confirmed incident, responders can quickly understand what happened, contain the incident, assess its extent, and comprehensively remedy its consequences. This is not only important for the organization’s own security, but also necessary to comply with any incident reporting and breach notice requirements that could be triggered.</p><h4>5. Secrets management</h4><p>Of all the ingredients in our recipe, secrets management is perhaps the most potent, yet most frequently overlooked. Much like a tiny pinch of saffron can dramatically alter a culinary dish, a single mishandled secret can have an outsized and devastating impact on your organization's security and reputation. In our context, a <em>secret</em> is any piece of sensitive information needed for our applications to function: API keys for first- or third-party services, database passwords, trust certificates, or tokens for authenticating to LLMs.</p><p>When these secrets are hard-coded into source code or left in plain-text configuration files, they create a massive vulnerability. A leaked API key or an exposed database credential can bypass all other security measures, providing a direct path for attackers to access sensitive data and systems. This is especially critical in a GenAI sandbox, where developers are frequently connecting to various data sources and external model providers. Without a robust secrets management strategy, you’re leaving the keys to your kingdom scattered across your digital landscape, turning your innovative sandbox into a potential source for a major data breach.</p><p>To properly secure these secrets, a dedicated secrets management platform is an essential ingredient. These tools provide a centralized, encrypted vault for storing secrets, with robust access control, auditing, and dynamic rotation capabilities. Whether you choose a self-hosted solution, like HashiCorp Vault, or a managed cloud service, such as Google Cloud's Secret Manager, or AWS Key Management Service (KMS), the principle is the same: Programmatically inject secrets into your applications at runtime. This practice ensures that secrets are never exposed in your code, keeping your most valuable credentials secure and your sandbox environment protected.</p><p>And this is more than just a best practice: since secret management technology is readily available and widely used, it forms part of the “state-of-the-art” which certain privacy laws and regulators reference as the benchmark against which an organization’s information security posture must be assessed. Failing to protect an organization’s most valuable secrets with the latest and greatest techniques available is not only a missed opportunity, it is also a potential case of regulatory non-compliance, as enforcement agencies and courts of law often recall.</p><h4>6. Private LLM deployment(s)</h4><p>Early in the advent of modern GenAI, the primary driver for using managed services, like Azure OpenAI, was the assurance that customer prompts and data would not be used to retrain public models. This was a crucial first step in enterprise adoption. However, as the field has matured, the conversation has shifted. While data privacy remains paramount, the decision to use private LLM instances, whether from major cloud providers or self-hosted, is now equally driven by the need for guaranteed throughput, predictable latency, and fine-grained control over the model's operational environment to support production-grade applications.</p><p>This critical ingredient comes in three distinct flavors, each with valid use cases and its own set of trade-offs:</p><p><strong>A. Cloud-hosted SaaS</strong></p><p>This is the most common and accessible approach. Services like OpenAI Enterprise, Azure OpenAI, Google Cloud's Vertex AI, and AWS Bedrock provide access to powerful, state-of-the-art models through a managed API.</p><ul><li><p><strong>Pros:</strong> This flavor offers the fastest time-to-market. The cloud provider handles all the underlying infrastructure, scaling, and maintenance, allowing teams to focus purely on application development. It provides a simple, pay-as-you-go model and access to a diverse model library of proprietary and open-source options.</p></li><li><p><strong>Cons:</strong> This approach offers the least control over the underlying infrastructure, which can lead to variability in performance during peak demand. It can also be more expensive at very high volumes, and it creates a dependency on the provider's roadmap and model availability. It also increases the potential vulnerability surface of the application, with data leaving the customer premises: a challenge for highly regulated and/or sovereignty-minded customers.</p></li></ul><p><strong>B. Cloud-hosted GPU + containerized LLMs</strong></p><p>This flavor involves running open-source LLMs (like models from Mistral or Meta's Llama series) on your own virtualized GPU infrastructure within a cloud provider. This is typically managed using the containerization and Kubernetes orchestration we've already discussed, often with high-performance inference servers like vLLM.</p><ul><li><p><strong>Pros:</strong> This approach provides a powerful balance of control and flexibility. You gain direct control over resource allocation, model versioning, and the serving configuration, allowing for significant performance tuning. In high-concurrency scenarios, a well-tuned inference server can dramatically increase throughput. For example, benchmarks have shown inference engines like vLLM delivering significantly higher tokens-per-second and lower latency compared to less production-oriented servers under heavy load [<a href="https://developers.redhat.com/articles/2025/09/30/vllm-or-llamacpp-choosing-right-llm-inference-engine-your-use-case">Red Hat, 2025</a>].</p></li><li><p><strong>Cons:</strong> This option carries a higher operational burden. Your team is now responsible for managing the GPU instances, container images, and the inference server configuration. It requires a deeper technical expertise in machine learning operations (MLOps) and infrastructure management to implement and maintain effectively.</p></li></ul><p><strong>C. On-premises GPUs + containerized LLMs</strong></p><p>The most controlled, and often most complex, approach involves deploying containerized LLMs on your own dedicated hardware within your own data centers. This setup is functionally similar to the second flavor but removes the reliance on a public cloud provider for the hardware layer.</p><ul><li><p><strong>Pros:</strong> This flavor offers maximum security, control, and data sovereignty. It’s the only option for organizations that require a completely <em>air-gapped environment</em>, where no data leaves the physical premises. For massive, predictable workloads, it can become more cost-effective in the long run by avoiding cloud data egress fees and per-transaction costs.</p></li><li><p><strong>Cons:</strong> The initial capital expenditure for purchasing and maintaining high-end GPU hardware is substantial. It requires a highly specialized team to manage the physical infrastructure, networking, and the entire software stack. This approach is more difficult to scale, as it requires the physical procurement and installation of new hardware.</p></li></ul><h4>7. Search AI data store</h4><p>If the LLM is the brain of our GenAI application, then the datastore is its heart, pumping relevant, context-rich information to be reasoned upon. For a RAG application to be truly effective, it cannot rely on a simple vector database alone. The grounding data is often complex, containing a mix of unstructured text, structured metadata, and a variety of data types. Therefore, the datastore you select must possess a unique set of characteristics to handle this complexity at scale.</p><p>Underpinning this entire process is the creation of <em>vector embeddings</em>, numerical representations of your data relative to the knowledge set of that embedding space. To enable semantic search, your data must first be converted into these numerical representations by an inference model. A flexible datastore should not only store these vectors but also be capable of hosting the inference process itself. Crucially, it should allow you to use your model of choice, whether it's a state-of-the-art multilingual model, a fine-tuned model for a specific domain like finance or law, a compact model built for very high-speed results, or even a model that can process images. By managing inference, the platform ensures that your data is consistently and efficiently vectorized, paving the way for the powerful search capabilities that follow.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfeba6cde3b0cc176/6a170c64e8fbce256f39fc97/dc05dfaf1bde3c7a74cf559b8c790a6e8e36be45-1600x900.png" alt="Search AI data store  for GenAI Sandbox" /><p>First, it must master <strong>hybrid search</strong>. The best retrieval systems don't force a choice between traditional keyword search, like BM25, which excels at finding specific keywords, and modern vector search, which excels at finding results using semantic meaning (that is, natural language). A truly capable datastore allows you to use both simultaneously in a single query. This ensures you can find documents that match exact product codes or acronyms while also finding documents that are conceptually related, providing the LLM with the most relevant possible context.</p><p></p><p>Second, it needs a sophisticated method for <strong>intelligent result reranking</strong>. When you run a hybrid search that combines multiple approaches, you need a way to merge the different result sets into a single, coherent ranking. Techniques like reciprocal rank fusion (RRF) are crucial here, as they intelligently combine the relevance scores from different queries to produce a final list that is more accurate and relevant than any single approach could deliver on its own.</p><p>Finally, a search AI-oriented datastore must be a <strong>unified engine with security built in</strong>. For enterprise RAG, it's not enough to just find similar vectors. You must be able to apply security and access controls to data <em>before</em> the search even happens. The aforementioned RBAC and ABAC capabilities allow prefiltering of content at search time, ensuring that the vector search is only performed on data a user is authorized to see. This mitigates risks of accidental or malicious circumvention of your access controls through the sandbox preserving demonstrable compliance with privacy and confidentiality requirements. This capability, which combines filtering, full-text search, and vector search in a single, scalable platform, is the defining characteristic of a datastore truly ready to power a secure, privacy-first GenAI sandbox.</p><h4>8. APM and security</h4><p>The final ingredient in our recipe ensures the health, security, and performance of the entire sandbox: a unified platform for APM and security information and event monitoring (SIEM). A key characteristic of a truly versatile search AI datastore is its ability to power the <em>R</em> in your RAG applications, while also acting as the standards-based repository for all logs, metrics, and traces generated by your infrastructure and applications. By consolidating this operational data into the same powerful datastore, you create a single pane of glass for observability and security.</p><p>This approach provides several critical capabilities. At the infrastructure level, you can monitor the performance and resource utilization of both the k8s clusters hosting your sandbox and the underlying GPUs that power your LLMs, allowing you to proactively identify bottlenecks or failures. At the application layer, APM provides detailed traces to diagnose latency issues or errors within your GenAI prototypes. For security, this centralized datastore becomes your SIEM, correlating login events, application logs, and network traffic to detect anomalous behavior or potential threats within the sandbox.</p><p>Most importantly, this unified platform allows you to gain deep insights into the usage of the GenAI applications themselves. By ingesting and analyzing the application telemetry, which should include the prompts being submitted by users wherever permissible, potentially with PII redacted, you can identify trends, understand what types of questions are being asked, and discover popular use cases. This provides an invaluable feedback loop for improving your RAG applications and demonstrates the power of using a single, scalable datastore to secure, monitor, and optimize your entire GenAI ecosystem.</p><h2>Cooking the recipe</h2><p>With all of the ingredients in place, let’s talk about the steps for assembling them into a production-grade sandbox.</p><p>As with any recipe book, let’s start with a photo of the cooked dish. Here’s a view of what a final architecture might look like:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte868ddaf52a782a4/6a170c65b339d59f9876a00b/de028ce2f5acd6f84e59ea67091472128a8a0143-1430x990.png" alt="GenAI sandbox recipe" /><p>The holistic environment depicted here consists of a Kubernetes cluster to host your sandboxed AI applications (with dev/preprod/prod namespaces for a continuous integration and continuous deployment [CI/CD] pipeline), an IAM infrastructure for authentication, a few GenAI applications, a repository for code and container images, and a wrapper of APM and cyber monitoring around the entire sandbox.</p><h3>Recipe step 0: Policy baseline</h3><p>Before you begin mixing any ingredients, every good chef performs their <em>mise en place</em>, that is, setting up their station for success. In our recipe, this means establishing clear policies for how the sandbox will be used. This is the foundational step, where you decide the rules of your kitchen. Will developers be allowed to use internal production data, or production data sanitized with techniques like pseudonymization and differential privacy, or life-like synthetic data, or only public data? Will the sandbox be a completely self-service platform, or a managed service with guardrails? Will application updates require a formal Change Review Board, or is a peer-review process sufficient? These questions are highly specific to each organization’s context and purposes. Answering them up front is critical, as these policy decisions will directly influence how you configure every other ingredient in the recipe.</p><h3>Recipe step 1: InfoSec baseline</h3><p>As stated in the “Ingredients” section, IAM is a nonnegotiable part of our recipe. Before letting anyone into the kitchen, you must secure the perimeter and ensure only authorized chefs wearing your approved uniform and compliant protective gear can access the tools and ingredients. This means working directly with your information security organization from day one to build the sandbox on a foundation of strong security principles. Access to your datastore, your code repository, your Kubernetes hosting environment, and the applications themselves must be restricted based on established best practices.</p><p>With your organization’s IAM policies enforced in the environment, a practical authentication flow might look like the one depicted in figure 3.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf78695fa1b2a2775/6a170c67b339d552b076a00f/7b1062f775ed2cf21e9386d1999933ba3f73efc4-1432x1488.png" alt="Authentication flow for GenAI sandbox" /><p>As you can see in the figure, no communication can occur between applications in the Kubernetes production namespace without first passing through an OAuth proxy, such as Vouch. This ensures every user is authenticated against a central provider, like Okta, which enforces policies such as two-factor authentication. In this model, critical user context, such as username and IP address, can be passed along with every request, enabling robust auditing and nonrepudiation at the application layer.</p><h3>Recipe step 2: Container configuration baseline</h3><p>Assuming that many of your rapid prototypers are passionate innovators but not necessarily seasoned software engineers or legally trained data compliance experts, it’s critical to provide a baseline configuration to ensure their success and security, without putting them at risk of breaching any rules or policies inadvertently. Think of this step as providing a master recipe card that guarantees consistency. At a minimum, you should provide clear documentation on how to build a container image, deploy it into the Kubernetes cluster, and test that all connectivity is secure.</p><p>Even better, you can create a “Clone This Starter App” template in your code repository. This gives developers a preconfigured, security-blessed starting point, complete with Dockerfiles and pipeline scripts, that they can immediately fork to begin tinkering, dramatically lowering the barrier to entry while enforcing best practices from the outset.</p><p>Additionally, many real life GenAI use cases will inevitably involve some form of PII processing, or can produce outputs that will materially impact individuals such as your employees, your consumers, or your customers’ staff. In such cases, more and more state, federal and international laws require completing various risk assessments before actual work can begin. These assessments can be cumbersome to conduct and are difficult to scale if they are carried out case by case. The “Clone This Starter App” approach also helps to prevent such compliance mandates from becoming bottlenecks to innovation, since under most legal mandates, the required assessments can be completed once for your template, and they need not be repeated for any clone that doesn’t exceed your initially defined parameters.</p><h3>Recipe step 3: Deploy user applications</h3><p>With your policies defined, your security baseline established, and your developer templates in place, it’s finally time to serve the dish. Whether you've chosen a self-service or managed deployment model, you can now confidently invite the rapid prototypers in your organization to start creating in the sandbox.</p><p>Because you’ve included APM and security logging (ingredient #8) from the beginning, you have the necessary observability to monitor application performance and user activity. This is where the magic happens: You can now learn from the applications people build, identify powerful new use cases, and gather real-world data to improve the platform, all while safeguarding organizational data. Coincidentally, this approach will also allow you to organically collect the information you might need to put on record, disclose to users, or share with auditors and regulators to demonstrate the transparency, accountability and explainability of your GenAI application, ticking many compliance boxes as you build (and not after the fact) – a textbook best practice of <em>Privacy by Design</em>.</p><h2>Where do you go from here?</h2><p>We've now walked through the entire cookbook, from selecting your fresh ingredients to following the recipe step by step. Most of the domains we've discussed (containerization, APM, IAM, and more) are culinary specialties in and of themselves.</p><h2>Conclusion</h2><p>This cookbook was designed to provide a clear recipe for building a production-grade GenAI sandbox. By carefully selecting each foundational ingredient, from your containerization platform and Kubernetes orchestration to your search AI datastore and unified APM, you ensure your final dish will be both successful and secure. Following the recipe ensures that this powerful environment is built on a foundation of security and thoughtful policy from day one.</p><p>The goal is to empower your rapid prototypers, not restrict them, and to foster a culture of responsible innovation. By providing a secure, observable, and well-equipped kitchen for experimentation, you get ahead of the curve, fostering a culture of responsible innovation. This proactive approach enables you to harness the creativity of your entire organization, transforming brilliant ideas into tangible prototypes while preventing the rise of shadow AI. You've cooked the meal; now you can enjoy the innovation it serves.</p><p>If you want to chat about this or anything else related to Elasticsearch, come join us in our <a href="https://discuss.elastic.co/">Discuss forum</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/generative-ai-sandbox-data-privacy</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/generative-ai-sandbox-data-privacy</guid>
    <category><![CDATA[Developer Experience]]></category>
    <dc:creator><![CDATA[Sean MacKirdy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb517ed34cca3d88a/6a170c6947d49ce2322d8a50/e7ed91ecf91e7a1de7d9ff514a2c285f2cb3f65c-1000x628.png" length="0" type="image/png"/>
    <pubDate>Mon, 02 Feb 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Faster ES|QL stats with Swiss-style hash tables]]></title>
    <description><![CDATA[How Swiss-inspired hashing and SIMD-friendly design deliver consistent, measurable speedups in Elasticsearch Query Language (ES|QL).]]></description>
    <content:encoded><![CDATA[<p>We recently replaced key parts of Elasticsearch’s hash table implementation with a Swiss-style design and observed up to 2–3x faster build and iteration times on uniform, high-cardinality workloads. The result is lower latency, better throughput, and more predictable performance for Elasticsearch Query Language (ES|QL) stats and analytics operations.</p><h2>Why this matters</h2><p>Most typical analytical workflows eventually boil down to grouping data. Whether it’s computing average bytes per host, counting events per user, or aggregating metrics across dimensions, the core operation is the same — map keys to groups and update running aggregates.</p><p>At a small scale, almost any reasonable hash table works fine. At the large scale (hundreds of millions of documents and millions of distinct groups) details start to matter. Load factors, probing strategy, memory layout, and cache behavior can make the difference between linear performance and a wall of cache misses.</p><p>Elasticsearch has supported these workloads for years, but we’re always looking for opportunities to modernize core algorithms. As such, we evaluated a newer approach inspired by Swiss tables and applied it to how ES|QL computes statistics.</p><h2>What are Swiss tables, really?</h2><p>Swiss tables are a family of modern hash tables popularized by Google’s SwissTable and later adopted in Abseil and other libraries.</p><p>Traditional hash tables spend a lot of time chasing pointers or loading keys just to discover that they don’t match. Swiss tables’ defining feature is the ability to reject most probes using a tiny cache-resident array structure, stored separately from the keys and values, called <em>control bytes</em>, to dramatically reduce memory traffic.</p><p>Each control byte represents a single slot and, in our case, encodes two things: whether the slot is empty, and a short fingerprint derived from the hash. These control bytes are laid out contiguously in memory, typically in groups of 16, making them ideal for <a href="https://en.wikipedia.org/wiki/Single_instruction,_multiple_data">single instruction, multiple data</a> (SIMD) processing.</p><p>Instead of probing one slot at a time, Swiss tables scan an entire control-byte block using vector instructions. In a single operation, the CPU compares the fingerprint of the incoming key against 16 slots and filters out empty entries. Only the few candidates that survive this fast path require loading and comparing the actual keys.</p><p>This design trades a small amount of extra metadata for much better cache locality and far fewer random loads. As the table grows and probe chains lengthen, those properties become increasingly valuable.</p><h2>SIMD at the center</h2><p>The real star of the show is SIMD.</p><p>Control bytes are not just compact, they’re also explicitly designed to be processed with vector instructions. A single SIMD compare can check 16 fingerprints at once, turning what would normally be a loop into a handful of wide operations. For example:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1f710e87dd749ab3/6a170cc46234e052dadb1a49/bd418778f0c6144f8f5f18419f6220ac0c935c7a-903x407.png" alt="SIMD at the center in Elasticsearch" /><p>In practice, this means:</p><ul><li><p>Fewer branches.</p></li><li><p>Shorter probe chains.</p></li><li><p>Fewer loads from key and value memory.</p></li><li><p>Much better utilization of the CPU’s execution units.</p></li></ul><p>Most lookups never make it past the control-byte scan. When they do, the remaining work is focused and predictable. This is exactly the kind of workload that modern CPUs are good at.</p><h2>SIMD under the hood</h2><p>For readers who like to peek under the hood, here’s what happens when inserting a new key into the table. We use the Panama Vector API with 128-bit vectors, thus operating on 16 control bytes in parallel.</p><p>The following snippet shows the code generated on an Intel Rocket Lake with AVX-512. While the instructions reflect that environment, the design does not depend on AVX-512. The same high-level vector operations are emitted on other platforms using equivalent instructions (for example, AVX2, SSE, or NEON).</p>; Load 16 control bytes from the control block
vmovdqu xmm0, XMMWORD PTR [r9+r10*1+0x10]

; Broadcast the 7-bit fingerprint of the new key across the vector
vpbroadcastb xmm1, r11d

; Compare all 16 control bytes to the new fingerprint
vpcmpeqb k7, xmm0, xmm1
kmovq rbx, k7

; Check if any matches were found
test rbx, rbx
jne &lt;handle_match&gt;<p>Each instruction has a clear role in the insertion process:</p><ul><li><p><code>vmovdqu</code>: Loads 16 consecutive control bytes into the 128-bit <code>xmm0</code> register.</p></li><li><p><code>vpbroadcastb</code>: Replicates the 7-bit fingerprint of the new key across all lanes of the <code>xmm1</code> register.</p></li><li><p><code>vpcmpeqb</code>: Compares each control byte against the broadcasted fingerprint, producing a mask of potential matches.</p></li><li><p><code>kmovq</code> + <code>test</code>: Moves the mask to a general-purposes register and quickly checks whether a match exists.</p></li></ul><p>Finally, we settled on probing groups of 16 control bytes at a time, as benchmarking showed that expanding to 32 or 64 bytes with wider registers provided no measurable performance benefit.</p><h2>Integration in ES|QL</h2><p>Adopting Swiss-style hashing in Elasticsearch was not just a drop-in replacement. ES|QL has strong requirements around memory accounting, safety, and integration with the rest of the compute engine.</p><p>We integrated the new hash table tightly with Elasticsearch’s memory management, including the page recycler and circuit breaker accounting, ensuring that allocations remain visible and bounded. Elasticsearch's aggregations are stored densely and indexed by a group ID, keeping the memory layout compact and fast for iteration, as well as enabling certain performance optimizations by allowing random access.</p><p>For variable-length byte keys, we cache the full hash alongside the group ID. This avoids recomputing expensive hash codes during probing and improves cache locality by keeping related metadata close together. During rehashing, we can rely on the cached hash and control bytes without inspecting the values themselves, keeping resizing costs low.</p><p>One important simplification in our implementation is that entries are never deleted. This removes the need for <em>tombstones</em> (markers to identify previously occupied slots) and allows empty slots to remain truly empty, which further improves probe behavior and keeps control-byte scans efficient.</p><p>The result is a design that fits naturally into Elasticsearch’s execution model while preserving the performance characteristics that make Swiss tables attractive.</p><h2>How does it perform?</h2><p>At small cardinalities, Swiss tables perform roughly on par with the existing implementation. This is expected: When tables are small, cache effects dominate less and there is little probing to optimize.</p><p>As cardinality increases, the picture changes quickly.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09b13af2fe162f59/6a170cc66f7f04485c9148b8/24900afc47ab07b0e9933f6117b99d0f4613f794-962x599.png" alt="ES|QL stats with Swiss-style hash tables" /><p>The heatmap above plots time improvement factors for different key sizes (8, 32, 64, and 128 bytes) across cardinalities from 1,000 up to 10,000,000 groups. As cardinality grows, the improvement factor steadily increases, reaching up to 2–3x for uniform distributions.</p><p>This trend is exactly what the design predicts. Higher cardinality leads to longer probe chains in traditional hash tables, while Swiss-style probing continues to resolve most lookups inside SIMD-friendly control-byte blocks.</p><h2>Cache behavior tells the story</h2><p>To better understand the speedups, we ran the same JMH <a href="https://github.com/elastic/elasticsearch/pull/139343/files#diff-d0e0cc91a7495bf36b2d44eacce95f5185d01879e5f6c38089ac7a89aad17da7"><code>benchmarks</code></a> under Linux <code>perf</code> and captured cache and TLB statistics.</p><p>Compared to the original implementation, the Swiss version performs about 60% fewer cache references overall. Last-level cache loads drop by more than 4x, and LLC load misses fall by over 6x. Since LLC misses often translate directly into main-memory accesses, this reduction alone explains a large portion of the end-to-end improvement.</p><p>Closer to the CPU, we see fewer L1 data cache misses and nearly 6x fewer data TLB misses, pointing to tighter spatial locality and more predictable memory access patterns.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb987a5bd98c0d7eb/6a170cc8a929cf9655ae0a25/6e49b7609fba83e33692cb9834552b6ca7e42a83-998x499.png" alt="Cache behavior: Original vs. ES|QL stats with Swiss-style hash tables" /><p>This is the practical payoff of SIMD-friendly control bytes. Instead of repeatedly loading keys and values from scattered memory locations, most probes are resolved by scanning a compact, cache-resident structure. Less memory touched means fewer misses, and fewer misses mean faster queries.</p><h2>Wrapping up</h2><p>By adopting a Swiss-style hash table design and leaning hard into SIMD-friendly probing, we achieved 2–3x speedups for high-cardinality ES|QL stats workloads, along with more stable and predictable performance.</p><p>This work highlights how modern CPU-aware data structures can unlock substantial gains, even for well-trodded problems, like hash tables. There is more room to explore here, like additional primitive type specializations and use in other high-cardinality paths, like joins, all of which are just part of the broader and ongoing effort to continually modernize Elasticsearch internals.</p><p>If you’re interested in the details or want to follow the work, check out this <a href="https://github.com/elastic/elasticsearch/pull/139343">pull request</a> and <a href="https://github.com/elastic/elasticsearch/issues/138799">meta issue</a> tracking progress on Github.</p><p>Happy hashing!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-swiss-hash-stats</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-swiss-hash-stats</guid>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Chris Hegarty,Matthew Alp,Nik Everett]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf76dd688c5b737e6/6a170cc9839dfa7fc7dcff40/21036e031070f14faccb2b53b22723de2750c391-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 19 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Managing agentic memory with Elasticsearch]]></title>
    <description><![CDATA[Creating more context-aware and efficient agents by managing memories using Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>In the emerging discipline of <strong>context engineering</strong>, giving AI agents the right information at the right time is crucial. One of the most important aspects of context engineering is managing an AI’s <strong>memory</strong>. Much like humans, AI systems rely on both a short-term memory and a long-term memory to recall information. If we want large language model (LLM) agents to carry on logical conversations, remember user preferences, or build on previous results or responses, we need to equip them with effective memory mechanisms.</p><p>After all, everything in the context influences the AI’s responses. G<em>arbage in, garbage out</em> holds true.</p><p>In this article, we’ll introduce what short-term and long-term memory mean for AI agents, specifically:</p><ul><li><p>The difference between short- and long-term memory.</p></li><li><p>How they relate to retrieval-augmented generation (RAG) techniques with vector databases, like Elasticsearch, and why careful memory management is necessary.</p></li><li><p>The risks of neglecting memory, including context overflow and context poisoning.</p></li><li><p>Best practices, like context pruning, summarizing, and retrieving only what’s relevant, to keep an agent’s memory both useful and safe.</p></li><li><p>Finally, we’ll touch on how memory can be shared and propagated in multi-agent systems to enable agents to collaborate without confusion using Elasticsearch.</p></li></ul><h2>Short-term versus long-term memory in AI agents</h2><p><em><strong>Short-term memory</strong></em> in an AI agent typically refers to the immediate conversational context or state—essentially, the current chat history or recent messages in the active session. This includes the user’s latest query and recent back-and-forth exchanges. It’s very similar to the information a person holds in mind during an ongoing conversation.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteb714ce810d1c472/6a170f321949f782cbe7aaf6/4fbcc6f68055b2bccefc4176297a4ca50056dc0d-764x498.png" alt="Short-term &amp; long-term agentic memory" /><p>AI frameworks often maintain this transient memory as part of the agent’s state (for example, using a checkpointer to store the conversation state as covered by <a href="https://docs.langchain.com/oss/python/langgraph/persistence#checkpoints">this example from LangGraph</a>). Short-term memory is <em><strong>session-scoped</strong></em>; that is, it exists within a single conversation or task and is reset or cleared when that session ends, unless explicitly saved elsewhere. An example of session-bound short-term memory would be the <a href="https://help.openai.com/en/articles/8914046-temporary-chat-faq"><strong>temporary chat</strong></a>available in ChatGPT.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4b8680e22d4e1185/6a170f341949f78bbae7aafa/150bdf209cda5ed20b59cddf34e624ad1a8016aa-1100x577.png" alt="AI frameworks memory" /><p><em><strong>Long-term memory</strong></em>, on the other hand, refers to information that persists <strong>across conversations or sessions</strong>. This is the knowledge an agent retains over time, facts it learned earlier, user preferences, or any data we’ve told it to remember permanently.</p><p>Long-term memory is usually implemented by storing and fetching it from an external source, such as a file or vector database that’s outside the immediate context window. Unlike short-term chat history, long-term memory isn’t automatically included in every prompt. Instead, based on a given scenario, the agent must <strong>recall</strong> or retrieve it when relevant tools are invoked. In practice, long-term memory might include a user’s profile info, prior answers or analyses the agent produced, or a knowledge base the agent can query.</p><p>For instance, if you have a travel-planner agent, the <em>short-term memory</em> would contain details of the current trip inquiry (dates, destination, budget) and any follow-up questions in that chat; whereas the <em>long-term memory</em> could store the user’s general travel preferences, past itineraries, and other facts shared in previous sessions. When the user returns later, the agent can pull from this long-term store (for example, the user loves beaches and mountains, has an average budget of INR 100,000, has a bucket list to visit, and prefers to experience history and culture rather than kid-friendly attractions) so that it doesn’t treat the user as a blank slate each time.</p><p>The short-term memory (chat history) provides immediate context and continuity, while long-term memory provides a broader context that the agent can draw upon when needed. Most advanced AI agent frameworks enable both: They keep track of recent dialogue to maintain context <em>and</em> offer mechanisms to look up or store information in a longer-term repository. Managing short-term memory ensures it stays within the context window, while managing long-term memory helps the agent to ground the answers based on prior interactions and personas.</p><h2>Memory and RAG in context engineering</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt98c1514741bea460/6a170f36509168083ce1bbae/46635aa11ceff89b8d6a26ac3e22da52407d82f3-1600x900.png" alt="Memory and RAG in context engineering" /><p><em><strong>How do we give an AI agent a useful long-term memory in practice?</strong></em></p><p>One prominent approach for long-term memory is <em><strong>semantic memory</strong></em>, often implemented via <strong>retrieval-augmented generation (RAG)</strong>. This involves coupling the LLM with an external knowledge store or vector-enabled datastore, like Elasticsearch. When the LLM needs information beyond what’s in the prompt or its built-in training, it performs semantic retrieval against Elasticsearch and injects the most relevant results into the prompt as context. This way, the model’s effective context includes not only the recent conversation (short-term memory) but also pertinent long-term facts fetched on the fly. The LLM then grounds its answer on both its own reasoning and the retrieved information, effectively combining short-term memory and long-term memory to produce a more accurate, context-aware response.</p><p><strong>Elasticsearch </strong>can be used to implement long-term memory for AI agents. Here’s a high-level example of how context can be retrieved from Elasticsearch for long-term memory.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt44f5a6887b0bca32/6a170f37a6c2b9c735e797be/41ccbc7b5171e8170ac300139a963c0708816ba6-1600x900.png" alt="RAG in action" /><p>This way, the agent “remembers” by searching for relevant data rather than by storing everything in its limited prompt, <strong>where it leads to different risks.</strong></p><p><strong>Using RAG with Elasticsearch or any vector stores offers multiple benefits:</strong></p><p>First, it <strong>extends the knowledge</strong> of the model beyond its training cutoff. The agent can retrieve up-to-date information or domain-specific data that the LLM might not know. This is crucial for questions about recent events or specialized topics.</p><p>Second, retrieving context on demand helps reduce hallucinations, especially since LLMs aren’t trained on the proprietary or highly specialized data relative to your niche use case, which is highly likely to expose it to hallucinations. Instead of the LLM guessing or inventing new information as it has been incentivised through evaluation, as highlighted in a recent OpenAI paper (<a href="https://arxiv.org/pdf/2509.04664">Why Language Models Hallucinate</a>), the model can be grounded by factual references from Elasticsearch. Naturally, the LLM depends on the reliability of the data in the vector store to truly prevent misinformation and the relevant data is retrieved as per the core relevance measures.</p><p>Third, RAG allows an agent to work with knowledge bases far larger than anything you could ever fit into a prompt. Instead of pushing entire documents, like long research papers or policy documents, into the context window and risking overload or irrelevant information <a href="https://www.elastic.co/search-labs/blog/agentic-memory-management-elasticsearch#context-poisoning">context poisoning</a> the model’s reasoning, RAG relies on <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">chunking</a>. Large documents are broken into smaller, semantically meaningful pieces, and the system retrieves only the few chunks most relevant to the query. This way, the model doesn’t need a million-token context to appear knowledgeable; it just needs access to the right chunks of a much larger corpus.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c90f81a56db0a33/6a170f3960084be7ba3c462e/e6897356c9f0940e35a63d005e9cd20bc33e5dd7-1600x931.png" alt="Evolution of LLM context engineering" /><p>It’s worth noting that as LLM context windows have grown (<a href="https://www.anthropic.com/news/1m-context">some models now support hundreds of thousands or even millions of tokens</a><em>)</em>, a debate arose about whether RAG is “dead.” Why not push all the data into the prompt? If you feel likewise, refer to this wonderful article by my colleagues, Jeffrey Rengifo and Eduard Martin, <a href="https://www.elastic.co/search-labs/blog/rag-vs-long-context-model-llm">Longer context ≠ better: Why RAG still matters</a>. This avoids the “garbage in, garbage out” problem: The LLM stays focused on the few chunks that matter, rather than running through noise.</p><p>That said, integrating Elasticsearch or any vector store into an AI agent architecture provides <strong>long-term memory</strong>. The agent stores knowledge externally and pulls it in as memory context when needed. This could be implemented as an <em>architecture</em>, where after each user query, the agent performs a search on Elasticsearch for relevant info and then appends the top results to the prompt before calling the LLM. The response might also be saved back into the long-term store if it contains useful new information (creating a feedback loop of learning). By using such retrieval-based memory, the agent remains informed and up to date, without having to cram everything it knows into every prompt, even though the context window supports <em>one million tokens</em>. This technique is a cornerstone of context engineering, combining the strengths of information retrieval and generative AI. </p><p>Here’s an example of a managed in-memory conversation state using LangGraph's checkpoint system for short-term memory during the session. (Refer to our <a href="https://github.com/someshwaranM/elastic-context-engineering-short-term-long-term-memory">supporting context engineering app</a>.)</p># Initialize chat memory (Note: This is in-memory only, not persistent)
memory = MemorySaver()

# Create a LangGraph agent
langgraph_agent = create_react_agent(model=llm, tools=tools, checkpointer=memory)

...
...
# Only process and display checkpoints if verbose mode is enabled
if args.verbose:
    # List all checkpoints that match a given configuration
    checkpoints = memory.list({"configurable": {"thread_id": "1"}})
    # Process the checkpoints
    process_checkpoints(checkpoints)<p>Here’s how it stores <strong>checkpoints</strong>:</p>Checkpoint:
Timestamp: 2025-12-30T09:19:41.691087+00:00
Checkpoint ID: 1f0e560a-c2fa-69ec-8001-14ee5373f9cf
User: Hi I'm Som, how are you? (Message ID: ad0a8415-5392-4a58-85ad-84154875bbf2)
Agent: Hi Som! I'm doing well, thank you! How about you? (Message ID: 
56d31efb-14e3-4148-806e-24a839799ece)
Agent:  (Message ID: lc_run--019b6e8e-553f-7b52-8796-a8b1fbb206a4-0)

Checkpoint:
Timestamp: 2025-12-30T09:19:40.350507+00:00
Checkpoint ID: 1f0e560a-b631-6a08-8000-7796d108109a
User: Hi I'm Som, how are you? (Message ID: ad0a8415-5392-4a58-85ad-84154875bbf2)
Agent: Hi Som! I'm doing well, thank you! How about you? (Message ID: 
56d31efb-14e3-4148-806e-24a839799ece)

Checkpoint:
Timestamp: 2025-12-30T09:19:40.349027+00:00
Checkpoint ID: 1f0e560a-b62e-6010-bfff-cbebe1d865f6<p>For long-term memory, here's how we perform semantic search on Elasticsearch to retrieve relevant previous conversations using vector embeddings after summarizing and indexing the checkpoints to Elasticsearch.</p>Functions: 
retrieve_from_elasticsearch() 

# Enhanced Elasticsearch retrieval with rank_window and verbose display
def retrieve_from_elasticsearch(query: str, k: int = 5, rank_window: int = None) -&gt; tuple[List[Dict[str, Any]], str]:
    """
    Retrieve context from Elasticsearch with score-based ranking
    
    Args:
        query: Search query
        k: Number of results to return
        rank_window: Number of candidates to retrieve before ranking (default: args.rank_window)
        
    Returns:
        Tuple of (retrieved_documents, formatted_context_string)
    """
    if not es_client or not es_index_name:
        return [], "Elasticsearch is not available. Cannot search long-term memory."
    
    if rank_window is None:
        rank_window = args.rank_window
    
    try:
        # Check if index exists and has documents
        if not es_client.indices.exists(index=es_index_name):
            return [], "No previous conversations stored in long-term memory yet."
        
        # Get document count
        try:
            doc_count = es_client.count(index=es_index_name)["count"]
            if doc_count == 0:
                return [], "Long-term memory is empty. No previous conversations to search."
        except Exception as e:
            return [], f"Error checking memory: {str(e)}"
        
        # Generate embedding for the query
        try:
            query_embedding = embeddings.embed_query(query)
        except Exception as e:
            return [], f"Error generating embedding: {str(e)}"
        
        # Perform semantic search using kNN with rank_window
        try:
            search_body = {
                "knn": {
                    "field": "vector",
                    "query_vector": query_embedding,
                    "k": k,
                    "num_candidates": rank_window  # Retrieve more candidates, then rank top k
                },
                "_source": ["text", "content", "message_type", "timestamp", "thread_id"],
                "size": k
            }
            
            response = es_client.search(index=es_index_name, body=search_body)
            
            if not response.get("hits") or len(response["hits"]["hits"]) == 0:
                return [], "No relevant previous conversations found in long-term memory."
            
            # Extract documents with scores
            retrieved_docs = []
            for hit in response["hits"]["hits"]:
                source = hit["_source"]
                score = hit["_score"]
                retrieved_docs.append({
                    "content": source.get("content", source.get("text", "")),
                    "message_type": source.get("message_type", "unknown"),
                    "timestamp": source.get("timestamp", "unknown"),
                    "thread_id": source.get("thread_id", "unknown"),
                    "score": score
                })
            
            # Format context string
            context_parts = []
            for i, doc in enumerate(retrieved_docs, 1):
                context_parts.append(doc["content"])
            
            context_string = "\n\n".join(context_parts)
            
            # Verbose display
            if args.verbose:
                rich.print(f"\n[bold yellow]🔍 RETRIEVAL ANALYSIS[/bold yellow]")
                rich.print("="*80)
                rich.print(f"[blue]Query:[/blue] {query}")
                rich.print(f"[blue]Retrieved:[/blue] {len(retrieved_docs)} documents (from {rank_window} candidates)")
                rich.print(f"[blue]Total context length:[/blue] {len(context_string)} characters\n")
                
                for i, doc in enumerate(retrieved_docs, 1):
                    rich.print(f"[cyan]📄 Document {i} | Score: {doc['score']:.4f} | Type: {doc['message_type']}[/cyan]")
                    rich.print(f"[cyan]   Timestamp: {doc['timestamp']} | Thread: {doc['thread_id']}[/cyan]")
                    content_preview = doc['content'][:200] + "..." if len(doc['content']) &gt; 200 else doc['content']
                    rich.print(f"[cyan]   Content: {content_preview}[/cyan]")
                    rich.print("-" * 80)
            
            return retrieved_docs, context_string
            
        except Exception as e:
            return [], f"Error searching memory: {str(e)}"
            
    except Exception as e:
        return [], f"Error accessing long-term memory: {str(e)}"<p>Now that we’ve explored how short-term memory and long-term memory are indexed and fetched using LangGraph’s checkpoints in Elasticsearch, let’s take some time to understand why indexing and dumping the complete conversations can be risky.</p><h2>Risks of not managing context memory</h2><p>As we’re talking much about context engineering, along with short-term and long-term memory, let’s understand what happens if we don’t manage an agent’s memory and context well.</p><p>Unfortunately, many things can go wrong when an AI’s context grows extremely long or contains bad information. As context windows get larger, <strong>new failure modes</strong> emerge, like:</p><ul><li><p><strong>Context poisoning</strong></p></li><li><p><strong>Context distraction</strong></p></li><li><p><strong>Context confusion</strong></p></li><li><p><strong>Context clash</strong></p></li><li><p><strong>Context leakage and knowledge conflicts</strong></p></li><li><p><strong>Hallucinations and misinformation</strong></p></li></ul><p>Let’s break down these issues and other risks that arise from poor context management:</p><h3>Context poisoning</h3><p><em>Context poisoning</em> refers to when incorrect or harmful information ends up in the context and “poisons” the model’s subsequent outputs. A common example is a hallucination by the model that gets treated as fact and inserted into the conversation history. The model might then build on that error in later responses, compounding the mistake. In iterative agent loops, once a false information makes it into the shared context (for example, in a summary of the agent’s working notes), it can be reinforced over and over. </p><p><a href="https://storage.googleapis.com/deepmind-media/gemini/gemini_v2_5_report.pdf">Researchers at DeepMind, in the release of the Gemini 2.5 report</a> (TL;DR, check <a href="https://www.dbreunig.com/2025/06/17/an-agentic-case-study-playing-pok%C3%A9mon-with-gemini.html">here</a>), observed this in a long-running <em>Pokémon</em>-playing agent: If the agent hallucinated a wrong game state and that got recorded into its <em>context </em>(its memory of goals), the agent would form <strong>nonsensical strategies</strong> around an impossible goal and get stuck. In other words, a poisoned memory can send the agent down the wrong path indefinitely.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd56e9e0681f32239/6a170f3b4a531bd79536aa21/3f2facf5aad67613ad557422e09ec23a66adc0ed-1600x1388.png" alt="Context poisoning" /><p>Context poisoning can happen innocently (by mistake) or even maliciously, for instance, via prompt injection attacks where a user or third-party sneaks in a hidden instruction or false fact that the agent then remembers and follows.</p><p><strong>Recommended countermeasures:</strong></p><p>Based on insights from <a href="https://www.wiz.io/academy/data-poisoning">Wiz</a>, <a href="https://zerlo.net/en/blog/what-is-llm-data-poisoning">Zerlo</a>, and <a href="https://www.anthropic.com/research/small-samples-poison">Anthropic</a>, countermeasures for context poisoning focus on preventing bad or misleading information from entering an LLM’s prompt, context window, or retrieval pipeline. Key steps include:</p><ul><li><p>Check the context constantly: Monitor the conversation or retrieved text for anything suspicious or harmful, not just the starting prompt.</p></li><li><p>Use trusted sources: Score or label documents based on credibility so the system prefers reliable information and ignores low scored data.</p></li><li><p>Spot unusual data: Use tools that detect odd, out-of-place, or manipulated content, and remove it before the model uses it.</p></li><li><p>Filter inputs and outputs: Add guardrails so harmful or misleading text can’t easily enter the system or be repeated by the model.</p></li><li><p>Keep the model updated with clean data: Regularly refresh the system with verified information to counter any bad data that slipped through.</p></li><li><p>Human-in-the-loop: Have people review important outputs or compare them against known, trustworthy sources.</p></li></ul><p>Simple user habits also help, resetting long chats, sharing only relevant information, breaking complex tasks into smaller steps, and maintaining clean notes outside the model.</p><p>Together, these measures create a layered defense that protects LLMs from context poisoning and keeps outputs accurate and trustworthy.</p><p>Without countermeasures as mentioned here, an agent might remember instructions, like ignore previous guidelinesor trivial facts that an attacker inserted, leading to harmful outputs.</p><h3>Context distraction</h3><p><em>Context distraction</em> is when a context grows so long that the model overfocuses on the context, neglecting what it learned during training. In extreme cases, this resembles <a href="https://en.wikipedia.org/wiki/Catastrophic_interference"><em>catastrophic forgetting</em></a>; that is, the model effectively “forgets” its underlying knowledge and becomes overly attached to the information placed in front of it. Previous studies have shown that LLMs often lose focus when the prompt is extremely long.</p><p>The Gemini 2.5 agent, for example, supported a million-token window, but once its context grew beyond a certain point (on the order of 100,000 tokens in an experiment), it began to <strong>fixate on repeating its past actions</strong> instead of coming up with new solutions. In a sense, the agent became a prisoner of its extensive history. It kept looking at its long log of previous moves (the context) and mimicking them, rather than using its underlying training knowledge to devise fresh and novel strategies.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt91ea0056bbda6e2d/6a170f3d2b835fdd2bf4b2db/e08e5b6d2e8ec7e3511d455985eed3d7fa6241e0-1352x636.png" alt="Context distraction " /><p>This is counterproductive. We want the model to use relevant context to help reasoning, not override its ability to think. Notably, even models with huge windows exhibit this <a href="https://research.trychroma.com/context-rot"><em>context rot</em></a>: Their performance degrades nonuniformly as more tokens are added. There appears to be an <em>attention budget</em>., Like humans with limited working memory, an LLM has a finite capacity to attend to tokens, and as that budget is stretched, its precision and focus drop.</p><p>As a mitigation, you can prevent context distraction using chunking, engineering the right information, regular context summarization, and evaluation and monitoring techniques to measure the accuracy of the response using scoring.</p><p>These methods keep the model grounded in both relevant context and its underlying training, reducing the risk of distraction and improving overall reasoning quality.</p><h3>Context confusion</h3><p><em>Context confusion</em> is when superfluous content in the context is used by the model to generate a low-quality response.A prime example is giving an agent a large set of tools or API definitions that it might use. If many of those tools are unrelated to the current task, the model may still try to use them inappropriately, simply because they’re present in context. Experiments have found that providing <em>more</em> tools or documents can <em>hurt</em> performance if they’re not all needed. The agent starts making mistakes, like calling the wrong function or referencing irrelevant text. </p><p>In one case, a small <strong>Llama 3.1 8B</strong> model failed a task when given 46 tools to consider but succeeded when given only 19 tools. The extra tools created confusion, even though the context was within length limits. The underlying issue is that any information in the prompt will be <em>attended to</em> by the model. If it doesn’t know to ignore something, that something could influence its output in undesired ways. Irrelevant bits can “steal” some of the model’s attention and lead it astray (for instance, an irrelevant document might cause the agent to answer a different question than asked). Context confusion often manifests as the model producing a low-quality response that integrates unrelated context. Refer to the research paper: <a href="https://arxiv.org/pdf/2411.15399">Less is More: Optimizing Function Calling for LLM Execution on Edge Devices.</a></p><p>It reminds us that more context isn’t always better, especially if it’s not <strong>curated</strong> for relevance.</p><h3>Context clash</h3><p><em>Context clash</em> occurs when <strong>parts of the context contradict each other</strong>, causing internal inconsistencies that derail the model’s reasoning. A clash can happen if the agent accumulates multiple pieces of information that are in conflict. </p><p>For example, imagine an agent that fetched data from two sources: One says <em>Flight A departs at 5 PM</em>, and the other says <em>Flight A departs at 6 PM</em>. If both facts end up in the context, the poor model has no way to know which is correct; it may get confused or produce an incorrect or non-similar answer.</p><p>Context clash also frequently occurs in multiturn conversations where the model’s <strong>earlier attempts</strong> at answering are still lingering in the context along with later refined information.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd86976266867c0ed/6a170f3e66c4f9c785f8c105/500d7a80dc8db1923f9b5ca84728eed64fa296f7-1316x580.png" alt="Context clash" /><p>A <a href="https://arxiv.org/pdf/2505.06120">research study</a> by Microsoft and Salesforce shows that if you break a complex query into multiple chatbot turns (adding details gradually), the final accuracy drops significantly, compared to giving all details in a single prompt. Why? Because the early turns contain partial or incorrect intermediate answers from the model, and those remain in the context. When the model later tries to answer with all info, its <em>memory</em> still includes those wrong attempts, which conflict with the corrected info and lead it off track. Essentially, the conversation’s context clashes with itself. The model may inadvertently use an outdated piece of context (from an earlier turn) that doesn’t apply after new info is added.</p><p>In agent systems, context clash is especially dangerous because an agent might combine outputs from different tools or subagents. If those outputs disagree, the aggregated context is inconsistent. The agent could then get stuck or produce nonsensical results trying to reconcile the contradictions. Preventing context clash involves ensuring the context is <strong>fresh and consistent</strong>,for instance, clearing or updating any outdated info and not mixing sources that haven’t been vetted for consistency.</p><h3>Context leakage and knowledge conflicts</h3><p>In systems where multiple agents or users share a memory store, there’s a risk of information bleeding over between contexts.</p><p>For example, if two separate users’ data embeddings reside in the same vector database without proper access control, an agent answering User A’s query might accidentally retrieve some of User B’s memory. This <em><strong>cross-context leak</strong></em> can expose private information or just create confusion in responses.</p><p>According to the <a href="https://wtit.com/blog/2025/04/17/owasp-top-10-for-llm-applications-2025/">OWASP Top 10 for LLM Applications</a>, multitenant vector databases must guard against such leakage:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte433216805a66d29/6a170f404a531b2c4e36aa25/8f0ccf0b2f7bd6715c14aceee2deffb213d50bd9-1600x936.png" alt="Context leakage" /><p>According to <a href="https://wtit.com/blog/2025/04/17/owasp-top-10-for-llm-applications-2025/">LLM08:2025 Vector and Embedding Weaknesses</a><em>,</em> one of the common risks is context leakage:</p><em>In multi-tenant environments where multiple classes of users or applications share the same vector database, there's a risk of context leakage between users or queries. Data federation knowledge conflict errors can occur when data from multiple sources contradict each other. This can also happen when an LLM can’t supersede old knowledge that it has learned while training, with the new data from Retrieval Augmentation.</em><p>Another aspect is that an LLM might have trouble overriding its <strong>built-in knowledge</strong> with new info from memory. If the model was trained on some fact and the retrieved context says the opposite, the model can get confused about which to trust. Without proper design, the agent could mix up contexts or fail to update old knowledge with new evidence, leading to stale or incorrect answers.</p><h3><strong>Hallucinations and misinformation</strong></h3><p>While <em>hallucination </em>(the LLM making up plausible-sounding but false information) is a known problem even without long contexts, poor memory management can amplify it. </p><p>If the agent’s memory is lacking a crucial fact, the model may just <strong>fill in the gap with a guess</strong>, and if that guess then enters the context (poisoning it), the error persists. </p><p>The OWASP LLM security report <a href="https://wtit.com/blog/2025/04/17/owasp-top-10-for-llm-applications-2025/"><strong>(LLM09:2025 Misinformation)</strong></a> highlights misinformation as a core vulnerability: LLMs can produce confident but fabricated answers, and users may overtrust them. An agent with a bad or outdated long-term memory might confidently cite something that was true last year but is false now, unless its memory is kept up to date. </p><p>Overreliance on the AI’s output (by either the user or the agent itself in a loop) can make this worse. If no one ever checks the info in memory, the agent can accumulate falsehoods. This is why RAG is often used to reduce hallucinations: By retrieving an authoritative source, the model doesn’t have to invent facts. But if your retrieval pulls in the wrong document (say, one that contains misinformation) or if an early hallucination isn’t pruned, the system may propagate that misinformation throughout its actions. </p><p>The bottom line: Failing to manage memory can lead to <strong>incorrect and misleading outputs</strong>, which can be damaging, especially if the stakes are high (for example, bad advice in a finance or medical domain). An agent needs mechanisms to verify or correct its memory content, not just unconditionally trust whatever is in the context.</p><p>In summary, giving an AI agent an infinitely long memory or dumping every possible thing into its context is <em>not</em> a recipe for success.</p><h2>Best practices for memory management in LLM applications</h2><p>To avoid the pitfalls above, developers and researchers devised a number of <strong>best practices for managing context and memory</strong> in AI systems. These practices aim to keep the AI’s working context lean, relevant, and up to date.Here are some of the key strategies, along with examples of how they help.</p><h3>RAG: Use targeted context</h3><p>Much of RAG has already been covered in the earlier section, so this serves as a concise set of practical reminders:</p><ul><li><p>Use targeted retrieval, not bulk loading: Retrieve only the most relevant chunks instead of pushing entire documents or full conversation histories into the prompt.</p></li><li><p>Treat RAG as just-in-time memory recall: Fetch context only when it’s needed, rather than carrying everything forward across turns.</p></li><li><p>Prefer relevance-aware retrieval strategies: Approaches like top-k semantic search, Reciprocal Rank Fusion, or tool loadout filtering help reduce noise and improve grounding.</p></li><li><p>Larger context windows don’t remove the need for RAG: Two highly relevant paragraphs are almost always more effective than 20 loosely related pages.</p></li></ul><p>That said, RAG isn’t about adding more context; it’s about adding the right context.</p><h3>Tool loadout</h3><p><em>Tool loadout</em> is about giving a model only the tools it actually needs for a task. The term comes from gaming: You pick a loadout that fits the situation. Too many tools slow you down; the wrong ones cause failure. LLMs behave the same way, according to the research paper <a href="https://arxiv.org/abs/2411.15399">Less is more</a>. Once you pass ~30 tools, descriptions start overlapping and the model gets confused. Past ~100 tools, failure is almost guaranteed. This isn’t a context window problem, it’s context confusion.</p><p>A simple and effective fix is <a href="https://arxiv.org/abs/2505.03275"><strong>RAG-MCP</strong></a>. Instead of dumping every tool into the prompt, tool descriptions are stored in a vector database and only the most relevant ones are retrieved per request. In practice, this keeps the loadout small and focused, dramatically shortens prompts, and can improve tool selection accuracy by up to 3x.</p><p>Smaller models hit this wall even sooner. The research shows an 8B model failing with dozens of tools but succeeding once the loadout is trimmed. Dynamically selecting tools, sometimes with an LLM first, reasoning about what it thinks it needs, can boost performance by 44%, while also reducing power usage and latency. The takeaway is that most agents only need a few tools, but as your system grows, tool loadout and RAG-MCP become first-order design decisions.</p><h3>Context pruning: Limit the chat history length</h3><p>If a conversation goes on for many turns, the accumulated chat history can become too large to fit, leading to context overflow or becoming too distracting to the model. </p><p><em>Trimming</em> means programmatically removing or shortening less important parts of the dialogue as it grows. One simple form is to drop the oldest turns of the conversation when you hit a certain limit, keeping only the latest <em>N</em> messages. More sophisticated pruning might remove irrelevant digressions or previous instructions that are no longer needed. The goal is to <strong>keep the context window uncluttered</strong> by old news. </p><p>For example, if the agent solved a subproblem 10 turns ago and we have since moved on, we might delete that portion of the history from the context (assuming it won’t be needed further). Many chat-based implementations do this: They maintain a rolling window of recent messages. </p><p>Trimming can be as simple as “forgetting” the earliest parts of a conversation once they’ve been summarized or are deemed irrelevant. By doing so, we reduce the risk of context overflow errors and also reduce <a href="https://www.elastic.co/search-labs/blog/agentic-memory-management-elasticsearch#context-distraction"><strong>context distraction</strong></a>, so the model won’t see and get sidetracked by old or off-topic content. This approach is very similar to how humans might not remember every word from an hour-long talk but will retain the highlights. </p><p>If you’re confused about context pruning, as highlighted by the author Drew Breunig <a href="https://www.dbreunig.com/2025/06/26/how-to-fix-your-context.html#tool-loadout:~:text=Provence%20is%20fast%2C%20accurate%2C%20simple%20to%20use%2C%20and%20relatively%20small%20%E2%80%93%20only%201.75%20GB.%20You%20can%20call%20it%20in%20a%20few%20lines%2C%20like%20so%3A">here</a>, usage of the Provence (`<a href="https://huggingface.co/naver/provence-reranker-debertav3-v1">naver/provence-reranker-debertav3-v1</a>`) model, a lightweight (1.75 GB), efficient, and accurate context pruner for question answering, can make a difference. It can trim large documents down to only the most relevant text for a given query. You can call it in specific intervals.</p><p>Here’s how we invoke the `provence-reranker` model in our code to prune the context:</p># Context pruning with Provence
def prune_with_provence(query: str, context: str, threshold: Optional[float] = None) -&gt; str:
    """
    Prune context using Provence reranker model
    
    Args:
        query: User's query/question
        context: Original context to prune
        threshold: Relevance threshold (0-1) for Provence reranker.
                   If None, uses args.pruning_threshold.
                   0.1 = conservative (recommended, no performance drop)
                   0.3-0.5 = moderate to aggressive pruning
    
    Returns:
        Pruned context with only relevant sentences
    """
    if provence_model is None:
        return context
    
    if threshold is None:
        threshold = args.pruning_threshold
    
    try:
        # Use Provence's process method
        provence_output = provence_model.process(
            question=query,
            context=context,
            threshold=threshold,
            always_select_title=False,
            enable_warnings=False
        )
        
        # Extract pruned context from output
        pruned_context = provence_output.get('pruned_context', context)
        reranking_score = provence_output.get('reranking_score', 0.0)
        
        # Log statistics
        original_length = len(context)
        pruned_length = len(pruned_context)
        reduction_pct = ((original_length - pruned_length) / original_length * 100) if original_length &gt; 0 else 0
        
        if args.verbose:
            rich.print(f"[cyan]📊 Pruning stats: {pruned_length}/{original_length} chars ({reduction_pct:.1f}% reduction, threshold={threshold:.2f}, rerank_score={reranking_score:.3f})[/cyan]")
        
        return pruned_context if pruned_context else context
        
    except Exception as e:
        rich.print(f"[yellow]⚠️ Error in Provence pruning: {str(e)}[/yellow]")
        rich.print(f"[yellow]⚠️ Falling back to original context[/yellow]")
        return context<p>We use the Provence reranker model (`naver/provence-reranker-debertav3-v1`) to score sentence relevance. Threshold-based filtering keeps sentences above the relevance threshold. Also, we introduce a fallback mechanism, where we return to the original context if pruning fails. Finally, statistics logging tracks reduction percentage in verbose mode.</p><h3>Context summarization: Condense older information instead of dropping it entirely</h3><p><em>Summarization</em> is a companion to trimming. When the history or knowledge base becomes too large, you can employ the LLM to generate a brief summary of the important points and use that summary in place of the full content going forward, as we performed in our code above.</p><p>For example, if an AI assistant has had a 50-turn conversation, instead of sending all 50 turns to the model on turn 51 (which likely won’t fit), the system might take turns 1–40, have the model summarize them in a paragraph, and then only supply that summary plus the last 10 turns in the next prompt. This way, the model still knows what was discussed without needing every detail. Early chatbot users did this manually by asking, “Can you summarize what we’ve talked about so far?” and then continuing in a new session with the summary. Now it can be automated. Summarization not only saves context window space but can also reduce <strong>context confusion/distraction</strong> by stripping away extra detail and retaining just the salient facts.</p><p>Here’s how we use OpenAI models (you can use any LLMs) to condense context while preserving all relevant information, eliminating redundancy and duplication.
</p># Context summarization
def summarize_context(query: str, context: str) -&gt; str:
    """
    Summarize context using LLM to reduce duplication and focus on relevant information
    
    Args:
        query: User's query/question
        context: Context to summarize
        
    Returns:
        Summarized context
    """
    try:
        summary_prompt = f"""You are an expert at summarizing conversation context.

Your task: Analyze the provided conversation context and produce a condensed summary that fully answers or supports the user's specific question.

The summary must:
1. Preserve every fact, detail, and information that directly relates to the question
2. Eliminate redundancy and duplicate information
3. Maintain chronological flow when relevant
4. Focus on information that helps answer: "{query}"

Context to summarize:
{context}

Provide a concise summary that preserves all relevant information:"""

        summary = llm.invoke(summary_prompt).content
        
        if args.verbose:
            original_length = len(context)
            summary_length = len(summary)
            reduction_pct = ((original_length - summary_length) / original_length * 100) if original_length &gt; 0 else 0
            rich.print(f"[cyan]📝 Summarization stats: {summary_length}/{original_length} chars ({reduction_pct:.1f}% reduction)[/cyan]")
        
        return summary
        
    except Exception as e:
        rich.print(f"[yellow]⚠️ Error in context summarization: {str(e)}[/yellow]")
        rich.print(f"[yellow]⚠️ Falling back to original context[/yellow]")
        return context<p>Importantly, when the context is summarized, the model is less likely to get overwhelmed by trivial details or past errors (assuming the summary is accurate). </p><p>However, summarization has to be done carefully. A bad summary might omit a crucial detail or even introduce an error. It’s essentially another prompt to the model (“summarize this”), so it can hallucinate or lose nuance. Best practice is to summarize incrementally and perhaps keep some canonical facts unsummarized.</p><p>Nonetheless, it has proven very useful. <a href="https://storage.googleapis.com/deepmind-media/gemini/gemini_v2_5_report.pdf">In the Gemini agent scenario, </a>summarizing the context every ~100k tokens was a way to counteract the model’s tendency to repeat itself. The summary acts like a compressed memory of the conversation or data. As developers, we can implement this by having an agent periodically call a summarization function (maybe a smaller LLM or a dedicated routine) on the conversation history or a long document. The resulting summary replaces the original content in the prompt. This tactic is widely used to keep contexts within limits and distill the information.</p><h3>Context quarantine: Isolate contexts when possible</h3><p>This is more relevant in complex agent systems or multistep workflows. The idea of context segmentation is to split a big task into smaller, isolated tasks, each with its own context, so that you never accumulate one enormous context that contains everything. Each subagent or subtask works on a piece of the problem with a focused context, and then a higher-level agent, or supervisor or coordinator integrates the results.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09d1eac7442aea2b/6a170f42dc55deb10de00ea7/f2de68c3339883d7658e633af3948f29f427e6cf-1600x900.png" alt="Context quarantine" /><p><a href="https://www.anthropic.com/engineering/multi-agent-research-system">Anthropic’s research strategy uses multiple subagents</a>, each investigating a different aspect of a question, with their own context windows, and a lead agent that reads the distilled results from those subagents. This parallel, modular approach means that no single context window gets too bloated. It also reduces the chance of irrelevant information mixing, each thread stays on topic (no context confusion), and it doesn’t carry unnecessary baggage when answering its specific subquestion. In a sense, it’s like running separate threads of thought that only share their outcomes, not their entire thought process.</p><p>In multi-agent systems, this approach is essential. If Agent A is handling task A and Agent B is handling task B, there’s no reason for either agent to consume the other’s full context unless it’s truly required. Instead, agents can exchange only the necessary information. For example, Agent A can pass a consolidated summary of its findings to Agent B via a supervisor agent, while each subagent maintains its own dedicated context thread. This setup doesn’t require human-in-the-loop intervention; it relies on a supervisory agent with enabled tools with minimal and controlled context sharing.</p><p>Nonetheless, designing your system so that agents or tools operate with minimal necessary context overlap can greatly enhance clarity and performance. Think of it as <strong>microservices for AI</strong>, each component deals with its context, and you pass messages between them in a controlled way, instead of one monolithic context.These best practices are often used in combination. Also, this gives you the flexibility to trim trivial history, summarize important older messages or conversations, offload the detailed logs to Elasticsearch for long-term context, and use retrieval to bring back anything relevant when needed.</p><p>As mentioned <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents#:~:text=While%20some%20models,to%20the%20LLM">here</a>, the guiding principle is that context is a limited and precious resource. You want every token in the prompt to earn its keep, meaning it should contribute to the quality of the output. If something in memory is not pulling its weight (or worse, actively causing confusion), then it should be pruned, summarized, or kept out.</p><p>As developers, we can now program the context just like we program code, deciding what information to include, how to format it, and when to omit or update it. By following these practices, we can give LLM agents the much-needed context to perform tasks without falling victim to the failure modes described earlier. The result is agents that remember what they should, forget what they don’t need, and retrieve what they require just in time.</p><h2>Conclusion</h2><p>Memory isn’t something you add to an agent; it’s something you engineer. Short-term memory is the agent’s working scratch pad, and long-term memory is its durable knowledge store. RAG is the bridge between the two, turning a passive datastore, like Elasticsearch, into an active recall mechanism that can ground outputs and keep the agent current.</p><p>But memory is a double-edged sword. The moment you let context grow unchecked, you invite poisoning, distraction, confusion, and clashes, and in shared systems, even data leakage. That’s why the most important memory work isn’t “store more,” it’s “curate better”: Retrieve selectively, prune aggressively, summarize carefully, and avoid mixing unrelated contexts unless the task truly demands it.</p><p>In practice, good context engineering looks like good systems design: smaller, sufficient contexts, controlled interfaces between components, and a clear separation between raw and the distilled state you actually want the model to see. Done right, you don’t end up with an agent that remembers everything - you end up with an agent that remembers the right things, at the right time, for the right reason.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/agentic-memory-management-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agentic-memory-management-elasticsearch</guid>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Someshwaran Mohankumar]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3bad6b045392e641/6a170f43a29299c189d010cc/80907fd072e72d6ec902470b449c9f337957a0d7-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 16 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[jina-embeddings-v3 is now available on Elastic Inference Service]]></title>
    <description><![CDATA[Introducing jina-embeddings-v3 on Elastic Inference Service (EIS) and explaining how to get started.]]></description>
    <content:encoded><![CDATA[<p>Today, we are excited to introduce <a href="https://jina.ai/news/jina-embeddings-v3-a-frontier-multilingual-embedding-model/"><code>jina-embeddings-v3</code></a> on Elastic Inference Service (EIS), enabling fast multilingual dense retrieval directly into Elasticsearch. Jina-embeddings-v3 is the first Jina AI model available on EIS, with many more to come soon.</p><p>Jina AI, <a href="https://www.elastic.co/blog/elastic-jina-ai">which recently joined Elastic via acquisition</a>, is a leader in open-source multilingual and multimodal embeddings, rerankers, and small language models. Jina brings deep expertise in <a href="https://www.elastic.co/search-labs/blog/jina-models-elasticsearch-guide">search foundation models</a> that help developers build high-quality retrieval and RAG systems across text, images, code, and long multilingual content.</p><p><a href="https://www.elastic.co/blog/elastic-inference-service">Elastic Inference Service</a> (EIS) makes it easy for developers to add fast, high-quality, and reliable semantic retrieval for search, RAG, and agentic applications with predictable, consumption-based pricing. EIS offers fully-managed GPU-powered inference with ready-to-use models, no additional setup or hosting complexity required.</p><p><code>jina-embeddings-v3</code> supports high-quality multilingual retrieval with long-context reasoning and task-tuned modes for RAG and agents. It provides developers fast dense embeddings across a broad range of languages without the operational overhead.</p><h2>Why jina-embeddings-v3?</h2><p><code>jina-embeddings-v3</code> is a text embedding model that supports 32 languages and up to 8192-token context, high relevance at lower cost, and GPU-powered inference through EIS.</p><h3><strong>Key capabilities</strong></h3><ul><li><p><strong>Multilinguality</strong>: Closes the language gap and aligns meaning across 32 languages, including Arabic, Bengali, Chinese, Danish, Dutch, English, Finnish, French, Georgian, German, Greek, Hindi, Indonesian, Italian, Japanese, Korean, Latvian, Norwegian, Polish, Portuguese, Romanian, Russian, Slovak, Spanish, Swedish, Thai, Turkish, Ukrainian, Urdu, and Vietnamese.</p></li><li><p><strong>Parameter-efficiency</strong>: <a href="https://jina.ai/news/jina-embeddings-v3-a-frontier-multilingual-embedding-model/">Delivers higher performance</a> with only 570M parameters, achieving comparable performance to much larger LLM-based embeddings at lower costs.</p></li><li><p><strong>Dimensionality control</strong>: Default in 1024 dimensions, and with Matryoshka representation support, it lets developers dial the embedding size all the way down to 32 dimensions, giving flexibility to balance accuracy, latency, and storage based on your needs.</p></li><li><p><strong>Task-specific optimization</strong>: Features task-specific Low-Rank Adaptation (LoRA) adapters, enabling it to generate high-quality embeddings for various tasks including query-document retrieval, clustering, classification, and text matching.</p></li></ul><h2><strong>Get started</strong></h2>POST _inference/text_embedding/.jina-embeddings-v3
{
  "input": ["Rocky Mountain National Park"],
  "input_type": "ingest"
}<p>The response:</p>{
  "text_embedding": [
    {
      "embedding": [
        -0.06678891,
        -0.0073341704,
        0.011903269,
        -0.041797,
      ...
      ]
    }
  ]
}<h2>What’s next</h2><p>Alongside these new models, EIS continues to evolve to support more users and simplify semantic search across environments.</p><p><strong>Cloud Connect for EIS: </strong><a href="https://www.elastic.co/docs/deploy-manage/cloud-connect">Cloud Connect</a> for EIS will soon bring EIS to self-managed environments, reducing operational overhead and enabling hybrid architectures and scaling where it works best for you.</p><p><strong>semantic_text defaults to jina-embeddings-v3 on EIS: </strong><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> currently uses ELSER as the embeddings model behind the scenes, but will default to the <code>jina-embeddings-v3</code> endpoint on EIS in the near future. This change will provide built-in inference at ingestion time, making it easier to adopt multilingual search without additional configuration.</p><p><strong>More models: </strong>We’re expanding the EIS model catalog to meet the rising inference demands of our customers. In the coming months, we’ll introduce new models that support an even broader set of search and inference workloads. Hot on the heels of <code>jina-embeddings-v3</code>, the next models to follow are <a href="https://jina.ai/models/jina-reranker-v2-base-multilingual/"><code>jina-reranker-v2-base-multilingual</code></a>and <a href="https://jina.ai/news/jina-reranker-v3-0-6b-listwise-reranker-for-sota-multilingual-retrieval/"><code>jina-reranker-v3</code></a>. Both Jina AI models greatly improve precision through multilingual reranking for RAG and AI agents.</p><h2>Conclusion</h2><p>With <code>jina-embeddings-v3</code> on EIS, you can build multilingual, high-precision retrieval pipelines without managing models, GPUs, or infrastructure. You get fast dense retrieval and tight integration with Elasticsearch’s relevance stack, all in one platform.</p><p>Whether you are building global RAG systems, search, or agentic workflows that need reliable context, Elastic now gives you a high-performance model out-of-the-box, and the operational simplicity to move from prototype to production with confidence.</p><p>All Elastic Cloud trials have access to the Elastic Inference Service. <a href="https://www.elastic.co/cloud/serverless">Try it now </a>on Elastic Cloud Serverless and Elastic Cloud Hosted.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service</guid>
    <category><![CDATA[Jina AI]]></category>
    <dc:creator><![CDATA[Sean Handley,Ranjana Devaji,Brendan Jugan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt763e299d97823861/6a170c16961e698842c4cf54/9bb1c96c697d8d48b764bee487a73a6cae130d0d-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 12 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Hybrid search and multistage retrieval in ES|QL]]></title>
    <description><![CDATA[Explore the multistage retrieval capabilities of ES|QL, using FORK and FUSE commands to integrate hybrid search with semantic reranking and native LLM completions.]]></description>
    <content:encoded><![CDATA[<p>In Elasticsearch 9.2, we’ve introduced the ability to do dense vector search and hybrid search in Elasticsearch Query Language (ES|QL). This continues our investment in making ES|QL the best search language to solve modern search use cases.</p><h2>Multistage retrieval: The challenge of modern search</h2><p>Modern search has evolved beyond simple keyword matching. Today's search applications need to understand intent, handle natural language, and combine multiple ranking signals to deliver the best results.</p><p>Retrieval of the most relevant results happens in multiple stages, with each stage gradually refining the result set. This wasn’t the case in the past, where most use cases would require one or two stages of retrieval: an initial query to get results and a potential rescoring phase.	</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3382265814939417/6a170df3a929cf1246ae0a61/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" alt="" /><p>We start with an initial retrieval, where we cast a wide net to gather results that are relevant to our query. Since we need to sieve through all the data, we should use techniques that return results fast, even when we index billions of documents.</p><p>We therefore employ trusted techniques, such as lexical search that Elasticsearch has supported and optimized since the beginning, or vector search, where Elasticsearch excels in speed and accuracy.</p><p>Lexical search using BM25 is quite fast and best at exact term matching or phrase matching, and <a href="https://www.elastic.co/docs/solutions/search/vector">vector</a> or <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> is better suited for handling natural language queries. <a href="https://www.elastic.co/what-is/hybrid-search">Hybrid search</a> combines lexical and <a href="https://www.elastic.co/docs/solutions/search/vector">vector search</a> results to bring the best from both. The challenge that hybrid search solves is that vector and lexical search have completely different and incompatible scoring functions which produce values in different intervals, following different distributions. A vector search score close to 1 can mean a very close match, but it doesn’t mean the same for lexical search. Hybrid search methods, such as <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">reciprocal rank fusion</a> (RRF) and linear combination of scores, assign new scores that blend the original scores from lexical and vector search.</p><p>After hybrid search, we can employ techniques such as <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking">semantic reranking</a> and <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr">Learning To Rank</a> (LTR), which use specialized machine learning models to rerank the result.</p><p>With our most relevant results, we can use large language models (LLMs) to further enrich our response or pass the most relevant results as context to LLMs in agentic workflows in tools such as <a href="https://www.elastic.co/search-labs/blog/elastic-ai-agent-builder-context-engineering-introduction">Elastic Agent Builder</a>.</p><p>ES|QL is able to handle all these stages of retrieval. By design, ES|QL is a piped language, where each command transforms the input and sends the output to the next command. Each stage of retrieval is represented by one or more consecutive ES|QL commands. In this article, we show how each stage is supported in ES|QL.</p><h2>Vector search</h2><p>In Elasticsearch 9.2, we introduced tech preview support for dense vector search in ES|QL. This is as simple as calling the <code>knn</code> function, which only requires a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a> field and a query vector:</p>FROM books METADATA _score
| WHERE KNN(description_vector, ?query_vector)
| SORT _score DESC
| LIMIT 100<p>This query executes an approximate nearest neighbor search, retrieving 100 documents that are the most similar to the <code>query_vector</code>.</p><h2>Hybrid search: Reciprocal rank fusion</h2><p>In Elasticsearch 9.2, we introduced support for hybrid search using RRF and linear combination of results in ES|QL.</p><p>This allows combining vector search and lexical search results into a single result set.</p><p>To achieve this in ES|QL, we need to use the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> commands. <code>FORK</code> runs multiple branches of execution, and <code>FUSE</code> merges the results and assigns new relevance scores using RRF or linear combination.</p><p>In the following example, we use <code>FORK</code> to run two separate branches, where one is doing a lexical search using the <code>match</code> function, while the other is doing a vector search using the <code>knn</code> function. We then merge the results together using <code>FUSE</code>:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE // uses RRF by default
| SORT _score DESC<p>Let's decompose the query to better understand the execution model and first look at the output of the <code>FORK</code> command:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)<p>The<code> FORK</code> commands outputs the results from both branches and adds a <code>_fork</code> discriminator column:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>0.88</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.88</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>0.86</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>0.84</p><p>fork1</p><p>4123</p><p>The Silmarillion</p><p>0.78</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>0.79</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>4.55</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>4.25</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>4.11</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>3.8</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>4.1</p><p>fork2</p><p>As you’ll notice, certain documents appear twice, which is why we then use <code>FUSE</code> to merge rows that represent the same documents and assign new relevance scores. <code>FUSE</code> is executed in two stages:</p><ul><li><p>For each row, <code>FUSE</code> assigns a new relevance score, depending on the hybrid search algorithm that is being used.</p></li><li><p>Rows that represent the same document are merged together, and a new score is computed.</p></li></ul><p>In our example, we’re using RRF. As a first step, <code>FUSE</code> assigns a new score to each row using the RRF formula:</p>score(doc) = 1 / (rank_constant + rank(doc))<p>Where the <code>rank_constant</code> takes a default value of 60 and <code>rank(doc)</code>represents the position of the document in the result set.</p><p>In the first phase, our results become:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>1 / (60 + 1) = 0.01639</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>1 / (60 + 2) = 0.01613</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>1 / (60 + 3) = 0.01587</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>1 / (60 + 4) = 0.01563</p><p>fork1</p><p>4123</p><p> The Silmarillion</p><p>1 / (60 + 5) = 0.01538</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>1 / (60 + 6) = 0.01515</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>1 / (60 + 1) = 0.01639</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>1 / (60 + 2) = 0.01613</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>1 / (60 + 3) = 0.01587</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>1 / (60 + 4) = 0.01563</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>1 / (60 + 5) = 0.01538</p><p>fork2</p><p>Then the rows are merged together and a new score is assigned. Since a <code>SORT _score DESC</code> follows the <code>FUSE</code> command, the final results are:</p><p>_id</p><p>title</p><p>_score</p><p>4001</p><p>The Hobbit</p><p>0.01639 + 0.01639 = 0.03279</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.01613 + 0.01613 = 0.03226</p><p>4005</p><p>The Two Towers</p><p>0.01587 + 0.01563 = 0.0315</p><p>4123</p><p>The Silmarillion</p><p>0.01538 + 0.01587 = 0.03125</p><p>4006</p><p>The Return of the King</p><p>0.01563 + 0.01538 = 0.03101</p><p>4144</p><p>The Children of Húrin</p><p>0.01515</p><h2>Hybrid search: Linear combination of scores</h2><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">Reciprocal rank fusion</a> is the simplest way to do hybrid search, but it isn’t the only hybrid search method that we support in ES|QL.</p><p>In the following example, we use <code>FUSE</code> to combine lexical and <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text">semantic search</a> results using linear combination of scores:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 } }
| SORT _score DESC<p>Let's first decompose the query and take a look at the input of the <code>FUSE</code> command when we only run the <code>FORK</code> command.</p><p>Notice that we use the <code>match</code> function, which is able to not only query lexical fields, such as <code>text</code> or <code>keyword</code>, but also <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> fields.</p><p>The first <code>FORK</code> branch executes a semantic query by querying a <code>semantic_text</code> field, while the second one executes a lexical query:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)<p>The output of the <code>FORK</code> command can contain rows with the same <code>_id</code> and <code>_index</code> values representing the same Elasticsearch document:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>0.88</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.88</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>0.86</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>0.84</p><p>fork1</p><p>4123</p><p>The Silmarillion</p><p>0.78</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>0.79</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>4.55</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>4.25</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>4.11</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>3.8</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>4.1</p><p>fork2</p><p>In the next step, we use <code>FUSE</code> to merge rows that have the same <code>_id</code> and <code>_index</code> values, and assign new relevance scores.</p><p>The new score is a linear combination of the scores the row had in each <code>FORK</code> branch:</p>_score = 0.7 *_score1 + 0.3 * _score2<p>Here, <code>_score1</code> and <code>_score2</code> represent the score a document has in the first <code>FORK</code> branch and the second <code>FORK</code> branch, respectively.</p><p>Notice that we also apply custom weights, giving more weight to the semantic score over the lexical one, resulting in this set of documents:</p><p>_id</p><p>title</p><p>_score</p><p>4001</p><p>The Hobbit</p><p>0.7 * 0.88 + 0.3 * 4.55 = 1.981</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.7 * 0.88 + 0.3 * 4.25 = 1.891</p><p>4006</p><p>The Return of the King</p><p>0.7 * 0.84 + 0.3 * 4.1 = 1.818</p><p>4123</p><p>The Silmarillion</p><p>0.7 * 0.78 + 0.3 * 4.11 = 1.779</p><p>4005</p><p>The Two Towers</p><p>0.7 * 0.86 + 0.3 * 3.8 = 1.742</p><p>4144</p><p>The Children of Húrin</p><p>0.7 * 0.79 + 0.3 * 0 = 0.553</p><p>One challenge is that the semantic and lexical scores can be incompatible to apply the linear combination, since they can follow completely different distributions. To mitigate this, we first need to normalize the scores, employing score normalization methods, such as <code>minmax</code>. This ensures that the scores from each <code>FORK</code> branch are first normalized to take values between 0 and 1, before applying the linear combination formula.</p><p>To achieve this with <code>FUSE</code>, we need to specify the <code>normalizer</code> option:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 }, "normalizer": "minmax" }
| SORT _score DESC<h2>Semantic reranking</h2><p>At this stage, after hybrid search, we should be left with the most relevant documents. We can now use semantic reranking to reorder the results using the <code>RERANK</code> command. By default, <code>RERANK</code> uses the latest Elastic <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking">semantic reranking</a> machine learning model, so no additional configuration is needed:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC<p>We now have our best results, sorted by relevance.</p><p>One key feature that sets the <code>RERANK</code> command apart from other products that offer semantic reranking integrations is that it doesn’t require the input to represent a mapped field from an index. <code>RERANK</code> only expects an expression that evaluates to a string value, making it possible to do semantic reranking using multiple fields:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON CONCAT(title, "\n", description) 
| SORT _score DESC<h2>LLM completions</h2><p>Now we have a set of highly relevant, reranked results.</p><p>At this stage, you might simply decide to return the results back to your application or you might want to further enhance your results using LLM completions.</p><p>If you’re using ES|QL as part of a retrieval-augmented generation (RAG) workflow, you can choose to call your favorite LLM directly from ES|QL.
To achieve this, we’ve added a new <code>COMPLETION</code> command that takes in a prompt, a completion inference ID which designates which LLM to call, and a column identifier to specify where to output the LLM response.</p><p>In the following example, we’re using <code>COMPLETION</code> to add a new <code>_completion</code> column that contains the summary of the <code>content</code> column:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC
| LIMIT 10
| COMPLETION CONCAT("Summarize the following:\n", description) WITH { "inference_id" : "my_inference_endpoint" } <p>Each row now contains a summary:</p><p>_id</p><p>title</p><p>_score</p><p>summary</p><p>4001</p><p>The Hobbit</p><p>0.03279</p><p>Bilbo helps dwarves reclaim Erebor from the dragon Smaug.</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.03226</p><p>Frodo begins the quest to destroy the One Ring.</p><p>4005</p><p>The Two Towers</p><p>0.0315</p><p>The Fellowship splits; war comes to Rohan; Frodo nears Mordor.</p><p>4123</p><p>The Silmarillion</p><p>0.03125</p><p>Ancient myths and history of Middle-earth's First Age.</p><p>4006</p><p>The Return of the King</p><p>0.3101</p><p>Sauron is defeated and Aragorn is crowned King.</p><p>4144</p><p>The Children of Húrin</p><p>0.01515</p><p>The tragic tale of Túrin Turambar's cursed life.</p><p>In another use case, you may simply want to answer a question using the proprietary data that you have indexed in Elasticsearch. In this case, the best search results that we’ve computed in the previous stage can be used as context for the prompt:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC
| LIMIT 10
| STATS context = VALUES(CONCAT(title, "\n", description)
| COMPLETION CONCAT("Answer the following question ", ?query, "based on:\n", context) WITH { "inference_id" : "my_inference_endpoint" }<p>Since the <code>COMPLETION</code> command unlocks the ability to send any prompt to an LLM, the possibilities are endless. Although we’re only showing a few examples, the <code>COMPLETION</code> command can be used in a wide range of scenarios, from security analysts using it to assign scores depending on whether a log event can represent a malicious action or data scientists using it to analyze data, to cases where you just need to<a href="https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator"> generate Chuck Norris facts based on your data</a>.</p><h2>This is only the beginning</h2><p>In the future, we’ll be expanding ES|QL to improve semantic reranking for long documents, better conditional execution of the ES|QL queries using multiple <code>FORK</code> commands, support sparse vector queries, removing close duplicate results to enhance result diversity, allowing full text search on runtime generated columns, and many other scenarios.</p><p>Additional tutorials and guides:</p><ul><li><p><a href="https://www.elastic.co/docs/solutions/search/esql-for-search">ES|QL for search</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-search-tutorial">ES|QL for search tutorial</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">Semantic_text field type</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> documentation</p></li><li><p>ES|QL search functions</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Ioana Tagirta,Aurélien Foucret,Carlos Delgado]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3382265814939417/6a170df3a929cf1246ae0a61/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" length="0" type="image/png"/>
    <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Implementing an agentic reference architecture with Elastic Agent Builder and MCP]]></title>
    <description><![CDATA[Explore an agentic reference architecture with Elastic Agent Builder, MCP, and semantic search to build a security agent for automated threat analysis.]]></description>
    <content:encoded><![CDATA[<p>In this article, we will present a reference architecture for using Elasticsearch with AI capabilities through the <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic Agent Builder</a>, exposing an <a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCP server</a> to access Agent Builder tools and Elasticsearch data.</p><p>Model Context Protocol (<a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCP</a>) is an open-source standard that enables applications and LLMs to communicate with external systems via <a href="https://modelcontextprotocol.io/specification/2025-06-18/server/tools">MCP tools</a> (programmatic capabilities), and <a href="https://docs.langchain.com/oss/python/langgraph/overview">LangGraph</a> (an extension of <a href="https://docs.langchain.com/oss/javascript/langchain/overview">LangChain</a>) provides the orchestration framework for these agentic workflows.</p><p>We’ll implement an application that can search both internal knowledge (Elasticsearch stored data) and external sources (on the internet) to identify potential and known vulnerabilities related to a specific tool. The application will gather the information and generate a detailed summary of the findings.</p><h2>Requirements</h2><ul><li><p>Elasticsearch 9.2</p></li><li><p>Python 3.1x</p></li><li><p><a href="https://platform.openai.com/api-keys">OpenAI API Key</a></p></li><li><p><a href="https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys">Elasticsearch API Key</a></p></li><li><p><a href="https://serpapi.com/users/sign_up?plan=free">Serper API Key</a></p></li></ul><h2>Elastic Agent Builder</h2><p><a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic Agent Builder</a> is a set of AI-powered capabilities for developing and integrating agents that can interact with your Elasticsearch data. It provides a built-in agent that can be used for natural language conversations with your data or instance, and it also supports tool creation, Elastic APIs, A2A, and MCP. In this article, we will focus on using the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">MCP server</a> for external access to the Elastic Agent Builder tools.</p><p>To know more about Agent Builder features, you can read <a href="https://www.elastic.co/search-labs/blog/elastic-ai-agent-builder-context-engineering-introduction">this article</a>.</p><h3>Agent Builder MCP feature</h3><p>The <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">MCP server</a> is available in the Agent Builder and can be accessed at:</p>{KIBANA_URL}/api/agent_builder/mcp
# Or if you are using a custom Kibana space:
{KIBANA_URL}/s/{SPACE_NAME}/api/agent_builder/mcp<p>The Agent Builder offers <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools#built-in-tools">Built-in tools</a>, and you can also create your <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools#custom-tools">custom tools</a>.</p><h2>Reference architecture</h2><p>To get a complete overview of the elements used by an agentic application in an end-to-end workflow, let’s look at the following diagram:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7a1c664318e2c848/6a170bde964cea3c6908bbe8/c5bbba345340bfe5571b17d53b5896d4a3235eac-4720x2560.png" alt="Agent Builder MCP feature reference architecture." /><p>Elasticsearch is at the center of this architecture, functioning as a vector store, providing the embeddings generation model, and also serving the MCP server to access the data via tools. To better explain the workflow, let’s look at the ingestion and the Agent Builder layer separately.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb616cae7400ea5f0/6a170be0dc55debba1e00e27/97a0075ae637d64140ec7ff0d167297723675632-3000x1176.png" alt="Elasticsearch at the center of the architecture, functioning as a vector store, providing the embeddings generation model, and also serving the MCP server to access the data via tools." /><p>Here, the first element is the data that will be stored in Elasticsearch. The data passes through an ingest pipeline, where it is processed by the Elasticsearch ELSER model to generate embeddings and then stored in Elasticsearch.</p><h3>Elastic Agent Builder layer</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt33175b496b636661/6a170be2dc55de7daae00e2b/9bb396bbd4c3baa3be26f9d9e386f4d5405132ab-2180x2560.png" alt="The agent builder layer where the Agent Builder plays a central role by exposing the tools needed to interact with the Elasticsearch data." /><p>On this layer, the Agent Builder plays a central role by exposing the tools needed to interact with the Elasticsearch data. It manages the tools that operate over Elasticsearch indices and makes them available for consumption. Then <a href="https://docs.langchain.com/oss/python/langchain/overview">LangChain</a> handles the orchestration via the MCP client.</p><p>This architecture allows Agent Builder to work as one of many MCP servers available to the client so that the Elasticsearch agent builder can combine with other MCPs. This way, the MCP client can ask cross-source questions and then combine the answers.</p><h2>Use case: Security vulnerability agent</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9d7291c0c39e3dbe/6a170be4ab7f08b2bedb9ec4/1b46b29a8cde4645ebaec1f747be4f6888dd8d39-1600x906.png" alt="Agent builder and MCP use case. Building a security vulnerability agent." /><p>The security vulnerability agent identifies potential risks based on a user’s question by combining three complementary layers:</p><p><strong>First</strong>, it performs a <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> with embeddings over an internal knowledge base of past incidents, configurations, and known vulnerabilities to retrieve relevant historical evidence.</p><p><strong>Second</strong>, it searches the internet for newly published recommendations or threat intelligence that may not yet exist internally.</p><p><strong>Finally</strong>, an LLM correlates and prioritizes both internal and external findings, evaluates their relevance to the user’s specific environment, and produces a clear explanation along with potential mitigation steps.</p><h2>Developing the application</h2><p>The application’s code can be found in the attached <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-reference-architecture-for-agentic-applications/notebook.ipynb">notebook</a>.</p><p>You can see the setup for the Python application below:</p># load environment variables
load_dotenv()

ELASTICSEARCH_ENDPOINT = os.getenv("ELASTICSEARCH_ENDPOINT")
ELASTICSEARCH_API_KEY = os.getenv("ELASTICSEARCH_API_KEY")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
SERPER_API_KEY = os.getenv("SERPER_API_KEY")
KIBANA_URL = os.getenv("KIBANA_URL")

INDEX_NAME = "security-vulnerabilities"
KIBANA_HEADERS = {
    "kbn-xsrf": "true",
    "Content-Type": "application/json",
    "Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}",
} # Useful for Agent Builder API calls


es_client = Elasticsearch(ELASTICSEARCH_ENDPOINT, api_key=ELASTICSEARCH_API_KEY) # Elasticsearch client<p>We need to access Agent Builder and create one agent specialized in security queries and one tool to perform semantic search. You need to have the<a href="https://www.elastic.co/docs/solutions/search/agent-builder/get-started"> Agent Builder </a><a href="https://www.elastic.co/docs/solutions/search/agent-builder/get-started"><strong>enabled</strong></a> for the next step. Once it’s on, we’ll use the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/kibana-api#tools">tools API</a> to create a tool that will perform a semantic search.</p>security_search_tool = {
    "id": "security-semantic-search",
    "type": "index_search",
    "description": "Search internal security documents including incident reports, pentests, internal CVEs, security guidelines, and architecture decisions. Uses semantic search powered by ELSER to find relevant security information even without exact keyword matches. Returns documents with severity assessment and affected systems.",
    "tags": ["security", "semantic", "vulnerabilities"],
    "configuration": {
        "pattern": INDEX_NAME,
    },
}

try:
    response = requests.post(
        f"{KIBANA_URL}/api/agent_builder/tools",
        headers=KIBANA_HEADERS,
        json=security_search_tool,
    )

    if response.status_code == 200:
        print("✅ Security semantic search tool created successfully")    
    else:
        print(f"Response: {response.text}")
except Exception as e:
    print(f"❌ Error creating tool: {e}")<p>Configure your tools following the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools#best-practices">best practices</a> defined by Elastic for developing Tools. Once created, this tool will be ready to use in the Kibana UI.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt57d9fb62f55979e7/6a170be6509168a2a9e1bb0d/5e5b3282dea07987613d8e8d35c372ca68820e44-1600x381.png" alt="Configuring tools following the best practices defined by Elastic for developing Tools." /><p>With the tool created, we can start writing the code for the ingestion workflow:</p><h3>Ingest pipeline</h3><p>To define the data structure, we need to have a <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-reference-architecture-for-agentic-applications/dataset.json">dataset</a> prepared for ingestion. Below is a sample document for this example:</p>{
    "title": "Incident Report: Node.js Express 4.17 Prototype Pollution RCE",
    "content": "In March 2024, our production Node.js Express 4.17 API gateway experienced a critical prototype pollution vulnerability leading to remote code execution. The attack vector involved manipulating object prototypes through JSON payloads in POST requests. This affected all Express middleware processing user input. Immediate mitigation: upgrade to Express 4.18.2+, implement input validation, use Object.freeze() for critical objects. Related to CVE-2022-24999.",
    "doc_type": "incident_report",
    "severity": "critical",
    "affected_systems": [
      "api-gateway-prod",
      "api-gateway-staging"
    ],
    "date": "2024-03-15"
}<p>For this type of document, we will use the following index mappings:</p>index_mapping = {
    "mappings": {
        "properties": {
            "title": {"type": "text", "copy_to": "semantic_field"},
            "content": {"type": "text", "copy_to": "semantic_field"},
            "doc_type": {"type": "keyword", "copy_to": "semantic_field"},
            "severity": {"type": "keyword", "copy_to": "semantic_field"},
            "affected_systems": {"type": "keyword", "copy_to": "semantic_field"},
            "date": {"type": "date"},
            "semantic_field": {"type": "semantic_text"},
        }
    }
}

if es_client.indices.exists(index=INDEX_NAME) is False:
    es_client.indices.create(index=INDEX_NAME, body=index_mapping)
    print(f"✅ Index '{INDEX_NAME}' created with semantic_text field for ELSER")
else:
    print(f"ℹ️  Index '{INDEX_NAME}' already exists, skipping creation")<p>We are creating a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">semantic_text</a> field to perform semantic search using the information from the fields marked with the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to">copy_to</a> property.</p><p>With that mapping definition, we can ingest the data using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk">bulk API</a>.</p>def build_bulk_actions(documents, index_name):
    for doc in documents:
        yield {"_index": index_name, "_source": doc}


try:
    with open("dataset.json", "r") as f:
        security_documents = json.load(f)

    success, failed = helpers.bulk(
        es_client,
        build_bulk_actions(security_documents, INDEX_NAME),
        refresh=True,
    )
    print(f"📥 {success} documents indexed successfully")

except Exception as e:
    print(f"❌ Error during bulk indexing: {str(e)}")<h3>LangChain MCP client</h3><p>Here we’re going to create an MCP client using LangChain to consume the Agent Builder tools and build a workflow with LangGraph to orchestrate the client execution. The first step is to <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server#configuring-mcp-clients">connect to the MCP server</a>:</p>client = MultiServerMCPClient(
    {
        "agent-builder": {
            "transport": "streamable_http",
            "url": MCP_ENDPOINT,
            "headers": {"Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}"},
        }
    }
)

tools = await client.get_tools()

print(f"📋 MCP Tools available: {[t.name for t in tools]}") # ['platform_core_search',  ... 'security-semantic-search']<p>Next, we create an agent that selects the appropriate tool based on the user input:</p>reasoning = {"effort": "low"}

llm = ChatOpenAI(
    model="gpt-5.2-2025-12-11", reasoning=reasoning, openai_api_key=OPENAI_API_KEY
) # LLM client 

agent = create_agent(
    llm,
    tools=tools,
    system_prompt="""You are a cybersecurity expert specializing in infrastructure security.

        Your role is to:
        1. Analyze security queries from users
        2. Search internal security documents (incidents, pentests, CVEs, guidelines)
        3. Provide actionable security recommendations
        4. Assess vulnerability severity and impact

        When responding:
        - Always search internal documents first using the agent builder tools
        - Provide specific, technical, and actionable advice
        - Cite relevant internal incidents and documentation
        - Assess severity (critical, high, medium, low)
        - Recommend immediate mitigation steps

        Be concise but comprehensive. Focus on practical security guidance.""",
)<p>We’ll use the GPT-5.2 model, which represents OpenAI’s state-of-the-art for agent management tasks. We configure it with low reasoning effort to achieve faster responses compared to the medium or high settings, while still delivering high-quality results by leveraging the full capabilities of the GPT-5 family. You can read more about the GPT 5.2 <a href="https://openai.com/index/introducing-gpt-5-2/">here</a>.</p><p>Now that the initial setup is done, the next step is to define a workflow capable of making decisions, running tool calls, and summarizing results.</p><p>For this, we use LangGraph. We won’t cover LangGraph in depth here; <a href="https://www.elastic.co/search-labs/blog/ai-agent-workflow-finance-langgraph-elasticsearch">this article</a> provides a detailed overview of its functionality.</p><p>The following image shows a high-level view of the LangGraph application.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte293b7cf62f54f8e/6a170be7964cea816908bbec/729295115427ec981a594e873245fa541dd977aa-332x531.png" alt="High-level view of the LangGraph application." /><p>We need to define the application state:</p>class AgentState(TypedDict):
    query: str
    agent_builder_response: dict
    internet_results: list
    final_response: str
    needs_internet_search: bool<p>To better understand how the workflow operates, here is a brief description of each function. For full implementation details, refer to the accompanying <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-reference-architecture-for-agentic-applications/notebook.ipynb">notebook</a>.</p><ul><li><p><strong>call_agent_builder_semantic_search:</strong> Queries internal documentation using the Agent Builder MCP server and also stores the retrieved messages in the state.</p></li><li><p><strong>decide_internet_search:</strong> Analyzes the internal results and determines whether an external search is required.</p></li><li><p><strong>perform_internet_search: </strong>Runs an external search using the <a href="https://serper.dev/">Serper</a> API when needed.</p></li><li><p><strong>generate_response:</strong> Correlates internal and external findings and produces a final, actionable cybersecurity analysis for the user.</p></li></ul><p>With the workflow defined, we can now send a query:</p>query = "We are using Node.js with Express 4.17 for our API gateway. Are there known prototype pollution or remote code execution vulnerabilities?"<p>In this example, we want to evaluate whether this specific version of Express is affected by known vulnerabilities.</p><h4>Research results</h4><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltac164b2086d23589/6a170be9a29299162cd01057/b18a31e42bcd8f4d86bb605f85d4ff77135b0855-1084x517.png" alt="Elastic agent builder and MCP security agent research results." /><p>See the complete response in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-reference-architecture-for-agentic-applications/notebook.ipynb">this file</a>.</p><p>This response clearly correlates internal and internet findings and provides actionable mitigation steps. It successfully highlights the severity of the vulnerability and offers a structured, security-oriented summary.</p><h3>Extensions and future enhancements</h3><p>This architecture is modular and allows us to extend its capabilities by replacing, improving, or adding components to the existing list. We could add another agent, consumed by the same MCP client. We can also use an automated ingestion workflow with tools such as Logstash, Kafka, or <a href="https://www.elastic.co/docs/reference/search-connectors/self-managed-connectors">Elastic self-managed connectors.</a> Feel free to change the LLM, the MCP client framework, or the embeddings model or add more tools depending on your needs.</p><h2>Conclusion</h2><p>This reference architecture shows a practical way to combine Elasticsearch, the Agent Builder, and MCP to build an AI-driven application. Its structure keeps each part independent, which makes the system easy to implement, maintain, and extend.</p><p>You can start with a simple setup (like the security use case in this article) and scale it by adding new tools, data sources, or agents as your needs grow. Overall, it provides a straightforward path for building flexible and reliable agentic workflows on top of Elasticsearch.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/agent-builder-mcp-reference-architecture-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-mcp-reference-architecture-elasticsearch</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt22bfbe4b04ea2e92/6a170beb60084b717d3c4597/33a57e3f61f9095c99b6d1499175a6edb0d5dfc5-4720x2560.png" length="0" type="image/png"/>
    <pubDate>Wed, 07 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Automating log parsing in Streams with ML]]></title>
    <description><![CDATA[Learn how a hybrid ML approach achieved 94% log parsing and 91% log partitioning accuracy through automation experiments with log format fingerprinting in Streams.]]></description>
    <content:encoded><![CDATA[<p>In modern observability stacks, ingesting unstructured logs from diverse data providers into platforms like Elasticsearch remains a challenge. Reliance on manually crafted parsing rules creates brittle pipelines, where even minor upstream code updates lead to parsing failures and unindexed data. This fragility is compounded by the scalability challenge: in dynamic microservices environments, the continuous addition of new services turns manual rule maintenance into an operational nightmare.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte8f5bd0e4986b04c/6a170e6acdacbf612e7d2a9e/9108ec303339dd091faa3c363c7cf5c228155f49-3840x2160.png" alt="" /><p>Our goal was to transition to an automated, adaptive approach capable of handling both log parsing (field extraction) and log partitioning (source identification). We hypothesized that Large Language Models (LLMs), with their inherent understanding of code syntax and semantic patterns, could automate these tasks with minimal human intervention.</p><p>We are happy to announce that this feature is already available in <a href="http://elastic.co/elasticsearch/streams"><u>Streams</u></a>!</p><h2>Dataset description</h2><p>We chose a <a href="https://github.com/logpai/loghub"><strong>Loghub</strong></a>collection of logs for PoC purposes. For our investigation, we selected representative samples from the following key areas:</p><ul><li><p>Distributed systems: We used the HDFS (Hadoop Distributed File System) and Spark datasets. These contain a mix of info, debug, and error messages typical of big data platforms.</p></li><li><p>Server &amp; web applications: Logs from Apache web servers and OpenSSH provided a valuable source of access, error, and security-relevant events. These are critical for monitoring web traffic and detecting potential threats.</p></li><li><p>Operating systems: We included logs from Linux and Windows. These datasets represent the common, semi-structured system-level events that operations teams encounter daily.</p></li><li><p>Mobile systems: To ensure our model could handle logs from mobile environments, we included the Android dataset. These logs are often verbose and capture a wide range of application and system-level activities on mobile devices.</p></li><li><p>Supercomputers: To test performance on high-performance computing (HPC) environments, we incorporated the BGL (Blue Gene/L) dataset, which features highly structured logs with specific domain terminology.</p></li></ul><p>A key advantage of the Loghub collection is that the logs are largely unsanitized and unlabeled, mirroring a noisy live production environment with microservice architecture.</p><p>Log examples:</p>[Sun Dec 04 20:34:21 2005] [notice] jk2_init() Found child 2008 in scoreboard slot 6
[Sun Dec 04 20:34:25 2005] [notice] workerEnv.init() ok /etc/httpd/conf/workers2.properties
[Mon Dec 05 11:06:51 2005] [notice] workerEnv.init() ok /etc/httpd/conf/workers2.properties
17/06/09 20:10:58 INFO output.FileOutputCommitter: Saved output of task 'attempt_201706092018_0024_m_000083_1138' to hdfs://10.10.34.11:9000/pjhe/test/1/_temporary/0/task_201706092018_0024_m_000083
17/06/09 20:10:58 INFO mapred.SparkHadoopMapRedUtil: attempt_201706092018_0024_m_000083_1138: Committed<p>In addition, we created a Kubernetes cluster with a typical web application + database set up to mine extra logs in the most common domain.</p><p>Example of common log fields: timestamp, log level (INFO, WARN, ERROR), source, message.</p><h2>Few-shot log parsing with an LLM</h2><p>Our first set of experiments focused on a fundamental question: <strong>Can an LLM reliably identify key fields and generate consistent parsing rules to extract them?</strong></p><p>We asked a model to analyse raw log samples and generate log parsing rules in regular expression (regex) and <a href="https://www.elastic.co/docs/explore-analyze/scripting/grok">Grok</a> formats. Our results showed that this approach has a lot of potential, but also significant implementation challenges.</p><h3>High confidence &amp; context awareness</h3><p>Initial results were promising. The LLM demonstrated a strong ability to generate parsing rules that matched the provided few-shot examples with high confidence. Besides simple pattern matching, the model showed a capacity for log understanding —it could correctly identify and name the log source (e.g., health tracking app, Nginx web app, Mongo database).</p><h3>The "Goldilocks" dilemma of input samples</h3><p>Our experiments quickly surfaced a significant lack of robustness because of extreme<strong> sensitivity to the input sample.</strong> The model's performance fluctuates wildly based on the specific log examples included in the prompt. We observed a log similarity problem where the log sample needs to include <em>just diverse enough </em>logs:</p><ul><li><p>Too homogeneous (overfitting)<strong>:</strong> If the input logs are too similar, the LLM tends to <strong>overspecify</strong>. It treats variable data—such as specific Java class names in a stack trace—as static parts of the template. This results in brittle rules that cover a tiny ratio of logs and extract unusable fields.</p></li><li><p>Too heterogeneous (confusion): Conversely, if the sample contains significant formatting variance—or worse, "trash logs" like progress bars, memory tables, or ASCII art—the model struggles to find a common denominator. It often resorts to generating complex, broken regexes or lazily over-generalizing the entire line into a single message blob field.</p></li></ul><h3>The context window constraint</h3><p>We also encountered a context window bottleneck. When input logs were long, heterogeneous, or rich in extractable fields, the model's output often deteriorated, becoming "messy" or too long to fit into the output context window. Naturally, chunking helps in this case. By splitting logs using character-based and entity-based delimiters, we could help the model focus on extracting the main fields without being overwhelmed by noise.</p><h3>The consistency &amp; standardization gap</h3><p>Even when the model successfully generated rules, we noted slight inconsistencies:</p><ul><li><p>Service naming variations: The model proposes different names for the same entity (e.g., labeling the source as "Spark," "Apache Spark," and "Spark Log Analytics" in different runs).</p></li><li><p>Field naming variations: Field names lacked standardization (e.g., <code>id</code> vs. <code>service.id</code> vs. <code>device.id</code>). We normalized names using a standardized <a href="https://www.elastic.co/docs/reference/ecs/ecs-field-reference">Elastic field naming</a>.</p></li><li><p>Resolution variance: The resolution of the field extraction varied depending on how similar the input logs were to one another.</p></li></ul><h2>Log format fingerprint</h2><p>To address the challenge of log similarity, we introduce a high-performance heuristic: <strong>log format fingerprint (LFF)</strong>.</p><p>Instead of feeding raw, noisy logs directly into an LLM, we first apply a deterministic transformation to reveal the underlying structure of each message. This pre-processing step abstracts away variable data, generating a simplified "fingerprint" that allows us to group related logs.</p><p>The mapping logic is simple to ensure speed and consistency:</p><ol><li><p>Digit abstraction: Any sequence of digits (0-9) is replaced by a single ‘0’.</p></li><li><p>Text abstraction: Any sequence of alphabetical characters with whitespace is replaced by a single ‘a’.</p></li><li><p>Whitespace normalization: All sequences of whitespace (spaces, tabs, newlines) are collapsed into a single space.</p></li><li><p>Symbol preservation: Punctuation and special characters (e.g., :, [, ], /) are preserved, as they are often the strongest indicators of log structure.</p></li></ol><p>We introduce the log mapping approach. The basic mapping patterns include the following:</p><ul><li><p>Digits 0-9 of any length -&gt; to ‘0.’</p></li><li><p>Text (alphabetical characters with spaces) of any length -&gt; to ‘a’.</p></li><li><p>White spaces, tabs, and new lines -&gt; to a single space.</p></li></ul><p>Let's look at an example of how this mapping allows us to transform the logs.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf91eebab0ad79ccd/6a170e6c67045ba94f45c29c/78fa2887486eb9417804354ee3bf2a4fdb0f6383-846x252.png" alt="" /><p>As a result, we obtain the following log masks:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt438d74dcb921578b/6a170e6d1949f74aa0e7aae3/ec439a3d3a25002498b97defcff733ea5ebc6b55-826x94.png" alt="" /><p>Notice the fingerprints of the first two logs. Despite different timestamps, source classes, and message content, their prefixes (<code>0/0/0 0:0:0 a a.a:</code>) are identical. This structural alignment allows us to automatically bucket these logs into the same cluster.</p><p>The third log, however, produces a completely divergent fingerprint (<code>0-0-0...</code>). This allows us to algorithmically separate it from the first group <em>before</em> we ever invoke an LLM.</p><h2>Bonus part: Instant implementation with ES|QL</h2><p>It’s as easy as passing this query in Discover.</p><p><strong>Query breakdown:</strong></p><p><strong>FROM</strong> loghub: Targets our index containing the raw log data.</p><p><strong>EVAL</strong> pattern = …: The core mapping logic. We chain REPLACE functions to perform the abstraction (e.g., digits to '0', text to 'a', etc.) and save the result in a “pattern” field.</p><p><strong>STATS </strong>[column1 =] expression1, …<strong> BY </strong>SUBSTRING(pattern, 0, 15):</p><p>This is a clustering step. We group logs that share the first 15 characters of their pattern and create aggregated fields such as total log count per group, list of log datasources, pattern prefix, 3 log examples</p><p><strong>SORT</strong> total_count DESC | <strong>LIMIT</strong> 100 : Surfaces the top 100 most frequent log patterns</p><p>The query results on LogHub are displayed below:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfa3960cf94ccf331/6a170e6fdc55decfa3e00e7c/b119498f124376c41d242a099bf9081fd6536be8-1600x394.png" alt="Log parsing query results on LogHub." /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2dbcde2a22e06367/6a170e71961e693a18c4cfb6/4dcfc0a5b7fa753497cc5def5ea3cd54449c0481-1600x719.png" alt="" /><p>As demonstrated in the visualization, this “LLM-free” approach partitions logs with high accuracy. It successfully clustered 10 out of 16 data sources (based on LogHub labels) completely (&gt;90%) and achieved majority clustering in 13 out of 16 sources (&gt;60%) —all without requiring additional cleaning, preprocessing, or fine-tuning.</p><p>Log format fingerprint offers a pragmatic, high-impact alternative and addition to sophisticated ML solutions like <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-categorize-text-aggregation">log pattern analysis</a>. It provides immediate insights into log relationships and effectively manages large log clusters.</p><ul><li><p>Versatility as a primitive </p></li></ul><p>Thanks to <a href="https://www.elastic.co/blog/getting-started-elasticsearch-query-language">ES|QL</a> implementation, LFF serves both as a standalone tool for fast data diagnostics/visualisations, and as a building block in log analysis pipelines for high-volume use cases. </p><ul><li><p>Flexibility</p></li></ul><p>LFF is easy to customize and extend to capture specific patterns, i.e. hexadecimal numbers and IP addresses.</p><ul><li><p>Deterministic stability</p></li></ul><p>Unlike ML-based clustering algorithms, LFF logic is straightforward and deterministic. New incoming logs do not retroactively affect existing log clusters.</p><ul><li><p>Performance and mMemory</p></li></ul><p>It requires minimal memory, no training or GPU making it ideal for real-time high-throughput environments.</p><h2>Combining log format fingerprint with an LLM</h2><p>To validate the proposed hybrid architecture, each experiment contained a random 20% subset of the logs from each data source. This constraint simulates a real-world production environment where logs are processed in batches rather than as a monolithic historical dump.</p><p>The objective was to demonstrate that LFF acts as an effective compression layer. We aimed to prove that high-coverage parsing rules could be generated from small, curated samples and successfully generalized to the entire dataset.</p><h2>Execution pipeline</h2><p>We implemented a multi-stage pipeline that filters, clusters, and applies stratified sampling to the data before it reaches the LLM.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt26635762891b3a41/6a170e73509168eea4e1bb91/b3f46ea471760b406a32fc7d4bc74cc03faaced2-3840x1660.png" alt="" /><p>1. Two-stage hierarchical clustering</p><ul><li><p>Subclasses (exact match): Logs are aggregated by identical fingerprints. Every log in one subclass shares the exact same format structure.</p></li><li><p>Outlier cleaning. We discard any subclasses that represent less than 5% of the total log volume. This ensures the LLM focuses on the dominant signal and won’t be sidetracked by noise or malformed logs.</p></li><li><p>Metaclasses (prefix match): Remaining subclasses are grouped into Metaclasses by the first N characters of the format fingerprint match. This grouping strategy effectively splits lexically similar formats under a single umbrella.We chose N=5 for Log parsing and N=15 for Log partitioning when data sources are unknown.</p></li></ul><p>2. Stratified sampling. Once the hierarchical tree is built, we construct the log sample for the LLM. The strategic goal is to maximize variance coverage while minimizing token usage.</p><ul><li><p>We select representative logs from <em>each</em> valid subclass within the broader metaclass.</p></li><li><p>To manage an edge case of too numerous subclasses, we apply random down-sampling to fit the target window size.</p></li></ul><p>3. Rule generation Finally, we prompt the LLM to generate a regex parsing rule that fits all logs in the provided sample for each Metaclass. For our PoC, we used the GPT-4o mini model.</p><h2>Experimental results &amp; observations</h2><p>We achieved 94% parsing accuracy and 91% partitioning accuracy on the Loghub dataset.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b896b41b3b70e7e/6a170e757d8d67601a70e7d9/49b2b6a1401dd1f33951da68e5a3fac37d0b5aaa-1600x1506.png" alt="94% parsing accuracy and 91% partitioning accuracy on the Loghub dataset." /><p>The confusion matrix above illustrates log partitioning results. The vertical axis represents the actual data sources, and the horizontal axis represents the predicted data sources. The heatmap intensity corresponds to log volume, with lighter tiles indicating a higher count. The diagonal alignment demonstrates the model's high fidelity in source attribution, with minimal scattering.</p><h2>Our performance benchmarks insights:</h2><ul><li><p><strong>Optimal baseline:</strong> a context window of <strong>30–40 log samples</strong> per category proved to be the "sweet spot," consistently producing robust parsing with both Regex and Grok patterns.</p></li><li><p><strong>Input minimisation:</strong> we pushed the input size to 10 logs per category for Regex patterns and observed only 2% drop in parsing performance, confirming that diversity-based sampling is more critical than raw volume.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/log-parsing-partitioning-automation-experiments-streams</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/log-parsing-partitioning-automation-experiments-streams</guid>
    <category><![CDATA[ML Research]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Nastia Havriushenko]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc1df5a7cae463d59/6a170e76a6c2b907d7e797ab/965c58f19742361160593c38fcaa8b2f4b0d6cc5-3838x2159.png" length="0" type="image/png"/>
    <pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Influencing BM25 ranking with multiplicative boosting in Elasticsearch]]></title>
    <description><![CDATA[Learn why additive boosting methods can destabilize BM25 rankings and how multiplicative scoring provides controlled, scalable ranking influence in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p><a href="https://en.wikipedia.org/wiki/Okapi_BM25">BM25</a> is one of the most widely used scoring models in Elasticsearch for text-based search. In many e-commerce implementations, it forms a major component of how product relevance is determined because it provides a well-understood, interpretable score that reflects how closely an item matches a shopper’s query. In addition to this text relevance, merchandising and search teams often need to influence the ranking with business metrics such as margin, stock levels, popularity, personalization, or campaign strategy, in a way that doesn’t destabilize the underlying text relevance.</p><p>The most intuitive levers for doing this are boosted <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">should</a> clauses or <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/rank-feature">rank_feature</a> fields. These may initially appear effective, but both approaches degrade and may even fail, as query patterns shift or catalog composition changes. Their shared limitation is that they introduce additive adjustments into a scoring system whose scale varies substantially across queries. A boost like “+2” might overwhelm the base BM25 score in one query while barely registering in another. In other words, additive methods may create brittle, unpredictable ranking behavior.</p><p>In contrast, <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score</a> with multiplicative boosting provides a stable and mathematically proportional way to shape BM25 scores without distorting their underlying structure. Your application logic determines what merits uplift; <code>function_score</code> expresses that intent in a predictable and explainable way that preserves the geometry (high-level relative ordering) of the BM25 relevance signal, nudging rankings in controlled ways rather than overwhelming the core text relevance.</p><p>This article builds on two earlier pieces that demonstrated practical uses of multiplicative boosting: (1) <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>, and (2) <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch">How to improve e-commerce search relevance with personalized cohort-aware ranking</a>. Here we step back from those examples to examine the architectural principle that underlies them: why multiplicative boosting via <code>function_score</code> is one of the most reliable and scalable ways to influence BM25-based ranking in Elasticsearch.</p><h2>Why it's important to preserve base BM25 rankings</h2><p>In many Elasticsearch-based applications, including e-commerce, BM25 remains a central component of how text relevance is assessed. It provides a signal that is interpretable and transparent for teams who need to understand why a product ranked where it did. These properties make BM25 particularly attractive in environments where explainability and operational predictability matter.</p><p>Because of this, most teams want to shape, rather than replace, the rankings produced by BM25. For example, they may want to allow higher-margin items to surface slightly more often, reduce exposure for low-stock products without hiding them, or highlight items aligned with a particular user segment. Ideally, this shaping should preserve the geometry of the rankings produced by the BM25 algorithm.</p><p>The difficulty arises when teams try to achieve these goals using mechanisms that add separate scoring streams on top of the base BM25 ranking. These additive adjustments are not always comparable to BM25’s scale and behave inconsistently as queries, data distributions, and catalog composition evolve. Over time, the ranking becomes brittle, unintuitive, and difficult to tune. A reliable influence mechanism must work with BM25’s scoring geometry rather than overpowering it.</p><p>The <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score</a> query with multiplicative boosting provides this property. It allows teams to apply business influence in a proportional, explainable way while keeping BM25’s underlying structure intact.</p><h2>Why many approaches to influencing ranking degrade (or break) BM25</h2><p>Teams often begin with mechanisms that look straightforward: boosted <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">should</a> clauses, <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/rank-feature">rank_feature</a> fields, or custom <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-script-score-query">script_score</a> logic. These tools can be effective in their intended use cases, which is why they seem like natural levers for adding business influence. But when they are used to shape or influence BM25-based text relevance, they may create unstable, opaque, or brittle ranking behavior.</p><p>The underlying issue is that these approaches introduce independent additive scoring contributions into a system whose base BM25 values vary widely across queries, fields, and data sets. Without respecting that variability, the influence becomes unpredictable.</p><p>Below are the three most common patterns and why they fail in practice.</p><h3>1. Additive boosts via should clauses</h3><p>A boosted <code>should</code> clause feels intuitive: “Promote items that match this business rule.” But under the hood, the behavior is fundamentally additive.</p><p>Consider a query of the form:</p>GET products/_search
{
  "query": {
    "bool": {
      "must": [ { "match": { "description": "running shoes" }}],
      "should": [ { "term": { "brand": { "value": "nike", "boost": 1 }}}]
    }
  }
}<p>This kind of query results in the following behavior:</p>final_score = base_BM25 + should_BM25<p>The problem is that <code>base_BM25</code> and <code>should_BM25</code> do not scale together. As your dataset changes, or as different queries are issued, the magnitude of BM25 can shift dramatically. For example, the base BM25 scores for three products might be 12, 8, 4 in one context, and 0.12, 0.08, 0.04 in another. Such a change might happen after a catalog update or a modification to the query structure.</p><p>A boosted <code>should</code> clause adds its own BM25-style contribution to the final score. In this situation, an additive contribution (i.e. should_BM25 = +2) behaves inconsistently:</p><ul><li><p>When base_BM25 is small (0.12), +2 dominates the score — roughly an 18× increase.</p></li><li><p>When base_BM25 is large (12), the same +2 barely shifts the document —  only about a 17% increase.</p></li></ul><p>This instability means that the combined <code>must</code> score and <code>should</code> score have no stable meaning across queries or catalogs. A rule that slightly promotes a brand for one query can dominate the ranking for another, or become irrelevant in a third. This is not a tuning issue; it is a structural property of additive scoring.</p><h3>2. Using rank_feature for business influence</h3><p>The <code>rank_feature</code> family is extremely useful for representing numeric qualities such as recency or popularity. It is fast, compressed, and operationally simple. However, when it is used to influence text relevance (BM25), it runs into the same structural limitation described in the previous section.</p><p>A <code>rank_feature</code> clause produces its own scoring contribution, which is then added to the BM25 score:</p>final_score = base_BM25 + feature_score<p>Just as with boosted <code>should</code> clauses, the two components do not scale together. BM25 values vary substantially across queries depending on term rarity and catalog statistics, while the <code>feature_score</code> follows the scale of the underlying business attribute being boosted (for example, popularity or recency), which typically bears no relationship to the scale of BM25. As a result, the two scoring streams drift apart as your corpus or query patterns evolve.</p><p>The consequence is the same as what we discussed above with relation to the should-clause problem:</p><ul><li><p>The feature score can dominate BM25 in one query and be negligible in another.</p></li><li><p>Tuning becomes fragile because you are calibrating two independent scales — BM25, which varies with query term statistics, and the feature score, which varies with the business attribute’s own distribution.</p></li></ul><p>Although <code>rank_feature</code> remains an excellent mechanism for representing raw numeric attributes, it is not well-suited for proportional influence on BM25, where the goal is not to add a second score but to gently shape the existing one.</p><h3>Custom scoring with script_score</h3><p>When boosted clauses or <code>rank_feature</code> fields become difficult to tune, teams often turn to <code>script_score</code> as a last resort. It provides complete freedom to manipulate the score, including adding, subtracting, multiplying, or replacing the BM25 value according to any business rule. A <code>script_score</code> query replaces Elasticsearch’s scoring pipeline with custom logic. Instead of shaping the BM25 score, the script builds a separate scoring mechanism whose behavior depends entirely on the code inside the script. While this can be powerful, it introduces three challenges that become more significant as the system grows.</p><p><strong>1. Opacity</strong></p><p>Scoring logic is hidden inside a script rather than expressed declaratively. When ranking behavior changes unexpectedly, it is difficult to understand whether the issue is the script itself, a data shift, or an interaction with BM25. Merchandisers and relevance engineers lose the ability to reason about why a document moved up or down.</p><p><strong>2. Performance and operational cost</strong></p><p>Script scoring bypasses many of Elasticsearch’s optimizations and caching pathways. Each document that matches the initial query must execute the script, often leading to higher CPU usage and unpredictable latency.</p><p><strong>3. Fragility when combined with BM25</strong></p><p>Because <code>script_score</code> allows arbitrary computations, it is easy to drift into scoring behaviors that no longer resemble BM25 or that fail to preserve its relative structure. As the dataset evolves or query patterns shift, the custom logic may interact with BM25 in unanticipated ways. A script that behaved reasonably early in development can produce surprising or unstable results once the catalog grows or data distributions change. Because <code>script_score</code> allows arbitrary math, two engineers working on different parts of the system may unintentionally encode competing scoring models, making ranking difficult to reason about as the organization scales.</p><h2>How function_score provides predictable influence on BM25</h2><p>BM25 already captures how well a document matches a query. It reflects text relevance, term rarity, document length, and the statistical shape of the corpus. When teams introduce business signals including margin, stock levels, popularity, personalization, or merchandising strategy, the goal is not to replace this relevance. The goal is to <em>influence it</em>.</p><p>This distinction is subtle but crucial. Most business requirements are proportional in nature:</p><ul><li><p>Promote higher-margin items modestly</p></li><li><p>Reduce exposure for low-stock products, but don’t hide them</p></li><li><p>Give this user segment a slight uplift for matching products</p></li><li><p>Boost for popularity, but not so much that textual relevance is lost</p></li></ul><p>These are naturally expressed as <em>percentage adjustments</em> rather than as fixed additive values. A merchandiser is rarely asking for “+2 points of score”; they are asking for “a little more visibility,” irrespective of the absolute numeric scale of the BM25 score. Mathematically, this means that the desired transformation is:</p>final_score = BM25 × boost_factor<p>Where <em>boost_factor</em> might be 1.05, 1.2, or 1.5, depending on the signal. Multiplicative boosting does not attempt to reinvent scoring; it simply adjusts the BM25 output by a proportional factor. A multiplicative adjustment has three properties that align well with real-world ranking control:</p><ol><li><p>The boost remains proportional. In other words, a 20% uplift is always a 20% uplift—whether BM25 is 0.12 or 12. The magnitude of the boost does not depend on the underlying BM25 scale.</p></li><li><p>BM25 retains its role as the primary signal. The multiplicative shaping nudges the ordering without overriding it. Strong textual matches still win; business logic influences but does not dominate.</p></li><li><p>Because the operation is multiplicative, not additive, changing the query or updating the corpus does not require re-tuning numeric constants. The boost has the same meaning everywhere.</p></li></ol><p>Elasticsearch’s <code>function_score</code> query provides an elegant mechanism for expressing this pattern. By using:</p><ul><li><p><strong>score_mode: “sum”</strong> to assemble a boost factor (building the multiplier), and</p></li><li><p><strong>boost_mode: “multiply”</strong> to apply the boost (multiplier) to BM25</p></li></ul><p>You can express business intent in a way that remains stable and explainable as your data and query patterns evolve. Instead of adding a second score beside BM25, <code>function_score</code> transforms BM25 itself—shaping it gently, predictably, and in line with how merchandisers and product owners think about ranking adjustments.</p><h2>Examples in practice: How multiplicative boosting behaves in real e-commerce queries</h2><p>To illustrate how multiplicative boosting works in real-world ranking scenarios, it helps to look at a small, concrete example. The goal here is not to demonstrate tuning or production-scale scoring, but rather to show how <code>function_score</code> influences BM25 in predictable, proportional ways that align with business intent.</p><p>Consider a simple catalog with three basketball shoes from three different brands: Nike, Adidas, and Reebok. The product descriptions are intentionally crafted so the BM25 scores exhibit natural differences based on query specificity and field length—just as they would in a real catalog.</p><h3>Example dataset</h3><p>For the following examples, we use a small, straightforward sample dataset with the following characteristics.</p><p>Brand</p><p>Description</p><p>nike</p><p>“Nike basketball shoes”</p><p>adidas</p><p>“New Adidas basketball shoes”</p><p>reebok</p><p>“Reebok basketball shoes”</p><p>We can create an index with the above products with the following commands from Kibana Dev Tools:</p>PUT products
{
  "mappings": {
    "properties": {
      "brand":       { "type": "keyword" },
      "description": { "type": "text" }
    }
  }
}

POST products/_bulk
{ "index": { "_id": "nike-001" } }
{ "brand": "nike",    "description": "Nike basketball shoes" }
{ "index": { "_id": "adi-001" } }
{ "brand": "adidas",  "description": "New Adidas basketball shoes" }
{ "index": { "_id": "ree-001" } }
{ "brand": "reebok", "description": "Reebok basketball shoes" }<p>With this dataset, we now evaluate three queries:</p><ul><li><p>A baseline “basketball shoes” search</p></li><li><p>The same query with a 50% promotion for Adidas and a 25% promotion for Nike</p></li><li><p>A specific “Reebok basketball shoes” query while the Adidas and Nike promotions are still active</p></li></ul><p>Each scenario highlights a different property of multiplicative boosting.</p><h3>1. Baseline ranking: No promotion</h3>GET products/_search
{
  "size": 3,
  "_source": ["brand", "description"],
  "query": {
    "match": { "description": "basketball shoes" }
  }
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt272891c9a78166c1/6a16f9d12b835f9e90f4b00b/050f44956c112ea9916d34946e9296480354f7d3-2322x1324.png" alt="" /><p>This query returns the following results where Nike and Reebok are ranked above adidas:</p><p>Rank</p><p>Brand</p><p>Score (BM25)</p><p>1/2 (tie)</p><p>nike</p><p>0.27845407</p><p>1/2 (tie)</p><p>reebok</p><p>0.27845407</p><p>3</p><p>adidas</p><p>0.24686474</p><h3>2. Adding 50% Adidas uplift and 25% Nike uplift with function_score</h3><p>If marketing launches a campaign where Adidas basketball shoes should receive a 50% uplift and Nike a 25% uplift, then the application layer could construct its queries to include those uplifts as follows:</p>GET products/_search
{
  "size": 3,
  "_source": ["brand", "description"],
  "query": {
    "function_score": {
      "query": {
        "match": { "description": "basketball shoes" }
      },
      "functions": [
        {
          "filter": { "term": { "brand": "adidas" } },
          "weight": 0.5
        },
        {
          "filter": { "term": { "brand": "nike" } },
          "weight": 0.25
        },
        {
          "weight": 1.0
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}<h3>How the multiplier is constructed</h3><ul><li><p>Base weight = 1.0</p></li><li><p>Adidas gets an additional +0.5</p></li><li><p>So Adidas’s multiplier = 1.5</p></li><li><p>Nike gets an additional +0.25</p></li><li><p>So Nike’s multiplier = 1.25</p></li><li><p>All other brands (including Reebok) get the base weight multiplier = 1.0</p></li></ul><h3>Apply multiplier:</h3><p>Final score = BM25 × multiplier</p><p>Product</p><p>BM25</p><p>Multiplier</p><p>Final score</p><p>Adidas</p><p>0.24686474</p><p>1.5</p><p>0.37029710</p><p>Nike</p><p>0.27845407</p><p>1.25</p><p>0.34806758</p><p>Reebok</p><p>0.27845407</p><p>1.0</p><p>0.27845407</p><h3>Result</h3><p>Adidas moves to the top, Nike follows, and Reebok is at the bottom with no change in its score. This is exactly the behavior that multiplicative boosting is designed to produce:</p><ul><li><p>Adidas and Nike both gain visibility, but in proportion to their configured uplifts.</p></li><li><p>The relative differences in BM25 still matter; we are reshaping the ranking, not replacing it.</p></li><li><p>The ordering changes primarily where BM25 scores are close.</p></li></ul><p>With additive boosts, the same “50% versus 25%” business intent would have to be approximated with numeric constants on an arbitrary BM25 scale, and the effect would vary drastically across queries.</p><h2>3. Specific intent still wins: “Reebok basketball shoes”</h2><p>Now run a highly specific branded query for “Reebok basketball shoes”, with the same Adidas (50%) and Nike (25%) promotions still active:</p>GET products/_search
{
  "size": 3,
  "_source": ["brand", "description"],
  "query": {
    "function_score": {
      "query": {
        "match": { "description": "Reebok basketball shoes" }
      },
      "functions": [
        {
          "filter": { "term": { "brand": "adidas" } },
          "weight": 0.5
        },
        {
          "filter": { "term": { "brand": "nike" } },
          "weight": 0.25
        },
        {
          "weight": 1.0
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}<p>The response shows the following results:</p><p>Rank</p><p>Brand</p><p>Final score</p><p>1</p><p>reebok</p><p>1.3011196</p><p>2</p><p>adidas</p><p>0.3702971</p><p>3</p><p>nike</p><p>0.34806758</p><h3>Result</h3><p>Reebok wins overwhelmingly because BM25 correctly detects strong intent for “Reebok basketball shoes”. Adidas and Nike still receive their 50% and 25% promotions, respectively, but those multipliers are nowhere near enough to override the BM25 score.</p><p>This is exactly the behavior that multiplicative boosting is designed to produce:</p><ul><li><p>When BM25 scores are close, boosts can shift the relative ordering.</p></li><li><p>When BM25 scores differ significantly (as they do here, due to strong text matching), the same boosts have little practical effect.</p></li></ul><p>Promotions influence the ranking, but they do not override the core text relevance signal.</p><h2>What this example demonstrates</h2><p>These real queries illustrate the key properties of multiplicative boosting:</p><ol><li><p>The influence is proportional, not arbitrary. A percentage-based uplift has the same proportional effect regardless of the underlying BM25 scale.</p></li><li><p>Text relevance remains in control. Strong brand-intent queries still surface the correct product.The system behaves intuitively. Merchandisers see exactly the ranking changes they expect.</p></li><li><p>The math is stable across queries. The same promotion works correctly whether the match is broad or highly specific.</p></li><li><p>Application logic stays clean. The business layer decides the uplift; Elasticsearch applies it predictably.</p></li></ol><p>Multiplicative boosting through <code>function_score</code> preserves relevance in a predictable and controllable way, while enabling business impact.</p><h2>Application logic remains the author of influence</h2><p>There is a clear separation between deciding what should be boosted and applying that boost in Elasticsearch. <code>function_score</code> handles the second task, but the first belongs firmly to application logic.</p><p>Your application logic is where decisions are made about:</p><ul><li><p>Which margin thresholds matter for your business</p></li><li><p>Whether popularity should rise or fall based on seasonality</p></li><li><p>How to interpret customer behavior or cohort membership</p></li><li><p>How to encode campaign rules</p></li><li><p>When to surface or suppress certain product groups</p></li></ul><p>These are <em>business</em> decisions, not scoring decisions. Elasticsearch does not infer whether a user is budget-focused or luxury-oriented, whether a promotion is active, or whether low stock requires a visibility adjustment. Those determinations occur upstream, in the part of the system that has access to user context, session features, analytics, and business configuration. After application logic produces clear numeric signals for fields such as weights, uplift factors, thresholds, and cohort tags, a <code>function_score</code> query provides a reliable way to express those signals as controlled multipliers on BM25.</p><p>This creates a clean architectural contract:</p><ul><li><p>Application logic: decides <em>what</em> should be influenced.</p></li><li><p>BM25 provides the core text relevance.</p></li><li><p><code>function_score</code> applies influence in a mathematically stable way.</p></li></ul><p>Because business logic lives outside the index, teams can adjust or experiment with uplift strategies without reindexing or restructuring documents.</p><h2>Conclusion</h2><p>E-commerce search must balance core text relevance with business considerations such as profitability, stock position, customer intent, seasonality, and personalization. BM25 provides a stable and interpretable foundation for text relevance, but influencing that score requires care. Business signals should shape the ranking, not overpower it.</p><p>However, the most commonly used levers such as boosted <code>should</code> clauses, <code>rank_feature</code> fields, and ad-hoc script scoring often behave unpredictably. These approaches can appear effective in early development, but their limitations emerge as soon as the catalog evolves or new query patterns arrive. Additive boosts fluctuate wildly because their impact depends entirely on the underlying scale of BM25, which varies dramatically across queries. A boost that produces a subtle nudge in one situation can dominate the ordering in another. Script scoring introduces its own challenges: opaque logic, reduced performance, and scoring behavior that becomes harder to understand or maintain over time.</p><p>Multiplicative boosting with <code>function_score</code> avoids these pitfalls by transforming BM25 proportionally rather than competing with it. Instead of adding a second, independent score component, it applies a controlled multiplier to BM25 itself. This produces the kind of predictable adjustments that merchandisers actually intend. For example, it allows slight promotions for high-margin items, modest reductions for low-stock products, or gentle uplifts for relevant user cohorts.</p><p>Equally important, the architecture remains clean. Application logic determines which business signals matter, and <code>function_score</code> applies them in a consistent, explainable way. Business teams can evolve business strategy without destabilizing relevance, and Engineering teams can refine relevance without disturbing business rules.</p><p>This principle is the foundation of the previous blogs that demonstrated how to influence e-commerce rankings: (1) <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>, and (2) <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch">How to improve e-commerce search relevance with personalized cohort-aware ranking</a>. Both approaches rely on the idea that business signals should guide BM25, not override it. Multiplicative boosting through <code>function_score</code> provides a practical, transparent, and scalable method for achieving that balance in real-world e-commerce search.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/bm25-ranking-multiplicative-boosting-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/bm25-ranking-multiplicative-boosting-elasticsearch</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt72914b2406032448/6a170e2c60084b4e913c45f9/6150bb846170d9be926a19260846a161ed377a5f-1098x542.png" length="0" type="image/png"/>
    <pubDate>Mon, 22 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch Serverless pricing demystified: VCUs and ECUs explained]]></title>
    <description><![CDATA[Learn how Elasticsearch Serverless pricing works for Elastic’s fully-managed deployment offering. We explain VCUs (Search, Ingest, ML) and ECUs, detailing how consumption is based on actual allocated resources, workload complexity, and Search Power.]]></description>
    <content:encoded><![CDATA[<p><em>Navigating Elasticsearch Serverless pricing is simple... you pay for the resources you use. Getting a handle on VCUs, ECUs, and the factors that drive your consumption is key to making informed decisions about your usage. In this blog, we'll break down exactly how Elasticsearch Serverless pricing works so you can plan, monitor, and optimize your spend.</em></p><p>When we built Elasticsearch Serverless, we had to decide how to bill our users. While a charge per query may have been easier to reason about from a consumption perspective, it would be a lot harder to reason about from a resource perspective. Instead, we implemented a simple pricing scheme comprising three dimensions for compute: search, ingest, and machine learning VCUs. This means we charge users for the actual resources we allocate to fulfill your requested workloads.</p><h2>VCU, ECU, and other terms</h2><p>Let's start by defining a few terms that will keep coming back throughout this post.</p><h3>VCU</h3><p>A VCU is a <a href="https://www.elastic.co/docs/deploy-manage/cloud-organization/billing/elasticsearch-billing-dimensions#elasticsearch-billing-information-about-the-vcu-types-search-ingest-and-ml">Virtual Compute Unit</a>, representing a fraction of RAM, CPU, and local disk for caching. We separate compute by the workloads they support, so we have three flavors of VCU:</p><ol><li><p>Search VCU</p></li><li><p>Ingest VCU</p></li><li><p>Machine Learning (ML) VCU</p></li></ol><p>VCU’s are charged by the hour.</p><h3>Regional pricing</h3><p>We have different prices for different regions and different cloud providers. You can find a full list of prices <a href="https://cloud.elastic.co/cloud-pricing-table?productType=serverless">on this page</a>.</p><h3>ECU</h3><p>An ECU is an <a href="https://www.elastic.co/docs/deploy-manage/cloud-organization/billing/ecu">Elastic Consumption Unit</a>, which is the unit we bill you in. The nominal value of an ECU is $1.00 USD. All of the different components of consumption are charged at a specific rate of ECUs per time unit. For example, one Gigabyte of storage might cost 0.047 ECU per month, so 100 GB of storage will cost you 4.7 ECU = $4.70 for one month. Similarly, if your search workload consumed 10 VCUs in a day and the Search VCU rate in your region is 0.09 ECU, your cost for that day would be $0.90.</p><h3>Interactive Dataset Size</h3><p>The amount of data in your project has a direct influence on your costs. We make the distinction of “interactive dataset” primarily for time-series data, as this relates to the amount of data in the Boost Window. For non-time-series data, this is simply the amount of data in the project.</p><p></p><h2>Project settings</h2><p>We have three <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/project-settings">project settings</a> that allow you to control your project's usage.</p><h3>Search power</h3><p>Search Power controls the speed of searches against your data. With Search Power, you can improve search performance by adding more resources for querying, or you can reduce provisioned resources to cut costs. Choose from three Search Power settings:</p><p><strong>On-demand</strong>: Autoscales based on data and search load, with a lower minimum baseline for resource use. This flexibility results in more variable query latency and reduced maximum throughput.</p><p><strong>Performant</strong>: Delivers consistently low latency and autoscales to accommodate moderately high query throughput.</p><p><strong>High-availability</strong>: Optimized for high-throughput scenarios, autoscaling to maintain query latency even at very high query volumes.</p><h3>Boost window</h3><p>For time series use cases, the boost window is the number of days of data that constitutes your interactive dataset size. The interactive dataset is the portion of your data that we keep cached, and that we use to determine how to scale the Search tier for your project. By default, the boost window is seven days.</p><h3>Data retention</h3><p>You can set the number of days of data that are retained in your project, which will affect the amount of storage we need. You can do this on a per-data stream basis in your project.</p><h2>Price components</h2><p>Serverless Elasticsearch contains a few different pricing components. For most use cases, the components you will care most about are Search, Ingest, and ML VCUs, as well as the Elastic Inference Service's token consumption.</p><h3>Search VCUs</h3><p>Search VCU consumption is the most complex part of pricing. We make this simple for you by automatically determining the right amount of VCUs that are needed to fulfill your workloads. For more details on how our autoscaling logic works, see <a href="https://www.elastic.co/search-labs/blog/elasticsearch-serverless-tier-autoscaling">our earlier blog on the topic</a>.</p><h4>Search VCU inputs</h4><p>Search VCUs are allocated based on a few factors, but mainly, we can boil it down to three inputs: the interactive dataset size, the search load on the system, and Search Power.</p><p>For traditional search use cases, the interactive dataset size will generally be your entire dataset. For time series use cases, it will be the portion of your dataset that fits inside the Boost Window.</p><p>Search load measures the amount of load being placed on the system by currently active searches. The main contributing factors are the number of searches per second, the complexity of the searches (the more that needs to be computed, the higher the load), and the size of the dataset that needs to be searched to fulfill the result. If we can get you the right number of results by scanning 10% of the dataset, then the load will be much lower than if we need to scan the full dataset.</p><p>Finally, <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/project-settings">Search Power</a> influences the number of VCUs we allocate. Each Search Power setting defines the baseline capacity of the search tier.</p><p>In short: the larger the dataset size and the higher the search load, the more VCUs we need to fulfill your search requests. Search Power allows you to tune to what extent we will scale up and down.</p><h4>Minimum VCUs</h4><p>Elasticsearch Serverless is designed to align infrastructure costs directly with your application's demand. </p><p>For smaller workloads, the search infrastructure can scale down to zero VCUs during periods of inactivity. If the system detects fifteen minutes of total inactivity, the associated hardware resources are deprovisioned. This makes the platform highly cost-effective for development environments, bursty workloads, or applications with intermittent usage. Note that inactivity means actual inactivity: no user-initiated searches whatsoever. As soon as we need to serve a search of any kind, we need to allocate hardware resources to execute that search.</p><p>As your interactive dataset grows, the system eventually reaches a storage threshold where a baseline level of resources is required to maintain data availability and indexing readiness. A minimum VCU allocation is maintained to ensure your data remains "warm" and queryable, even if no active searches are occurring.</p><h4>VCU consumption is not linear</h4><p>Because our hardware is allocated in steps, consumption of VCUs does not necessarily scale linearly with workload size. Each scaling step can contain a wide range of workloads, and if your workload is at the bottom of that range, it may have a lot of room to grow before we need to jump to the next scaling step.</p><p>This can make estimating based on a non-representative workload hard. For example, you may be consuming 2 VCUs per hour on a small workload. It's entirely possible that you could increase your workload size by a factor of 100 and still fit in that 2 VCU per hour load before we need to start increasing the amount of VCUs we allocate to serve your workload.</p><p>We know this makes estimating your cost a little harder, and we are working on ways to make that easier for you. If you need more help estimating your likely price, you can always talk to our customer team and get more personalized assistance.</p><h2>Ingest VCUs</h2><p>Ingest VCUs are much simpler than Search VCUs.</p><h4>Ingest VCU Inputs</h4><p>Ingest VCUs have essentially three inputs: the number of indices, the ingest rate, and the ingest complexity. We need to allocate a little bit of memory for every index in your system, which is why the number of indices matters. Read indices in data streams do not count for this calculation.</p><p>The faster you ingest, the more CPU we will need to process that ingestion. And the more complex your ingest requests, the more CPU we will need. Some factors that make ingest requests more expensive to execute are complicated field mappings or a lot of post-processing.</p><h4>Minimum Ingest VCUs</h4><p>We do not have a minimum number of VCUs we allocate to your ingest. If you do not ingest data, we do not need to allocate any VCUs to processing ingestion. There is an exception for a large number of indices (think: thousands of indices), where we do need to keep some resources allocated to be responsive when indexing requests come in.</p><h4>VCU consumption is not linear</h4><p>As with Search VCUs, we allocate Ingest VCUs based on step functions. Each step can contain a wide range of workloads: it's entirely possible that if you have a minimal amount of ingest, you could increase your ingest rate by a factor of 100 and still fit in the same step, thus not actually increasing your cost.</p><h2>AI workloads</h2><p>When running machine learning tasks in Serverless, we give you three options:</p><ol><li><p>You use our Elastic Inference Service (EIS) to run your inference and completion workloads. We take care of everything, and you are charged per token.</p></li><li><p>You use traditional Elasticsearch Machine Learning capabilities to run your workloads. These use our Trained Models capabilities. We will scale up and down based on your machine learning workload requirements.</p></li><li><p>You do it yourself, outside of our systems, and just bring your vectors or other inference results to store and search in Elasticsearch.</p></li></ol><h4>EIS</h4><p>The pricing for EIS is <a href="https://cloud.elastic.co/cloud-pricing-table?productType=serverless">quite straightforward</a>: you get charged a rate per one million consumed tokens. Token consumption is generally easy to predict for inference workloads. For LLM-based tasks, particularly agentic ones, this can be more complex, and some experimentation and trial runs may be useful to determine how many tokens your workloads typically consume.</p><h4>ML VCUs</h4><p>Machine Learning VCUs work on one simple input: machine learning workloads. The more inference you require, the more VCUs we will consume. Once you stop performing inference, we will scale down. We will keep a trained model in memory for about 24 hours after you last used it so that we can be responsive, which means that the minimal amount of VCU required to keep that model available will remain up for 24 hours before scaling down entirely.</p><p>We generally recommend our customers use EIS instead of our Machine Learning nodes for inference, particularly if your usage is periodic. By switching to EIS, you will not have to wait for machine learning nodes to spin up, and we won't charge you for unused ML node time before scaling down. EIS charges on a per token basis.</p><h2>Storage</h2><p>We charge storage per gigabyte per month. Storage does serve as an input into other parts of our system, particularly Search VCUs (see Search VCU above), but the pricing for storage itself is <a href="https://cloud.elastic.co/cloud-pricing-table?productType=serverless">quite straightforward</a>.</p><h2>Data Out (egress)</h2><p>We charge you for the data you take out of the system.</p><p>To minimize your egress costs, we recommend a few optimizations on your queries:</p><ol><li><p>Do not return vectors in your query responses. We <a href="https://www.elastic.co/search-labs/blog/elasticsearch-exclude-vectors-from-source">do this by default</a> for indices created after October 2025. You can always return vectors in your responses explicitly if necessary.</p></li><li><p>Return only the fields needed for your application. You can <a href="https://www.elastic.co/search-labs/blog/displaying-fields-in-an-elasticsearch-index">do this</a> by using the <code>fields</code> and <code>_source</code> parameters.</p></li></ol><h2>Support</h2><p>We charge <a href="https://www.elastic.co/pricing/serverless-search">support</a> as a percentage of your total ECU usage. We currently have four levels of support:</p><ol><li><p>Limited support</p></li><li><p>Base support</p></li><li><p>Enhanced support</p></li><li><p>Premium support</p></li></ol><h2>Project subtype profiles</h2><p>We currently offer two project subtypes for Serverless Elasticsearch, referred to as “General Purpose” and “Vector Optimized”. All Serverless Elasticsearch projects created through the cloud console UI will be created using the “General Purpose” option. You may create a “Vector Optimized” by calling the API directly with the <code>optimized_for</code> parameter (see <a href="https://www.elastic.co/docs/api/doc/elastic-cloud-serverless/operation/operation-createelasticsearchproject">documentation</a> for all options).</p><p>The difference between the two options is the allocation of resources. We allocate approximately four times more resources (aka VCUs) to the “Vector Optimized” profile, which will result in your costs being up to four times higher. This is why we recommend starting on the “General Purpose” profile and only using the “Vector Optimized” profile when your use case demands the use of uncompressed dense vectors with high dimensionality, and quantization and <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a> will not serve your needs.</p><p>When Serverless Elasticsearch was envisioned years ago, we thought that vector workloads would require much more resources to remain performant. However, with innovations like <code>semantic_text</code>, <code>sparse_vector</code> models, and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-9-1-bbq-acorn-vector-search">Better Binary Quantization</a> (BBQ), we’ve found that many vector workloads perform well on the “General Purpose” profile at a fraction of the cost. Therefore, don’t let the “Vector Optimized” label fool you…you can get excellent price <em>and</em> performance for vector workloads on the “General Purpose” profile.</p><h2>Monitoring costs</h2><p>We recognize that keeping track of your costs, especially when you are new to Elasticsearch Serverless, is important to you. We built a few tools just for this purpose, and continue to improve them for even greater visibility.</p><h2>Cloud console billing usage</h2><p>The <a href="https://www.elastic.co/docs/deploy-manage/cloud-organization/billing/view-billing-history">Elastic Cloud Console</a> provides billing details for your cloud account, across all cloud-based resources, including Elasticsearch Serverless. There, you can find a breakdown of all the price components described in this article. Filters allow you to zoom in on specific time periods and resources.</p><p>To further monitor your costs, you can also configure custom <a href="https://www.elastic.co/docs/deploy-manage/cloud-organization/billing/manage-billing-notifications">budget alerts </a>from the Budgets and notifications tab under the Billing and subscriptions page.</p><h2>AutoOps monitoring</h2><p>We’re bringing <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/autoops-for-serverless">AutoOps to Serverless</a>! One of the key value propositions of Elasticsearch Serverless is that we ensure everything runs smoothly, but that also means you have limited observability into the infrastructure. AutoOps for Serverless gives users visibility into what is driving usage, and, therefore, costs.</p><p>AutoOps is rolled out in new Serverless regions regularly, and we're always working to add new monitoring tools. Make sure to check out the <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/ec-autoops-regions#autoops-for-serverless-full-regions">region coverage</a> and future planned monitoring tools.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-serverless-pricing-vcus-ecus</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-serverless-pricing-vcus-ecus</guid>
    <category><![CDATA[Basics]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Sander Philipse,Pete Galeotti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3b8542204a8988dc/6a170bed0e2e49cd2641a12e/46f1e3c09e17cb8aa2a1cca64624bf533e55fe1d-1746x1096.png" length="0" type="image/png"/>
    <pubDate>Fri, 19 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Evaluating search query relevance with judgment lists]]></title>
    <description><![CDATA[Explore how to build judgment lists to objectively evaluate search query relevance and improve performance metrics such as recall, for scalable search testing in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Developers working on search engines often encounter the same issue: the business team is not satisfied with one particular search because the documents they expect to be at the top of the search results appear third or fourth on the list of results.</p><p>However, when you fix this one issue, you accidentally break other queries since you couldn’t test all cases manually. But how can you or your QA team test if a change in one query has a ripple effect in other queries? Or even more importantly, how can you be sure that your changes actually improved a query?</p><h2>Towards a systematic evaluation</h2><p>Here is where judgment lists come in useful. Instead of depending on manual and subjective testing any time you make a change, you can define a fixed set of queries that are relevant for your business case, together with their relevant results.</p><p>This set becomes your baseline. Every time you implement a change, you use it to evaluate if your search actually improved or not.</p><p>The value of this approach is that it:</p><ul><li><p><strong>Removes uncertainty</strong>: you no longer need to wonder if your changes impact other queries; the data will tell you.</p></li><li><p><strong>Stops manual testing</strong>: once the judgment sets are recorded, the test is automatic.</p></li><li><p><strong>Supports changes</strong>: You can show clear metrics that support the benefits of a change.</p></li></ul><h2>How to start building your judgment list</h2><p>One of the easiest ways to start is to take a representative query and manually select the relevant documents. There are two ways to do this list:</p><ul><li><p><strong>Binary Judgments:</strong> Each document associated with a query gets a <strong>simple tag</strong>: <em>relevant</em> (usually with a score of “1”) and not-relevant (“0”).</p></li><li><p><strong>Graded Judgments:</strong> Here, each document gets a score with different levels. For example: setting a 0 to 4 scale, similar to a <a href="https://en.wikipedia.org/wiki/Likert_scale">Likert scale</a>, where 0 = “not at all relevant” and 4 = “totally relevant,” with variations like “relevant,” “somewhat relevant,” etc.</p></li></ul><p>Binary judgments work well when the search intent has clear limits: Should this document be in the results or not?</p><p>Graded judgements are more useful when there are grey areas: some results are better than others, so you can get “very good,” “good,” and “useless” results and use metrics that value the order of the results and the user’s feedback. However, graded scales also introduce drawbacks: different reviewers may use the scoring levels differently, which makes the judgments less consistent. And because graded metrics give more weight to higher scores, even a small change (like rating something a 3 instead of a 4) can create a much bigger shift in the metric than the reviewer intended. This added subjectivity makes graded judgments noisier and harder to manage over time.</p><h2>Do I need to classify the documents myself?</h2><p>Not necessarily, since there are different ways to create your judgment list, each with its own advantages and disadvantages:</p><ul><li><p><strong>Explicit Judgments:</strong> Here, SMEs go over each query/document and manually decide if (or how) relevant it is. Though this provides quality and control, it is less scalable.</p></li><li><p><strong>Implicit Judgments:</strong> With this method, you infer the relevant documents based on real-user behavior like clicks, bounce rate, and purchases, among others. This approach allows you to gather data automatically, but it might be biased. For example, users tend to click top results more often, even if they are not relevant.</p></li><li><p><strong>AI-Generated Judgments:</strong> This last option uses models (like LLMs) to automatically evaluate queries and documents, often referred to as <a href="https://en.wikipedia.org/wiki/LLM-as-a-Judge">LLM juries</a>. It’s fast and easy to scale, but the quality of the data depends on the quality of the model you’re using and how well LLM training data aligns with your business <a href="http://interests.as/">interests</a>. As with human grades, LLM juries can introduce their own biases or inconsistencies, so it’s important to validate their output against a smaller set of trusted judgments. LLM models are probabilistic by nature, so it is not uncommon to see an LLM model giving different grades to the same result regardless of setting <a href="https://www.ibm.com/think/topics/llm-temperature">temperature</a> parameter as 0.</p></li></ul><p>Below are some recommendations to choose the best method for creating your judgment set:</p><ul><li><p>Decide how critical some features are for you that only users can properly judge (like price, brand, language, style, and product details). If those are critical, you need <strong>explicit judgments</strong> for at least some part of your <em>judgment list</em>.</p></li><li><p>Use <strong>implicit judgements</strong> when your search engine already has enough traffic so you can use clicks, conversions, and lingering time metrics to detect usage trends. You should still interpret these carefully, contrasting them with your explicit judgement sets to prevent any bias (e.g: users tend to click top-ranked results more often, even if lower-ranked results are more relevant)</p></li></ul><p>To address this, position debiasing techniques adjust or reweight click data to better reflect true user interest. Some approaches include:</p><ul><li><p><strong>Results shuffling</strong>: Change the order of search results for a subset of users to estimate how position affects clicks.</p></li><li><p><strong>Click models </strong>include<a href="https://wiki.math.uwaterloo.ca/statwiki/index.php?title=a_Dynamic_Bayesian_Network_Click_Model_for_web_search_ranking">Dynamic Bayesian Network </a><a href="https://wiki.math.uwaterloo.ca/statwiki/index.php?title=a_Dynamic_Bayesian_Network_Click_Model_for_web_search_ranking"><strong>DBN</strong></a>, <a href="https://rsrikant.com/papers/kdd10.pdf">User Browsing Model </a><a href="https://rsrikant.com/papers/kdd10.pdf"><strong>UBM</strong></a>. These Statistical models estimate the probability of a click reflects real interest rather than just position, using patterns like scrolling, dwell time, click sequence, and returning to the results page.</p></li></ul><h2>Example: Movie rating app</h2><h3>Prerequisites</h3><p>To run this example, you need a running Elasticsearch 8.x cluster, <a href="https://www.elastic.co/downloads/elasticsearch">locally</a> or <a href="https://www.elastic.co/cloud/cloud-trial-overview">Elastic Cloud</a> (Hosted or Serverless), and access to the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis">REST API</a> or Kibana.</p><p>Think about an app in which users can upload their opinions about movies and also search for movies to watch. As the texts are written by users themselves, they can have typos and many variations in terms of expression. So it’s essential that the search engine is able to interpret that diversity and provide helpful results for the users.</p><p>To be able to iterate queries without impacting the overall search behavior, the business team in your company created the following binary judgment set, based on the most frequent searches:</p><p>Query</p><p>DocID</p><p>Text</p><p>DiCaprio performance</p><p>doc1</p><p>DiCaprio's performance in The Revenant was breathtaking.</p><p>DiCaprio performance</p><p>doc2</p><p>Inception shows Leonardo DiCaprio in one of his most iconic roles.</p><p>DiCaprio performance</p><p>doc3</p><p>Brad Pitt delivers a solid performance in this crime thriller.</p><p>DiCaprio performance</p><p>doc4</p><p>An action-packed adventure with stunning visual effects.</p><p>sad movies that make you cry</p><p>doc5</p><p>A heartbreaking story of love and loss that made me cry for hours.</p><p>sad movies that make you cry</p><p>doc6</p><p>One of the saddest movies ever made — bring tissues!</p><p>sad movies that make you cry</p><p>doc7</p><p>A lighthearted comedy that will make you laugh</p><p>sad movies that make you cry</p><p>doc8</p><p>A science-fiction epic full of action and excitement.</p><p>Creating the index:</p>PUT movies
{
  "mappings": {
    "properties": {
      "text": {
        "type": "text"
      }
    }
  }
}<p>BULK request:</p>POST /movies/_bulk
{ "index": { "_id": "doc1" } }
{ "text": "DiCaprio performance in The Revenant was breathtaking." }
{ "index": { "_id": "doc2" } }
{ "text": "Inception shows Leonardo DiCaprio in one of his most iconic roles." }
{ "index": { "_id": "doc3" } }
{ "text": "Brad Pitt delivers a solid performance in this crime thriller." }
{ "index": { "_id": "doc4" } }
{ "text": "An action-packed adventure with stunning visual effects." }
{ "index": { "_id": "doc5" } }
{ "text": "A heartbreaking story of love and loss that made me cry for hours." }
{ "index": { "_id": "doc6" } }
{ "text": "One of the saddest movies ever made -- bring tissues!" }
{ "index": { "_id": "doc7" } }
{ "text": "A lighthearted comedy that will make you laugh." }
{ "index": { "_id": "doc8" } }
{ "text": "A science-fiction epic full of action and excitement." }<p>Below is the Elasticsearch query the app is using:</p>GET movies/_search
{
 "query": {
   "match": {
     "text": {
       "query": "DiCaprio performance",
       "minimum_should_match": "100%"
     }
   }
 }
}<h3>From judgment to metrics</h3><p>By themselves, judgment lists do not provide much information; they are only an expectation of the results from our queries. Where they really shine is when we use them to calculate objective metrics to measure our search performance.</p><p>Nowadays, most of the popular metrics include</p><ul><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-precision"><strong>Precision</strong></a><strong>: </strong>Measures the proportion of results that are truly relevant within all search results.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall"><strong>Recall</strong></a><strong>: </strong>Measures the proportion of relevant results the search engine found among x results.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#_discounted_cumulative_gain_dcg"><strong>Discounted Cumulative Gain (DCG)</strong></a><strong>: </strong>Measures the quality of the result’s ranking, considering the most relevant results should be at the top.</p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#_mean_reciprocal_rank"><strong>Mean Reciprocal Rank (MRR):</strong></a> Measures the position of the first relevant result. The higher it is in the list, the higher its score.</p></li></ul><p>Using the same movie rating app as an example, we’ll calculate the recall metric to see if there’s any information that is being left out of our queries.</p><p>In Elasticsearch, we can use the <em>judgment lists</em> to calculate metrics via the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval">Ranking Evaluation API</a>. This API receives as input the judgment list, the query, and the metric you want to evaluate, and returns a value, which is a comparison of the query result with the judgment list.</p><p>Let’s run the judgment list for the two queries that we have:</p>POST /movies/_rank_eval
{
 "requests": [
   {
     "id": "dicaprio-performance",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "DiCaprio performance",
             "minimum_should_match": "100%"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc1",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc2",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc3",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc4",
         "rating": 0
       }
     ]
   },
   {
     "id": "sad-movies",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "sad movies that make you cry",
             "minimum_should_match": "100%"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc5",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc6",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc7",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc8",
         "rating": 0
       }
     ]
   }
 ],
 "metric": {
   "recall": {
     "k": 10,
     "relevant_rating_threshold": 1
     }
 }
}<p>We’ll use two requests to _rank_eval: one for the DiCaprio query and another for sad movies. Each request includes a query and its judgment list (ratings). We don’t need to grade all documents since the ones not included in the ratings are considered as with no judgment. To do the calculations, recall only considers the “relevant set,” the documents that are considered relevant in the rating.</p><p>In this case, the DiCaprio query has a recall of 1, while the sad movies got 0. This means that for the first query, we were able to get all relevant results, while in the second query, we did not get any. The average recall is therefore 0.5.</p>{
 "metric_score": 0.5,
 "details": {
   "dicaprio-performance": {
     "metric_score": 1,
     "unrated_docs": [],
     "hits": [
       {
         "hit": {
           "_index": "movies",
           "_id": "doc1",
           "_score": 2.4826927
         },
         "rating": 1
       },
       {
         "hit": {
           "_index": "movies",
           "_id": "doc2",
           "_score": 2.0780432
         },
         "rating": 1
       }
     ],
     "metric_details": {
       "recall": {
         "relevant_docs_retrieved": 2,
         "relevant_docs": 2
       }
     }
   },
   "sad-movies": {
     "metric_score": 0,
     "unrated_docs": [],
     "hits": [],
     "metric_details": {
       "recall": {
         "relevant_docs_retrieved": 0,
         "relevant_docs": 2
       }
     }
   }
 },
 "failures": {}
}<p>Maybe we’re being too strict with the <strong>minimum_should_match </strong>parameter since by demanding that 100% of the words in the query are found in the documents, we’re probably leaving relevant results out. Let’s remove the <strong>minimum_should_match</strong> parameter so that a document is considered relevant if only one word in the query is found in it.</p>POST /movies/_rank_eval
{
 "requests": [
   {
     "id": "dicaprio-performance",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "DiCaprio performance"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc1",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc2",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc3",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc4",
         "rating": 0
       }
     ]
   },
   {
     "id": "sad-movies",
     "request": {
       "query": {
         "match": {
           "text": {
             "query": "sad movies that make you cry"
           }
         }
       }
     },
     "ratings": [
       {
         "_index": "movies",
         "_id": "doc5",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc6",
         "rating": 1
       },
       {
         "_index": "movies",
         "_id": "doc7",
         "rating": 0
       },
       {
         "_index": "movies",
         "_id": "doc8",
         "rating": 0
       }
     ]
   }
 ],
 "metric": {
   "recall": {
     "k": 10,
     "relevant_rating_threshold": 1
     }
 }
}<p>As you can see, by removing the <strong>minimum_should_match</strong> parameter in one of the two queries, we now get an average recall of 1 in both.</p>{
  "metric_score": 1,
  "details": {
    "dicaprio-performance": {
      "metric_score": 1,
      "unrated_docs": [],
      "hits": [
        {
          "hit": {
            "_index": "movies",
            "_id": "doc1",
            "_score": 2.0661702
          },
          "rating": 1
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc3",
            "_score": 0.732218
          },
          "rating": 0
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc2",
            "_score": 0.6271719
          },
          "rating": 1
        }
      ],
      "metric_details": {
        "recall": {
          "relevant_docs_retrieved": 2,
          "relevant_docs": 2
        }
      }
    },
    "sad-movies": {
      "metric_score": 1,
      "unrated_docs": [],
      "hits": [
        {
          "hit": {
            "_index": "movies",
            "_id": "doc7",
            "_score": 2.1307156
          },
          "rating": 0
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc5",
            "_score": 1.3160692
          },
          "rating": 1
        },
        {
          "hit": {
            "_index": "movies",
            "_id": "doc6",
            "_score": 1.190063
          },
          "rating": 1
        }
      ],
      "metric_details": {
        "recall": {
          "relevant_docs_retrieved": 2,
          "relevant_docs": 2
        }
      }
    }
  },
  "failures": {}
}<p>In summary, removing the minimum_should_match: 100% clause, allows us to got a perfect recall for both queries.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaf4f08a8a2915180/6a170df61949f76cbfe7aaba/24d055da4348c63827ba7046fe8cafb6f47cadd8-546x628.png" alt="" /><p>We did it! Right?</p><p>Not so fast!</p><p>By improving recall, we open the door to a wider range of results. However, each adjustment implies a trade-off. This is why defining complete test cases, using different metrics to evaluate changes.</p><p>Using judgment lists and metrics prevents you from going in blind when making changes since you now have data to back them up. Validation is no longer manual and repetitive, and you can test your changes in more than just one use case. Additionally, A/B testing allows you to test live which configuration works best for your users and business case, thus coming full circle from technical metrics and real-world metrics.</p><h2>Final recommendations for using judgment lists</h2><p>Working with judgment lists is not only about measuring but also about creating a framework that allows you to iterate with confidence. To achieve this, you can follow these recommendations:</p><ol><li><p><strong>Start small, but start</strong>. You don’t need to have 10,000 queries with 50 judgment lists each. You only need to identify the 5–10 most critical queries for your business case and define which documents you expect to see at the top of the results. This already gives you a base. You typically want to start with the top queries plus the queries with no results. You can also start testing with an easy-to-configure metric like Precision and then work your way up in complexity.</p></li><li><p><strong>Validate with users.</strong> Complement the numbers with A/B testing in production. This way, you’ll know if changes that look good in the metrics are also generating a real impact.</p></li><li><p><strong>Keep the list alive.</strong> Your business case will evolve, and so will your critical queries. Update your judgment periodically to reflect new needs.</p></li><li><p><strong>Make it part of the flow.</strong> Integrate judgment lists into your development pipelines. Make sure each configuration change, synonym, or text analysis is automatically validated against your base list.</p></li><li><p><strong>Connect technical knowledge with strategy.</strong> Don’t stop at measuring technical metrics like precision or recall. Use your evaluation results to inform business outcomes.</p></li></ol>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch</guid>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Jhon Guzmán]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcadfd2fb1cc95b4c/6a170df7acf0887798be9bd0/25478d0ffb228afd5d65d82312998ec1c299c565-700x490.png" length="0" type="image/png"/>
    <pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to improve e-commerce search relevance with personalized cohort-aware ranking]]></title>
    <description><![CDATA[Improve e-commerce search relevance with explainable, cohort-aware ranking in Elasticsearch. Learn how multiplicative boosting delivers stable, predictable personalization at query time.]]></description>
    <content:encoded><![CDATA[<h2>Overview</h2><p>In this article, we explore how to make Elasticsearch search results more relevant for different e-commerce user segments using an explainable, multiplicative boosting strategy — without any machine learning post-processing.</p><h2>Introduction: Why personalization matters</h2><p>Elasticsearch is very good at ranking results by textual relevance (BM25) and by semantic relevance (vectors). In e-commerce, that is necessary but not sufficient. Two people can type the same query and reasonably expect different results:</p><ul><li><p>A luxury shopper searching for “red lipstick” expects prestige brands near the top.</p></li><li><p>A budget shopper wants affordable options promoted.</p></li><li><p>A gift buyer may prefer popular bundles.</p></li></ul><p>The goal is to adjust ranking so that, for a given query, products that align with the user’s segment rise modestly in the list, without destroying the underlying relevance. This article shows how to add cohort-aware personalization on top of Elasticsearch’s relevance using only <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score</a>, a keyword field, and small multiplicative boosts.</p><h2>Multiplicative boosting for cohort personalization</h2><p>The core challenge in cohort personalization is stability. You want a product that is relevant to the query to remain relevant, with a controlled, explainable uplift when it matches the user’s segment. What often goes wrong is that personalization signals are added to the score in a way that either:</p><ul><li><p>overwhelms BM25 on some queries, or</p></li><li><p>has almost no effect on others.</p></li></ul><p>This happens because most boosting approaches use additive scoring. However, BM25 scales can vary dramatically across queries and datasets, so a fixed additive adjustment (e.g., “add +2.0 for a cohort match”) is sometimes a massive change to the BM25 score, and other times is negligible. Instead, what we want is a guarantee that if a product is a good match for the query, and it aligns with the user’s cohort, then its score is increased by a controlled percentage regardless of the absolute BM25 scale. We can achieve this with a multiplicative pattern:</p>final_score = BM25 × (1 + cohort_overlap × weight_per_cohort)<p>This article shows how to implement this pattern using Elasticsearch’s function_score query, a cohorts field on the product, and a list of user cohorts passed at query time.</p><h2>Modeling cohorts in your product catalog</h2><p>The simplest way to enable cohort-aware ranking is to treat cohorts as tags. For example, a product might carry tags such as:</p><ul><li><p>Lipstick: ["female", "beauty", "luxury"]</p></li><li><p>Men’s deodorant: ["male", "personal_care", "sport"]</p></li><li><p>Glitter gloss: ["female", "beauty", "youth", "party"].</p></li></ul><p>A user or session carries a set of tags inferred from behavior and profile:</p><ul><li><p>High-income female luxury shopper: ["female", "beauty", "luxury"]</p></li><li><p>Budget-oriented female shopper: ["female", "beauty", "budget"]</p></li></ul><p>Cohort overlap is the count of shared cohort tags between the user/session and the product. No weighting, no semantic similarity — just a simple intersection. For example, if the user cohorts are [“female”, “beauty”, “budget”] and a lipstick has [“female”, “beauty”, “luxury”], the overlap is 2. If a men’s deodorant has [“male”, “personal_care”, “sport”], the overlap with that same user is 0.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta288b12a3e3fa2a8/6a170bda286714007393e33b/e88ddfa1b453327fe75211500b850b49ad3776f5-1172x844.png" alt="" /><p>The intuition is that (a) BM25 ranks documents depending on how relevant they are to the user’s query, and (b) cohort overlap boosts products based on how well each product aligns with the user's segment. To accomplish this, we transform the cohort overlap between the user cohorts and the product cohorts into a multiplicative boost that scales BM25.</p><p>To avoid field explosion, we keep all cohort tags in a single keyword field, such as follows:</p>{
  "product_id": "LIP-001",
  "description": "Premium cherry red lipstick with velvet finish",
  "cohorts": ["female", "beauty", "luxury"]
}<p>This is easy for merchandisers to understand, avoids hundreds of boolean fields like is_female or is_luxury, and works efficiently with term filters.</p><h2>Why additive boosts don’t work</h2><p>One subtle but important point is that even a standard boolean query is additive. When Elasticsearch scores a document, the base BM25 score from the main query (typically in a <code>must</code>) and every matching <code>should</code> clause contributes an additive score. This means “additive boosting” is not just about boosts, it’s fundamental to how boolean scoring works.</p><p>Personalization built on additive logic behaves inconsistently because BM25 scales differ per query and dataset. For example the base BM25 scores for three products might be 12, 8, 4 in one instance, and might be 0.12, 0.08, 0.04 after updating your dataset or modifying your query. In this case an additive boost (e.g., +2.0) becomes a dominating force when the base BM25 scores are small (a +2.0 boost on a score of 0.12 is about 18× higher) or a rounding error when the base BM25 scores are large (+2.0 boost on a score of 12 is only about 1.17× higher). This creates inconsistent, unpredictable ranking behavior.</p><h2>Why multiplicative boosting is the right shape</h2><p>If we apply a multiplicative boost, the shape is consistent:</p>final_score = BM25 × boost
boost = 1 + overlap × weight_per_cohort<p>With weight_per_cohort = 0.1, an overlap of 2 gives a boost of 1.2 (20% increase), an overlap of 1 gives a boost of 1.1 (10% increase), and an overlap of 0 gives a boost of 1.0 (no change). This means that a product that is more aligned with the user cohort gets a predictable percentage uplift, regardless of whether its BM25 score is 0.01 or 10.0. BM25 remains the primary signal; cohort alignment gently reshapes the ranking.</p><h2>How function_score gives us multiplicative behavior</h2><p>To convert cohort overlap into a controlled percentage boost, we need a way to take the normal BM25 score and scale it up by a factor such as 1.1, 1.2, or 1.3. Elasticsearch does not support multiplying a score directly inside a standard query, but <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score</a> provides exactly this capability: it lets us compute an additional score component and combine it with the base score using a chosen strategy, which is "multiply" for this use case.</p><p>Elasticsearch’s function_score lets us implement multiplicative cohort boosting in three steps. First, each cohort match contributes a small weight (e.g., 0.1). Second, we include a baseline weight of 1.0 so that the final multiplier never drops below 1. Third, we sum all cohort contributions using score_mode: "sum" to produce a boost factor that represents (1 + overlap × weight). Finally, we combine this boost factor with the BM25 score using boost_mode: "multiply", which gives us the exact multiplicative behavior we want.</p><p>The calculation below shows how the final score is calculated, where BM25 is the base relevance; n is the number of matched cohorts; w is weight_per_cohort (e.g., 0.1); and additive baseline = 1.0:</p>sum_score = baseline + n × w
final_score = BM25 × sum_score<p>So, with 2 overlapping cohorts and w = 0.1:</p>sum_score = 1.0 + 2 × 0.1 = 1.2
final_score = BM25 × 1.2<p>This is exactly the multiplicative behavior we want.</p><h2>Putting it together: index, data, and baseline ranking</h2><p>Create a simple index:</p>PUT product_catalog
{
  "mappings": {
    "properties": {
      "product_id": {
        "type": "keyword"
      },
      "description": {
        "type": "text"
      },
      "cohorts": {
        "type": "keyword"
      }
    }
  }
}<p>Index a few products:</p>POST _bulk
{ "index": { "_index": "product_catalog", "_id": "LIP-001" }}
{ "product_id": "LIP-001", "description": "Premium cherry red lipstick with velvet finish", "cohorts": ["female", "beauty", "luxury"] }
{ "index": { "_index": "product_catalog", "_id": "LIP-002" }}
{ "product_id": "LIP-002", "description": "Affordable matte red lipstick for everyday wear", "cohorts": ["female", "beauty", "budget"] }
{ "index": { "_index": "product_catalog", "_id": "LIP-003" }}
{ "product_id": "LIP-003", "description": "Glitter red gloss for parties and festivals", "cohorts": ["female", "beauty", "youth", "party"] }<p>A baseline query for “red lipstick” might look like:
</p>POST product_catalog/_search
{
  "size": 5,
  "_source": ["product_id", "description"],
  "query": {
    "multi_match": {
      "query": "red lipstick",
      "fields": ["description"]
    }
  }
}<p>This returns a pure BM25 ranking (without any cohort boosting). In this example, the scores of LIP-001 and LIP-002 will be very close (or identical), because they match the same query terms with similar frequencies and have comparable lengths.</p><p>The relative ranking is what matters; the exact numeric scores may differ depending on shard configuration, analyzer differences, or Elasticsearch version.</p><p>Product ID</p><p>Description</p><p>BM25 score</p><p>LIP-001</p><p>Premium cherry red lipstick with velvet finish</p><p>0.603535</p><p>LIP-002</p><p>Affordable matte red lipstick for everyday wear</p><p>0.603535</p><p>LIP-003</p><p>Glitter red gloss for parties and festivals</p><p>0.13353139</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ef304420fe7d036/6a170bdcb0367dc72072bd4b/513bfba3467fb9966ed00b6e798889eeb690fe84-1788x1306.png" alt="" /><h3>Persona A: high-income luxury shopper</h3><p>Suppose we know that Persona A fits into the following cohorts:</p>["female", "beauty", "luxury"]<p>We translate that into a set of cohort filters, each with a small weight, plus a baseline factor:</p>GET product_catalog/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "red lipstick",
          "fields": ["description"]
        }
      },
      "functions": [
        { "filter": { "term": { "cohorts": "female" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "beauty" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "luxury" }},  "weight": 0.1 },
        { "weight": 1.0 }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}<p>For this persona LIP-001 (“Premium cherry red lipstick with velvet finish”) matches “female”, “beauty”, and “luxury” which means that the cohort overlap is 3 and therefore the boost factor is 1.3. On the other hand, LIP-002 and LIP-003 match “female” and “beauty” which results in a boost factor of 1.2.</p><p>Product ID</p><p>Description</p><p>Base BM25 score</p><p>Boost factor</p><p>New score</p><p>LIP-001</p><p>Premium cherry red lipstick with velvet finish</p><p>0.603535</p><p>1.3x (30%)</p><p>0.7845955</p><p>LIP-002</p><p>Affordable matte red lipstick for everyday wear</p><p>0.603535</p><p>1.2x (20%)</p><p>0.724242</p><p>LIP-003</p><p>Glitter red gloss for parties and festivals</p><p>0.13353139</p><p>1.2x (20%)</p><p>0.16023767</p><p>As desired for this luxury user, the luxury lipstick (LIP-001) receives the strongest uplift and will tend to rise above similar alternatives in the results.</p><h3>Persona B: budget-oriented shopper</h3><p>A budget-conscious shopper might belong to the following cohorts:</p><p>["female", "beauty", "budget"]</p><p>The query for this user is nearly identical to the previous query, except for the cohort values which now reflect “budget” rather than “luxury”:</p>GET product_catalog/_search
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "red lipstick",
          "fields": ["description"]
        }
      },
      "functions": [
        { "filter": { "term": { "cohorts": "female" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "beauty" }},  "weight": 0.1 },
        { "filter": { "term": { "cohorts": "budget" }},  "weight": 0.1 },
        { "weight": 1.0 }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}<p>For this persona LIP-002 (“Affordable matte red lipstick for everyday wear”) matches “female”, “beauty”, and “budget” which means that the cohort overlap is 3 and therefore the boost factor is 1.3. On the other hand, LIP-001 and LIP-003 match “female” and “beauty” which results in a boost factor of 1.2.</p><p>Product ID</p><p>Description</p><p>Base BM25 score</p><p>Boost factor</p><p>New score</p><p>LIP-002</p><p>Affordable matte red lipstick for everyday wear</p><p>0.603535</p><p>1.3x (30%)</p><p>0.7845955</p><p>LIP-001</p><p>Premium cherry red lipstick with velvet finish</p><p>0.603535</p><p>1.2x (20%)</p><p>0.724242</p><p>LIP-003</p><p>Glitter red gloss for parties and festivals</p><p>0.13353139</p><p>1.2x (20%)</p><p>0.16023767</p><p>As desired for this budget user, the budget lipstick (LIP-002) receives the strongest uplift and will tend to rise above similar alternatives in the results.</p><h2>How to build the cohort filter dynamically (Python example)</h2><p>You will normally inject the cohort filters at query time based on the user/session profile. For example:</p>user_cohorts = ["female", "beauty"]
functions = [
    { "filter": { "term": { "cohorts": cohort } }, "weight": 0.1 }
    for cohort in user_cohorts
]
# add baseline multiplier
functions.append({ "weight": 1.0 })<p>Using term filters on a keyword field is fast, shard-cache friendly, and fully visible in the _explain API, which shows exactly which filters fired and which weights were applied.</p><h2>How cohort assignment works</h2><p>Cohort assignment is intentionally left outside Elasticsearch, and is outside the scope of this article. However, sources could include:</p><ul><li><p>browsing events (“has viewed lipstick” → beauty)</p></li><li><p>gender inference (from preferences or marketing profile)</p></li><li><p>device characteristics (mobile shopper)</p></li><li><p>location (“urban buyer”)</p></li><li><p>historical purchases</p></li><li><p>marketing segments</p></li><li><p>personalization cookies</p></li></ul><p>All of these are input signals, but the scoring mechanism in Elasticsearch remains the same. Elasticsearch does not need to know how you inferred the segments. This separation of concerns keeps Elasticsearch focused on ranking, while your application or data science layer owns the logic for inferring segments.</p><h3>How to choose the right boost weight</h3><p>In our examples, we used 0.1 per cohort. This value is tunable. Staying between 0.05 and 0.20 will likely provide good results. You should A/B test weights based on:</p><ul><li><p>catalog diversity</p></li><li><p>number of cohort tags per product</p></li><li><p>variability in BM25</p></li><li><p>business goals (revenue vs. discovery vs. personalization)</p></li></ul><h3>Limit the number of cohorts assigned to each product</h3><p>Giving a product 20 cohort tags leads to:</p><ul><li><p>Noise in the signals</p></li><li><p>Gaming by merchandisers (“tag everything as luxury”)</p></li><li><p>Loss of explainability</p></li><li><p>Over-boosting</p></li></ul><p>As a starting point (to be confirmed by your own testing), we recommend:</p><ul><li><p>Approximately 5 cohorts per product.</p></li><li><p>Optionally, an offline validation step (ingest pipeline, CI script, or index-time check) that warns or blocks when more than 5 tags are assigned.</p></li></ul><h3>Customized cohort boosting per user</h3><p>So far, our examples assume every cohort contributes equally. In reality, some users strongly prefer certain segments. In some cases, you might know that certain cohorts are especially important for an individual user. For example:</p><ul><li><p>A user who almost always buys luxury brands.</p></li><li><p>A user who consistently picks budget options.</p></li></ul><p>You can encode this by assigning different weights per cohort instead of a flat 0.1. For example, if your application has detected a “super-luxury” shopper, then you could modify the function scoring as follows:</p>"functions": [
  { "filter": { "term": { "cohorts": "female" }},  "weight": 0.1 },
  { "filter": { "term": { "cohorts": "beauty" }},  "weight": 0.1 },
  { "filter": { "term": { "cohorts": "luxury" }},  "weight": 0.2 },
  { "weight": 1.0 }
]<p>In the above example matching “female” or “beauty” each add <code>+0.1</code> while matching luxury adds <code>+0.2</code>. In this example, a product matching all three cohorts would get:</p>boost = 1.0 + 0.1 + 0.1 + 0.2 = 1.4<p>This remains fully explainable, and you can document the configuration (“luxury is 2× as important as other cohorts for this user”). Additionally, the explain API will show exactly how those numbers contributed to the final score.</p><h2>Conclusion:</h2><p>This Elasticsearch-native approach to cohort personalization uses only lightweight metadata and standard query constructs, while preserving explainability, stability, and business control over the relevance model. This delivers precise, predictable relevance that ensures the business goals never sacrifice the quality of the search results.</p><h2>Implementation summary</h2><p>If you want to adopt this pattern in production, the high-level steps are:</p><ul><li><p>Add a single keyword field (cohorts) to each product containing 3–5 cohort tags.</p></li><li><p>Compute user/session cohorts in your application logic (from browsing, purchase history, CRM, etc.) and pass them with the query.</p></li><li><p>Inject dynamic function_score filters into your query with one per user cohort, and each with a small weight (e.g., 0.1), plus a baseline weight (1.0).</p></li><li><p>Wrap your existing BM25 query in function_score with score_mode: "sum" and boost_mode: "multiply" to apply multiplicative boosting.</p></li><li><p>Tune per-cohort weights (typically 0.05–0.20) based on A/B experiments, ensuring BM25 remains the dominant signal.</p></li></ul><p>These steps let you layer cohort personalization cleanly on top of your existing search relevance, without scripts, ML models, or major architecture changes.</p><h2>What’s next?</h2><p>This pattern is a powerful example of how to build sophisticated relevance rules directly into your queries, ensuring speed and reliability.</p><ol><li><p><strong>Implement custom personalization faster:</strong> If you're ready to deploy and optimize this advanced cohort personalization strategy, or to tackle other complex relevance challenges, our team can help you build, tune, and operationalize your Elasticsearch solution quickly. Contact <a href="https://www.elastic.co/consulting">Elastic Services</a> for help implementing this and other advanced search techniques.</p></li><li><p><strong>Join the discussion:</strong> For general questions about advanced relevance techniques and implementation, join the <a href="https://discuss.elastic.co/">broader Elastic Stack community</a> for search discussions.</p></li></ol>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta288b12a3e3fa2a8/6a170bda286714007393e33b/e88ddfa1b453327fe75211500b850b49ad3776f5-1172x844.png" length="0" type="image/png"/>
    <pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Building an AI agent for HR with Elastic Agent Builder and GPT-OSS]]></title>
    <description><![CDATA[Discover how to build an AI agent that can answer natural language queries about your employee HR data using Elastic Agent Builder and GPT-OSS.]]></description>
    <content:encoded><![CDATA[<h2>Introduction</h2><p>This article will show you how to build an AI agent for HR using <a href="https://openai.com/index/introducing-gpt-oss/">GPT-OSS</a> and Elastic Agent Builder. The agent can answer your questions without sending data to OpenAI, Anthropic, or any external service.</p><p>We’ll use LM Studio to serve GPT-OSS locally and connect it to Elastic Agent Builder.</p><p>By the end of this article, you’ll have a custom AI agent that can answer natural language questions about your employee data while maintaining full control over your information and model.</p><h2>Prerequisites</h2><p>For this article, you need:</p><ul><li><p><a href="https://www.elastic.co/cloud">Elastic Cloud</a> hosted 9.2, serverless or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">local</a> deployment</p></li><li><p>Machine with 32GB RAM recommended (minimum 16GB for GPT-OSS 20B)</p></li><li><p><a href="https://lmstudio.ai/">LM Studio</a> installed</p></li><li><p><a href="https://www.docker.com/products/docker-desktop/">Docker Desktop</a> Installed</p></li></ul><h2>Why use GPT-OSS?</h2><p>With a local LLM you have the control to deploy it in your own infrastructure and fine-tune it to fit your own needs. All this while maintaining control over the data that you share with the model, and of course, you don’t have to pay a license fee to an external provider.</p><p>OpenAI <a href="https://openai.com/index/introducing-gpt-oss/">released GPT-OSS</a> on August 5, 2025, as part of their commitment to the open model ecosystem.</p><p>The 20B parameter model offers:</p><ul><li><p><strong>Tool use capabilities</strong></p></li><li><p><strong>Efficient inference</strong></p></li><li><p><strong>OpenAI SDK compatible</strong></p></li><li><p><strong>Compatible with agentic workflows</strong></p></li></ul><p>Benchmark comparison:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt58fab956edb40412/6a170cfcb0367da43a72bd80/29160e3345352088e8213297630882f252b00c47-1600x680.png" alt="" /><h2>Solution architecture</h2><p>The architecture runs entirely on your local machine. Elastic (running in Docker) communicates directly with your local LLM through LM Studio, and the Elastic Agent Builder uses this connection to create custom AI agents that can query your employee data.</p><p>For more details, refer to this <a href="https://www.elastic.co/docs/solutions/observability/connect-to-own-local-llm">documentation</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt80db5bb0a797f51b/6a170cfd0e2e492f2c41a16f/a4a886750ff25fa8bb7aefc7448161e52cf73ed3-1600x896.png" alt="" /><h2>Building an AI agent for HR: Steps</h2><p>We’ll divide the implementation into 5 steps:</p><ol><li><p>Configure LM studio with a local model</p></li><li><p>Deploy Local Elastic with Docker</p></li><li><p>Create the OpenAI connector in Elastic</p></li><li><p>Upload employee data to Elasticsearch</p></li><li><p>Build and test your AI Agent</p></li></ol><h2>Step 1: Configure LM Studio with GPT-OSS 20B</h2><p>LM Studio is a user-friendly application that allows you to run large language models locally on your computer. It provides an OpenAI-compatible API server, making it easy to integrate with tools like Elastic without a complex setup process. For more details, refer to the <a href="https://lmstudio.ai/docs/app">LM Studio Docs</a>.</p><p>First, download and install <a href="https://lmstudio.ai/">LM Studio</a> from the official website. Once installed, open the application.</p><h3>In the LM Studio interface:</h3><ol><li><p>Go to the search tab and search for “GPT-OSS”</p></li><li><p>Select the <code>openai/gpt-oss-20b</code> from OpenAI</p></li><li><p>Click download</p></li></ol><p>The size of this model should be approximately <strong>12.10GB</strong>. The download may take a few minutes, depending on your internet connection.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2dc341a6625e34b7/6a170cff839dfa2eb4dcff44/5d01bc4dcb377b5259fc6b521fe2425a31b90ca4-1312x872.png" alt="" /><h4>Once the model is downloaded:</h4><ol><li><p>Go to the local server tab</p></li><li><p>Select the openai/gpt-oss-20b</p></li><li><p>Use the default port 1234</p></li><li><p>On the right panel, go to <strong>Load </strong>and set the Context Length to <strong>40K</strong> or higher</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3704ca1b28465cc4/6a170d00d7c022ed8fde64ef/e546033f916381647b876815b2c1f1ae2a08365f-326x337.png" alt="" /><p>5. Click start server</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b9170a4945ff857/6a170d0266c4f9ffadf8c0a6/28ee78a3caa84d14e04db3d42f30acbe4d4d005a-1312x872.png" alt="" /><p>You should see this if the server is running.</p>[LM STUDIO SERVER] Success! HTTP server listening on port 1234
[LM STUDIO SERVER] Supported endpoints:
[LM STUDIO SERVER] -&gt;	GET  http://localhost:1234/v1/models
[LM STUDIO SERVER] -&gt;	POST http://localhost:1234/v1/responses
[LM STUDIO SERVER] -&gt;	POST http://localhost:1234/v1/chat/completions
[LM STUDIO SERVER] -&gt;	POST http://localhost:1234/v1/completions
[LM STUDIO SERVER] -&gt;	POST http://localhost:1234/v1/embeddings
Server started.<h2>Step 2: Deploy Local Elastic with Docker</h2><p>Now we’ll set up Elasticsearch and Kibana locally using Docker. Elastic provides a convenient script that handles the entire setup process. For more details refer to the <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">official documentation</a>.</p><h3>Run the start-local script</h3><p>Execute the following command in your terminal:</p>curl -fsSL https://elastic.co/start-local | sh<p>This script will:</p><ul><li><p>Download and configure Elasticsearch and Kibana</p></li><li><p>Start both services using Docker Compose</p></li><li><p>Automatically activate a 30-day Platinum trial license</p></li></ul><h3>Expected output</h3><p>Just wait for the following message and save the password and API key shown; you’ll need them to access Kibana:</p>🎉 Congrats, Elasticsearch and Kibana are installed and running in Docker!
🌐 Open your browser at http://localhost:5601
   Username: elastic
   Password: KSUlOMNr
🔌 Elasticsearch API endpoint: http://localhost:9200
🔑 API key: cnJGX0pwb0JhOG00cmNJVklUNXg6cnNJdXZWMnM4bncwMllpQlFlUTlWdw==
Learn more at https://github.com/elastic/start-local<h3>Access Kibana</h3><p>Open your browser and navigate to:</p>http://localhost:5601<p>Log in using the credentials obtained in the terminal output.</p><h3>Enable Agent Builder</h3><p>Once logged in to Kibana, navigate to <strong>Management </strong>&gt;<strong> AI </strong>&gt;<strong> Agent Builder </strong>and activate the Agent Builder.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0a934bd99fa6a0ce/6a170d046234e019c3db1a5a/92e104cb846c20d875865ded8a3d37f5c7daae9b-1491x1528.png" alt="" /><h2>Step 3: Create the OpenAI connector in Elastic</h2><p>Now we’ll configure Elastic to use your local LLM.</p><h3>Access Connectors</h3><ol><li><p>In Kibana</p></li><li><p>Go to <strong>Project Settings</strong> &gt; <strong>Management</strong></p></li><li><p>Under <strong>Alerts and Insights</strong>, select <strong>Connectors</strong></p></li><li><p>Click Create Connector</p></li></ol><h3>Configure the connector</h3><p>Select <strong>OpenAI</strong> from the list of connectors. LM Studio uses the OpenAI SDK, making it compatible.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt762023c39781eb78/6a170d06a29299a59ed01087/5ac87042e086c7a2bd47a8039e646ec831f0dcc6-923x974.png" alt="" /><p>Fill in the fields with these values:</p><ul><li><p><strong>Connector name: </strong>LM Studio - GPT-OSS 20B</p></li><li><p><strong>Select an OpenAI provider: </strong>Other (OpenAI Compatible Service)</p></li><li><p><strong>URL: </strong><code>http://host.docker.internal:1234/v1/chat/completions</code></p></li><li><p><strong>Default model: </strong>openai/gpt-oss-20b</p></li><li><p><strong>API Key:</strong> testkey-123 (any text works, because LM Studio Server doesn't require authentication.)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt980e595f80e2be2e/6a170d086f7f0468a19148cc/2084ac32fcf1fb810c8b54ecab1c85a1e3e8905b-672x1302.png" alt="" /><p>To finish the configuration, click <strong>Save &amp; test</strong>.</p><p><strong>Important:</strong> Toggle ON the “<strong>Enable native function calling</strong>”; this is required for the Agent Builder to work properly. If you don’t enable this, you’ll get a <strong><code>No tool calls found in the response</code></strong> error.</p><h3>Test the connection</h3><p>Elastic should automatically test the connection. If everything is configured correctly, you’ll see a success message like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d2e815dd558f881/6a170d090e2e49076541a177/f567d767f1969c4730c1daa92f651789dc3742ac-1042x812.png" alt="" /><p>Response:</p>{
  "status": "ok",
  "data": {
    "id": "chatcmpl-flj9h0hy4wcx4bfson00an",
    "object": "chat.completion",
    "created": 1761189456,
    "model": "openai/gpt-oss-20b",
    "choices": [
      {
        "index": 0,
        "message": {
          "role": "assistant",
          "content": "Hello! 👋 How can I assist you today?",
          "reasoning": "Just greet.",
          "tool_calls": []
        },
        "logprobs": null,
        "finish_reason": "stop"
      }
    ],
    "usage": {
      "prompt_tokens": 69,
      "completion_tokens": 23,
      "total_tokens": 92
    },
    "stats": {},
    "system_fingerprint": "openai/gpt-oss-20b"
  },
  "actionId": "ee1c3aaf-bad0-4ada-8149-118f52dad757"
}<h2>Step 4: Upload employee data to Elasticsearch</h2><p>Now we’ll upload the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/gpt-oss-with-elasticsearch/hr-employees-bulk.json">HR employee dataset</a> to demonstrate how the agent works with sensitive data. I generated a fictional dataset with this structure.</p><h3>Dataset structure</h3>{
  "employee_id": "0f4dce68-2a09-4cb1-b2af-6bcb4821539b",
  "full_name": "Daffi Stiebler",
  "email": "lscutchings0@huffingtonpost.com",
  "date_of_birth": "1975-06-20T15:39:36Z",
  "hire_date": "2025-07-28T00:10:45Z",
  "job_title": "Physical Therapy Assistant",
  "department": "HR",
  "salary": "108455",
  "performance_rating": "Needs Improvement",
  "years_of_experience": 2,
  "skills": "Java",
  "education_level": "Master's Degree",
  "manager": "Carl MacGibbon",
  "emergency_contact": "Leigha Scutchings",
  "home_address": "5571 6th Park"
}<h3>Create the index with mappings</h3><p>First, create the index with proper mappings. Note that we’re using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">semantic_text</a> fields for some key fields; this enables semantic search capabilities for our index.</p>​​PUT hr-employees
{
  "mappings": {
    "properties": {
      "@timestamp": {
        "type": "date"
      },
      "employee_id": {
        "type": "keyword"
      },
      "full_name": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "email": {
        "type": "keyword"
      },
      "date_of_birth": {
        "type": "date",
        "format": "iso8601"
      },
      "hire_date": {
        "type": "date",
        "format": "iso8601"
      },
      "job_title": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "department": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "salary": {
        "type": "double"
      },
      "performance_rating": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "years_of_experience": {
        "type": "long"
      },
      "skills": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "education_level": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "manager": {
        "type": "text",
        "copy_to": "employee_semantic"
      },
      "emergency_contact": {
        "type": "keyword"
      },
      "home_address": {
        "type": "keyword"
      },
      "employee_semantic": {
        "type": "semantic_text"
      }
    }
  }
}<h3>Index with Bulk API</h3><p>Copy and paste the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/gpt-oss-with-elasticsearch/hr-employees-bulk.json">dataset</a> into your Dev Tools in Kibana and execute it:</p>POST hr-employees/_bulk
{"index": {}}
{"employee_id": "57728b91-e5d7-4fa8-954a-2384040d3886", "full_name": "Filide Gane", "email": "vhallahan1@booking.com", "job_title": "Business Systems Development Analyst", "department": "Marketing", "salary": "$52330.27", "performance_rating": "Meets Expectations", "years_of_experience": 12, "skills": "Java", "education_level": "Bachelor's Degree", "date_of_birth": "2000-02-07T16:49:32Z", "hire_date": "2023-11-07T13:03:16Z", "manager": "Freedman Kings", "emergency_contact": "Vilhelmina Hallahan", "home_address": "75 Dennis Junction"}
{"index": {}}
{"employee_id": "...", ...}<h3>Verify the data</h3><p>Run a query to verify:</p>GET hr-employees/_search<h2>Step 5: Build and test your AI agent</h2><p>With everything configured, it’s time to build a custom AI agent using Elastic Agent Builder. For more details refer to the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/get-started">Elastic documentation</a>.</p><h3>Add the connector</h3><p>Before we can create our new agent, we have to set our Agent builder to use our custom connector called <code>LM Studio - GPT-OSS 20B</code> because the default one is the <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/elastic-managed-llm">Elastic Managed LLM</a>. For that, we need to go to <strong>Project Setting</strong> &gt; <strong>Management</strong> &gt; <strong>GenAI Settings</strong>; now we select the one we created and click <strong>Save</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc42f079c5e756057/6a170d0acf4f2501d9b2d1c7/11e830c3e2fb4c298b020c928fa5422f3397ba08-1600x1152.png" alt="" /><h3>Access Agent Builder</h3><ol><li><p>Go to <strong>Agents</strong></p></li><li><p>Click on <strong>Create a new agent</strong></p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb8e734817c5a7c6a/6a170d0ca929cf867cae0a34/c1e60541563650163f972ac9088dc1ed1de759a7-1600x1054.png" alt="" /><h3>Configure the agent</h3><p>To create a new agent, the required fields are the <strong>Agent ID</strong>, <strong>Display Name</strong>, and <strong>Display Instructions</strong>.</p><p>But there are more customization options, like the Custom Instructions that guide how your agent is going to behave and interact with your tools, similar to a system prompt, but for our custom agent. Labels help organize your agents, avatar color, and avatar symbol.</p><p>The ones that I chose for our agent based on the dataset are:

<strong>Agent ID:</strong> <code>hr_assistant</code></p><p><strong>Custom instructions:</strong></p>You are an HR Analytics Assistant that helps answer questions about employee data.
When responding to queries:
- Provide clear, concise answers
- Include relevant employee details (name, department, salary, skills)
- Format monetary values with currency symbols
- Be professional and maintain data confidentiality<p>
Labels: <code>Human Resources</code> and <code>GPT-OSS</code></p><p>Display name: <code>HR Analytics Assistant</code></p><p>Display description:</p>A specialized AI assistant for Human Resources that helps analyze employee data, compensation, performance metrics, and talent management. Ask questions about employees, departments, salaries, or performance analytics.<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt23fb011e5b4f4d49/6a170d0e7d8d67f47a70e77f/f94bb2bf08497e5e756ca76b30a3a51f42927756-1424x1217.png" alt="" /><p>With all the data in there, we can click on <strong>Save</strong> our new agent.</p><h3>Test the agent</h3><p>Now you can ask natural language questions about your employee data, and GPT-OSS 20B will understand the intent and generate an appropriate response.</p><h4>Prompt:</h4>Which employee is the one with the highest salary in the hr-employees index?<h4>Answer:</h4><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc0c52faacf63b583/6a170d0f0e2e497bfd41a17b/94ad19f80b96304028a59f60beca51dfc9aecc8a-899x631.png" alt="" /><p>The Agent process was:</p><p>1. Understand your question using the GPT-OSS connector</p><p>2. Generate the appropriate Elasticsearch query (using the built-in tools or custom <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a>)</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte32a8a7e6363c7f2/6a170d115091680077e1bb44/6f2961d0d1b97475f6dda300acee84da540938e6-844x466.png" alt="" /><p>3. Retrieve matching employee records</p><p>4. Present results in natural language with proper formatting</p><p>Unlike traditional lexical search, the agent powered by GPT-OSS understands intent and context, making it easier to find information without knowing exact field names or query syntax. For more details on the agent's thinking process, refer to this <a href="https://www.elastic.co/search-labs/blog/ai-agent-builder-experiments-performance">article</a>.</p><h2>Conclusion</h2><p>In this article, we built a custom AI agent using Elastic’s Agent Builder to connect to the OpenAI GPT-OSS model running locally. By deploying both Elastic and the LLM on your local machine, this architecture allows you to leverage generative AI capabilities while maintaining full control over your data, all without sending information to external services.</p><p>We used GPT-OSS 20B as an experiment, but the officially recommended models for Elastic Agent Builder are referenced <a href="https://www.elastic.co/docs/solutions/search/agent-builder/models#recommended-models">here</a>. If you need more advanced reasoning capabilities, there's also the <a href="https://huggingface.co/openai/gpt-oss-120b">120B parameter variant</a> that performs better for complex scenarios, though it requires a higher-spec machine to run locally. For more details, refer to the <a href="https://openai.com/open-models/">official OpenAI documentation</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/build-an-ai-agent-hr-elastic-agent-builder-gpt-oss</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/build-an-ai-agent-hr-elastic-agent-builder-gpt-oss</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt664f490053e46e6b/6a170d13b0367d2d7e72bd84/05d2d0513fff67d975f9223d75108aa9f50646bc-1600x914.png" length="0" type="image/png"/>
    <pubDate>Wed, 26 Nov 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Getting started with Elastic Agent Builder and Microsoft Agent Framework]]></title>
    <description><![CDATA[Walk through the complete process of creating an agent with Elastic Agent Builder and then explore how to use the agent via the A2A protocol orchestrated with the Microsoft Agent Framework.]]></description>
    <content:encoded><![CDATA[<p>Elastic <a href="https://www.elastic.co/blog/whats-new-elastic-9-2-0">9.2</a> was recently released and includes a new feature called <a href="https://www.elastic.co/elasticsearch/agent-builder">Agent Builder</a>. It enables developers to quickly create AI agents and tools powered by data stored in Elasticsearch. Any tools or agents you create in Agent Builder can be utilized immediately within your own custom AI apps.</p><p>In this blog post we’ll walk through all the steps to use Elastic Agent Builder to create an agent. Then we’ll walk through the process of running an example Python app that uses the Microsoft Agent Framework to orchestrate your Elastic agent.</p><h2>Create an Elastic Serverless project</h2><p>To use Agent Builder you need an Elastic deployment or an Elastic serverless project, so let’s begin by creating an Elastic serverless project. Go to <a href="https://cloud.elastic.co/registration">Elastic Cloud</a> and create a new Elasticsearch Serverless project.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt26ec5e33540a6a05/6a170c6c67045b1ffb45c23a/05da6b45ca88b70181028f394bdcc2c289ca68da-1677x952.gif" alt="elastic-agent-builder-gif" /><h2>Create an index and add data</h2><p>Now that we’ve got an Elastic project, let’s create an index, which is what Elasticsearch uses to store data. Open Developer Tools in Elastic Cloud where we can run a command to create an index.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc53e2236abb894aa/6a170c6d66c4f9dbaaf8c082/ac31098e0d557c7758f180d497b86c90ff50cf66-1976x1099.png" alt="elastic-agent-builder-add-data" /><p>Copy the following PUT command which creates an index named <em>my-docs </em>containing a mixture of fields, and our content leveraging <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">semantic search</a>.</p><p></p>PUT /my-docs
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "content": { 
        "type": "semantic_text"
      },
      "filename": { "type": "keyword" },
      "last_modified": { "type": "date" }
    }
  }
}<p>Paste the PUT command into the input area of the Developer Tools console. Hover your mouse over the command in the console and then click the <strong>Run</strong> button to execute the command.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8393cfc3184ed7f9/6a170c6f4a531b8e2436a9a5/e7f426fd9a5ad6909f81af1246fe84726b7d6596-1980x1103.png" alt="elastic-agent-builder-send-request" /><p>The next step is to add some data to the <em>my-docs</em> index that you just created. Copy and paste the following command into the Develop Tools console.</p>PUT /my-docs/_doc/greetings-md
{
  "title": "Greetings",
  "content": "
# Greetings

## Basic Greeting
Hello!

## Helloworld Greeting
Hello World! 🌎

## Not Greeting
I'm only a greeting agent. 🤷

",
  "filename": "greetings.md",
  "last_modified": "2025-11-04T12:00:00Z"
}<p>Click the command’s <strong>Run </strong>button to execute the command which will add a document to the <em><code>my-docs</code></em> index.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfca5c697878a7669/6a170c71c1e8a5fa58f88308/a8224c4379c88cfb720cb110d13b1c3c27291fc3-1999x1247.png" alt="elastic-agent-builder-run" /><p>As you can see, the command above adds a document named <em>greetings.md</em> that includes the contents of different potential types of greeting responses.</p><p>Now that we’ve got some data in an Elastic index, let’s get a confirmation of what data we have to work with. Using the power of the built-in Elastic AI Agent that is enabled by default in Agent Builder, you can now have a chat about your data. Select <strong>Agents</strong> in the navigation menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt12c35b2ef94e2d50/6a170c734a531b34fc36a9a9/5f2ab858f9cb73c40b6ca70c8da60f6d7417db74-1970x1266.png" alt="elastic-agent-builder-agents" /><p>Then simply ask, “What data do I have?”</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4469224308944d2c/6a170c74a929cf8e3cae0a0e/4311fb41a114e932ceacb4d6bb535263cad57479-1708x938.gif" alt="elastic-agent-builder-gif-data" /><p>The default Elastic AI Agent provides a nice summary of the data currently stored in Elastic.</p><h2>Create a tool</h2><p>The next step on this walkthrough journey is to create an agent that can utilize the data stored in Elastic.</p><p>As you’ve seen the default agent in Elastic Agent builder is already useful for chatting with your data but to really give agents custom powers, they need access to tools via the <a href="https://modelcontextprotocol.io/docs/getting-started/intro">Model Context Protocol</a> (MCP). Agent Builder has fully featured tool creation and management functionality that you can use to quickly create custom MCP tools that are hosted right in the same scalable Elastic project as your data.</p><p>Let’s create a tool in Elastic Agent Builder that can access the data now stored in Elastic. Click <strong>+ New</strong> to start a new chat.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltea95e23f23f46050/6a170c76e8fbceb11f39fc9d/f5af8fdb8738b07eceba130e49fdf97478d65646-1636x414.png" alt="elastic-agent-builder" /><p>Then click on <strong>Manage tools</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09343b252bf64c54/6a170c787d8d675a9e70e766/b8a29be0d6c8fa07deb2c523585c3a6bc67153b0-1999x992.png" alt="elastic-agent-builder-manage-tools" /><p>Click the <strong>+ New tool</strong> button.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd076c54cbb46af32/6a170c7a2b835f80caf4b254/92a91e94cfa071b66761aaa81e48f7b3962cdaea-1970x1128.png" alt="elastic-agent-builder-new-tool" /><p>In the <strong>Create Tool</strong> form, select the <strong>ES|QL </strong>as the tool <strong>Type</strong> and enter the following values.</p><p>For <strong>Tool ID</strong>:</p>example.get_greetings<p>For <strong>Description</strong>:</p>Get greetings doc from Elasticsearch my_docs index.<p>For <strong>Configuration </strong>enter the following query into the <strong>ES|QL Query </strong>text area:</p><p>Your completed <strong>Create a new tool</strong> form should look like the following completed form. Click <strong>Save</strong> to create the tool.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb9361be43f00ef0d/6a170c7c1949f7784ee7aa7f/51698c74174fd6963101eebb7ebe720902209eb5-1406x1271.png" alt="elastic-agent-builder-create-tool" /><h2>Create an Agent and assign it a tool</h2><p>Ah! There’s that feeling of having a new tool and being ready to use it. Agents need tools to give them special abilities beyond what general LLMs can provide and we’ve now got a brand new tool. Let’s create an agent that can put our tool to good use. Select <strong>Agents</strong> in the navigation menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1516be4466ab7b94/6a170c7dc1e8a514e5f8830c/f6770cbf2047fed5a5827bfa9f24a3489a1f7deb-1400x500.png" alt="elastic-agent-builder-tools" /><p>Click <strong>Create a new agent</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfdc817e7fe832623/6a170c7f286714389293e359/8fe9bbb5296118c3c044aa943a08f9ec78c82173-1400x763.png" alt="elastic-agent-builder-create-agent" /><p>Based on the name of the tool and the data it’s accessing, you’ve probably already guessed that we’re going to be creating a greeting agent and you’re right! Let’s create a Hello World agent right now.</p><p>In the <strong>New Agent</strong> form, enter the following values.</p><p>For <strong>Agent ID </strong>enter the text:</p>helloworld_agent<p>In the <strong>Custom Instructions </strong>text area enter the following instructions:</p>If the prompt contains greeting text like "Hi" or "Hello" then respond with only the Basic Hello text from your documents.

If the prompt contains the text “Hello World” then respond with only the Hello World text from your documents.

In all other cases where the prompt does not contain greeting words, then respond with only the Not Greeting text from your documents.<p>For <strong>Display name </strong>enter the text:</p>HelloWorld Agent<p>For the <strong>Display description </strong>enter the text:</p>An agent that responds to greetings.<p>Your completed <strong>New Agent</strong> form should look like the following completed form. The next step is to assign the agent the tool we created in the previous step. Click the <strong>Tools </strong>tab.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt116ae96fcd2183a1/6a170c81a929cf7c3cae0a12/e189613957fa86016710764e665a2cd11e98d401-1400x1303.png" alt="elastic-agent-builder-agent-tools" /><p>Select only the <em><code>example.get_greetings</code></em> tool that we created previously. Unselect all the other available tools. This will configure the agent being created to only have access to the tool we’ve created.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltef927cf26297de7c/6a170c83d7c022ee16de64d1/5e1f95fa27afe30c402da5ffde385774e1bc8b5a-1999x1550.png" alt="elastic-agent-builder-example-tool" /><p>Click <strong>Save</strong> to create the agent.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltefbde78961805855/6a170c848b73cbcb2118a080/20e7e03a8e749597f612e0f0087ff2541b3cb930-1999x545.png" alt="elastic-agent-builder-save-new-agent" /><p>You’ll be taken to the Agents list where you can see that the new HelloWorld Agent has been created.We can quickly test out our new agent right inside Agent Builder. Select <strong>Agents</strong> in the navigation menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc210a95ca0a13a0e/6a170c8650916808b3e1bb26/e16a5ab26003c2e5b5fc8516427bd2f9b9737a12-1999x704.png" alt="elastic-agent-builder-agent-list" /><p>Select the <strong>HelloWorld Agent</strong> from the Agent Chat agent selector. Enter the prompt “hello world” and you should get back the Hello World text from the <em><code>greetings.md</code></em> document stored in the <em><code>my-docs</code></em> Elastic index.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69eda206b8d87e06/6a170c87dc55de1414e00e38/2cc98878450a7d3b26ebbfbcc5f9b841b8971c41-1191x566.gif" alt="elastic-agent-builder-gif-hello-world" /><p>Well done. Now that we know our agent is working as expected, let’s explore the immediate development benefit that you get with tools and agents created in Agent Builder. Any tools you create in Agent Builder are usable via MCP by any agent-building platform that supports MCP. Also, any agents you create in Agent Builder are available for use in any agent-building platform that supports the <a href="https://a2a-protocol.org">AgentToAgent</a> (A2A) protocol.</p><h2>Microsoft Agent Framework</h2><p>If you’re interested in trying out new Agent development tools, then there’s a recently announced open-source development kit called the <a href="https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview">Microsoft Agent Framework</a> that you should definitely try out for yourself. The Agent Framework allows you to use the A2A protocol to orchestrate agentic apps that can combine multiple agents running on different hosts to enable solutions that aren’t possible with only a generic GenAI Large Language Model. The Agent Framework is available in Python and C#. Let’s see how we can use the Python-based Agent Framework to call the custom Elastic Agent we just created.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4eeacb3bf7e3256d/6a170c89286714037193e35f/6428e470f3323c2a88c20e126969939a7b616a83-1844x414.png" alt="microsoft-agent-framework" /><h2>Getting started with the Agent Framework in Python</h2><p>Let’s run some code! On your local computer open <a href="https://code.visualstudio.com/download">Visual Studio Code</a> and open a new terminal.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9455a01413b8c277/6a170c8b0c4857291201aad5/11f2ea916bf277e39c98701c8d31e251fcdf6a8b-956x571.png" alt="new-terminal" /><p>In the open terminal, clone the Elastic Search Labs source code repository which contains the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/agent-builder-a2a-agent-framework">Elastic Agent Builder A2A example app</a>.</p>git clone https://github.com/elastic/elasticsearch-labs<p>In the terminal, cd to change directory to elasticsearch-labs.</p>cd elasticsearch-labs<p>In the terminal, enter the following command to open the current folder in the Visual Studio Code editor.</p>code .<p>In the Visual Studio File Explorer, expand the <code>supporting-blog-content</code> and <code>agent-builder-a2a-agent-framework</code> folders and then open the file named <em>elastic_agent_builder_a2a.py</em> in the text editor.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt38aafb5bbf2b4274/6a170c8dd7c0222ecede64d5/ae1f173f953e6d805cdc2b5ab756c229b3b31793-1428x1044.png" alt="agent-builder-code" /><p>Here’s the contents of <em>elastic_agent_builder_a2a.py </em>that you should see in your text editor:</p>import asyncio
from dotenv import load_dotenv
import httpx
import os
from a2a.client import A2ACardResolver
from agent_framework.a2a import A2AAgent


async def main():
    load_dotenv()
    a2a_agent_host = os.getenv("ES_AGENT_URL")
    a2a_agent_key = os.getenv("ES_API_KEY")

    print(f"Connection to Elastic A2A agent at: {a2a_agent_host}")

    custom_headers = {"Authorization": f"ApiKey {a2a_agent_key}"}

    async with httpx.AsyncClient(timeout=60.0, headers=custom_headers) as http_client:
        # Resolve the A2A Agent Card
        resolver = A2ACardResolver(httpx_client=http_client, base_url=a2a_agent_host)
        agent_card = await resolver.get_agent_card(
            relative_card_path="/helloworld_agent.json"
        )
        print(f"Found Agent: {agent_card.name} - {agent_card.description}")

        # Use the Agent
        agent = A2AAgent(
            name=agent_card.name,
            description=agent_card.description,
            agent_card=agent_card,
            url=a2a_agent_host,
            http_client=http_client,
        )
        prompt = input("Enter Greeting &gt;&gt;&gt; ")
        print("\nSending message to Elastic A2A agent...")
        response = await agent.run(prompt)
        print("\nAgent Response:")
        for message in response.messages:
            print(message.text)


if __name__ == "__main__":
    asyncio.run(main())<p>The code within the main() method demonstrates how to control your Elastic Agent Builder agent using the Agent Framework. It creates an <code>http_client</code> using a URL and API key for the agent which you’ll provide from your Elastic project. Then the Agent Framework’s A2ACardResolver is called with that <code>http_client</code> to get your agent’s A2A agent card based on the <code>relative_card_path</code> of “<code>/helloworld_agent.json</code>” to reference your agent’s <strong>Agent ID </strong>which is “helloworld_agent”. The code then uses the Agent Framework to invoke your agent with the A2A agent card. The final part of the main() method prompts the user of the app for input of a “greeting” and then sends the user input as a prompt to your agent. Based on the instructions and tools specified when you created your agent, the agent’s response is displayed to the app user.</p><h2>Setting your agent URL and API Key as environment variables</h2><p>Make a copy of the file <em>env.example</em> and name the new file <em>.env</em> Edit the newly created <em>.env</em> file to set the values of the environment variables to use specific values copied from your Elastic project.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc8d702407ed2eaec/6a170c8fa6c2b9798be79751/6420978cf4e3edd5ff148dca47554995da3e3f22-1428x603.png" alt="" /><p>First we’ll replace <strong>&lt;YOUR-ELASTIC-AGENT-BUILDER-URL&gt;</strong> with the Agent URL path that you can copy from your Elastic project’s Agent Builder - Tools page. Back in Elastic Agent Builder click <strong>Agents </strong>in the navigation menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09a8b629754c9cae/6a170c90e8fbce3f4739fca6/530ebafbc6327f24cb1a94b7c94d205339db6d28-1191x321.png" alt="" /><p>Select <strong>Manage tools</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09343b252bf64c54/6a170c787d8d675a9e70e766/b8a29be0d6c8fa07deb2c523585c3a6bc67153b0-1999x992.png" alt="" /><p>Click the <strong>MCP Server</strong> dropdown at the top of the Tools page. Select <strong>Copy MCP Server URL.</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69a60e413e418922/6a170c9260084b20cf3c45ae/3a9d5bf730b9541013db2b72601202d6a76e15f1-1977x1002.png" alt="" /><p>Back in Visual Studio Code, within the <em>.env file</em>, find where the placeholder text “<strong>&lt;YOUR-ELASTIC-AGENT-BUILDER-URL&gt;</strong>” appears and paste in the copied <strong>MCP Server URL </strong>to replace the placeholder text. Now edit the pasted <strong>MCP Server URL</strong>. Delete the text “mcp” at the end of the URL and replace it with the text “a2a”. The edited URL should look something like this:</p>https://example-project-a123.kb.westus2.azure.elastic.cloud/api/agent_builder/a2a<p>The next placeholder text to replace in the <em>.env</em> file is <strong>&lt;YOUR-ELASTIC-API-KEY&gt;.</strong> We’ll replace it with an actual API Key from your Elastic project. Back in your Elastic project, click <strong>Elasticsearch</strong> in the navigation menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta81babf678fe00c8/6a170c946234e0dd7edb1a32/11696609b354e75aa8110987b0d476634ac6b322-1965x663.png" alt="" /><p>Click <strong>Create API key</strong> to create a new API key.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b6476fdcb6d6715/6a170c966f7f04b6479148a6/67b15b2db4e43d6abe3ec42f3e8692f953aa4731-1995x1038.png" alt="" /><p>Enter a <strong>Name</strong> for the API key and click <strong>Create API key</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9f36eefdc509494/6a170c98a929cf56c8ae0a16/5e49445a77dc9730dd967f2aa8f8f11f4911eb41-1999x1076.png" alt="" /><p>Click the <strong>copy</strong> button to copy the API key.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5b99693a8a71414d/6a170c9aab7f082955db9ee3/f5b6993bea36493cf5d80867df607a71c2c24bcf-1971x1038.png" alt="" /><p>Back in Visual Studio Code, within the <em>.env</em> file , find where the placeholder text “<strong>&lt;YOUR-ELASTIC-API-KEY&gt;</strong>” appears and paste in the copied API Keyvalueto replace the placeholder text.</p><p>Now we can save the changes we’ve made to the <em>.env</em> file. The edited file should look something like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1fa4f6a82f1d6656/6a170c9ba2929993a6d0107a/91636964285003a3dda01ce43214b74c33492393-1428x601.png" alt="" /><h2>Run the example app</h2><p>It’s time to run the code. To do so, open a new terminal in Visual Studio Code. Click the <strong>Terminal</strong> top level menu and select <strong>New Terminal</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9455a01413b8c277/6a170c8b0c4857291201aad5/11f2ea916bf277e39c98701c8d31e251fcdf6a8b-956x571.png" alt="" /><p>In the new terminal, <code>cd</code> to change directory to the directory containing the agent-<code>builder-a2a-agent-framework</code> example app.</p>cd elasticsearch-labs/supporting-blog-content/agent-builder-a2a-agent-framework<p>In the terminal, create a Python virtual environment by running the following code.</p>python -m venv .venv<p>Activate the virtual environment by running the following command (based on your operating system) in the terminal window:</p><ul><li><p>If you’re running MacOS or Linux, the command to activate the virtual environment is:</p></li></ul>source .venv/bin/activate<ul><li><p>If you’re on Windows, the command to activate the virtual environment is:</p></li></ul>.venv\Scripts\activate<p>The code in the <em>elastic_agent_builder_a2a.py</em> file is powered by the Microsoft Agent Framework and we still need to install it, so let's do that now. Run the following <em>pip</em> command to install the Python based Agent Framework along with its necessary Python packages:</p>pip install -r requirements.txt<p>Hurray! Everything is now in its right place. It’s time for the good feeling fireworks…let’s run it. Run the example code by entering the following command into the terminal:</p>python elastic_agent_builder_a2a.py<p>You should see the agent framework connect to the Elastic Agent. When prompted for a greeting, enter “hello world”. You should see the HelloWorld Agent’s response → Hello World! 🌎</p><p>Top-notch work!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt725fda509b411bf7/6a170c9dab7f0862ffdb9ee7/f77903e5bcaa0f52bed80d5c8ea23e7c538561d6-1703x1027.gif" alt="" /><p>Building agents and connecting them to tools in Agent Builder gets you immediate operability with the latest agent development platforms like the Microsoft Agent Framework. You now know how to create an Elastic agent and put it to use as a scalable relevant data source, ready to provide custom context to all the AI apps you’ll be building next.</p><p>Try <a href="https://cloud.elastic.co/registration?utm_source=agentic-ai-category&amp;utm_medium=search-labs&amp;utm_campaign=agent-builder">Elastic</a> for free and build some agents today!</p><p>
</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/agent-builder-a2a-with-agent-framework</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-a2a-with-agent-framework</guid>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Jonathan Simon]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt725fda509b411bf7/6a170c9dab7f0862ffdb9ee7/f77903e5bcaa0f52bed80d5c8ea23e7c538561d6-1703x1027.gif" length="0" type="image/gif"/>
    <pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Parsing JSON fields in Elasticsearch]]></title>
    <description><![CDATA[Learn how to parse JSON fields in Elasticsearch using an ingest pipeline to efficiently index, query, and aggregate JSON data.]]></description>
    <content:encoded><![CDATA[<p>In this article, we will discuss how to parse JSON fields in Elasticsearch, which is a common requirement when dealing with log data or other structured data formats. We will cover the following topics:</p><ol><li><p>Ingesting JSON data into Elasticsearch</p></li><li><p>Using an Ingest Pipeline to parse JSON fields</p></li><li><p>Querying and aggregating JSON fields</p></li></ol><h2>1. Ingesting JSON data into Elasticsearch</h2><p>When ingesting JSON data into Elasticsearch, it is essential to ensure that the data is properly formatted and structured. Elasticsearch can automatically detect and map JSON fields, but it is recommended to define an explicit mapping for better control over the indexing process.</p><p>To create an index with a custom mapping, you can use the following API call:</p>PUT /my_index
{
 "mappings": {
   "properties": {
     "message": {
       "type": "keyword"
     },
     "json_field": {
       "properties": {
         "field1": {
           "type": "keyword"
         },
         "field2": {
           "type": "integer"
         }
       }
     }
   }
 }
}<p>In this example, we create an index called <code>my_index</code> with a custom mapping for a JSON field named <code>json_field</code>.</p><h2>2. Using an Ingest Pipeline to parse JSON fields</h2><p>If your JSON data is stored as a string within a field, you can use the Ingest Pipeline feature in Elasticsearch to parse the JSON string and extract the relevant fields. The Ingest Pipeline provides a set of built-in processors, including the <code>json</code> processor, which can be used to parse JSON data.</p><p>To create an ingest pipeline with the <code>json</code> processor, use the following API call:</p>PUT _ingest/pipeline/json_parser
{
 "description": "Parse JSON field",
 "processors": [
   {
     "json": {
       "field": "message",
       "target_field": "json_field"
     }
   }
 ]
}<p>In this example, we create an ingest pipeline called <code>json_parser</code> that parses the JSON string stored in the <code>message</code> field and stores the resulting JSON object in a new field called <code>json_field</code>.</p><p>To index a document using this pipeline, use the following API call:</p>POST /my_index/_doc?pipeline=json_parser
{
 "message": "{\"field1\": \"value1\", \"field2\": 42}"
}<p>The document will be indexed with the parsed JSON fields:</p>{
 "_index": "my_index",
 "_type": "_doc",
 "_id": "1",
 "_source": {
   "message": "{\"field1\": \"value1\", \"field2\": 42}",
   "json_field": {
     "field1": "value1",
     "field2": 42
   }
 }
}<h2>3. Querying and aggregating JSON fields</h2><p>Once the JSON fields are indexed, you can query and aggregate them using the Elasticsearch Query DSL. For example, to search for documents with a specific value in the <code>field1</code> subfield, you can use the following query:</p>POST /my_index/_search
{
 "query": {
       "term": {
         "json_field.field1": "value1"
       }
 }
}<p>To aggregate the values of the <code>field2</code> subfield, you can use the following aggregation:</p>POST /my_index/_search
{
 "size": 0,
 "aggs": {
   "field2_sum": {
         "sum": {
           "field": "json_field.field2"
         }
 }
}<h2>Bonus: How to deal with unparsed JSON data?</h2><p>If you are in the situation where you have already ingested unparsed JSON data into a text/keyword field, there’s a way to extract the JSON data without having to reindex everything from scratch.</p><p>You can leverage the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-update-by-query#operation-update-by-query-pipeline">Update by Query API</a> with the ingest pipeline developed in section 2. But before running that update, you’ll first need to update your index mapping similarly to what we did in section 1 to add the <code>json_field</code> mapping, by running the command below:</p>PUT /my_index/_mapping
{
  "properties": {
    "json_field": {
      "properties": {
        "field1": {
          "type": "keyword"
        },
        "field2": {
          "type": "integer"
        }
      }
    }
  }
}<p>When done, you can simply run the command below, which will iterate over all documents in your index, extract the JSON from the <code>message</code> field and index the parsed JSON data into the <code>json_field</code> object.</p>POST /my_index/_update_by_query?pipeline=json_parser<h2>Conclusion</h2><p>In conclusion, parsing JSON fields in Elasticsearch can be achieved using custom mappings, the Ingest Pipeline feature, and the Elasticsearch Query DSL. By following these steps, you can efficiently index, query, and aggregate JSON data in your Elasticsearch cluster.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-parse-json-field-ingest-pipeline</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-parse-json-field-ingest-pipeline</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Valentin Crettaz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6d8bd040a15da207/6a17e422414c640e67945116/ef9edded97edd7c919e617e648e62016155cde56-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 17 Oct 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Training LTR models in Elasticsearch with judgement lists based on user behavior data]]></title>
    <description><![CDATA[Learn how to use UBI data to create judgment lists to automate the training of your Learning to Rank (LTR) models in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>A big challenge when using <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr"><em><strong>Learning-to-rank</strong></em></a> models is to create a high-quality <a href="https://www.elastic.co/search-labs/blog/judgment-lists"><em><strong>judgment list</strong></em></a> to train the model on. Traditionally, this process involves a <em><strong>manual</strong></em> evaluation of query-document relevance to assign a grade to each one. This is a slow process that does not scale well and is hard to maintain (imagine having to update a list with hundreds of entries by hand).</p><p>Now, what if we could use real user interactions with our search application to create this training data? Using <a href="https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-insights"><em><strong>UBI</strong></em></a> data lets us do just that. Creating an automatic system that can capture and use our searches, clicks, and other interactions to generate a judgment list. This process can scale and be repeated far more easily than a manual interaction and would tend to yield better results. In this blog, we will explore how we can query UBI data stored in Elasticsearch to calculate meaningful signals to generate a training dataset for an <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction"><em><strong>LTR</strong></em></a> model.</p><p><em><strong>You can find the full experiment </strong></em><a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog.git"><em><strong>here</strong></em></a><em><strong>.</strong></em></p><h2>Why UBI data can be useful to train your LTR model</h2><p>UBI data offers several advantages over a manual annotation:</p><ul><li><p><strong>Volume:</strong> Given that UBI data comes from real interactions, we can collect much more data than we can generate manually. This is assuming we have enough traffic to generate this data, of course.</p></li><li><p><strong>Real User intent:</strong> Traditionally, a manual judgment list comes from an expert evaluation of the available data. On the other hand, UBI data reflects real user behavior. This means we can generate better training data that will improve our search system's accuracy, because it's based on how users actually interact with and find value in your content rather than theoretical assumptions about what should be relevant.</p></li><li><p><strong>Continuous updates:</strong> Judgment lists need to be refreshed over time. If we create them from UBI data, we can have current data that results in updated judgment lists.</p></li><li><p><strong>Cost effectiveness:</strong> Without the overhead of manually creating a judgment list, the process can be repeated efficiently any number of times.</p></li><li><p><strong>Natural query distribution</strong>: UBI data represent real user queries, which can drive deeper changes. For example, do our users use natural language to search in our system? If so, we might want to implement a semantic search or hybrid search approach.</p></li></ul><p>It does come with some warnings, though:</p><ul><li><p><strong>Bias amplification: </strong>Popular content is more likely to receive clicks, just because it gets more exposure. So this might end up amplifying popular items and possibly drowning out better options.</p></li><li><p><strong>Incomplete coverage: </strong>New content lacks any interactions, so it might be difficult for it to be high in the results. Rare queries can also lack sufficient data points to create meaningful training data.</p></li><li><p><strong>Seasonal variations:</strong> If you expect user behaviour to change drastically over time, historical data might not tell you much about what is a good result.</p></li><li><p><strong>Task ambiguity:</strong> A click doesn’t always guarantee that the user found what they were looking for.</p></li></ul><h2>Grades calculation</h2><h3>Grades for LTR training</h3><p>To train LTR models, we need to provide some numerical representation of how relevant a document is for a query. In our implementation, this number is a continuous score going from 0.0 to 5.0+, where higher scores indicate higher relevance.</p><p>To show how this grading system works, consider this manually created example:</p><p>Query</p><p>Document content</p><p>Grade</p><p>Explanation</p><p>"best pizza recipe"</p><p>"Authentic Italian Pizza Dough Recipe with Step-by-Step Photos"</p><p>4.0</p><p>Highly relevant, exactly what the user is looking for </p><p>"best pizza recipe"</p><p>"History of Pizza in Italy"</p><p>1.0</p><p>Somewhat in topic, it is about pizza but is not a recipe</p><p>"best pizza recipe"</p><p>"Quick 15-Minute Pizza Recipe for Beginners"</p><p>3.0</p><p>Relevant, a good result but it maybe misses the mark on being the “best” recipe. </p><p>"best pizza recipe"</p><p>"Car Maintenance Guide"</p><p>0.0</p><p>Not relevant at all, completely unrelated to the query</p><p>As we can see here, the grade is a numerical representation of how relevant a document is to our sample query of “best pizza recipe”. With these scores, our LTR model can learn which documents should be presented higher in the results.</p><p>How to calculate the grades is the core of our training dataset. There are <a href="https://www.elastic.co/search-labs/blog/judgment-lists">multiple approaches</a> to do this, each with its own strengths and weaknesses. For example, we could assign a binary score of 1 for relevant 0 for not relevant or we could just count the number of clicks in a resulting document for each query.</p><p>In this blog post, we will be using a different approach, <em><strong>taking into account the user behavior as our input and calculating a grade number as the output</strong></em>. We will also be correcting bias that could occur from the fact that higher results tend to be more clicked, regardless of the relevancy of the document.</p><h2>Calculating the grades - COEC algorithm</h2><p>The COEC (<a href="https://www.wsdm-conference.org/2010/proceedings/docs/p351.pdf">Clicks over Expected Clicks</a>) algorithm is a methodology for calculating judgment grades from user clicks.
As we stated earlier, users tend to click on higher-positioned results even if the document is not the most relevant to the query; this is called <a href="https://eugeneyan.com/writing/position-bias/">Position Bias</a>. The core idea for using the COEC algorithm is that not all clicks are equally significant; a click on a document at position 10 indicates that the document is much more relevant to the query than a click on a document at position 1. To quote the research paper about the COEC algorithm (linked above):</p><p><em>“It is well known that the click-through rate (CTR) of search results or advertisements decreases significantly depending on the position of the results.”</em></p><p>You can further read about position bias <a href="https://www.researchgate.net/publication/200110550_An_experimental_comparison_of_click_position-bias_models">here</a>.</p><p>To address this with the COEC algorithm, we follow these steps:</p><p><strong>1. Establish position baselines:</strong> We calculate the click-through rate (CTR) for each search position from 1 to 10. This means we determine what percentage of users typically click on position 1, position 2, and so on. This step captures the users’ natural position bias.

We calculate the CTR using:Where:</p><p> = Position. From 1 to 10</p><p>
= Total clicks (on any document) at position p across all queries</p><p>
 = Total impressions: How many times any document appeared at the position p across all queries</p><p>Here, we expect higher positions to get more clicks.</p><p></p><p><strong>2.</strong> <strong>Calculate Expected Clicks (EC)</strong>:</p><p>This metric establishes how many clicks a document “should” have received based on the positions it appeared in and the CTR for those positions We calculate EC using:Where:</p><p> = All queries where the document d appeared</p><p>
= Position of the document d in the query q results</p><p></p><p>3. <strong>Count actual clicks: </strong>We count the actual total clicks a document received across all queries where it appeared, hereafter called <strong>A(d).</strong></p><p></p><p>4. <strong>Compute the COEC score:</strong> This is the ratio of Actual clicks (A(d)) over the Expected clicks (EC(d)):This metric normalizes for position bias like this:</p><ul><li><p>A score of 1.0 means the document performed exactly as expected given the positions it appeared in.</p></li><li><p>A score above 1.0 means the document performed better than expected by looking at its positions. So this document is more relevant for the query.</p></li><li><p>A score under 1.0 means the document performed worse than expected by looking at its positions. So this document is less relevant for the query.</p></li></ul><p><em><strong>The end result is a grade number that captures what users are looking for, taking into account position-based expectations extracted from real interactions with our search system.</strong></em></p><h2>Technical implementation</h2><p>We will be creating a script to create a judgment list to train an LTR model.</p><p>The input for this script is the UBI data indexed in Elastic (queries and events).</p><p>The output is a judgment list in a CSV file generated from these UBI documents using the COEC algorithm. This judgment list can be used with <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">Eland</a> to extract relevant features and train an LTR model.</p><h3>Quick start</h3><p>To generate a judgment list from the sample data in this blog, you can follow these steps:</p><p>1. Clone the repository:</p>git clone https://github.com/Alex1795/elastic-ltr-judgement_list-blog.git  
cd elastic-ltr-judgement_list-blog<p>2. Install required libraries</p><p>For this script, we need the following libraries:</p><ul><li><p><em>pandas</em>: to save the judgment list</p></li><li><p><em>elasticsearch</em>: To get the UBI data from our Elastic deployment</p></li></ul><p>We also need Python 3.11</p>pip install -r requirements.txt<p>3. Update the environment variables for your Elastic deployment in a <a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog/blob/main/.env-example">.env file</a></p><ul><li><p>ES_HOST</p></li><li><p>API_KEY</p></li></ul><p>To add the environment variables, use:</p>source .env<p>4. Create the ubi_queries, ubi_events indices, and upload the sample data. Run the setup.py file:</p>python setup.py<p>5. Run the Python script:</p>python judgement_list-generator.py<p>If you follow these steps, you should see a new file called judgment_list.csv that looks like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt94317eda8f7af194/6a170aa46f7f04542f914821/2531090131ac9fe3e4e1d79de9d156fc47a7825a-782x531.png" alt="" /><p>This script calculates the grades applying the COEC algorithm discussed before using the <strong>calculate_relevance_grade()</strong> function that is shown below.</p><h2>Data architecture</h2><h3>Ubi queries</h3><p>Our UBI queries index has information about the queries executed in our search system. This is a sample document:</p>{
          "client_id": "client_002",
          "query": "italian pasta recipes",
          "query_attributes": {
            "search_type": "recipe",
            "category": "food",
            "cuisine": "italian"
          },
          "query_id": "q002",
          "query_response_id": "qr002",
          "query_response_object_ids": [
            "doc_011",
            "doc_012",
            "doc_013",
            "doc_014",
            "doc_015",
            "doc_016",
            "doc_017",
            "doc_018",
            "doc_019",
            "doc_020"
          ],
          "timestamp": "2024-08-14T11:15:00Z",
          "user_query": "italian pasta recipes"
        }<p>Here we can see data from the user (client_id), from the results of the query (query_response_object_ids), and the query itself (timestamp, user_query)</p><h3>Ubi click events</h3><p>Our ubi_events index has data from each time a user clicked a document in the results. This is a sample document:</p>{
          "action_name": "click",
          "application": "recipe_search",
          "client_id": "client_001",
          "event_attributes": {
            "object": {
              "description": "Authentic Italian Pizza Dough Recipe with Step-by-Step Photos",
              "device": "desktop",
              "object_id": "doc_001",
              "position": {
                "ordinal": 1,
                "page_depth": 1
              },
              "user": {
                "city": "New York",
                "country": "USA",
                "ip": "192.168.1.100",
                "location": {
                  "lat": 40.7128,
                  "lon": -74.006
                },
                "region": "NY"
              }
            }
          },
          "message": "User clicked on document doc_001",
          "message_type": "click",
          "query_id": "q001",
          "timestamp": "2024-08-14T10:31:00Z",
          "user_query": "best pizza recipe"
        }<h2>Judgment list generation script</h2><h3>General script overview</h3><p>This script automates the generation of the judgment list using UBI data from Queries and Click events stored in Elasticsearch. It executes these tasks:</p><ul><li><p>Fetches and processes the UBI data in Elasticsearch.</p></li><li><p>Correlates UBI events with its queries.</p></li><li><p>Calculates the CTR for each position.</p></li><li><p>Calculates the expected clicks (EC) for each document.</p></li><li><p>Counts the actual clicks for each document.</p></li><li><p>Calculates the COEC score for each query-document pair.</p></li><li><p>Generates a judgment list and writes it in a CSV file.</p></li></ul><p>Let’s go over each function:</p><h3>connect_to_elasticsearch()</h3>def connect_to_elasticsearch(host, api_key):
    """Create and return Elasticsearch client"""
    try:
        es = Elasticsearch(
            hosts=[host],
            api_key=api_key,
            request_timeout=60
        )
        # Test the connection
        if es.ping():
            print(f"✓ Successfully connected to Elasticsearch at {host}")
            return es
        else:
            print("✗ Failed to connect to Elasticsearch")
            return None
    except Exception as e:
        print(f"✗ Error connecting to Elasticsearch: {e}")
        return None<p>This function returns an Elasticsearch client object using the host and api key.</p><h3>fetch_ubi_data()</h3>def fetch_ubi_data(es_client: Elasticsearch, queries_index: str, events_index: str,
                   size: int = 10000) -&gt; Tuple[List[Dict], List[Dict]]:
    """
    Fetch UBI queries and events data from Elasticsearch indices.

    Args:
        es_client: Elasticsearch client
        queries_index: Name of the UBI queries index
        events_index: Name of the UBI events index
        size: Maximum number of documents to fetch

    Returns:
        Tuple of (queries_data, events_data)
    """
    logger.info(f"Fetching data from {queries_index} and {events_index}")

    # Fetch queries with error handling
    try:
        queries_response = es_client.search(
            index=queries_index,
            body={
                "query": {"match_all": {}},
                "size": size
            }
        )
        queries_data = [hit['_source'] for hit in queries_response['hits']['hits']]
        logger.info(f"Fetched {len(queries_data)} queries")

    except Exception as e:
        logger.error(f"Error fetching queries from {queries_index}: {e}")
        raise

    # Fetch events (only click events for now) with error handling
    try:
        events_response = es_client.search(
            index=events_index,
            body={
                "query": {
                    "term": {"message_type.keyword": "CLICK_THROUGH"}
                },
                "size": size
            }
        )
        events_data = [hit['_source'] for hit in events_response['hits']['hits']]
        logger.info(f"Fetched {len(events_data)} click events")

    except Exception as e:
        logger.error(f"Error fetching events from {events_index}: {e}")
        raise

    logger.info(f"Data fetch completed successfully - Queries: {len(queries_data)}, Events: {len(events_data)}")

    return queries_data, events_data<p>This function is the data extraction layer; it connects with Elasticsearch to fetch UBI queries using a match_all query and filters UBI events to get ‘CLICK_THROUGH’ events only.</p><h3>process_ubi_data()</h3>def process_ubi_data(queries_data: List[Dict], events_data: List[Dict]) -&gt; pd.DataFrame:
    """
    Process UBI data and generate judgment list.

    Args:
        queries_data: List of query documents from UBI queries index
        events_data: List of event documents from UBI events index

    Returns:
        DataFrame with judgment list (qid, docid, grade, keywords)
    """
    logger.info("Processing UBI data to generate judgment list")

    # Group events by query_id
    clicks_by_query = {}
    for event in events_data:
        query_id = event['query_id']
        if query_id not in clicks_by_query:
            clicks_by_query[query_id] = {}

        # Extract clicked document info
        object_id = event['event_attributes']['object']['object_id']
        position = event['event_attributes']['object']['position']['ordinal']

        clicks_by_query[query_id][object_id] = {
            'position': position,
            'timestamp': event['timestamp']
        }

    judgment_list = []

    # Process each query
    for query in queries_data:
        query_id = query['query_id']
        user_query = query['user_query']
        document_ids = query['query_response_object_ids']

        # Get clicks for this query
        query_clicks = clicks_by_query.get(query_id, {})

        # Generate judgment for each document shown
        for doc_id in document_ids:
            grade = calculate_relevance_grade(doc_id, query_clicks, document_ids, queries_data, events_data)

            judgment_list.append({
                'qid': query_id,
                'docid': doc_id,
                'grade': grade,
                'query': user_query
            })

    df = pd.DataFrame(judgment_list)
    logger.info(f"Generated {len(df)} judgment entries for {df['qid'].nunique()} unique queries")

    return df<p>This function handles the judgment list generation. It starts processing the UBI data by associating UBI events and queries. Then it calls the calculate_relevance_grade() function for each document-query pair to obtain the entries for the judgment list. Finally, it returns the resulting list as a pandas dataframe.</p><h3>calculate_relevance_grade()</h3>def calculate_relevance_grade(document_id: str, clicks_data: Dict,
                              query_response_ids: List[str], all_queries_data: List[Dict] = None,
                              all_events_data: List[Dict] = None) -&gt; float:
    """
    Calculate COEC (Click Over Expected Clicks) relevance score for a document.

    Args:
        document_id: ID of the document
        clicks_data: Dictionary of clicked documents with their positions for current query
        query_response_ids: List of document IDs shown in search results (ordered by position)
        all_queries_data: All queries data for calculating position CTR averages
        all_events_data: All events data for calculating position CTR averages

    Returns:
        COEC relevance score (continuous value, typically 0.0 to 5.0+)
    """

    # If no global data provided, fall back to simple position-based grading
    if all_queries_data is None or all_events_data is None:
        logger.warning("No global data provided, falling back to position-based grading")
        # Simple fallback logic
        if document_id in clicks_data:
            position = clicks_data[document_id]['position']
            if position &gt; 3:
                return 4.0
            elif position &gt;= 1 and position &lt;= 3:
                return 3.0
        if document_id in query_response_ids:
            position = query_response_ids.index(document_id) + 1
            if position &lt;= 5:
                return 2.0
            elif position &gt;= 6 and position &lt;= 10:
                return 1.0
        return 0.0

    # Calculate rank-aggregated click-through rates
    position_ctr_averages = {}
    position_impression_counts = {}
    position_click_counts = {}

    # Initialize counters
    for pos in range(1, 11):  # Positions 1-10
        position_impression_counts[pos] = 0
        position_click_counts[pos] = 0

    # Count impressions (every document shown contributes)
    for query in all_queries_data:
        for i, doc_id in enumerate(query['query_response_object_ids'][:10]):  # Top 10 positions
            position = i + 1
            position_impression_counts[position] += 1

    # Count clicks by position
    for event in all_events_data:
        if event.get('action_name') == 'click':
            position = event['event_attributes']['object']['position']['ordinal']
            if position &lt;= 10:
                position_click_counts[position] += 1

    # Calculate average CTR per position
    for pos in range(1, 11):
        if position_impression_counts[pos] &gt; 0:
            position_ctr_averages[pos] = position_click_counts[pos] / position_impression_counts[pos]
        else:
            position_ctr_averages[pos] = 0.0

    # Calculate expected clicks for this specific document
    expected_clicks = 0.0

    # Count how many times this document appeared at each position for any query
    for query in all_queries_data:
        if document_id in query['query_response_object_ids']:
            position = query['query_response_object_ids'].index(document_id) + 1
            if position &lt;= 10:
                expected_clicks += position_ctr_averages[position]

    # Count total actual clicks for this document across all queries
    actual_clicks = 0
    for event in all_events_data:
        if (event.get('action_name') == 'click' and
                event['event_attributes']['object']['object_id'] == document_id):
            actual_clicks += 1

    # Calculate COEC score
    if expected_clicks &gt; 0:
        coec_score = actual_clicks / expected_clicks
    else:
        coec_score = 0.0

    logger.debug(
        f"Document {document_id}: {actual_clicks} clicks / {expected_clicks:.3f} expected = {coec_score:.3f} COEC")

    return coec_score<p>This is the function that implements the COEC algorithm. It calculates the CTR for each position, then it compares the actual clicks for a document-query pair, and finally calculates the actual COEC score for each one.</p><h3>generate_judgment_statistics()</h3>def generate_judgment_statistics(df: pd.DataFrame) -&gt; Dict:
    """Generate statistics about the judgment list."""
    stats = {
        'total_judgments': len(df),
        'unique_queries': df['qid'].nunique(),
        'unique_documents': df['docid'].nunique(),
        'grade_distribution': df['grade'].value_counts().to_dict(),
        'avg_judgments_per_query': len(df) / df['qid'].nunique() if df['qid'].nunique() &gt; 0 else 0,
        'queries_with_clicks': len(df[df['grade'] &gt; 1]['qid'].unique()),
        'click_through_rate': len(df[df['grade'] &gt; 1]) / len(df) if len(df) &gt; 0 else 0
    }
    return stats<p>It generates useful statistics from the judgment list, such as total queries, total unique documents, or the grade distribution. This is purely informational and does not change the resulting judgment list.</p><h2>Results and impact</h2><p>If you follow the instructions in the Quick start section, you should see a resulting CSV file containing a judgment list with 320 entries (you can see a <a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog/blob/main/judgment_list.csv">sample output</a> in the repo). With these fields:</p><ul><li><p>qid: unique ID of the query</p></li><li><p>docid: unique identifier for a resulting document</p></li><li><p>grade: the calculated grade for the query-document pair</p></li><li><p>query: The user query</p></li></ul><p> Let’s look at the results for the query “Italian recipes”:</p><p>qid</p><p>docid</p><p>grade</p><p>query</p><p>q1-italian-recipes</p><p>recipe_pasta_basics</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_pizza_margherita</p><p>3.333333</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_risotto_guide</p><p>10.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_french_croissant</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_spanish_paella</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_greek_moussaka</p><p>1.875</p><p>Italian recipes</p><p>We can see from the results that for the query “Italian recipes”:</p><ul><li><p>The risotto recipe is definitely the best result for the query, receiving 10 times more clicks than expected</p></li><li><p>Pizza Margherita is a great result too.</p></li><li><p>The Greek mousaka (surprisingly) is a good result as well and performs better than its position on the results would suggest. This means a few users looking for Italian recipes got interested in this recipe instead. Maybe these users are interested in Mediterranean dishes in general. At the end, what this tells us is that this could be a good result to be shown under the other two ‘better’ matches we discussed above.</p></li></ul><h2>Conclusion</h2><p>Using UBI data lets us automate the training of LTR models, creating high-quality judgment lists from our own users. UBI data provides a big dataset that reflects how our search system is being used.By using the COEC algorithm to generate the grades, we account for inherent bias while at the same time, it reflects what a user considers a better result. The method outlined here can be applied to real use cases to provide a better search experience that evolves with real usage trends.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Elastic Cloud Hosted]]></category>
    <dc:creator><![CDATA[Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt037eb2f4d380fe65/6a170aa67d8d67397170e6e6/762bf09c28829d626d42c2cfadc719e1dd618d1b-1536x1024.png" length="0" type="image/png"/>
    <pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[CI/CD pipelines with agentic AI: How to create self-correcting monorepos]]></title>
    <description><![CDATA[How our team introduced GenAI into CI pipelines to create self-correcting pull requests, automizing the update of hundreds of dependencies in large monorepos]]></description>
    <content:encoded><![CDATA[<p>At Elastic Control Plane, the team behind <a href="http://cloud.elastic.co/">cloud.elastic.co</a> (Elastic Cloud Hosted) and Elastic Cloud Enterprise, we have introduced agentic AI technology into our build pipelines, giving our codebases the self-healing capabilities: Just like axolotls can grow limbs, our Pull Request builds fix themselves. This article shows why we needed to take this step, how we designed and executed it, what we learnt, and the impact of this change in our daily work.</p><p>Large codebases and their maintainers are like organisms, and any change can make them sick: Breaking builds, unit tests, etc. Maintainers, like antibodies, quickly jump in to restore health by removing the resulting bugs, a process that takes time and energy.</p><p>These codebases are built on the shoulders of hundreds of dependencies. We keep them all up to date on the products we host or distribute. This is aligned with our security standards, but comes with significant work volume generated from high rates of update-fix cycles. Each update is a potential germ that can break the build, getting the organism “sick”.</p><h2>Traditional automation to deal with a big problem: Keeping dependencies up-to-date</h2><p>This post is about automation. In this day and age, there is an important distinction to make between:</p><ul><li><p>Traditional automation: Steps performed by software encoding algorithms, driving the process being automated. It is deterministic, and its functionality is limited to what the developer intended the software to do.</p></li><li><p>Generative AI (gen AI) automation: Steps performed by Large Language Models technology from inputs in natural language and driven by prompts. Its results are usually non-deterministic and require human supervision.</p></li></ul><p>Back to the codebase-maintainers as organisms analogy, let’s talk about choosing our experiment subject. And yes, this post is about an experiment. An experiment that was so successful that it started helping our teams before becoming a fully polished internal feature.</p><p>We maintain a considerably large monorepo. We make sure dependencies are up-to-date and free of known vulnerabilities.</p><p>With about 500 actively updated dependencies for our core services, checking and bumping dependency versions is a non-trivial challenge. If performed manually, capable of taking away hundreds of engineering hours a week.</p><p>We adopted the dependency updates management system provided by our internal Engineering Productivity team as traditional automation. <a href="https://www.elastic.co/blog/reducing-cves-in-elastic-container-images">It is based on Renovate</a>.</p><p>Renovate is a simple but effective bot. In a nutshell, it does two things:</p><ol><li><p>Compares our repository dependency catalogs with the upstream packages repositories.</p></li><li><p>Opens Pull Requests when newer versions are found.</p></li></ol><p>In about six months of operation, Renovate authored pull -requests have already bumped 41% of our dependencies, becoming one of our most prolific contributors:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt393c19b2bc601973/6a170ce31949f70216e7aa8b/6b2834659619615131bcc22b3dc530f0349bef30-769x262.png" alt="" /><h2>Self-healing Pull Requests: Fix-Approve-Merge</h2><p>Our integration with Renovate was successful. So much so that it raised the bar enough to swamp the team with PR reviews. Of course, it can be set up to throttle down, but we really want to keep <em>everything</em> up-to-date<em>. </em>It made us aware that such a standard came with an effort price that could have a detrimental impact on project progression time.</p><p>Renovate PRs, as any other in our repository, go through a build pipeline that compiles code, runs unit tests (UTs) and integration tests (ITs), builds Docker images, and publishes them. All this is done using the Buildkite platform with Gradle build steps as demonstrated in the following screenshot:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc827642220d2c761/6a170ce51949f73b3ce7aa8f/b3ed789481006f9b5280a33790c8d3c59a7b519f-1372x579.png" alt="" /><p>Renovate is set up to automatically merge Pull Requests when ITs pass successfully.</p><p>One could expect that most dependency bumps would just go through, especially for patch versions. So, where is the toil coming from? Well, we live in a world where dependency maintainers introduce breaking changes in patch or minor releases, or even worse, where they introduce subtle runtime changes that are not reflected in the interfaces at all (e.g, ZooKeeper’s 3.8.3-&gt;3.8.4 new ACL constraints in existence checks operations - <a href="https://issues.apache.org/jira/browse/ZOOKEEPER-2590">ZOOKEEPER-2590</a> and downstream consequences in Apache Curator clients <a href="https://issues.apache.org/jira/browse/CURATOR-715">CURATOR-715</a>). Here is where the build breaks and humans <strong>are</strong> required.</p><h3>Really, humans?</h3><p>We noticed that these broken PRs were the real bottleneck for automatic updates. Broken as in not compiling at all, failing UTs or ITs. Engineers on our team needed to chime in on these cases invariably. This is an unplanned interruption of work, bringing frequent and production-killing context switches.</p><p>These fixes:</p><ul><li><p>Are usually self-contained: They don’t require software rearchitecture, just adjustments to the changes in the updated libraries. Nor do they require deep creative work.</p></li><li><p>They provide fast feedback loops: Edit-compile-test.</p></li></ul><p>Can you think of an emergent technology targeting repetitive self-contained coding tasks?</p><p>What if each broken PR to review came with proposed code changes fixing it?</p><p>This is exactly the approach we decided to try in an experimentation week. And it worked!</p><h3>But, how?</h3><p>The idea is simple: follow the natural way of working with your workmates. Let AI chime in and contribute to PR branches. That is, integrate AI agents as part of PRs’ workflow.</p><p>This is also an approach pursued in the coding agents industry, with examples like <a href="https://github.blog/news-insights/product-news/github-copilot-meet-the-new-coding-agent/">GitHub Copilot Coding Agent</a> or <a href="https://docs.anthropic.com/en/docs/claude-code/github-actions">Claude Code GitHub actions</a>.</p><p>Both off-the-shelf solutions make it easy to get the agent to interact with the code, but are less flexible.</p><p>We wanted the agent to act on the build errors in a targeted way: This unit test failed with this error for this module, fix that concrete problem, and add a commit to the working branch when you have fixed it.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte01dbaa9ffa77055/6a170ce71949f706c4e7aa93/e52476e705353a99e3446e90855c8b8def5c31ba-898x428.png" alt="" /><p>For that, we needed to:</p><ul><li><p>Be able to feed the AI agent with the concrete error messages and failed build tasks. Including those depending on internal Elastic Cloud services.</p></li><li><p>Give it the agency to run and iterate over failed Gradle steps until a solution is found or it desists.</p></li></ul><p>As in most systems, the key to human Software Engineers’ productivity is fast iteration cycles: Edit-Compile-Test, and that’s what we decided with code editing AI agents.</p><p>For that, we just expanded the set of build steps we described at the beginning of this post with another. A replica of the Gradle build steps with the twist of being controlled by a coding agent with a quite specific prompt:</p> # This step is used to fix compilation and UT failures when builds fail. It uses Claude Code to analyze the build logs and suggest fixes that
  # are then applied to the original branch in the form of commits once verified to have fixed the build. Only branches in elastic/cloud repository
  # can get these commits. If the source branch lives in a different repository, a new branch with the suggested branches will be posted in elastic/cloud.
  #
  # The effect is that automatic updates PR issued by Renovate can self heal. The moment Claude commits to elastic/cloud's PR branch, the build pipeline will be restarted
  # with the fixes.
  # Take into account that these commits are only added if Claude can verify that the fix the build by running the initially broken Gradle tasks.
  # To save time, computational resources and genAI tokens, this step is first run for AMD64 architecture. Chances are that the pushed fixes will also work for ARM64 architecture.
  #
  # NOTE: This step is currently using Claude Code agent but this latter could be replaced by any other genAI agent that can be invoked in headless mode from the command line.
  #
  - label: ":github: :terminal: :gradle: Claude Fix Build (AMD64)"
    key: "claude-fix-build-amd64"
    depends_on: "publishPlatformIndependent-amd64"
    allow_dependency_failure: true
    command: |
      if [ $$(buildkite-agent step get "outcome" --step "publishPlatformIndependent-amd64") != "passed" ]; then
        echo "--- Trying to fix build with Claude Code"
        .buildkite/scripts/claude-fix-build.sh
      else
        echo "--- There is nothing to fix"
      fi
    timeout_in_minutes: 240
    ...
    ...
    ...<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb25d0410d012aea3/6a170ce8c1e8a5c961f88322/3519248466ca50df4939dffe98ab7e1de0b3076b-1448x750.png" alt="" /><p><code>claude-fix-build.sh</code> is the script that invokes the agent through several steps:</p><p>1. Run a pre-hook command, grabbing the credentials we need to interact with Claude and GitHub.</p><p>2. Clone the GitHub target repository.</p><p>3. Get ready to run Gradle build steps. That is preparing the environment for Gradle to be able to build the working code. To this point, this script is just a copy of the scripts we were already using for the regular build steps in the Buildkite workers: <code>publishPlatformIndependent-amd64</code> and <code>publishPlatformIndependent-arm64</code>.</p><p>4. Use Buildkite CLI to try to fetch the previous step, the building step that failed, thus triggering the fix log. As we’ll see soon, this is a performance booster: The AI agent will analyze the file, deduce which build steps failed, and iterate over fixing loops for them.</p># Obtain the result of the build step that failed thus triggering the Claude fix
echo "--- Obtaining Gradle log from the failed build step"
mkdir /tmp/previous_step_artifacts
buildkite-agent artifact download "tmp/gradle_*.log" /tmp/previous_step_artifacts/

if [ -f /tmp/gradle.log ]; then
  echo "Found Gradle logs from previous job, Claude will use them to analyze the build failure:"
  ls -l /tmp/previous_step_artifacts
else
  echo "Couldn't find previous stage gradle log, Claude will run the build commands to evaluate what is failing"
fi<p>5. Install and configure the Claude Code agent CLI tool:</p>echo "--- Installing Claude"
sudo apt update -y
sudo apt install -y curl

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# shellcheck disable=SC1091
\. "$HOME/.nvm/nvm.sh"
nvm install 22
npm install -g @anthropic-ai/claude-code

echo "Configuring Claude Code and running environment"

# This value needs to be passed with the --allowedTools parameter to claude run
export CLAUDE_ALLOWED_TOOLS='Bash,Bash(chmod:*),Bash(git:*),Bash(./gradlew:*),Edit,NotebookEdit,MultiEdit,View,GlobTool,GrepTool,BatchTool,Write,WebFetch,WebSearch'<p>6. Prepare the agent actions log file and replicate its content on stdout (more about this below):</p># Prepare Claude actions log file and replicate its contents in stdout
touch /tmp/claude-actions.log
tail -f /tmp/claude-actions.log &amp;<p>7. Set the prompt, the soul of this integration (we are skipping the details here because they deserve their own section in the post: <a href="https://docs.google.com/document/d/17UFfcn3RKRdzOjcT_iANoBxXuc1TzoGBM_ZMk9oEVBQ/edit?tab=t.0#heading=h.ljo06oa5znpl">The prompt</a>), along with the repository CLAUDE.md file.</p># Claude fix prompt string
CLAUDE_FIX_PROMPT=$(cat &lt;&lt; 'EOF'
The build is failing, you might find a Gradle log ...
...
...
EOF
)<p>8. The original motivation for this feature is to help us fix Pull Requests bumping dependency versions. Pull Requests created by the Renovate version management bot. Our Renovate set-up rebases its PRs branches as it detects changes in upstream main branches. We observed that this behaviour interrupted the agent’s work: More often than not, it was verifying that it fixes with a long-running Gradle task just to have the build canceled by the latest change in the main branch. To avoid this situation, we leveraged the <a href="https://docs.renovatebot.com/configuration-options/#stopupdatinglabel">Renovate “stop updating labels” feature</a> so it would stop pushing changes to branches of PRs with a pre-configured label. In our case, <code>stop-updating</code> .
This is the magical spot where you can see traditional automation shaking hands with generative AI automation to reach a common goal. Our integration is telling Renovate: “Hey, I am in charge now”.</p>function add-pr-label() {
  local label="$1"
  echo "Adding label '$label' to the PR"
  if ! curl -f -X POST \
    -H "Authorization: token $GITHUB_TOKEN" \
    -H "Accept: application/vnd.github.v3+json" \
    -H "Content-Type: application/json" \
    -d "{\"labels\":[\"$label\"]}" \
    https://api.github.com/repos/elastic/cloud/issues/"$BUILDKITE_PULL_REQUEST"/labels; then
    echo "Failed to add label '$label' to the PR"
    exit 1
  fi
}

...
...
...

echo "--- Block further updates from Renovate until the PR is fixed"

# This is done adding a 'stop-updating' label to the PR (https://docs.renovatebot.com/configuration-options/#stopupdatinglabel)
add-pr-label "stop-updating"<p>9. With the next step, this integration is going to start pushing AI-generated commits to a branch candidate, merging a branch that is likely set to be auto-merged upon successful builds. Our team has a core principle: Never commit AI work without human supervision. Therefore, the script makes sure that GitHub PR auto-merge is disabled:</p>echo "--- Making sure auto-merge is disabled for the PR when there are AI contributions"

# Get GraphQL PR node id
PR_NODE_ID=$(curl -s -X POST \
    -H "Authorization: bearer $GITHUB_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"query\":\"query { repository(owner: \\\"elastic\\\", name: \\\"cloud\\\") { pullRequest(number: $BUILDKITE_PULL_REQUEST) { id } } }\"}" \
    https://api.github.com/graphql | jq '.data.repository.pullRequest.id' -r
)

# Disable automerge
AUTOMERGE_FAILURE_MSG="Failed to disable auto-merge for the PR. Aborting: It is dangerous to allow auto-merge when there are AI contributions."

if ! AUTOMERGE_NO_ERRORS=$(curl -s -f -X POST \
  -H "Authorization: bearer $GITHUB_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"query\":\"mutation { disablePullRequestAutoMerge(input: {pullRequestId: \\\"$PR_NODE_ID\\\"}) { clientMutationId } }\"}" \
  https://api.github.com/graphql | jq -r '.errors | length'); then
  echo "$AUTOMERGE_FAILURE_MSG"
  exit 1
fi

if [ "$AUTOMERGE_NO_ERRORS" -ne 0 ]; then
  echo "$AUTOMERGE_FAILURE_MSG"
  exit 1
fi<p>10. Finally! The agent is invoked with the configurations needed, and the prompt is prepared from the previous steps. The <code>--allowedTools</code> parameter deterministically constrains which commands and actions Claude can use and take, respectively. This is key for safety and was set as part of the configuration generated in step (5). As we’ll see in the prompt description and analysis, we ask Claude to explain its actions as they happen, appending them to the file created in step (6), claude-actions.log . This is crucial for real-time monitoring and post-finalization reporting.</p>echo "--- Claude actions"
claude --allowedTools="$CLAUDE_ALLOWED_TOOLS" -p "$CLAUDE_FIX_PROMPT"<p>11. When the agent finishes its work, some post-processing steps are taken:</p><p>a. Upload the actions log.</p><p>b. Determine the script return code depending on whether it has found a solution to the broken build.</p><p>c. Add success report labels to the pull request. </p>echo "~~~ Post processing"

# Upload claude output log 
buildkite-agent artifact upload /tmp/claude-actions.log

# Evaluate the success of the step in function of the success of the Claude fix
if grep -qF "SUCCESSFUL FIX" ; then
  EXIT_CODE=0
else
  EXIT_CODE=1
fi

if [ $EXIT_CODE != "0" ] ; then
    RED='\033[0;31m'
    NC='\033[0m' # No Color
    echo -e "${RED}Claude was unable to fix the build, check artifacts for details.${NC}"
    add-pr-label "claude-fix-failed"
else
    echo "Claude was able to fix the build"
    add-pr-label "claude-fix-success"
fi

exit $EXIT_CODE<p>One important point about this flow is that the build pipeline is set up to be restarted when new commits are pushed. So, after the agent pushes its changes, the pipeline starts over, and the agent kicks in again only and only if the new iteration fails.Commits control the pipeline flow, pipeline build steps control the invocation of the agent, and this latter commits fixes when it can find them. As a result, closing the circle of a user experience that can be described as human-supervised AI autonomous contribution.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8b120ebb01b836c1/6a170cea6234e000dedb1a51/bfec7ca96e602a651a429e45e4634cb3e5cee199-1030x784.png" alt="" /><p>This approach to AI agent-based coding is to editors with AI batteries (VSCode+Copilot, Cursor, Windsurf…) what a real autonomous car is to a car with cruise control and lane-keep assist.</p><p><em>“This isn't about replacing our work with generated code, nor is it about having an AI buddy making suggestions beside us. Instead, it's about an AI buddy contributing to our codebase through GitHub, offering code change suggestions in an experience akin to open-source collaboration online.”</em></p><h4>The prompt</h4><p>Though it comes with the disadvantages of undetermined behaviour and ambiguity, the big win behind generative AI is that the instructions are self-explanatory. This is the prompt we are currently using (italic black font) with annotations adding context for this post (regular, magenta):</p><p><em>The build is failing, you might find a Gradle log to analyze under /tmp/previous_step_artifacts, but if not, you will have to run the build commands to evaluate what is failing. </em><strong>The reader might recall that on step (4) of the integration script we fetched the Gradle logs from Buildkite so Claude could analyze them. This is where we tell it to look at them and take action.</strong></p><p><em>These commands are './gradlew "--max-workers=$MAX_WORKERS" --console=plain publishForPlatform' and</em></p><p><em>'./gradlew "--max-workers=$MAX_WORKERS" --console=plain publishPlatformIndependent' but you don't need to run them as a first step if the log files under /tmp/previous_step_artifacts exist and contain the necessary information to analyze the failure. </em><strong>…to fall back to building everything from scratch in those cases where those logs are not available.</strong></p><p><em>In any case, you must find which Gradle subtasks are failing and fix them so that the build succeeds. </em>This gives Claude its goal: It must make sure that the build succeeds, <strong>applying whatever changes are necessary to the source (always obeying the constraints set below) code and iterating on the execution of global or local subtasks.</strong></p><p><em>Please:</em></p><p><em>- Analyze their output and apply the necessary fixes to make them succeed. </em></p><p><strong>This integration is our AI contributor buddy, our team treats it as a new hire. A capable one which still needs to learn “our ways”. The code style, preferred techniques, pitfalls to avoid, etc… In a nutshell, what we learn as we contribute is encoded in an ever evolving file of recommendations in the Cloud repository: CLAUDE.MD.Claude code looks for this file by default but we wanted to make it explicit that it must follow its recommendations:</strong></p><p><em>- Follow the recommendations under the CLAUDE.md file within the working directory.</em> </p><p><em>- If concrete Gradle subtasks are failing, iterate over them before attempting execution of the global ones. </em><strong>Boom! Just like this, you can follow the agent's “thinking” and actions in real-time from Buildkite. This is how the contents appended to claude-actions.log are generated.</strong></p><p><em>- Log each action you take in the file /tmp/claude-actions.log as they happen in realtime. Each entry should have a prefix with the current timestamp in the format "Claude Action [YYYY-MM-DD HH:MM:SS]: " and a description of the action taken. Run commands and their outputs should be logged too.</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f8ebc994afbe602/6a170ceccf4f255bb6b2d1c1/489a1092e976e74f457d14cec2d545edbbb923b4-1495x947.png" alt="" /><p><em>- Log in the same file, using the same format, the plans you are going to follow and their outcomes as you finish them.</em></p><p><em>- Always include "--max-workers=$MAX_WORKERS" option in invocations to ./gradlew command.</em><strong>Used by the post-processing step in the integration script, step (11):</strong></p><p><em>- If you get the build fixed, please add a last line to /tmp/claude-actions.log with the string "SUCCESSFUL FIX". Otherwise, add "FAILED FIX" at the end of the file.</em></p><p><strong>Next, the instructions telling Claude to commit its fix changes, only they are successful:</strong> <em>- If you succeed and added the "SUCCESSFUL FIX" line, please commit the changes that fixed the problems and push to the source branch in the same Git repository. The branch name is given by the BUILDKITE_BRANCH environment variable. Your commit messages should be prefixed with the "Claude fix: " string.</em></p><p><em>- To push the changes, you must use Github token authentication like in the following example git push https://token:$GITHUB_TOKEN@github.com/elastic/cloud.git HEAD:&lt;BRANCH_NAME&gt; taking into account that the token is stored in the GITHUB_TOKEN environment variable.</em></p><p><em>- Separate each fix into a different commit, so that the history is clear and understandable.</em></p><p><em>- If Git pushes fail due authentication issues, retry again after 1 minute. If still failing, then after 5 minutes and a last attempt after 10 minutes.</em></p><p><em>- Before pushing to Git: If and only if there are changes to push because the fix was successful, add the label "claude-fix-success" to the PR.</em></p><p><em>- Do not change what is not strictly necessary to fix the build. </em><strong>Humans tend to be indolent, AI even more. We had to introduce this last point as we found that Claude tended to fix the problems of a version bump by, well, … removing the version bump:</strong></p><p><em>- NEVER downgrade versions of dependencies as declared in the version catalogs of elastic/cloud master branches such as gradle/libs.versions.toml, prefer failure to version downgrades relative to the master branch.</em></p><h2>Results and lessons learnt</h2><p>During its first month of operation, and limited to only 45% of dependencies, Self Healing PRs became one of our Cloud GitHub repository’s top contributors. The plugin successfully fixed a total of 24 initially broken PRs, with the Claude author making 22 commits between July 22nd and August 22nd.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4d32417b2c914c8/6a170ced47d49c57932d8a67/c74c19b53c16fb8685dc71b598b96988d83b1f21-1128x583.png" alt="" /><p>Our estimates indicate that, during this period, its contributions<strong> saved 20 days of active development work for our team</strong>. That’s a considerable amount of reduced toil, repetitive low-value work for engineering that is key for the codebase organism but invisible for its performance metrics.</p><p>Even when it fails to completely fix problems, it nudges things forward, hinting at the way to the solution or trimming the search space.</p><p>We have also learnt that tuning AI agents' behaviour marks the difference between failure and success. Teaching it the team’s skills through contributions to CLAUDE.md repository files made it stop following undesired coding practices, but, more importantly, made it diligent. We have learnt that there is nothing as lazy as an uneducated coding agent. This teaching process never ends, but each added hint and rule translates into a saved day of work.</p><p><em>“Trust but verify” </em></p><p>I started this post highlighting the differences between traditional and generative AI automation. Underterminism in the latter group means that you can never blindly trust the changes proposed by this automation. <strong>Reviews are imperative, and a critical attitude towards the proposed changes is extremely important</strong> for the health of this axolotl-like code organism. The alternative is exposing ourselves to devilish incidents and bugs behind seemingly perfect code with alien ways of being wrong.</p><h2>Conclusion</h2><p>We have seen how CI and AI can work together to satisfy the ever-expanding demands of infosec quality standards.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltac9faff69f27f2a3/6a170cef8b73cb60f818a097/3eecb15806bba67dc983c82522cbe889db37627a-542x440.png" alt="" /><p>This area of intersection between tools and needs made us design an application that behaves pretty much like a human, adding contributions in a collaborative code repository, and that can easily control traditional automation, avoiding bot tug-of-wars.</p><p>This is just the beginning. With this tool at our hand, we are starting to explore alternative direct applications.</p><p>For example, by enabling it on all pull requests, we can just open Pull Requests with incomplete changes, leaving behind less creative tasks such as API specs regeneration or linting; expecting the integration to add the necessary commits to finish these steps that are as important as boring for software engineers.</p><p>We have observed with awe how it has worked around transient problems in ancillary build services, filling the gaps when necessary with proactive execution of approved tools.</p><p>We expect this to go well beyond helping with automatic updates.</p><p>Who knows? Even to the extreme of reverse axolotl PRs: Humans writing interfaces and their unit tests, and letting Self Healing PRs come up with the rest.</p><p>The success of this pilot has traced a plan where we activate the integration for all Renovate PRs in the Cloud repository and possibly expand to all pull requests regardless of their origin.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ci-pipelines-claude-ai-agent</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ci-pipelines-claude-ai-agent</guid>
    <category><![CDATA[Agentic AI]]></category>
    <dc:creator><![CDATA[Pablo Pérez Hidalgo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4f368c579d510a6/6a170cf0961e69ef90c4cf6d/7259232be4b223710e21e6cd0082e2270ed07ad3-1600x873.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 30 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch plugin for UBI: Analyze user data in Kibana]]></title>
    <description><![CDATA[Discover how to capture user behavior data using the Elasticsearch plugin for UBI and build a custom dashboard in Kibana to analyze it. ]]></description>
    <content:encoded><![CDATA[<p>In this article, we’ll show you how to capture and analyze user analytics data using the <strong>UBI</strong> <em>(User Behavior Insights)</em> standard in Elasticsearch.</p><p><em>You can learn more about UBI in </em><a href="https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-insights"><em>this article</em></a><em>.</em></p><p>Data collected with the UBI collector can be used on Kibana to build dashboards that open the window to users’ behavior in our application. In this blog, we will explore how to analyze UBI data in Kibana to gain insights into how our app is being used.</p><h2>Demo set up</h2><p>We can easily reproduce the demo in this blog following these steps:</p><p>1. Clone the repository</p>git clone https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog.git 
cd ubi-dashboard-elasticsearch_blog<p>2. Install required libraries:</p>pip install -r requirements.txt<p>3. Run the setup script. Make sure to have the following environment variables set beforehand</p><ol><li><p>ES_HOST</p></li><li><p>API_KEY</p></li><li><p>KIBANA_HOST</p></li></ol>python setup.py<p>That’s all you need to do. If everything went well, you should see this output from the script execution:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c568b39867bd357/6a170e3360084be8393c45ff/947a67ef7210fa76f62324a3eadb62a3e10bb887-1600x633.png" alt="" /><p>As we can see the script:</p><ul><li><p>Created two indices with the appropriate mappings</p></li><li><p>Indexed 23 documents to these indices</p></li><li><p>Uploaded some saved objects to Kibana</p></li></ul><p>Now, let’s take a look at what exactly this script did behind the scenes.</p><h2>Understanding the uploaded data</h2><p>First, we put some data in Elasticsearch before creating our visualizations.</p><p>You can reproduce the process manually in Kibana DevTools, copying the mappings and sample data and using the <strong>PUT &lt;index&gt;</strong> and <strong>PUT _bulk</strong> APIs, respectively.</p><h3>Ubi_events index</h3><p>User action data, documents are generated for every click (in this case), and it includes:</p><ul><li><p><strong>application</strong>: The client application that generated the event ("search-ui")</p></li><li><p><strong>action_name</strong>: Type of user action performed ("click")</p></li><li><p><strong>query_id</strong>: Links this event to the corresponding search query session</p></li><li><p><strong>client_id</strong>: A generated, unique ID that represents a user or session without revealing personal data. It is generated instead of using identifiable data like email addresses or usernames. This approach allows us to have privacy advantages such as safe analytics capabilities and secure data sharing without exposing PII, while still having important functionality like session continuity, behavioral analysis, or A/B testing.</p></li><li><p><strong>timestamp</strong>: ISO 8601 formatted timestamp when the event occurred</p></li><li><p><strong>message_type</strong>: Category of the event for processing ("CLICK_THROUGH")</p></li><li><p><strong>message</strong>: Human-readable description of what happened ("Clicked Fahrenheit 451")</p></li><li><p><strong>user_query</strong>: The original search term that led to this event ("fahrenheit")</p></li><li><p><strong>event_attributes</strong>: Nested object containing detailed event context:</p><ul><li><p><strong>object.device</strong>: Device type used by the user ("mobile")</p></li><li><p><strong>object.object_id</strong>: Unique identifier of the clicked item</p></li><li><p><strong>object.description</strong>: Details about the clicked item (book title, date, author)</p></li><li><p><strong>object.position.ordinal</strong>: Ranking position of the item in search results (1st)</p></li><li><p><strong>object.position.page_depth</strong>: Which page of results the item appeared on (1st page)</p></li><li><p><strong>object.user.ip</strong>: User's IP address</p></li><li><p><strong>object.user.city/region/country</strong>: Geographic location data</p></li><li><p><strong>object.user.location</strong>: Precise latitude/longitude coordinates</p></li></ul></li></ul><p>Sample document:</p>       {
         "application": "search-ui",
         "action_name": "click",
         "query_id": "2dd48446-7ca8-4510-89f4-2ebb67ed240b",
         "client_id": "8c1915fe-8ee0-4487-b801-3b1d67c25cf6",
         "timestamp": "2025-07-30T14:25:52.698Z",
         "message_type": "CLICK_THROUGH",
         "message": "Clicked Fahrenheit 451",
         "user_query": "fahrenheit",
         "event_attributes": {
           "object": {
             "device": "mobile",
             "object_id": "ZwoTM5gBPJ218VOaBpj4",
             "description": "Fahrenheit 451(1953-10-15) by Ray Bradbury",
             "position": {
               "ordinal": 1,
               "page_depth": 1
             },
             "user": {
               "ip": "192.168.1.100",
               "city": "New York",
               "region": "New York",
               "country": "United States",
               "location": {
                 "lat": 40.7128,
                 "lon": -74.006
               }
             }
           }
         }
       }<p>You can download the index mappings <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/index_mappings/ubi_events-mappings.json">here</a></p><h3>Ubi_queries index</h3><p>Search data includes data relevant to each search executed by the users:</p><ul><li><p><strong>query_response_id</strong>: Unique identifier for this specific query response instance</p></li><li><p><strong>user_query</strong>: The original search term entered by the user ("fahrenheit")</p></li><li><p><strong>query_id</strong>: Unique identifier for the search query session</p></li><li><p><strong>query_response_object_ids</strong>: Array of object IDs that were returned as search results (["3", "9"])</p></li><li><p><strong>query</strong>: The complete Elasticsearch query object in JSON format, including search parameters, fields to search, result size, sorting, and metadata</p></li><li><p><strong>client_id</strong>: A generated unique ID that represents a user or session without revealing personal data. It is generated instead of using identifiable data like email addresses or usernames. This approach allows us to have privacy advantages such as safe analytics capabilities and secure data sharing without exposing PII, while still having important functionality like session continuity, behavioral analysis, or A/B testing.</p></li><li><p><strong>timestamp</strong>: Unix timestamp in milliseconds when the query was executed (1753885225098)</p></li></ul><p>Sample document:</p>    {
         "query_response_id": "03e8af3e-8725-49d9-99ad-36bf2a8e96d1",
         "user_query": "fahrenheit",
         "query_id": "f8b2f5bc-cb3c-49d4-86bc-19212a782ba7",
         "query_response_object_ids": [
           "3",
           "9"
         ],
         "query": """{"from":0,"size":20,"query":{"multi_match":{"query":"fahrenheit","fields":["author^1.0","name^1.0"]}},"_source":{"includes":["name","author","image_url","url","price","release_date"],"excludes":[]},"sort":[{"_score":{"order":"desc"}}],"ext":{"query_id":"f8b2f5bc-cb3c-49d4-86bc-19212a782ba7","user_query":"fahrenheit","client_id":"8c1915fe-8ee0-4487-b801-3b1d67c25cf6","object_id_field":null,"query_attributes":{}}}""",
         "client_id": "8c1915fe-8ee0-4487-b801-3b1d67c25cf6",
         "timestamp": 1753885225098
       }<p>You can download the index mappings <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/index_mappings/ubi_queries-mappings.json">here</a><strong>.</strong></p><h3>Sample data</h3><p>We can use the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk">_bulk API</a> to index <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/sample_documents/bulk_index.ndjson">some sample</a> data in both indices</p><p>This will create 6 documents in the <strong>ubi_queries </strong>index and 16 in the <strong>ubi_events</strong> index.</p><h3>Dashboard object</h3><p>Before going into details of the visualizations used in this example <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/dashboards/web_analytics_dashboard.ndjson">here</a>, you can download the Saved Object of the full example dashboard and <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/saved-objects#saved-objects-import">import</a> it into your Kibana instance. This dashboard explores the most searched terms, when searches and events took place, and where they come from (in a map).</p><h2>Visualize Insights</h2><p>We are going to create a Kibana dashboard to analyze the most common metrics leveraging <a href="https://www.elastic.co/docs/explore-analyze/visualize/lens">Kibana Lens</a>. For a reference on available visualizations, visit <a href="https://www.elastic.co/docs/explore-analyze/visualize/supported-chart-types">this</a> page.</p><h3>Ubi_events</h3><p>We will start with some simple Metric visualizations created with Lens: <strong>Total events:</strong> Counts how many events were triggered in the timeframe. Uses a simple count of the documents in the index, denoted by <strong># Records</strong> in the field list.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69d672ece8a13874/6a170e35286714284693e3be/2d06ff89f2cf43ee4102e9e01079ff63754e99fd-502x182.png" alt="" /><p><strong>Event actions: </strong>Counts actions by <code>action_name</code>. This is a simple count of documents split by <code>action_name.keyword</code>. In our sample data, we have two types of actions:</p><ul><li><p>click: Generated when a user clicks in the book link</p></li><li><p>search_input: Generated when a user enters text in the search box (debounce 300ms)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda49e8d513f0166c/6a170e366f7f04c66c9148fb/3735206d066f62990159c0777243f8b3d0703b6b-1188x186.png" alt="" /><p>Now on table visualizations:</p><p><strong>Top clicks: </strong>A table with a count of the number of events split by the query they come from. It uses a Top values function on the <code>user_query.keyword</code>. This can give us visibility on which queries generate more interactions on our webpage.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt81925167886ba5e3/6a170e37b339d58be776a06c/87aba3bbb9cf32aefa2aa126ba8edfb6bb456ae4-223x296.png" alt="" /><p>Finally, some other visualizations:</p><p><strong>Device types:</strong> This visualization breaks down the percentage of events by the device they come from. The device can be one of three categories: Desktop, mobile, or tablet. This visualization is a pie that uses the top values of <code>event_attributes.object.device.keyword,</code> and can give us insights into which type of devices our users have. This can generate alerts if we detect an unexpected, sudden fall of events on a specific type of device, as this might indicate that a recent change in our app resulted in errors when accessing it from a device.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt695213931fbb528c/6a170e397d8d6741e770e7c2/b9e8d1a07fcebf73b3107bcbc728b77a5d30e6a6-846x484.png" alt="" /><p><strong>Events map:</strong> A <a href="https://www.elastic.co/docs/explore-analyze/visualize/maps/maps-getting-started">map visualization</a> that shows where the events are coming from, which allows us to see the geographical distribution of our users. Right now, this shows where individual documents come from, but this can also be used to see the density of users with a heatmap, for example.</p><p>This particular visualization can provide very interesting insights when used with different filters. For example, we can see where different search terms are coming from or where most of our clicks are originating. This can be useful information for making decisions on localization efforts or establishing differences across local markets. The map uses the location at <code>event_attributes.object.user.location</code>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1fbe510ac15fc06a/6a170e3b4a531b59a036a9fb/ca87fe7d5f84b9c38d9787c899c55bd48f2af9f5-1600x759.png" alt="" /><p><strong>UBI Events: </strong>A saved search with the latest UBI events documents</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0101bc86e2a11639/6a170e3dd7c022575bde6545/73c94c7f0d238149851e066b4b53d16dff9e2e74-1309x379.png" alt="" /><h3>Ubi_queries</h3><p>Here we have visualizations from this index:</p><p><strong>Total queries:</strong> A simple document count of the index to show how many queries have been received in total. This shows the big picture and answers the question of how many total queries we had in the selected time window.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2f0e83ff0e87e104/6a170e3e286714171893e3c2/10af8288eaa278aff04625dab4d4a2c86c9ebf6d-218x90.png" alt="" /><p><strong>Unique clients: </strong>A <code>unique_count</code> of the field <code>client_id</code> to show how many different clients have used our website.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt30a15247e689a3c3/6a170e3f509168aec9e1bb82/baf7b29670f9392619dffef4800cb50efb3a0578-249x95.png" alt="" /><p><strong>Top queries (tag cloud):</strong> A Tag cloud of the top 5 most searched terms. This visualization uses the field <code>user_query.keyword</code> and allows us to easily see the main terms that our users are looking for.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt31218b03715ea15c/6a170e41e8fbce688139fd0b/833844afba1a5120ba2e83eade8f83954c34ba82-790x327.png" alt="" /><p><strong>Queries over time: </strong>A line chart of queries per hour, which uses a simple count metric in a horizontal axis of the field timestamp</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3c0731a036283fad/6a170e42a6c2b9839ce79798/01d79d29cd63bbc3be273ed3f55f49cc01859f64-873x182.png" alt="" /><p><strong>Query terms over time:</strong> Similar to the last one, but broken down by the <code>user_query.keyword</code>. This chart can show how many different terms are searched over time.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcb263c69b0e87ecb/6a170e4467045b7cac45c288/afb1c9c19919ca9117b5dc26c6f938c425261ae9-844x209.png" alt="" /><p><strong>Top queries:</strong> A Top values table showing how many times a term was searched. It uses the <code>user_query.keyword</code> field.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfb8f5d3bf9cb7943/6a170e45a292995c17d010b6/19854feaa749b528e328e1e427aa285b2abd16b6-384x295.png" alt="" /><p><strong>Client queries:</strong> A Top values table of the <code>client_id</code> field that counts the total queries and unique queries per client.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9201d14e53e3666a/6a170e4760084b756b3c4608/af18679364d92263913febf59c792957e41e5292-382x291.png" alt="" /><p><strong>No result queries:</strong> A Top value table that shows the top <code>query_terms</code> that didn’t match any document, and a Unique Count of the field client_id. This can be very useful to determine what products our website is lacking. For example, in an e-commerce book store, seeing regular searches for a particular book title could lead us to buy copies to sell. Alternatively, it can also indicate shortcomings in our search implementation, for example, if people are using question-based searches that align better with semantic search approaches.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc59e01f67e2899a1/6a170e48dc55de5d75e00e72/75dc6908767eb86ef2e2b8ac7e25c57b55f722ee-746x574.png" alt="" /><p>Here you can see the full dashboard:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e197f27fb09906b/6a170e4a0e2e49c69541a1b3/39a00886ed753e12e8f2966b509b08afc45b4389-1600x913.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt057ec5248cd60aca/6a170e4c8b73cb5b3b18a0ce/2eb980f0123d6464b94599bc01f61de542f8b8f9-1600x412.png" alt="" /><h2>Analysis of sample data</h2><p>In our dashboard, we can get some insights:</p><ul><li><p>Traffic is coming from 3 different cities in the US</p></li><li><p>Most of our users access our website from a desktop device, but we have a sizable number of users using a mobile device and even some using a tablet.</p></li><li><p>We can see the top query is “asimov,” but at the same time, we do not have any results. This might be a good indicator of what products should be prioritized for stock acquisition.</p></li></ul><p>To further this analysis, we could use Kibana’s Machine Learning capabilities to understand and predict behaviours on our website. Going even one step further, we can create alerts based on these behaviors using the different available connectors.</p><p>From a search relevance perspective, user behavior is a useful input for relevance engineering tools like <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR</a>.</p><h2>Conclusion</h2><p>Data collected by the UBI collector can be easily used to have a better understanding of our users. The resulting dashboard becomes a live pulse of what our users are searching for and can point to data gaps to drive improvements in our search engine.</p><p><strong>Note:</strong> The o19s User Behavior Insights (UBI) plugin mentioned in this article is a third-party, community-maintained plugin and is not officially supported by Elastic. For questions or issues related to this plugin, please refer to the o19s UBI project repository at <a href="https://github.com/o19s/ubi">https://github.com/o19s/ubi</a>. </p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-data-kibana</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-data-kibana</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Eduard Martin,Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d7e4a5bbd9427c2/6a170e4d0e2e4905b641a1b7/04f1738a38cead88c9a67b0f863171b4b43010ab-1600x913.png" length="0" type="image/png"/>
    <pubDate>Fri, 26 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[RAG with a map: Multimodal + geospatial in Elasticsearch]]></title>
    <description><![CDATA[Combining multimodal RAG capabilities with core Elasticsearch features such as geospatial queries and lexical search.]]></description>
    <content:encoded><![CDATA[<p>When working with RAG systems, Elasticsearch offers a significant advantage by combining a <a href="https://www.elastic.co/what-is/hybrid-search">hybrid search</a> (vector search + traditional text search) approach with hard filters to ensure the retrieved data is relevant to the user query. This makes models less prone to hallucination and, in general, improves your system quality. In this blog, we will explore how we can take a multimodal RAG system to the next level using <a href="https://www.elastic.co/docs/explore-analyze/geospatial-analysis">Elastic’s geospatial search features</a>.</p><h2>Getting started</h2><p><em>You can find the full source code used in this blog </em><a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch"><em>here</em></a><em>.</em></p><h3>Prerequisites</h3><ul><li><p>Elasticsearch 8.0.0+</p></li><li><p>Ollama</p><ul><li><p>cogito:3b model</p></li></ul></li><li><p>Python 3.8+</p></li><li><p>Python dependencies:</p><ul><li><p>elasticsearch</p></li><li><p>elasticsearch-dsl</p></li><li><p>ollama</p></li><li><p>clip_processor</p><ul><li><p>torch</p></li><li><p>transformers</p></li><li><p>PIL</p></li></ul></li><li><p>streamlit</p></li><li><p>json</p></li><li><p>os</p></li><li><p>typing</p></li></ul></li></ul><h3>Setup</h3><p>1. Clone the repository:</p>git clone https://github.com/Alex1795/multimodal_RAG_elasticsearch.git  
cd multimodal_RAG_elasticsearch<p>2. Install required libraries:</p>pip install -r requirements.txt<p>3. Install and set up Ollama:</p>Download from https://ollama.com/download/# Download and start the required model
ollama pull cogito:3b
ollama run cogito:3b<p>4. Configure Elasticsearch</p><ul><li><p>Make sure to have the following environment variables set:</p><ul><li><p>ES_INDEX</p></li><li><p>ES_HOST</p></li><li><p>ES_API_KEY</p></li></ul></li><li><p>Set the index mapping on Elasticsearch, put special attention to the geolocation and embeddings definition:</p></li></ul>PUT mmrag_blog
{  
  "mappings": {  
    "properties": {  
      "title": {  
        "type": "text",  
        "analyzer": "standard"  
      },  
      "geolocation": {  
        "type": "geo_point"  
      },  
      "image_filename": {  
        "type": "keyword"  
      },  
      "generated_description": {  
        "type": "text",  
        "analyzer": "standard"  
      },  
      "description": {  
        "type": "text",  
        "analyzer": "standard"  
      },  
      "text_embedding": {  
        "type": "dense_vector",  
        "dims": 512,  
        "index": true,  
        "similarity": "cosine"  
      },  
      "image_embedding": {  
        "type": "dense_vector",  
        "dims": 512,  
        "index": true,  
        "similarity": "cosine"  
      },  
      "photo_id": {  
        "type": "keyword"  
      }  
    }  
  }  
}<h3>Run the application</h3><p>1. Generate and index images’ embeddings and metadata:</p>python upload_documents.py<p>This file runs the data indexing pipeline. It processes the image metadata files and enriches them with multimodal embeddings (from the description and the image itself using the CLIP model). Finally, it uploads the documents to Elasticsearch. After executing this command, you should see the <strong>mmrag_blog </strong>index in Elasticsearch with the image metadata, geolocation, and image and text embeddings. </p><p>2. Run the streamlit app and use the UI in your browser with:</p>streamlit run streamlit_app.py #comment<p>After executing this command, you can see the project webpage at <a href="http://localhost:8501/">http://localhost:8501</a>.</p><p>The webpage is the interface for the RAG application. From there, you can ask a question, and then the assistant will extract the appropriate parameters from your question, run an RRF search on Elasticsearch to find related pictures, and formulate a response. It will also show some pictures from the results. </p><h2>Implementation overview</h2><p>To demonstrate Elastic’s RAG capabilities, we will build an assistant that can answer questions about national parks using relevant data. The search combines 4 approaches with data inferred from the user’s text query:</p><ul><li><p>Image vector search</p></li><li><p>Text vector search</p></li><li><p>Lexical text search</p></li><li><p>Geospatial filtering</p></li></ul><p>This allows our assistant to answer questions that are relevant and focused on what the user needs.</p><p>Now, how can the geospatial filter improve the assistant results? For example, if the user asks, “Where can I find canyons near Salt Lake City?” Without a geospatial filter, the assistant might suggest:</p><ul><li><p>Canyonlands National Park - Utah</p></li><li><p>Grand Canyon National Park - Arizona</p></li></ul><p>However, since we know the user is specifically looking for sites near Salt Lake City, it makes sense to look for answers in Utah. Therefore, the correct option is Canyonlands National Park only.</p><p>The implementation in this blog uses a <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-distance-query">geo_distance query</a> to be able to find results (the picture’s <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/geo-point">geopoint</a>) in a particular national park area. We are also using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/geo-shape">geoshapes</a> to draw the parks’ areas.</p><p>However, Elastic capabilities with geo queries go well beyond that:</p><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-bounding-box-query">geo_bounding_box query</a>: Finds documents (geopoints or geoshapes) that intersect a specified rectangle</p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-grid-query">geo_grid query</a>: Finds documents that intersect a specified geohash, map tile, or H3 bin</p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-shape-query">geo_shape query</a>: Finds documents that are related (intersects, is contained by, is within, or a disjoint operation) to the specified geoshape</p></li></ul><h2>Dataset</h2><p>We will use <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/tree/main/images_metadata">geotagged pictures</a> from national parks obtained from <a href="https://www.flickr.com/groups/335743@N24/pool/">Flickr</a>. We augment these pictures with a description and vectorize both the image and description using the <a href="https://huggingface.co/openai/clip-vit-base-patch32">openai/clip-vit-base-patch32</a> model:</p><p>We merge these embeddings with the images’ metadata, and at the end, we get a document that looks like this:</p>{
         "title": "Spa Geyser in Yellowstone National Park on a sunny day",
         "geolocation": {
           "lat": 44.45899722222222,
           "lon": -110.82573611111111
         },
         "image_filename": "52631363114_Spa_Geyser_in_Yellowstone_National_Park_on_a_sunny.jpg",
         "generated_description": "A small geyser releases steady streams of hot water and steam into the air on a clear sunny day. Colorful mineral deposits surround the thermal feature, creating vibrant orange and yellow formations. The active geothermal vent demonstrates the underground volcanic activity that powers these natural fountains.",
         "text_embedding": [
           0.02323250286281109,
           …
           -0.17811810970306396
         ],
         "image_embedding": [
           -0.22548234462738037,
		…
		-0.040389999747276306
         ]

       }<p>A key benefit of using Elastic with geo positions is the <a href="https://www.elastic.co/docs/explore-analyze/visualize/maps">Kibana Maps</a> visualization. In Kibana, our dataset looks like this (note that we also added geo shapes for the national parks):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0693f395474e2a79/6a170af514b270d8f1e3c615/6b81be988c32038740c27139f705a4fe2819d2aa-1600x1020.png" alt="" /><p>Zooming in, we can see the same document as before in Yellowstone. Additionally, Yellowstone Park’s (approximate) shape is also drawn in the layer below.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd20c39432496c6a5/6a170af7964cea382d08bb9f/53458b9fe44ac666a2baec0706b066e2c2ef6cb2-1600x1280.png" alt="" /><h2>System architecture</h2><h3>Indexing pipeline</h3><p>The <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/upload_documents.py">indexing pipeline</a> will handle the vectorization of both the image and description. It will also add more metadata to the image to create a document and index it to Elastic:</p><p>1. The starting point is pairs of <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/tree/main/images_metadata">images and metadata from national parks.</a> This metadata includes the geolocation, title of the image, and a description.</p><p>2. We feed the image and description to the CLIP model (<a href="https://huggingface.co/openai/clip-vit-base-patch32">openai/clip-vit-base-patch32</a>) to obtain an embedding of each in the same vectorial space of 512 dimensions. You can see the complete source code of this step <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/clip_processor.py">here</a>.</p>def create_image_embedding(image_path):
    image = Image.open(image_path).convert('RGB')
    inputs = processor(images=image, return_tensors="pt", padding=True, truncation=True, use_fast=True)
    with torch.no_grad():
        outputs = model.get_image_features(**inputs)
    return outputs.numpy().flatten()<p>The process to generate an embedding from our image is:</p><ul><li><p>Load the image in RGB format using <strong>Image.open()</strong></p></li><li><p>Process the image by converting it into tensors, which is the format the model expects, using <strong>processor()</strong></p></li><li><p>Extracts a dense vector representation in 512 dimensions from the image using <strong>model.get_image_features()</strong></p></li><li><p>At the end, converts the PyTorch tensor into a flattened numpy array using outputs.<strong>numpy().flatten() </strong></p></li></ul>def create_text_embedding(text):
    # Process the text
    inputs = processor(text=[text],  return_tensors="pt", padding=True, truncation=True)
    # Generate embedding
    with torch.no_grad():
        text_features = model.get_text_features(**inputs)
        # Normalize the embedding (CLIP embeddings are typically normalized)
        text_features = text_features / text_features.norm(dim=-1, keepdim=True)
    # Convert to numpy array
    embedding = text_features.numpy().flatten()

    return embedding<p>The process to generate an embedding from text is:</p><ul><li><p>Processes the input text, tokenizing it and converting it to tensors using <strong>processor()</strong></p></li><li><p>Parses the tokenized text using the model to extract its semantic features using <strong>model.get_text_features()</strong>. The resulting embedding also has 512 dimensions.</p></li><li><p>Normalizes the embedding so the dot similarity can be computed using <strong>text_features / text_features.norm()</strong></p></li><li><p>Finally, it converts the embedding into a flattened numpy array using <strong>text_features.numpy().flatten()</strong> </p></li></ul><p>We chose this model because it is a multimodal model that maximizes the similarity between image and text. This way, a description of an image and the image itself tend to generate embeddings that are close in the vector space. </p><p>3.  We merge all the metadata, the description, geoposition, and embeddings from the image and description in a JSON file</p><p>We index the JSON file to Elastic using:</p>es.index(document=doc, index=index)<p>Where doc is the metadata for each image.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteca5ba5008a12b6c/6a170af9509168d4dfe1bad4/34d59b01d1b86427e0ac3903cfcda30e28acb6e7-492x801.png" alt="" /><h3>Search pipeline</h3><p>This stage will handle the user’s query, create the search, and generate a response from the search results. The LLM used is <a href="https://huggingface.co/deepcogito/cogito-v1-preview-llama-3B">cogito:3b</a> with Ollama, though it could be easily replaced by any remote model—like Claude or ChatGPT. We chose this particular model because it’s lightweight and it excels at general tasks (as is expected from an assistant) compared to similar models (like Llama 3.2 3B). This means we get proper results without a long waiting time, and everything is running locally!</p><p>The pipeline works like this:</p><p>1. We receive an input from the user: <code>Where can I see mountains in Washington State?</code>.</p><p>2. We <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/LLM_conversation.py#L65-L106">feed</a> the user input and a <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/LLM_conversation.py#L7-L52">dictionary</a> of the parks, including their states and geolocations (defined in the same Python file), to the LLM with instructions to extract parameters for the Elastic query. The exact <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/LLM_conversation.py#L71-L90">prompt</a> is:</p>”””You are going to extract data from a user query for a national parks search system. 

Available National Parks:
{parks_info}

Extract the following information and format it as JSON:
- context_search: the main activity or interest (e.g., "hike","walk dog"," or"camping")
- distance_km: estimated search radius in kilometers (default: 100 if not specified)
- location_type: specific state, city, or region mentioned
- reference_location: if a city is mentioned, include it (e.g., "Boston","Denver")
- relevant_parks: list of park IDs that might be relevant based on location (use the exact park IDs from the list above)

Examples:
User query: "Where can I hike in Utah?"
Response: {{"context_search": "hike", "distance_km": 100, "location_type": "Utah", "reference_location": null, "relevant_parks": ["arches_national_park", "canyonlands_national_park"]}}

Only respond with valid JSON. No additional text. If a city is mentioned, use the state that city is in as the location_type.

User query: {query}”””<p>And this is an example of data in the parks_info dictionary:</p> { 
    "mt_rainier_national_park": {
        "coordinates": (46.8523, -121.7603),
        "state": "Washington"
    }
  }<p>The model extracts the following data from the prompt above:</p>{
 'context_search': 'mountains', 
 'distance_km': 100, 
 'location_type': 'Washington', 
 'reference_location': None, 
 'relevant_parks': ['mt_rainier_national_park']
}<p>3. We use the context_search parameter to generate a new embedding using the same CLIP model.</p><p>4. We extract the coordinates from the <code>parks_info</code> dictionary.</p><p>5. We use all these parameters to <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/rag_search_execution.py">create an Elasticsearch query</a>. This is the heart of the RAG feature:</p><ul><li><p>We create a <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/rag_search_execution.py#L37-L39">geo_distance filter</a> using the coordinates and the 'distance_km' parameter.</p></li><li><p>We create a <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/rag_search_execution.py#L42-L45">match text query</a> against the ‘generated_description’ field.</p></li><li><p>We create a <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/rag_search_execution.py#L48">standard retriever</a> that uses the text query from the previous step and the geo_distance filter.</p></li><li><p>We create <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/rag_search_execution.py#L63-L81">two knn retrievers</a> that use the embedding created in step 4 and match it against the image embedding  and text embedding indexed on each document. Each retriever also uses the geo_distance filter.</p></li><li><p>We use an <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/rag_search_execution.py#L55-L85">RRF retriever</a> to combine the resulting datasets of all the other retrievers.</p></li></ul><p>This whole process is executed in the <strong>rrf_search() </strong>function:</p>def rrf_search(index_name, lat, lon, distance, text_query, k=10,
               num_candidates=100):
    """
    Create an RRF search object bound to a specific index. Then executes the search. 

    Args:
        index_name (str): Name of the Elasticsearch index
        lat (float): Latitude for geo filtering
        lon (float): Longitude for geo filtering
        distance (int/str): Distance for geo filtering
        text_query (str): Text to search in description fields
        k (int): Number of top results for KNN search
        num_candidates (int): Number of candidates for KNN search

    Returns:
        Search: List of results frm Elasticsearch
    """

    embedding = create_text_embedding(text_query).tolist()

    # Create geo distance query
    geo_filter = Q('geo_distance',
                   distance=distance,
                   geolocation={'lat': lat, 'lon': lon})

    # Create text search queries
    text_queries = [
        Q('match', generated_description=text_query),
        Q('match', description=text_query)
    ]

    # Create boolean query for standard search
    standard_query = Q('bool', filter=[geo_filter], should=text_queries)

    # Create search object bound to index
    s = Search(index=index_name)
    s = s.source(["image_filename", "generated_description"])
    # Build RRF configuration
    retrievers = [
        # Standard retriever
        {
            "standard": {
                "query": standard_query.to_dict()
            }
        },
        # Text KNN retriever
        {
            "knn": {
                "filter": geo_filter.to_dict(),
                "field": "text_embedding",
                "query_vector": embedding,
                "k": k,
                "num_candidates": num_candidates
            }
        },
        # Image KNN retriever
        {
            "knn": {
                "filter": geo_filter.to_dict(),
                "field": "image_embedding",
                "query_vector": embedding,
                "k": k,
                "num_candidates": num_candidates
            }
        }
    ]

    # Apply RRF configuration
    s = s.extra(retriever={'rrf': {'retrievers': retrievers}}, size=3)

    #print(s.to_dict())

    es = Elasticsearch(cloud_id=cloud_id, api_key=api_key)

    results = s.using(es).execute()["hits"]["hits"]

    return results<p>At the end, we obtain a query like this:</p>{
 "retriever": {
   "rrf": {
     "retrievers": [
       {
         "standard": {
           "query": {
             "bool": {
               "filter": [
                 {
                   "geo_distance": {
                     "distance": "100km",
                     "geolocation": {
                       "lat": 46.8523,
                       "lon": -121.7603
                     }
                   }
                 }
               ],
               "should": [
                 {
                   "match": {
                     "generated_description": "mountains"
                   }
                 }
               ]
             }
           }
         }
       },
       {
         "knn": {
           "filter": {
             "geo_distance": {
               "distance": "100km",
               "geolocation": {
                 "lat": 46.8523,
                 "lon": -121.7603
               }
             }
           },
           "field": "text_embedding",
           "query_vector": [
             0.01967986486852169,
             ...
             0.00988344382494688],
           "k": 10,
           "num_candidates": 100
         }
       },
       {
         "knn": {
           "filter": {
             "geo_distance": {
               "distance": "100km",
               "geolocation": {
                 "lat": 46.8523,
                 "lon": -121.7603
               }
             }
           },
           "field": "image_embedding",
           "query_vector": [
             0.01967986486852169,
             ...
             0.00988344382494688],
           "k": 10,
           "num_candidates": 100
         }
       }
     ]
   }
 },
 "size": 3,
 "_source": [
   "image_filename",
   "generated_description"
 ]
}<p>6. <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/LLM_conversation.py#L161-L220">Afterwards</a>, we feed the documents obtained from Elastic and the user’s original query to the LLM with this <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/LLM_conversation.py#L183-L205">prompt</a>:</p>f"""You are a helpful assistant for national parks activities. Based on the search results below, provide a comprehensive and helpful response to the user's original query.

Original User Query: {original_query}

Search Parameters Used:
- Activity/Interest: {search_params.get('context_search', 'N/A')}
- Search Distance: {search_params.get('distance_km', 'N/A')} km
- Location: {search_params.get('location_type', 'N/A')}

Search results: {results_text}

Instructions:
- Provide a natural, conversational response
- Recommend specific activities and locations based on the search results only
- Include practical information when available
- Do not suggest alternatives if no results were found
- Be enthusiastic and helpful about national parks experiences
- Keep the response focused and not too lengthy
- Structure your response separating your suggestions per national park
- Do not include anything about national parks that are not in the results"""<p>7.    Finally, the LLM <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/LLM_conversation.py#L208-L220">creates a response</a> from the search results:</p>I'd be happy to help you find mountains in Washington State! Based on the search results, here are some fantastic locations:
Mount Rainier National Park is a must-visit destination for mountain lovers. Paradise Valley offers breathtaking views of the Tatoosh Mountain Range and Mount Rainier itself. The best time to visit is during late spring when the wildflowers bloom.

This location offers incredible opportunities to see mountains up close and personal - whether you're hiking, camping, or simply taking in the breathtaking scenery. Would you like more specific information about this park?<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt47eb5192c8735187/6a170afa14b2701ce5e3c619/29da726ea6b572467a0ab2c98b3fab3ab30504bb-498x881.png" alt="" /><h3>Web application</h3><p>A <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/streamlit_app.py">front-end based on Streamlit</a> handles the user input, runs the search pipeline to obtain the LLM final response, and displays images from the search results with their descriptions.</p><p>You can find the application source code and instructions <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/README.md"><em><strong>here</strong></em></a>.</p><h3>Multimodal RAG and geospatial search usage example</h3><p><strong>Query: </strong>Any places to ride a boat in Oregon?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbb53db2c36ec4a20/6a170afc1949f752b0e7aa32/9ad8f3bf4d2ffb5cf9e644afb77a8143881ad493-741x369.png" alt="" /><p>Here, the LLM extracted these parameters:</p>{
 'context_search': 'boat ride', 
 'distance_km': 100, 
 'location_type': 'Oregon', 
 'reference_location': None, 
 'relevant_parks': ['crater_lake_national_park']
}<p>And the search centered on Crater Lake National Park, so the response comes only from this national park in Oregon. This way, the system makes sure that it responds to the user under the given constraints and does not mention other parks where a boat ride is possible, but are not in Oregon.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4af44b69b02fa51c/6a170afe7d8d67249670e706/a055cb82649b0eb1d9f8c26bdd7a3b8804c8c09b-715x619.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd6672c7f4bda9d50/6a170affb339d566a6769fb6/7e2c54291c33b33c7cd90ee2d7a64ca6b215074f-748x596.png" alt="" /><h2>Conclusion</h2><p>In this article, we saw how integrating multimodal RAG capabilities with Elasticsearch's robust geospatial features significantly enhances the relevance and accuracy of search results in RAG systems. By combining image and text vector search with lexical search and precise geo-filtering, the system can provide highly contextualized answers. This approach not only minimizes hallucinations but also leverages Elasticsearch's diverse geo-query options and Kibana's visualization tools to deliver a comprehensive and user-centric search experience.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/multimodal-rag-elasticsearch-geospatial</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multimodal-rag-elasticsearch-geospatial</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt054e0e3f92147965/6a170b01961e694ad1c4cf2a/1a50013786b02c4a3a4a2912e279edd9f9d0a44d-1000x628.png" length="0" type="image/png"/>
    <pubDate>Wed, 10 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Transforming data interaction: Deploying Elastic’s MCP server on Amazon Bedrock AgentCore Runtime for crafting agentic AI applications]]></title>
    <description><![CDATA[Transform complex database queries into simple conversations by deploying Elastic's search capabilities on Amazon Bedrock AgentCore Runtime platform.]]></description>
    <content:encoded><![CDATA[<p>Imagine asking your data questions in plain English: "Fitness/diet recommendations based on my health goals," or "Help find investment opportunities based on my risk level?" and getting accurate answers without writing a single query. Today, we'll explore how to achieve this by deploying Elastic's <a href="https://www.anthropic.com/news/model-context-protocol">Model Context Protocol</a> (MCP) server on <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-how-it-works.html">Amazon Bedrock AgentCore Runtime</a>, creating a powerful bridge between conversational AI and your data.</p><p>At its core, this solution combines the power of Elasticsearch's search capabilities with Amazon's serverless AI infrastructure. Here's how it works:</p><ul><li><p>Your natural language questions are processed through the MCP server that is deployed on Amazon Bedrock AgentCore Runtime</p></li><li><p>The MCP server translates these questions into precise Elasticsearch queries</p></li><li><p>Results are returned in human-readable format, making your data instantly accessible</p></li><li><p>All of this happens in a secure, scalable environment that's production-ready, deployed on Amazon Bedrock AgentCore Runtime</p></li></ul><p>In this blog post, we'll explore how to:</p><ul><li><p>Deploy Elastic's MCP server on Amazon Bedrock AgentCore Runtime</p></li><li><p>Transform local MCP prototypes into production-ready solutions</p></li><li><p>How to Build Conversational Interfaces on top of Elasticsearch</p></li><li><p>Implement secure, scalable AI agent architectures</p></li></ul><h2>Background</h2><h3>Model Context Protocol (MCP)</h3><p>MCP is an open protocol that revolutionizes how businesses interact with their data through AI. Unlike traditional Retrieval-Augmented Generation (RAG) systems that simply retrieve documents, MCP enables AI agents to dynamically construct and execute complex tasks in real-time, mirroring the flexibility of human problem-solving</p><p>In practice, this means a business analyst can ask a series of increasingly specific questions about market trends, and the MCP-powered system will intelligently select and combine the appropriate data sources and analytical tools to provide comprehensive answers, while still maintaining context, allowing for follow-up questions without repetition.</p><p>For instance, when analyzing a product launch, the AI might integrate data from sales reports, customer feedback, and social media sentiment, orchestrating multiple tools simultaneously to provide a holistic view, thus enabling businesses to uncover deeper insights and make informed decisions, all through natural language interactions with their data systems.</p><h3>Agents</h3><p>Agents are AI-powered software applications that can think, plan, and act to achieve specific goals with minimal human supervision. They use foundation models (advanced AI models) to understand and complete complex tasks.</p><p>There are two types of AI agents.</p><p><strong>Knowledge AI agents</strong></p><p>These agents focus on enterprise knowledge. They gather context from company data — documents, logs, dashboards, communications, and customer records — and use that information to complete business tasks.</p><p><em>Example: A knowledge AI agent that can search across contracts, policies, and past tickets to help a customer support representative instantly resolve an issue.</em></p><p><strong>General AI agents</strong></p><p>These agents go further. They can understand goals and autonomously execute tasks on behalf of a user in broader, cross-domain workflows.</p><p><em>Example: A general AI agent that books travel, manages schedules, and negotiates with other systems to complete a user’s request end-to-end.</em></p><h3>Elastic’s role in agentic AI</h3><p><strong>For knowledge AI agents</strong>: Elastic enables secure access to enterprise data, retrieves relevant context, and grounds responses in facts.</p><p><strong>For general AI agents</strong>: Elastic serves as the knowledge store and context engine, providing trusted information so agents can perform more complex, goal-driven tasks.</p><p>In short, Elastic isn’t just storing data; it’s making enterprise knowledge usable, actionable, and AI-ready. This is the foundation for building intelligent agents that both understand and act.</p><h3>AWS partnership and MCP server</h3><p>Elastic has earned the AWS Generative AI Competency status. This recognition is awarded to AWS partners who deliver cutting-edge generative AI solutions that drive measurable gains in business efficiency, creativity, and productivity</p><p>Elastic also integrates with the Model Context Protocol (MCP), providing a seamless way for AI agents and applications to interact with Elasticsearch data through natural language conversations.</p><p>With the MCP server, you can connect to Elasticsearch directly from any MCP client — such as Claude Desktop, MCP Inspector, or an agentic application. The Elasticsearch MCP server is free to use (though infrastructure and Elasticsearch cluster costs may apply).</p><p>And with Amazon Bedrock models (such as Anthropic’s Claude) supporting MCP clients, organizations can now deploy intelligent, data-aware agents more easily and powerfully than ever before.</p><h3>Amazon Bedrock AgentCore</h3><p><a href="https://aws.amazon.com/bedrock/agentcore/?trk=e61dee65-4ce8-4738-84db-75305c9cd4fe&amp;sc_channel=el">Amazon Bedrock AgentCore</a> is an enterprise-grade orchestration platform designed for scalable AI agent deployment and management. </p><ul><li><p>The platform provides serverless runtime environments with session isolation capabilities, enabling concurrent agent operations across multiple frameworks.</p></li><li><p>It implements memory management systems for both session-state and persistent storage, facilitating context-aware model interactions and learning capabilities.</p></li><li><p>The architecture includes observability features with granular logging, metrics collection, and advanced debugging capabilities for agent trajectory analysis.</p></li><li><p>The platform's robust identity and access management layer enables secure service-to-service authentication and fine-grained authorization controls for AWS and third-party service integrations.</p></li></ul><p>It features a protocol-agnostic gateway for API transformations and tool discovery, supporting MCP-compliant interfaces. The infrastructure also includes containerized browser instances for web automation workflows and isolated compute environments for secure code execution. This end-to-end solution eliminates the need for building custom infrastructure components while maintaining enterprise security and compliance standards.</p><h2><strong>Solution overview</strong></h2><h3><strong>High-level architecture</strong></h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb55c578d65fd5085/6a17f3046864a47abeb688cc/40812bd3424c21232c6d03e24b74e01034ab2c76-1600x503.png" alt="" /><p>The architecture consists of four main components:</p><ol><li><p><strong> Python client: </strong>Handles user interactions and AWS authentication</p></li><li><p><strong>Amazon Bedrock</strong> AgentCore Runtime: Provides serverless hosting and session management</p></li><li><p><strong>Elastic MCP server</strong>: Processes MCP protocol requests and queries Elasticsearch</p></li><li><p><strong>Elasticsearch cluster</strong>: Stores and indexes the searchable data</p></li></ol><h3>Step-by-step workflow walkthrough</h3><ol><li><p>User authenticates using an authentication mechanism such as OAuth.</p></li><li><p>User access secured Client application running in a Customer AWS account using authenticated credentials.</p></li><li><p>The client application invokes a Supervisor Agent that further invokes and orchestrates other Agents.</p></li><li><p>All the agents are deployed on Amazon AgentCore Runtime and their tools are made available for the Agents, including Elastic’s MCP server and its tools.</p></li><li><p>Foundation Models are available for the agentic AI application through Amazon Bedrock.</p></li><li><p>Elastic Cloud is deployed on AWS and its endpoints are accessed by the Elastic MCP Server. Elastic MCP server automatically crafts the required queries, runs the queries against the Elastic data and fetches the response back to the Supervisor Agent.</p></li><li><p>Supervisor Agent responds back to the end user via the Client Application.</p></li></ol><h2>Implementation guide</h2><p>Please refer to <a href="https://github.com/aws-samples/aws-generativeai-partner-samples/tree/main/elastic/mcp/elastic-mcp-on-agentcore">this GitHub repo</a> to get a hands-on experience of how this solution can be implemented. Pay close attention to the <a href="https://github.com/aws-samples/aws-generativeai-partner-samples/tree/main/elastic/mcp/elastic-mcp-on-agentcore#-prerequisites">prerequisites</a> before getting started.</p><h3>Step 1: Deploy Elastic MCP server to ECR</h3><p>The <a href="https://github.com/aws-samples/aws-generativeai-partner-samples/blob/main/elastic/mcp/elastic-mcp-on-agentcore/deploy-elastic-mcp.sh">automated deployment script</a> handles the entire container build and upload process:</p>./deploy-elastic-mcp.sh<p>Here is what the script does when you execute:</p><ol><li><p>Downloads the official Elastic MCP server repository</p></li><li><p>Builds Docker container using <code>Dockerfile-8000</code></p></li><li><p>Creates ECR repository with image scanning enabled</p></li><li><p>Uploads container image to ECR with proper tagging</p></li></ol><h3>Step 2: Create AgentCore Runtime host</h3><p>Navigate to the AWS Console and configure your AgentCore Runtime:</p><p>1. Access AgentCore: Go to Amazon Bedrock AgentCore &gt; Build and Deploy &gt; Agent Runtime &gt; Host Agent</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4750c7fd2c1cdc5c/6a17f3062f4a5c7105fa8a1a/6818bafc00fdcba4c311b8ea1199c3d4ba9e979c-1428x396.png" alt="" /><p>2. Basic configuration: Click on “Host Agent” and give it a meaningful name if you prefer. Point to the Container Image you have uploaded to Amazon ECR.</p>   Name: hosted_agent_elastic_mcp
   Container Image: [ECR URI from Step 1]
<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt22fa13daf65713e3/6a17f30896142a918aeb1c29/beea587f4d1c022216c03f8f2895072c7075c1aa-1600x654.png" alt="" /><p>3. Service role: Choose "Create and use a new service role"</p><p>4. Protocol settings: Choose MCP, and for the Inbound Identity, select <code>Use IAM username</code></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc4e24abbc56b21b2/6a17f309414c647a149452a9/a0cc50ebdfaf657cb2a5fbb0e60f1b5b45aa60fe-1600x505.png" alt="" /><p>5. Environment variables: Finally, configure your Elasticsearch endpoints and pass them as environment variables to your Docker container.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdec0c051b141511c/6a17f30b414c6499b59452ad/b3c1e97513b516bf5604c87950811a444b93745e-1432x358.png" alt="" /><p>6. After creating the host agent, go ahead and copy the Agent Runtime ARN from the "View invocation code" section. Here is an example:</p>arn:aws:bedrock-agentcore:us-west-2:XXXXXXXXXX:runtime/hosted_agent_elastic_mcp-xWSaxNGjf5<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte88f4a7205373342/6a17f30d96142a177feb1c2d/27330cf1d735d1240f4ce292f032d0a22cf6d986-1600x651.png" alt="" /><h3>Step 3: Configure Python client</h3><p><strong>Install dependencies:</strong></p><p>Go ahead and initialize a virtual environment and install the Python libraries.</p>python3 -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install -r requirements.txt<p>Update Agent ARN in <code>my_mcp_client_remote.py</code>:</p><p>Next, update the Python MCP client with the agent ARN you obtained in the previous steps.</p>agent_arn = "arn:aws:bedrock-agentcore:us-west-2:XXXXXXXX:runtime/hosted_agent_elastic_mcp-xWSbYNGjf5"<p>Here are the key client components in this Python file.</p><p><strong>AWS authentication class:</strong></p>class AWSAuth:
    def __init__(self, service='bedrock-agentcore', region='us-west-2'):
        self.session = boto3.Session()
        self.credentials = self.session.get_credentials()
        self.region = region
        self.service = service
        
    def get_auth_headers(self, url, method='POST', body=None):
        request = AWSRequest(method=method, url=url, data=body)
        SigV4Auth(self.credentials, self.service, self.region).add_auth(request)
        return dict(request.headers)<p><strong>MCP request formation / payload:</strong></p>chat_request = {
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
        "name": "search",
        "arguments": {
            "index": "events",
            "query_body": {
                "query": {
                    "bool": {
                        "should": [
                            {"match": {"name": "paris"}},
                            {"match": {"description": "paris"}},
                            {"match": {"venue": "paris"}},
                            {"match": {"address": "paris"}}
                        ]
                    }
                },
                "size": 10
            }
        }
    }
}<h3>Step 4: Run the client</h3><p>Execute the <a href="https://github.com/aws-samples/aws-generativeai-partner-samples/blob/main/elastic/mcp/elastic-mcp-on-agentcore/my_mcp_client_remote.py">Python client</a> to test the integration. This Python program implements an asynchronous client for interacting with Amazon Bedrock AgentCore, specifically designed to query event information. The code utilizes AWS SigV4 authentication and consists of two main functions: <code>test_mcp_endpoint()</code> and <code>chat_with_agentcore()</code>. The first function demonstrates basic API interaction by listing available tools and performing a search query, while the second function implements a more sophisticated search functionality specifically for events in Paris.</p><p>The program uses the <code>httpx</code> library for async HTTP requests and handles Server-Sent Events (SSE) responses, parsing and displaying event details including names, venues, dates, and descriptions. The authentication is managed through a custom <code>AWSAuth</code> class that handles AWS SigV4 signing of requests. The code includes comprehensive error handling and formatted output display, making it suitable for both testing and production use cases.</p>python my_mcp_client_remote.py<h2>Use case demonstrations</h2><h3>Use case 1: Data discovery</h3><p>Scenario: Finding events in a specific city using natural language.</p><p>Query: "Events in Paris"</p><p>MCP request: Here is the payload you supply to the Amazon Bedrock Agentcore Runtime.</p>{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "index": "events",
      "query_body": {
        "query": {
          "bool": {
            "should": [
              {"match": {"name": "paris"}},
              {"match": {"description": "paris"}},
              {"match": {"venue": "paris"}},
              {"match": {"address": "paris"}}
            ]
          }
        }
      }
    }
  }
}<p><strong>Response</strong>: And here is the response you get after Elastic’s MCP server runs a query in Elastic Search AI Platform and returns the result.</p>🎉 I found 1 events in Paris:


1. Paris Fashion Week
   📍 Murray-Howell Theater - 17814 Mills Mountains Apt. 815, Poncetown, DE 29241
   📅 2026-04-02
   📝 Major fashion event showcasing the latest collections from top designers.
   💰 $$$
   🎫 https://tickets.reed.net/event/DEST0001_EVT002<h3>Use case 2: Elastic MCP tool discovery</h3><p><strong>Scenario</strong>: Discovering the available MCP tools that Elastic’s MCP server offers.</p><p><strong>MCP request:</strong></p>{
  "jsonrpc": "2.0",
  "method": "tools/list",
  "id": 1
}<p>This returns a list of available tools that the MCP server provides, enabling dynamic tool discovery.</p><h3>Use case 3: Complex search queries</h3><p><strong>Scenario</strong>: Advanced filtering with multiple criteria.
You can run more advanced Elastic Search Query Language based queries, like one shown below.</p><p><strong>Query</strong>: Events with specific price ranges, dates, and categories.</p><p><strong>MCP request:</strong></p>{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "index": "events",
      "query_body": {
        "query": {
          "bool": {
            "must": [
              {"range": {"start_date": {"gte": "2026-01-01"}}},
              {"term": {"price_range": "$$$"}}
            ],
            "should": [
              {"match": {"type": "Fashion"}},
              {"match": {"type": "Music"}}
            ]
          }
        },
        "size": 20
      }
    }
  }
}<h2>How it works (technical deep dive)</h2><h3>MCP implementation</h3><p>The Model Context Protocol uses JSON-RPC 2.0 format for all communications:</p>Client Request → AgentCore → MCP Server → Elasticsearch → Response Chain<p><strong>Key protocol features:</strong></p><ul><li><p>Stateless operation: Each request is independent with session isolation</p></li><li><p>Tool discovery: Dynamic discovery of available capabilities</p></li><li><p>Structured responses: Consistent response format across all tools</p></li><li><p>Error handling: Standardized error reporting and recovery</p></li></ul><p><strong>AWS authentication flow:</strong></p># 1. Create AWS request object
request = AWSRequest(method='POST', url=mcp_url, data=body)


# 2. Apply SigV4 authentication
SigV4Auth(credentials, 'bedrock-agentcore', region).add_auth(request)


# 3. Extract headers for HTTP client
headers = dict(request.headers)
headers["Content-Type"] = "application/json"
headers["Accept"] = "application/json, text/event-stream"<h3>Session management</h3><p>AgentCore automatically adds <code>Mcp-Session-Id</code> headers for session isolation:</p><ul><li><p>Each client session gets a unique identifier</p></li><li><p>Stateless servers can maintain conversation context</p></li><li><p>Automatic cleanup of inactive sessions</p></li></ul><h3>Response processing pipeline</h3><ol><li><p>Server-Sent Events (SSE): Responses come as <code>data: {...}</code> in JSON format</p></li><li><p>JSON parsing: Extract JSON from SSE wrapper</p></li><li><p>Content extraction: Parse the MCP result structure</p></li><li><p>Data formatting: Convert Elasticsearch results to user-friendly format</p></li></ol># Parse SSE response
if response.text.startswith('data: '):
    json_part = response.text[6:]  # Remove 'data: ' prefix
    response_json = json.loads(json_part)
    
    # Extract search results
    result = response_json.get('result', {})
    for content_item in result['content']:
        if content_item['type'] == 'text':
            # Process and format results
            search_results = json.loads(content_item['text'])<h3>Cleanup</h3><p>After you have played around with this setup, if you would like to clean up the environment, please follow the steps outlined below.</p><p><strong>Delete AgentCore Runtime:</strong></p><ol><li><p>Navigate to Amazon Bedrock AgentCore in AWS Console</p></li><li><p>Select your agent runtime</p></li><li><p>Click "Delete" and confirm</p></li></ol><p><strong>Remove ECR repository:</strong></p>aws ecr delete-repository \
    --repository-name elastic-mcp-server \
    --region us-west-2 \
    --force<p><strong>Clean local environment:</strong></p># Remove virtual environment
deactivate
rm -rf venv

# Remove cloned repository
rm -rf mcp-server-elasticsearch

# Remove Docker images
docker rmi elastic-mcp-server:latest
docker rmi [ECR_URI]:latest<h2>Conclusion</h2><p>By deploying Elastic's MCP server on Amazon Bedrock AgentCore Runtime, we've created a powerful, scalable, production-ready solution for natural language interaction with Elasticsearch data. This implementation opens up new possibilities for data exploration and analysis, making complex queries accessible through simple conversations.</p><p>Key takeaways include:</p><ul><li><p>Seamless integration: MCP protocol enables natural language querying of complex data</p></li><li><p>Production scalability: AgentCore provides enterprise-grade hosting with minimal configuration</p></li><li><p>Developer productivity: Transform local prototypes to production with minimal code changes</p></li><li><p>Security first: Built-in AWS security and authentication mechanisms</p></li></ul><p>Potential applications can be in any of the following areas of implementation: </p><ul><li><p>Customer support: Natural language querying of support ticket databases</p></li><li><p>Business intelligence: Conversational analytics for business metrics</p></li><li><p>Content discovery: Intelligent search across document repositories</p></li><li><p>IoT data analysis: Natural language queries for sensor and telemetry data</p></li></ul><p>Additional resources:</p><ul><li><p><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-amazonbedrock">Amazon Bedrock integration documentation</a></p></li><li><p><a href="https://github.com/elastic/mcp-server-elasticsearch?tab=readme-ov-file#elasticsearch-mcp-server">Elastic MCP server documentation</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-mcp-server-amazon-bedrock-agentcore-runtime</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-mcp-server-amazon-bedrock-agentcore-runtime</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Udayasimha Theepireddy,Srinivas Pendyala,Matt Ryan,Ganesh Ramesh Shenoy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt576d87464937da1e/6a17f30e0b0bed0469dd36b1/7086754859ba2cbbaf673c843013462892738c30-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 04 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Using ES|QL COMPLETION + an LLM to write a Chuck Norris fact generator in 5 minutes]]></title>
    <description><![CDATA[Discover how to use the ES|QL COMPLETION command to turn your Elasticsearch data into creative output using an LLM in just a few lines of code.]]></description>
    <content:encoded><![CDATA[<p>What if you could turn your Elasticsearch data into creative output using an LLM—in just a few lines of code? With the new <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion">COMPLETION command</a> in <strong>ES|QL</strong>, now you can.</p><p>Let’s build something fun to show it off: a Chuck Norris fact generator. We'll combine movie descriptions with a GPT model to generate facts so legendary even Rambo would be impressed.</p><h2>What you'll need</h2><ul><li><p>Access to an LLM (like OpenAI’s GPT-4o in our example below)</p></li><li><p>A dataset of movie descriptions </p></li></ul><p>You can download a <a href="https://www.kaggle.com/datasets/ursmaheshj/top-10000-popular-movies-tmdb-05-2023?resource=download">sample dataset</a> from Kaggle and upload it to your Elasticsearch cluster using the Data Visualizer in Kibana or the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"><code>_bulk</code></a><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"> API</a>.</p><h2>Setting up the inference endpoint</h2><p>Before you can run the <code>COMPLETION</code> command, you need to create an inference endpoint for the model you want to use via the <code>_inference</code>  API.</p><p>Here’s how to set up GPT-4o with OpenAI:</p><p>Once this is in place, you can reference <code>my-gpt-4o-endpoint</code> directly in your query.</p><h2>The query</h2><p>Here’s the magic in action. This single <strong>ES|QL</strong> query handles the entire workflow: it finds a movie based on your input, constructs a prompt from its description, and then calls the LLM to generate a legendary Chuck Norris fact. Below is the full <strong>ES|QL</strong> query that powers our Chuck Norris fact generator. It takes in a movie query, retrieves the most relevant description, turns it into a prompt, and sends it off to the LLM—all in a single, piped query.</p><p>Here’s what comes back:</p><p>Yes, the model really said that. 💪🐐🚁</p><h2>Dissecting the query</h2><p>Let’s dissect the query and break down what’s happening, step by step.</p><h3>Step 1: Retrieve relevant movie data</h3><p>We begin by searching for the most relevant movie for the user query.
We use the <code>MATCH</code> function to search both the title and overview fields for the text provided by the <code>query</code> parameter, keeping only the first result, sorted by relevance using the metadata <code>_score</code> field:</p><p>This narrows down our dataset to the best match, giving us the movie's title and description, which will become the context for the LLM.</p><h3>Step 2: Build the prompt from the context</h3><p>Now we create the input prompt for the LLM by concatenating a static instruction provided as a query parameter, denoted by <code>?instruction</code>, with the movie’s overview:</p><p>This creates a new <code>prompt</code> column combining the provided instruction with the overview field from the returned document, which for our request looks a bit like this:</p>Generate a Chuck Norris Fact from the following description:
Combat has taken its toll on Rambo, but he's finally begun to find inner peace in a monastery. When Rambo's friend and mentor Col. Trautman asks for his help on a top secret mission to Afghanistan, Rambo declines but must reconsider when Trautman is captured.<p>You can easily swap in different instructions to change the tone or style of what the LLM generates by tweaking the instruction parameter. And because the prompt is just another <strong>ES|QL</strong> expression, you can compose it with any string-generating function—whether it’s simple concatenation, conditional logic, or even formatting based on your document content.</p><h3>Step 3: Generate text using the LLM</h3><p>Finally, we pass the prompt to the inference endpoint connected to our model using our new <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion"><code>COMPLETION</code></a><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion"> command</a>, and select which fields to return:</p><p>The result? A Chuck Norris fact, rooted in your movie data without any extra tooling required.</p><p>This example also demonstrates the full power of ES|QL's piped structure. Each step flows naturally into the next, letting you express a full retrieval augmented generation (RAG) pipeline in a single, declarative query. It’s clean, composable, and stays entirely inside Elasticsearch.</p><h2>What’s next?</h2><p>While the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion">COMPLETION command</a> is still a tech preview, this new feature unlocks a whole new world of possibilities—from summarization and content generation to enrichment and storytelling. Try it yourself! Point it at your favorite movie, tweak the prompt, or go wild and generate haikus from SQL errors. The power is yours.</p><p>Let us know what you build! 💬</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Aurélien Foucret]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbca7a25e3c272aa8/6a17ffb1be60868d100049d2/6494d88d51edf6a5b31a92b8439792354eae7190-1536x1024.png" length="0" type="image/png"/>
    <pubDate>Thu, 28 Aug 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Failure store: see what didn’t make it]]></title>
    <description><![CDATA[Learn about failure store, a new feature in the Elastic Stack that captures and indexes previously lost events. ]]></description>
    <content:encoded><![CDATA[<p>If a tree falls in the woods and no one is around, does it make a sound? Yes, it does. Just like if a log message is emitted but fails to process into your observability platform, <strong>that log message really did happen</strong>—and, assuming it’s something important, you’ll almost certainly hear about it eventually.</p><p>Elastic is built to adapt to all kinds of data your systems emit: logs, metrics, traces, custom telemetry, and more. But when that data doesn’t match the expected shape due to a schema change, a misconfigured agent, or a rogue service emitting unexpected fields, it can fail to process and silently disappear.</p><p>That absence is a signal. But it’s also hard to detect, hard to debug, and hard to report on. And worse, it puts the burden on the client to figure out what happened.</p><p>That’s why we built <strong>failure store</strong>: a new way to capture, debug, and analyze failed events directly in the Elastic Stack. In this blog, we’ll go over Elastic’s failure store and explain how it provides visibility into data ingestion issues, helps debug schema changes, and enables teams to monitor data quality and pinpoint failure patterns.</p><h2>About failure store</h2><p><em>Failure store</em> gives you visibility into failed events that were previously only visible to the client sending the data and to dead letter queues. It works by capturing and indexing failed documents into dedicated `::failures` indices that live in your data stream alongside your production data. You can enable it per data stream or across multiple data streams with a single cluster setting.</p><h2>Why it matters</h2><p>Teams are often downstream from the source of truth. They don’t write the code; they just keep it all running. When upstream teams ship changes that break mappings or introduce unexpected fields, failures happen. But without access to the original failed data itself, debugging becomes guesswork.</p><p>Even worse, when data fails to be indexed, it doesn’t exist in your indexes—which means it’s much harder to measure the impact. You can’t track which streams are failing most often. You can’t quantify how broken your pipelines are. And you certainly can’t alert on what’s missing if the platform never saw it (except for alerts when data goes missing).</p><p>The failure store allows a developer to understand which data failed indexing and why, giving observability engineers the tools needed to quickly understand and fix ingestion failures. Triage also happens quickly since failures are stored in Elasticsearch, with no need to gather information from remote clients or shippers.</p><h2>Get started</h2><p><strong>Set up for new data streams…</strong></p>PUT _index_template/my-index-template
{
  "index_patterns": ["my-datastream-*"],
  "data_stream": { },
  "template": {
    "data_stream_options": {
      "failure_store": { // ✨
        "enabled": true 
      } 
    }
  }
}<p><strong>…or enable for existing data streams</strong></p><p>Enable failure store for individual data streams in stack management in Kibana or leverage the _data_stream API:</p>PUT _data_stream/my-existing-datastream/_options
{
  "failure_store": {
    "enabled": true
  }
}<p><strong>Enable failure store via cluster setting</strong></p><p>If you have a large number of existing data streams, you may want to enable their failure stores in one place. Instead of updating each of their options individually, set data_streams.failure_store.enabled to a list of index patterns in the cluster settings. Any data streams that match one of these patterns will operate with their failure store enabled.</p>PUT _cluster/settings
{
  "persistent" : {
    "data_streams.failure_store.enabled" : [ "my-datastream-*", "logs-*" ]
  }
}<p><strong>A failing document response</strong></p><p>After enabling the failure store, requests that previously would fail are now processed differently. The client now receives a <code>201 created</code> instead of a <code>400 Bad Request</code>. Especially if you’re using custom applications or our <a href="https://www.elastic.co/docs/reference/elasticsearch-clients">language clients</a>, make sure to update your code accordingly. When a document goes to the failure store, the response will contain the <code>failure_store: used</code> attribute.</p>{
  "_index": ".fs-logs-generic.otel-default-2025.07.31-000010",
  "_id": "2K9IYpgBfukt97YIaUPG",
  "_version": 1,
  "result": "created",
  "_shards": {
    "total": 2,
    "successful": 1,
    "failed": 0
  },
  "_seq_no": 0,
  "_primary_term": 1,
  "failure_store": "used"
}<p><strong>Search and filter failure data</strong> just like any other logs, with support for ES|QL and Kibana tools:</p>  FROM logs-generic.otel-default::failures<p>Data in the failure store comes with all the context to make debugging simple. Each ::failures index contains information about which pipeline failed, along with specific error messages, stack traces, and error types that help you identify patterns.</p><p>Are you getting lots of errors and aren't sure where to start? Use ES|QL and ML functions. With the data exposed in ES|QL, errors can be analyzed with ML capabilities such as <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/grouping-functions#esql-categorize">CATEGORIZE</a> to help parse errors and extract patterns. Read more about data remediation techniques in our <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/failure-store-recipes#failure-store-examples-remediation">documentation</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta20404a812af7309/6a170deda929cf3114ae0a5d/9a7c92485859ee13e971466c074bd001dbad12ba-1506x596.png" alt="Elastic failure store - example of query failures with ES|QL" /><p><strong>Control costs and retention</strong> using the same data stream lifecycle you're already using for your other data. Absent a custom retention, failure store data will stick around for 30 days.</p><p><strong>Monitor data quality over time</strong> with failure metrics and sortable dashboards by failure percentage to find new problem areas that require investigation. Read more about data quality monitoring in the <a href="https://www.elastic.co/docs/solutions/observability/data-set-quality-monitoring">documentation</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc5a3f8e6bfc435d/6a170def7d8d67042370e7a4/f6790c267346481ba3b54f203c0f30224215855b-1600x818.png" alt="Elastic failure store - example for data quality and failure summaries" /><h2>Learn more</h2><p>Failure store is available starting in Elastic 9.1 and 8.19 and will be enabled by default on <strong>logs-*-*</strong> indexes in an upcoming release. To learn more, check the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/failure-store">documentation</a> for setup instructions and best practices.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-failure-store</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-failure-store</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[James Baiera,Graham Hudgins]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a02efdbda3b403b/6a170df1961e6963e4c4cf8d/9ad9833bbf5e1fd93376b955500d6cbc70e19ec0-1200x628.png" length="0" type="image/png"/>
    <pubDate>Wed, 13 Aug 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch open inference API adds support for IBM watsonx.ai rerank models]]></title>
    <description><![CDATA[Explore how to use IBM watsonx™ reranking when building semantic search experiences in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Elastic announced Elastic Rerank in December 2024, which brings powerful semantic search capabilities with no required reindexing—delivering high relevance, top performance, and efficiency. The core set of capabilities powered by Elastic is now even more flexible, allowing developers to bring their own models from Cohere, Vertex AI, Hugging Face, <a href="https://www.elastic.co/search-labs/blog/jina-ai-embeddings-rerank-model-open-inference-api">Jina AI</a> and now IBM watsonx.ai. With our open Inference API, you get the control and choice to integrate, test, and optimize reranking for your needs.</p><p>Along with support for IBM watsonx™ <a href="https://www.elastic.co/search-labs/blog/ibm-watsonx-elasticsearch-inference-api">Slate embedding models</a>, Elasticsearch vector database powers <a href="https://www.elastic.co/blog/ibm-elasticsearch-partnership-conversational-search-watsonx-assistant">watsonx Assistant for Conversational Search</a>—now with semantic reranking for even better answer quality.</p><p>Reranking refines LLM responses by prioritizing the most relevant documents using advanced scoring methods, ensuring accurate responses in multi-stage retrieval, and making it broadly applicable even for datasets that you don’t want to reindex or remap.</p><p><a href="https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models-embed.html?context=wx#rerank">IBM watsonx offers high-quality reranker models</a> that accurately score and prioritize passages based on query relevance, helping refine search results for better precision. These models enhance tasks like semantic search and document comparison, making them essential for delivering highly relevant answers in AI-driven retrieval systems.</p><p>In this blog, we’ll explore how to use IBM watsonx™ reranking when building search experiences in the Elasticsearch vector database to reorder search results by meaning, giving you sharper, more context-aware answers without altering your existing index.</p><h2>How reranking can create powerful search experiences</h2><p>Semantic reranking is crucial because users expect the best answers at the top, and GenAI models require accurate results to avoid generating incorrect information. Semantic reranking provides consistent scoring, ensuring the most relevant documents are used by AI models and enabling effective cutoff points to prevent hallucinations.</p><h2>Prerequisites &amp; Creation of an Inference Endpoint</h2><p><a href="https://www.elastic.co/guide/en/serverless/current/elasticsearch-get-started.html"><strong>Create an Elasticsearch Serverless Project</strong></a>.</p><p>Elasticsearch Cloud Serverless offers fast query execution and seamless integration with the open Inference API, making it ideal for deploying reranking without infrastructure overhead.</p><h2>Generate an API key in IBM Cloud</h2><ul><li><p>Go to IBM watsonx.ai <a href="https://dataplatform.cloud.ibm.com/registration/stepone?context=wx">Cloud</a> and log in using your credentials. You will land on the welcome page.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta7ddd843ddb47a32/6a17e14b3e9e454aaaba13a6/f4039b1ee68fa91fcffc01708099dd302302ff19-1600x551.png" alt="Generating an API key in IBM Cloud." /><ul><li><p>Go to the <a href="https://cloud.ibm.com/iam/apikeys">API keys</a> page.</p></li><li><p>Create an API key.</p></li></ul><h2>Steps in Elasticsearch</h2><p>Using DevTools in Kibana, create an inference endpoint using the watsonxai service for reranking. This example uses the MS Marco MiniLM L-12 v2 model which is supported by IBM, for ensuring high relevance in passage retrieval.</p>PUT _inference/rerank/ibm_watsonx_rerank
{
    "service": "watsonxai",
    "service_settings": {
        "api_key": "&lt;api_key&gt;",
        "url": "xxx.ml.cloud.ibm.com",
        "model_id": "cross-encoder/ms-marco-minilm-l-12-v2",
        "project_id": "&lt;project_id&gt;",
        "api_version": "2024-05-02"
    }
}<p>You will receive the following response on the successful creation of the inference endpoint:</p>{
  "inference_id": "ibm_watsonx_rerank",
  "task_type": "rerank",
  "service": "watsonxai",
  "service_settings": {
    "url": "xxx.ml.cloud.ibm.com",
    "api_version": "2024-05-02",
    "model_id": "cross-encoder/ms-marco-minilm-l-12-v2",
    "project_id": "&lt;project_id&gt;",
    "rate_limit": {
      "requests_per_minute": 120
    }
  }
}<p>Let us now create an index.</p>PUT quotes-index
{
  "mappings": { 
    "properties": {
      "movie_title": {
        "type": "text"
      },
      "quotes": {
        "type": "text"
      }
    }
  }
}<p>Next, insert data into the created index.</p>PUT quotes-index/_doc/1
{
  "movie_title": "The Big Lebowski",
  "quotes": [
    "That rug really tied the room together",
    "Yeah, well, you know, that's just like, uh, your opinion, man"
  ]
}

PUT quotes-index/_doc/2
{
  "movie_title": "Star Wars",
  "quotes": [
    "These are not the droids you're looking for",
    "I have a bad feeling about this",
    "Do. Or do not. There is no try."
  ]
}

PUT quotes-index/_doc/3
{
  "movie_title": "The Avengers",
  "quotes": [
    "What's the matter, scared of a little lightning?",
    "Superheroes? In New York? Give me a break!"
  ]
}<p>Next, let’s search using a <code>text_similarity_reranker</code> retriever, which enhances search results by reranking documents based on semantic similarity to a specified inference text, using an ML model.</p><p>The retriever helps you configure both the retrieval and reranking of search results in a single API call.</p>POST quotes-index/_search
{
  "retriever": {
    "text_similarity_reranker": {
      "retriever": {
        "standard": {
          "query": {
            "match": {
              "quotes": "feeling lightning"
            }
          }
        }
      },
      "field": "quotes",
      "inference_id": "ibm_watsonx_rerank",
      "inference_text": "feeling lightning",
      "rank_window_size": 50
    }
  },
  "size": 50
}<p>Next, let’s verify the returned result.</p>{
  "took": 718,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 0.003072956,
    "hits": [
      {
        "_index": "quotes-index",
        "_id": "3",
        "_score": 0.003072956,
        "_source": {
          "movie_title": "The Avengers",
          "quotes": [
            "What's the matter, scared of a little lightning?",
            "Superheroes? In New York? Give me a break!"
          ]
        }
      },
      {
        "_index": "quotes-index",
        "_id": "2",
        "_score": 0.000024473073,
        "_source": {
          "movie_title": "Star Wars",
          "quotes": [
            "These are not the droids you're looking for",
            "I have a bad feeling about this",
            "Do. Or do not. There is no try."
          ]
        }
      }
    ]
  }
}<p>The passages are now reordered to show the passages with the highest scores first. In this example, lexical retrieval initially selected The Avengers (_id: 3) and Star Wars (_id: 2) based on word matches—“lightning” in one and “feeling” in the other. This approach considers surface-level overlaps and keywords.</p><p>IBM watsonx.ai rerank then re-evaluated the results based on context, ranking The Avengers higher because “lightning” directly aligned with the query "feeling lightning." This demonstrates that by prioritizing meaning over simple keyword matches, reranking ensures more relevant search results.</p><h2>Try semantic reranking with watsonx and Elasticsearch today</h2><p>With the integration of <a href="https://www.elastic.co/search-labs/integrations/ibm-watsonx">IBM watsonx™</a> rerank models, the Elasticsearch Open Inference API continues to empower developers with enhanced capabilities for building powerful and flexible AI-powered search experiences. Explore more supported <a href="https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models-embed.html?context=wx">encoder foundation models available with watsonx.ai</a>.</p><p>Additionally, use IBM watsonx Assistant’s new Conversational Search feature and IBM watsonx Discovery today. Visit <a href="https://www.ibm.com/docs/en/watsonx/saas?topic=models-retrieval-augmented-generation">IBM watsonx Discovery</a> to learn more about this new capability using Elasticsearch. You can follow <a href="https://github.com/watson-developer-cloud/assistant-toolkit/blob/master/integrations/extensions/docs/elasticsearch-install-and-setup/watsonx_discovery_install_and_setup.md">these steps</a> for setup and integration with IBM watsonx Assistants.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ibm-watsonx-elasticsearch-inference-api-rerank-models</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ibm-watsonx-elasticsearch-inference-api-rerank-models</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Saikat Sarkar]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6abcdba172164e4b/6a17e14c1480090ff7b48697/34c9034c09f965630567d796380c7d57cb836b32-700x420.png" length="0" type="image/png"/>
    <pubDate>Mon, 16 Jun 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Built with Elastic: Hybrid search for Cypris – the world’s largest innovation database]]></title>
    <description><![CDATA[Discover how Cypris optimized hybrid search in Elasticsearch to handle 500M vectors, cutting query times from 60 seconds down to 5–10 seconds using binary quantization and segment tuning.]]></description>
    <content:encoded><![CDATA[<em>“When I first typed ‘drone’ into the search and saw results for ‘unmanned aerial vehicles’ without synonyms, I was like, ‘Wow, this thing really gets it.’ That’s when it clicked—it genuinely felt like magic.” — Logan Pashby, Principal Engineer, Cypris.ai</em><h2>Relevance at scale: Cypris’ search story</h2><p>Cypris is a platform that helps R&amp;D and innovation teams navigate a massive dataset of patents and research papers of over 500 million documents. Their mission is to make it easier to track innovation, find prior art, and understand the organizations driving new technologies.</p><p>But there was a problem. To get relevant results, users had to write complex boolean queries—which was fine for expert users, but a barrier for many others. Cypris needed a way to make search more intuitive and accessible.</p><p>The answer was semantic search powered by vector similarity. However, they discovered that scaling semantic search over a large corpus turned out to be a tough engineering problem. Handling 500 million high dimensional vectors wasn’t just a matter of pushing them into a system and hitting “search.” “When we first indexed all 500 million vectors, we were looking at 30- to 60-second query times in the worst case.”</p><p>It would require a series of carefully considered trade-offs between model complexity, hardware resources, and indexing strategy.</p><p><em><strong>Logan Pashby </strong></em><em>is a Principal Engineer at Cypris, where he focuses on the platform's innovation intelligence features. With expertise in topics such as deep learning, distributed systems, and full-stack development, Logan solves complex data challenges and develops efficient search solutions for R&amp;D and IP teams.</em></p><h2>Choosing the right model</h2><p>Cypris’ first attempt at vector search used 750-dimensional embeddings for every document, but they quickly realized scaling such large embeddings across 500 million documents would be unmanageable. By using the <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/approximate-knn-search#_ensure_data_nodes_have_enough_memory">memory approximation formula</a> without quantization, the estimated bytes of RAM required would be around 1500 GB, making it clear that they needed to adjust their strategy.</p><p>“We assumed, and we hoped, that the larger the dimension of the vector, the more information we could encode. A richer embedding space should mean better search relevance.”</p><p>They considered using sparse vectors like Elastic’s ELSER which avoids the fixed-dimension limitations of dense embeddings by representing documents as weighted lists of tokens instead. However, at the time, ELSER’s CPU-only inference seemed too slow for Cypris’s dataset. Dense vectors, on the other hand, let them leverage off-cluster GPU acceleration, which improved throughput by 10x to 50x when generating embeddings.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4bb779818f016fef/6a17f811445de9a9824d02d1/3a16fcc95b52e9ec3171dea7a021e33dd5bfeb5c-1394x1116.png" alt="Cypris’ setup to reduce hybrid search latency including an external GPU based service to compute vectors which were then indexed into Elasticsearch." /><p>The team ultimately decided on lower-dimensional dense vectors that struck a balance: they were compact enough to make indexing and search feasible, yet rich enough to maintain relevance in results.</p><h2>Making hybrid search work with production scale data</h2><h3>Challenges - disk space</h3><p>Once Cypris had vectors ready to be indexed, they faced the next hurdle: <strong>efficiently storing and searching over them in Elasticsearch</strong>.</p><p>The first step was reducing disk space. “At the end of the day, vectors are just arrays of floats.... But when you have 500 million of them, the storage requirements add up quickly.” By default, vectors in Elasticsearch are stored multiple times: first in the _source field (the original JSON document), then in doc_values (columnar storage optimized for retrieval), and finally within the HNSW graph itself. Given that each 750-dimensional float32 vector takes about 3KB, storing 500 million vectors quickly becomes problematic, potentially exceeding 1.5 terabytes per storage layer.</p><p>One practical optimization Cypris used was excluding vectors from the source document in Elasticsearch. This helped reduce overhead, but it turned out disk space wasn’t the biggest challenge. The bigger challenge was memory management.</p><p><em><strong>Did you know?</strong></em></p><p><em>Elasticsearch allows you to optimize disk space by excluding vectors from the source document. This can significantly reduce storage costs, especially when dealing with large datasets. However, be aware that excluding vectors from the source will impact reindexing performance. For more details, check out the </em><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html"><em>Elasticsearch documentation on source filtering</em></a><em>.</em></p><h3>Challenges - RAM explosion</h3><p>Known nearest neighbor (kNN) search in Elasticsearch relies on HNSW graphs, which perform best when fully loaded into RAM. With 500 million high-dimensional vectors, there were significant memory demands on the system. “Trying to fit all of those vectors in memory at query time was not an easy thing to do,” Logan adds.</p><p>Cypris had to juggle multiple memory requirements: the vectors and their HNSW graphs needed to reside in off-heap memory for fast search performance, while the JVM heap had to remain available for other operations. On top of that, they still needed to support traditional keyword search, and the associated Elasticsearch inverted index would need to stay in memory as well.</p><h4>Managing memory with dimensionality reduction, quantization, and segments</h4><p>Cypris explored multiple approaches to better manage memory and storage, here were three that worked well:</p><ul><li><p><strong>Lower-dimensional vectors</strong>: The Cypris team swapped to using a smaller model that reduced vector sizes, thereby lowering resource requirements.</p></li><li><p><strong>BBQ (Better Binary Quantization)</strong>: Cypris was considering int8 quantization, but when Elastic released BBQ, Cypris adopted it quickly. “We tested it out and it didn’t have a huge hit to relevance and was significantly cheaper. So we implemented it right away”, says Logan<strong>. </strong>BBQ immediately reduced the size of their vector indexes by around <strong>20%</strong>!</p></li></ul><p><em><strong>Did you know?</strong></em></p><p><em>Elasticsearch’s Binary Quantized Vectors (BBQ) can reduce the size of vector indexes by ~20%, with minimal impact on search relevance. BBQ reduces both disk usage—by shrinking index size—and memory usage, since smaller vectors take up less space in RAM during searches. It’s especially helpful when scaling KNN search with HNSW graphs, where keeping everything in memory is critical for performance. Explore how BBQ can optimize your search infrastructure in the Elasticsearch documentation on vector search.</em></p><ul><li><p><strong>Segment and shard tuning: </strong>Cypris also optimized how Elasticsearch segments and shards were managed. HNSW graphs are built per segment, so searching dense vectors means querying across all segments in a shard. As Logan explains: “HNSW graphs are independent within each segment and each dense vector field search involves finding the nearest neighbors in every segment, making the total cost dependent on the number of segments.”

Fewer segments generally mean faster searches—but aggressively merging them can slow down indexing. Since Cypris ingests new documents daily, they regularly force-merge segments to keep them slightly below the default 5GB threshold, preserving automatic merging and tombstone garbage collection. To balance search speed with indexing throughput, force-merging occurs during low-traffic periods, and shard sizes are maintained within a healthy range (below 50GB) to optimize performance without sacrificing ingestion speed.</p></li></ul><h3>More vectors, faster searches, hybrid search and happy users</h3><p>With these optimizations, Cypris brought query times down from <strong>30–60 seconds</strong> to <strong>5–10 seconds</strong>. They are also seeing <strong>60–70%</strong> of their user queries shift from the previous boolean search experience to the new semantic search interface.</p><p>But the team is not stopping here! The goal is to achieve sub-second queries to support fast, iterative search and get most of their users to shift to semantic search.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2af7d4fbeca1afbd/6a17f81396142a0f2eeb1cc6/2c7007332a935495e9b279bdaf3968905f021e68-1600x1003.png" alt="Cypris' product handling 500M docs providing real-time AI search and retreival." /><h2>What did the Cypris team learn? … and what’s next?</h2><h3>500 million vectors don’t scale themselves</h3><p>Handling 500 million vectors isn’t just a storage problem or a search problem—it’s both. Cypris had to balance search relevance, hardware resources, and indexing performance at every step.</p><p><em><strong>Did you know</strong></em></p><p><em>Elasticsearch's _search API includes a profile feature that allows you to analyze the execution time of search queries. This can help identify bottlenecks and optimize query performance. By enabling profiling, you can gain insights into how different components of your query are processed. Learn more about using the profile feature in the </em><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html"><em>Elasticsearch search profiling documentation</em></a><em>.</em></p><h3>With search, there’s always a trade-off</h3><p>BBQ was a major win, but it didn’t eliminate the need to rethink sharding, memory allocation, and indexing strategy. Reducing the number of segments improved search speed, but made indexing slower. Excluding vectors from the source reduced disk space but complicated reindexing, as Elasticsearch doesn’t retain the original vector data needed to efficiently recreate the index. Every optimization came with a cost that had to be carefully weighed.</p><h3>Prioritize your users, not the model</h3><p>Cypris didn’t chase the largest models or highest dimension vectors. They focused on what made sense for their users, and working backwards. “Figure out what relevance means for your data,” Logan advises. “And work backward from there.”</p><p>Cypris is now expanding to other datasets, which could double the number of documents they have to index in Elastic. They need to move quickly to stay competitive, “We’re a small team,” Logan says. “So everything we do has to scale—and it has to work.”</p><p>To learn more, visit <a href="http://cypris.ai">cypris.ai</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/building-hybrid-search-at-cypris</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/building-hybrid-search-at-cypris</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Elastic Team,Logan Pashby]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9dd95ca3f3bd2004/6a17f8153e9e457308ba165d/69d9c14dac7e255e3df6c3aa15c84ac6564c3401-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 06 May 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[RAG and the value of grounding in Elasticsearch]]></title>
    <description><![CDATA[Learn about RAG, grounding, and how to reduce hallucinations by connecting an LLM to your documents.]]></description>
    <content:encoded><![CDATA[<p>Large language models (<a href="https://www.elastic.co/what-is/large-language-models">LLMs</a>) are able to generate coherent answers, but when you need real and updated information, they might hallucinate (make up data) and give unreliable answers. To prevent this, we use grounding to provide the models with specialized, use-case-specific, and context-relevant information that goes beyond the LLM’s training.</p><p><em><strong>Grounding</strong></em> is the process by which you connect specific data sources to a model to “ground” it to truthful content instead of only relying on the patterns learned during the model’s training, thus giving more reliable and accurate answers.</p><p>Grounding helps reduce model hallucinations, generate responses based on your data sources, and allows you to examine the answers by providing citations for them.</p><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/_retrieval_augmented_generation.html"><em>Retrieval Augmented Generation</em></a>(RAG) is a <em><strong>grounding</strong></em> technique where you use search algorithms to retrieve relevant information from external sources, then you use that information as context for the LLM, and finally the model uses the augmented context together with their original training data to generate an answer.</p><p>Flow diagram of how RAG works:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4f44f3376a784119/6a170b718b73cb357818a04f/b542f2e6e2af19e59c8877aa5dfbe3b85c78651b-564x311.png" alt="Flow diagram of how RAG works" /><p>RAG allows you to easily scale by updating or expanding the external data sources the model has access to. It is also a cost-effective <a href="https://www.elastic.co/search-labs/blog/rag-vs-fine-tuning">alternative to fine-tuning</a> LLMs since you can just add data without extensive customization.</p><p>And since RAG can access and utilize up-to-date information, it is ideal for use cases when the latest information is key.</p><h2>Hallucination example</h2><p>For this example, we’ll use DeepSeek and ask, <em><strong>“Who is the author who won the Chilean National Literature Prize in 1932?”</strong></em> This is a tricky question since the prize was created in 1942. Let’s see how the model answers:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte368ecbb808e84a1/6a170b735091682d2ee1bafa/60f86813a36f7521efce94c55124c3423e3d35ab-1600x699.png" alt="RAG hallucination example" /><p>As you can see, since the AI did not have all the information, it hallucinated and provided a made-up answer. Though the information is real in the sense that both the author and work exist, the other parts of the answer are wrong.</p><p>Now, let’s see how the model does when we ground it using RAG. For this, we will upload the Wikipedia page about the Chilean National Prize for Literature:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7abceb12479d7a59/6a170b7550916832b7e1bafe/86f8a8cb1e838650bfe946098d595bca0ae56340-1151x1600.png" alt="Grounding a model using RAG based on this document." /><p>Now, let’s ask the same question and check the answer:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1bfa5bf9368b6a3a/6a170b770c4857204401aaa5/d9907035f53f6270796e88ce32205b907c2b9592-1600x865.png" alt="Asking the model questions to test RAG grounding." /><p>As you can see, with RAG we got the right answer. It says there was no prize in 1932 and asks for clarification from the user.</p><h2>Using RAG in Playground</h2><p>By using Elasticsearch, you can easily scale with only having your cluster capacity as a limit. You can use different data sources and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/es-connectors.html">connectors</a> to get access to the data you need. Additionally, you have total ownership of your data since it stays in your infrastructure and is not uploaded to a 3rd party service; if you run a local LLM, your data won’t even leave your network. Finally, you have control over search by designing the queries and how to filter data based on access control (<a href="https://www.elastic.co/search-labs/blog/rag-and-rbac-integration">RBAC</a>).</p><p>We will use <a href="https://www.elastic.co/guide/en/kibana/current/playground.html">Playground</a>, our low-code platform that allows you to quickly and simply create a RAG application using your Elasticsearch content.</p><p>Here’s a step-by-step guide on <a href="https://www.elastic.co/search-labs/blog/chat-with-pdf-elastic-playground">how to upload your PDFs or other documents into Playground</a>. You can also read more about it here and try Playground <a href="https://www.elastic.co/demo-gallery/ai-playground">here</a>.</p><h3>Upload the PDF</h3><p>We’ll index into Kibana the <a href="https://en.wikipedia.org/w/index.php?title=Special:DownloadAsPdf&amp;page=National_Prize_for_Literature_%28Chile%29&amp;action=show-download-screen">same PDF file</a> we provided to DeepSeek. If you followed the instructions in <a href="https://www.elastic.co/search-labs/blog/chat-with-pdf-elastic-playground">the article above</a> and created the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-semantic-text.html">semantic_text</a> field, you’ll be creating a <a href="https://www.elastic.co/what-is/vector-database">vector database</a> with its corresponding <a href="https://www.elastic.co/what-is/vector-embedding">embeddings</a>, ready to be used.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b8b8fd1584e8f45/6a170b79a929cf0c49ae09e6/8776e1b83e3e042dd3150f706de3cd15af65dcd5-1600x1002.png" alt="Uploading the sample PDF for RAG to Elasticsearch Playground." /><h3>Ask the question</h3><p>Ask the following question:</p><p><em><strong>“Who is the author who won the Chilean National Literature Prize in 1932?”</strong></em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd2824a121c0c0416/6a170b7aacf0881ccebe9b7c/382655b7858c525a36ce540c6b28bfc725703e00-765x1125.png" alt="Asking a RAG question in Elasticsearch Playground." /><p>Playground sends this query to Elasticsearch, which in turn, runs a <a href="https://www.elastic.co/what-is/semantic-search">semantic search</a> and localizes the fragments with information that is relevant to the question. Then, these fragments are included as context in the prompt sent to the LLM to ground the answer to the information source we provided.</p><p>Finally, Playground generated an answer saying that <strong>there was no prize</strong> in 1932 and provides citations for the relevant fragments as evidence.</p><p>Playground also offers two very useful features to understand the RAG system underlying components:</p><h3>Query</h3><p>You can see the query Elasticsearch is running to retrieve the relevant documents, and you can enable/disable fields based on your needs.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta24ee951f7df3924/6a170b7c4a531b153336a979/ec02d879a850d3d5519cf68391a39f0e0cd992ac-1440x534.png" alt="How Elasticsearch runs a query to retrieve relevant documents for RAG." /><h3>View code</h3><p>If you can deploy your RAG application, Playground got you covered. Under the View Code tab, you can see the code used under the hood to create the entire RAG workflow. You can choose between two Python alternatives: Elasticsearch Client with <em><strong>OpenAI</strong></em>, or a <em><strong>Langchain</strong></em> based implementation.</p><p>If you want to customize the experience and deploy the code elsewhere, you can use this snippet as a starting point.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5055df7029dee861/6a170b7ea929cffc90ae09ec/04b092ef3b75f97efffce96daecfccdce36d9e06-1439x954.png" alt="Deploying a RAG application in Elasticsearch Playground." /><h2>Conclusion</h2><p>Grounding is a process that connects LLMs to external data sources so they can go beyond their training to provide more accurate and trustworthy answers. Retrieval Augmented Generation (RAG) is a grounding method that is scalable, cost-effective, and ensures access to up-to-date information.</p><p>Tools like Playground simplify RAG implementation by enabling large-scale indexing, customized searches, and responses with citations, which allow you to easily verify an answer and make sure you’re getting accurate and trustworthy results.</p><p>If you want to read more in-depth articles about RAG features, you can start with this one to get a <a href="https://www.elastic.co/search-labs/blog/retrieval-augmented-generation-rag">more technical definition of RAG</a>. You can also check <a href="https://www.elastic.co/search-labs/blog/rag-vs-fine-tuning">Rag vs. fine-tuning: When RAG is the best decision</a>, <a href="https://www.elastic.co/search-labs/blog/sharepoint-federated-searches-azure">How to leverage document security using RAG</a> and <a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2">RAG systems in production</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/grounding-rag</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/grounding-rag</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt54210f630536d9bf/6a170b806234e080a5db1a01/8f02b6b264d8a26be3ae983a8f6d2013a21835a2-1324x742.png" length="0" type="image/png"/>
    <pubDate>Thu, 01 May 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Kibana Alerting: Breaking past scalability limits & unlocking 50x scale]]></title>
    <description><![CDATA[Kibana Alerting now scales 50x better, handling up to 160,000 rules per minute. Learn how key innovations in the task manager, smarter resource allocation, and performance optimizations have helped break past our limits and enabled significant efficiency gains.]]></description>
    <content:encoded><![CDATA[<p>Kibana alerting has been the monitoring solution of choice for many large organizations over the last few years. As adoption has continued to grow, so has the number of alerting rules users have created to monitor their systems. With more organizations relying on Kibana for alerting at scale, we have seen an opportunity to improve efficiency and ensure sufficient performance for future workload needs.</p><p>Between Kibana 8.16 and 8.18, we tackled these issues head-on, introducing key improvements that shattered previous scalability barriers. Before these enhancements, Kibana Alerting could only support up to 3,200 rules per minute with at least 16 Kibana nodes before hitting significant performance bottlenecks. By Kibana 8.18, we’ve increased the scalability ceiling of rules per minute by 50x, supporting up to 160,000 lightweight alerting rules per minute. This was achieved by making Kibana efficiently scale beyond 16 Kibana nodes and increasing per-node throughput from 200 to up to 3,500 rules per minute. These enhancements make all alerting rules run faster, with fewer delays and more efficiently.</p><p>In this blog, we’ll explore the scaling challenges we overcame, the key innovations that made it possible, and how you can leverage them to run Kibana Alerting at scale efficiently.</p><h2>How Kibana Alerting scales with Task Manager</h2><p>Kibana Alerting allows users to define rules that trigger alerts based on real-time data. Behind the scenes, the Kibana Task Manager schedules and runs these rules.</p><p>The Task Manager is Kibana’s built-in job scheduler, designed to handle asynchronous background tasks separately from user interactions. Its key responsibilities include:</p><ul><li><p><strong>Running one-time and recurring tasks</strong> such as alerting rules, connector actions, and reports.</p></li><li><p><strong>Dynamically distributing workloads</strong> as Kibana background nodes join or leave the cluster.</p></li><li><p><strong>Keeping the Kibana UI responsive</strong> by offloading tasks to dedicated background processes.</p></li></ul><p>Each alerting rule translates into a recurring background task. Each background task is an Elasticsearch document, meaning it is stored, fetched and updated as an Elasticsearch document. As the number of alerting rules increases, so do the background tasks Kibana must manage. However, each Kibana node has a limit on how many tasks it can handle simultaneously. Once capacity is reached, additional tasks must wait, leading to delays and slower task run times.</p><h2>The problem: Why scaling was limited</h2><p>Before these improvements, Task Manager faced several scalability constraints, preventing it from scaling beyond 3,200 tasks per minute and 16 Kibana nodes. At this scale, we observed diminishing returns as contention and resource inefficiencies limited further scale. These numbers were based on internal performance testing using a basic Elasticsearch query alerting rule performing a no-op query. The diminishing returns observed included:</p><p><strong>Task claiming contention</strong></p><p>Task Manager uses a distributed polling approach to claim tasks within an Elasticsearch index. Kibana nodes periodically query for tasks and attempt to claim them using Elasticsearch’s <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html">optimistic concurrency control</a>, which prevents conflicting document updates. If another node updates the task first, the original node drops it, reducing overall efficiency.</p><p>With too many Kibana nodes competing for tasks, document update conflicts increase drastically, limiting efficiency beyond 16 nodes and reducing system throughput.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt236fbb4a3a7689a7/6a170bb0286714697893e333/85114dc3fd052718e3713545a9870b76bdf87496-1600x676.png" alt="Kibana task claiming contention" /><p><strong>Inefficient per-node throughput</strong></p><p>Each Kibana node has a limit on the number of tasks that can run concurrently (default: 10 tasks at a time) to prevent memory and CPU overload. This safeguard often results in underutilized CPU and memory, requiring more nodes than necessary.</p><p>Additionally, the polling interval (default: 3000ms) defines how often Task Manager claims new tasks. A shorter interval reduces task delays but increases contention as nodes compete more for updates.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5e7e41ddbb4fb894/6a170bb1acf08830b5be9b8c/bba5bd813853d106e62d041239a228ea236b3ebd-1600x495.png" alt="Kibana alerting Inefficient per-node throughput" /><p><strong>Resource inefficiencies</strong></p><p>When running a high volume of alerting rules, Kibana nodes perform repetitive Elasticsearch queries, repeatedly loading the same objects and lists for each alerting rule run, consuming more CPU, memory, and Elasticsearch resources than necessary. Scaling up requires costly infrastructure expansions to support the increasing request loads.</p><h2>Why it’s important</h2><p>Breaking these barriers is crucial for Kibana’s continued evolution. Improved scalability unlocks:</p><ul><li><p><strong>Cost optimization</strong>: Reducing infrastructure costs for large-scale operations.</p></li><li><p><strong>Faster recovery</strong>: Enhancing Kibana’s ability to recover from node or cluster failures.</p></li><li><p><strong>Future expansion</strong>: Enabling scalability for additional workloads, such as scheduled reports and event-driven automation.</p></li></ul><h2>Key innovations in Kibana Task Manager</h2><p>To achieve a 50x scalability boost, we introduced several innovations:</p><p><strong>Kibana discovery service: smarter scaling</strong></p><p>Previously, Kibana nodes were unaware of each other’s presence, leading to inefficient task distribution. The new Kibana discovery service dynamically monitors active nodes and assigns task partitions accordingly, ensuring even load distribution and reducing contention.</p><p><strong>Task partitioning: eliminating contention</strong></p><p>To prevent nodes from competing for the same tasks, we introduced task partitioning. Tasks are now distributed across 256 partitions, ensuring only a subset of Kibana background nodes attempt to claim the same tasks at any given time. By default, each partition is assigned to a maximum of two Kibana nodes, while a single Kibana node can be responsible for multiple partitions.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6bf10cb5ad39fa39/6a170bb314b27053e3e3c63e/3bfb8c38499e0794665bedcaf05c398bc2ceb6a7-1589x597.png" alt="Kibana Task Manager: Task partitioning" /><p><strong>Task costing: smarter resource allocation</strong></p><p>Not all background tasks consume the same resources. We implemented a task costing system that assigns task weights based on CPU and memory usage. This allows Task Manager to dynamically adjust the number of tasks to claim, optimize resource allocation, and ensure efficient performance.</p><p><strong>New task claiming algorithm</strong></p><p>The old algorithm relied on update-by-query with forced index refresh to identify claimed tasks. This approach was inefficient and introduced unnecessary load on Elasticsearch. The new algorithm avoids this by searching for tasks without requiring an immediate refresh. Instead, it performs the following operations on the task manager index; a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html">_search</a> to find candidate tasks, followed by an <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html">_mget</a> which returns documents that may have been updated more recently but are not yet reflected in the refreshed index state. By comparing document versions from <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html">_search</a> and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html">_mget</a> results, it discards mismatches before proceeding with bulk updates. This approach increases efficiency in Elasticsearch and offers finer control to support task costing.</p><p>By factoring in the poll interval, task concurrency and the index refresh rate, we can calculate the upper limit of expected conflicts and adjust the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html">_search</a> page size accordingly. This helps ensure enough tasks are retrieved so the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html">_mget</a> doesn’t discard all the search results due to document version mismatches.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a033a41792212f8/6a170bb4cf4f25ddd3b2d1a0/fa4a8238528ae0b337c764c3185b844b4ac14795-785x792.png" alt="Kibana Task Manager- New task claiming algorithm" /><p><strong>More frequent polling for tasks</strong></p><p>By ensuring a fixed number of nodes compete for the same tasks with task partitioning and a new lightweight task claiming algorithm, Task Manager can now poll for tasks more frequently without additional stress on Elasticsearch. This reduces delays between a task completing and the next one starting, increasing overall system throughput.</p><h2>Performance optimizations in Kibana Alerting</h2><p>Before our optimizations using Elastic <a href="https://www.elastic.co/observability/application-performance-monitoring">APM</a>, we analyzed alerting rule performance and found that the alerting framework required at least 20 Elasticsearch queries to run any alerting rule. After the optimizations, we reduced this to just 3 queries - an 85% reduction, significantly improving run times and reducing CPU overhead.</p><p>Additionally, Elasticsearch previously relied on the resource-intensive pbkdf2 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#password-hashing-algorithms">hashing algorithm</a> for API key authentication, introducing excessive overhead at scale. We optimized authentication by switching to the more efficient SHA-256 algorithm, allowing us to eliminate the use of an internal Elasticsearch cache that was severely limited by the number of API keys used concurrently.</p><h2>Impact: How users are benefiting</h2><p>Early adoption has demonstrated:</p><ul><li><p><strong>50% faster rule run times</strong>, reducing overall system load.</p></li><li><p><strong>Increased task capacity</strong>, enabling more tasks to run on existing infrastructure.</p></li><li><p><strong>Fewer under-provisioned clusters</strong>, minimizing the need for scaling infrastructure to meet demand.</p></li></ul><p><strong>Drop in average task delay because of increased per-node throughput and making the cluster properly provisioned</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba942618abef6e61/6a170bb64a531b30b536a985/f576de7f6c29e27da636484d45cb7d7c1611135d-1600x314.png" alt="Kibana task manager: average task delay" /><p><strong>Drop in rule run duration because of alerting framework optimizations</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltce4d77fca3635ca9/6a170bb766c4f9cdf6f8c062/178d7d16abed458a20107e4582a4fc7243bcb1cc-1600x793.png" alt="Kibana task manager:  Drop in rule run duration" /><p><strong>Drop in Elasticsearch requests because of alerting framework optimizations</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt36def2b00a0297aa/6a170bb9a292991aa8d01047/e88ff1073e3c0f67bfaf842b4865ae6d588f8828-1117x609.png" alt="Kibana task manager: Drop in Elasticsearch requests" /><h2>Getting started: How to scale efficiently</h2><p>Upgrading to Kibana 8.18 unlocks most of these benefits automatically. For additional optimization, consider adjusting the <code>xpack.task_manager.capacity</code> <a href="https://www.elastic.co/guide/en/kibana/current/task-manager-settings-kb.html#task-manager-settings">setting</a> to maximize per-node throughput while ensuring p999 <a href="https://www.elastic.co/guide/en/kibana/current/configuring-monitoring.html">resource usage</a> remains below 80% for memory, CPU, and event loop utilization and below 500ms for event loop delay.</p><p>By default, Kibana has a guardrail of 32,000 alerting rules per minute. If you plan to exceed this limit, you can modify the <code>xpack.alerting.rules.maxScheduledPerMinute</code> setting accordingly.</p><p>The new <code>xpack.task_manager.capacity</code> setting makes Kibana handle workload distributions more effectively, making the following settings unnecessary in most cases and should be removed from your kibana.yml settings:</p><ul><li><p><code>xpack.task_manager.max_workers</code></p></li><li><p><code>xpack.task_manager.poll_interval</code></p></li></ul><p>If you’re running Kibana on-prem and want to isolate background tasks into dedicated nodes, you can use the <code>node.roles</code> <a href="https://www.elastic.co/guide/en/kibana/current/settings.html">setting</a> to separate UI-serving nodes from those handling background tasks. If you’re using Kibana on Elastic Cloud Hosted (ECH), scaling to 8GB or higher will automatically enable this isolation.</p><h2>What’s next for Kibana Alerting?</h2><p>We’re not stopping at 50x. Our roadmap aims for 100x+ scalability, further eliminating Elasticsearch bottlenecks.</p><p>Beyond scaling, we’re also focusing on improving system monitoring at scale. Upcoming integrations will provide system administrators with deeper insights into background task performance, making it easier to decide when and how to scale.</p><p>Additionally, with task costing, we plan to increase task concurrency for Elastic Cloud Hosted (ECH) customers when configured with more CPU and memory (e.g., Kibana clusters with 2GB, 4GB, or 8GB+ of memory).</p><p>Stay tuned for even more advancements as we continue to push the limits of Kibana scalability!</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/kibana-alerting-task-manager-scalability</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-alerting-task-manager-scalability</guid>
    <category><![CDATA[Kibana]]></category>
    <dc:creator><![CDATA[Mike Cote]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltff8f9a2d63790904/6a170bbbcdacbf61eb7d2a26/86f6f563d9f1ee6929ce4afc9005dfacd93f2990-720x420.png" length="0" type="image/png"/>
    <pubDate>Fri, 18 Apr 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ES|QL, you know, for Search - Introducing scoring and semantic search]]></title>
    <description><![CDATA[Elasticsearch 8.18 and 9.0 introduce several ES|QL enhancements: scoring, semantic search, expanded configuration for the match function, and a new KQL function.]]></description>
    <content:encoded><![CDATA[<h2>Search with ES|QL</h2><p>With Elasticsearch 8.18 and 9.0, ES|QL adds a host of new functionalities, including:</p><ul><li><p>support for scoring</p></li><li><p>semantic search</p></li><li><p>more configuration options for the match function</p></li><li><p>a new KQL function</p></li></ul><p>In this blog, we will review these 8.18 features and other exciting new features that we plan to add to ES|QL, reinforcing our investment in making ES|QL a modern search language ready to fit your needs, whether you are building a search application powered by ES|QL or analyzing your data in Kibana Discover.</p><h3>Introducing scoring</h3><p>In 8.17 we added the ability to filter documents using full text functions. If you are unfamiliar with full text filtering in ES|QL, we suggest reading our <a href="https://www.elastic.co/search-labs/blog/filtering-in-esql-full-text-search-match-qstr">original blog post</a> about it.</p><p>With 8.18 and 9.0 we introduce support for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-for-search.html#esql-for-search-scoring">scoring</a>, making it possible to return documents in order of their relevance. To access the score for each document, simply add the metadata <code>_score</code> field to your ES|QL query:</p><p>We retrieve the same scores we get from the equivalent search API query:</p>GET books/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": "Shakespeare"
          }
        },
        {
          "match": {
            "title": "Shakespeare"
          }
        }
      ]
    }
  }
}<p>Full text search functions such as <code>match</code>, <code>qstr</code> and <code>kql</code> can only be used in the context of a WHERE condition and are the only ones that contribute to the score.</p><p>The <code>_score</code> column can not only be used to sort documents by relevance, but also in custom scoring formulas. In the next example, we keep only the most relevant results using a score threshold and then add a score boost based on the reader rating:</p><h3>Improving the match function</h3><p>In ES|QL, the match function simply translates to a Query DSL <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match query</a>. In 8.18 and 9.0, we expanded the match function's capabilities to include all options that are currently available in Query DSL. It is now possible to set well-known match options such as boost, <code>fuzziness</code> and <code>operator</code> in ES|QL too:</p><h3>Enter semantic search</h3><p>The 8.18 release comes with the <a href="https://www.elastic.co/search-labs/blog/semantic-text-ga">exciting announcement</a> that semantic search is now generally available. We've expanded the <code>match</code> function to support querying over <code>semantic_text</code> field types. </p><p>In ES|QL, executing a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-for-search.html#esql-for-search-semantic">semantic query</a> is now as simple as performing a full-text query, as shown in this example:</p><p>In this example, we set <code>semantic_title</code> to use the <code>semantic_text</code> field type.</p><p>Mapping your index fields as <code>semantic_text</code> is all it takes to set up your index for semantic search.</p><p>Check our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-semantic-text.html">search with semantic text tutorial</a> for more details.</p><h3>Hybrid search with ES|QL</h3><p>ES|QL makes it straightforward to do both semantic and lexical search at the same time. It is also possible to set different boosts, prioritizing results from semantic search or lexical search, depending on your use case:</p><h3>Transitioning from KQL</h3><p>If you are a long-term user of Kibana Discover and use KQL (<a href="https://www.elastic.co/guide/en/kibana/8.18/kuery-query.html">Kibana Query Language</a>) to query and visualize your data and you'd like to try ES|QL but don't know where to start, don't worry, we got you! </p><p>In 8.18 and 9.0, ES|QL adds a new function which allows you to use <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-for-search.html#esql-for-search-kql">KQL inside ES|QL</a>. This is as simple as:</p><p>ES|QL is already available in Kibana Discover.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte9995e2ae807f22b/6a170ae80e2e49c62d41a0da/3311b56a07d2915d0896bb171b995f39f58d757c-1600x852.png" alt="ES|QL in Kibana Discover" /><p>This way, you get the best of both worlds: you can continue to use KQL and start getting more familiar with ES|QL at your own pace.</p><p>Check out our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-getting-started.html">getting started with ES|QL</a> guide for more information.</p><h3>Beyond 8.18 and 9.0</h3><p>In future releases, we'll be adding more and more search capabilities to ES|QL, including vector search, semantic reranking, enhanced score customization options, and additional methods for combining hybrid search results, such as Reciprocal Rank Fusion (RRF).</p><h3>Try it out yourself</h3><p>These changes are available starting with Elasticsearch 8.18, but they are already available in Elasticsearch Serverless. For Elasticsearch Serverless, start a free trial cloud today or try Elastic on your <a href="https://github.com/elastic/start-local?cta=local-machine&amp;tech=github&amp;plcmt=cross%20module&amp;pg=search-labs">local machine</a> now!</p><p>Follow the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.18/esql-search-tutorial.html">Search and filter in ES|QL tutorial</a> for a hands-on introduction to the features described in this blog post! </p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-introducing-scoring-semantic-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-introducing-scoring-semantic-search</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Ioana Tagirta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt021070497c8cbd23/6a170aeab0367d782c72bd0e/c506a0f5c0a33ca6f85454d4f742d7cb266a7b78-715x413.png" length="0" type="image/png"/>
    <pubDate>Wed, 16 Apr 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to migrate data between different versions of Elasticsearch & between clusters]]></title>
    <description><![CDATA[Exploring methods for transferring data between Elasticsearch versions and clusters.]]></description>
    <content:encoded><![CDATA[<p>When you want to upgrade an Elasticsearch cluster, it is sometimes easier to create a new, separate cluster and transfer data from the old cluster to the new one. This affords users the advantage of being able to test all of their data and configurations on the new cluster with all of their applications without any risk of downtime or data loss.</p><p>The disadvantages of that approach are that it requires some duplication of hardware and could create difficulties when trying to smoothly transfer and synchronize all of the data.</p><p>It may also be necessary to carry out a similar procedure if you need to migrate applications from one data center to another.</p><p>In this article, we will discuss and detail three ways to transfer data between Elasticsearch clusters.</p><p><strong>How to migrate data between Elasticsearch clusters?</strong></p><p>There are 3 ways to transfer data between Elasticsearch clusters:</p><ol><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-migrate-data-versions-clusters#1.-reindexing-data-from-a-remote-cluster">Reindexing from a remote cluster</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-migrate-data-versions-clusters#2.-transferring-data-using-snapshots">Transferring data using snapshots</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-migrate-data-versions-clusters#3.-transferring-data-using-logstash">Transferring data using Logstash</a></p></li></ol><p>Using snapshots is usually the quickest and most reliable way to transfer data. However, bear in mind that you can only restore a snapshot onto a cluster of an equal or higher version and never with a difference of over one major version. That means you can restore a 6.x snapshot onto a 7.x cluster but not an 8.x cluster.</p><p>If you need to increase by more than one major version, you will need to reindex or use Logstash.</p><p>Now, let’s look in detail at each of the three options for transferring data between Elasticsearch clusters.</p><h2>1. Reindexing data from a remote cluster</h2><p>Before starting to reindex, remember that you will need to set up appropriate mappings for all of the indices on the new cluster. To do that, you must either create the indices directly with the appropriate mappings or use index templates.</p><h3>Reindexing from remote — configuration required</h3><p>In order to reindex from remote, you should add the configuration below to the elasticseearch.yml file for the cluster that is receiving the data, which, in Linux systems, is usually located here: /etc/elasticsearch/elasticsearch.yml. The configuration to add is as follows:</p>reindex.remote.whitelist: "192.168.1.11:9200"<p>If you are using SSL, you should add the CA certificate to each node and include the following in the command for each node in elasticsearch.yml:</p>reindex.ssl.certificate_authorities: “/path/to/ca.pem”<p>Alternatively, you can add the line below to all Elasticsearch nodes in order to disable SSL verification. However, that approach is less recommended since it is not as secure as the previous option:</p>reindex.remote.whitelist: "192.168.1.11:9200"
reindex.ssl.verification_mode: none
systemctl restart elasticsearch service <p>You will need to make these modifications on every node and carry out a rolling restart. For more information on how to do that, please see <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.17/restart-cluster.html#restart-cluster-rolling">our guide</a>.</p><h3>Reindexing command</h3><p>After you have defined the remote host in the elasticsearch.yml file and added the SSL certificates if necessary, you can start reindexing data with the command below:</p>POST _reindex
{
  "source": {
    "remote": {
      "host": "http://192.168.1.11:9200",
      "username": "elastic",
      "password": "123456",
     "socket_timeout": "1m",
      "connect_timeout": "1m"

    },
    "index": "companydatabase"
  },
  "dest": {
    "index": "my-new-index-000001"
  }
}<p>While doing that, you may face timeout errors, so it may be useful to establish generous values for timeouts rather than relying on defaults.</p><p>Now, let’s take a look at some other common errors that you may encounter when reindexing from remote.</p><h3>Common errors when reindexing from remote</h3><h4>1. Reindexing not whitelisted</h4>{
  "error": {
    "root_cause": [
      {
        "type": "illegal_argument_exception",
        "reason": "[192.168.1.11:9200] not whitelisted in reindex.remote.whitelist"
      }
    ],
    "type": "illegal_argument_exception",
    "reason": "[192.168.1.11:9200] not whitelisted in reindex.remote.whitelist"
  },
  "status": 400
}<p>If you encounter this error, it shows that you did not define the remote host IP address or node name DNS in Elasticsearch as described above or forgot to restart Elasticsearch services.</p><p>To fix that for the Elasticsearch cluster, you need to add the remote host to all Elasticsearch nodes and restart Elasticsearch services.</p><h4>2. SSL handshake exception</h4>{
  "error": {
    "root_cause": [
      {
        "type": "s_s_l_handshake_exception",
        "reason": "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target"
      }
    ],
    "type": "s_s_l_handshake_exception",
    "reason": "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target",
    "caused_by": {
      "type": "validator_exception",
      "reason": "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target",
      "caused_by": {
        "type": "sun_cert_path_builder_exception",
        "reason": "unable to find valid certification path to requested target"
      }
    }
  },
  "status": 500
}<p>This error means that you forgot to add the reindex.ssl.certificate_authorities to elasticsearch.yml as described above. To add it:</p>#elasticsearch.yml
reindex.ssl.certificate_authorities: "/path/to/ca.pem"<h2>2. Transferring data using snapshots</h2><p>Remember, as mentioned above, you can only restore a snapshot onto a cluster of an equal or higher version and never with a difference of over one major version</p><p>If you need to increase by more than one major version, you will need to reindex or use Logstash.</p><p>The following steps are required to transfer data via snapshots:</p><p>Step 1. Adding the repository plugin to the first Elasticsearch cluster – In order to transfer data between clusters via snapshots, you need to ensure that the repository is accessible from both the new and the old clusters. Cloud storage repositories such as AWS, Google, and Azure are generally ideal for this. To take snapshots, please see <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html">our guide</a> and follow the steps it describes.</p><p>Step 2. Restart Elasticsearch service (rolling restart).</p><p>Step 3. Create a repository for the first Elasticsearch cluster.</p><p>Step 4- Add the repository plugin to the second Elasticsearch cluster.</p><p>Step 5- Add repository as read only to second Elasticsearch cluster – You will need to add a repository by repeating the same steps that you took to create the first Elasticsearch cluster.</p><p>Important note: When connecting the second Elasticsearch cluster to the same AWS S3 repository, you should define the repository as a read-only repository:</p>PUT _snapshot/my_s3_repository
{
  "type": "s3",
  "settings": {
    "bucket": "my-analytic-data",
    "endpoint": "s3.eu-de.cloud-object-storage.appdomain.cloud",
    "readonly": "true"
  }
}<p>That is important because you want to prevent the risk of mixing Elasticsearch versions inside the same snapshot repository.</p><p>Step 6- Restoring data to the second Elasticsearch cluster – After taking the above steps, you can restore data and transfer it to the new cluster. Please follow the steps described in <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html">this article</a> to restore data to the new cluster. </p><h2>3. Transferring data using Logstash</h2><p>Before starting to transfer the data with logstash, remember that you will need to set up appropriate mappings for all of the indices on the new cluster. To do that, you will need to either create the indices directly or use index templates.</p><p>To transfer data between two Elasticsearch clusters, you can set up a temporary Logstash server and use it to transfer your data between two clusters. For small clusters, a 2GB ram instance should be sufficient. For larger clusters, you can use four-core CPUs with 8GB RAM.</p><p>For guidance on installing Logstash, please <a href="https://www.elastic.co/guide/en/logstash/current/installing-logstash.html">see here</a>.</p><h3>Logstash configuration for transferring data from one cluster to another</h3><p>A basic configuration to copy a single index from cluster A to cluster B is:</p>iinput
{
elasticsearch
      {
        hosts =&gt; ["192.168.1.11:9200"]
        index =&gt; "index_name"
       docinfo =&gt; true      
      }
}

output 
{
  elasticsearch {
        hosts =&gt; "https://192.168.1.12:9200"
        index =&gt; "index_name"
        
  }
}<p>For secured elasticsearch, you can use the configuration below:</p>input
{
  elasticsearch
      {
        hosts =&gt; ["192.168.1.11:9200"]
        index =&gt; "index_name"
        docinfo =&gt; true 
        user =&gt; "elastic"
        password =&gt; "elastic_password"
        ssl =&gt; true
        ssl_certificate_verification =&gt; false
            
      }
}

output 
{
  elasticsearch {
        hosts =&gt; "https://192.168.1.12:9200"
        index =&gt; "index_name"
        user =&gt; "elastic"
        password =&gt; "elastic_password"
        ssl =&gt; true
        ssl_certificate_verification =&gt; false
  }
}<h3>Index metadata</h3><p>The above commands will write to a single named index. If you want to transfer multiple indices and preserve the index names, then you will need to add the following line to the Logstash output:</p>index =&gt; "%{[@metadata][_index]}"<p>Also if you want to preserve the original ID of the document, then you will need to add:</p>document_id =&gt; "%{[@metadata][_id]}"<p>Bear in mind that setting the document ID will make the data transfer significantly slower, so only preserve the original ID if you need to.</p><h2>Synchronization of updates</h2><p>All of the methods described above will take a relatively long period of time, and you might find that data in the original cluster has been updated while waiting for the process to complete.</p><p>There are various strategies to enable the synchronization of any updates that may have occurred during the data transfer process, and you should give some thought to these issues before starting that process. In particular, you need to think about:</p><ul><li><p>What method do you have to identify any data that has been updated/added since the start of the data transfer process (e.g., a “last_update_time” field in the data)?</p></li><li><p>What method can you use to transfer the last piece of data?</p></li><li><p>Is there a risk of records being duplicated? Usually, there is, unless the method you are using sets the document ID during reindexing to a known value).</p></li></ul><p>The different methods to enable the synchronization of updates are described below.</p><h3>1. Use of queueing systems</h3><p>Some ingestion/updating systems use queues that enable you to “replay” data modifications received in the last x days. That may provide a means to synchronize any changes carried out. </p><h3>2. Reindex from remote</h3><p>Repeat the reindexing process for all items where “last_update_time” &gt; x days ago. You can do this by adding a “query” parameter to the reindex request.</p><h3>3. Logstash</h3><p>In the Logstash input, you can add a query to filter all items where “last_update_time” &gt; x days ago. However, this process will cause duplicates in non-time-series data unless you have set the document_id.</p><h3>4. Snapshots</h3><p>It is not possible to restore only part of an index, so you would have to use one of the other data transfer methods described above (or a script) to update any changes that have taken place since the data transfer process was carried out.</p><p>However, snapshot restore is a much quicker process than reindexing/Logstash, so it may be possible to suspend updates for a brief period of time while snapshots are transferred to avoid the problem altogether.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-migrate-data-versions-clusters</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-migrate-data-versions-clusters</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Kofi Bartlett]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfc041ebca11476c6/6a16f70560084b31b93c4344/01fde3b1d714f12bf8673140c9f2f940d443de31-1440x823.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 14 Apr 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Generating filters and facets using ML]]></title>
    <description><![CDATA[Exploring the pros and cons of automating the creation of filters and facets in a search experience using ML models vs the classical hard-coded approach.]]></description>
    <content:encoded><![CDATA[<p>Filters and facets are mechanisms used to refine search results, helping users find relevant content or products more quickly. In the classical approach, rules are manually defined. For example, in a movie catalog, attributes such as genre are pre-defined for use in filters and facets. On the other hand, with AI models, new attributes can be automatically extracted from the characteristics of movies, making the process more dynamic and personalized. In this blog, we explore the pros and cons of each method, highlighting their applications and challenges.</p><h2>Filters vs facets</h2><p>Before we begin, let's define what filters and facets are. <strong>Filters</strong> are predefined attributes used to restrict a set of results. In a marketplace, for example, filters are available even before a search is performed. The user can select a category, such as <strong>"Video games"</strong>, before searching for <strong>"PS5"</strong>, refining the search to a more specific subset instead of the entire database. This significantly increases the chances of obtaining more relevant results.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0a77b38aae238938/6a170b821949f72a52e7aa51/5ed8868fa5017d034e1273e35c884a5430afdf3c-1600x937.png" alt="Filters" /><p><strong>Facets</strong> work similarly to filters but are only available after the search is performed. In other words, the search returns results, and based on them, a new list of refinement options is generated. For example, when searching for a PS5 console, facets such as storage <strong>capacity</strong>, <strong>shipping cost</strong>, and <strong>color</strong> may be displayed to help users choose the ideal product.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt166e356b80d423ef/6a170b840e2e494ca341a10f/c5633fcc5b6fbb916110faf32144d8d43572e33a-1600x937.png" alt="Facets " /><p>Now that we have defined filters and facets, let's discuss the impact of the classical and Machine Learning (ML)-based approaches on their implementation and usage. Each method has advantages and challenges that influence search efficiency.</p><h2>Classical approach to filters and facets</h2><p>In this approach, filters and facets are manually defined based on predefined rules. This means that the attributes available for refining the search are fixed and planned in advance, considering the catalog structure and user needs.</p><p>For example, in a marketplace, categories such as "Electronics" or "Fashion" may have specific filters like brand, format and price range. These rules are created statically, ensuring consistency in the search experience but requiring manual adjustments whenever new products or categories emerge.</p><p>Although this approach provides predictability and control over the displayed filters and facets, it can be limited when new trends arise that demand dynamic refinement.</p><p><strong>Pros:</strong></p><ul><li><p><strong>Predictability and control:</strong> Since filters and facets are manually defined, management becomes easier.</p></li><li><p><strong>Low complexity:</strong> No need to train models.</p></li><li><p><strong>Ease of maintenance:</strong> As rules are predefined, adjustments and corrections can be made quickly.</p></li></ul><p><strong>Cons</strong>:</p><ul><li><p><strong>Reindexing required for new filters:</strong> Whenever a new attribute needs to be used as a filter, the entire dataset must be reindexed to ensure that documents contain this information.</p></li><li><p><strong>Lack of dynamic adaptation:</strong> Filters are static and do not automatically adjust to changes in user behavior.</p></li></ul><h3>Implementation of filters/facets – Classical approach</h3><p>In <strong>Dev Tools, Kibana</strong>, we will create a demonstration of filters/facets using the <strong>classical approach</strong>.</p><p>First, we define the mapping to structure the index:</p>PUT videogames
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "brand": { "type": "keyword" },
      "storage": { "type": "keyword" },
      "price": { "type": "float" },
      "description": { "type": "text" }
    }
  }
}<p>The <strong>brand</strong> and <strong>storage</strong> fields are set as <strong>keyword</strong>, allowing them to be used directly in aggregations (<strong>facets</strong>). The <strong>price</strong> field is of type <strong>float</strong>, enabling the creation of <strong>price ranges</strong>.</p><p>In the next step, the product data will be indexed:</p>POST videogames/_bulk
{ "index": { "_id": 1 } }
{ "name": "Play Station 5", "brand": "Sony", "storage": "1TB", "price": 499.99, "description": "Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5. Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology. Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design. 1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage. Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games." }
{ "index": { "_id": 2 } }
{ "name": "Xbox Series X", "brand": "Microsoft", "storage": "1TB", "price": 499.99, "description": "Fastest, most powerful Xbox console ever. Play thousands of titles: Every game looks and plays better on Xbox Series X. At the heart of Series X is the Xbox Velocity. Architecture, which combines a custom SSD and built-in software to significantly reduce load times in and out of game. Switch between multiple games in an instant with Quick Resume. Explore new worlds and experience the action like never before with an unparalleled 12 teraflops of graphics processing power. Enjoy 4K gaming at up to 120 frames per second, premium advanced 3D sound, and more. 4K at 120 FPS: requires compatible content and display X version - with disc drive" }
{ "index": { "_id": 3 } }
{ "name": "Nintendo Switch", "brand": "Nintendo", "storage": "512GB", "price": 299.99, "description": "SHARPER, VIBRANT VISUALS. The new 7-inch screen on the Nintendo Switch OLED takes your gaming to the next level: vibrant colors with sharp contrasts for every moment. INTEGRATED GAMEPLAY. Enjoy the console's many multiplayer modes and connect with other players. Online or locally, the fun on the Nintendo Switch is guaranteed. ENJOY IMMERSION FOR LONGER. In addition to delivering an unparalleled experience, thanks to its improved audio, the Nintendo Switch has a rechargeable battery while you play. From 4.5 hours to 9 hours of battery life. INCLUDES SUPER MARIO BROS. WONDER. Transform your world with the phenomenal flowers in this new Mario game, full of amazing adventures, power-ups and new abilities. NINTENDO SWITCH ONLINE SUBSCRIPTION. Access online games, play with friends and enjoy the exclusive benefits of the Nintendo Switch Online subscription." }
{ "index": { "_id": 4 } }
{ "name": "Steam Deck", "brand": "Valve", "storage": "512GB", "price": 399.99, "description": "You can save games, apps, photos and videos without worrying about space. High-Level Performance: The 4-core processor and graphics ensure a dynamic experience and fast responses. High-Definition Images: Smooth transitions and sharp images provide complete immersion in the game. Wireless Connectivity: Wi-Fi technology allows you to play wherever you want, without wires or cables limiting your fun" }
{ "index": { "_id": 5 } }
{ "name": "Nintendo Switch Lite", "brand": "Nintendo", "storage": "512GB", "price": 299.99, "description": "MADE TO BE PORTABLE. Nintendo Switch Lite is designed specifically for portable gaming. The console lets you jump into your favorite games wherever you are. COMPACT AND LIGHTWEIGHT. With its sleek, lightweight design, this console is ready to hit the road wherever you are. COMPATIBLE GAMES. The Nintendo Switch Lite system plays the library of Nintendo Switch games that work in handheld mode. A WORLD OF COLOR TO CHOOSE FROM. Available in a variety of vibrant and unique colors, Nintendo Switch Lite lets you bring even more personality wherever you go." }<p>Now, let's retrieve classic facets by grouping the results by brand, storage, and price range. In the query, size:0 was defined. In this scenario, the goal is to retrieve only the aggregation results without including the documents corresponding to the query.</p>POST videogames/_search
{
  "size": 0,
  "aggs": {
    "brands": {
      "terms": { "field": "brand" }
    },
    "storage_sizes": {
      "terms": { "field": "storage" }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 300 },   
          { "from": 300, "to": 500 },  
          { "from": 500 }  
        ]
      }
    }
  }
}<p>The response will include counts for <strong>Brand</strong>, <strong>Storage</strong>, and <strong>Price</strong>, helping to create filters and facets.</p>"aggregations": {
   "brands": {
     "doc_count_error_upper_bound": 0,
     "sum_other_doc_count": 0,
     "buckets": [
       {
         "key": "Microsoft",
         "doc_count": 1
       },
       {
         "key": "Nintendo",
         "doc_count": 1
       },
       {
         "key": "Sony",
         "doc_count": 1
       },
       {
         "key": "Valve",
         "doc_count": 1
       }
     ]
   },
   "storage_sizes": {
     "doc_count_error_upper_bound": 0,
     "sum_other_doc_count": 0,
     "buckets": [
       {
         "key": "1TB",
         "doc_count": 2
       },
       {
         "key": "512GB",
         "doc_count": 2
       }
     ]
   },
   "price_ranges": {
     "buckets": [
       {
         "key": "*-300.0",
         "to": 300,
         "doc_count": 1
       },
       {
         "key": "300.0-500.0",
         "from": 300,
         "to": 500,
         "doc_count": 3
       },
       {
         "key": "500.0-*",
         "from": 500,
         "doc_count": 0
       }
     ]
   }
 }<h2>Machine learning/AI-based approach to filters and facets</h2><p>In this approach, Machine Learning (ML) models, including Artificial Intelligence (AI) techniques, analyze data attributes to generate relevant filters and facets. Instead of relying on predefined rules, ML/AI leverages indexed data characteristics. This enables the dynamic discovery of new facets and filters.</p><p><strong>Pros</strong>:</p><ul><li><p><strong>Automatic updates:</strong> New filters and facets are generated automatically, without the need for manual adjustments.</p></li><li><p><strong>Discovery of new attributes:</strong> It can identify <strong>previously unconsidered </strong>data characteristics as filters, enriching the search experience.</p></li><li><p><strong>Reduced manual effort:</strong> The team does not need to constantly define and update filtering rules as AI learns from available data.</p></li></ul><p><strong>Cons:</strong></p><ul><li><p><strong>Maintenance complexity:</strong> The use of models may require pre-validation to ensure the consistency of the generated filters.</p></li><li><p><strong>Requires ML and AI expertise:</strong> The solution demands qualified professionals to fine-tune and monitor model performance.</p></li><li><p><strong>Risk of irrelevant filters:</strong> If the model is not well-calibrated, it may generate facets that are not useful for users.</p></li><li><p><strong>Cost:</strong> The use of ML and AI may require third-party services, increasing operational costs.</p></li></ul><p>It's worth noting that even with a well-calibrated model and a well-crafted prompt, the generated facets should still go through a review step. This validation can be manual or based on moderation rules, ensuring that the content is appropriate and safe. While not necessarily a drawback, it is an important consideration to ensure the quality and suitability of the facets before they are made available to users.</p><h3>Implementation of filters/facets – AI approach</h3><p>In this demonstration, we will use an AI model to automatically analyze product characteristics and suggest relevant attributes. With a well-structured prompt, we extract information from the catalog and transform it into filters and facets. Below, we present each step of the process.</p><p>Initially, we will use the <strong>Inference API</strong> to register an endpoint for integration with an ML service. Below is an example of integration with <strong>OpenAI's service</strong>.</p>PUT _inference/completion/generate_filter_ia
{
   "service": "openai",
   "service_settings": {
       "api_key": "your-key",
       "model_id": "gpt-4o-mini"
   }
}<p>Now, we define the pipeline to execute the prompt and obtain the new filters generated by the model.</p>PUT /_ingest/pipeline/generate_filter_ai
{
   "processors": [
     {
       "script": {
         "source": """ctx.prompt = "You are an expert in data organization for search and product categorization. Your task is to analyze the following product and identify the best dynamic facets that can be used in an e-commerce search experience. Product: " + ctx.name + "description: " + ctx.description + "Instructions: - Analyze the product name and description. - Extract only the dynamic facets (technological features or product characteristics that can be inferred from the description, try to create max 3 facets by characteristics found). Put the values into an array. Using key and value, e.g. dynamic_facets: [{ \"name\": \"Gaming Experience\", \"value\": \"Haptic Feedback\" },{ \"name\": \"Gaming Experience\", \"value\": \"Adaptive Triggers\" } - Return only a JSON."
         """
       }
     },
     {
       "inference": {
         "model_id": "generate_filter_ia",
         "input_output": {
           "input_field": "prompt",
           "output_field": "result"
         }
       }
     },
     {
       "gsub": {
         "field": "result",
         "pattern": "```json",
         "replacement": ""
       }
     },
     {
       "json" : {
         "field" : "result",
         "strict_json_parsing": false,
         "add_to_root" : true
       }
     },
     {
       "remove": {
         "field": "result"
       }
     },
     {
       "remove": {
         "field": "prompt"
       }
     }
   ]
}<p>Running a simulation of this pipeline for the "PlayStation 5" product, with the following description:</p><p><em>Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5.</em></p><p><em>Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology.</em></p><p><em>Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design.</em></p><p><em>1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage.</em></p><p><em>Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games.</em></p><p>Let's observe the prompt output generated from this simulation.</p>{
 "docs": [
   {
     "doc": {
       "_index": "index",
       "_version": "-3",
       "_id": "1",
       "_source": {
         "name": "Play Station 5",
         "result": """```json
{
 "dynamic_facets": [
   { "name": "Storage Capacity", "value": "1TB SSD" },
   { "name": "Graphics Technology", "value": "Stunning Graphics" },
   { "name": "Audio Technology", "value": "3D Audio" }
 ]
}
```""",
         "description": "Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5. Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology. Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design. 1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage. Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games.",
         "model_id": "generate_filter_ia",
         "prompt": """You are an expert in data organization for search and product categorization. Your task is to analyze the following product and identify the best dynamic facets that can be used in an e-commerce search experience. Product: Play Station 5description: Stunning Gaming: Marvel at stunning graphics and experience the features of the new PS5. Breathtaking Immersion: Discover a deeper gaming experience with support for haptic feedback, adaptive triggers, and 3D Audio technology. Slim Design: With the PS5 Digital Edition, gamers get powerful gaming technology in a sleek, compact design. 1TB of Storage: Have your favorite games ready and waiting for you to play with 1TB of built-in SSD storage. Backward Compatibility and Game Boost: The PS5 console can play over 4,000 PS4 games. With Game Boost, you can even enjoy faster, smoother frame rates in some of the best PS4 console games.Instructions: - Analyze the product name and description. - Extract only the dynamic facets (technological features or product characteristics that can be inferred from the description, try create max 3 facets by characteristics found). Put the values like arrays. Using key and value, e.g. dynamic_facets: [{ "name": "Gaming Experience", "value": "Haptic Feedback" },{ "name": "Gaming Experience", "value": "Adaptive Triggers" } - Return only a JSON."""
       },
       "_ingest": {
         "timestamp": "2025-03-19T22:14:32.0161803Z"
       }
     }
   }
 ]
}<p>Now a new field, <strong>dynamic_facets</strong>, will be added to the new index to store the facets generated by the AI.</p>PUT videogames_1
{
 "mappings": {
   "properties": {
     "name": { "type": "text" },
     "brand": { "type": "keyword" },
     "storage": { "type": "keyword" },
     "price": { "type": "float" },
     "description": { "type": "text" },
     "dynamic_facets": { "type": "nested",
     "properties": { "name": { "type": "keyword" },
                     "value": { "type": "keyword" } } }
   }
 }
}<p>Using the <strong>Reindex API</strong>, we will reindex the <strong>videogames</strong> index to <strong>videogames_1</strong>, applying the <strong>generate_filter_ai</strong> pipeline during the process. This pipeline will automatically generate dynamic facets during indexing.</p>POST _reindex?wait_for_completion=false
{
 "source": {
   "index": "videogames"
 },
 "dest": {
   "index": "videogames_1",
   "pipeline": "generate_filter_ai"
 }
}<p>Now, we will run a search and get the new filters:</p>GET videogames_1/_search
{
 "size": 0,
 "query": {
   "match": {
     "name": "nintendo"
   }
 },
 "aggs": {
   "dynamic_facets": {
     "nested": {
       "path": "dynamic_facets"
     },
     "aggs": {
       "facets": {
         "terms": {
           "field": "dynamic_facets.name"
         },
         "aggs": {
           "facets": {
             "terms": {
               "field": "dynamic_facets.value"
             }
           }
         }
       }
     }
   }
 }
}<p>Results:</p>"aggregations": {
   "dynamic_facets": {
     "doc_count": 3,
     "facets": {
       "doc_count_error_upper_bound": 0,
       "sum_other_doc_count": 0,
       "buckets": [
         {
           "key": "Frame Rate",
           "doc_count": 1,
           "facets": {
             "doc_count_error_upper_bound": 0,
             "sum_other_doc_count": 0,
             "buckets": [
               {
                 "key": "120 FPS",
                 "doc_count": 1
               }
             ]
           }
         },
         {
           "key": "Gaming Resolution",
           "doc_count": 1,
           "facets": {
             "doc_count_error_upper_bound": 0,
             "sum_other_doc_count": 0,
             "buckets": [
               {
                 "key": "4K",
                 "doc_count": 1
               }
             ]
           }
         },
         {
           "key": "Graphics Processing Power",
           "doc_count": 1,
           "facets": {
             "doc_count_error_upper_bound": 0,
             "sum_other_doc_count": 0,
             "buckets": [
               {
                 "key": "12 Teraflops",
                 "doc_count": 1
               }
             ]
           }
         }
       ]
     }
   }
 }<p>To symbolize the implementation of the facets, below is a simple front-end:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb0d6aa40caf7a91a/6a170b86ab7f0839afdb9eb6/12b6d9d4f4d0985848d92841545fd22b7253ae6d-1600x1288.png" alt="implementation of the facets" /><p>The UI code presented is <a href="https://gist.github.com/andreluiz1987/06d9ec1b381e942e9def0e969bd811a0">here</a>.</p><h2>Conclusion</h2><p>Both approaches to creating filters and facets have their benefits and points of concern. The classic approach, based on manual rules, offers control and lower costs but requires constant updates and does not dynamically adapt to new products or features.</p><p>On the other hand, the AI ​​and Machine Learning-based approach automates facet extraction, making the search more flexible and allowing the discovery of new attributes without manual intervention. However, this approach can be more complex to implement and maintain, requiring calibration to ensure consistent results.</p><p>The choice between the classic and AI-based approaches depends on the needs and complexity of the business. For simpler scenarios, where data attributes are stable and predictable, the classic approach can be more efficient and easier to maintain, avoiding unnecessary costs with infrastructure and AI models. On the other hand, the use of ML/AI to extract facets can add significant value, improving the search experience and making filtering more intelligent.</p><p>The important thing is to evaluate whether automation justifies the investment or whether a more traditional solution already meets the business needs effectively.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/filters-facets-using-ml</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/filters-facets-using-ml</guid>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[ML Research]]></category>
    <dc:creator><![CDATA[Andre Luiz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4084864dcdaa25d3/6a170b880c485781f901aaa9/6f196643d573614fe5124705c7e4db9bfce004b0-1200x628.png" length="0" type="image/png"/>
    <pubDate>Thu, 03 Apr 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Exploring GPU-accelerated vector search in Elasticsearch with NVIDIA: Chapter I]]></title>
    <description><![CDATA[Powered by NVIDIA cuVS, the collaboration looks to provide developers with GPU-acceleration for vector search in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>We in the Elastic Engineering org have been busy optimizing vector database performance for a while now. Our mission: making Lucene and Elasticsearch the best vector database. Through hardware accelerated <a href="https://www.elastic.co/blog/accelerating-vector-search-simd-instructions">CPU SIMD instructions</a>, introducing new vector data compression innovations (<a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch">Better Binary Quantization a.k.a BBQ</a>), and then exceeding expectations by updating the algorithmic approach to BBQ for even more benefits, and also <a href="https://www.elastic.co/search-labs/blog/filtered-hnsw-knn-search">making Filtered HNSW faster</a>. You get the gist—we’re building a faster, better, efficient(er?) vector database for the developers as they solve those RAG-gedy problems!</p><p>As part of our mission to leave no efficiencies behind, we are exploring acceleration opportunities with these curious computer chips, which you may have heard of—NVIDIA GPUs! (Seriously, have you not?).</p><p>When obsessing over performance, we have several problem spaces to explore—how to index exponentially more data, how to retrieve insights from it, and how to do it when your ML models are involved. You should be able to eke out every last benefit available when you have GPUs.</p><p>In this post, we dive into our collaboration with the NVIDIA vector search team as we explore GPU-accelerated vector search in Elasticsearch. This work paves the way for use cases where developers could use a mix of GPUs and CPUs for real-world Elasticsearch-powered apps. Exciting times!</p><h2>Elasticsearch GPUs</h2><p>We are excited to share that the Elasticsearch engineering team is helping build the open-source cuVS Java API experience for developers, which exposes bindings for vector search algorithms. This work leverages our previous experience with Panama FFI. Elasticsearch and Apache Lucene use the NVIDIA cuVS API to build the graph during indexing. Okay, we are jumping ahead; let’s rewind a bit.</p><p><a href="https://developer.nvidia.com/cuvs">NVIDIA cuVS</a>, an open-source C++ library, is at the heart of this collaboration. It aims to bring GPU acceleration to vector search by providing higher throughput, lower latency, and faster index build times. But Elasticsearch and Apache Lucene are written in Java; how will this work?</p><p>Enter <a href="https://github.com/SearchScale/lucene-cuvs">lucene-cuvs</a> and the Elastic-NVIDIA-SearchScale collaboration to bring it into the Lucene ecosystem to explore GPU-accelerated vector search in Elasticsearch. In the recent NVIDIA cuVS 25.02 release, we added a Java API for cuVS. The new API is experimental and will continue to evolve, but it’s currently available for use. The question may arise: aren’t Java to native function calls slow? Not anymore! We’re using the new <a href="https://openjdk.org/projects/panama/">Panama FFI</a> (Foreign Function Interface) for the bindings, which has minimal overhead for Java to native downcalls.</p><p>We’ve been using <a href="https://www.elastic.co/search-labs/blog/lucene-and-java-moving-forward-together">Panama FFI in Elasticsearch and Lucene</a> for a while now. It’s awesome! But... there is always a “but”, isn’t there? FFI has availability challenges across Java versions. We overcame this by compiling the cuVS API to Java 21 and encapsulating the implementation within a multi-release jar targeting Java 22. This allows the use of cuVS Java directly in Lucene and Elasticsearch.</p><p>Ok, now that we have the cuVS Java API, what else would we need?</p><h2>A tale of two algorithms for CPU</h2><p>Elasticsearch supports the <a href="https://arxiv.org/abs/1603.09320">HNSW algorithm</a> for scalable approximate KNN search. However, to get the most out of the GPU, we use a different algorithm, <a href="https://arxiv.org/pdf/2308.15136">CAGRA [</a><a href="https://arxiv.org/pdf/2308.15136"><strong>C</strong></a><a href="https://arxiv.org/pdf/2308.15136"><em>UDA</em></a> <a href="https://arxiv.org/pdf/2308.15136"><strong>A</strong></a><a href="https://arxiv.org/pdf/2308.15136"><em>NN</em></a> <a href="https://arxiv.org/pdf/2308.15136"><strong>GRA</strong></a><a href="https://arxiv.org/pdf/2308.15136"><em>ph</em></a><a href="https://arxiv.org/pdf/2308.15136">]</a>, which has been specifically designed for the high levels of parallelism offered by the GPU.</p><p>Before we get into how we look to add support for CAGRA, let’s look at how Elasticsearch and Lucene access index data through a “codec format”. This consists of</p><ol><li><p>the on-disk representation,</p></li><li><p>the interfaces for reading and writing data,</p></li><li><p>and the machinery for dealing with Lucene’s segment-based architecture.</p></li></ol><p>We are implementing a new KNN (k-nearest neighbors) <a href="https://lucene.apache.org/core/10_1_0/core/org/apache/lucene/codecs/KnnVectorsFormat.html">vector format</a> that internally uses the cuVS Java API to index and search on the GPU. From here, we “plumb” this codec type through Elasticsearch’s mappings to a field type in the index. As a result, your existing KNN queries continue to work regardless of whether the backing index is using a CAGRA or HNSW graph. Of course, this glosses over many details, which we plan to cover in a future blog. The following is the high-level architecture for a GPU-accelerated Elasticsearch.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb6197b631a34f8b6/6a170b0da6c2b9e60ce7970b/be6b7356c03df4dee7230625c2c9af3b019f93be-756x510.png" alt="" /><p>This new codec format defaults to CAGRA. However, it also supports converting a CAGRA graph to an HNSW graph for search on the CPU.</p><h2>Indexing and searching on the GPU: Making some “core” decisions</h2><p>With the stateless <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">architecture</a> for Elasticsearch Serverless, which separates indexing and search, there is now a clear delineation of responsibilities. We pick the best hardware profile to fulfill each of these independent responsibilities.</p><p>We anticipate users to consider two main deployment strategies:</p><ol><li><p>Index and search on the GPU: During indexing, build a CAGRA graph and use it during search - ideal when extremely low latency search is required.</p></li><li><p>Index on GPU and search on CPU: During indexing, build a CAGRA graph and convert it to an HNSW graph. The HNSW graph is stored in the index, which can later be used on the CPU for searching.</p></li></ol><p>This flexibility provides different deployment models, offering tradeoffs between cost and performance. For example, an indexing service could use GPU to efficiently build and merge graphs in a timely manner while using a lower-powered CPU for searching.</p><h2>So here is the plan for GPU-accelerated vector search in Elasticsearch</h2><p>We are looking forward to bringing performance gains and flexibility with deployment strategies to users, offering various knobs to balance cost and performance. <a href="https://www.nvidia.com/gtc/session-catalog/?tab.catalogallsessionstab=16566177511100015Kus&amp;search=Lucene#/">Here is the NVIDIA GTC 2025 session</a> where this work was presented in detail.</p><p>We’d like to thank the engineering teams at NVIDIA and SearchScale for their fantastic collaboration. In an upcoming blog, we will explore the implementation details and performance analysis in greater depth. Hold on to your curiosity hats 🎩!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/gpu-accelerated-vector-search-elasticsearch-nvidia</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/gpu-accelerated-vector-search-elasticsearch-nvidia</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Chris Hegarty,Hemant Malik]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt298e839e708ca11c/6a170b0fb339d560c2769fc2/38bc0377a6adce7eae0099f61902fdbbe644eb4a-1440x960.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 19 Mar 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsesarch semantic search, leveled up: now with native match, knn and sparse_vector support]]></title>
    <description><![CDATA[Semantic text search becomes even more powerful, with native support for match, knn and sparse_vector queries. This allows us to keep the simplicity of the semantic query while offering the flexibility of the Elasticsearch query DSL. ]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch’s <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html">semantic query</a> is incredibly powerful, allowing users to perform semantic search over data configured in <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html">semantic_text</a> fields. Much of this power lies in simplicity: just set up a <code>semantic_text</code> field with the inference endpoint you want to use, and then ingest content as if indexing content into a regular <code>text</code> field. The inference happens automatically and transparently, making it simple to set up and use a search index with semantic functionality.</p><p></p><p>This ease of use does come with some tradeoffs: we simplified semantic search with <code>semantic_text</code> by making judgments on default behavior that fit the majority of use cases. Unfortunately, this means that some customizations available for traditional vector search queries aren’t present in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html">semantic query</a>. We didn’t want to add all of these options directly to the <code>semantic</code> query, as that would undermine the simplicity that we strive for. Instead, we expanded the queries that support the <code>semantic_text</code> field, leaving it up to you to choose the best query that meets your needs.</p><p></p><p>Let’s walk through these changes, starting with creating a simple index with a semantic_text field:</p>PUT index-songs-semantic
{
  "mappings": {
    "properties": {
      "song_title": {
        "type": "text"
      },
      "artist": {
        "type": "keyword"
      },
      "lyric": {
        "type": "semantic_text"
      }
    }
  }
}

// Now index a sample document
POST index-songs-semantic/_doc/1
{
  "song_title": "...Baby One More Time",
  "artist": "Britney Spears",
  "lyric": "When I'm with you, I lose my mind, give me a sign"
}
<p></p><h2>We made match happen in semantic search!</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc5e9a7041fc40292/6a17eeda414c646ff494524f/9b15e17822e297e35a46393ef2a7c1e6d55fedd9-1792x1024.png" alt="" /><p></p><p>First and most importantly, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match query</a> will now work with <code>semantic_text</code> fields!</p><p></p><p>This means that you can change your old semantic query:</p>GET index-songs-semantic/_search
{
  "query": {
    "semantic": {
      "field": "lyric",
      "query": "song lyrics about love"
    }
  }
}
<p></p><p>Into a simple <code>match</code> query:</p>GET index-songs-semantic/_search
{
  "query": {
    "match": {
      "lyric": "song lyrics about love"
    }
  }
}
<p></p><p>We can see the benefits of semantic search here because we’re searching for “song lyrics about love”, none of which appears in the indexed document. This is because of ELSER’s text expansion.</p><p></p><p>But wait, it gets better!</p><p></p><p>If you have multiple indices, and the same field name is <code>semantic_text</code> in one field and perhaps text in the other field, you can still run <code>match</code> queries against these fields. Let’s create another index, with the same field names, but different types (<code>text</code> instead of <code>semantic_text</code>). Here’s a simple example to illustrate:</p>// Setup - Create a similar index without semantic fields
PUT index-songs-lexical
{
  "mappings": {
    "properties": {
      "song_title": {
        "type": "text"
      },
      "artist": {
        "type": "keyword"
      },
      "lyric": {
        "type": "text"
      }
    }
  }
}

POST index-songs-lexical/_doc/2
{
  "song_title": "Crazy",
  "artist": "Britney Spears",
  "lyric": "You drive me crazy, I just can't sleep, I'm so excited, I'm in too deep"
}

GET index-songs-semantic,index-songs-lexical/_search
{
  "query": {
    "match": {
      "lyric": "crazy"
    }
  }
}
<p></p><p>Here, searching for “crazy” brings up both the lexical match that has “crazy” in the title, and the semantic lyric “lose my mind.”</p><p></p><p>There are some caveats to keep in mind when using the <code>match</code> functionality with <code>semantic_text</code>:</p><ul><li><p>The underlying <code>semantic_text</code> field has a limitation where you can’t use multiple inference IDs on the same field. This limitation extends to <code>match</code><strong>—</strong>meaning that if you have two semantic_text fields with the same name, they need to have the same inference ID or you’ll get an error. You can work around this by creating different names and querying them in a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html">boolean query</a> or a compound <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/retrievers-overview.html">retriever</a>.</p></li><li><p>Depending on what model you use, the scores between lexical (text) matches and semantic matches will likely be very different. In order to get the best ranking of results, we recommend using second stage rerankers such as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-reranking.html">semantic reranking</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html">RRF</a>.</p></li></ul><p></p><p>Semantic search using the <code>match</code> query is also available in <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-functions-operators.html#esql-match">ES|QL</a>! Here’s the same example as above, but using ES|QL:</p><p></p><h2>Expert-level semantic search with knn and sparse_vector</h2><p>Match is great, but sometimes you want to specify more vector search options than the semantic query supports. Remember, the tradeoff of making the semantic query as simple as it is involved making some decisions on default behavior.</p><p></p><p>This means that if you want to take advantage of some of the more advanced vector search features, perhaps <code>num_candidates</code> or <code>filter</code> from the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-knn-query.html">knn query</a> or <a href="https://www.elastic.co/search-labs/blog/text-expansion-pruning">token pruning</a> in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html">sparse_vector query</a>, you won’t be able to do so using the semantic query.</p><p></p><p>In the past, we provided some workarounds to this, but they were convoluted and required knowing the inner workings and architecture of the <code>semantic_text</code> field and constructing a corresponding nested query. If you’re doing that workaround now, it will still work—however, we now support query DSL using <code>knn</code> or <code>sparse_vector</code> queries on <code>semantic_text</code> fields.</p><p></p><h3>All about that dense (vector), no trouble</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf6651a79c3e56345/6a17eedcb1e113820979f31d/aa3ea406fc072698db510a0b2ab64eb0266948c1-1792x1024.png" alt="" /><p>Here’s an example script that populates a <code>text_embedding</code> model and queries a <code>semantic_text</code> field using the knn query:</p>PUT index-dense-semantic-songs
{
  "mappings": {
    "properties": {
      "song_title": {
        "type": "text"
      },
      "artist": {
        "type": "keyword"
      },
      "lyric": {
        "type": "semantic_text",
        "inference_id": ".multilingual-e5-small-elasticsearch"
      }
    }
  }
}

// Index sample documents
POST index-dense-semantic-songs/_doc/4
{
  "song_title": "Oops! ...I Did It Again",
  "artist": "Britney Spears",
  "lyric": "Oops, I did it again, I played with your heart, got lost in the game."
}

POST index-dense-semantic-songs/_doc/5
{
  "song_title": "Poker Face",
  "artist": "Lady Gaga",
  "lyric": "Can't read my, can't read my, no, he can't read my poker face"
}

GET index-dense-semantic-songs/_search
{
  "query": {
    "knn": {
      "field": "lyric",
      "k": 10,
      "num_candidates": 100,
      "query_vector_builder": {
        "text_embedding": {
          "model_text": "game"
        }
      }
    }
  }
}
<p></p><p>The <code>knn</code> query can be modified with extra options to enable more advanced queries against the semantic_text field. Here, we perform the same query but add a pre-filter against the <code>semantic_text</code> field:</p>GET index-dense-semantic-songs/_search
{
  "query": {
    "knn": {
      "field": "lyric",
      "k": 10,
      "num_candidates": 100,
      "query_vector_builder": {
        "text_embedding": {
          "model_text": "game"
        }
      },
      "filter": {
        "term": {
          "artist": "Britney Spears"
        }
      }
    }
  }
}
<p></p><h3>Keepin’ it sparse (vector), keepin’ it real</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt540cbe07b8eeb546/6a17eedfe8fbce91343a1a09/1af6680a3bae23b0f1b3fd4605ba6cb3184318e2-1792x1024.png" alt="" /><p></p><p>Similarly, sparse embedding models can be queried more specifically using <code>semantic_text</code> fields as well. Here’s an example script that adds a few more documents and uses the <code>sparse_vector</code> query:</p>POST index-songs-semantic/_doc/6
{
  "song_title": "Crazy In Love",
  "artist": "Beyoncé",
  "lyric": "Looking so crazy, your love's got me looking, got me looking so crazy in love"
}


POST index-songs-semantic/_doc/7
{
  "song_title": "Complicated",
  "artist": "Avril Lavigne",
  "lyric": "Why'd you have to go and make things so complicated?, I see the way you're acting like you're somebody else"
}

GET index-songs-semantic/_search
{
  "query": {
    "sparse_vector": {
      "field": "lyric",
      "query": "crazy"
    }
  }
}
<p></p><p>The <code>sparse_vector</code> query can be modified with extra options, to enable more advanced queries against the <code>semantic_text</code> field. Here, we perform the same query but add <a href="https://www.elastic.co/search-labs/blog/text-expansion-pruning">token pruning</a> against a <code>semantic_text</code> field:</p>GET index-songs-semantic/_search
{
  "query": {
    "sparse_vector": {
      "field": "lyric",
      "query": "crazy",
      "prune": true,
      "pruning_config": {
        "tokens_freq_ratio_threshold": 1,
        "tokens_weight_threshold": 0.4,
        "only_score_pruned_tokens": false
      }
    }
  }
}
<p>This example significantly decreases the token frequency ratio required to pruning, which helps us show differences with such a small dataset, though they’re probably more aggressive than you’d want to see in production (remember, token pruning is about pruning irrelevant tokens to improve performance, not drastically change recall or relevance). You can see in this example that the Avril Lavigne song is no longer returned, and the scores have changed due to the pruned tokens. (Note that this is an illustrative example, and we still <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html#sparse-vector-query-with-pruning-config-and-rescore-example">recommend a rescore adding pruned tokens back into scoring</a> for most use cases).</p><p></p><p>You’ll note that with all of these queries if you’re only querying a <code>semantic_text</code> field, you no longer need to specify the inference ID in <code>knn</code>’s <code>query_vector_builder</code> or in the <code>sparse_vector</code> query. This is because it will be inferred from the <code>semantic_text</code> field. You <em>can</em> specify it if you want to override with a different (compatible!) inference ID for some reason or if you’re searching combined indices that have both <code>semantic_text</code> and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/sparse-vector.html">sparse_vector</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html">dense_vector</a> fields though.</p><h2>Try it out yourself</h2><p>We’re keeping the original <code>semantic</code> query simple, but expanding our semantic search capabilities to power more use cases and seamlessly integrate semantic search with existing workflows. These power-ups are native to Elasticsearch and are already available in Serverless. They’ll be available in stack-hosted Elasticsearch starting with version 8.18.</p><p></p><p>Try it out today!</p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/semantic-search-match-knn-sparse-vector</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/semantic-search-match-knn-sparse-vector</guid>
    <category><![CDATA[Relevance]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf6651a79c3e56345/6a17eedcb1e113820979f31d/aa3ea406fc072698db510a0b2ab64eb0266948c1-1792x1024.png" length="0" type="image/png"/>
    <pubDate>Thu, 06 Mar 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[High Quality RAG with Aryn DocPrep, DocParse and Elasticsearch vector database]]></title>
    <description><![CDATA[Learn how to achieve high-quality RAG with effective data preparation using  Aryn.ai DocParse, DocPrep, and Elasticsearch vector database.]]></description>
    <content:encoded><![CDATA[<p>Organizations rely on natural language queries to gain insights from unstructured data, but achieving high-quality answers starts with effective data preparation. <a href="https://www.aryn.ai/">Aryn DocParse and DocPrep</a> streamline this process by converting complex documents into structured JSON or markdown, <a href="https://www.aryn.ai/post/an-evaluation-of-pdf-segmentation-and-layout-analysis-models">delivering up to 6x better data chunking and 2x improved recall</a> for hybrid search and Retrieval-Augmented Generation (RAG) applications. Powered by the open-source Aryn Partitioner and <a href="https://huggingface.co/Aryn/deformable-detr-DocLayNet">effective, deep learning DETR AI</a> model trained on 80K+ enterprise documents, these tools ensure higher accuracy and relevance <a href="https://www.aryn.ai/post/an-evaluation-of-pdf-segmentation-and-layout-analysis-models">compared to off-the-shelf solutions</a>.</p><p>In this blog, we’ll demonstrate how to use DocParse and DocPrep to prepare and load a dataset of complex PDFs into Elasticsearch for a RAG application. We will use ~75 PDF reports from the National Transportation Safety Board (NTSB) about aircraft incidents. An example document from the collection is <a href="https://data.ntsb.gov/carol-repgen/api/Aviation/ReportMain/GenerateNewestReport/103753/pdf">here</a>.</p><h2>What is Aryn DocParse and DocPrep</h2><p>Aryn DocParse segments and labels documents, extracts tables, and images, and does OCR – turning 30+ document types into structured JSON. It runs the open-source Aryn Partitioner and its <a href="https://huggingface.co/Aryn/deformable-detr-DocLayNet">open-source deep learning DETR AI model</a> trained on 80k+ enterprise documents. This leads to <a href="https://www.aryn.ai/post/an-evaluation-of-pdf-segmentation-and-layout-analysis-models">up to 6x more accurate data chunking and 2x improved recall</a> on hybrid search or RAG compared to off-the-shelf systems.</p><p><a href="https://docs.aryn.ai/docprep/getting_started">Aryn DocPrep</a> is a tool for creating document ETL pipelines to prepare and load this data into vector databases and hybrid search indexes like Elasticsearch. The first step in a pipeline is using DocParse to process each document. DocPrep creates Python code using <a href="https://sycamore.readthedocs.io/en/stable/">Sycamore</a>, an open-source, scalable, LLM-powered document ETL library. Though DocPrep can easily create ETL pipelines using Sycamore code, you will likely need to customize the pipeline using additional <a href="https://sycamore.readthedocs.io/en/stable/">Sycamore data transforms, chunking/merging, extraction, and cleaning functions</a>.</p><p>As can be seen, these documents are complex, containing tables, images, section headings, and complicated layouts. Let’s begin!</p><h2>Building high-quality RAG apps with effective data preparation</h2><h3>Launch an Elasticsearch vector database container</h3><p>We’ll install Elasticsearch locally using a Docker container for the demo RAG application. Follow <a href="https://github.com/elastic/start-local">these instructions</a> to deploy it.</p><h3>Prepare data for RAG using Aryn DocPrep and DocParse</h3><p>Aryn <a href="https://docs.aryn.ai/docprep/getting_started">DocPrep</a> is a tool for creating document ETL pipelines that prepare and load data into vector databases and hybrid search indexes like Elasticsearch. The first step in a pipeline is using DocParse to process each document.</p><p>We will use Aryn DocParse in Aryn Cloud to generate our initial ETL pipeline code. You can <a href="https://www.aryn.ai/get-started">sign up for free</a> to use Aryn Cloud and go to the <a href="https://console.aryn.cloud/docprep/">DocPrep UI in the Aryn Cloud console</a>.</p><p>You can also write an ETL pipeline and run a version of the Aryn Partitioner (used in DocParse) locally. <a href="https://sycamore.readthedocs.io/en/stable/">Visit the Sycamore documentation</a> to learn more.</p><h3>Create ETL pipeline with Aryn DocPrep</h3><p>DocPrep creates Python code using <a href="https://sycamore.readthedocs.io/en/stable/">Sycamore</a>, an open-source, scalable, LLM-powered document ETL library. While DocPrep can easily create ETL pipelines using Sycamore code, you may need to customize the pipeline further with additional <a href="https://sycamore.readthedocs.io/en/stable/">Sycamore data transforms, extraction, and cleaning functions</a>.</p><p>DocPrep simplifies the creation of a base ETL pipeline to prepare unstructured data for RAG and semantic search.</p><p>First, we provide the document type (PDF) and the source location of our PDFs in Amazon S3 (<code>s3://aryn-public/ntsb/</code>):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt688c73411f105c89/6a17e25a63173062e45859f0/799421fc6a5544dbba666a81fe88845bfe5532d2-738x410.png" alt="Select document type and source" /><p></p><p>Next, we will select MiniLM for our embedding model to create our vector embeddings locally. DocPrep uses DocParse for document segmentation, extraction, and other processing, but we don’t need to change the default configuration.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0b3959439d5478d1/6a17e25be31791b3a42d5759/4f84fe3e9d0130f815ecd380e011c036c5c506dd-936x452.png" alt="Select chunking options" /><p>Finally, we select Elasticsearch as our target database and add the Host URL and Index name. Note that the URL is set to “localhost” because we are running Elasticsearch locally. We will also run DocPrep/Sycamore ETL pipeline locally so it can easily load the cluster.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt31d8160e920700df/6a17e25da2929981afd02bda/345545b1264db36bb113071e5baf69c51fe90fb1-936x728.png" alt="Configure Elasticsearch Connector" /><p>Now, click “Generate pipeline” to create the ETL pipeline. Click “Download notebook” on the next page to download the code as a Jupyter notebook file.</p><p></p><h3>Install Jupyter and Sycamore</h3><p>We will run the ETL pipeline locally in a Jupyter notebook and use the Sycamore document ETL library. As a first step, install Jupyter and Sycamore with the Elasticsearch connector and local inference libraries to create vector embeddings.</p>pip install jupyter
pip install 'sycamore-ai[elasticsearch,local-inference]'<p></p><h3>Run Pipeline</h3><p>Run Jupyter and open the notebook with the ETL pipeline downloaded in the earlier step.</p><p>If you haven’t set your Aryn Cloud API key as an environmental variable called <code>ARYN_API_KEY</code>, you can set it directly in the notebook.</p><p>In the second-to-last cell, update the Elasticsearch loading configuration. Replace the es_client_args from setting an Elasticsearch password to the Elasticsearch basic auth config from your container:</p><p></p>es_client_args={"basic_auth": (“&lt;YOUR-USERNAME&gt;”, os.getenv("ELASTIC_PASSWORD"))}<p></p><p>If the password isn’t set as an environment variable, you can add it directly here.</p><p>Now, run the cells in the notebook. Each of the ~75 PDFs is sent to DocParse for processing, and this step in the pipeline will take a few minutes. One of the cells will output three pages of a document with bounding boxes to show how DocParse segments the data.</p><p>The final cell runs a read query to verify if the data has been loaded correctly. Now, you can use the prepared data in the Elasticsearch index with your RAG application.</p><p></p><h3>Add additional data enrichment and transforms</h3><p>The code generated in DocPrep is great for a basic ETL pipeline, however, you may want to extract metadata and perform data cleaning. The pipeline code is fully customizable, and you can use additional transformations in Sycamore or arbitrary Python code.</p><p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/Aryn-elasticsearch-RAG-data-preparation-demo/aryn-elasticsearch-blog-dataprep.ipynb">Here is an example notebook</a> with additional data transforms, metadata extraction, and data cleaning steps. You can use this metadata in your RAG applications to filter your results.</p><h2>Conclusion</h2><p>This blog used Aryn DocParse, DocPrep, and Sycamore to parse, extract, enrich, clean, embed, and load data into vector and keyword indexes in the Elasticsearch vector database. We used DocPrep to create an initial ETL pipeline and then used a notebook with additional Sycamore code to demonstrate additional data enrichment and cleaning.</p><p>How your documents are parsed, enriched, and processed significantly impacts the quality of your RAG queries. Use the examples in this blog post to quickly and easily build your own RAG systems with Aryn and Elasticsearch and iterate on the processing and retrieval strategies as you build your GenAI application.</p><p>Below are some resources for your next steps:</p><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/Aryn-elasticsearch-RAG-data-preparation-demo/aryn-elasticsearch-blog-dataprep.ipynb">Sample notebook with Aryn DataPrep and Elasticsearch</a></p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/run-elasticsearch-locally.html">start-local with Elasticsearch vector database</a></p></li><li><p><a href="https://www.aryn.ai/get-started">Get started with Aryn Cloud DocPrep</a></p></li><li><p><a href="https://sycamore.readthedocs.io/en/stable/">Sycamore documentation</a></p></li><li><p><a href="https://www.elastic.co/search-labs/integrations">Elasticsearch vector database ecosystem integrations</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/rag-aryn-elasticsearch-data-prep</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/rag-aryn-elasticsearch-data-prep</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Hemant Malik,Jonathan Fritz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte3979255ddfc7f45/6a17e25ffaa913812f93c7cb/92c517a2e7b36122a18feee317a0215981b62b6b-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 21 Jan 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Autosharding of data streams in Elasticsearch Serverless]]></title>
    <description><![CDATA[In Elastic Cloud Serverless we spare our users from the need to fiddle with sharding by automatically configuring the optimal number of shards for data streams based on the indexing load.]]></description>
    <content:encoded><![CDATA[<h2>How Elastic Cloud Serverless autosharding works</h2><ol><li><p><strong>Monitors indexing write load: </strong>Elasticsearch continuously tracks the <code>write_load </code>metric, representing the average number of write threads used for indexing. This metric informs sharding decisions.</p></li><li><p><strong>Calculates optimal shard count: </strong>The system uses a formula that considers the current <code>write_load</code>, the minimum and maximum write threads available per node, to determine the ideal number of shards. This balances performance with overhead.</p></li><li><p><strong>Triggers rollover based on write load:</strong> When the <code>write_load</code> necessitates a shard increase, a rollover operation is triggered. This creates a new index in the data stream with the calculated optimal number of shards. Regular rollover conditions (like shard size) also trigger rollovers, potentially leading to shard reduction if the <code>write_load</code> is lower.</p></li><li><p><strong>Applies cooldown periods: </strong>To prevent rapid shard adjustments, cooldown periods are enforced. There's a 4.5-minute wait before increasing shards and a 3-day wait before reducing them.</p></li><li><p><strong>Autoscaling integration: </strong>Autosharding works in conjunction with autoscaling. By dynamically adjusting shard counts, autosharding enables autoscaling to more effectively increase resources as needed, preventing the system from being constrained at low capacity during high indexing workloads. It also enables autoscaling to more effectively increase resources as needed, preventing the system from being constrained at low capacity during high indexing workloads.</p></li></ol><h2>Background</h2><p>Traditionally, users change the sharding configuration of data streams in order to deal with various workloads and make the best use of the available resources. In <a href="https://www.elastic.co/docs/current/serverless">Elastic Cloud Serverless</a> we've introduced autosharding of data streams, enabling them to be managed and scaled automatically based on indexing load. </p><p>This post explores the mechanics of autosharding, its benefits, and its implications for users dealing with variable workloads. The autosharding philosophy is to increase the number of shards aggressively and reduce them very conservatively, such that an increase in shards is not followed prematurely by a reduction of shards due to a small period of reduced workload.</p><h2>Autosharding of data streams in Serverless Elasticsearch</h2><p>Imagine you have a large pizza that needs to be shared among your friends at a party. If you cut the pizza into only two slices for a group of six friends, each slice will need to serve multiple people. This will create a bottleneck, where one person hogs a whole slice while others wait, leading to a slow sharing process. Additionally, not everyone can enjoy the pizza at the same time; you can practically hear the sighs from the friends left waiting. If more friends show up unexpectedly, you’ll struggle to feed them with just two slices and find yourself scrambling to reshape those slices on the spot.</p><p>On the other hand, if you cut the pizza into 36 tiny slices for those same six friends, managing the sharing becomes tricky. Instead of enjoying the pizza, everyone spends more time figuring out how to grab their tiny portions. If the slices are too small, the pizza might even fall apart.</p><p>To ensure everyone enjoys the pizza efficiently, you’d aim to cut it into a number of slices that matches the number of friends. If you have six friends, cutting the pizza into 6 or 12 slices allows everyone to grab a slice without long waits. By finding the right balance in slicing your pizza, you’ll keep the party running smoothly and everyone happy.</p><p>You know it’s a good analogy when you immediately follow-up with the explanation; the pizza represents the data, the slices represent the index shards, and the friends are the Elasticsearch nodes in your cluster.</p><p>Traditionally, users of Elasticsearch had to anticipate their indexing throughput and manually configure the number of shards for each <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/data-streams.html">data stream</a>. This approach relied heavily on predictive heuristics and required ongoing adjustments based on workload characteristics whilst also balancing <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html">data storage, search analytics, and application performance</a>.</p><p>Businesses with seasonal traffic, like retail, often deal with spikes in data demands, while IoT applications can experience rapid load increases at specific times. Development and testing environments typically run only a few hours a week, making fixed shard configurations inefficient. New applications might struggle to estimate workload needs accurately, leading to potential over- or under-provisioning.</p><p>We've introduced autosharding of data streams in <a href="https://www.elastic.co/docs/current/serverless">Elastic Cloud Serverless</a>. Data streams in <a href="https://www.elastic.co/docs/current/serverless">Serverless</a> are managed and scaled automatically based on indexing load - automatically slicing your pizza as friends arrive to your party or finish eating.</p><h2>The promise of autosharding</h2><p>Autosharding addresses these challenges by automatically adjusting the number of shards in response to the current indexing load. This means that instead of users having to manually tweak configurations, Elasticsearch will dynamically manage shard counts for the data streams in your project based on real-time data traffic.</p><p>Elasticsearch keeps track of the indexing load for every index as part of a metric named write load, and exposes it for on-prem and ESS deployments as part of the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-stats.html">index stats API</a> under the indexing section.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbf8a17014280842a/6a170cf55091687333e1bb3e/b83c902ebadc04d7e0e0a794b6b2ec925b0f4cb4-1200x1600.png" alt="Autosharding in Elasticesarch:  indexing load " /><p>The <code>write_load</code> represents the average number of write threads used while indexing documents.</p><p>For an index with one shard the maximum possible value of the <code>write_load</code> metric is the number of write threads available (e.g. all write threads are busy writing in the same shard).</p><p>For indices with multiple shards the maximum possible value for the write load is the number of write threads available in a node times the number of indexing nodes in the project. (e.g. all write threads on all the indexing nodes that host a shard for our index are busy writing in the shards belonging to our index, exclusively)</p><p>To get a sense of the values allowed for <code>write_load</code> let’s look at index <code>logs</code> with one shard running on one Elasticsearch machine with 2 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html#node.processors%60">allocated processors.</a> The <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html">write thread pool</a> will be sized to 2 threads. This means that if this Elasticsearch node is exclusively and constantly writing to the same index <code>logs</code>, the <code>write_load</code> we’ll report for index <code>logs</code> will be <strong>2.0</strong> (i.e. 2 write threads fully utilized for writing into index <code>logs</code>).</p><p>If <code>logs</code> has 2 primary shards and we’re now running on two Elasticsearch nodes, each with 2 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html#node.processors">allocated processors</a> we’ll be able to get a maximum reported <code>write_load</code> of <strong>4.0 </strong>if all write threads on both Elasticsearch nodes are exclusively writing into the <code>logs</code> index.</p><h3>Serverless autoscaling</h3><p>We just looked at how the write load capacity doubled when we increased the number of shards and Elasticsearch nodes. <a href="https://www.elastic.co/docs/current/serverless">Elastic Cloud Serverless</a> takes care automatically of both these operations using data stream autosharding and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-ingest-autoscaling">ingest autoscaling</a>. Autoscaling refers to the process of dynamically adjusting resources - like memory, CPU, and disk - based on current demands.</p><p>In our serverless architecture, we start with a small 2GB memory server and use a step-function scaling approach to increase capacity efficiently. We scale up memory incrementally and then scale out by adding servers. This cycle continues, increasing memory per server incrementally up to 64GB while managing the number of servers.</p><h4>Linking autoscaling and autosharding</h4><p>The connection between auto<strong>scaling</strong> and auto<strong>sharding</strong> is essential for optimizing performance. When calculating the optimal number of shards for a data stream, we consider the minimum and maximum number of available write threads per node in our scaling setup.</p><ul><li><p>For small projects, the system will move from 1 to 2 shards when the data stream uses more than half the capacity of a node (i.e., more than one indexing thread).</p></li><li><p>For medium-sized projects, as the system scales across multiple nodes, it will not exceed 3 shards to avoid excessive overhead.</p></li><li><p>Once we reach the largest node sizes, further sharding is enabled to accommodate larger workloads.</p></li></ul><p>Autosharding also enables autoscaling to increase resources as needed, preventing the system from staying at low capacity during high indexing workloads, by enabling projects to reach higher ingestion load values.</p><h3>Auto sharding formula</h3><p>To determine the number of shards needed, we use the following formula:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93cb63543b31d1b3/6a170cf660084b4d273c45c4/2185640ab125aaf4cd300fbaff74b3d83cf0de31-667x275.png" alt="Autosharding formula in Elasticsearch" /><p>This equation balances the need for increasing shards based on <code>write_load</code> while capping the number of shards to prevent oversharding. The division by 2 reflects the strategy of increasing shards only after exceeding half the capacity of a node. The min/max write threads represent the minimum and maximum number of write threads available in the autoscaling step function (i.e. the number of write threads available on the smallest 2GB step and the number of write threads available on the largest server)</p><p>Let’s visualize the output of the formula:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd5ebade9321a608b/6a170cf8ab7f085179db9ef7/ca928d959397c42a57311a91269d5418e983609a-1600x986.png" alt="Output of autosharding formula formula in Elasticsearch" /><p>On the Y axis we have the <strong>number of shards</strong>. And on the X axis we have the<strong> write load</strong>. We start with 1 shard and we get to 3 shards when the write load is just over 3.0. We remain with 3 shards for quite some time until the write load is about 48.0.</p><p>This covers us for the time we scale up through the nodes but haven’t really got to 2 or more or the largest servers, at which point we unlock auto sharding to more than 3 shards, as many as needed to ingest data.</p><p>While adding shards can improve indexing performance, excessive sharding in an Elasticsearch cluster can have negative repercussions - imagine that pizza with 56 slices being shared by only 7 friends. Each shard carries overhead costs, including maintenance and resource allocation. Our algorithm accounts for and avoids the peril of excessive sharding until we get to the largest workloads where adding more than 3 shards makes a material difference to indexing performance and throughput.</p><h3>Implementing autosharding with rollovers</h3><p>The implementation of autosharding relies on the concept of <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-rollover-index.html">rollover</a>. A rollover operation creates a new index within the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/data-streams.html">data stream</a>, promoting it to the write index while designating the previous index as a regular backing index, which no longer accepts writes. This transition can occur based on specific conditions, such as exceeding a shard size of 50GB. We take care of configuring the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/data-stream-lifecycle-settings.html#_cluster_level_settings_3">optimal rollover conditions</a> for data streams in <a href="https://www.elastic.co/docs/current/serverless">Serverless</a>.</p><p>In <a href="https://www.elastic.co/docs/current/serverless">Serverless</a> alongside the usual rollover conditions that relate to maintaining healthy indices and shards we introduce a new condition that evaluates whether the current write load necessitates an increase in shard count. If this condition is met, a rollover will be triggered and the new resulting data stream <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/data-streams.html#data-stream-write-index">write index</a> will be configured with the optimal number of shards.</p><p>For downscaling, the system will monitor the workload and will not trigger a rollover solely for reducing shards. Instead, it will wait until a regular rollover condition, like the primary shard size, triggers the rollover. The resulting write index will be configured with a lower number of shards.</p><h3>Cooldown periods for shard adjustments</h3><p>To ensure stability during shard adjustments, we implement cooldown periods:</p><ul><li><p><strong>Increase shards cooldown</strong>: A minimum wait time of 4.5 minutes is enforced before increasing the number of shards since the last adjustment. The 4.5 minutes cooldown might seem peculiar but the interval has been chosen to make sure we <strong>can </strong>increase the number of shards every time <a href="https://www.elastic.co/search-labs/blog/data-lifecycle-simplified-for-data-streams">data stream lifecycle</a> checks if data streams should rollover (currently, every 5 minutes) but not more often than 5 minutes, covering for internal Elasticsearch cluster reconfiguration.</p></li><li><p><strong>Decrease shards cooldown</strong>: We maintain a 3-day minimum wait time before reducing shards to ensure that the decision is based on sustained workload patterns rather than temporary fluctuations.</p></li></ul><h2>Conclusion</h2><p>The data streams autosharding feature in <a href="https://www.elastic.co/docs/current/serverless">Serverless</a> Elasticsearch represents significant progress in managing data streams effectively. By automatically adjusting shard counts based on real-time indexing loads, this feature simplifies operations and enhances scalability.</p><p>With the added benefits of <a href="https://www.elastic.co/search-labs/blog/elasticsearch-ingest-autoscaling">autoscaling</a>, users can expect a more efficient and responsive experience, whether they are handling small projects or large-scale applications. As data workloads continue to evolve, the adaptability provided by auto sharding ensures that Elasticsearch remains a robust solution for managing diverse indexing needs.</p><p>Try out our <a href="https://www.elastic.co/docs/current/serverless">Serverless</a> Elasticsearch offering to take advantage of data streams auto sharding and observe the indexing throughput scaling seamlessly as your data ingestion load increases.</p><p>Your pizzas will be optimally sliced as more friends arrive at your party, keen to try those sourdough craft pizzas you prepared for them.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/datastream-autosharding-serverless</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/datastream-autosharding-serverless</guid>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <dc:creator><![CDATA[Andrei Dan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt84da396c370b06ec/6a170cfa6234e00dd3db1a55/d92e2e9fbae1dca1f18e623b2f5eb2a835307130-1600x1066.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 10 Dec 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to use Elasticsearch Vector Store Connector for Microsoft Semantic Kernel for AI Agent development]]></title>
    <description><![CDATA[Microsoft Semantic Kernel is a lightweight, open-source development kit that lets you easily build AI agents and integrate the latest AI models into your C#, Python, or Java codebase. With the release of Semantic Kernel Elasticsearch Vector Store Connector, developers using Semantic Kernel for building AI agents can now plugin Elasticsearch as a scalable enterprise-grade vector store while continuing to use Semantic Kernel abstractions.]]></description>
    <content:encoded><![CDATA[<p>In collaboration with the <a href="https://learn.microsoft.com/en-us/semantic-kernel/overview/">Microsoft Semantic Kernel</a> team, we are announcing the availability of <a href="https://github.com/elastic/semantic-kernel-net/">Semantic Kernel Elasticsearch Vector Store Connector</a>, for <a href="https://learn.microsoft.com/en-us/semantic-kernel/overview/">Microsoft Semantic Kernel</a> (.NET) users. Semantic Kernel simplifies building enterprise-grade AI agents, including the capability to enhance large language models (LLMs) with more relevant, data-driven responses from a Vector Store. Semantic Kernel provides a seamless abstraction layer for interacting with Vector Stores like Elasticsearch, offering essential features such as creating, listing, and deleting collections of records and uploading, retrieving, deleting individual records.</p><p>The <a href="https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/out-of-the-box-connectors/elasticsearch-connector?pivots=programming-language-csharp">out-of-the-box Semantic Kernel Elasticsearch Vector Store Connector</a> supports the Semantic Kernel <a href="https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/?pivots=programming-language-csharp#the-vector-store-abstraction">vector store abstractions</a> which make it very easy for developers to plugin Elasticsearch as a vector store while building AI agents.</p><p>Elasticsearch has a strong foundation in the open-source community and recently adopted the <a href="https://www.elastic.co/blog/elasticsearch-is-open-source-again">AGPL license</a>. Combined with the open-source Microsoft Semantic Kernel, these tools offer a powerful, enterprise-ready solution. You can get started locally by spinning up Elasticsearch in a few minutes by running this command <code>curl -fsSL https://elastic.co/start-local | sh </code>(refer <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/run-elasticsearch-locally.html">start-local</a> for details) and move to <a href="https://cloud.elastic.co/registration?onboarding_token=vectorsearch&amp;utm_source=semantickernel&amp;utm_content=documentation">cloud-hosted</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.16/install-elasticsearch.html">self-hosted</a> versions while productionizing your AI agents.</p><p>In this blog we look at how to use <a href="https://github.com/elastic/semantic-kernel-net/">Semantic Kernel Elasticsearch Vector Store Connector</a> when using Semantic Kernel. A Python version of the connector will be made available in the future.</p><h2>High-level scenario: Building a RAG app with Semantic Kernel &amp; Elasticsearch</h2><p>In the following section we go through an example. At a high-level we are building a RAG (Retrieval Augmented Generation) application which takes a user's question as input and returns an answer. We will use Azure OpenAI (<a href="https://devblogs.microsoft.com/semantic-kernel/introducing-new-ollama-connector-for-local-models/">local LLM</a> can be used as well) as the LLM, Elasticsearch as the vector store and Semantic Kernel (.net) as the framework to tie all components together.</p><p>If you are not familiar with RAG architectures, you can have a quick introduction with this article: <a href="https://www.elastic.co/search-labs/blog/retrieval-augmented-generation-rag">https://www.elastic.co/search-labs/blog/retrieval-augmented-generation-rag</a>.</p><p>The answer is generated by the LLM which is fed with context, relevant to the question, retrieved from Elasticsearch vectorstore. The response also includes the source that was used as the context by the LLM.</p><h3>RAG example</h3><p>In this specific example, we build an application that allows users to ask questions about hotels stored in an internal hotel database. The user could e.g. search for a specific hotel, based on different criteria, or ask for a list of hotels.</p><p>For the example database, we generated a <a href="https://github.com/elastic/semantic-kernel-net/blob/main/Elastic.SemanticKernel.Playground/hotels.csv">list of hotels</a> containing 100 entries. The sample size is intentionally small to allow you to try out the connector demo as easily as possible. In a real-world application, the Elasticsearch connector would show its advantages over other options, such as the `InMemory` vector store implementation, especially when working with extremely large amounts of data.</p><p>The complete demo application can be found in the Elasticsearch vector store connector <a href="https://github.com/elastic/semantic-kernel-net/tree/main/Elastic.SemanticKernel.Playground">repository</a>.</p><p>Let’s start with adding the required NuGet packages and using directives to our project:</p>dotnet add package "Elastic.Clients.Elasticsearch" -v 8.16.2
dotnet add package "Elastic.SemanticKernel.Connectors.Elasticsearch" -v 0.1.2
dotnet add package "Microsoft.Extensions.Hosting" -v 9.0.0
dotnet add package "Microsoft.SemanticKernel.Connectors.AzureOpenAI" -v 1.30.0
dotnet add package "Microsoft.SemanticKernel.PromptTemplates.Handlebars" -v 1.30.0using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

using Elastic.Clients.Elasticsearch;
using Elastic.Transport;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Data;
using Microsoft.SemanticKernel.Embeddings;
using Microsoft.SemanticKernel.PromptTemplates.Handlebars;<p>We can now create our data model and provide it with Semantic Kernel specific attributes to define the storage model schema and some hints for the text search:</p>/// &lt;summary&gt;
/// Data model for storing a "hotel" with a name, a description, a  description embedding and an optional reference link.
/// &lt;/summary&gt;
public sealed record Hotel
{
	[VectorStoreRecordKey]
	public required string HotelId { get; set; }

	[TextSearchResultName]
	[VectorStoreRecordData(IsFilterable = true)]
	public required string HotelName { get; set; }

	[TextSearchResultValue]
	[VectorStoreRecordData(IsFullTextSearchable = true)]
	public required string Description { get; set; }

	[VectorStoreRecordVector(Dimensions: 1536, DistanceFunction.CosineSimilarity, IndexKind.Hnsw)]
	public ReadOnlyMemory&lt;float&gt;? DescriptionEmbedding { get; set; }

	[TextSearchResultLink]
	[VectorStoreRecordData]
	public string? ReferenceLink { get; set; }
}<p>The Storage Model Schema attributes (`VectorStore*`) are most relevant for the actual use of the Elasticsearch Vector Store Connector, namely:</p><p></p><ul><li><p><code>VectorStoreRecordKey</code> to mark a property on a record class as the key under which the record is stored in a vector store.</p></li><li><p><code>VectorStoreRecordData</code> to mark a property on a record class as 'data'.</p></li><li><p><code>VectorStoreRecordVector</code> to mark a property on a record class as a vector.</p></li></ul><p>All of these attributes accept various optional parameters that can be used to further customize the storage model. In the case of <code>VectorStoreRecordKey </code>, for example, it is possible to specify a different distance function or a different index type.</p><p>The text search attributes (<code>TextSearch*</code>) will be important in the last step of this example. We will come back to them later.</p><p>In the next step, we initialize the Semantic Kernel engine and obtain references to the core services. In a real world application, <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection">dependency injection</a> should be used instead of directly accessing the service collection. The same thing applies to the hardcoded configuration and secrets, which should be read using a <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration">configuration provider</a> instead:</p>var builder = Host.CreateApplicationBuilder(args);

// Register AI services.
var kernelBuilder = builder.Services.AddKernel();

kernelBuilder.AddAzureOpenAIChatCompletion("gpt-4o", "https://my-service.openai.azure.com", "my_token");

kernelBuilder.AddAzureOpenAITextEmbeddingGeneration("ada-002", "https://my-service.openai.azure.com", "my_token");

// Register text search service.
kernelBuilder.AddVectorStoreTextSearch&lt;Hotel&gt;();

// Register Elasticsearch vector store.
var elasticsearchClientSettings = new ElasticsearchClientSettings(new Uri("https://my-elasticsearch-instance.cloud"))
    .Authentication(new BasicAuthentication("elastic", "my_password"));

kernelBuilder.AddElasticsearchVectorStoreRecordCollection&lt;string, Hotel&gt;("skhotels", elasticsearchClientSettings);

// Build the host.
using var host = builder.Build();

// For demo purposes, we access the services directly without using a DI context.

var kernel = host.Services.GetService&lt;Kernel&gt;()!;
var embeddings = host.Services.GetService&lt;ITextEmbeddingGenerationService&gt;()!;
var vectorStoreCollection = host.Services.GetService&lt;IVectorStoreRecordCollection&lt;string, Hotel&gt;&gt;()!;

// Register search plugin.
var textSearch = host.Services.GetService&lt;VectorStoreTextSearch&lt;Hotel&gt;&gt;()!;
kernel.Plugins.Add(textSearch.CreateWithGetTextSearchResults("SearchPlugin"));<p>The <code>vectorStoreCollection</code> service can now be used to create the collection and to ingest a few <a href="https://github.com/elastic/semantic-kernel-net/blob/main/Elastic.SemanticKernel.Playground/hotels.csv">demo records</a>:</p>await vectorStoreCollection.CreateCollectionIfNotExistsAsync();

// CSV format: ID;Hotel Name;Description;Reference Link
var hotels = (await File.ReadAllLinesAsync("hotels.csv"))
    .Select(x =&gt; x.Split(';'));

foreach (var chunk in hotels.Chunk(25))
{
    var descriptionEmbeddings = await embeddings.GenerateEmbeddingsAsync(chunk.Select(x =&gt; x[2]).ToArray());
    
    for (var i = 0; i &lt; chunk.Length; ++i)
    {
        var hotel = chunk[i];
        await vectorStoreCollection.UpsertAsync(new Hotel
        {
            HotelId = hotel[0],
            HotelName = hotel[1],
            Description = hotel[2],
            DescriptionEmbedding = descriptionEmbeddings[i],
            ReferenceLink = hotel[3]
        });
    }
}<p>This shows how Semantic Kernel reduces the use of a vector store with all its complexity to a few simple method calls.</p><p>Under the hood, a new index is created in Elasticsearch and all the necessary property mappings are created. Our data set is then mapped completely transparently into the storage model and finally stored in the index. Below is how the mappings look in Elasticsearch.</p>{
  "mappings": {
    "properties": {
      "descriptionEmbedding": {
        "dims": 1536,
        "index": true,
        "index_options": {
          "type": "hnsw"
        },
        "similarity": "cosine",
        "type": "dense_vector"
      },
      "hotelName": {
        "type": "keyword"
      },
      "description": {
        "type": "text"
      }
    }
  }
}<p>The <code>embeddings.GenerateEmbeddingsAsync()</code> calls transparently called the configured Azure AI Embeddings Generation service.</p><p>Even more magic can be observed in the last step of this demo.</p><p>With just a single call to <code>InvokePromptAsync</code>, all of the following operations are performed when the user asks a question about the data:</p><p>1. An embedding for the user's question is generated</p><p>2. The vector store is searched for relevant entries</p><p>3. The results of the query are inserted into a prompt template</p><p>4. The actual query in the form of the final prompt is sent to the AI chat completion service</p>// Invoke the LLM with a template that uses the search plugin to
// 1. get related information to the user query from the vector store
// 2. add the information to the LLM prompt.
var response = await kernel.InvokePromptAsync(
    promptTemplate: """
                    Please use this information to answer the question:
                    {{#with (SearchPlugin-GetTextSearchResults question)}}
                      {{#each this}}
                        Name: {{Name}}
                        Value: {{Value}}
                        Source: {{Link}}
                        -----------------
                      {{/each}}
                    {{/with}}
                    
                    Include the source of relevant information in the response.

                    Question: {{question}}
                    """,
    arguments: new KernelArguments
    {
        { "question", "Please show me all hotels that have a rooftop bar." },
    },
    templateFormat: "handlebars",
    promptTemplateFactory: new HandlebarsPromptTemplateFactory());<p>Remember the <code>TextSearch*</code> attributes, we previously defined on our data model? These attributes enable us to use corresponding placeholders in our prompt template which are automatically populated with the information from our entries in the vector store.</p><p>The final response to our question "Please show me all hotels that have a rooftop bar." is as follows:</p>Console.WriteLine(response.ToString());

// &gt; The hotel that has a rooftop bar is Skyline Suites. You can find more information about this hotel [here](https://example.com/yz567).<p>The answer correctly refers to the following entry in our hotels.csv</p>9;
Skyline Suites;
Offering panoramic city views from every suite, this hotel is perfect for those who love the urban landscape. Enjoy luxurious amenities, a rooftop bar, and close proximity to attractions. Luxurious and contemporary.;
https://example.com/yz567<p>This example shows very well how the use of Microsoft Semantic Kernel achieves a significant reduction in complexity through its well thought abstractions, as well as enabling a very high level of flexibility. By changing a single line of code, for example, the vector store or the AI services used can be replaced without having to refactor any other part of the code.</p><p>At the same time, the framework provides an enormous set of high-level functionality, such as the `InvokePrompt` function, or the template or search plugin system.</p><p>The complete demo application can be found in the Elasticsearch vector store connector repository.</p><h2>What else is possible with Elasticsearch</h2><ul><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">Elasticsearch new semantic_text mapping: Simplifying semantic search</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-reranking-with-retrievers">Semantic reranking in Elasticsearch with retrievers</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1">Advanced RAG techniques part 1: Data processing</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2">Advanced RAG techniques part 2: Querying and testing</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-rag-with-llama3-opensource-and-elastic">Building RAG with Llama 3 open-source and Elastic</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/local-rag-agent-elasticsearch-langgraph-llama3">A tutorial on building local agent using LangGraph, LLaMA3 and Elasticsearch vector store from scratch</a></p></li></ul><h2>Elasticsearch &amp; Semantic Kernel: What's next?</h2><ul><li><p>We showed how the Elasticsearch vector store can be easily plugged into Semantic Kernel while building GenAI applications in .NET. Stay tuned for a Python integration next.</p></li><li><p>As Semantic Kernel builds abstractions for advanced search features like <a href="https://www.elastic.co/search-labs/tutorials/search-tutorial/vector-search/hybrid-search">hybrid search</a>, the Elasticsearch connect will enable .NET developers to easily implement them while using Semantic Kernel.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-connector-microsoft-semantic-kernel</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-connector-microsoft-semantic-kernel</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[.NET]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Florian Bernd,Srikanth Manvi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d8725035e86f8a8/6a17fe447f6f1564f8c09d74/0564fe794e4c66d0507317822d7aa71826183d20-1311x762.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 06 Dec 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Export your Kibana Dev Console requests to Python and JavaScript Code]]></title>
    <description><![CDATA[The Kibana Dev Console now offers the option to export requests to Python and JavaScript code that is ready to be integrated into your application.]]></description>
    <content:encoded><![CDATA[<p>Have you used the Kibana Dev Console? This is a fantastic prototyping tool that allows you to build and test your Elasticsearch requests interactively. But what do you do after you have a working request in the Console?</p><p>In this article we'll take a look at the new code generation feature in the Kibana Dev Console, and how it can significantly reduce your development effort by generating ready to use code for you.</p><p>This feature is available in our Serverless platform and in Elastic Cloud and self-hosted releases 8.16 and up.</p><h2>The Kibana Dev Console</h2><p>This section provides a quick introduction to the <a href="https://www.elastic.co/guide/en/kibana/current/console-kibana.html">Kibana Dev Console</a>, in case you have never used it before. Skip to the next section if you are already familiar with it.</p><p>While you are in any part of the Search section in Kibana, you will notice a "Console" link at the bottom of your browser's page:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7c0bbd6964b0886d/6a170aeb6f7f04dfb991484b/e80850635ecc74536696743181afb3ac0c74e38f-1024x742.png" alt="The Kibana Dev Console - Open Console" /><p>When you click this link, the Console expands to cover the page. Click it again to collapse it.</p><p>In the left-side panel of the Dev Console, you can enter Elasticsearch requests, with the help of an interactive editor that provides auto-completion and checks your syntax. Some example requests are already pre-populated so that you have something to start experimenting with.</p><p>When the cursor is on a request, a "play" button appears to its right. You can click this button to send the request to your Elasticsearch server.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8e8bf8f61f065daa/6a170aed964cea4ffa08bb9b/520637e15cd03234aefd26502e42c80310b3734f-1006x230.png" alt="Kibana Dev Console Send Request" /><p>After you execute a request, the response from the server appears in the panel on the right.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8c9a61493ab010d9/6a170aef0e2e49de6d41a0e2/ef250921da3ff260a6f56d6d4745842096809564-1024x642.png" alt="Kibana Dev Console Response" /><h2>Code Export feature in Kibana Dev Console</h2><p>The Dev Console makes it easy to prototype your requests or queries until you get exactly what you want. But what happens next? If you need to convert the request to code so that you can incorporate it into your application, then you can save time using the new code export feature.</p><p>Next to the Play button you will find the three dot or "kebab" button, which opens a menu of options. The first option provides access to the code export feature. If you've never used this feature before, it will appear with a "Copy as curl" label.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69fd88f2ae0eadae/6a170af02b835f8ca2f4b205/27fe3d5aa5874d094d26d35ff2188ccc0e435b9f-1272x476.png" alt="Kibana Dev Console Options Menu" /><p>If you select this option, your clipboard will be loaded with a <a href="https://curl.se/">curl</a> command that is equivalent to the selected request.</p><p>Now, things get more interesting when you click the "Change" link, which allows you to switch to a different target language. In this initial release, the code export adds support for Python and JavaScript. More languages are expected to be added in future releases.</p><p>You can now select your desired language and click "Copy code" to put the exported code in your clipboard. You can also change the default language that is offered in the menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64d35579b66099a4/6a170af266c4f9d021f8c02d/dd4c2c94ea17ba74bae7cf05fbab4b3944cb37ed-1256x702.png" alt="Kibana Dev Console Select Language" /><p>The exported code is a complete script in the selected language, using the official Elasticsearch client for that language. Here is an example of how the <code>PUT /my-index</code> request shown above looks when exported to the Python language:</p>import os
from elasticsearch import Elasticsearch

client = Elasticsearch(
    hosts=["&lt;your-elasticsearch-endpoint-url-here"],
    api_key=os.getenv("ELASTIC_API_KEY"),
)

resp = client.indices.create(
    index="my-index",
)
print(resp)<p>To use the exported code follow these steps:</p><ul><li><p>Paste the code from the clipboard to a new file with the correct extension (<code>.py</code> for Python, or <code>.js</code> for JavaScript).</p></li><li><p>In your terminal, add an environment variable called <code>ELASTIC_API_KEY</code> with a valid API Key for your Elasticsearch cluster. You can <a href="https://www.elastic.co/guide/en/kibana/current/api-keys.html#create-api-key">create an API key</a> right in Kibana if you don't have one yet.</p></li><li><p>Execute the script with the <code>python</code> or <code>node</code> commands depending on your language, making sure the official Elasticsearch client is installed.</p></li></ul><p>Now you are ready to adapt the exported code as needed to integrate it into your application!</p><h2>Conclusion</h2><p>In this article you have learned about the new Code Export feature in the Kibana Dev Console. We hope this feature will streamline your development process with Elasticsearch!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/kibana-dev-console-code-export</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dev-console-code-export</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Javascript]]></category>
    <category><![CDATA[Kibana]]></category>
    <dc:creator><![CDATA[Miguel Grinberg]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64d35579b66099a4/6a170af266c4f9d021f8c02d/dd4c2c94ea17ba74bae7cf05fbab4b3944cb37ed-1256x702.png" length="0" type="image/png"/>
    <pubDate>Wed, 30 Oct 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From ES|QL to native Pandas dataframes in Python]]></title>
    <description><![CDATA[Learn how to export ES|QL queries as native Pandas dataframes in Python through practical examples.]]></description>
    <content:encoded><![CDATA[<p>Since Elasticsearch 8.15 or with Elasticsearch Serverless, <a href="https://github.com/elastic/elasticsearch/pull/109873">ES|QL responses support the Apache Arrow streaming format</a>. This blog post will show you how to take advantage of it in Python. In an <a href="https://www.elastic.co/search-labs/blog/esql-pandas-dataframes-python">earlier blog post</a>, I demonstrated how to convert ES|QL queries to Pandas dataframes using CSV as an intermediate representation. Unfortunately, CSV requires explicit type declarations, is slow (especially for larger datasets) and does not handle nested arrays and objects. Apache Arrow lifts all these limitations.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta5d7fdd54f312f06/6a17d7bbfaa913809b93c6db/7decbe330061eae8108f7ad6a32a2df01f55244f-389x144.svg" alt="ES|QL produces tables" /><h2>ES|QL to Pandas dataframes in Python</h2><h3>Importing test data</h3><p>First, let's import some test data. As before, we will be using the <code>employees</code> <a href="https://github.com/elastic/elasticsearch/blob/d46bcc968e6cabca55f1a62b2218e9fc4e84e9d4/x-pack/plugin/esql/qa/testFixtures/src/main/resources/employees.csv">sample data</a> and <a href="https://github.com/elastic/elasticsearch/blob/main/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-default.json">mappings</a>. The easiest way to load this dataset is to <a href="https://gist.github.com/pquentin/7cf29a5932cf52b293699dd994b1a276">run these two Elasticsearch API requests</a> in the <a href="https://www.elastic.co/guide/en/kibana/current/console-kibana.html">Kibana Console</a>.</p><h3>Converting dataset to a Pandas DataFrame object</h3><p>OK, with that out of the way, let's convert the full <code>employees</code> dataset to a Pandas DataFrame object using the ES|QL Arrow export:</p>from elasticsearch import Elasticsearch
import pandas as pd

client = Elasticsearch(
    "https://[host].elastic-cloud.com",
    api_key="...",
)

response = client.esql.query(
    query="""
    FROM employees
    | DROP is_rehired,job_positions,salary_change*
    | LIMIT 500
    """,
    format="arrow",
)
df = response.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
<p>Even though this dataset only contains 100 records, we use a <code>LIMIT</code> command to avoid ES|QL warning us about potentially missing records. This prints the following dataframe:</p>    avg_worked_seconds           birth_date  ...  salary still_hired
0            268728049  1953-09-02 00:00:00  ...   57305        True
1            328922887  1964-06-02 00:00:00  ...   56371        True
2            200296405  1959-12-03 00:00:00  ...   61805       False
3            311267831  1954-05-01 00:00:00  ...   36174        True
4            244294991  1955-01-21 00:00:00  ...   63528        True
..                 ...                  ...  ...     ...         ...
95           204381503  1954-09-16 00:00:00  ...   43889       False
96           206258084  1952-02-27 00:00:00  ...   71165       False
97           272392146  1961-09-23 00:00:00  ...   44817       False
98           377713748  1956-05-25 00:00:00  ...   73578        True
99           223910853  1953-04-21 00:00:00  ...   68431        True

[100 rows x 17 columns]
<p>OK, so what actually happened here?</p><ul><li><p>Given <code>format="arrow"</code>, Elasticsearch returns binary Arrow streaming data</p></li><li><p>The Elasticsearch Python client looks at the Content-Type header and creates a <a href="https://arrow.apache.org/docs/python/index.html">PyArrow object</a></p></li><li><p>Finally, PyArrow's <a href="https://arrow.apache.org/docs/python/pandas.html">Pandas integration</a> converts the PyArrow object to a Pandas dataframe.</p></li></ul><p>Note that the <code>types_mapper=pd.ArrowDtype</code> parameter asks Pandas to use a PyArrow backend instead of a NumPy backend, since the source data is PyArrow. While this backend is not enabled by default for compatibility reasons, it <a href="https://datapythonista.me/blog/pandas-20-and-the-arrow-revolution-part-i">has many advantages</a>: it handles missing values, is faster, more interopable and supports more types. (This is not a <a href="https://arrow.apache.org/docs/python/pandas.html#memory-usage-and-zero-copy">zero copy conversion</a>, however.)</p><p>For this example to work, the Pandas and PyArrow optional dependencies need to be installed. If you want to use another dataframe library such as Polars instead, you don't need Pandas and can directly use <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.from_arrow.html"><code>polars.from_arrow</code></a> to create a Polars DataFrame from the PyArrow table returned by the Elasticsearch client.</p><p>One limitation is that Elasticsearch does not currently handle multi-valued fields, which is why we had to drop the <code>is_rehired</code>, <code>job_positions</code> and <code>salary_change</code> columns. This limitation will be lifted in a future version of Elasticsearch.</p><p>Anyway, you now have a Pandas dataframe that you can use to analyze your data further. But you can also continue massaging the data using ES|QL, which is particularly useful when queries return more than 10,000 rows, the current maximum number of rows that ES|QL queries can return.</p><h3>More complex queries</h3><p>In the next example, we're counting how many employees are speaking a given language by using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-stats-by"><code>STATS ... BY</code></a> (not unlike <code>GROUP BY</code> in SQL). And then we sort the result with the <code>languages</code> column using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-sort"><code>SORT</code></a>:</p>response = client.esql.query(
    query="""
    FROM employees
    | DROP is_rehired,job_positions,salary_change*
    | STATS count = COUNT(emp_no) BY languages
    | SORT languages
    | LIMIT 500
    """,
    format="arrow",
)

df = response.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
<p>Unlike with CSV, we did not have to specify any types, as Arrow data already includes types. Here's the result:</p>   count  languages
0     15          1
1     19          2
2     17          3
3     18          4
4     21          5
5     10       &lt;NA&gt;
<p>21 employees speak 5 languages, wow! And 10 employees did not declare any spoken language. The missing value is denoted by <code>&lt;NA&gt;</code>, which is consistently used for missing data with the PyArrow backend. If we had used the NumPy backend instead, this column would have been converted to floats and the missing value would have been a confusing <code>NaN</code>, as <a href="https://pandas.pydata.org/docs/user_guide/missing_data.html">NumPy integers don't have any sentinel value for missing data</a>.</p><h3>Queries with parameters</h3><p>Finally, suppose that you want to expand the query from the previous section to only consider employees that speak N or more languages, with N being a variable parameter. For this we can use <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-rest.html#esql-rest-params">ES|QL's built-in support for parameters</a>, which eliminates the risk of an injection attack associated with manually assembling queries with variable parts:</p>response = client.esql.query(
    query="""
    FROM employees
    | DROP is_rehired,job_positions,salary_change*
    | STATS count = COUNT(emp_no) BY languages
    | WHERE languages &gt;= (?)
    | SORT languages
    | LIMIT 500
    """,
    format="arrow",
    params=[3],
)

df = response.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
<p>which prints the following:</p>   count  languages
0     17          3
1     18          4
2     21          5
<h2>Conclusion</h2><p>As we saw, ES|QL's native Arrow support makes working with Pandas and other DataFrame libraries even nicer than using CSV and it will continue to improve over time, with the multi-value support coming in a future version of Elasticsearch.</p><h2>Additional resources</h2><p>If you want to learn more about ES|QL, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a> is the best place to start. You can also check out <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/Boston-Celtics-Demo/celtics-esql-demo.ipynb">this other Python example using Boston Celtics data</a>. To know more about the Python Elasticsearch client itself, you can <a href="https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/index.html">refer to the documentation</a>, ask a question <a href="https://discuss.elastic.co/tag/language-clients">on Discuss with the language-clients tag</a> or <a href="https://github.com/elastic/elasticsearch-py">open a new issue</a> if you found a bug or have a feature request. Thank you!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-pandas-native-dataframes-python</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-pandas-native-dataframes-python</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Quentin Pradet]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb1808f6b0c1b0ed3/6a17d7bcec0f89c6c35a644e/1b32822c3bf2ad216b21d819c5795f080b6e6cbf-500x500.png" length="0" type="image/png"/>
    <pubDate>Thu, 05 Sep 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch open inference API for Anthropic’s Claude]]></title>
    <description><![CDATA[Interact with Anthropic's Claude 3.5 Sonnet and other models to generate content and perform question &amp; answering.]]></description>
    <content:encoded><![CDATA[<p>We are excited to announce our latest addition to the Elasticsearch Open <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-apis.html">Inference API</a>: the integration of Anthropic's Claude. This work enables Elastic users to connect directly with the Anthropic platform, and use large language models like Claude 3.5 Sonnet to build GenAI applications with use cases such as question answering. Previously customers could access this capability from providers like Amazon Bedrock, but now can utilize their Anthropic account for these purposes.</p><h2>Using Anthropic’s messages to answer questions</h2><p>In this blog, we’ll use the Claude Messages API to answer questions during ingestion to have answers ready ahead of searching. Before we start interacting with Elasticsearch, make sure you have an Anthropic API key by creating an <a href="https://console.anthropic.com/login">evaluation account</a> first and <a href="https://console.anthropic.com/settings/keys">generating a key</a>. We’ll use <a href="https://www.elastic.co/guide/en/kibana/current/console-kibana.html">Kibana's Console</a> to execute these next steps in Elasticsearch without setting up an IDE.</p><p>First, we configure an inference endpoint, which will interact with Anthropic’s messages API:</p>PUT _inference/completion/anthropic_completion
{
  "service": "anthropic",
  "service_settings": {
    "api_key": "&lt;api key&gt;",
    "model_id": "claude-3-5-sonnet-20240620"
  },
  "task_settings": {
    "max_tokens": 1024
  }
}
<p>We’ll get back a response similar to the following with status code <code>200 OK</code> on successful inference endpoint creation:</p>{
  "model_id": "anthropic_completion",
  "task_type": "completion",
  "service": "anthropic",
  "service_settings": {
    "model_id": "claude-3-5-sonnet-20240620",
    "rate_limit": {
      "requests_per_minute": 50
    }
  },
  "task_settings": {
    "max_tokens": 1024
  }
}
<p>We can now call the configured endpoint to perform completion on any text input. Let’s ask the model for a short description of GenAI:</p>POST _inference/completion/anthropic_completion
{
  "input": "What is a short description of GenAI?"
}
<p>We should get a response back with a status code <code>200 OK</code> providing a short description of GenAI:</p>{
  "completion": [
    {
      "result": "GenAI, short for Generative Artificial Intelligence, refers to AI systems that can create new content, such as text, images, audio, or video, based on patterns learned from existing data. These systems use advanced machine learning techniques, often involving deep neural networks, to generate human-like outputs in response to prompts or inputs. GenAI has diverse applications across industries, including content creation, design, coding, and problem-solving."
    }
  ]
}
<p>Now we can set up a catalog of questions which we want to be answered during ingestion. We’ll use the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html">Elasticsearch Bulk API</a> to index these questions about Elastic products:</p>POST _bulk
{ "index" : { "_index" : "questions" } }
{"question": "What is Elasticsearch?"}
{ "index" : { "_index" : "questions" } }
{"question": "What is Kibana?"}
{ "index" : { "_index" : "questions" } }
{"question": "What is Logstash?"}
<p>A response similar to the one below should be returned upon successful indexing:</p>{
  "errors": false,
  "took": 1552829728,
  "items": [
    {
      "index": {
        "_index": "questions",
        "_id": "ipR_qJABkw3SJM5Tm3IC",
        "_version": 1,
        "result": "created",
        "_shards": {
          "total": 2,
          "successful": 1,
          "failed": 0
        },
        "_seq_no": 0,
        "_primary_term": 1,
        "status": 201
      }
    },
    {
      "index": {
        "_index": "questions",
        "_id": "i5R_qJABkw3SJM5Tm3IC",
        "_version": 1,
        "result": "created",
        "_shards": {
          "total": 2,
          "successful": 1,
          "failed": 0
        },
        "_seq_no": 1,
        "_primary_term": 1,
        "status": 201
      }
    },
    {
      "index": {
        "_index": "questions",
        "_id": "jJR_qJABkw3SJM5Tm3IC",
        "_version": 1,
        "result": "created",
        "_shards": {
          "total": 2,
          "successful": 1,
          "failed": 0
        },
        "_seq_no": 2,
        "_primary_term": 1,
        "status": 201
      }
    }
  ]
}
<p>We’ll now create our question and answering <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html">ingest pipeline</a> using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/script-processor.html">script</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/inference-processor.html">inference</a>, and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/remove-processor.html">remove</a> processors:</p>PUT _ingest/pipeline/question_answering_pipeline
{
  "processors": [
    {
      "script": {
        "source": "ctx.prompt = 'Please answer the following question: ' + ctx.question"
      }
    },
    {
      "inference": {
        "model_id": "anthropic_completion",
        "input_output": {
          "input_field": "prompt",
          "output_field": "answer"
        }
      }
    },
    {
      "remove": {
        "field": "prompt"
      }
    }
  ]
}
<p>The pipeline prefixes the <code>question</code> field with the text: <code>“Please answer the following question: “</code> in a temporary field called <code>prompt</code>. The content of the temporary <code>prompt</code> field is sent to the Anthropic service via the inference API. Using an ingest pipeline provides extensive flexibility as you can set the pre-prompt to fit your needs. This approach can be used to summarize documents as well.</p><p>Next, we’ll send our documents containing the questions through the question and answering pipeline by calling the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html">reindex API</a>.</p>POST _reindex
{
  "source": {
    "index": "questions",
    "size": 50
  },
  "dest": {
    "index": "answers",
    "pipeline": "question_answering_pipeline"
  }
}
<p>We should get back a response similar to the following:</p>{
  "took": 9571,
  "timed_out": false,
  "total": 3,
  "updated": 0,
  "created": 3,
  "deleted": 0,
  "batches": 1,
  "version_conflicts": 0,
  "noops": 0,
  "retries": {
    "bulk": 0,
    "search": 0
  },
  "throttled_millis": 0,
  "requests_per_second": -1,
  "throttled_until_millis": 0,
  "failures": []
}
<p>In a production setup, you’ll likely use another ingestion mechanism to ingest your documents in an automated manner. Check out our <a href="https://www.elastic.co/guide/en/cloud/current/ec-cloud-ingest-data.html">Adding data to Elasticsearch guide</a> to learn more about the various options offered by Elastic to ingest data into Elasticsearch. We’re also committed to showcasing ingest mechanisms and providing guidance on bringing data into Elasticsearch using 3rd party tools. For example, take a look at <a href="https://www.elastic.co/search-labs/blog/data-ingestion-from-snowflake-to-elasticsearch-using-meltano">Ingest Data from Snowflake to Elasticsearch using Meltano: A developer’s journey</a> to see how to use Meltano for ingesting data.</p><p>We can now search for our pre-generated answers using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html">Search API</a>:</p>POST answers/_search
{
  "query": {
    "match_all": {}
  }
}
<p>The response will contain the pre-generated answers:</p>{
  "took": 11,
  "timed_out": false,
  "_shards": { ... },
  "hits": {
    "total": { ... },
    "max_score": 1.0,
    "hits": [
      {
        "_index": "answers",
        "_id": "4RO6YY8Bv2OsAP2iNusn",
        "_score": 1.0,
        "_ignored": [
          "answer.keyword"
        ],
        "_source": {
          "model_id": "azure_openai_completion",
          "question": "What is Elasticsearch?",
          "answer": "Elasticsearch is an open-source, RESTful, distributed search and analytics engine built on Apache Lucene. It can handle a wide variety of data types, including textual, numerical, geospatial, structured, and unstructured data. Elasticsearch is scalable and designed to operate in real-time, making it an ideal choice for use cases such as application search, log and event data analysis, and anomaly detection."
        }
      },
      { ... },
      { ... }
    ]
  }
}
<p>Pre-generating answers for frequently asked questions is particularly effective in reducing operational costs. By minimizing the need for on-the-fly response generation, you can significantly cut down on the amount of computational resources required. Additionally, this method ensures that every user receives the same precise information. Consistency is critical, especially in fields requiring high reliability and accuracy such as medical, legal, or technical support.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-anthropic-support</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-anthropic-support</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Jonathan Buttner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1d3c715e5832af68/6a1712292b835f4c7bf4b33f/d030a3b1f4c719792c5c11ba06f6547d2197343c-1440x810.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 26 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[GenAI for customer support — Part 4: Tuning RAG search for relevance]]></title>
    <description><![CDATA[This series gives you an inside look at how we're using generative AI in customer support. Join us as we share our journey in real-time, focusing in this section on tuning RAG search for relevance.]]></description>
    <content:encoded><![CDATA[<p></p>This blog series reveals how our Field Engineering team used the Elastic stack with generative AI to develop a lovable and effective customer support chatbot. If you missed other installments in the series, be sure to check out <a href="https://www.elastic.co/blog/genai-customer-support-building-proof-of-concept">part one</a>, <a href="https://search-labs.elastic.co/search-labs/blog/genai-customer-support-building-a-knowledge-library">part two</a>, <a href="https://search-labs.elastic.co/search-labs/blog/genai-elastic-elser-chat-interface">part three</a>, the <a href="https://www.elastic.co/blog/generative-ai-customer-support-elastic-support-assistant">launch blog</a>, and <a href="https://www.elastic.co/search-labs/blog/genai-customer-support-observability">part five</a>.<p>
Welcome to part 4 of our blog series on integrating generative AI in Elastic's customer support. This installment dives deep into the role of Retrieval-Augmented Generation (RAG) in enhancing our AI-driven Technical Support Assistant. Here, we address the challenges, solutions, and outcomes of refining search effectiveness, providing action items to further improve its capabilities using the toolset provided in the Elastic Stack version <em>8.11</em>.</p><p>Implied by those actions, we have achieved a <strong>~75% increase in top-3 results</strong> relevance and gained over <strong>300,000 AI-generated summaries that we can leverage for all kinds of future applications</strong>. If you're new to this series, be sure to review the earlier posts that introduce the core technology and architectural setup. If you missed the last blog of the series, you can find it <a href="https://www.elastic.co/search-labs/blog/genai-elastic-elser-chat-interface">here</a>.</p><h2>RAG tuning: A search problem</h2><p>Perfecting RAG (Retrieval-Augmented Generation) is fundamentally about hitting the bullseye in search accuracy 🎯:</p><ul><li><p>Like an archer carefully aiming to hit the center of the target, we want to focus on <strong>accuracy</strong> for each hit.</p></li><li><p>Not only that, we also want to ensure that we have the best targets to hit – or <strong>high-quality data</strong>.</p></li></ul><p>Without <strong>both together</strong>, there's the potential risk that large language models (LLMs) might hallucinate and generate misleading responses. Such mistakes can definitely shake users' trust in our system, leading to a deflecting usage and poor return on investment.</p><p>To avoid those negative implications, we've encountered several challenges that have helped us refine our search accuracy and data quality over the course of our journey. These challenges have been instrumental in shaping our approach to tuning RAG for relevance, and we're excited to share our insights with you.</p><p>That said: <strong>let's dive into the details!</strong></p><h2>Our first approach</h2><p>We started with a lean, effective solution that could quickly get us a valuable RAG-powered chatbot in production. This meant focusing on key functional aspects that would bring it to operational readiness with optimal search capabilities. To get us into context, we'll make a quick walkthrough around four key vital components of the Support AI Assistant: <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#data"><em>data</em></a>, <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#query"><em>querying</em></a>, <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#generation"><em>generation</em></a>, and <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#feedback"><em>feedback</em></a>.</p><h3>Data</h3><p>As showcased in the <a href="https://search-labs.elastic.co/search-labs/blog/genai-customer-support-building-a-knowledge-library#elastic-supports-knowledge-library">2nd blog article of this series</a>, our journey began with an extensive database that included over 300,000 documents consisting of <em>Technical Support Knowledge Articles</em> and various pages crawled from our website, such as Elastic's <em>Product Documentation</em> and <em>Blogs</em>. This rich dataset served as the foundation for our search queries, ensuring a broad spectrum of information about Elastic products was available for precise retrieval. To this end, we leveraged Elasticsearch to store and search our data.</p><h3>Query</h3><p>Having great <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#data">data</a> to search by, it's time to talk about our querying component. We adopted a standard Hybrid-Search strategy, which combines the traditional strengths of <strong>BM25</strong>, <em>Keyword-based Search</em>, with the capabilities of <em>Semantic Search</em>, powered by <strong>ELSER</strong>.</p><p>For the semantic search component, we used <code>text_expansion</code> queries against both <code>title</code> and <code>summary</code> embeddings. On the other hand, for broad keyword relevance we search multiple fields using <code>cross_fields</code>, with a <code>minimum_should_match</code> parameter tuned to better perform with longer queries. Phrase matches, which often signal greater relevance, receive a higher boost. Here’s our initial setup:</p>const searchResults = await client.elasticsearchClient({
  // Alias pointing to the knowledge base indices.
  index: "knowledge-search", 
  body: {
    size: 3,
    query: {
      bool: {
        should: [
          // Keyword-search Component. 
          {
            multi_match: {
              query,
              // For queries with 3+ words, at least 49% must match.
              minimum_should_match: "1&lt;-1 3&lt;49%", 
              type: "cross_fields",
              fields: [
                "title",
                "summary",
                "body",
                "id",
              ],
            },
          },
          {
            multi_match: {
              query,
              type: "phrase",
              boost: 9,
              fields: [
                // Stem-based versions of our fields. 
                "title.stem",
                "summary.stem",
                "body.stem",
              ],
            },
          },
          // Semantic Search Component.
          {
            text_expansion: {
              "ml.inference.title_expanded.predicted_value": {
                model_id: ".elser_model_2",
                model_text: query,
              },
            },
          },
          {
            text_expansion: {
              "ml.inference.summary_expanded.predicted_value": {
                model_id: ".elser_model_2",
                model_text: query,
              },
            },
          },
        ],
      },
    },
  },
});
<h3>Generation</h3><p>After search, we build up the system prompt with different sets of instructions, also contemplating the <em><strong>top 3</strong></em> search results as context to be used. Finally, we feed the conversation alongside the built context into the LLM, generating a response. Here's the pseudocode showing the described behavior:</p>// We then feed the context into the LLM, generating a response.
const { stopGeneration } = fetchChatCompletionAPI(
  {
    // The system prompt + vector search results.
    context: buildContext(searchResults), 
    // The entire conversation + the brand new user question.
    messages, 
    // Additional parameters.
    parameters: { model: LLM.GPT4 } 
  },
  {
    onGeneration: (event: StreamGenerationEvent) =&gt; {
      // Stream generation events back to the user interface here...
    }
  }
);
<p>The reason for not including more than 3 search results was the limited quantity of tokens available to work within our dedicated Azure OpenAI's GPT4 deployment (PTU), allied with a relatively large user base.</p><h3>Feedback</h3><p>We used a third-party tool to capture client-side events, connecting to <em>Big Query</em> for storage and making the JSON-encoded events accessible for comprehensive analysis by everyone on the team. Here's a glance into the Big Query syntax that builds up our feedback view. The</p><p><code>JSON_VALUE</code> function is a means to extract fields from the event payload:</p><p></p>  SELECT
    -- Extract relevant fields from event properties
    JSON_VALUE(event_properties, '$.chat_id') AS `Chat ID`,
    JSON_VALUE(event_properties, '$.input') AS `Input`,
    JSON_VALUE(event_properties, '$.output') AS `Output`,
    JSON_VALUE(event_properties, '$.context') AS `Context`,
    
    -- Determine the reaction (like or dislike) to the interaction
    CASE JSON_VALUE(event_properties, '$.reaction')
      WHEN 'disliked' THEN '👎'
      WHEN 'liked' THEN '👍'
    END AS `Reaction`,
    
    -- Extract feedback comment
    JSON_VALUE(event_properties, '$.comment') AS `Comment`,

    event_time AS `Time`
  FROM
    `frontend_events` -- Table containing event data
  WHERE
    event_type = "custom"
    AND JSON_VALUE(event_properties, '$.event_name') IN (
      'Chat Interaction', -- Input, output, context. 
      'Chat Feedback', -- Feedback comments.
      'Response Like/Dislike' -- Thumbs up/down.
    )
  ORDER BY `Chat ID` DESC, `Time` ASC; -- Order results by Chat ID and time
<p>We also took advantage of valuable direct feedback from internal users regarding the chatbot experience, enabling us to quickly identify areas where our search results did not match the user intent. Incorporating both would be instrumental in the discovery process that enabled us to refine our RAG implementation, as we're going to observe throughout the next section.</p><h2>Challenges</h2><p>With usage, interesting patterns started to emerge from feedback. Some user queries, like those involving specific <a href="https://en.wikipedia.org/wiki/Common_Vulnerabilities_and_Exposures"><em>CVEs</em></a> or <em>Product Versions</em> for instance, were yielding suboptimal results, indicating a disconnect between the user's intent and the <em>GenAI</em> responses. Let's take a closer look at the specific challenges identified, and how we solved them.</p><h3>#1: CVEs (Common Vulnerabilities and Exposures)</h3><p>Our customers frequently encounter alerts regarding lists of open CVEs that could impact their systems, often resulting in support cases. To address questions about those effectively, our dedicated internal teams meticulously maintain <em>CVE-type</em> Knowledge Articles. These articles provide standardized, official descriptions from Elastic, including detailed statements on the implications, and list the artifacts affected by each CVE.</p><p>Recognizing the potential of our chatbot to streamline access to this crucial information, our internal <em>InfoSec</em> and <em>Support Engineering</em> teams began exploring its capabilities with questions like this:</p>👨🏽 What are the implications of CVE's `2016-1837`, `2019-11756` and `2014-6439`?
<p>For such questions, one of the key advantages of using RAG – and also the main functional goal of adopting this design – is that we can pull up-to-date information, including it as context to the LLM and thus making it available instantly to produce awesome responses. That naturally will save us time and resources over fine-tuned LLM alternatives.</p><p>However, the produced responses wouldn't perform as expected. Essential to answer those questions, the search results often lacked relevance, a fact which we can confirm by looking closely at the search results for the example:</p>{
  ...
  "hits": [
    {
      "_index": "search-knowledge-articles",
      "_id": "...",
      "_score": 59.449028,
      "_source": {
        "id": "...",
        "title": "CVE-2019-11756", // Hit!
        "summary": "...",
        "body": "...",
        "category": "cve"
      }
    },
    {
      "_index": "search-knowledge-articles",
      "_id": "...",
      "_score": 42.15182,
      "_source": {
        "title": "CVE-2019-10172", // :(
        "summary": "...",
        "body": "...",
        "category": "cve"
      }
    },
    {
      "_index": "search-docs",
      "_id": "...",
      "_score": 38.413914,
      "_source": {
        "title": "Potential Sudo Privilege Escalation via CVE-2019-14287 | Elastic  Security Solution [8.11] | Elastic",  // :(
        "summary": "...",
        "body": "...",
        "category": "documentation"
      }
    }
  ]
}
<p>With just one relevant hit (<code>CVE-2019-10172</code>), we left the LLM without the necessary context to generate proper answers:</p>The context only contains information about CVE-2019-11756, which is...
<p>The observed behavior prompted us with an interesting question:</p>How could we use the fact that users often include close-to-exact CVE codes in their queries to enhance the accuracy of our search results?<p>To solve this, we approached the issue as a search challenge. We hypothesized that by emphasizing the <code>title</code> field matching for such articles, which directly contain the CVE codes, we could significantly improve the precision of our search results. This led to a strategic decision to conditionally boost the weighting of title matches in our search algorithm. By implementing this focused adjustment, we refined our query strategy as follows:</p>    ...
    should: [
        // Additional boosting for CVEs.
        {
          bool: {
            filter: {
              term: {
                category: 'cve',
              },
            },
            must: {
              match: {
                title: {
                  query: queryText,
                  boost: 10,
                },
              },
            },
          },
        },
        // BM-25 based search.
        {
          multi_match: {
             ...
<p>As a result, we experienced much better hits for CVE-related use cases, ensuring that <code>CVE-2016-1837</code>, <code>CVE-2019-11756</code> and <code>CVE-2014-6439</code> are top 3:</p>{
  ...
  "hits": [
    {
      "_index": "search-knowledge-articles",
      "_id": "...",
      "_score": 181.63962,
      "_source": {
        "title": "CVE-2019-11756",
        "summary": "...",
        "body": "...",
        "category": "cve"
      }
    },
    {
      "_index": "search-knowledge-articles",
      "_id": "...",
      "_score": 175.13728,
      "_source": {
        "title": "CVE-2014-6439",
        "summary": "...",
        "body": "...",
        "category": "cve"
      }
    },
    {
      "_index": "search-knowledge-articles",
      "_id": "...",
      "_score": 152.9553,
      "_source": {
        "title": "CVE-2016-1837",
        "summary": "...",
        "body": "...",
        "category": "cve"
      }
    }
  ]
}
<p>And thus generating a much better response by the LLM:</p>🤖 The implications of the CVEs mentioned are as follows: (...)
<p>Lovely! By tuning our Hybrid Search approach, we significantly improved our performance with a pretty simple, but mostly effective <em>Bob's Your Uncle</em> solution (like some folks would say)! This improvement underscores that while semantic search is a powerful tool, understanding and leveraging user intent is crucial for optimizing search results and overall chat experience in your business reality. With that in mind, let's dive into the next challenge!</p><h3>#2: Product versions</h3><p>As we delved deeper into the challenges, another significant issue emerged with queries related to specific versions. Users frequently inquire about features, migration guides, or version comparisons, but our initial search responses were not meeting expectations. For instance, let's take the following question:</p>👨🏽 Can you compare Elasticsearch versions 8.14.3 and 8.14.2?
<p>Our initial query approach would return the following top 3:</p><ul><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/hadoop/current/eshadoop-8.14.1.html">Elasticsearch for Apache Hadoop version 8.14.1 | Elasticsearch for Apache Hadoop [8.14] | Elastic</a>;</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/apm-release-notes-8.14.html">APM version 8.14 | Elastic Observability [8.14] | Elastic</a><em>;</em></p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/hadoop/8.14/eshadoop-8.14.3.html">Elasticsearch for Apache Hadoop version 8.14.3 | Elasticsearch for Apache Hadoop [8.14] | Elastic</a>.</p></li></ul><p>Corresponding to the following <code>_search</code> response:</p>{
  ...
  "hits": [
    {
      "_index": "search-docs",
      "_id": "6807c4cf67ad0a52e02c4c2ef436194d2796faa454640ec64cc2bb999fe6633a",
      "_score": 29.79520,
      "_source": {
        "title": "Elasticsearch for Apache Hadoop version 8.14.1 | Elasticsearch for Apache Hadoop [8.14] | Elastic",
        "summary": "...",
        "body": "...",
        "url": "https://www.elastic.co/guide/en/elasticsearch/hadoop/current/eshadoop-8.14.1.html",
        "category": "documentation"
      }
    },
    {
      "_index": "search-docs",
      "_id": "9233c6b53a9d2e8b5daf1f68e7d82590516f0dfc23d979783ea1146854dc9d63",
      "_score": 27.19520,
      "_source": {
        "title": "APM version 8.14 | Elastic Observability [8.14] | Elastic",
        "summary": "...",
        "body": "...",
        "url": "https://www.elastic.co/guide/en/observability/current/apm-release-notes-8.14.html",
        "category": "documentation"
      }
    },
    {
      "_index": "search-docs",
      "_id": "392a39dd81933523d6018cac5ae4fc38b31de4ae3e1c0f1655d98191133c4e8a",
      "_score": 26.7618,
      "_source": {
        "title": "Elasticsearch for Apache Hadoop version 8.14.3 | Elasticsearch for Apache Hadoop [8.14] | Elastic",
        "summary": "...",
        "body": "...",
        "url": "https://www.elastic.co/guide/en/elasticsearch/hadoop/8.14/eshadoop-8.14.3.html",
        "category": "documentation"
      }
    }
  ]
}
<p>Being irrevocably irrelevant, they ended up resulting in a completely uninformed answer from the chatbot, affecting the overall user experience and trust in the Support AI Assistant:</p>🤖 As of my last update, Elasticsearch 8.14.2 and 8.14.3 had not been released, and there were no official release notes available for these versions. Elasticsearch follows a rapid release cycle, and new versions often include bug fixes, performance improvements, and occasionally new features. However, without specific release notes or announcements from Elastic, I cannot provide details on what is new in these hypothetical versions.

To find the most up-to-date information on Elasticsearch releases, you should check the official Elastic website or their GitHub repository for the latest release notes. Release notes are typically published alongside the software release and provide a comprehensive list of changes, including new features, enhancements, bug fixes, and known issues
<p>Further investigating the issue we collected valuable insights. By replaying the query and looking into the search results, we noticed three serious problems with our crawled <em>Product Documentation</em> data that were contributing to the overall bad performance:</p><ol><li><p><a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#a-inaccurate-semantic-matching"><strong>Inaccurate semantic matching</strong></a>: Semantically, we definitely missed the shot. Why would we match against such specific articles, including two specifically about Apache Hadoop, when the question was so much broader than Hadoop?</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#b-multiple-versions-same-articles"><strong>Multiple versions, same articles</strong></a>: Going further down on the hits of the initially asked question, we often noticed multiple versions for the same articles, with close to exactly the same content. That often led to a top 3 cluttered with irrelevant matches!</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#c-wrong-versions-being-returned"><strong>Wrong versions being returned</strong></a>: It's fair to expect that having both <em>8.14.1</em> and <em>8.14.2</em> versions of the <em>Elasticsearch for Apache Hadoop</em> article, we'd return the latter for our query – but that just wasn't happening consistently.</p></li></ol><p>From the impact perspective, we had to stop and solve those – else, a considerable part of user queries would be affected. Let's dive into the approaches taken to solve both!</p><h4>A. Inaccurate semantic matching</h4><p>After some examination into our data, we've discovered that the root of our semantic matching issue lived in the fact that the <code>summary</code> field for <em>Product Documentation-type</em> articles generated upon ingestion by the crawler was just the first few characters of the <code>body</code>. This redundancy misled our semantic model, causing it to generate vector embeddings that did not accurately represent the document's content in relation to user queries.</p><p>As a data problem, we had to solve this problem in the data domain: by leveraging the use of GenAI and the GPT4 model, we made a team decision to craft a new AI Enrichment Service – introduced in the <a href="https://search-labs.elastic.co/search-labs/blog/genai-customer-support-building-a-knowledge-library#enriching-document-sources">2nd installment of this blog series</a>. We decided to create our own tool for a few specific reasons:</p><ul><li><p>We had unused PTU resources available. Why not use them?</p></li><li><p>We needed this data gap filled quickly, as this was probably the greatest relevance detractor.</p></li><li><p>We wanted a fully customizable approach to make our own experiments.</p></li></ul><p>Modeled to be generic, our usage for it boils down to generating four new fields for our data into a new index, using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest-enriching-data.html"><em>Enrich Processors</em></a> to make them available to the respective documents on the target indices upon ingestion. Here's a quick view into the specification for each field to be generated:</p>const fields: FieldToGenerate[] = [
  {
    // A one-liner summary for the article.
    name: 'ai_subtitle', 
    strategy: GenerationStrategy.AbstractiveSummarizer,
  },
  {
    // A longer summary for the article.
    name: 'ai_summary', 
    strategy: GenerationStrategy.AbstractiveSummarizer,
  },
  {
    // A list of questions answered by the article.
    name: 'ai_questions_answered', 
    strategy: GenerationStrategy.QuestionSummarizer,
  },
  {
    // A condensed list of tags for the article.
    name: 'ai_tags',
    strategy: GenerationStrategy.TagsSummarizer,
  }
];
<p>After generating those fields and setting up the index <em>Enrich Processors</em>, the underlying RAG-search indices were enriched with a new <code>ai_fields</code> object, also making ELSER embeddings available under <code>ai_fields.ml.inference</code>:</p>{
  ...
  "_source": {
    "product_name": "Elasticsearch",
    "version": "8.14",
    "url": "https://www.elastic.co/guide/en/elasticsearch/hadoop/8.14/eshadoop-8.14.1.html",
    "ai_fields": {
      "ai_summary": "ES-Hadoop 8.14.1; tested against Elasticsearch 8.14.1. ES-Hadoop 8.14.1 is a compatibility release, aligning with Elasticsearch 8.14.1. This version ensures seamless integration and operation with Elasticsearch's corresponding version, maintaining feature parity and stability across the Elastic ecosystem.",
      "ai_subtitle": "ES-Hadoop 8.14.1 Compatibility Release",
      "ai_tags": [
        "Elasticsearch",
        "ES-Hadoop",
        "Compatibility",
        "Integration",
        "Version 8.14.1"
      ],
      "source_id": "6807c4cf67ad0a52e02c4c2ef436194d2796faa454640ec64cc2bb999fe6633a",
      "ai_questions_answered": [
        "What is ES-Hadoop 8.14.1?",
        "Which Elasticsearch version is ES-Hadoop 8.14.1 tested against?",
        "What is the purpose of the ES-Hadoop 8.14.1 release?"
      ],
      "ml": {
        "inference": {
          "ai_subtitle_expanded": {...},
          "ai_summary_expanded": {...},
          "ai_questions_answered_expanded": {...}
        }
      }
    }
  }
  ...
}
<p>Now, we can tune the query to use those fields, making for better overall semantic and keyword matching:</p>   ...
   // BM-25 Component. 
   {
      multi_match: {
        ...
        type: 'cross_fields',
        fields: [
          ...
          // Adding the `ai_fields` to the `cross_fields` matcher.
          'ai_fields.ai_subtitle',
          'ai_fields.ai_summary',
          'ai_fields.ai_questions_answered',
          'ai_fields.ai_tags',
        ],
      },
   },
   {
      multi_match: {
        ...
        type: 'phrase',
        fields: [
          ...
          // Adding the `ai_fields` to the `phrase` matcher.
          'ai_fields.ai_subtitle.stem',
          'ai_fields.ai_summary.stem',
          'ai_fields.ai_questions_answered.stem',
        ],
      },
   },
   ...
   // Semantic Search Component.
   {
      text_expansion: {
        // Adding `text_expansion` queries for `ai_fields` embeddings.
        'ai_fields.ml.inference.ai_subtitle_expanded.predicted_value': {
          model_id: '.elser_model_2',
          model_text: queryText,
        },
      },
    },
    {
      text_expansion: {
        'ai_fields.ml.inference.ai_summary_expanded.predicted_value': {
          model_id: '.elser_model_2',
          model_text: queryText,
        },
      },
    },
    {
      text_expansion: {
        'ai_fields.ml.inference.ai_questions_answered_expanded.predicted_value':
          {
            model_id: '.elser_model_2',
            model_text: queryText,
          },
      },
    },
    ...
<p>Single-handedly, that made us much more relevant. More than that – it also opened a lot of new possibilities to use the AI-generated data throughout our applications – matters of which we'll talk about in future blog posts.</p><p>Now, before retrying the query to check the results: <strong>what about the multiple versions problem?</strong></p><h4>B. Multiple versions, same articles</h4><p>When duplicate content infiltrates these top positions, it diminishes the value of the data pool, thereby diluting the effectiveness of GenAI responses and leading to a suboptimal user experience. In this context, a significant challenge we encountered was <strong>the presence of multiple versions of the same article.</strong> This redundancy, while contributing to a rich collection of version-specific data, often cluttered the essential data feed to our LLM, reducing the diversity of it and therefore undermining the response quality.</p><p>To address the problem, we employed the</p><p><em>Elasticsearch API</em> <code>collapse</code> parameter, sifting through the noise and prioritizing only the most relevant version of a single content. To do that, we computed a new <code>slug</code> field into our <em>Product Documentation</em> crawled documents to identify different versions of the same article, using it as the <em>collapse field</em> (or <em>key</em>).</p><p></p><p>Taking the <em>Sort search results</em> <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.14/sort-search-results.html">documentation page</a> as an example, we have two versions of this article being crawled:</p><ul><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.14/sort-search-results.html">Sort search results | Elasticsearch Guide [8.14] | Elastic</a></p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/sort-search-results.html">Sort search results | Elasticsearch Guide [7.17] | Elastic</a></p></li></ul><p>Those two will generate the following <code>slug</code>:</p>guide-en-elasticsearch-reference-sort-search-results<p>Taking advantage of that, we can now tune the query to use <code>collapse</code>:</p>...
const searchQuery = {
  index: "knowledge-search",
  body: {
    ...
    query: {...},
    collapse: {
      // This is a "field alias" that will point to the `slug` field for product docs.
      field: "collapse_field" 
    }
  }
};
...
<p>As a result, we'll now only show the top-scored documentation in the search results, which will definitely contribute to increasing the diversity of knowledge being sent to the LLM.</p><h4>C. Wrong versions being returned</h4><p>Similar to the <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#1-cves-common-vulnerabilities-and-exposures">CVE matching problem</a>, we can boost results based on the specific versions being mentioned, allied with the fact that <code>version</code> is a separate field in our index. To do that, we used the following simple regex-based function to pull off versions directly from the user question:</p>/**
 * Extracts versions from the query text.
 * @param queryText The user query (or question).
 * @returns Array of versions found in the query text.
 * @example getVersionsFromQueryText("What's new in 8.14.2? And 8.14.3?") =&gt; ['8.14.2', '8.14.3'] 
 */
const getVersionsFromQueryText = (queryText: string) : string[] =&gt; {
  let results = null;
  const versions = [];
  // Will match `x.y.z` or `x.y` 
  const versionRegex = /\b\d+\.\d+(\.\d+)?\b/gi;

  while ((results = versionRegex.exec(queryText))) {
    const [version] = results;
    versions.push(version);
  }

  return versions;
}; 
<p>We then add one more query to the <code>should</code> clause, boosting the <code>version</code> field accordingly and getting the right versions to the top (whenever they're mentioned):</p>    // Additional boosting for versions.
    ...(versions.length &gt; 0 ? [
    {
      bool: {
        filter: {
          term: {
            category: 'documentation',
          },
        },
        must: {
          terms: {
            version: versions,
            boost: 4,
          },
        },
      },
   }] : []),
    // BM-25 Component.
   {
      multi_match: {
        ...
        type: 'cross_fields',
        fields: [
          ...
    ...
<p>With <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#a-inaccurate-semantic-matching"><strong>A</strong></a>, <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#b-multiple-versions-same-articles"><strong>B</strong></a> and <a href="https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance#c-wrong-versions-being-returned"><strong>C</strong></a> solved, we're probably ready to see some strong results!</p><h3>Let's replay the question!</h3><p>By replaying the previously tried question:</p>👨🏽 Can you compare Elasticsearch versions 8.14.3 and 8.14.2?
<p>And therefore running the Elasticsearch query once again, we get dramatically better results consisting of the following articles:</p><ul><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/release-notes-8.14.3.html">Elasticsearch version 8.14.3 | Elasticsearch Guide [master] | Elastic</a></p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/release-notes-8.14.2.html">Elasticsearch version 8.14.2 | Elasticsearch Guide [master] | Elastic</a></p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.14/es-release-notes.html">Release notes | Elasticsearch Guide [8.14] | Elastic</a></p></li></ul><p>Consequently, we have a better answer generated by the LLM. More powerful than that – in the context of this conversation, the LLM is now conscious about versions of Elasticsearch that are newer than the model's cut-off date, crafting correct answers around those:</p>🤖 Certainly! Here's a formatted summary of the changes in Elasticsearch versions 8.14.2 and 8.14.3:

**Elasticsearch 8.14.2:**

- **Known Issue:**
  - Upgrading clusters from version 8.11.4 or earlier with non-master-eligible nodes may result in new functionality not being registered. This is fixed in version 8.15.0.

- **Bug Fixes:**
  - **Data streams:** Fixed lazy rollover request to rollover the target data stream once.
  - **ES|QL:** Addressed ESQL cancellation for exchange requests and issues with SingleValueQuery.LuceneQuery.
  - **Infra/Scripting:** Added array size check before returning array item in script doc values.
  - **Infra/Settings:** Ensured file settings readiness is guarded on file settings support.
  - **Machine Learning:** Resolved IndexOutOfBoundsException during inference.
  - **Mapping:** Re-defined index.mapper.dynamic setting for smoother 7.x to 8.x upgrades.
  - **Ranking:** Corrected the 'from' parameter when using sub_searches and rank.
  - **Search:** Supported hexstring for byte painless scorers and fixed tracking of collapse with docvalue_fields.

**Elasticsearch 8.14.3:**

- **Known Issue:**
  - Same as in 8.14.2 regarding the upgrade process and non-master-eligible nodes.

- **Bug Fixes:**
  - **Cluster Coordination:** Ensured tasks preserve versions in MasterService.
  - **ES|QL:** Implemented a compute listener.
  - **Mapping:** Set ignore_malformed only for the @timestamp automatically.
  - **TSDB:** Prohibited setting or updating the index.time_series.end_time in normal indices.

For both versions, users should be aware of the known issue related to upgrading clusters and follow the recommended resolution until the fix is available in version 8.15.0.

**URL References:**
- [Elasticsearch 8.14.2 Release Notes](https://www.elastic.co/guide/en/elasticsearch/reference/master/release-notes-8.14.2.html)
- [Elasticsearch 8.14.3 Release Notes](https://www.elastic.co/guide/en/elasticsearch/reference/master/release-notes-8.14.3.html)
<p>Exciting, right? But how can we quantify the improvements in our query at this point? <strong>Let's see the numbers together!</strong></p><h2>Measuring success</h2><p>To assess the performance implied by our changes, we've compiled a test suite based on user behavior, each containing a question plus a curated list of results that are considered relevant to answer it. Those will cover a wide wide range of subjects and query styles, reflecting the diverse needs of our users. Here's a complete look into it:</p>...
const initialCases: Array&lt;TestCase&gt; = [
  {
    query: 'Can you compare Elasticsearch versions 8.14.3 and 8.14.2?',
    expectedResults: [...], // Elasticsearch version 8.14.3 | Elasticsearch Guide | Elastic, Elasticsearch version 8.14.2 | Elasticsearch Guide | Elastic.
  },
  {
    query: "What are the implications of CVE's 2019-10202, 2019-11756, 2019-15903?",
    expectedResults: [...], // CVE-2016-1837; CVE-2019-11756; CVE-2014-6439. 
  },
  {
    query: 'How to run the support diagnostics tool?',
    expectedResults: [...], // How to install and run the support diagnostics troubleshooting utility; How to install and run the ECK support diagnostics utility.
  },
  {
    query: 'How can I create data views in Kibana via API?',
    expectedResults: [...], // Create data view API | Kibana Guide | Elastic; How to create Kibana data view using api; Data views API | Kibana Guide | Elastic.
  },
  {
    query: 'What would the repercussions be of deleting a searchable snapshot and how would you be able to recover that index?',
    expectedResults: [...], // The repercussions of deleting a snapshot used by searchable snapshots; Does delete backing index delete the corresponding searchable snapshots, and vice versa?; Can one use a regular snapshot to restore searchable snapshot indices?; [ESS] Can deleted index data be recovered Elastic Cloud / Elasticsearch Service?.
  },
  {
    query: 'How can I create a data view in Kibana?',
    expectedResults: [...], // Create a data view | Kibana Guide | Elastic; Create data view API | Kibana Guide [8.2] | Elastic; How to create Kibana data view using api.
  },
  {
    query: 'Do we have an air gapped version of the Elastic Maps Service?',
    expectedResults: [...], // Installing in an air-gapped environment | Elastic Installation and Upgrade Guide [master] | Elastic; Connect to Elastic Maps Service | Kibana Guide | Elastic; 1.6.0 release highlights | Elastic Cloud on Kubernetes | Elastic.
  },
  {
    query: 'How to setup an enrich processor?',
    expectedResults: [...], // Set up an enrich processor | Elasticsearch Guide | Elastic; Enrich processor | Elasticsearch Guide | Elastic; Enrich your data | Elasticsearch Guide | Elastic.
  },
  {
    query: 'How to use index lifecycle management (ILM)?',
    expectedResults: [...], // Tutorial: Automate rollover with ILM | Elasticsearch Guide | Elastic; ILM: Manage the index lifecycle | Elasticsearch Guide | Elastic; ILM overview | Elasticsearch Guide | Elastic.
  },
  {
    query: 'How to rotate my ECE UI proxy certificates?',
    expectedResults: [...], // Manage security certificates | Elastic Cloud Enterprise Reference | Elastic; Generate ECE Self Signed Proxy Certificate; ECE Certificate Rotation (2.6 -&gt; 2.10).
  },
  {
    query:
      'How to rotate my ECE UI proxy certificates between versions 2.6 and 2.10?',
    expectedResults: [...], // ECE Certificate Rotation (2.6 -&gt; 2.10); Manage security certificates | Elastic Cloud Enterprise Reference | Elastic; Generate ECE Self Signed Proxy Certificate.
  }
];
...
<p>But how do we turn those test cases into quantifiable success? To this end, we have employed Elasticsearch's <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Ranking Evaluation API</a> alongside with the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html#k-precision">Precision at K (P@K)</a> metric to determine how many relevant results are returned between the first <em>K</em> hits of a query. As we're interested in the top 3 results being fed into the LLM, we're making K = 3 here.</p><p>To automate the computation of this metric against our curated list of questions and effectively assess our performance gains, we used <em>TypeScript/Node.js</em> to create a simple script wrapping everything up. First, we define a function to make the corresponding <em>Ranking Evaluation</em> API calls:</p>const rankingEvaluation = async (
    // The query to execute ("before" or "after").
    getSearchRequestFn: (queryText: string) =&gt; string
) =&gt;
    const testSuite = getTestSuite();
    const rankEvalResult = await elasticsearchClient.rankEval({
      index: 'knowledge-search',
      body: {
    metric: {
      precision: {
        k: 3,
        relevant_rating_threshold: 1,
      },
    },
    // For each test case, we'll have one item here.
    requests: testSuite.map((testCase) =&gt; ({
      id: testCase.queryText,
      request: getSearchRequestFn(testCase.queryText),
      ratings: testCase.expectedResults.map(({ _id, _index }) =&gt; ({
        _index,
        _id,
        rating: 1, // A value &gt;= 1 means relevant.
      })),
    })),
      },
    });
    // Return a normalized version of the data.
    return transformRankEvalResult(rankEvalResult);
}
<p>After that, we need to define the search queries <em>before</em> and <em>after</em> the optimizations:</p>// Before the optimizations.
const getSearchRequestBefore = (queryText: string): any =&gt; ({
  query: {
    bool: {
      should: [
        {
          multi_match: {
            query: queryText,
            minimum_should_match: '1&lt;-1 3&lt;49%',
            type: 'cross_fields',
            fields: ['title', 'summary', 'body', 'id'],
          },
        },
        {
          multi_match: {
            query: queryText,
            type: 'phrase',
            boost: 9,
            fields: [
              'title.stem',
              'summary.stem',
              'body.stem',
            ],
          },
        },
        {
          text_expansion: {
            'ml.inference.title_expanded.predicted_value': {
              model_id: '.elser_model_2',
              model_text: queryText,
            },
          },
        },
        {
          text_expansion: {
            'ml.inference.summary_expanded.predicted_value': {
              model_id: '.elser_model_2',
              model_text: queryText,
            },
          },
        },
      ],
    },
  },
});

// After the optimizations.
const getSearchRequestAfter = (queryText: string): any =&gt; {
  const versions = getVersionsFromQueryText(queryText);
  const matchesKeywords = [
    {
      multi_match: {
        query: queryText,
        minimum_should_match: '1&lt;-1 3&lt;49%',
        type: 'cross_fields',
        fields: [
          'title',
          'summary',
          'body',
          'id',
          'ai_fields.ai_subtitle',
          'ai_fields.ai_summary',
          'ai_fields.ai_questions_answered',
          'ai_fields.ai_tags',
        ],
      },
    },
    {
      multi_match: {
        query: queryText,
        type: 'phrase',
        boost: 9,
        slop: 0,
        fields: [
          'title.stem',
          'summary.stem',
          'body.stem',
          'ai_fields.ai_subtitle.stem',
          'ai_fields.ai_summary.stem',
          'ai_fields.ai_questions_answered.stem',
        ],
      },
    },
  ];

  const matchesSemantics = [
    {
      text_expansion: {
        'ml.inference.title_expanded.predicted_value': {
          model_id: '.elser_model_2',
          model_text: queryText,
        },
      },
    },
    {
      text_expansion: {
        'ml.inference.summary_expanded.predicted_value': {
          model_id: '.elser_model_2',
          model_text: queryText,
        },
      },
    },
    {
      text_expansion: {
        'ai_fields.ml.inference.ai_subtitle_expanded.predicted_value': {
          model_id: '.elser_model_2',
          model_text: queryText,
        },
      },
    },
    {
      text_expansion: {
        'ai_fields.ml.inference.ai_summary_expanded.predicted_value': {
          model_id: '.elser_model_2',
          model_text: queryText,
        },
      },
    },
    {
      text_expansion: {
        'ai_fields.ml.inference.ai_questions_answered_expanded.predicted_value':
          {
            model_id: '.elser_model_2',
            model_text: queryText,
          },
      },
    },
  ];

  const matchesCvesAndVersions = [
    {
      bool: {
        filter: {
          term: {
            category: 'cve',
          },
        },
        must: {
          match: {
            title: {
              query: queryText,
              boost: 10,
            },
          },
        },
      },
    },
    ...(versions.length &gt; 0
      ? [
          {
            bool: {
              filter: {
                term: {
                  category: 'documentation',
                },
              },
              must: {
                terms: {
                  version: versions,
                  boost: 4,
                },
              },
            },
          },
        ]
      : []),
  ];

  return {
    query: {
      bool: {
        should: [
          ...matchesKeywords,
          ...matchesSemantics,
          ...matchesCvesAndVersions,
        ]
      },
    },
    collapse: {
      // Alias to the collapse key for each underlying index. 
      field: 'collapse_field' 
    },
  };
};
<p>Then, we'll output the resulting metrics for each query:</p>const [rankEvaluationBefore, rankEvaluationAfter] =
  await Promise.all([
    rankingEvaluation(getSearchRequestBefore), // The "before" query.
    rankingEvaluation(getSearchRequestAfter), // The "after" query.
  ]);

console.log(`Before -&gt; Precision at K = 3 (P@K):`);
console.table(rankEvaluationBefore);

console.log(`After -&gt; Precision at K = 3(P@k):`);
console.table(rankEvaluationAfter);

// Computing the change in P@K.
const metricScoreBefore = rankEvaluationBefore.getMetricScore();
const metricScoreAfter = rankEvaluationAfter.getMetricScore();

const percentDifference =
  ((metricScoreAfter - metricScoreBefore) * 100) / metricScoreBefore;

console.log(`Change in P@K: ${percentDifference.toFixed(2)}%`);
<p>Finally, by running the script against our <em>development</em> Elasticsearch instance, we can see the following output demonstrating the P@K or (P@3) values for each query, <em>before</em> and <em>after</em> the changes. That is – how many results on the top 3 are considered relevant to the response:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta011b61ca9ef32e9/6a17d7be7b54f914718b371c/c89071c3194eb69f251b6716ea6341a6e423c684-1061x580.png" alt="Script output for the ranking evaluation using the P@K metric" /><h2>Improvements observed</h2><p>As an archer carefully adjusts for a precise shot, our recent efforts into relevance have brought considerable improvements in precision over time. Each one of the previous enhancements, in sequence, were small steps towards achieving better accuracy in our RAG-search results, and overall user experience. Here's a look at how our efforts have improved performance across various queries:</p><h4>Before and after – <code>P@K</code></h4>Relevant results in the top 3: <code>❌ = 0</code>, <code>🥉 = 1</code>, <code>🥈 = 2</code>, <code>🥇 = 3</code>.<p>Query Description</p><p>P@K Before</p><p>P@K After</p><p>Change</p><p>Support Diagnostics Tool</p><p>0.333 🥉</p><p>1.000 🥇</p><p>+200%</p><p>Air Gapped Maps Service</p><p>0.333 🥉</p><p>0.667 🥈</p><p>+100%</p><p>CVE Implications</p><p>0.000 ❌</p><p>1.000 🥇</p><p>∞</p><p>Enrich Processor Setup</p><p>0.667 🥈</p><p>0.667 🥈</p><p>0%</p><p>Proxy Certificates Rotation</p><p>0.333 🥉</p><p>0.333 🥉</p><p>0%</p><p>Proxy Certificates Version-specific Rotation</p><p>0.333 🥉</p><p>0.333 🥉</p><p>0%</p><p>Searchable Snapshot Deletion</p><p>0.667 🥈</p><p>1.000 🥇</p><p>+50%</p><p>Index Lifecycle Management Usage</p><p>0.667 🥈</p><p>0.667 🥈</p><p>0%</p><p>Creating Data Views via API in Kibana</p><p>0.333 🥉</p><p>0.667 🥈</p><p>+100%</p><p>Kibana Data View Creation</p><p>1.000 🥇</p><p>1.000 🥇</p><p>0%</p><p>Comparing Elasticsearch Versions</p><p>0.000 ❌</p><p>0.667 🥈</p><p>∞</p><p>Maximum Bucket Size in Aggregations</p><p>0.000 ❌</p><p>0.333 🥉</p><p>∞</p><p><strong>Average </strong><strong><code>P@K</code></strong><strong> Improvement: +78.41% 🏆🎉</strong>. Let's summarize a few observations about our results:</p><p><strong>Significant Improvements</strong>: With the measured overall <strong>+78.41%</strong> of relevance increase, the following queries – <em>Support Diagnostics Tool</em>, <em>CVE implications</em>, <em>Searchable Snapshot Deletion, Comparing Elasticsearch Versions</em> – showed substantial enhancements. These areas not only reached the <em>podium</em> of search relevance but did so with flying colors, significantly outpacing their initial performances!</p><p><strong>Opportunities for Optimization</strong>: Certain queries like the <em>Enrich Processor Setup</em>, <em>Kibana Data View Creation</em> and <em>Proxy Certificates Rotation</em> have shown reliable performances, without regressions. These results underscore the effectiveness of our core search strategies. However, those remind us that precision in search is an ongoing effort. These static results highlight where we'll focus our efforts to sharpen our aim throughout the next iterations. As we continue, we'll also expand our test suite, incorporating more diverse and meticulously selected use cases to ensure our enhancements are both relevant and robust.</p><h2>What's next? 🔎</h2><p>The path ahead is marked by opportunities for further gains, and with each iteration, we aim to push the RAG implementation performance and overall experience even higher. With that, let's discuss areas that we're currently interested in!</p><ol><li><p><strong>Our data can be futher optimized for search</strong>: Although we have a large base of sources, we observed that having semantically close search candidates often led to less effective chatbot responses. Some of the crawled pages aren't really valuable, and often generate noise that impacts relevance negatively. To solve that, we can curate and enhance our existing knowledge base by applying a plethora of techniques, making it lean and effective to ensure an optimal search experience.</p></li><li><p><strong>Chatbots must handle conversations – and so must RAG searches</strong>: It's common user behavior to ask follow-up questions to the chatbot. A question asking "How to configure Elasticsearch on a Linux machine?" followed by "What about Windows?" should query something like "How to configure Elasticsearch on a Linux machine?" (not the raw 2nd question). The RAG query approach should find the most relevant content regarding the entire context of the conversation.</p></li><li><p><strong>Conditional context inclusion</strong>: By extracting the semantic meaning of the user question, it would be possible to conditionally include pieces of data as context, saving token limits, making the generated content even more relevant, and potentially saving <em>round trips</em> for search and external services.</p></li></ol><h2>Conclusion</h2><p>In this installment of our series on GenAI for Customer Support, we have thoroughly explored the enhancements to the Retrieval-Augmented Generation (RAG) search within Elastic's customer support systems. By refining the interaction between large language models and our search algorithms, we have successfully elevated the precision and effectiveness of the Support AI Assistant.</p><p>Looking ahead, we aim to further optimize our search capabilities and expand our understanding of user interactions. This continuous improvement will focus on refining our AI models and search algorithms to better serve user needs and enhance overall customer satisfaction.</p><p>Stay tuned for more insights and updates as we continue to push the boundaries of what's possible with AI in customer support, and don't forget to join us in our next discussion, where we'll explore how Observability plays a critical role in monitoring, diagnosing, and optimizing the performance and reliability of the Support AI Assistant as we scale!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elser-rag-search-for-relevance</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Antonio Schönmann]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt975c900958571074/6a17d7c0abe0f277e2dfe85d/fd800d12d1c12abf68ccb7e8dd80ad7b62bec38c-1440x840.png" length="0" type="image/png"/>
    <pubDate>Thu, 22 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[An Elasticsearch Query Language (ES|QL) analysis: Millionaire odds vs. hit by a bus]]></title>
    <description><![CDATA[Use Elasticsearch Query Language (ES|QL) to run statistical analysis on demographic data index in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch Query Language (ES|QL) is designed for fast, efficient querying of large datasets. It has a straightforward syntax which will allow you to write complex queries easily, with a pipe based language, reducing the learning curve. We're going to use ES|QL to run statistical analysis and compare different odds.</p><p>If you are reading this, you probably want to know how rich you can get before actually reaching the same odds of being hit by a bus. I can't blame you, I want to know too. Let's work out the odds so that we can make sure we win the lottery rather than get in an accident!</p><p>What we are going to see in this blog is figuring out the probability of being hit by a bus and the probability of achieving wealth. We'll then compare both and understand until what point your chances of getting rich are higher, and when you should consider getting life insurance.</p><p>So how are we going to do that? This is going to be a mix of magic numbers pulled from different articles online, some synthetics data and the power of ES|QL, the new Elasticsearch Query Language. Let's get started.</p><h2>Data for the ES|QL analysis</h2><h3>The magic number</h3><p>The challenge starts here as the dataset is going to be somewhat challenging to find. We are then going to assume for the sake of the example that ChatGPT is always right. Let’s see what we get for the following question:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b0ac87235183705/6a17d75e3e03d768544f2ac1/e53cf27540f30f58af23fc26d3d1b93cc7fd5497-1440x229.png" alt="bus-odds" /><p>Cough Cough… That sounds about right, this is going to be our magic number.</p><h3>Generating the wealth data</h3><h4>Prerequisites</h4><p>Before running any of the scripts below, make sure to install the following packages:</p>
elasticsearch==8.14.0
matplotlib
numpy
panda
scipy

<p>Now, there is one more thing we need, a representative dataset with wealth distribution to compute wealth probability. There is definitely some portion of it here and there, but again, for the example we are going to generate a 500K line dataset with the below python script. I am using python 3.11.5 in this example:</p>
import pandas as pd
import numpy as np
import getpass
from elasticsearch import Elasticsearch, helpers

# Input the Elasticsearch host
hosts = input('Enter your Elasticsearch host address : ')

# Securely input the Elasticsearch API key
api_key = getpass.getpass(prompt='Enter your Elasticsearch API Key: ')

# Initialize Elasticsearch client
client = Elasticsearch(
    hosts=hosts,
    api_key=api_key,
)

# Generate synthetic data with a highly skewed distribution
num_records = 500000
np.random.seed(42)  # Ensure reproducibility

# Generate net worth using a highly skewed distribution
ages = np.random.randint(20, 80, num_records)  # Random ages between 20 and 80
incomes = np.random.exponential(scale=10000, size=num_records)  # Exponential distribution for income
# Use a more skewed distribution for net worth with a much larger range
net_worths = np.random.exponential(scale=100000000, size=num_records)  # Extremely skewed net worth

# Scale up the net worths to reach up to $100 billion
net_worths = np.clip(net_worths, 0, 100000000000)

# Create DataFrame
df = pd.DataFrame({
    'id': range(1, num_records + 1),
    'age': ages,
    'income': incomes,
    'net_worth': net_worths,
    'counter': range(1, num_records + 1)  # Add a counter field for pagination
})

# Index the data into Elasticsearch
index_name = 'raw_wealth_data_large'
try:
    if client.indices.exists(index=index_name):
        client.indices.delete(index=index_name)
except exceptions.NotFoundError:
    pass
client.indices.create(index=index_name)


def generator(df):
    for index, row in df.iterrows():
        yield {
            "_index": index_name,
            "_source": row.to_dict()
        }

helpers.bulk(client, generator(df))

print("Data indexed successfully.")
<p>It should take some time to run depending on your configuration since we are injecting 500K documents here!</p><p>FYI, after playing with a couple of versions of the script above and the ESQL query on the synthetic data, it was obvious that the net worth generated across the population was not really representative of the real world. So I decided to use a log-normal distribution (np.random.lognormal) for income to reflect a more realistic spread where most people have lower incomes, and fewer people have very high incomes.</p><p>Net Worth Calculation: Used a combination of random multipliers (np.random.uniform(0.5, 5)) and additional noise (np.random.normal(0, 10000)) to calculate net worth. Added a check to ensure no negative net worth values by using np.maximum(0, net_worths).</p><p>Not only have we generated 500K documents, but we also used the Elasticsearch python client to bulk ingest all these documents in our deployment. Please note that you will find the endpoint to pass in as hosts Cloud ID in the code above.</p><p>For the deployment API key, open Kibana, and generate the key in Stack Management / API Keys:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb38e3a3a44c66b9e/6a17d7606864a40557b685e2/544c32086c18ca3b5e65fa0e5bbf2d60490a7f66-1440x864.png" alt="api-key" /><p>The good news is that if you have a real data set, all you will need to do is to change the above code to read your dataset and write documents with the same data mapping.</p><p>Ok we're getting there! The next step is pouring our wealth distribution.</p><h2>ES|QL wealth analysis</h2><h3>Introducing ES|QL: A powerful tool for data analysis</h3><p>The arrival of Elasticsearch Query Language (ES|QL) is very exciting news for our users. It largely simplifies querying, analyzing, and visualizing data stored in Elasticsearch, making it a powerful tool for all data-driven use cases.</p><p>ES|QL comes with a variety of functions and operators, to perform aggregations, statistical analyses, and data transformations. We won’t address them all in this blog post, however <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">our documentation</a> is very detailed and will help you familiarize with the language and the possibilities.</p><p>To get started with ES|QL today and run the blog post queries, simply <a href="https://www.elastic.co/getting-started?utm_source=github&amp;utm_content=elasticsearch-labs-notebook">start a trial on Elastic Cloud</a>, load the data and run your first ES|QL query.</p><h3>Understanding the wealth distribution with our first query</h3><p>To get familiar with the dataset, head to Discover in Kibana and switch to ES|QL in the dropdown on the left hand side:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5aba101d232dbb13/6a17d762e8fbce48b53a174c/357703b5c0b543daa61fe98354de29e57182469d-1440x585.png" alt="discover" /><p>Let’s fire our first request:</p>from raw_wealth_data_large | keep age, id, income, net_worth | limit 10
<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9597115bb01b8c3/6a17d7643e03d7488f4f2ac5/898970fd63e9b2811bf24badd72ef57a45912564-1312x1930.png" alt="result set" /><p>As you could expect from our indexing script earlier, we are finding the documents we bulk ingested, notice the simplicity of pulling data from a given dataset with ES|QL where every query starts with the From clause, then your index.</p><p>In the query above given we have 500K lines, we limited the amount of returned documents to 10. To do this, we are passing the output of the first segment of the query via a pipe to the limit command to only get 10 results. Pretty intuitive, right?</p><p>Alright, what would be more interesting is to understand the wealth distribution in our dataset, for this we will leverage one of the 30 functions ES|QL provides, namely percentile.</p><p>This will allow us to understand the relative position of each data point within the distribution of net worth. By calculating the median percentile (50th percentile), we can gauge where an individual’s net worth stands compared to others.</p>
FROM raw_wealth_data_large
| stats p50 = percentile(net_worth, 50) 

<p>Like our first query, we are passing the output of our index to another function, Stats, which combined with the percentile function will output the median net worth:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt35023abe49d381f3/6a17d7654b055d09c5432048/57f65e58941783994d67dee7ee763a4934bd8ca9-1440x640.png" alt="result set" /><p>The median is about 54K, which unfortunately is probably optimistic compared to the real world, but we are not going to solve this here. If we go a little further, we can look at the distribution in more granularity by computing more percentiles:</p>
FROM raw_wealth_data_large
| STATS  p25 = percentile(net_worth, 25)
       , p50 = percentile(net_worth, 50)
       , p75 = percentile(net_worth, 75)
       , p90 = percentile(net_worth, 90)
       , p95 = percentile(net_worth, 95)
       , p96 = percentile(net_worth, 96)
       , p98 = percentile(net_worth, 98)
       , p97 = percentile(net_worth, 97)
       , p99 = percentile(net_worth, 99)
| keep p25, p25, p50, p75, p90, p95, p96, p97, p98, p99

<p>With the below output:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt48a66c799c539a68/6a17d767414c644eaa944fd6/59f1c99d52887e275422aea7d4e0547c8f3ca886-1440x458.png" alt="Percentile result" /><p>The data reveals a significant disparity in wealth distribution, with the majority of wealth being concentrated among the richest individuals. Specifically, the top 5% (95th percentile) possess a disproportionately large portion of the total wealth, with a net worth starting at $852,988.26 and increasing dramatically in the higher percentiles.</p><p>The 99th percentile individuals hold a net worth exceeding $2 million, highlighting the skewed nature of wealth distribution. This indicates that a substantial portion of the population has modest net worth, which is probably what we want for this example.</p><p>Another way to look at this is to augment the previous query and grouping by age to see if there is, (in our synthetic dataset), a relation between wealth and age:</p>
FROM raw_wealth_data_large
| STATS  p25 = percentile(net_worth, 25)
      , p50 = percentile(net_worth, 50)
      , p75 = percentile(net_worth, 75)
      , p90 = percentile(net_worth, 90)
      , p95 = percentile(net_worth, 95)
      , p96 = percentile(net_worth, 96)
      , p98 = percentile(net_worth, 98)
      , p97 = percentile(net_worth, 97)
      , p99 = percentile(net_worth, 99) by age
| keep p25, p25, p50, p75, p90, p95, p96, p97, p98, p99, age
<p>This could be visualized in a Kibana dashboard. Simply:</p><ul><li><p>Navigate to Dashboard</p></li><li><p>Add a new ES|QL visualization</p></li><li><p>Copy and paste our query</p></li><li><p>Move the age field to the horizontal axis in the visualization configuration</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5e72df525e4fe575/6a17d769b1e113339979f0d3/f47a62215862c5981d2642128c2e41ac707a7476-1066x1864.png" alt="Create ESQL visualization" /><p>Which will output:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2de89c1c00eb23be/6a17d76afbc5f807ff491908/59e17103d0233fa9d79dd65e510b776b96041489-1440x854.png" alt="Visualization output" /><p>The above suggests that the data generator randomized wealth uniformly across the population age, there is no specific trend pattern we can really see.</p><h4>Median Absolute Deviation (MAD)</h4><p>We calculate the median absolute deviation (MAD) to measure the variability of net worth in a robust manner, less influenced by outliers.</p>
FROM raw_wealth_data_large
| stats median_net_worth = MEDIAN(net_worth), mad_net_worth = MEDIAN_ABSOLUTE_DEVIATION(net_worth)
| keep median_net_worth, mad_net_worth

<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc1488254b38e203e/6a17d76c2f4a5c2a81fa8766/535103bff9200ff4c2566418fedf8654de30fc13-1440x335.png" alt="Visualization output" /><p>With a median net worth of 44,205.44, we can infer the typical range of Net Worth: Most individuals’ net worth falls within a range of 9,581.78 to $97,992.66.</p><h3>The statistical showdown between Net Worth and Bus Collision</h3><p>Alright, this is the moment to understand how rich we can get, based on our dataset, before getting hit by a bus. To do that, we are going to leverage ES|QL to pull our entire dataset in chunks and load it into a pandas dataframe to build a net worth probability distribution. Finally, we will determine where the ends meet between the net worth and bus collision probabilities.</p><p>The entire Python <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/esql-millionaire/millionaire.ipynb">notebook is available here</a>. I also recommend you read <a href="https://www.elastic.co/search-labs/blog/esql-pandas-dataframes-python">this blog post</a> which walks you through using ES|QL with pandas dataframes.</p><h4>Helper functions</h4><p>As you can see in the previously referred blog post, we introduced support for ES|QL since version 8.12 of the Elasticsearch python client. Thus our notebook first defines the below functions:</p>
from io import StringIO

# Function to execute ESQL query and fetch data in chunks
def execute_esql_query(query):
    response = client.esql.query(query=query, format="csv")
    return pd.read_csv(StringIO(response.body))

# Function to fetch paginated data using the counter field
def fetch_paginated_data(index, num_records, size=10000):
    all_data = pd.DataFrame()
    for start in range(1, num_records + 1, size):
        end = start + size - 1
        query = f"""
        FROM {index}
        | WHERE counter &gt;= {start} AND counter &lt;= {end}
        | limit {size}
        """
        data_chunk = execute_esql_query(query)
        all_data = pd.concat([all_data, data_chunk], ignore_index=True)
    return all_data

<p>The first function is straightforward and executes an ES|QL query, the second is fetching the entire dataset from our index. Notice the trick in there that I am using a counter built-in to a field in my index to paginate through the data. This is workaround I am using while our engineering team is working on <a href="https://github.com/elastic/elasticsearch/issues/100000">the support for pagination in ES|QL</a>.</p><p>Next, knowing that we have 500K documents in our index, we simply call these function to load the data in a data frame:</p>
# Fetch all data using pagination and ES|QL
num_records = 500000
all_data_df = fetch_paginated_data(index_name, num_records)
print(f"Total Data Retrieved: {len(all_data_df)} records")

<h4>Fit Pareto distribution</h4><p>Next, we fit our data to a Pareto distribution, which is often used to model wealth distribution because it reflects the reality that a small percentage of the population controls most of the wealth. By fitting our data to this distribution, we can more accurately represent the probabilities of different net worth levels.</p>from scipy.stats import pareto



# Fit a Pareto distribution to the data
shape, loc, scale = pareto.fit(all_data_df['net_worth'], floc=0)

# Calculate the probability density for each net worth
all_data_df['net_worth_probability'] = pareto.pdf(all_data_df['net_worth'], shape, loc=loc, scale=scale)

# Normalize the probabilities to sum to 1
all_data_df['net_worth_probability'] /= all_data_df['net_worth_probability'].sum()

print("Data with Net Worth Probability:")
print(all_data_df.head())

<p>We can visualize the pareto distribution with the code below: ``</p>
import matplotlib.pyplot as plt
from scipy.stats import pareto

# Assuming all_data_df contains the fetched net worth data from Elasticsearch
# Fit a Pareto distribution to the data
shape, loc, scale = pareto.fit(all_data_df['net_worth'], floc=0)

# Plot the Net Worth Probability Distribution
plt.figure(figsize=(10, 6))

# Plot histogram of empirical net worth data
plt.hist(all_data_df['net_worth'], bins=100, density=True, alpha=0.6, color='g', label='Empirical Data')

# Plot fitted Pareto distribution
xmin, xmax = plt.xlim()
x = np.linspace(xmin, xmax, 100)
p = pareto.pdf(x, shape, loc=loc, scale=scale)
plt.plot(x, p, 'k', linewidth=2, label='Fitted Pareto Distribution')

# Show the plot
plt.xlabel('Net Worth')
plt.y bnblabel('Probability')
plt.title('Net Worth Probability Distribution')
plt.legend()
plt.grid(True)
plt.show()

<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6c446c9de49ec808/6a17d76d2f4a5c686bfa876a/31a715f9d4881c6662c67fe72178635b5836a033-1440x942.png" alt="Pareto" /><h4>Breaking point</h4><p>Finally, with the calculated probability, we determine the target net worth corresponding to the bus hit probability and visualize it. Remember, we use the magic number ChatGPT gave us for the probability of getting hit by a bus:</p>
# Find the Net Worth Corresponding to the Bus Hit Probability
target_probability = 0.0000181
cumulative_probability = all_data_df['net_worth_probability'].cumsum()
target_net_worth_df = all_data_df[cumulative_probability &gt;= target_probability].head(1)
target_net_worth = target_net_worth_df['net_worth'].iloc[0]
print(f"Net Worth with Probability &gt;= {target_probability}: {target_net_worth}")

# Plot the Net Worth Probability Distribution
plt.figure(figsize=(10, 6))
plt.hist(all_data_df['net_worth'], bins=100, density=True, alpha=0.6, color='g', label='Empirical Data')
xmin, xmax = plt.xlim()
x = np.linspace(xmin, xmax, 100)
p = pareto.pdf(x, shape, loc=loc, scale=scale)
plt.plot(x, p, 'k', linewidth=2, label='Fitted Pareto Distribution')
plt.axhline(y=target_probability, color='r', linestyle='--', label='Bus Hit Probability')
plt.axvline(x=target_net_worth, color='g', linestyle='--', label=f'Net Worth = {target_net_worth:.2f}')
plt.xlabel('Net Worth')
plt.ylabel('Probability')
plt.title('Net Worth Probability Distribution')
plt.legend()
plt.grid(True)
plt.show()

<h2>Conclusion</h2><p>Based on our synthetic dataset, this chart vividly illustrates that the probability of amassing a net worth of approximately $12.5 million is as rare as the chance of being hit by a bus. For the fun of it, let’s ask ChatGPT what the probability is:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt224d185dc880bf21/6a17d76f7f6f1581edc09989/077e13f5ebce5019d00b7374ad6fd22dbcf7fe0b-1440x925.png" alt="Probaility Distribution" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d0ab89f76b8b2e2/6a17d77063baff5bf1741ac3/4aaa32cd60d59770137ae5a3fb582675e606bbd8-1440x210.png" alt="Net worth" /><p>Okay… $439 million? I think ChatGPT might be hallucinating again.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-query-language-esql-statistical-analysis</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-query-language-esql-statistical-analysis</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Baha Azarmi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1acb1d84387a6310/6a17d7726df7314a250a0d48/274867ef7971390c5d1d4f535c76e50a9f4a8224-1206x1522.png" length="0" type="image/png"/>
    <pubDate>Tue, 20 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch revisited: Building a chatbot using RAG]]></title>
    <description><![CDATA[Learn how to create a chatbot using ChatGPT and Elasticsearch, utilizing all of the newest RAG features.]]></description>
    <content:encoded><![CDATA[<p>Follow up to the blog <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a>.</p><p>In this blog, you will learn how to:</p><ul><li><p>Create an Elasticsearch Serverless project</p></li><li><p>Create an Inference Endpoint to generate embeddings with ELSER</p></li><li><p>Use a Semantic Text field for auto-chunking and calling the Inference Endpoint</p></li><li><p>Use the Open Crawler to crawl blogs</p></li><li><p>Connect to an LLM using Elastic’s Playground to test prompts and context settings for a RAG chat application.</p></li></ul><p>If you want to jump right into the code, you can view the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/rag-ties-the-app-together/ChatGPT_and_Elasticsearch__The_RAG_Really_Tied_the_App_Together.ipynb">accompanying Jupyter Notebook here</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2779150d12271ef1/6a1711e460084bdf023c4688/e0e3d205d5b4cb58c4b4b5f22aab57c8ef659ed6-1440x807.png" alt="The Dude Abides" /><h2>ChatGPT and Elasticsearch (April 2023)</h2><p>A lot has changed since I wrote the initial <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a>. Most people were just playing around with ChatGPT, if they had tried it at all. And every booth at every tech conference didn’t feature the letters “AI” (whether it is a useful fit or not).</p><h2>Updates in Elasticsearch (August 2024)</h2><p>Since then, Elastic has embraced being a full featured vector database and is putting a lot of engineering effort into making it the best vector database option for anyone building a search application. So as not to spend several pages talking about all the enhancements to Elasticsearch, here is a non-exhaustive list in no particular order:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/introducing-elser-v2-part-1">ELSER - The Elastic Learned Sparse Encoder</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">Elastic Serverless Service</a> was built and is in public beta</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-completion-support">Elasticsearch open Inference API</a> </p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-amazon-bedrock-support">Embeddings</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-openai-completion-support">Chat completion</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank">Semantic rerankers</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">Semantic_text type</a> - Simplify semantic search</p><ul><li><p>Automatic chunking</p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/rag-playground-introduction">Playground</a> - Visually experiment with RAG application building in Elasticsearch</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-retrievers">Retrievers</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-open-crawler-release">Open web crawler</a></p></li></ul><p>With all that change and more, the original blog needs a rewrite. So let’s get started.</p><h2>Updated flow: ChatGPT, Elasticsearch &amp; RAG</h2><p>The plan for this updated flow will be:</p><ol><li><p>Setup  </p><ol><li><p>Create a new Elasticsearch serverless search project</p></li><li><p>Create an embedding inference API using ELSER</p></li><li><p>Configure an index template with a <code>semantic_text</code> field</p></li><li><p>Create a new LLM connector</p></li><li><p>Configure a chat completion inference service using our LLM connector</p></li></ol></li><li><p>Ingest and Test</p><ol><li><p>Crawl the Elastic Labs sites (Search, Observability, Security) with the Elastic Open Web Crawler.</p></li><li><p>Use Playground to test prompts using our indexed Labs content</p></li></ol></li><li><p>Configure and deploy our App </p><ol><li><p>Export the generated code from Playground to an application using FastAPI as the backend and React as the front end.</p></li><li><p>Run it locally</p></li><li><p>Optionally deploy our chatbot to Google Cloud Run</p></li></ol></li></ol><h2>Setup</h2><h3>Elasticsearch Serverless Project</h3><p>We will be using an Elastic serverless project for our chatbot. Serverless removes much of the complexity of running an Elasticsearch cluster and lets you focus on actually using and gaining value from your data. Read more about the <a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">architecture of Serverless here</a>.</p><p>If you don’t have an Elastic Cloud account, you can create a free two-week trial at <a href="https://cloud.elastic.co/registration">elastic.co</a> (Serverless pricing <a href="https://www.elastic.co/pricing/serverless-search">available here</a>). If you already have one, you can simply log in.</p><p>Once logged in, you will need to <a href="https://cloud.elastic.co/account/keys">create a cloud API key</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt03e19072d48a28bb/6a1711e5dc55def695e00f03/d8121ed3d0fb4bbd5927a78aee20619589106df8-1300x1920.png" alt="alt_text" /><p><strong>NOTE: In the steps below, I will show the relevant parts of Python code. For the sake of brevity, I’m not going to show complete code that will import required libraries, wait for steps to complete, catch errors, etc.</strong></p><p><strong>For more robust code you can run, please see the </strong><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/rag-ties-the-app-together/ChatGPT_and_Elasticsearch__The_RAG_Really_Tied_the_App_Together.ipynb"><strong>accompanying Jypyter notebook</strong></a><strong>!</strong></p><h3>Create Serverless Project</h3><p>We will use our newly created API key to perform the next setup steps.</p><p>First off, create a new Elasticsearch project.</p>url = "https://api.elastic-cloud.com/api/v1/serverless/projects/elasticsearch" 

project_data = {
    "name": "The RAG Really Tied the App Together",
    "region_id": "aws-us-east-1",
    "optimized_for": "vector"
}

auth_header = f"ApiKey {api_key}"  # seeing what a comment lokos like with pound
headers = {
    "Content-Type": "application/json",
    "Authorization": auth_header
}

es_project = requests.post(url, json=project_data, headers=headers)  :four:
<ul><li><p><code>url</code> - This is the standard Serverless endpoint for Elastic Cloud</p></li><li><p><code>project_data</code> - Your Elasticsearch Serverless project settings </p><ul><li><p><code>name</code> - Name we want for the project</p></li><li><p><code>region_id</code> - Region to deploy</p></li><li><p><code>optimized_for</code> - Configuration type - We are using <code>vector</code> which isn’t strictly required for the ELSER model but can be suitable if you select a dense vector model such as e5.</p></li></ul></li></ul><h3>Create Elasticsearch Python client</h3><p>One nice thing about creating a programmatic project is that you will get back the connection information and credentials you need to interact with it!</p>es = Elasticsearch(es_project_keys['endpoints']['elasticsearch'],
                   basic_auth=(es_project_keys['credentials']['username'],
                              es_project_keys['credentials']['password']
                              )
                   )
<h3>ELSER Embedding API</h3><p>Once the project is created, which usually takes less than a few minutes, we can prepare it to handle our labs’ data.</p><p>The first step is to configure the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/put-inference-api.html#inference-example-elser">inference API for embedding</a>. We will be using the <a href="https://www.elastic.co/search-labs/blog/introducing-elser-v2-part-2">Elastic Learned Sparse Encoder</a> (ELSER).</p><ul><li><p>Command to create the inference endpoint</p></li><li><p>Specify this endpoint will be for generating sparse embeddings</p></li></ul>model_config = {
    "service": "elser",
    "service_settings": {
        "num_allocations": 8,
        "num_threads": 1
    }
}

inference_id = "my-elser-model"

create_endpoint = es.inference.put_model(
    inference_id=inference_id,
    task_type="sparse_embedding",
    body=model_config
)
<ul><li><p><code>model_config</code> - Settings we want to use for deploying our semantic reranking model </p><ul><li><p><code>service</code> - Use the pre-defined <code>elser</code> inference service</p></li><li><p><code>service_settings.num_allocations</code> - <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-deploy-model.html">Deploy the model</a> with 8 allocations</p></li><li><p><code>service_settings.num_threads</code> - Deploy with one thread per allocation</p></li></ul></li><li><p><code>inference_id</code> - The name you want to give to you inference endpoint</p></li><li><p><code>task_type</code>- Specifies this endpoint will be for generating sparse embeddings</p></li></ul><p>This single command will trigger Elasticsearch to perform a couple of tasks:</p><ol><li><p>It will download the ELSER model.</p></li><li><p>It will deploy (start) the ELSER model with eight allocations and one thread per allocation.</p></li><li><p>It will create an inference API we use in our field mapping in the next step.</p></li></ol><h3>Index Mapping</h3><p>With our ELSER API created, we will create our index template.</p>template_body = {
    "index_patterns": ["elastic-labs*"],
    "template": {
        "mappings": {
            "properties": {
                "body": {
                    "type": "text",
                    "copy_to": "semantic_body"
                },
                "semantic_body": {
                    "type": "semantic_text",
                    "inference_id": "my-elser-model"
                },
                "headings": {
                    "type": "text"
                },
                "id": {
                    "type": "keyword"
                },
                "meta_description": {
                    "type": "text"
                },
                "title": {
                    "type": "text"
                }
            }
        }
    }
}

template_resp = es.indices.put_index_template(  :eight:
    name="labs_template",
    body=template_body
)
<ul><li><p><code>index_patterns</code> - The pattern of indices we want this template to apply to.</p></li><li><p><code>body</code> - The main content of a web page the crawler collects will be written to</p><ul><li><p><code>type</code> - It is a text field</p></li><li><p><code>copy_to</code> - We need to copy that text to our semantic text field for semantic processing</p></li></ul></li><li><p><code>semantic_body</code> is our <a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">semantic text field</a> </p><ul><li><p>This field will automatically handle chunking of long text and generating embeddings which we will later use for semantic search</p></li><li><p><code>inference_id</code> specifies the name of the inference endpoint we created above, allowing us to generate embeddings from our ELSER model</p></li></ul></li><li><p><code>headings</code> - Heading tags from the html</p></li><li><p><code>id</code> - crawl id for this document</p></li><li><p><code>meta_description</code> - value of the description meta tag from the html</p></li><li><p><code>title</code> is the title of the web page the content is from</p></li></ul><p>Other fields will be indexed but auto-mapped. The ones we are focused on pre-defining in the template will not need to be both keyword and text type, which is defined automatically otherwise.</p><p>Most importantly, for this guide, we must define our <code>semantic_text</code> field and set a source field to copy from with <code>copy_to</code>. In this case, we are interested in performing semantic search on the body of the text, which the crawler indexes into the <code>body</code>.</p><h2>Crawl All the Labs!</h2><p>We can now install and configure the crawler to crawl the Elastic * Labs. We will loosely follow the excellent guide from the <a href="https://www.elastic.co/search-labs/blog/elastic-open-crawler-release#how-do-i-use-it">Open Crawler released for tech-preview</a> Search Labs blog.</p><p>The steps below will use docker and run on a MacBook Pro. To run this with a different setup, consult the <a href="https://github.com/elastic/crawler?tab=readme-ov-file#elastic-open-web-crawler">Open Crawler Github readme</a>.</p><h3>Clone the repo</h3><p>
Open the command line tool of your choice. I’ll be using Iterm2. Clone the <a href="https://github.com/elastic/crawler">crawler repo</a> to your machine.</p>~/repos
❯ git clone git@github.com:elastic/crawler.git
Cloning into 'crawler'...
remote: Enumerating objects: 1944, done.
remote: Counting objects: 100% (418/418), done.
remote: Compressing objects: 100% (243/243), done.
remote: Total 1944 (delta 237), reused 238 (delta 170), pack-reused 1526
Receiving objects: 100% (1944/1944), 84.85 MiB | 31.32 MiB/s, done.
Resolving deltas: 100% (727/727), done.
<h3>Build the crawler container</h3><p>Run the following command to build and run the crawler.</p>docker build -t crawler-image . &amp;&amp; docker run -i -d --name crawler crawler-image
~/repos
 ❯ cd crawler
~/repos/crawler main
 ❯ docker build -t crawler-image . &amp;&amp; docker run -i -d --name crawler crawler-image

[+] Building 66.9s (6/10)                                                                                                                                                                docker:desktop-linux
 =&gt; [internal] load build definition from Dockerfile					0.0s
 =&gt; =&gt; transferring dockerfile: 333B							0.0s
 =&gt; [internal] load .dockerignore							0.0s
 =&gt; =&gt; transferring context: 2B								0.0s
 =&gt; [internal] load metadata for docker.io/library/jruby:9.4.7.0-jdk21		1.7s
 =&gt; [auth] library/jruby:pull token for registry-1.docker.io			0.0s
...
...
 =&gt; [5/5] RUN make clean install								50.7s
 =&gt; exporting to image									0.9s
 =&gt; =&gt; exporting layers									0.9s
 =&gt; =&gt; writing image sha256:6b3f4000a121e76aba76fdbbf11b53f53a3fabba61c0b7cf3fdcdb21e244f1d8	0.0s
 =&gt; =&gt; naming to docker.io/library/crawler-image					0.0s
cc6c16941de04355c050ef5f5fd0041ee7f3505b8cf8448c7223f0d2e80b5498
<h3>Configure the crawler</h3><p>Create a new YAML in your favorite editor (vim):</p>~/repos/crawler main
 ❯ vim config/elastic-labs.yml
<p>We want to crawl all the documents on the three labs’ sites, but since blogs and tutorials on those sites tend to link out to other parts of elastic.co, we need to set a couple of runs to restrict the scope. We will allow crawling the three paths for our site and then deny anything else.</p><p>Paste the following in the file and save</p>domains:
  - url: https://www.elastic.co
    seed_urls:
      - https://www.elastic.co/search-labs
      - https://www.elastic.co/observability-labs
      - https://www.elastic.co/security-labs
    crawl_rules:
      - policy: allow
        type: begins
        pattern: /search-labs
      - policy: allow
        type: begins
        pattern: /observability-labs
      - policy: allow
        type: begins
        pattern: /security-labs
      - policy:deny
        type: regex
        pattern: .*/author/.*
      - policy: deny
        type: regex
        pattern: .*

output_sink: elasticsearch
output_index: elastic-labs
max_crawl_depth: 2

elasticsearch:
  host: "https://&lt;your_serverless_project&gt;.es.&lt;region&gt;.aws.elastic.cloud"
  port: "443"
  api_key: "&lt;API Key generated above&gt;"
<p>Copy the configuration into the Docker container:</p>~/repos/crawler main ⇣
 ❯ docker cp config/elastic-labs.yml crawler:/app/config/elastic-labs.yml

Successfully copied 2.05kB to crawler:/app/config/elastic-labs.yml
<h3>Validate the domain</h3><p>Ensure the config file has no issues by running:</p> ❯ docker exec -it crawler bin/crawler validate config/elastic-labs.yml
Domain https://www.elastic.co is valid
<h3>Start the crawler</h3><p>When you first run the crawler, processing all the articles on the three lab sites may take several minutes.</p>docker exec -it crawler bin/crawler crawl config/elastic-labs.yml
~/repos/crawler/config main ⇣
 ❯ docker exec -it crawler bin/crawler crawl config/elastic-labs.yml
[crawl:6692c3b584f98612e3a465ce] [primary] Initialized an in-memory URL queue for up to 10000 URLs
[crawl:6692c3b584f98612e3a465ce] [primary] ES connections will be authorized with configured API key
[crawl:6692c3b584f98612e3a465ce] [primary] ES connections will use SSL without ca_fingerprint
[crawl:6692c3b584f98612e3a465ce] [primary] Elasticsearch sink initialized for index [elastic-labs] with pipeline [ent-search-generic-ingestion]
[crawl:6692c3b584f98612e3a465ce] [primary] Starting the crawl with up to 10 parallel thread(s)...
[crawl:6692c3b584f98612e3a465ce] [primary] Crawl status: queue_size=11, pages_visited=1, urls_allowed=12, urls_denied={}, crawl_duration_msec=847, crawling_time_msec=635.0, avg_response_time_msec=635.0, active_threads=1, http_client={:max_connections=&gt;100, :used_connections=&gt;1}, status_codes={"200"=&gt;1}
<h3>Confirm articles have been indexed</h3><p>We will confirm two ways.</p><p>First, we will look at a sample document to ensure that ELSER embeddings have been generated. We just want to look at any doc so we can search without any arguments:</p>GET elastic-labs/_search
<p>Ensure you get results and then check that the field <code>body</code> contains text and <code>semantic_body.inference.chunks.0.embeddings</code> contains tokens.</p>    "hits": [
      {
        "_index": "elastic-labs",
...
        "_source": {
          "body": "Tutorials Integrations Blog Start Free Trial Contact Sales Open navigation menu Overview ...
          "semantic_body": {
            "inference": {
              "inference_id": "my-elser-model",
              "model_settings": {
                "task_type": "sparse_embedding"
              },
              "chunks": [
                {
                  "text": "Tutorials Integrations Blog Start Free Trial Contact Sales Open navigation menu Overview ...
                  "embeddings": {
                    "##her": 2.1016746,
                    "elastic": 2.084594,
                    "##ai": 1.6336359,
                    "dock": 1.5765089,
                    ...
<p>We can check we are gathering data from each of the three sites with a <code>terms</code> aggregation:</p>GET elastic-labs/_search
{
  "size": 0,
  "aggs": {
    "url_path_dir1": {
      "terms": {
        "field": "url_path_dir1.keyword"
      }
    }
  }
}
<p>You should see results that start with one of our three site paths.</p>      "buckets": [
        {
          "key": "security-labs",
          "doc_count": 37
        },
        {
          "key": "observability-labs",
          "doc_count": 30
        },
        {
          "key": "search-labs",
          "doc_count": 6
        }
      ]
<h2>To the Playground!</h2><p>With our data ingested, chunked, and inference, we can start working on the backend application code that will interact with the LLM for our RAG app.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd1ceed13b358b1bf/6a1711e767045b096445c2fd/abd9cb1460436f0e658f654f76ab90828892a671-494x144.png" alt="alt_text" /><h3>LLM Connection</h3><p>We need to configure a connection for Playground to make API calls to an LLM. As of this writing, Playground supports chat completion connections to OpenAI, AWS Bedrock, and Google Gemini. More connections are planned, so check the docs for the latest list.</p><p>When you first enter the Playground UI, click on “Connect to an LLM”</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8338faedbc15c2a5/6a1711e9961e69ce8ac4d021/ff5dbf52272a53ffe1c97136cf6bc02e0b05ff45-1146x872.png" alt="alt_text" /><p>Since I used OpenAI for the original blog, we’ll stick with that. The great thing about the Playground is that you can switch connections to a different service, and the Playground code will generate code specifically to that service’s API specification. You only need to select which one you want to use today.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt472f08d397464176/6a1711eb4a531b73db36aa9b/cf27f69cd578b936d78476bdf8ee5c387e725061-1440x480.png" alt="alt_text" /><p>In this step, you must fill out the fields depending on which LLM you wish to use. As mentioned above, since Playground will abstract away the API differences, you can use whichever supported LLM service works for you, and the rest of the steps in this guide will work the same.</p><p>If you don’t have an Azure OpenAI account or OpenAI API account, you can get one <a href="https://platform.openai.com/signup/">here</a> (OpenAI now requires a $5 minimum to fund the API account).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdd63e7d871549ab9/6a1711ed1949f72e3fe7ab52/1ac508303f4cc9d9427ae039f354b8ae0ac4473d-1370x1642.png" alt="alt_text" /><p>Once you have completed that, hit “Save,” and you will get confirmation that the connector has been added. After that, you just need to select the indices we will use in our app. You can select multiple, but since all our crawler data is going into <code>elastic-labs,</code> you can choose that one.</p><p>Click “Add data sources” and you can start using Playground!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a1b97d9677c2dd4/6a1711ee0e2e496c2a41a266/365855fbca95613171777e9171d2c3dd65b11694-1128x840.png" alt="alt_text" /><p>Select the “restaurant_reviews” index created earlier.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4cdedc79bcf7e160/6a1711f01949f787f3e7ab56/4355652e648e3e69915fad0afcade2a1a55ab1f7-740x524.png" alt="alt_text" /><h2>Playing in the Playground</h2><p>After adding your data source you will be in the Playground UI.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb8d1b52a4bffced1/6a1711f12b835f39adf4b329/6ad316fb6dfcd815d1f66844a2b23e02f8cf0826-1440x874.png" alt="alt_text" /><p>To keep getting started as simple as possible, we will stick with all the default settings other than the prompt. However, for more details on Playground components and how to use them, check out the <a href="https://www.elastic.co/search-labs/blog/rag-playground-introduction">Playground: Experiment with RAG applications with Elasticsearch in minutes</a> blog and the <a href="https://www.elastic.co/guide/en/kibana/current/playground.html">Playground documentation</a>.</p><p>Experimenting with different settings to fit your particular data and application needs is an important part of setting up a RAG-backed application.</p><p>The defaults we will be using are:</p><ul><li><p>Querying the <code>semantic_body</code> chunks</p></li><li><p>Using the three nearest semantic chunks as context to pass to the LLM</p></li></ul><h3>Creating a more detailed prompt</h3><p>The default prompt in Playground is simply a placeholder. Prompt engineering continues to develop as LLMs become more capable. Exploring the ever-changing world of prompt engineering is a blog, but there are a few basic concepts to remember when creating a system prompt:</p><ul><li><p>Be detailed when describing the app or service the LLM response is part of. This includes what data will be provided and who will consume the responses.</p></li><li><p>Provide example questions and responses. This technique, called <em>few-shot-prompting</em>, helps the LLM structure its responses.</p></li><li><p>Clearly state how the LLM should behave.</p></li><li><p>Specify the Desired Output Format.</p></li><li><p>Test and Iterate on Prompts.</p></li></ul><p>With this in mind, we can create a more detailed system prompt:</p>You are a helpful and knowledgeable assistant designed to assist users in querying information related to Search, Observability, and Security. Your primary goal is to provide clear, concise, and accurate responses based on semantically relevant documents retrieved using Elasticsearch.

Guidelines:

Audience:
Assume the user could be of any experience level but lean towards a technical slant in your explanations.
Avoid overly complex jargon unless it is common in the context of Elasticsearch, Search, Observability, or Security.

Response Structure:
Clarity: Responses should be clear and concise, avoiding unnecessary verbosity.
Conciseness: Provide information in the most direct way possible, using bullet points when appropriate.

Formatting: Use Markdown formatting for:
Bullet points to organize information
Code blocks for any code snippets, configurations, or commands
Relevance: Ensure the information provided is directly relevant to the user's query, prioritizing accuracy.

Content:
Technical Depth: Offer sufficient technical depth while remaining accessible. Tailor the complexity based on the user's apparent knowledge level inferred from their query.

Examples: Where appropriate, provide examples or scenarios to clarify concepts or illustrate use cases.
Documentation Links: When applicable, suggest additional resources or documentation from Elastic.co that can further assist the user.

Tone and Style:
Maintain a professional yet approachable tone.
Encourage curiosity by being supportive and patient with all user queries, regardless of complexity.

Example Queries:
"How can I optimize my Elasticsearch cluster for large-scale data?"
"What are the best practices for implementing observability in a microservices architecture?"
"How can I secure sensitive data in Elasticsearch?"
<p>Feel free to to test out different prompts and context settings to see what results you feel are best for your particular data. For more examples on advanced techiques, check out the <a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#prompts">Prompt section on the two part blog Advanced RAG Techniques</a>. Again, see the <a href="https://www.elastic.co/search-labs/blog/rag-playground-introduction">Playground blog post</a> for more details on the various settings you can tweak.</p><h2>Export the Code</h2><p>Behind the scenes, Playground generates all the backend chat code we need to perform semantic search, parse the relevant contextual fields, and make a chat completion call to the LLM. No coding work from us required!</p><p>In the upper right corner click on the “View Code” button to expand the code flyout</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf523ed7f26673f46/6a1711f35091687af1e1bbee/aca43cd5554f5a35cc4a336557b0497675c044a2-962x406.png" alt="alt_text" /><p>You will see the generated python code with all the settings your configured as well as the the functions to make a semantic call to Elasticsearch, parse the results, built the complete prompt, make the call to the LLM, and parse those results.</p><p>Click the copy icon to copy the code.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt841e18f13ddd4145/6a1711f514b270564ce3c6f9/ff6a3a012c9a2f5f760efe78f7b663ae6261ec52-1440x1449.png" alt="alt_text" /><p>You can now incorporate the code into your own chat application!</p><h2>Wrapup</h2><p>A lot has changed since the first iteration of this blog over a year ago, and we covered a lot in this blog. You started from a cloud API key, created an Elasticsearch Serverless project, generated a cloud API key, configured the Open Web Crawler, crawled three Elastic Lab sites, chunked the long text, generated embeddings, tested out the optimal chat settings for a RAG application, and exported the code!</p><p><em>Where’s the UI, Vestal?</em></p><p>Be on the lookout for part two where we will integrate the playground code into a python backend with a React frontend. We will also look at deploying the full chat application.</p><p>For a complete set of code for everything above, see the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/rag-ties-the-app-together/ChatGPT_and_Elasticsearch__The_RAG_Really_Tied_the_App_Together.ipynb">accompanying Jypyter notebook</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-rag-enhancements</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-rag-enhancements</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Jeff Vestal]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2779150d12271ef1/6a1711e460084bdf023c4688/e0e3d205d5b4cb58c4b4b5f22aab57c8ef659ed6-1440x807.png" length="0" type="image/png"/>
    <pubDate>Mon, 19 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Vector embeddings made simple with the Elasticsearch-DSL client for Python]]></title>
    <description><![CDATA[Learn how to ingest and search dense vectors in Python using the Elasticsearch-DSL client.]]></description>
    <content:encoded><![CDATA[<p>In this article we'll take a look at the <a href="https://elasticsearch-dsl.readthedocs.io/en/latest/index.html">Elasticsearch-DSL</a> client for Python, with a focus on how it simplifies the task of building a vector search solution.</p><p>The <a href="https://github.com/miguelgrinberg/quotes">code</a> that accompanies this article implements a database of famous quotes. It includes a back end written in Python with the <a href="https://fastapi.tiangolo.com/">FastAPI</a> web framework, and a front end written in <a href="https://www.typescriptlang.org/">TypeScript</a> and <a href="https://react.dev/">React</a>. Regarding vector search, this application demonstrates how to:</p><ul><li><p>run a local Elasticsearch service using Docker,</p></li><li><p>bulk-ingest a large number of documents efficiently,</p></li><li><p>generate vector embeddings for documents as they are ingested,</p></li><li><p>leverage the power of a GPU to accelerate the generation of vector embeddings through parallelization,</p></li><li><p>run vector search queries using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#approximate-knn">approximate kNN algorithm</a>,</p></li><li><p>aggregate results from vector search,</p></li><li><p>compare vector search results against those resulting from a standard <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match</a> (BM25) query.</p></li></ul><p>Below you can see a screenshot of the application. In this article you will find a detailed explanation of how the ingest and search features work. You then have the option to install and run the code on your own computer to experiment and learn!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a680a6bf95f4421/6a17122b66c4f9358ef8c157/1225861f71cf9ccbb2102216a9365dd07ff71e9c-1440x858.png" alt="Application screenshot" /><h2>What is the Elasticsearch-DSL client for Python?</h2><p>Sometimes called the "high-level" Python client, <a href="https://elasticsearch-dsl.readthedocs.io/en/latest/index.html">Elasticsearch-DSL</a> offers idiomatic (or "Pythonic") access to your Elasticsearch database, in contrast with the official (or "low-level") Python client, which provides direct access to the complete range of Elasticsearch features and endpoints.</p><p>When using Elasticsearch-DSL, the structure (or "mappings") of Elasticsearch indices are defined as classes, with a syntax that is similar to that of Python <a href="https://docs.python.org/3/library/dataclasses.html">dataclasses</a>. The documents stored in these indices are represented by instances of these classes. All the transformations that are necessary to map between Python objects and Elasticsearch documents are automatically and transparently carried out, resulting in application code that is simple and idiomatic.</p><p>To add Elasticsearch-DSL to your Python project, you can install it with <code>pip</code>:</p>pip install elasticsearch-dsl
<p>If your project is asynchronous, then there are additional dependencies that need to be installed, so in that case use the following command instead:</p>pip install "elasticsearch-dsl[async]"
<h2>Index definition</h2><p>As stated above, with Elasticsearch-DSL the structure of an Elasticsearch index is defined as a Python class. The example application featured in this article uses a dataset of famous quotes that have the following fields:</p><ul><li><p><code>quote</code>: the text of the quote, as a string</p></li><li><p><code>author</code>: the name of the author, as a string</p></li><li><p><code>tags</code>: a list of tag names that apply to the quote, each a string</p></li></ul><p>As part of this application we are going to add one additional field, the vector embedding that we will use to search for quotes:</p><ul><li><p><code>embedding</code>: a list of floating point numbers representing a vector embedding for the quote</p></li></ul><p>Let's write an initial document class to describe our famous quotes index:</p>import elasticsearch_dsl as dsl

class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str
    tags: list[str]
    embedding: list[float]

    class Index:
        name = 'quotes'
<p>The <code>AsyncDocument</code> class that is used as a base class for our <code>QuoteDoc</code> class implements all the functionality to connect the class to an Elasticsearch index. The choice of an asynchronous document base class was made because this examples uses the FastAPI web framework, which is also asynchronous. For projects that do not use asynchronous Python, the <code>Document</code> base class must be used when declaring document classes.</p><p>The <code>name</code> attribute given in the <code>Index</code> inner class defines the name of the Elasticsearch index that will be used with documents of this class.</p><p>If you have used Python dataclasses before, you likely find the way fields are defined very familiar, with each field being given a Python type hint. These Python types are mapped to the closest Elasticsearch type, so for example, in the case of <code>str</code>, the corresponding field in the Elasticsearch index will be given the type <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html#text-field-type"><code>text</code></a>, the standard type that is used for text that needs to be indexed for full-text search, while <code>float</code> is mapped to the equally named <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/number.html"><code>float</code></a> on the Elasticsearch side.</p><p>While it can be useful to leave the <code>quote</code> field as is so that we can use it for both vector and full-text searches, the <code>author</code> and <code>tags</code> fields do not really need all the extra work associated with full-text search. The best Elasticsearch type for these fields is <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html#keyword-field-type"><code>keyword</code></a>, which just stores the text, without doing any indexing. Likewise, the <code>embedding</code> field is not just a simple list of floating point numbers, we are going to use it for vector search, which is a behavior associated with the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html"><code>dense_vector</code></a> type in Elasticsearch.</p><p>To assign a type override to a field, we add an assignment with the <code>mapped_field()</code> function, as shown in the improved version of the <code>QuoteDoc</code> class that follows:</p>class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str = dsl.mapped_field(dsl.Keyword())
    tags: list[str] = dsl.mapped_field(dsl.Keyword())
    embedding: list[float] = dsl.mapped_field(dsl.DenseVector(), init=False)

    class Index:
        name = 'quotes'
<p>As you can see in this updated version, the <code>elasticsearch_dsl</code> package includes classes such as <code>Keyword</code> and <code>DenseVector</code> to represent all the native Elasticsearch field types.</p><p>Did you notice the <code>init=False</code> argument given in this new definition of the <code>embedding</code> field? If you are familiar with Python dataclasses you may recognize <code>init</code> as one of the options available in the dataclasses <a href="https://docs.python.org/3/library/dataclasses.html#dataclasses.field"><code>field()</code></a> function, used to indicate that the given attribute should be omitted from the constructor for instances of the class. The behavior is the same here, which means that when creating an instance of <code>QuoteDoc</code>, this argument should not be given.</p><p>How will the vector embeddings be generated if they will not be passed down to the document constructor? Elasticsearch-DSL always calls the <code>clean()</code> method in all documents before serializing them and sending them to Elasticsearch. This method is a convenience entry point where the application can add any custom field processing logic. For example, fields that are optional or auto-generated can be added in this method. Here is the final version of the <code>QuoteDoc</code> document class, including the logic that generates the embeddings:</p>from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str = dsl.mapped_field(dsl.Keyword())
    tags: list[str] = dsl.mapped_field(dsl.Keyword())
    embedding: list[float] = dsl.mapped_field(dsl.DenseVector(), init=False)

    class Index:
        name = 'quotes'

    def clean(self):
        if not self.embedding:
            self.embedding = model.encode(self.quote).tolist()
<p>For this example we are going to use embeddings from a <a href="https://sbert.net/">SentenceTransformers</a> model. These embeddings are easy to generate locally and being open source and free they are convenient to use when experimenting. The <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2">all-MiniLM-L6-v2</a> model is a great general purpose embedding model for English text. There are many other models that are also compatible with the SentenceTransformers framework, so feel free to use a different one if you prefer.</p><p>The <code>clean()</code> method can be used for more advanced use cases as well. For example, it is common when working with large bodies of text to split the text into smaller chunks, and then generate embeddings for each chunk. Elasticsearch accommodates this use case through nested objects. If you want to see an advanced example that implements this type of solution, check out the <a href="https://github.com/elastic/elasticsearch-dsl-py/blob/main/examples/vectors.py">vectors</a> example in the Elasticsearch-DSL repository.</p><h2>Document ingestion</h2><p>With the structure of the index in place, we can now create the index. This is done with the <code>init()</code> class method:</p>async def ingest_quotes():
    await QuoteDoc.init()
<p>In many cases it is useful to delete a previously existing index to make sure an ingest process begins from a clean starting point. This can be done using the <code>_index</code> class attribute, which provides access to the Elasticsearch index, along with its <code>exists()</code> and <code>delete()</code> methods:</p>async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()
<p>The example dataset used by the example application is a collection of almost 37,000 famous quotes. It comes as a CSV file with the <code>quote</code>, <code>author</code> and <code>tags</code> columns. The tags are given as a comma-separated string. The dataset is available for <a href="https://raw.githubusercontent.com/miguelgrinberg/quotes/main/backend/quotes.csv">download</a> from the example GitHub repository.</p><p>To ingest the data contained in this dataset, Python's <code>csv</code> module can be used:</p>import csv

async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    with open('quotes.csv') as f:
        reader = csv.DictReader(f)
        for row in reader:
            q = QuoteDoc(quote=row['quote'], author=row['author'],
                         tags=row['tags'].split(','))
            await q.save()
<p>The <code>csv.DictReader</code> class creates a CSV file importer that returns a dictionary for each row in the data file. For each row, we create a <code>QuoteDoc</code> instance and pass the <code>quote</code>, <code>author</code> and <code>tags</code> in the constructor. For the tags, the string that is read from the CSV file has to be split into a list, which is how it will be stored in the Elasticsearch index.</p><p>To write a document to the index, the <code>save()</code> method is invoked. This method will call the document's <code>clean()</code> method, which in turn will generate the vector embedding for the quote.</p><h3>Starting an Elasticsearch instance</h3><p>Before the above ingest script can be executed, you need to have access to a running instance of Elasticsearch. By far the easiest (and also 100% free) way to do this is with a <a href="https://www.docker.com/">Docker</a> container.</p><p>To start a single-node Elasticsearch service on your computer first make sure you have Docker running, and then execute the following command:</p>docker run -p 127.0.0.1:9200:9200 -d --name elasticsearch \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  -e "xpack.license.self_generated.type=basic" \
  -v "./data:/usr/share/elasticsearch/data" \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.0
<p>To make sure you are running the latest and greatest version, open the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/es-release-notes.html">release notes</a> page to find out what is the current version, then replace the version number in the last line of the above command.</p><p>The <code>-v</code> option in the command above sets up a mapping between a directory named <code>data</code> in your local system and the data directory in the Elasticsearch container. All the data files used by Elasticsearch will be saved in this directory, so that in case you need to restart your container you do not lose any data. If you prefer to not store the data files in your computer, then you can remove the <code>-v</code> line and the data will be stored ephemerally in the container.</p><p>Note that deploying Elasticsearch using this method is only adequate for local experimentation. If you intend to deploy Elasticsearch on a production server, consider using our <a href="https://www.elastic.co/blog/getting-started-with-the-elastic-stack-and-docker-compose">Elasticsearch on Docker Compose</a> or <a href="https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-deploy-eck.html">Elasticsearch on Kubernetes</a> guides.</p><h3>Connecting to Elasticsearch</h3><p>The ingestion script needs to know how to connect to Elasticsearch. If you are running a Docker container as demonstrated in the previous section, add the following line between the imports and the definition of the <code>QuoteDoc</code> class:</p>dsl.async_connections.create_connection(hosts=['http://localhost:9200'])
<p>To complete the script, the <code>ingest_quotes()</code> function should be called. Add the following snippet at the bottom of your source file:</p>if __name__ == '__main__':
    asyncio.run(ingest_quotes())
<p>The <code>asyncio.run()</code> function will launch the asynchronous application. If your application is not asynchronous, then you would just call the ingest function directly.</p><p>For your convenience, below you can find the complete code for the script up to this point. You can save this file as <em>search.py</em>. You can find an example of this file <a href="https://github.com/miguelgrinberg/quotes/blob/main/backend/search.py">here</a>.</p>import asyncio
import csv
import elasticsearch_dsl as dsl
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
dsl.async_connections.create_connection(hosts=['http://localhost:9200'], serializer=OrjsonSerializer())


class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str = dsl.mapped_field(dsl.Keyword())
    tags: list[str] = dsl.mapped_field(dsl.Keyword())
    embedding: list[float] = dsl.mapped_field(dsl.DenseVector(), init=False)

    class Index:
        name = 'quotes'

    def clean(self):
        if not self.embedding:
            self.embedding = model.encode(self.quote).tolist()

async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    with open('quotes.csv') as f:
        reader = csv.DictReader(f)
        for row in reader:
            q = QuoteDoc(quote=row['quote'], author=row['author'],
                         tags=row['tags'].split(','))
            await q.save()

if __name__ == '__main__':
    asyncio.run(ingest_quotes())
<p>Create a virtual environment for your project using the tool of your choice, and then install the dependencies on it:</p>pip install "elasticsearch-dsl[async]" sentence-transformers
<p>Make sure you have the <a href="https://raw.githubusercontent.com/miguelgrinberg/quotes/main/backend/quotes.csv">quotes.csv</a> file in the current directory, and then start the ingest by running the script:</p>python search.py
<p>The script does not print anything, so it will run for a while adding the quotes from the CSV file into your Elasticsearch index. The file has about 37,000 quotes, so expect the process to run for several minutes.</p><p>Luckily you do not need to wait that long. If you start the script and no error appears, that is confirmation that everything is working. You can press Ctrl-C to stop it and continue reading to learn about ingest performance.</p><h3>Performance tuning part 1: bulk processing</h3><p>If your dataset is small, then the above ingest solution will work just fine, and it has the benefit that it is simple to code and easy to understand.</p><p>For larger ingest jobs, however, it is necessary to sacrifice code clarity and pay attention to performance, so let's see what optimizations can be done in this application.</p><p>First of all, to evaluate performance we need to be able to measure the performance of the existing solution. Below is the updated <code>ingest_quotes()</code> function, which now calls <code>ingest_progress()</code> every 100 ingested documents to show how many documents have been ingested, along with an average document per second.</p>from time import time

# ...

def ingest_progress(count, start):
    elapsed = time() - start
    print(f'\rIngested {count} quotes. ({count / elapsed:.0f}/sec)', end='')

async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    with open('quotes.csv') as f:
        reader = csv.DictReader(f)
        count = 0
        start = time()
        for row in reader:
            q = QuoteDoc(quote=row['quote'], author=row['author'],
                         tags=row['tags'].split(','))
            await q.save()
            count += 1
            if count % 100 == 0:
                ingest_progress(count, start)
        ingest_progress(count, start)

# ...
<p>This version of the ingest is nicer than the previous one because it prints regular status updates. If you let the script run for a while you may see an output similar to the one below:</p>❯ python search.py
Ingested 4900 quotes. (97/sec)
<p>The data file has close to 37,000 quotes, so now you can have a good idea of how long the ingest will take. Assuming the average of 97 ingested documents per second holds throughout the entire ingest job, it should take less than 7 minutes to ingest the entire dataset. You can press Ctrl-C to stop this ingest process, there is no need to let it run to completion yet.</p><p>Elasticsearch offers a very flexible bulk ingest feature, which is made available in the Elasticsearch-DSL package's <code>bulk()</code> method. Instead of saving each document, the entire import loop can be moved into a generator function which is given to the <code>bulk()</code> method as an argument:</p>async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    async def get_next_quote():
        with open('quotes.csv') as f:
            reader = csv.DictReader(f)
            count = 0
            start = time()
            for row in reader:
                q = QuoteDoc(quote=row['quote'], author=row['author'],
                             tags=row['tags'].split(','))
                yield q
                count += 1
                if count % 100 == 0:
                    ingest_progress(count, start)
            ingest_progress(count, start)

    await QuoteDoc.bulk(get_next_quote())
<p>Here the <code>get_next_quote()</code> inner generator function yields <code>QuoteDoc</code> instances. The <code>QuoteDoc.bulk()</code> method will run the generator and issue batch updates to Elasticsearch. With this change, you can expect to see a small speed improvement:</p>❯ python s.py
Ingested 5500 quotes. (108/sec)
<p>For another small improvement, the JSON serializer used by the Elasticsearch client can be changed to the <a href="https://pypi.org/project/orjson/">orjson</a> library, which performs better than Python's own:</p>from elasticsearch import OrjsonSerializer
# ...

dsl.async_connections.create_connection(hosts=['http://localhost:9200'],
                                        serializer=OrjsonSerializer())

# ...
<p>This should lead to another small performance improvement:</p>❯ python s.py
Ingested 5100 quotes. (111/sec)
<h3>Performance tuning part 2: GPU accelerated embeddings</h3><p>You have seen in the previous section that we have obtained some modest performance improvements by processing ingest requests in bulk. But while ingestion requests are now being grouped, the embeddings continue to be generated one by one in the <code>clean()</code> method of the <code>QuoteDoc</code> class.</p><p>Is there a way to optimize embedding generation? The SentenceTransformers model uses PyTorch, which in turn uses a GPU if one is available. But the embeddings are generated individually, which does not lead to an optimal utilization of the GPU hardware. GPUs are very good at parallelization, so we can reorganize the ingest function to generate embeddings in batches. And once again the price we pay for this comes in increased code complexity.</p><p>So we are going to stop using the <code>clean()</code> method to generate document embeddings, and instead we are going to accumulate the <code>QuoteDoc</code> instances in a list, and once we reach a good number we'll generate embeddings for all of them in a single operation.</p><p>Let's start by writing a helper function that generates embeddings for a list of <code>QuoteDoc</code> instances:</p>def embed_quotes(quotes):
    embeddings = model.encode([q.quote for q in quotes])
    for q, e in zip(quotes, embeddings):
        q.embedding = e.tolist()
<p>Note how now the <code>model.encode()</code> method is given a list of quotes to embed instead of a single one. When the input argument is a list, the model generates an embedding for each list element. The method accepts an optional <a href="https://sbert.net/docs/package_reference/sentence_transformer/SentenceTransformer.html#sentence_transformers.SentenceTransformer.encode"><code>batch_size</code></a> argument (not used in the example above) that defaults to 32 that can be used to control the size of each batch of samples that are sent to the model for computation. Depending on the GPU hardware you may find that different values of this argument help tune performance to the best possible. Once the embeddings are generated, they are assigned to each quote using a for-loop.</p><p>Now the ingest function can be refactored to accumulate quotes and use the helper function to generate embeddings:</p>async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    async def get_next_quote():
        quotes = []
        with open('quotes.csv') as f:
            reader = csv.DictReader(f)
            count = 0
            start = time()
            for row in reader:
                q = QuoteDoc(quote=row['quote'], author=row['author'],
                             tags=row['tags'].split(','))
                quotes.append(q)
                if len(quotes) == 512:
                    embed_quotes(quotes)
                    for q in quotes:
                        yield q
                    count += len(quotes)
                    ingest_progress(count, start)
                    quotes = []
            if len(quotes) &gt; 0:
                embed_quotes(quotes)
                for q in quotes:
                    yield q
            ingest_progress(count, start)
<p>In this version of <code>ingest_quotes()</code>, each <code>QuoteDoc</code> instance is added to the <code>quotes</code> list, and when 512 elements have accumulated the <code>embed_quotes()</code> function added above is used to generate the embeddings more efficiently. Once the objects have their embeddings, they are yielded, so that the <code>bulk()</code> method from Elasticsearch-DSL can add them to the index as before.</p><p>What is the significance of the 512 number? There isn't any. We know that the model uses a batch size of 32, so it makes sense to accumulate at least that many documents. Starting from 32, you can try if larger powers of 2 provide better performance. With the hardware available to me, I've found 512 to give the best performance.</p><p>Here is an example run using batched embeddings:</p>❯ python search.py
Ingested 36864 quotes. (481/sec)
<p>And now the ingestion process runs much faster, with the entire dataset ingested in about 1 minutes and 16 seconds.</p><p>If you decide to try to optimize your ingest, you are encouraged to try different options and see what works best with your hardware.</p><h2>Querying the index</h2><p>If you are following along, by now you have an Elasticsearch index called <code>quotes</code> that is populated with about 37K famous quotes, each with a searchable vector embedding. Now it is time to learn how to query this index.</p><p>When using Elasticsearch-DSL, the document classes return a search object from their <code>search()</code> method:</p>s = QuoteDoc.search()
<p>The search object has a large number of methods that map to the query options in the Elasticsearch <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html">query DSL</a>.</p><p>The simplest query that can be issued is the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-all-query.html">match all</a> query, which returns all the elements. With the class-based approach used by Elasticsearch-DSL, this is how to run the query:</p>s = QuoteDoc.search()
s = s.query(dsl.query.MatchAll())
async for q in s:
    print(q.quote)
<p>This would obviously print a listing of the entire list of quotes stored in the index, up to 10,000, which is the maximum number of results Elasticsearch returns by default.</p><p>In many cases it is useful to request a subset of the results. The search object uses Python style slicing for this. Here is how to request the first 25 results only:</p>async for q in s[:25]:
    print(q.quote)
<p>Here is how to request the second page of results, at 25 results per page:</p>async for q in s[25:50]:
    print(q.quote)
<p>Elasticsearch offers approximate and exact vector search queries, also called <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">k-nearest neighbor (kNN) queries</a>. To run a vector search query with the approximate k-nearest neighbor algorithm, the <code>Knn</code> query should be used:</p>s = QuoteDoc.search()
s = s.query(dsl.query.Knn(field=QuoteDoc.embedding, query_vector=model.encode(q).tolist()))
<p>The <code>Knn</code> query class accepts the field that stores the embeddings and a search vector as arguments. In the above snippet the variable <code>q</code> has the search text entered by the user.</p><p>If instead you prefer to run a regular full-text search, the <code>Match</code> query class is used:</p>s = QuoteDoc.search()
s = s.query(dsl.query.Match(quote=q))
<h3>Filters</h3><p>One of the most important benefits of using Elasticsearch as a vector database is that it is a robust database system, and all the options you can expect to have from a database nicely integrates with your vector search queries.</p><p>A great example of this is <em>filters</em>. The famous quotes database stores a list of tags for each quote, so it is only natural to have the option to restrict a query to quotes that have a specific tag.</p><p>Given a list of tag filters stored in a <code>tags</code> variable, the following snippet configures a search object to only return results that include the given tags using a "terms" filter:</p>for tag in tags:
    s = s.filter(dsl.query.Terms(tags=[tag]))
<h3>Aggregations</h3><p>Another example of a useful database function that is fully integrated with vector search is <em>aggregations</em>. Given a query, Elasticsearch can aggregate the tags and provide the counts of quotes per tag.</p><p>The next snippet shows how to add a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">Terms</a> aggregation to an existing query, which will return the 100 most referenced tags in the results:</p>s.aggs.bucket('tags', dsl.aggs.Terms(field=QuoteDoc.tags, size=100))
<p>Recall that the <code>tags</code> field was declared with the <code>Keyword()</code> type, which means that the tags will be stored as is on the index, without any processing. This is required by the Terms aggregation, which will count the occurrences of each tag in the results.</p><h3>A complete query example</h3><p>You have seen a few isolated query examples. In this section you can see how they can all be integrated into a function that performs a query in the example application.</p><p>The <code>search_quotes()</code> function shown below accepts a query string <code>q</code>, a list of filters <code>tags</code> and a <code>use_knn</code> flag to choose between kNN or full-text search query. It also accepts <code>start</code> and <code>size</code> pagination arguments.</p><p>The function decides which of the three queries you've seen above to issue depending on the input arguments. If <code>q</code> is empty, then it selects a "match all" query, and in any other case it selects a kNN or match query depending on the <code>use_knn</code> flag, which the user can control from a checkbox in the application's user interface.</p><p>The function returns three results as a tuple:</p><ul><li><p>a list of <code>QuoteDoc</code> instances that are the search results,</p></li><li><p>the tag aggregations as a list of tuples, each with tag name and document count,</p></li><li><p>the total number of results, which is useful to show in paginated queries</p></li></ul><p>Here is the complete code of this function:</p>async def search_quotes(q, tags, use_knn=True, start=0, size=25):
    s = QuoteDoc.search()
    if q == '':
        s = s.query(dsl.query.MatchAll())
    elif use_knn:
        s = s.query(dsl.query.Knn(field=QuoteDoc.embedding, query_vector=model.encode(q).tolist()))
    else:
        s = s.query(dsl.query.Match(quote=q))
    for tag in tags:
        s = s.filter(dsl.query.Terms(tags=[tag]))
    s.aggs.bucket('tags', dsl.aggs.Terms(field=QuoteDoc.tags, size=100))
    r = await s[start:start + size].execute()
    tags = [(tag.key, tag.doc_count) for tag in r.aggs.tags.buckets]
    return r.hits, tags, r['hits'].total.value
<p>To be able to access both the search results and the aggregation results, we now issue the request explicitly through the <code>execute()</code> method and store the response is stored in <code>r</code>. The <code>hits</code> attribute of the response object contains the actual search results, and the <code>aggs</code> attribute provides access to the aggregations. The format in which the aggregation results is provided is described in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">terms aggregation documentation</a>.</p><h2>Conclusion</h2><p>The complete quotes example is available in a <a href="https://github.com/miguelgrinberg/quotes">GitHub repository</a> that you can install and run on your computer. Follow the instructions on the <code>README.md</code> file to set it up.</p><p>You are welcome to use this example to experiment with vector embeddings and Elasticsearch!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-dsl-python-vectors</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-dsl-python-vectors</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Miguel Grinberg]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a680a6bf95f4421/6a17122b66c4f9358ef8c157/1225861f71cf9ccbb2102216a9365dd07ff71e9c-1440x858.png" length="0" type="image/png"/>
    <pubDate>Fri, 16 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Advanced RAG techniques part 2: Querying and testing]]></title>
    <description><![CDATA[Discussing and implementing techniques which may increase RAG performance. Part 2 of 2, focusing on querying and testing an advanced RAG pipeline.]]></description>
    <content:encoded><![CDATA[<p><em>All code may be found </em><a href="https://github.com/elastic/elasticsearch-labs/tree/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques"><em>in the Searchlabs repo, in the advanced-rag-techniques branch</em></a><em>.</em></p><p>Welcome to Part 2 of our article on Advanced RAG Techniques! In <a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1">part 1 of this series</a>, we set up, discussed, and implemented the data processing components of the advanced RAG pipeline:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a4691874a19d8da/6a170b3f47d49c99f22d8a24/72b51ba2ae5e5977b56e5b915674753d6cfd0e56-1440x840.jpg" alt="Advanced RAG pipeline" /><p>In this part, we're going to proceed with querying and testing out our implementation. Let's get right to it!</p><h3>Table of contents</h3><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#searching-and-retrieving,-generating-answers">Searching and retrieving, generating answers</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#enriching-queries-with-synonyms">Enriching queries with synonyms</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#hyde-hypothetical-document-embedding">HyDE (Hypothetical Document Embedding)</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#hybrid-search">Hybrid search</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#experiments">Experiments</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#summary-of-results">Summary of results</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#test-1-who-audits-elastic">Test 1: Who audits Elastic?</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#advancedrag">AdvancedRAG</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#simplerag">SimpleRAG</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#test-2--total-revenue-2023">Test 2: total revenue 2023</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#advancedrag-1">AdvancedRAG</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#simplerag-1">SimpleRAG</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#test-3-what-product-does-growth-primarily-depend-on-how-much">Test 3: What product does growth primarily depend on? How much?</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#advancedrag-2">AdvancedRAG</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#simplerag-2">SimpleRAG</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#test-4-describe-employee-benefit-plan">Test 4: Describe employee benefit plan</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#advancedrag-3">AdvancedRAG</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#simplerag-3">SimpleRAG</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#test-5-which-companies-did-elastic-acquire">Test 5: Which companies did Elastic acquire?</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#advancedrag-4">AdvancedRAG</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#simplerag-4">SimpleRAG</a></p></li></ul></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#conclusion">Conclusion</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#appendix">Appendix</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#prompts">Prompts</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#rag-question-answering-prompt">RAG question answering prompt</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#elastic-query-generator-prompt">Elastic query generator prompt</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#potential-questions-generator-prompt">Potential questions generator prompt</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#hyde-generator-prompt">HyDE generator prompt</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#sample-hybrid-search-query">Sample hybrid search query</a></p></li></ul></li></ul><h2>Searching and retrieving, generating answers</h2><p>Let's ask our first query, ideally some piece of information found primarily in the annual report. How about:</p>Who audits Elastic?"
<p>Now, let's apply a few of our techniques to enhance the query.</p><h3>Enriching queries with synonyms</h3><p>Firstly, let's enhance the diversity of the query wording, and turn it into a form that can be easily processed into an Elasticsearch query. We'll enlist the aid of GPT-4o to convert the query into a list of OR clauses. Let's write this prompt:</p>
ELASTIC_SEARCH_QUERY_GENERATOR_PROMPT = '''
You are an AI assistant specialized in generating Elasticsearch query strings. Your task is to create the most effective query string for the given user question. This query string will be used to search for relevant documents in an Elasticsearch index.

Guidelines:
1. Analyze the user's question carefully.
2. Generate ONLY a query string suitable for Elasticsearch's match query.
3. Focus on key terms and concepts from the question.
4. Include synonyms or related terms that might be in relevant documents.
5. Use simple Elasticsearch query string syntax if helpful (e.g., OR, AND).
6. Do not use advanced Elasticsearch features or syntax.
7. Do not include any explanations, comments, or additional text.
8. Provide only the query string, nothing else.

For the question "What is Clickthrough Data?", we would expect a response like:
clickthrough data OR click-through data OR click through rate OR CTR OR user clicks OR ad clicks OR search engine results OR web analytics

AND operator is not allowed. Use only OR.

User Question:
[The user's question will be inserted here]

Generate the Elasticsearch query string:
'''
<p>When applied to our query, GPT-4o generates synonyms of the base query and related vocabulary.</p>'audits elastic OR 
elasticsearch audits OR 
elastic auditor OR 
elasticsearch auditor OR 
elastic audit firm OR 
elastic audit company OR 
elastic audit organization OR 
elastic audit service'
<p>In the <code>ESQueryMaker</code> class, I've defined a function to split the query:</p>def parse_or_query(self, query_text: str) -&gt; List[str]:
    # Split the query by 'OR' and strip whitespace from each term
    # This converts a string like "term1 OR term2 OR term3" into a list ["term1", "term2", "term3"]
    return [term.strip() for term in query_text.split(' OR ')]
<p>Its role is to take this string of OR clauses and split them into a list of terms, allowing us do a multi-match on our key document fields:</p>["original_text", 'keyphrases', 'potential_questions', 'entities']
<p>Finally ending up with this query:</p> 'query': {
    'bool': {
        'must': [
            {
                'multi_match': {
                'query': 'audits Elastic Elastic auditing Elastic audit process Elastic compliance Elastic security audit Elasticsearch auditing Elasticsearch compliance Elasticsearch security audit',
                'fields': [
                    'original_text',
                'keyphrases',
                'potential_questions',
                'entities'
                ],
                'type': 'best_fields',
                'operator': 'or'
                }
            }
      ]
<p>This covers many more bases than the original query, hopefully reducing the risk of missing a search result because we forgot a synonym. But we can do more.</p><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#table-of-contents">Back to top</a></p><h3>HyDE (Hypothetical Document Embedding)</h3><p>Let's enlist GPT-4o again, this time to implement <a href="https://arxiv.org/abs/2212.10496">HyDE</a>.</p><p>The basic premise of HyDE is to generate a hypothetical document - The kind of document that would likely contain the answer to the original query. The factuality or accuracy of the document is not a concern. With that in mind, let's write the following prompt:</p>HYDE_DOCUMENT_GENERATOR_PROMPT = '''
You are an AI assistant specialized in generating hypothetical documents based on user queries. Your task is to create a detailed, factual document that would likely contain the answer to the user's question. This hypothetical document will be used to enhance the retrieval process in a Retrieval-Augmented Generation (RAG) system.

Guidelines:
1. Carefully analyze the user's query to understand the topic and the type of information being sought.
2. Generate a hypothetical document that:
   a. Is directly relevant to the query
   b. Contains factual information that would answer the query
   c. Includes additional context and related information
   d. Uses a formal, informative tone similar to an encyclopedia or textbook entry
3. Structure the document with clear paragraphs, covering different aspects of the topic.
4. Include specific details, examples, or data points that would be relevant to the query.
5. Aim for a document length of 200-300 words.
6. Do not use citations or references, as this is a hypothetical document.
7. Avoid using phrases like "In this document" or "This text discusses" - write as if it's a real, standalone document.
8. Do not mention or refer to the original query in the generated document.
9. Ensure the content is factual and objective, avoiding opinions or speculative information.
10. Output only the generated document, without any additional explanations or meta-text.

User Question:
[The user's question will be inserted here]

Generate a hypothetical document that would likely contain the answer to this query:
'''
<p>Since vector search typically operates on cosine vector similarity, the premise of HyDE is that we can achieve better results by matching documents to documents instead of queries to documents.</p><p>What we care about is structure, flow, and terminology. Not so much factuality. GPT-4o outputs a HyDE document like this:</p>'Elastic N.V., the parent company of Elastic, the organization known for developing Elasticsearch, is subject to audits to ensure financial accuracy, 
regulatory compliance, and the integrity of its financial statements. The auditing of Elastic N.V. is typically conducted by an external, 
independent auditing firm. This is common practice for publicly traded companies to provide stakeholders with assurance regarding the company\'s 
financial position and operations.\n\nThe primary external auditor for Elastic is the audit firm Ernst &amp; Young LLP (EY). Ernst &amp; Young is one of the 
four largest professional services networks in the world, commonly referred to as the "Big Four" audit firms. These firms handle a substantial number 
of audits for major corporations around the globe, ensuring adherence to generally accepted accounting principles (GAAP) and international financial 
reporting standards (IFRS).\n\nThe audit process conducted by EY involves several steps. Initially, the auditors perform a risk assessment to identify 
areas where misstatements due to error or fraud could occur. They then design audit procedures to test the accuracy and completeness of financial statements,
 which include examining financial transactions, assessing internal controls, and reviewing compliance with relevant laws and regulations. Upon completion of 
 the audit, Ernst &amp; Young issues an audit report, which includes the auditor’s opinion on whether the financial statements are free from material misstatement 
 and are presented fairly in accordance with the applicable financial reporting framework.\n\nIn addition to external audits by firms like Ernst &amp; Young, 
 Elastic may also be subject to internal audits. Internal audits are performed by the company’s own internal auditors to evaluate the effectiveness of internal 
 controls, risk management, and governance processes.\n\nOverall, the auditing process plays a crucial role in maintaining the transparency and reliability of 
 Elastic\'s financial information, providing confidence to investors, regulators, and other stakeholders.'
<p>It looks pretty believable, like the ideal candidate for the kinds of documents we'd like to index. We're going to embed this and use it for hybrid search.</p><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#table-of-contents">Back to top</a></p><h3>Hybrid search</h3><p>This is the core of our search logic. Our lexical search component will be the generated OR clause strings. Our dense vector component will be embedded HyDE Document (aka the search vector). We use KNN to efficiently identify several candidate documents closest to our search vector. We call our lexical search component <em>Scoring with TF-IDF and BM25</em> by default. Finally, the lexical and dense vector scores will be combined using the 30/70 ratio recommended by <a href="https://arxiv.org/abs/2407.01219">Wang et al</a>.</p>def hybrid_vector_search(self, index_name: str, query_text: str, query_vector: List[float], 
                         text_fields: List[str], vector_field: str, 
                         num_candidates: int = 100, num_results: int = 10) -&gt; Dict:
    """
    Perform a hybrid search combining text-based and vector-based similarity.

    Args:
        index_name (str): The name of the Elasticsearch index to search.
        query_text (str): The text query string, which may contain 'OR' separated terms.
        query_vector (List[float]): The query vector for semantic similarity search.
        text_fields (List[str]): List of text fields to search in the index.
        vector_field (str): The name of the field containing document vectors.
        num_candidates (int): Number of candidates to consider in the initial KNN search.
        num_results (int): Number of final results to return.

    Returns:
        Dict: A tuple containing the Elasticsearch response and the search body used.
    """
    try:
        # Parse the query_text into a list of individual search terms
        # This splits terms separated by 'OR' and removes any leading/trailing whitespace
        query_terms = self.parse_or_query(query_text)

        # Construct the search body for Elasticsearch
        search_body = {
            # KNN search component for vector similarity
            "knn": {
                "field": vector_field,  # The field containing document vectors
                "query_vector": query_vector,  # The query vector to compare against
                "k": num_candidates,  # Number of nearest neighbors to retrieve
                "num_candidates": num_candidates  # Number of candidates to consider in the KNN search
            },
            "query": {
                "bool": {
                    # The 'must' clause ensures that matching documents must satisfy this condition
                    # Documents that don't match this clause are excluded from the results
                    "must": [
                        {
                            # Multi-match query to search across multiple text fields
                            "multi_match": {
                                "query": " ".join(query_terms),  # Join all query terms into a single space-separated string
                                "fields": text_fields,  # List of fields to search in
                                "type": "best_fields",  # Use the best matching field for scoring
                                "operator": "or"  # Match any of the terms (equivalent to the original OR query)
                            }
                        }
                    ],
                    # The 'should' clause boosts relevance but doesn't exclude documents
                    # It's used here to combine vector similarity with text relevance
                    "should": [
                        {
                            # Custom scoring using a script to combine vector and text scores
                            "script_score": {
                                "query": {"match_all": {}},  # Apply this scoring to all documents that matched the 'must' clause
                                "script": {
                                    # Script to combine vector similarity and text relevance
                                    "source": """
                                    # Calculate vector similarity (cosine similarity + 1)
                                    # Adding 1 ensures the score is always positive
                                    double vector_score = cosineSimilarity(params.query_vector, params.vector_field) + 1.0;
                                    # Get the text-based relevance score from the multi_match query
                                    double text_score = _score;
                                    # Combine scores: 70% vector similarity, 30% text relevance
                                    # This weighting can be adjusted based on the importance of semantic vs keyword matching
                                    return 0.7 * vector_score + 0.3 * text_score;
                                    """,
                                    # Parameters passed to the script
                                    "params": {
                                        "query_vector": query_vector,  # Query vector for similarity calculation
                                        "vector_field": vector_field  # Field containing document vectors
                                    }
                                }
                            }
                        }
                    ]
                }
            }
        }

        # Execute the search request against the Elasticsearch index
        response = self.conn.search(index=index_name, body=search_body, size=num_results)
        # Log the successful execution of the search for monitoring and debugging
        logger.info(f"Hybrid search executed on index: {index_name} with text query: {query_text}")
        # Return both the response and the search body (useful for debugging and result analysis)
        return response, search_body
    except Exception as e:
        # Log any errors that occur during the search process
        logger.error(f"Error executing hybrid search on index: {index_name}. Error: {e}")
        # Re-raise the exception for further handling in the calling code
        raise e
<p>Finally, we can piece together a RAG function. Our RAG, from query to answer, will follow this flow:</p><ol><li><p>Convert Query to OR Clauses.</p></li><li><p>Generate HyDE document and embed it.</p></li><li><p>Pass both as inputs to Hybrid Search.</p></li><li><p>Retrieve top-n results, reverse them so that the most relevant score is the "most recent" in the LLM's contextual memory (Reverse Packing) Reverse Packing Example: Query: "Elasticsearch query optimization techniques" Retrieved documents (ordered by relevance):  Reversed order for LLM context:  By reversing the order, the most relevant information (1) appears last in the context, potentially receiving more attention from the LLM during answer generation.</p><ol><li><p>"Use bool queries to combine multiple search criteria efficiently."</p></li><li><p>"Implement caching strategies to improve query response times."</p></li><li><p>"Optimize index mappings for faster search performance."</p></li><li><p>"Optimize index mappings for faster search performance."</p></li><li><p>"Implement caching strategies to improve query response times."</p></li><li><p>"Use bool queries to combine multiple search criteria efficiently."</p></li></ol></li><li><p>Pass the context to the LLM for generation.</p></li></ol>def get_context(index_name, 
                match_query, 
                text_query, 
                fields, 
                num_candidates=100, 
                num_results=20, 
                text_fields=["original_text", 'keyphrases', 'potential_questions', 'entities'], 
                embedding_field="primary_embedding"):

    embedding=embedder.get_embeddings_from_text(text_query)

    results, search_body = es_query_maker.hybrid_vector_search(
        index_name=index_name,
        query_text=match_query,
        query_vector=embedding[0][0],
        text_fields=text_fields,
        vector_field=embedding_field,
        num_candidates=num_candidates,
        num_results=num_results
    )

    # Concatenates the text in each 'field' key of the search result objects into a single block of text.
    context_docs=['\n\n'.join([field+":\n\n"+j['_source'][field] for field in fields]) for j in results['hits']['hits']]

    # Reverse Packing to ensure that the highest ranking document is seen first by the LLM.
    context_docs.reverse()
    return context_docs, search_body

def retrieval_augmented_generation(query_text):
    match_query= gpt4o.generate_query(query_text)
    fields=['original_text']

    hyde_document=gpt4o.generate_HyDE(query_text)

    context, search_body=get_context(index_name, match_query, hyde_document, fields)

    answer= gpt4o.basic_qa(query=query_text, context=context)
    return answer, match_query, hyde_document, context, search_body

<p>Let's run our query and get back our answer:</p>According to the context, Elastic N.V. is audited by an independent registered public accounting firm, PricewaterhouseCoopers (PwC). 
This information is found in the section titled "report of independent registered public accounting firm," which states:

"We have audited the accompanying consolidated balance sheets of Elastic N.V. [...] / s / pricewaterhouseco."
<p>Nice. That's correct.</p><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#table-of-contents">Back to top</a></p><h2>Experiments</h2><p>There's an important question to answer now. What did we get out of investing so much effort and additional complexity into these implementations?</p><p>Let's do a little comparison. The RAG pipeline we've implemented versus baseline hybrid search, without any of the enhancements we've made. We'll run a small series of tests and see if we notice any substantial differences. We'll refer to the RAG we have just implemented as AdvancedRAG, and the basic pipeline as SimpleRAG.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf605c8246989df32/6a1711178b73cbc61d18a11d/8da40067835ab8b4dc12fe52a51a6c26858ad32f-1440x1095.jpg" alt="Simple RAG Pipeline" /><h4>Summary of results</h4><p>This table summarizes the results of five tests of both RAG pipelines. I judged the relative superiority of each method based on answer detail and quality, but this is a totally subjective judgement. The actual answers are reproduced below this table for your consideration. With that said, let's take a look at how they did!</p><p>SimpleRAG was unable to answer questions 1 &amp; 5. AdvancedRAG also went into far greater detail on questions 2, 3, and 4. Based on the increased detail, I judged the quality of AdvancedRAG's answers better.</p><p>Test</p><p>Question</p><p>AdvancedRAG Performance</p><p>SimpleRAG Performance</p><p>AdvancedRAG Latency</p><p>SimpleRAG Latency</p><p>Winner</p><p>1</p><p>Who audits Elastic?</p><p>Correctly identified PwC as the auditor.</p><p>Failed to identify the auditor.</p><p>11.6s</p><p>4.4s</p><p>AdvancedRAG</p><p>2</p><p>What was the total revenue in 2023?</p><p>Provided the correct revenue figure. Included additional context with revenue from previous years.</p><p>Provided the correct revenue figure.</p><p>13.3s</p><p>2.8s</p><p>AdvancedRAG</p><p>3</p><p>What product does growth primarily depend on? How much?</p><p>Correctly identified Elastic Cloud as the key driver. Included overall revenue context &amp; greater detail.</p><p>Correctly identified Elastic Cloud as the key driver.</p><p>14.1s</p><p>12.8s</p><p>AdvancedRAG</p><p>4</p><p>Describe employee benefit plan</p><p>Gave a comprehensive description of retirement plans, health programs, and other benefits. Included specific contribution amounts for different years.</p><p>Provided a good overview of benefits, including compensation, retirement plans, work environment, and the Elastic Cares program.</p><p>26.6s</p><p>11.6s</p><p>AdvancedRAG</p><p>5</p><p>Which companies did Elastic acquire?</p><p>Correctly listed recent acquisitions mentioned in the report (CmdWatch, Build Security, Optimyze). Provided some acquisition dates and purchase prices.</p><p>Failed to retrieve relevant information from the provided context.</p><p>11.9s</p><p>2.7s</p><p>AdvancedRAG</p><h4>Test 1: Who audits Elastic?</h4><p>AdvancedRAG</p>According to the context, Elastic N.V. is audited by an independent 
registered public accounting firm, PricewaterhouseCoopers (PwC). 
This information is found in the section titled "report of 
independent registered public accounting firm," which states:
"We have audited the accompanying consolidated balance sheets 
of Elastic N.V. [...] / s / pricewaterhouseco."
<p>SimpleRAG</p>According to the provided context, no specific auditing firm 
or company is mentioned as the auditor for Elastic. 
The context provided talks about various features, strengths, 
deployment options, and other details related to Elastic and 
its operations but does not specify which auditing firm is 
responsible for auditing Elastic's financial statements or 
internal controls.
<p><strong>Summary</strong>: SimpleRAG did not identify PWC as the auditor</p><p>Okay that's actually quite surprising. That looks like a search failure on SimpleRAG's part. No documents related to auditing were retrieved. Let's dial down the difficulty a little with the next test.</p><h4>Test 2: total revenue 2023</h4><p>AdvancedRAG</p>### Total Revenue for 2023

According to the provided context, the total revenue for the 
year ended April 30, 2023, was **$1,068,989,000**. This 
information can be found several times in the provided text. 
Specifically, one part states:

"The following table sets forth our results of operations 
for the periods presented in dollars and as a percentage of 
our total revenue. ... year ended April 30, 2023, ... 
total revenue 1,068,989".

Additionally, it is mentioned here as well:

"For the years ended April 30, 2023, 2022, and 2021, 
representing year-over-year growth of 24% for the year 
ended April 30, 2023... total revenue $1,068,989".

Therefore, the total revenue for 2023 is $1.069 billion.
<p>SimpleRAG</p>According to the context provided, the total revenue for the fiscal year ended April 30, 2023, is:

$1,068,989,000 (or approximately $1.1 billion).
<p><strong>Summary</strong>: Both RAGs got the right answer: $1,068,989,000 total revenue in 2023</p><p>Both of them were right here. It does seem like AdvancedRAG may have acquired a broader range of documents? Certainly the answer is more detailed and incorporates information from previous years. That is to be expected given the enhancements we made, but it's far too early to call.</p><p>Let's raise the difficulty.</p><h4>Test 3: What product does growth primarily depend on? How much?</h4><p>AdvancedRAG</p>### Product Growth Primarily Depends On:
According to the context, "our revenue is derived primarily from 
subscriptions of search, observability and security built into 
the elastic stack." The document mentions various strategies and 
factors contributing to the company’s growth which include investment 
in "elastic cloud," "elastic stack," and the conversion of free 
users to paid subscribers.

### Quantitative Data:
- **Revenue**: The document states, "Our total revenue was $1.1 billion, 
$862.4 million, and $608.5 million for the years ended April 30, 2023, 
2022, and 2021, respectively."
- **Subscription Revenue**: "Subscriptions accounted for 92%, 93%, 
and 93% of our total revenue for the years ended April 30, 2023, 
2022, and 2021, respectively."
- **Elastic Cloud Contribution**: "For the years ended April 30, 2023, 
2022, and 2021, Elastic Cloud contributed 40%, 35%, and 27% of our 
total revenue, respectively."
- **Customer Growth**: The number of customers increased from 
"over 15,000 as of April 30, 2021," to "approximately 20,200 as of 
April 30, 2023."

### Summary:
Product growth for the company primarily depends on the "Elastic Stack" 
(which includes search, observability, and security solutions) and 
particularly the "Elastic Cloud." The Elastic Cloud's growing contribution 
to revenue highlights its significance, which grew from 27% in 2021 to 40% in 2023.
<p>SimpleRAG</p>According to the provided context, growth primarily depends on **Elastic Cloud**, 
which is described as the company's "family of cloud-based offerings." 
The context also emphasizes the significance of Elastic Cloud in achieving 
the company's long-term growth potential and its role in increasing subscription revenue.

From the context:

- "Increasing adoption of Elastic Cloud... is an important growth opportunity 
for our business. Organizations are increasingly looking for hosted 
deployment alternatives with reduced administrative burdens."
- "We believe that offering Elastic Cloud is important for achieving 
our long-term growth potential, and we expect Elastic Cloud’s contribution 
to our subscription revenue to continue to increase."

In terms of the contribution to total revenue, here are the specific figures provided:

- Elastic Cloud contributed 40% of the total revenue for the year ended April 30, 2023.
- Elastic Cloud contributed 35% of the total revenue for the year ended April 30, 2022.
- Elastic Cloud contributed 27% of the total revenue for the year ended April 30, 2021.

This progression shows a clear upward trend in the importance and 
contribution of Elastic Cloud to the company's overall revenue.
<p><strong>Summary</strong>: Both RAGs correctly identified Elastic Cloud as the key growth driver. However, AdvancedRAG includes more detail, factoring in subscription revenues and customer growth, and explicitly mentions other Elastic offerings.</p><h4>Test 4: Describe employee benefit plan</h4><p>AdvancedRAG</p>### Employee Benefit Plan Overview

The provided context outlines various elements of the employee benefit plan at 
Elastic, often referred to as "Elasticians." Here are the key components 
described in the texts:

1. **401(k) Plan**:
   - **Coverage**: The 401(k) plan is available to substantially all U.S. 
   employees who meet minimum age and service requirements.
   - **Contributions**: Elastic makes contributions to the 401(k) plan up to 
   6% of the participating employee’s W-2 earnings and wages.
   - **Expenses**: For the fiscal years ended April 30, Elastic recorded 
   expenses of $17.9 million (2023), $15.2 million (2022), and $11.4 million (2021) 
   related to the 401(k) plan.
   - **Defined-Contribution Plans in Other Countries**: Elastic has 
   defined-contribution plans in various other countries and recorded respective 
   expenses of $9.4 million (2023), $7.2 million (2022), and $5.1 million (2021).

2. **Stock-Based Compensation**:
   - **Types of Awards**: Stock options, restricted stock units (RSUs), 
   and shares under the Employee Stock Purchase Plan (ESPP).
   - **Fair Value Measurement**: Fair value of these stock awards is 
   measured using models like Black-Scholes.
   - **Employee Stock Purchase Plan (2022 ESPP)**: 
     - Started in 2022, it allows employees to acquire ordinary 
     shares at a discount (85% of the market value at the beginning 
     or end of the offering period).
     - Offering periods are approximately six months long.

3. **Total Rewards Compensation**:
   - **Components**: Includes cash compensation as well as equity awards, 
   reflecting a comprehensive interest in the well-being of the employees.
   - **Programs**: Market competitive medical and dental programs, 
   mental health, and holistic well-being programs.
   - **Paid Time Off**: Features 16 weeks of paid leave for new 
   parents and life-planning benefits.

4. **Retirement and Income Protection Plans**:
   - **401(k) Match**: Elastic provides a dollar-for-dollar 
   match up to 6% of eligible earnings up to a plan-limit 
   maximum for U.S.-based employees.
   - **Similar Plans Outside the U.S.**: Competitive retirement 
   plans available for employees based outside the United States.

5. **Flexible Work Environment**: 
   - **Distributed Workforce**: Elastic operates with a global 
   distributed workforce model, allowing employees to work remotely 
   and providing a broad candidate pool.
   
6. **Learning and Development**:
   - **Programs**: Access to on-demand video-based learning, 
   leadership development programs, and other specific training 
   to develop managers and leaders.

7. **Employee Engagement and Surveys**:
   - **Mechanisms**: Annual employee engagement survey and 
   mid-year pulse survey.
   - **Objectives**: To monitor morale, develop action plans, 
   and maintain high engagement scores.

8. **Onboarding Program**:
   - **X-School**: An extensive new-hire orientation program to 
   help new employees meet peers and learn about the company's 
   products and solutions.

In summary, Elastic's employee benefit plan encompasses a broad 
spectrum of financial, health, well-being, and professional development 
benefits designed to support and enhance the lives of its employees. 
The plan is both competitive and comprehensive, aiming to attract,
retain, and engage its global workforce.
<p>SimpleRAG</p>The employee benefit plan at the Company includes several key 
components aimed at enhancing the overall well-being and 
professional development of employees, referred to as "Elasticians." 

1. **Compensation and Benefits:**
   - The Company provides market-competitive compensation 
   packages which typically include cash compensation and equity awards.
   - Comprehensive medical and dental programs are offered, 
   emphasizing mental health and holistic well-being.
   - Paid time off programs feature:
     - 16 weeks of paid leave for new parents.
     - Life-planning benefits.
     - Travel reimbursements for certain healthcare services.

2. **Retirement and Income Protection Plans:**
   - In the United States, employees are eligible for a 401k 
   plan with a dollar-for-dollar match by the Company up to 6% 
   of eligible earnings, subject to a plan limit.
   - Similar competitive retirement and income protection plans 
   are available for employees outside the United States.

3. **Work Environment:**
   - The Company offers a flexible work environment, allowing most 
   employees to work from anywhere as often as they would like.
   - Physical offices around the world are available for employees 
   who prefer to work on-site or need a space for face-to-face interactions.

4. **Community Involvement:**
   - The Elastic Cares Program supports charitable organizations 
   that matter to employees on both local and global levels.
   - The program includes:
     - Donation matching.
     - Providing the Company's technology for free to certain 
     nonprofit organizations.
     - Volunteer time off initiative.
<p><strong>Summary</strong>: AdvancedRAG goes into much greater depth and detail, mentioning the 401K plan for US-based employees, as well as defining contribution plans outside of the US. It also mentions Health and Well-Being plans but misses the Elastic Cares program, which SimpleRAG mentions.</p><h4>Test 5: Which companies did Elastic acquire?</h4><p>AdvancedRAG</p>Elastic acquired several companies over the years to enhance its 
technology and market presence. According to the provided context, 
the specific acquisitions include:

1. **CmdWatch Security Inc.**: Acquired on September 17, 2021. 
The total purchase consideration was $77.8 million.
2. **Build Security Ltd.**: Acquired on September 2, 2021. The 
combined purchase price for Build Security Ltd. and another acquisition was $57.2 million.
3. **Optimyze Cloud Inc.**: Acquired on November 1, 2021. Part 
of the combined purchase price mentioned above with Build Security Ltd.
4. **Endgame, Inc.**: Although the context mentions an amended 
and restated stock incentive plan, it indicates that Endgame, Inc. was 
associated with Elastic, suggesting it could have been acquired at some point.

These acquisitions are part of Elastic's strategy to 
enhance the technology underlying its security and 
observability offerings.
<p>SimpleRAG</p>The provided context does not specify any companies that Elastic has acquired. 
Therefore, based on the context, there is no information available about the companies acquired by Elastic.
<p><strong>Summary</strong>: SimpleRAG does not retrieve any relevant info about acquisitions, leading to a failed answer. AdvancedRAG correctly lists CmdWatch, Build Security, and Optimyze, which were the key acquisitions listed in the report.</p><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#table-of-contents">Back to top</a></p><h2>Conclusion</h2><p>Based on our tests, our advanced techniques appear to increase the range and depth of the information presented, potentially enhancing quality of RAG answers.</p><p>Additionally, there may be improvements in reliability, as ambiguously worded questions such as <code>Which companies did Elastic acquire?</code> and <code>Who audits Elastic</code> were correctly answered by AdvancedRAG but not by SimpleRAG.</p><p>However, it is worth keeping in perspective that in 3 out of 5 cases, the basic RAG pipeline, incorporating Hybrid Search but no other techniques, managed to produce answers that captured most of the key information.</p><p>We should note that due to the incorporation of LLMs at the data preparation and query phases, the latency of AdvancedRAG is generally between 2-5x larger that of SimpleRAG. This is a significant cost which may make AdvancedRAG suitable only for situations where answer quality is prioritized over latency.</p><p>The significant latency costs can be alleviated using a smaller and cheaper LLM like Claude Haiku or GPT-4o-mini at the data preparation stage. Save the advanced models for answer generation.</p><p>This aligns with the findings of Wang et al. As their results show, any improvements made are relatively incremental. In short, simple baseline RAG gets you most of the way to a decent end-product, while being cheaper and faster to boot. For me, it's an interesting conclusion. For use cases where speed and efficiency are key, SimpleRAG is the sensible choice. For use cases where every last drop of performance needs squeezing out, the techniques incorporated into AdvancedRAG may offer a way forward.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt56b7067a9d41d5a8/6a171119acf0886fb4be9c45/ea811706b6adc4731d90b925a9fefa0ac15901b4-1440x1060.jpg" alt="Wang Pipeline" /><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#table-of-contents">Back to top</a></p><h2>Appendix</h2><h3>Prompts</h3><h4>RAG question answering prompt</h4><p>Prompt for getting the LLM to generate answers based on query and context.</p>BASIC_RAG_PROMPT = '''
You are an AI assistant tasked with answering questions based primarily on the provided context, while also drawing on your own knowledge when appropriate. Your role is to accurately and comprehensively respond to queries, prioritizing the information given in the context but supplementing it with your own understanding when beneficial. Follow these guidelines:

1. Carefully read and analyze the entire context provided.
2. Primarily focus on the information present in the context to formulate your answer.
3. If the context doesn't contain sufficient information to fully answer the query, state this clearly and then supplement with your own knowledge if possible.
4. Use your own knowledge to provide additional context, explanations, or examples that enhance the answer.
5. Clearly distinguish between information from the provided context and your own knowledge. Use phrases like "According to the context..." or "The provided information states..." for context-based information, and "Based on my knowledge..." or "Drawing from my understanding..." for your own knowledge.
6. Provide comprehensive answers that address the query specifically, balancing conciseness with thoroughness.
7. When using information from the context, cite or quote relevant parts using quotation marks.
8. Maintain objectivity and clearly identify any opinions or interpretations as such.
9. If the context contains conflicting information, acknowledge this and use your knowledge to provide clarity if possible.
10. Make reasonable inferences based on the context and your knowledge, but clearly identify these as inferences.
11. If asked about the source of information, distinguish between the provided context and your own knowledge base.
12. If the query is ambiguous, ask for clarification before attempting to answer.
13. Use your judgment to determine when additional information from your knowledge base would be helpful or necessary to provide a complete and accurate answer.

Remember, your goal is to provide accurate, context-based responses, supplemented by your own knowledge when it adds value to the answer. Always prioritize the provided context, but don't hesitate to enhance it with your broader understanding when appropriate. Clearly differentiate between the two sources of information in your response.

Context:
[The concatenated documents will be inserted here]

Query:
[The user's question will be inserted here]

Please provide your answer based on the above guidelines, the given context, and your own knowledge where appropriate, clearly distinguishing between the two:
'''
<h4>Elastic query generator prompt</h4><p>Prompt for enriching queries with synonyms and converting them into the OR format.</p>ELASTIC_SEARCH_QUERY_GENERATOR_PROMPT = '''
You are an AI assistant specialized in generating Elasticsearch query strings. Your task is to create the most effective query string for the given user question. This query string will be used to search for relevant documents in an Elasticsearch index.

Guidelines:
1. Analyze the user's question carefully.
2. Generate ONLY a query string suitable for Elasticsearch's match query.
3. Focus on key terms and concepts from the question.
4. Include synonyms or related terms that might be in relevant documents.
5. Use simple Elasticsearch query string syntax if helpful (e.g., OR, AND).
6. Do not use advanced Elasticsearch features or syntax.
7. Do not include any explanations, comments, or additional text.
8. Provide only the query string, nothing else.

For the question "What is Clickthrough Data?", we would expect a response like:
clickthrough data OR click-through data OR click through rate OR CTR OR user clicks OR ad clicks OR search engine results OR web analytics

AND operator is not allowed. Use only OR.

User Question:
[The user's question will be inserted here]

Generate the Elasticsearch query string:
'''
<h4>Potential questions generator prompt</h4><p>Prompt for generating potential questions, enriching document metadata.</p>RAG_QUESTION_GENERATOR_PROMPT = '''
You are an AI assistant specialized in generating questions for Retrieval-Augmented Generation (RAG) systems. Your task is to analyze a given document and create 10 diverse questions that would effectively test a RAG system's ability to retrieve and synthesize information from this document.

Guidelines:
1. Thoroughly analyze the entire document.
2. Generate exactly 10 questions that cover various aspects and levels of complexity within the document's content.
3. Create questions that specifically target:
   a. Key facts and information
   b. Main concepts and ideas
   c. Relationships between different parts of the content
   d. Potential applications or implications of the information
   e. Comparisons or contrasts within the document
4. Ensure questions require answers of varying lengths and complexity, from simple retrieval to more complex synthesis.
5. Include questions that might require combining information from different parts of the document.
6. Frame questions to test both literal comprehension and inferential understanding.
7. Avoid yes/no questions; focus on open-ended questions that promote comprehensive answers.
8. Consider including questions that might require additional context or knowledge to fully answer, to test the RAG system's ability to combine retrieved information with broader knowledge.
9. Number the questions from 1 to 10.
10. Output only the ten questions, without any additional text, explanations, or answers.

Document:
[The document content will be inserted here]

Generate 10 questions optimized for testing a RAG system based on this document:
'''
<h4>HyDE generator prompt</h4><p>Prompt for generating hypothetical documents using HyDE</p>HYDE_DOCUMENT_GENERATOR_PROMPT = '''
You are an AI assistant specialized in generating hypothetical documents based on user queries. Your task is to create a detailed, factual document that would likely contain the answer to the user's question. This hypothetical document will be used to enhance the retrieval process in a Retrieval-Augmented Generation (RAG) system.

Guidelines:
1. Carefully analyze the user's query to understand the topic and the type of information being sought.
2. Generate a hypothetical document that:
   a. Is directly relevant to the query
   b. Contains factual information that would answer the query
   c. Includes additional context and related information
   d. Uses a formal, informative tone similar to an encyclopedia or textbook entry
3. Structure the document with clear paragraphs, covering different aspects of the topic.
4. Include specific details, examples, or data points that would be relevant to the query.
5. Aim for a document length of 200-300 words.
6. Do not use citations or references, as this is a hypothetical document.
7. Avoid using phrases like "In this document" or "This text discusses" - write as if it's a real, standalone document.
8. Do not mention or refer to the original query in the generated document.
9. Ensure the content is factual and objective, avoiding opinions or speculative information.
10. Output only the generated document, without any additional explanations or meta-text.

User Question:
[The user's question will be inserted here]

Generate a hypothetical document that would likely contain the answer to this query:
'''
<h3>Sample hybrid search query</h3>{'knn': {'field': 'primary_embedding',
  'query_vector': [0.4265527129173279,
   -0.1712949573993683,
   -0.042020395398139954,
   ...],
  'k': 100,
  'num_candidates': 100},
 'query': {'bool': {'must': [{'multi_match': {'query': 'audits Elastic Elastic auditing Elastic audit process Elastic compliance Elastic security audit Elasticsearch auditing Elasticsearch compliance Elasticsearch security audit',
      'fields': ['original_text',
       'keyphrases',
       'potential_questions',
       'entities'],
      'type': 'best_fields',
      'operator': 'or'}}],
   'should': [{'script_score': {'query': {'match_all': {}},
      'script': {'source': '\n                                        double vector_score = cosineSimilarity(params.query_vector, params.vector_field) + 1.0;\n                                        double text_score = _score;\n                                        return 0.7 * vector_score + 0.3 * text_score;\n                                        ',
       'params': {'query_vector': [0.4265527129173279,
         -0.1712949573993683,
         -0.042020395398139954,
        ...],
        'vector_field': 'primary_embedding'}}}}]}},
 'size': 10}
]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Han Xiang Choong]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf605c8246989df32/6a1711178b73cbc61d18a11d/8da40067835ab8b4dc12fe52a51a6c26858ad32f-1440x1095.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 15 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Advanced RAG techniques part 1: Data processing]]></title>
    <description><![CDATA[Discussing and implementing techniques which may increase RAG performance. Part 1 of 2, focusing on the data processing and ingestion component of an advanced RAG pipeline.]]></description>
    <content:encoded><![CDATA[<p><em>This is Part 1 of our exploration into Advanced RAG Techniques. </em><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2"><em>Click here for Part 2!</em></a></p><p>The recent paper <a href="https://arxiv.org/abs/2407.01219">Searching for Best Practices in Retrieval-Augmented Generation</a> empirically assesses the efficacy of various RAG enhancing techniques, with the goal of converging on a set of best-practices for RAG.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt671704ff06a4011d/6a170b3ea929cf2d19ae09d8/dafa7250e7c4ead4d9b4aed7c407509131929749-1440x572.png" alt="RAG pipeline recommended by Wang" /><p>We'll implement a few of these proposed best-practices, namely the ones which aim to improve the quality of search <strong>(Sentence Chunking, HyDE, Reverse Packing)</strong>.</p><p>For brevity, we will omit those techniques focused on improving efficiency <strong>(Query Classification and Summarization)</strong>.</p><p>We will also implement a few techniques that were not covered, but which I personally find useful and interesting <strong>(Metadata Inclusion, Composite Multi-Field Embeddings, Query Enrichment)</strong>.</p><p>Finally, we'll run a short test to see if the quality of our search results and generated answers has improved versus the baseline. Let's get to it!</p><h2>RAG overview</h2><p>RAG aims to enhance LLMs by retrieving information from external knowledge bases to enrich generated answers. By providing domain-specific information, LLMs can be quickly adapted for use cases outside the scope of their training data; significantly cheaper than fine-tuning, and easier to keep up-to-date.</p><p>Measures to improve the quality of RAG typically focus on two tracks:</p><ol><li><p>Enhancing the quality and clarity of the knowledge base.</p></li><li><p>Improving the coverage and specificity of search queries.</p></li></ol><p>These two measures will achieve the goal of improving the odds that the LLM has access to relevant facts and information, and is thus less likely to hallucinate or draw upon its own knowledge - which may be outdated or irrelevant.</p><p>The diversity of methods is difficult to clarify in just a few sentences. Let's go straight to implementation to make things clearer.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a4691874a19d8da/6a170b3f47d49c99f22d8a24/72b51ba2ae5e5977b56e5b915674753d6cfd0e56-1440x840.jpg" alt="Advanced RAG pipeline" /><h3>Table of contents</h3><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#overview">Overview</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Table of contents</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#set-up">Set-up</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#ingesting-processing-and-embedding-documents">Ingesting, processing, and embedding documents</a>  </p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#data-ingestion">Data ingestion</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#sentence-level-token-wise-chunking">Sentence-level, token-wise chunking</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#metadata-inclusion-and-generation">Metadata inclusion and generation</a> </p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#keyphrases-extracted-by-textrank">Keyphrases extracted by TextRank</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#potential-questions-generated-by-gpt-4o">Potential questions generated by GPT-4o</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#entities-extracted-by-spacy">Entities extracted by Spacy</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#composite-multi-field-embeddings">Composite multi-field embeddings</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#indexing-to-elastic">Indexing to Elastic</a></p></li></ul></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#cat-break">Cat break</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#appendix">Appendix</a></p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#definitions">Definitions</a></p></li></ul></li></ul><h2>Set-up</h2><p><em>All code may be found </em><a href="https://github.com/elastic/elasticsearch-labs/tree/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques"><em>in the Searchlabs repo</em></a><em>.</em></p><p>First things first. You will need the following:</p><ol><li><p>An Elastic Cloud Deployment</p></li><li><p>An LLM API - We are using a GPT-4o deployment on Azure OpenAI in this notebook</p></li><li><p>Python Version 3.12.4 or later</p></li></ol><p>We will be running all the code from <a href="https://github.com/elastic/elasticsearch-labs/blob/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques/main.ipynb">the main.ipynb notebook.</a></p><p>Go ahead and git clone the repo, navigate to supporting-blog-content/advanced-rag-techniques, then run the following commands:</p># Create a new virtual environment named 'rag_env'
python -m venv rag_env

# Activate the virtual environment (for Unix-based systems)
source rag_env/bin/activate

# (For Windows)
.\rag_env\Scripts\activate

# Install packages listed in requirements.txt
pip install -r requirements.txt
<p>Once that's done, create a <em>.env</em> file and fill out the following fields (Referenced in <a href="https://github.com/elastic/elasticsearch-labs/blob/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques/.env.example"><em>.env.example</em></a>). Credits to my co-author, Claude-3.5, for the helpful comments.</p># Elastic Cloud: Found in the 'Deployment' page of your Elastic Cloud 
# console
ELASTIC_CLOUD_ENDPOINT=""
ELASTIC_CLOUD_ID=""

# Elastic Cloud: Created during deployment setup or in 'Security' 
# settings
ELASTIC_USERNAME=""
ELASTIC_PASSWORD=""

# Elastic Cloud: The name of the index you created in Kibana or via API
ELASTIC_INDEX_NAME=""

# Azure AI Studio: Found in 'Keys and Endpoint' section of your Azure 
# OpenAI resource
AZURE_OPENAI_KEY_1=""
AZURE_OPENAI_KEY_2=""
AZURE_OPENAI_REGION=""
AZURE_OPENAI_ENDPOINT=""

# Azure AI Studio: Found in 'Deployments' section of your Azure OpenAI 
# resource
AZURE_OPENAI_DEPLOYMENT_NAME=""

# Using BAAI/bge-small-en-v1.5 because I think it is a good balance of 
# resource efficiency and performance. 
HUGGINGFACE_EMBEDDING_MODEL="BAAI/bge-small-en-v1.5"
<p>Next, we'll choose the document to ingest, and place it in the documents folder. For this article, we'll be using the <a href="https://s201.q4cdn.com/217177842/files/doc_downloads/OtherDocuments/2023/AnnualMeeting/Annual-Report-Fiscal-Year-2023.pdf">Elastic N.V. Annual Report 2023</a>. It's a pretty challenging and dense document, perfect for stress testing our RAG techniques.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte292dc6030d496cc/6a170b40dc55de9b03e00dfc/e513b9d67adac43da794c25a5969b893127bbbe3-1440x395.jpg" alt="Elastic Annual Report 2023" /><p>Now we're all set, let's go to ingestion. Open <em>main.ipynb</em> and execute the first two cells to import all packages and intialize all services.</p><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Back to top</a></p><h2>Ingesting, processing, and embedding documents</h2><h3>Data ingestion</h3><ul><li><p><em>Personal note: I am stunned by LlamaIndex's convenience. In the olden days before LLMs and LlamaIndex, ingesting documents of various formats was a painful process of collecting esoteric packages from all over. Now it's reduced to a single function call. Wild.</em></p></li></ul><p>The <code>SimpleDirectoryReader</code> will load every document in the <code>directory_path.</code> For <code>.pdf</code> files, it returns a list of document objects, which I convert to Python dictionaries because I find them easier to work with.</p># llamaindex_processor.py
from llama_index.core import SimpleDirectoryReader

class LlamaIndexProcessor:
   def __init__(self):
       pass 
   
   def load_documents(self, directory_path):
       ''' 
       Load all documents in directory
       '''
       reader = SimpleDirectoryReader(input_dir=directory_path)
       return reader.load_data()

# main.ipynb
llamaindex_processor=LlamaIndexProcessor()
documents=llamaindex_processor.load_documents('./documents/')
documents=[dict(doc_obj) for doc_obj in documents]
<p>Each dictionary contains the key content in the <code>text</code> field. It also contains useful metadata such as page number, filename, file size, and type.</p>{
  'id_': '5f76f0b3-22d8-49a8-9942-c2bbab14f63f',
  'metadata': {'page_label': '5',
   'file_name': 'Elastic_NV_Annual-Report-Fiscal-Year-2023.pdf',
   'file_path': '/Users/han/Desktop/Projects/truckasaurus/documents/Elastic_NV_Annual-Report-Fiscal-Year-2023.pdf',
   'file_type': 'application/pdf',
   'file_size': 3724426,
   'creation_date': '2024-07-27',
   'last_modified_date': '2024-07-27'},
   'text': 'Table of Contents\nPage\nPART I\nItem 1. Business 3\n15 Item 1A. Risk Factors\nItem 1B. Unresolved Staff Comments 48\nItem 2. Properties 48\nItem 3. Legal Proceedings 48\nItem 4. Mine Safety Disclosures 48\nPART II\nItem 5. Market for Registrant's Common Equity, Related Stockholder Matters and Issuer Purchases of \nEquity Securities49\nItem 6. [Reserved] 49\nItem 7. Management's Discussion and Analysis of Financial Condition and Results of Operations 50\nItem 7A. Quantitative and Qualitative Disclosures About Market Risk 64\nItem 8. Financial Statements and Supplementary Data 66\nItem 9. Changes in and Disagreements With Accountants on Accounting and Financial Disclosure 100\n100\n101Item 9A. Controls and Procedures\nItem 9B. Other Information\nItem 9C. Disclosure Regarding Foreign Jurisdictions That Prevent Inspections 101\nPART III\n102\n102\n102\n102Item 10. Directors, Executive Officers and Corporate Governance\nItem 11. Executive Compensation\nItem 12. Security Ownership of Certain Beneficial Owners and Management, and Related Stockholder Matters  \nItem 13. Certain Relationships and Related Transactions, and Director Independence\nItem 14. Principal Accountant Fees and Services 102\nPART IV\n103\n105Item 15. Exhibits and Financial Statement Schedules  \nItem 16. Form 10-K Summary\nSignatures 106\ni',
   ...
}
<p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Back to top</a></p><h3>Sentence-level, token-wise chunking</h3><p>The first thing to do is reduce our documents to chunks of a standard length (to ensure consistency and manageability). Embedding models have unique token limits (maximum input size they can process). Tokens are the basic units of text that models process. To prevent information loss (truncation or omission of content), we should provide text that does not exceed those limits (by splitting longer texts into smaller segments).</p><p>Chunking has a significant impact on performance. Ideally, each chunk would represent a self-contained piece of information, capturing contextual information about a single topic. Chunking methods include word-level chunking, where documents are split by word count, and semantic chunking which uses an LLM to identify logical breakpoints.</p><p>Word-level chunking is cheap, fast, and easy, but runs a risk of splitting sentences and thus breaking context. Semantic chunking gets slow and expensive, especially if you're dealing with documents like the 116-page Elastic Annual Report.</p><p>Let's choose a middleground approach. Sentence level chunking is still simple, but can preserve context more effectively than word-level chunking while being significantly cheaper and faster. Additionally, we'll implement a sliding window to capture some of the surrounding context, and alleviate the impact of splitting paragraphs.</p># chunker.py 

import uuid
import re


class Chunker: 
    def __init__(self, tokenizer):
        self.tokenizer = tokenizer 
    
    def split_into_sentences(self, text):
        """Split text into sentences."""
        return re.split(r'(?&lt;=[.!?])\s+', text)
 
    def sentence_wise_tokenized_chunk_documents(self, documents, chunk_size=512, overlap=20, min_chunk_size=50):
        '''
        1. Split text into sentences.
        2. Tokenize using the provided tokenizer method.
        3. Build chunks up to the chunk_size limit.
        4. Create an overlap based on tokens - to preserve context.
        5. Only keep chunks that meet the minimum token size requirement.
        '''
        chunked_documents = []

        for doc in documents:
            sentences = self.split_into_sentences(doc['text'])
            tokens = []
            sentence_boundaries = [0]

            # Tokenize all sentences and keep track of sentence boundaries
            for sentence in sentences:
                sentence_tokens = self.tokenizer.encode(sentence, add_special_tokens=True)
                tokens.extend(sentence_tokens)
                sentence_boundaries.append(len(tokens))

            # Create chunks
            chunk_start = 0
            while chunk_start &lt; len(tokens):
                chunk_end = chunk_start + chunk_size

                # Find the last complete sentence that fits in the chunk
                sentence_end = next((i for i in sentence_boundaries if i &gt; chunk_end), len(tokens))
                chunk_end = min(chunk_end, sentence_end)

                # Create the chunk
                chunk_tokens = tokens[chunk_start:chunk_end]

                # Check if the chunk meets the minimum size requirement
                if len(chunk_tokens) &gt;= min_chunk_size:
                    # Create a new document object for this chunk
                    chunk_doc = {
                        'id_': str(uuid.uuid4()),
                        'chunk': chunk_tokens,
                        'original_text': self.tokenizer.decode(chunk_tokens),
                        'chunk_index': len(chunked_documents),
                        'parent_id': doc['id_'],
                        'chunk_token_count': len(chunk_tokens)
                    }

                    # Copy all other fields from the original document
                    for key, value in doc.items():
                        if key != 'text' and key not in chunk_doc:
                            chunk_doc[key] = value

                    chunked_documents.append(chunk_doc)

                # Move to the next chunk start, considering overlap
                chunk_start = max(chunk_start + chunk_size - overlap, chunk_end - overlap)

        return chunked_documents

# main.ipynb 
# Initialize Embedding Model
HUGGINGFACE_EMBEDDING_MODEL = os.environ.get('HUGGINGFACE_EMBEDDING_MODEL')
embedder=EmbeddingModel(model_name=HUGGINGFACE_EMBEDDING_MODEL)

# Initialize Chunker
chunker=Chunker(embedder.tokenizer)
<p>The <code>Chunker</code> class takes in the embedding model's tokenizer to encode and decode text. We'll now build chunks of 512 tokens each, with an overlap of 20 tokens. To do this, we'll split the text into sentences, tokenize those sentences, and then add the tokenized sentences to our current chunk until we cannot add more without breaching our token limit.</p><p>Finally, decode the sentences back to the original text for embedding, storing it in a field called <code>original_text</code>. Chunks are stored in a field called <code>chunk</code>. To reduce noise (aka useless documents), we will discard any documents smaller than 50 tokens in length.</p><p>Let's run it over our documents:</p>chunked_documents=chunker.sentence_wise_tokenized_chunk_documents(documents, chunk_size=512)
<p>And get back chunks of text that look like this:</p>print(chunked_documents[4]['original_text'])

[CLS] the aggregate market value of the ordinary shares held by non - affiliates of the registrant, 
based on the closing price of the shares of ordinary shares on the new york stock exchange on 
october 31, 2022 ( the last business day of the registrant 's second fiscal quarter ), was 
approximately $ 6. 1 billion. [SEP] [CLS] as of may 31, 2023, the registrant had 97, 390, 886 
ordinary shares, par value €0. 01 per share, outstanding. [SEP] [CLS] documents incorporated by 
reference portions of the registrant 's definitive proxy statement relating to the registrant 's 2
023 annual general meeting of shareholders are incorporated by reference into part iii of this annual 
...
...
<p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Back to top</a></p><h3>Metadata inclusion and generation</h3><p>We've chunked our documents. Now it's time to enrich the data. I want to generate or extract additional metadata. This additional metadata can be used to influence and enhance search performance.</p><p>We'll define a <code>DocumentEnricher</code> class, whose role is to take in a list of documents (Python dictionaries), and a list of processor functions. These functions will run over the documents' <code>original_text</code> column, and store their outputs in new fields.</p><p>First, we extract keyphrases using <a href="https://github.com/elastic/elasticsearch-labs/blob/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques/nltk_processor.py">TextRank</a>. TextRank is a graph-based algorithm that extracts key phrases and sentences from text by ranking their importance based on the relationships between words.</p><p>Next, we'll <a href="https://github.com/elastic/elasticsearch-labs/blob/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques/llm.py">generate potential_questions using GPT-4o</a>.</p><p>Finally, we'll <a href="https://github.com/elastic/elasticsearch-labs/blob/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques/entity_extractor.py">extract entities</a> using <a href="https://spacy.io/">Spacy</a>.</p><p>Since the code for each of these is quite lengthy and involved, I will refrain from reproducing it here. If you are interested, the files are marked in the code samples below.</p><p>Let's run the data enrichment:</p># documentenricher.py
from tqdm import tqdm

class DocumentEnricher:

    def __init__(self):
        pass 

    def enrich_document(self, documents, processors, text_col='text'):
        for doc in tqdm(documents, desc="Enriching documents using processors: "+str(processors)): 
            for (processor, field) in processors: 
                metadata=processor(doc[text_col])
                if isinstance(metadata, list):
                    metadata='\n'.join(metadata)
                doc.update({field: metadata})
 
# main.ipynb
# Initialize processor classes 
nltkprocessor=NLTKProcessor() // nltk_processor.py
entity_extractor=EntityExtractor() // entity_extractor.py
gpt4o = LLMProcessor(model='gpt-4o') // llm.py

# Initialize LLM
documentenricher=DocumentEnricher()

# Create new fields in the documents - These are the outputs of the processor functions.
processors=[
    (nltkprocessor.textrank_phrases, "keyphrases"),
    (gpt4o.generate_questions, "potential_questions"),
    (entity_extractor.extract_entities, "entities")
    ]

# .enrich_document() will modify chunked_docs in place. 
# To view the results, we'll print chunked_docs in the next few cells!
documentenricher.enrich_document(chunked_docs, text_col='original_text', processors=processors)
<p>And take a look at the results:</p><h4>Keyphrases extracted by TextRank</h4><p>These keyphrases are a stand-in for the chunk's core topics. If a query has to do with cybersecurity, this chunk's score will be boosted.</p>print(chunked_documents[25]['keyphrases'])

'elastic agent stop', 'agent stop malware', 
'stop malware ransomware', 'malware ransomware environment', 
'ransomware environment wide', 'environment wide visibility', 
'wide visibility threat', 'visibility threat detection', 
'sep cl key', 'cl key feature'
<h4>Potential questions generated by GPT-4o</h4><p>These potential questions may directly match with user queries, offering a boost in score. We prompt GPT-4o to generate questions which can be answered using the information found in the current chunk.</p>print(chunked_documents[25]['potential_questions'])

1. What are the primary functions that Elastic Agent provides in terms of cybersecurity?
2. Describe how Logstash contributes to data management within an IT environment.
3. List and explain any key features of Logstash mentioned in the document.
4. How does Elastic Agent enhance environment-wide visibility in threat detection?
5. What capabilities does Logstash offer for handling data beyond simple collection?
6. In what ways does the document suggest that Elastic Agent stops malware and ransomware?
7. Can you identify any relationships between the functionalities of Elastic Agent and Logstash in an integrated environment?
8. What implications might the advanced threat detection capabilities of Elastic Agent have for organizational security policies?
9. Compare and contrast the roles of Elastic Agent and Logstash based on their described functions.
10. How might the centralized collection ability of Logstash support the threat detection capabilities of Elastic Agent?
<h4>Entities extracted by Spacy</h4><p>These entities serve a similar purpose to the keyphrases, but capture organizations' and individuals' names, which keyphrase extraction may miss.</p>print(chunked_documents[29]['entities'])

'appdynamics', 'apm data', 'azure sentinel', 
'microsoft', 'mcafee', 'broadcom', 'cisco', 
'dynatrace', 'coveo', 'lucidworks'
<p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Back to top</a></p><h3>Composite multi-field embeddings</h3><p>Now that we have enriched our documents with additional metadata, we can leverage this information to create more robust and context-aware embeddings.</p><p>Let's review our current point in the process. We've got four fields of interest in each document.</p>{
    "chunk": "...",
    "keyphrases": "...", 
    "potential_questions": "...", 
    "entities": "..." 
}
<p>Each field represents a different perspective on the document's context, potentially highlighting a key area for the LLM to focus on.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt84cb328fce6aae23/6a170b42964cea3e4408bbc4/aea1f513009a0c7c8545a79fad8f072a5bcae24c-1440x1067.jpg" alt="Metadata Enrichment Pipeline in RAG" /><p>The plan is to embed each of these fields, and then create a weighted sum of the embeddings, known as a Composite Embedding.</p><p>With luck, this Composite Embedding will allow the system to become more context aware, in addition to introducing another tunable hyperparameter from controlling the search behavior.</p><p>First, let's embed each field and update each document in place, using our locally defined embedding model imported at the beginning of the main.ipynb notebook.</p># EmbeddingModel defined in embedding_model.py
embedder=EmbeddingModel(model_name=HUGGINGFACE_EMBEDDING_MODEL)

cols_to_embed=['keyphrases', 'potential_questions', 'entities']

embedding_cols=[]
for col in cols_to_embed:
    # Works on text input
    embedding_col=embedder.embed_documents_text_wise(chunked_documents, text_field=col)
    embedding_cols.append(embedding_col)
# Works on token input
embedding_col=embedder.embed_documents_token_wise(chunked_documents, token_field="chunk")
embedding_cols.append(embedding_col)
<p>Each embedding function returns the embedding's field, which is just the original input field with an <code>_embedding</code> postfix.</p><p>Let's now define the weightings of our composite embedding:</p>embedding_cols=[
                'keyphrases_embedding',
                'potential_questions_embedding',
                'entities_embedding',
                'chunk_embedding']
combination_weights=[
                    0.1,
                    0.15,
                    0.05,
                    0.7
                ]
<p>The weightings allow you to assign priorities to each component, based on your usecase and the quality of your data. Intuitively, the size of these weightings is dependent on the semantic value of each component. Since the chunk text itself is by far the richest, I assign a weighting of 70%. Since the entities are the smallest, being just a list of org or person names, I assign it a weighting of 5%. The precise setting for these values has to be determined empirically, on a use-case by use-case basis.</p><p>Finally, let's write a function to apply the weightings, and create our composite embedding. We'll delete all the component embeddings as well to save space.</p>from tqdm import tqdm 
def combine_embeddings(objects, embedding_cols, combination_weights, primary_embedding='primary_embedding'):
    # Ensure the number of weights matches the number of embedding columns
    assert len(embedding_cols) == len(combination_weights), "Number of embedding columns must match number of weights"
    
    # Normalize weights to sum to 1
    weights = np.array(combination_weights) / np.sum(combination_weights)
    
    for obj in tqdm(objects, desc="Combining embeddings"):
        # Initialize the combined embedding
        combined = np.zeros_like(obj[embedding_cols[0]])
        
        # Compute the weighted sum
        for col, weight in zip(embedding_cols, weights):
            combined += weight * np.array(obj[col])
        
        # Add the new combined embedding to the object
        obj.update({primary_embedding:combined.tolist()})
        
        # Remove the original embedding columns
        for col in embedding_cols:
            obj.pop(col, None)

combine_embeddings(chunked_documents, embedding_cols, combination_weights)
<p>With this, we've completed our document processing. We now have a list of document objects which look like this:</p>{ 'id_': '7fe71686-5cd0-4831-9e79-998c6dbeae0c', 'chunk': [2312, 14613, ...], 'original_text': 'if an emerging growth company, indicate by check mark if the registrant has elected not to use the extended ...', 'chunk_index': 3, 'chunk_token_count': 399, 'metadata': {'page_label': '3', 'file_name': 'Elastic_NV_Annual-Report-Fiscal-Year-2023.pdf', ... 'keyphrases': 'sep cl unk\ncheck mark registrant\ncl unk indicate\nunk indicate check\nindicate check mark\nprincipal executive office\naccelerate filer unk\ncompany unk emerge\nunk emerge growth\nemerge growth company', 'potential_questions': '1. What are the different types of registrant statuses mentioned in the document?\n2. Under what section of the Sarbanes-Oxley Act must registrants file a report on the effectiveness of their internal ...', 'entities': 'the effe ctiveness of\nsection 13\nSEP\nUNK\nsection 21e\n1934\n1933\nu. s. c.\nsection 404\nsection 12\nal', 'primary_embedding': [-0.3946287803351879, -0.17586839850991964, ...] }
<h4>Indexing to Elastic</h4><p>Let's bulk upload our documents to Elastic Search. For this purpose, I long-ago defined a set of Elastic Helper functions in <a href="https://github.com/elastic/elasticsearch-labs/blob/advanced-rag-techniques/supporting-blog-content/advanced-rag-techniques/elastic_helpers.py"><code>elastic_helpers.py</code></a>. It is a very lengthy piece of code so let's sticking to looking at the function calls.</p><p><code>es_bulk_indexer.bulk_upload_documents</code> works with any list of dictionary objects, taking advantage of Elasticsearch's convenient dynamic mappings.</p># Initialize Elasticsearch
ELASTIC_CLOUD_ID = os.environ.get('ELASTIC_CLOUD_ID')
ELASTIC_USERNAME = os.environ.get('ELASTIC_USERNAME')
ELASTIC_PASSWORD = os.environ.get('ELASTIC_PASSWORD')
ELASTIC_CLOUD_AUTH = (ELASTIC_USERNAME, ELASTIC_PASSWORD)
es_bulk_indexer = ESBulkIndexer(cloud_id=ELASTIC_CLOUD_ID, credentials=ELASTIC_CLOUD_AUTH)
es_query_maker = ESQueryMaker(cloud_id=ELASTIC_CLOUD_ID, credentials=ELASTIC_CLOUD_AUTH)

# Define Index Name
index_name=os.environ.get('ELASTIC_INDEX_NAME')


# Create index and bulk upload 
index_exists = es_bulk_indexer.check_index_existence(index_name=index_name)
if not index_exists:
    logger.info(f"Creating new index: {index_name}")
    es_bulk_indexer.create_es_index(es_configuration=BASIC_CONFIG, index_name=index_name)

success_count = es_bulk_indexer.bulk_upload_documents(
    index_name=index_name, 
    documents=chunked_documents, 
    id_col='id_',
    batch_size=32
)
<p>Head on over to Kibana and verify that all documents have been indexed. There should be 224 of them. Not bad for such a large document!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8efeface6effe01d/6a170b447d8d67652870e72a/1b3b07f6b98ceb65f6594ce4be83c5b0ed7e7cf9-1440x1380.jpg" alt="Index Kibana" /><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Back to top</a></p><h2>Cat break</h2><p>Let's take a break, article's a little heavy, I know. Check out my cat:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc1db5595f71c12ff/6a170b450e2e49940241a0fe/baca4eb52b801b21ced97352cc55462f0a12d6b0-969x996.jpg" alt="Han Pipeline" /><p>Adorable. The hat went missing and I half suspect she stole and hid it somewhere :(</p><p>Congrats on making it this far :)</p><p>Join me in <a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2">Part 2</a> for testing and evaluation of our RAG pipeline!</p><h2>Appendix</h2><h3>Definitions</h3><p><strong>1. Sentence Chunking</strong></p><ul><li><p>A preprocessing technique used in RAG systems to divide text into smaller, meaningful units.</p></li><li><p><em>Process:</em> </p><ol><li><p>Input: Large block of text (e.g., document, paragraph)</p></li><li><p>Output: Smaller text segments (typically sentences or small groups of sentences)</p></li></ol></li><li><p><em>Purpose:</em> </p><ul><li><p>Creates granular, context-specific text segments</p></li><li><p>Allows for more precise indexing and retrieval</p></li><li><p>Improves the relevance of retrieved information in RAG systems</p></li></ul></li><li><p><em>Characteristics:</em> </p><ul><li><p>Segments are semantically meaningful</p></li><li><p>Can be independently indexed and retrieved</p></li><li><p>Often preserves some context to ensure standalone comprehensibility</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Enhances retrieval precision</p></li><li><p>Enables more focused augmentation in RAG pipelines</p></li></ul></li></ul><p><strong>2. HyDE (Hypothetical Document Embedding)</strong></p><ul><li><p>A technique that uses an LLM to generate a hypothetical document for query expansion in RAG systems.</p></li><li><p><em>Process:</em>  </p><ol><li><p>Input query to an LLM</p></li><li><p>LLM generates a hypothetical document answering the query</p></li><li><p>Embed the generated document</p></li><li><p>Use the embedding for vector search</p></li></ol></li><li><p><em>Key difference:</em> </p><ul><li><p>Traditional RAG: Matches query to documents</p></li><li><p>HyDE: Matches documents to documents</p></li></ul></li><li><p><em>Purpose:</em> </p><ul><li><p>Improve retrieval performance, especially for complex or ambiguous queries</p></li><li><p>Capture richer semantic context than a short query</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Leverages LLM's knowledge to expand queries</p></li><li><p>Can potentially improve relevance of retrieved documents</p></li></ul></li><li><p><em>Challenges:</em> </p><ul><li><p>Requires additional LLM inference, increasing latency and cost</p></li><li><p>Performance depends on quality of generated hypothetical document</p></li></ul></li></ul><p><strong>3. Reverse Packing</strong></p><ul><li><p>A technique used in RAG systems to reorder search results before passing them to the LLM.</p></li><li><p><em>Process:</em> </p><ol><li><p>Search engine (e.g., Elasticsearch) returns documents in descending order of relevance.</p></li><li><p>The order is reversed, placing the most relevant document last.</p></li></ol></li><li><p><em>Purpose:</em> </p><ul><li><p>Exploits the recency bias of LLMs, which tend to focus more on the latest information in their context.</p></li><li><p>Ensures the most relevant information is "freshest" in the LLM's context window.</p></li></ul></li><li><p><em>Example:</em> Original order: [Most Relevant, Second Most, Third Most, ...] Reversed order: [..., Third Most, Second Most, Most Relevant]</p></li></ul><p><strong>4. Query Classification</strong></p><ul><li><p>A technique to optimize RAG system efficiency by determining whether a query requires RAG or can be answered directly by the LLM.</p></li><li><p><em>Process:</em> </p><ol><li><p>Develop a custom dataset specific to the LLM in use</p></li><li><p>Train a specialized classification model</p></li><li><p>Use the model to categorize incoming queries</p></li></ol></li><li><p><em>Purpose:</em> </p><ul><li><p>Improve system efficiency by avoiding unnecessary RAG processing</p></li><li><p>Direct queries to the most appropriate response mechanism</p></li></ul></li><li><p><em>Requirements:</em> </p><ul><li><p>LLM-specific dataset and model</p></li><li><p>Ongoing refinement to maintain accuracy</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Reduces computational overhead for simple queries</p></li><li><p>Potentially improves response time for non-RAG queries</p></li></ul></li></ul><p><strong>5. Summarization</strong></p><ul><li><p>A technique to condense retrieved documents in RAG systems.</p></li><li><p><em>Process:</em> </p><ol><li><p>Retrieve relevant documents</p></li><li><p>Generate concise summaries of each document</p></li><li><p>Use summaries instead of full documents in the RAG pipeline</p></li></ol></li><li><p><em>Purpose:</em> </p><ul><li><p>Improve RAG performance by focusing on essential information</p></li><li><p>Reduce noise and interference from less relevant content</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Potentially improves relevance of LLM responses</p></li><li><p>Allows for inclusion of more documents within context limits</p></li></ul></li><li><p><em>Challenges:</em> </p><ul><li><p>Risk of losing important details in summarization</p></li><li><p>Additional computational overhead for summary generation</p></li></ul></li></ul><p><strong>6. Metadata Inclusion</strong></p><ul><li><p>A technique to enrich documents with additional contextual information.</p></li><li><p><em>Types of metadata:</em>  </p><ul><li><p>Keyphrases</p></li><li><p>Titles</p></li><li><p>Dates</p></li><li><p>Authorship details</p></li><li><p>Blurbs</p></li></ul></li><li><p><em>Purpose:</em> </p><ul><li><p>Increase contextual information available to the RAG system</p></li><li><p>Provide LLMs with clearer understanding of document content and relevance</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Potentially improves retrieval accuracy</p></li><li><p>Enhances LLM's ability to assess document usefulness</p></li></ul></li><li><p><em>Implementation:</em> </p><ul><li><p>Can be done during document preprocessing</p></li><li><p>May require additional data extraction or generation steps</p></li></ul></li></ul><p><strong>7. Composite Multi-Field Embeddings</strong></p><ul><li><p>An advanced embedding technique for RAG systems that creates separate embeddings for different document components.</p></li><li><p><em>Process:</em> </p><ol><li><p>Identify relevant fields (e.g., title, keyphrases, blurb, main content)</p></li><li><p>Generate separate embeddings for each field</p></li><li><p>Combine or store these embeddings for use in retrieval</p></li></ol></li><li><p><em>Difference from standard approach:</em> </p><ul><li><p>Traditional: Single embedding for entire document</p></li><li><p>Composite: Multiple embeddings for different document aspects</p></li></ul></li><li><p><em>Purpose:</em> </p><ul><li><p>Create more nuanced and context-aware document representations</p></li><li><p>Capture information from a wider variety of sources within a document</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Potentially improves performance on ambiguous or multi-faceted queries</p></li><li><p>Allows for more flexible weighting of different document aspects in retrieval</p></li></ul></li><li><p><em>Challenges:</em> </p><ul><li><p>Increased complexity in embedding storage and retrieval processes</p></li><li><p>May require more sophisticated matching algorithms</p></li></ul></li></ul><p><strong>8. Query Enrichment</strong></p><ul><li><p>A technique to expand the original query with related terms to improve search coverage.</p></li><li><p><em>Process:</em> </p><ol><li><p>Analyze the original query</p></li><li><p>Generate synonyms and semantically related phrases</p></li><li><p>Augment the query with these additional terms</p></li></ol></li><li><p><em>Purpose:</em> </p><ul><li><p>Increase the range of potential matches in the document corpus</p></li><li><p>Improve retrieval performance for queries with specific or technical language</p></li></ul></li><li><p><em>Benefits:</em> </p><ul><li><p>Potentially retrieves relevant documents that don't exactly match the original query terms</p></li><li><p>Can help overcome vocabulary mismatch between queries and documents</p></li></ul></li><li><p><em>Challenges:</em> </p><ul><li><p>Risk of query drift if not carefully implemented</p></li><li><p>May increase computational overhead in the retrieval process</p></li></ul></li></ul><p><a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1#table-of-contents">Back to top</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-1</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Han Xiang Choong]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a4691874a19d8da/6a170b3f47d49c99f22d8a24/72b51ba2ae5e5977b56e5b915674753d6cfd0e56-1440x840.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 14 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch geospatial search with ES|QL]]></title>
    <description><![CDATA[Geospatial search in Elasticsearch Query Language (ES|QL). Elasticsearch has powerful geospatial search features, which are now coming to ES|QL for dramatically improved ease of use and OGC familiarity.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch has had powerful <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/geospatial-analysis.html">geospatial search and analytics capabilities</a> for many years, but the API was quite different from what typical GIS users were used to. In the past year we've <a href="https://www.elastic.co/search-labs/blog/esql-piped-query-language-goes-ga">added the ES|QL query language</a>, a piped query language as easy, or even easier, than SQL. It's particularly suited to the search, security, and observability use cases Elastic excels at. We're also adding support for geospatial search and analytics within ES|QL, making it far easier to use, especially for users coming from SQL or <a href="https://en.wikipedia.org/wiki/Geographic_information_system">GIS</a> communities.</p><p>Elasticsearch 8.12 and 8.13 brought basic support for geospatial types to ES|QL. This was dramatically enhanced with the addition of geospatial search capabilities in 8.14. More importantly, this support was designed to conform closely to the <a href="https://en.wikipedia.org/wiki/Simple_Features">Simple Feature Access</a> standard from the <a href="https://en.wikipedia.org/wiki/Open_Geospatial_Consortium">Open Geospatial Consortium (OGC)</a> used by other spatial databases like PostGIS, making it much easier to use for GIS experts familiar with these standards.</p><p>In this blog, we'll show you how to use ES|QL to perform geospatial searches, and how it compares to the SQL and Query DSL equivalents. We'll also show you how to use ES|QL to perform spatial joins, and how to visualize the results in Kibana Maps. Note that all the features described here are in "technical preview", and we'd love to hear your feedback on how we can improve them.</p><h2>Searching for geospatial data</h2><p>Let's start with an example query:</p>FROM airport_city_boundaries
| WHERE ST_INTERSECTS(
      city_boundary,
      "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
  )
| KEEP abbrev, airport, region, city, city_location
<p>This performs a search for any city boundary polygons that intersect with a rectangular search polygon around the Sanya Phoenix International Airport (SYX).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3897df6bed5d6061/6a17d7c2abe0f29eccdfe861/e48bac8f246c8842f2ea97ddd54910045262aeb1-1440x808.png" alt="ESQL Geospatial Search" /><p>In a sample dataset of airports, cities and city boundaries, this search finds the intersecting polygon and returns the desired fields from the matching document:</p><p>abbrev</p><p>airport</p><p>region</p><p>city</p><p>city_location</p><p>SYX</p><p>Sanya Phoenix Int'l</p><p>天涯区</p><p>Sanya</p><p>POINT(109.5036 18.2533)</p><p>That was easy! Now compare this to the classic Elasticsearch Query DSL for the same query:</p>GET /airport_city_boundaries/_search
{
  "_source": ["abbrev", "airport", "region", "city", "city_location"],
  "query": {
    "geo_shape": {
      "city_boundary": {
        "shape": {
          "type": "polygon",
          "coordinates" : [[
            [109.4, 18.1],
            [109.6, 18.1],
            [109.6, 18.3],
            [109.4, 18.3],
            [109.4, 18.1]
          ]]
        }
      }
    }
  }
}
<p>Both queries are reasonably clear in their intent, but the ES|QL query closely resembles SQL. The same query in PostGIS looks like this:</p>SELECT abbrev, airport, region, city, city_location
FROM airport_city_boundaries
WHERE ST_INTERSECTS(
    city_boundary,
    'SRID=4326;POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'::geometry
);
<p>Look back at the ES|QL example. So similar, right?</p>FROM airport_city_boundaries
| WHERE ST_INTERSECTS(
      city_boundary,
      "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
  )
| KEEP abbrev, airport, region, city, city_location
<p>We've found that existing users of the Elasticsearch API find ES|QL much easier to use. We now expect that existing SQL users, particularly Spatial SQL users, will find that ES|QL feels very familiar to what they are used to seeing.</p><h4>Why not SQL?</h4><p>What about Elasticsearch SQL? It has been around for a while and has some geospatial features. However, Elasticsearch SQL was written as a wrapper on top of the original Query API, which meant only queries that could be transpiled down to the original API were supported. ES|QL does not have this limitation. Being a completely new stack allows for many optimizations that were not possible in SQL. Our benchmarks show ES|QL is <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/esql/nightly/default/6M">very often faster than the Query API</a>, particularly with aggregations!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt18bda964c8b24e36/6a17d7c3e3179155d22d568a/b8b6c2b2e45850d832805ed1e71e522f4955f53c-1440x813.png" alt="polygon-intersection-benchmark" /><h2>Differences to SQL</h2><p>Clearly, from the previous example, ES|QL is somewhat similar to SQL, but there are some important differences. For example, ES|QL is a piped query language, starting with a source command like FROM and then chaining all subsequent commands together with the pipe | character. This makes it very easy to understand how each command receives a table of data and performs some action on that table, such as filtering with <code>WHERE</code>, adding columns with <code>EVAL</code>, or performing aggregations with <code>STATS</code>. Rather than starting with <code>SELECT</code> to define the final output columns, there can be one or more <code>KEEP</code> commands, with the last one specifying the final output results. This structure simplifies reasoning about the query.</p><p>Focusing in on the <code>WHERE</code> command in the above example, we can see it looks quite similar to the PostGIS example:</p><p><em>ES|QL</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
)
<p><em>PostGIS</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    'SRID=4326;POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'::geometry
)
<p>Aside from the difference in string quotation characters, the biggest difference is in how we type-cast the string to a spatial type. In PostGIS, we use the <code>::geometry</code> suffix, while in ES|QL, we use the <code>::geo_shape</code> suffix. This is because ES|QL runs within Elasticsearch, and the type-casting operator <code>::</code> can be used to convert a string to any of the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-limitations.html#_supported_types">supported ES|QL types</a>, in this case, a <code>geo_shape</code>. Additionally, the <code>geo_shape</code> and <code>geo_point</code> types in Elasticsearch imply the spatial coordinate system known as WGS84, more commonly referred to using the SRID number 4326. In PostGIS, this needs to be explicit, hence the use of the <code>SRID=4326;</code> prefix to the WKT string. If that prefix is removed, the SRID will be set to 0, which is more like the Elasticsearch types <code>cartesian_point</code> and <code>cartesian_shape</code>, which are not tied to any specific coordinate system.</p><p>Both ES|QL and PostGIS provide type conversion function syntax as well:</p><p><em>ES|QL</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    TO_GEOSHAPE("POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))")
)
<p><em>PostGIS</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    ST_SetSRID(
      ST_GeomFromText('POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'),
      4326
    )
)
<h2>OGC functions</h2><p>Elasticsearch 8.14 introduces the following four OGC spatial search functions:</p><p>ES|QL</p><p>PostGIS</p><p>Description</p><p>ST_INTERSECTS</p><p>ST_Intersects</p><p>Returns true if two geometries intersect, and false otherwise.</p><p>ST_DISJOINT</p><p>ST_Disjoint</p><p>Returns true if two geometries do not intersect, and false otherwise. The inverse of ST_INTERSECTS.</p><p>ST_CONTAINS</p><p>ST_Contains</p><p>Returns true if one geometry contains another, and false otherwise.</p><p>ST_WITHIN</p><p>ST_Within</p><p>Returns true if one geometry is within another, and false otherwise. The inverse of ST_CONTAINS.</p><p>These function behave similarly to their PostGIS counterparts, and are used in the same way. For example, <code>ST_INTERSECTS</code> returns true if two geometries intersect and false otherwise. If you follow the documentation links in the above table, you might notice that all the ES|QL examples are within a <code>WHERE</code> clause after a <code>FROM</code> clause, while all the PostGIS examples are using literal geometries. In fact, both platforms support using the functions in any part of the query where they make sense.</p><p>The first example in the PostGIS documentation for <code>ST_INTERSECTS</code> is:</p>SELECT ST_Intersects(
    'POINT(0 0)'::geometry,
    'LINESTRING ( 2 0, 0 2 )'::geometry
);
<p>The ES|QL equivalent of this would be:</p>ROW ST_INTERSECTS(
    "POINT(0 0)"::geo_point,
    "LINESTRING ( 2 0, 0 2 )"::geo_shape
)
<p>Note how we did not specify the SRID in the PostGIS example. This is because in PostGIS when using the <code>geometry</code> type, all calculations are done on a planar coordinate system, and so if both geometries have the same SRID, it does not matter what the SRID is. In Elasticsearch, this is also true for most functions, however, there are exceptions where <code>geo_shape</code> and <code>geo_point</code> use spherical calculations, as we'll see in the next blog about spatial distance search.</p><h2>ES|QL versatility</h2><p>So, we've seen examples above for using spatial functions in <code>WHERE</code> clauses, and in <code>ROW</code> commands. Where else would they make sense? One very useful place is in the <code>EVAL</code> command. This command allows you to evaluate an expression and return the result. For example, let's determine if the centroids of all airports grouped by their country names are within a boundary outlining the country:</p>FROM airports
| EVAL in_uk = ST_INTERSECTS(location, TO_GEOSHAPE("POLYGON((1.2305 60.8449, -1.582 61.6899, -10.7227 58.4017, -7.1191 55.3291, -7.9102 54.2139, -5.4492 54.0078, -5.2734 52.3756, -7.8223 49.6676, -5.0977 49.2678, 0.9668 50.5134, 2.5488 52.1065, 2.6367 54.0078, -0.9668 56.4625, 1.2305 60.8449))"))
| EVAL in_iceland = ST_INTERSECTS(location, TO_GEOSHAPE("POLYGON ((-25.4883 65.5312, -23.4668 66.7746, -18.4131 67.4749, -13.0957 66.2669, -12.3926 64.4159, -20.1270 62.7346, -24.7852 63.3718, -25.4883 65.5312))"))
| EVAL within_uk = ST_WITHIN(location, TO_GEOSHAPE("POLYGON((1.2305 60.8449, -1.582 61.6899, -10.7227 58.4017, -7.1191 55.3291, -7.9102 54.2139, -5.4492 54.0078, -5.2734 52.3756, -7.8223 49.6676, -5.0977 49.2678, 0.9668 50.5134, 2.5488 52.1065, 2.6367 54.0078, -0.9668 56.4625, 1.2305 60.8449))"))
| EVAL within_iceland = ST_WITHIN(location, TO_GEOSHAPE("POLYGON ((-25.4883 65.5312, -23.4668 66.7746, -18.4131 67.4749, -13.0957 66.2669, -12.3926 64.4159, -20.1270 62.7346, -24.7852 63.3718, -25.4883 65.5312))"))
| STATS centroid = ST_CENTROID_AGG(location), count=COUNT() BY in_uk, in_iceland, within_uk, within_iceland
| SORT count ASC
<p>The results are expected, the centroid of UK airports are within the UK boundary, and not within the Iceland boundary, and vice versa:</p><p>centroid</p><p>count</p><p>in_uk</p><p>in_iceland</p><p>within_uk</p><p>within_iceland</p><p>POINT (-21.946634463965893 64.13187285885215)</p><p>1</p><p>false</p><p>true</p><p>false</p><p>true</p><p>POINT (-2.597342072712148 54.33551226578214)</p><p>17</p><p>true</p><p>false</p><p>true</p><p>false</p><p>POINT (0.04453958108176276 23.74658354606057)</p><p>873</p><p>false</p><p>false</p><p>false</p><p>false</p><p>In fact, these functions can be used in any part of the query where their signature makes sense. They all take two arguments, which are either a literal spatial object or a field of a spatial type, and they all return a boolean value. One important consideration is that the coordinate reference system (CRS) of the geometries must match, or an error will be returned. This means you cannot mix <code>geo_shape</code> and <code>cartesian_shape</code> types in the same function call. You can, however, mix <code>geo_point</code> and <code>geo_shape</code> types, as the <code>geo_point</code> type is a special case of the <code>geo_shape</code> type, and both share the same coordinate reference system. The documentation for each of the functions defined above lists the supported type combinations.</p><p>Additionally, either argument can be a spatial literal or a field, in either order. You can even specify two fields, two literals, a field and a literal, or a literal and a field. The only requirement is that the types are compatible. For example, this query compares two fields in the same index:</p>FROM airport_city_boundaries
| EVAL in_city = ST_INTERSECTS(city_location, city_boundary)
| STATS count=COUNT(*) BY in_city
| SORT count ASC
| EVAL cardinality = CASE(count &lt; 10, "very few", count &lt; 100, "few", "many")
| KEEP cardinality, count, in_city
<p>The query basically asks if the city location is within the city boundary, which should generally be true, but there are always exceptions:</p><p>cardinality</p><p>count</p><p>in_city</p><p>few</p><p>29</p><p>false</p><p>many</p><p>740</p><p>true</p><p>A far more interesting question would be whether the airport location is within the boundary of the city that the airport serves. However, the airport location resides in a different index than the one containing the city boundaries. This requires a method to effectively query and correlate data from these two separate indexes.</p><h2>Spatial joins</h2><p>ES|QL does not support <code>JOIN</code> commands, but you can achieve a special case of a join using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-enrich"><code>ENRICH</code></a><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-enrich"> command</a>, which behaves similarly to a 'left join' in SQL. This command operates akin to a 'left join' in SQL, allowing you to enrich results from one index with data from another index based on a spatial relationship between the two datasets.</p><p>For example, let's enrich the results from a table of airports with additional information about the city they serve by finding the city boundary that contains the airport location, and then perform some statistics on the results:</p>FROM airports
| ENRICH city_boundaries ON city_location WITH airport, region, city_boundary
| MV_EXPAND city_boundary
| EVAL boundary_wkt_length = LENGTH(TO_STRING(city_boundary))
| STATS centroid = ST_CENTROID_AGG(location), count = COUNT(city_location), min_wkt = MIN(boundary_wkt_length), max_wkt = MAX(boundary_wkt_length) BY region
| SORT count DESC
| LIMIT 5
<p>This returns the top 5 regions with the most airports, along with the centroid of all the airports that have matching regions, and the range in length of the WKT representation of the city boundaries within those regions:</p><p>centroid</p><p>count</p><p>min_wkt</p><p>max_wkt</p><p>region</p><p>POINT (-32.56093470960719 32.598117914802714)</p><p>90</p><p>207</p><p>207</p><p>null</p><p>POINT (-73.94515332765877 40.70366442203522)</p><p>9</p><p>438</p><p>438</p><p>City of New York</p><p>POINT (-83.10398317873478 42.300230911932886)</p><p>9</p><p>473</p><p>473</p><p>Detroit</p><p>POINT (-156.3020245861262 20.176383580081165)</p><p>5</p><p>307</p><p>803</p><p>Hawaii</p><p>POINT (-73.88902732171118 45.57078813901171)</p><p>4</p><p>837</p><p>837</p><p>Montréal</p><p>So, what really happened here? Where did the supposed <code>JOIN</code> occur? The crux of the query lies in the <code>ENRICH</code> command:</p>FROM airports
| ENRICH city_boundaries ON city_location WITH airport, region, city_boundary
<p>This command instructs Elasticsearch to enrich the results retrieved from the <code>airports</code> index, and perform an <code>intersects</code> join between the <code>city_location</code> field of the original index, and the <code>city_boundary</code> field of the <code>airport_city_boundaries</code> index, which we used in a few examples earlier. But some of this information is not clearly visible in this query. What we do see is the name of an enrich policy <code>city_boundaries</code>, and the missing information is encapsulated within that policy definition.</p>{
  "geo_match": {
    "indices": "airport_city_boundaries",
    "match_field": "city_boundary",
    "enrich_fields": ["city", "airport", "region", "city_boundary"]
  }
}
<p>Here we can see that it will perform a <code>geo_match</code> query (<code>intersects</code> is the default), the field to match against is <code>city_boundary</code>, and the <code>enrich_fields</code> are the fields we want to add to the original document. One of those fields, the <code>region</code> was actually used as the grouping key for the <code>STATS</code> command, something we could not have done without this 'left join' capability. For more information on enrich policies, see the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/enrich-setup.html">enrich documentation</a>. While reading those documents, you will notice that they describe using the enrich indexes for enriching data at index time, by configuring ingest pipelines. This is not required for ES|QL, as the <code>ENRICH</code> command works at query time. It is sufficient to prepare the enrich index with the necessary data and enrich policy, and then use the <code>ENRICH</code> command in your ES|QL queries.</p><p>You may also notice that the most commonly found region was <code>null</code>. What could this imply? Recall that I likened this command to a 'left join' in SQL, meaning if no matching city boundary is found for an airport, the airport is still returned but with <code>null</code> values for the fields from the <code>airport_city_boundaries</code> index. It turns out there were 89 airports that found no matching <code>city_boundary</code>, and one airport with a match where the <code>region</code> field was <code>null</code>. This lead to a count of 90 airports with no <code>region</code> in the results. Another interesting detail is the need for the <code>MV_EXPAND</code> command. This is necessary because the <code>ENRICH</code> command may return multiple results for each input row, and <code>MV_EXPAND</code> helps to separate these results into multiple rows, one for each outcome. This also clarifies why "Hawaii" shows different <code>min_wkt</code> and <code>max_wkt</code> results: there were multiple regions with the same name but different boundaries.</p><h2>Kibana Maps</h2><p>Kibana has added support for Spatial ES|QL in the Maps application. This means that you can now use ES|QL to search for geospatial data in Elasticsearch, and visualize the results on a map.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt91922eb4ef43d290/6a16f7161949f737bfe7a7b5/bd78470bd8a4bc60f0db7006bd804b8fe87e2fea-1440x683.png" alt="Kibana Layers ES|QL" /><p>There is a new layer option in the add layers menu, called "ES|QL". Like all of the geospatial features described so far, this is in "technical preview". Selecting this option allows you to add a layer to the map based on the results of an ES|QL query. For example, you could add a layer to the map that shows all the airports in the world.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5185ef7461d7e81d/6a16f718839dfa1559dcfcb1/1dd28d3d0509f92d26b0bb5320a2925f7a54c5d9-1440x736.png" alt="Kibana ES|QL - Airports" /><p>Or you could add a layer that shows the polygons from the <code>airport_city_boundaries</code> index, or even better, how about that complex <code>ENRICH</code> query above that generates statistics for how many airports are in each region?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt51ee3543bb811d98/6a16f71a8b73cbbe63189df1/679a0a401faa613c7cedddd07c64f614ac2b7144-1440x727.png" alt="Kibana ES|QL - Region Statistics" /><h2>What's next</h2><p>You might have noticed in two of the examples above we squeezed in yet another spatial function <code>ST_CENTROID_AGG</code>. This is an aggregating function used in the <code>STATS</code> command, and the first of many spatial analytics features we plan to add to ES|QL. We'll blog about it when we've got more to show!</p><p>Before that, we want to tell you more about a particularly exciting feature we've worked on: the ability to perform spatial distance searches, one of the most used spatial search features of Elasticsearch. Can you imagine what the syntax for distance searches might look like? Perhaps similar to an OGC function? Stay tuned for the next blog in this series to find out!</p><p>Spoiler alert: Elasticsearch 8.15 has just been released, and spatial distance search with ES|QL is included!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-geospatial-search-part-one</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-geospatial-search-part-one</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Craig Taverner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd05627be20e89dfb/6a17d7c6414c640256944fdb/de886289dcb56494920875303b622b030b9b810f-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 12 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Building multilingual RAG with Elastic and Mistral]]></title>
    <description><![CDATA[Building a multilingual RAG application using Elastic and Mixtral 8x22B model]]></description>
    <content:encoded><![CDATA[<p><a href="https://mistral.ai/news/mixtral-8x22b">Mixtral 8x22B</a> is the most performant open model, and one of its most powerful features is fluency in many languages; including English, Spanish, French, Italian, and German.</p><p>Imagine a multinational company with support tickets and solutions in different languages and wants to take advantage of that knowledge across divisions. Currently, knowledge is limited to the language the agent speaks. Let's fix that!</p><p>In this article, I’m going to show you how to test Mixtral’s language capabilities, by creating a multilingual RAG system.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4116efa3368e0387/6a17117a1949f76a59e7ab36/27ba7e0cdf3d484b5c9e697702b9a63bff49b82b-1440x868.png" alt="Building multilingual RAG with Elastic and Mistral diagram" /><p><em>You can follow the notebook to reproduce this article's example </em><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/building-multilingual-rag-with-elastic-and-mistral/building_multilingual_rag_with_elastic_and_mistral.ipynb"><em>here</em></a></p><h3>Steps</h3><ol><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#creating-endpoints">Creating embeddings endpoint</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#creating-mappings">Creating mappings</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#indexing-data">Indexing data</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#asking-questions">Asking questions</a></p></li></ol><h2>Creating embeddings endpoint</h2><p>Our support tickets for this example will come in English, Spanish, and German. The Mistral embeddings model is not multilingual, but we can generate <a href="https://www.elastic.co/search-labs/blog/multilingual-vector-search-e5-embedding-model">multilingual embeddings</a> using the e5 model, so we can index text on different languages and manage it as a single source, giving us a much richer context.</p><p>To create e5 multilingual embeddings you can use Kibana:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0aadfb7eeddd9754/6a17117c6234e00fc6db1ae8/a691763d2976a23d7d82177b6a7e8ad31051b913-800x549.gif" alt="Creating a multilingual endpoint with Kibana" /><p>Or the _inference API:</p>PUT _inference/text_embedding/multilingual-embeddings
 {
    "service": "elasticsearch",
    "service_settings": {
        "model_id": ".multilingual-e5-small",
        "num_allocations": 1 ,
        "num_threads": 1
    }
}
<h2>Creating Mappings</h2><p>For the mappings we will use <a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">semantic_text</a> mapping type, which is one of my favorite features. It handles the process of chunking the data, generating embeddings, and querying embeddings for you!</p>PUT multilingual-mistral
{
  "mappings": {
    "properties": {
      "super_body": {
        "type": "semantic_text",
        "inference_id": "multilingual-embeddings"
      }
    }
  }
}
<p>We call the text field <code>super_body</code> because with a single mapping type it will handle chunks and embeddings.</p><h2>Indexing data</h2><p>We will index a couple of support tickets with problems and solutions in two languages, and then ask a question about problems within many documents in a third.</p><p>The following documents will be added to the index:</p><p></p><p>1. English Support Ticket: Calendar Sync Issue</p><p></p><p><em>Support Ticket #EN1234</em> <strong>Subject</strong>: Calendar sync not working with Google Calendar</p><p><strong>Description</strong>: I'm having trouble syncing my project deadlines with Google Calendar. Whenever I try to sync, I get an error message saying "Unable to connect to external calendar service."</p><p><strong>Resolution</strong>: The issue was resolved by following these steps:</p><ol><li><p>Go to Settings &gt; Integrations</p></li></ol><p></p><ol><li><p>Disconnect the Google Calendar integration</p></li></ol><p></p><ol><li><p>Clear browser cache and cookies</p></li></ol><p></p><ol><li><p>Reconnect the Google Calendar integration</p></li></ol><p></p><ol><li><p>Authorize the app again in Google's security settings</p></li></ol><p>The sync should now work correctly. If problems persist, ensure that third-party cookies are enabled in your browser settings.</p><p></p><p>2. German Support Ticket: File Upload Problem</p><p></p><p><em>Support-Ticket #DE5678</em> <strong>Betreff</strong>: Datei-Upload funktioniert nicht</p><p><strong>Beschreibung</strong>: Ich kann keine Dateien mehr in meine Projekte hochladen. Jedes Mal, wenn ich es versuche, bleibt der Ladebalken bei 99% stehen und dann erscheint eine Fehlermeldung.</p><p><strong>Lösung</strong>: Das Problem wurde durch folgende Schritte gelöst:</p><ol><li><p>Überprüfen Sie die Dateigröße. Die maximale Uploadgröße beträgt 100 MB.</p></li></ol><p></p><ol><li><p>Deaktivieren Sie vorübergehend den Virenschutz oder die Firewall.</p></li></ol><p></p><ol><li><p>Versuchen Sie, die Datei im Inkognito-Modus hochzuladen.</p></li></ol><p></p><ol><li><p>Wenn das nicht funktioniert, leeren Sie den Browser-Cache und die Cookies.</p></li></ol><p></p><ol><li><p>Als letzten Ausweg, versuchen Sie einen anderen Browser zu verwenden.</p></li></ol><p>In den meisten Fällen lag das Problem an zu großen Dateien oder an Interferenzen durch Sicherheitssoftware. Nach Anwendung dieser Schritte sollte der Upload funktionieren.</p><p></p><p>3. Marketing Campaign Ideas (noise)</p><p></p><p><em>Q3 Marketing Campaign Ideas</em></p><ol><li><p>Social media contest: "Share Your Productivity Hack"</p><ul><li><p>Users share tips using our software, best entry wins a premium subscription.</p></li></ul></li></ol><p></p><ol><li><p>Webinar series: "Mastering Project Management"</p><ul><li><p>Invite industry experts to share insights using our tool.</p></li></ul></li></ol><p></p><ol><li><p>Email campaign: "Unlock Hidden Features"</p><ul><li><p>Series of emails highlighting lesser-known but powerful features.</p></li></ul></li></ol><p></p><ol><li><p>Partner with a productivity podcast for sponsored content.</p></li></ol><p></p><ol><li><p>Create a "Project Management Memes" social media account for lighter, shareable content.</p></li></ol><p></p><p>4. Mitarbeiter des Monats (noise)</p><p></p><p><em>Mitarbeiter des Monats: Juli 2023</em></p><p>Wir freuen uns, bekannt zu geben, dass Sarah Schmidt zur Mitarbeiterin des Monats Juli gewählt wurde!</p><p>Sarah hat außergewöhnliche Leistungen in folgenden Bereichen gezeigt:</p><ul><li><p>Kundenbetreuung: Sarah hat durchschnittlich 95% positive Bewertungen erhalten.</p></li></ul><p></p><ul><li><p>Teamarbeit: Sie hat maßgeblich zur Verbesserung unseres internen Wissensmanagementsystems beigetragen.</p></li></ul><p></p><ul><li><p>Innovation: Sarah hat eine neue Methode zur Priorisierung von Support-Tickets vorgeschlagen, die unsere Reaktionszeiten um 20% verbessert hat.</p></li></ul><p>Bitte gratulieren Sie Sarah zu dieser wohlverdienten Anerkennung!</p><p>This is how a document will look like inside Elasticsearch:</p>{
    "took": 9,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2,
            "relation": "eq"
        },
        "max_score": 0.9155389,
        "hits": [
            {
                "_index": "multilingual-mistral",
                "_id": "1",
                "_score": 0.9155389,
                "_source": {
                    "super_body": {
                        "text": "\n        _Support Ticket #EN1234_\n        **Subject**: Calendar sync not working with Google Calendar\n\n        **Description**:\n        I'm having trouble syncing my project deadlines with Google Calendar. Whenever I try to sync, I get an error message saying \"Unable to connect to external calendar service.\"\n\n        **Resolution**:\n        The issue was resolved by following these steps:\n        1. Go to Settings &gt; Integrations\n        2. Disconnect the Google Calendar integration\n        3. Clear browser cache and cookies\n        4. Reconnect the Google Calendar integration\n        5. Authorize the app again in Google's security settings\n\n        The sync should now work correctly. If problems persist, ensure that third-party cookies are enabled in your browser settings.\n    ",
                        "inference": {
                            "inference_id": "multilingual-embeddings",
                            "model_settings": {
                                "task_type": "text_embedding",
                                "dimensions": 384,
                                "similarity": "cosine",
                                "element_type": "float"
                            },
                            "chunks": [
                                {
                                    "text": "passage: \n        _Support Ticket #EN1234_\n        **Subject**: Calendar sync not working with Google Calendar\n\n        **Description**:\n        I'm having trouble syncing my project deadlines with Google Calendar. Whenever I try to sync, I get an error message saying \"Unable to connect to external calendar service.\"\n\n        **Resolution**:\n        The issue was resolved by following these steps:\n        1. Go to Settings &gt; Integrations\n        2. Disconnect the Google Calendar integration\n        3. Clear browser cache and cookies\n        4. Reconnect the Google Calendar integration\n        5. Authorize the app again in Google's security settings\n\n        The sync should now work correctly. If problems persist, ensure that third-party cookies are enabled in your browser settings.",
                                    "embeddings": [
                                        0.0059651174,
                                        0.0016363655,
                                        -0.064753555,
                                        0.0093298275,
                                        0.05689768,
                                        -0.049640983,
                                        0.02504726,
                                        0.0048340675,
                                        0.08093895,
                                        ...
                                    ]
                                }
                            ]
                        }
                    }
                }
            }
        ]
    }
}
<h2>Asking questions</h2><p>Now, we are going to ask a question in Spanish:</p>Hola, estoy teniendo problemas para ocupar su aplicación, estoy teniendo problemas para sincronizar mi calendario, y encima al intentar subir un archivo me da error.<p>The expectation is retrieving documents #1 and #2, then sending them to the LLM as additional context, and finally, getting an answer in Spanish.</p><h4>Retrieving documents</h4><p>To retrieve the relevant documents, we can use this nice and short query that will run a search on the embeddings, and return the support tickets most relevant to the question.</p>GET multilingual-mistral/_search
{
   "size": 2,
   "_source": {
    "excludes": ["*embeddings", "*chunks"]
   },
  "query": {
    "semantic": {
      "field": "super_body",
      "query": "Hola, estoy teniendo problemas para ocupar su aplicación, estoy teniendo problemas para sincronizar mi calendario, y encima al intentar subir un archivo me da error."
    }
  }
}
<p><em>Notes about the parameters set:</em> <code>size: 2</code> Because we know we want the top 2 documents. <code>excludes</code> For clarity in the response. Documents are short so each one will be one chunk long.</p><h4>Answering the question</h4><p>Now we can call the Mistral completion API using the Python library to answer the question.</p>from mistralai.client import MistralClient
from mistralai.models.chat_completion import ChatMessage

api_key = os.environ["MISTRAL_API_KEY"]
model = "open-mixtral-8x22b"

client = MistralClient(api_key=api_key)

system_message = "You are a helpful multilingual agent that helps users with their problems. You have access to a knowledge base of different languages and you must answer in the same language the question was asked."

user_message = """
## Question:

Hola, estoy teniendo problemas para ocupar su aplicación, estoy teniendo problemas para sincronizar mi calendario, y encima al intentar subir un archivo me da error. 

## Related knowledge:

Support Ticket #EN1234 Subject: Calendar sync not working with Google Calendar...
(the rest of the content of the document)

\n

Support-Ticket #DE5678 Betreff: Datei-Upload funktioniert nicht... 
(the rest of the content of the document)

ANSWER:

"""

messages = [
    ChatMessage(role="system", content=system_message),
    ChatMessage(role="user", content=user_message)
]

chat_response = client.chat(
    model=model,
    messages=messages,
)

print(chat_response.choices[0].message.content) 
<p>The answer is in perfect Spanish and on point!</p><p></p><p>Show answer</p><p></p><p>Hola, me alegra que te hayas comunicado con nosotros. Parece que hay dos problemas distintos.</p><p>En cuanto a la sincronización del calendario, puedes seguir estos pasos para resolver el problema:</p><ol><li><p>Ve a Configuración &gt; Integraciones</p></li></ol><p></p><ol><li><p>Desconecta la integración del Calendario de Google</p></li></ol><p></p><ol><li><p>Borra la caché y las cookies del navegador</p></li></ol><p></p><ol><li><p>Vuelve a conectar la integración del Calendario de Google</p></li></ol><p></p><ol><li><p>Autoriza de nuevo la aplicación en la configuración de seguridad de Google</p></li></ol><p>Si sigues teniendo problemas, asegúrate de que las cookies de terceros están habilitadas en la configuración de tu navegador.</p><p>En cuanto al problema de subir un archivo, hay varias cosas que puedes probar:</p><ol><li><p>Comprueba el tamaño del archivo. El tamaño máximo de carga es de 100 MB.</p></li></ol><p></p><ol><li><p>Desactiva temporalmente el antivirus o el cortafuegos.</p></li></ol><p></p><ol><li><p>Intenta cargar el archivo en modo incógnito.</p></li></ol><p></p><ol><li><p>Si eso no funciona, borra la caché y las cookies del navegador.</p></li></ol><p></p><ol><li><p>Como último recurso, prueba a usar un navegador diferente.</p></li></ol><p>En la mayoría de los casos, el problema se debe a archivos demasiado grandes o a interferencias causadas por software de seguridad. Al seguir estos pasos, deberías poder cargar el archivo correctamente.</p><p>¡Espero que esto te ayude a resolver tus problemas! Si tienes alguna otra pregunta, no dudes en preguntar.</p><h2>Conclusion</h2><p>Mixtral 8x22B is a powerful model that enables us to leverage data sources in different languages, being able to answer, understand, and translate in many languages. This ability– together with multilingual embeddings– allows you to have multilingual support both in the data retrieval and the answer generation stages, removing language barriers entirely.</p><p><em>If you are interested on reproducing the examples of this article, you can find the Python Notebook with the requests </em><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/building-multilingual-rag-with-elastic-and-mistral/building_multilingual_rag_with_elastic_and_mistral.ipynb"><em>here</em></a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9cf558f36ced44dc/6a17117dd7c022520cde65a2/7dd63f367670175590e30927ef432ff93e166c84-1440x809.png" length="0" type="image/png"/>
    <pubDate>Fri, 02 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How we optimized refresh costs in Elasticsearch Serverless]]></title>
    <description><![CDATA[We explore how serverless Elasticsearch facilitates searches using data stored in a blob store while maintaining the same visibility semantics as stateful Elasticsearch. We discuss the challenges encountered during implementation and share strategies for balancing costs and complexity.]]></description>
    <content:encoded><![CDATA[<p>Recently, we’ve <a href="https://www.elastic.co/blog/search-ai-lake-elastic-cloud-serverless">launched</a> the Elastic Cloud Serverless offering that aims to provide a seamless experience to run search workloads in the cloud. To launch this, we’ve rearchitected Elasticsearch to <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">decouple</a> storage from compute, where data is stored in a cloud blob store that provides virtually infinite storage and scalability. In this blog post, we’ll dive into how we removed a strong relationship between the number of indices and the number of object store calls, allowing us to improve UX and reduce costs at the same time.</p><p>Before we dive into the changes we made, it’s essential to first understand the interplay between Elasticsearch and Lucene.</p><p>Elasticsearch uses Lucene, a high-performance, open-source library written in Java, for full text indexing and search. When a document is indexed into Elasticsearch, it isn't immediately written to disk by Lucene. Instead, Lucene updates its internal in-memory data structures. Once enough data accumulates or a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html">refresh</a> is triggered, these documents are then written to disk, creating a new set of immutable files known as segments in Lucene terminology. The indexed documents are not available for search until the segments are written to disk. That’s the reason why <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html">refresh</a> is such an important concept in Elasticsearch. You might be wondering how durability is ensured when documents are kept in memory until a refresh is triggered. This is achieved through the Translog, which stores durably every operation to guarantee data persistence and recovery in case of failure.</p><p>Now that we know what Lucene segments are and why refreshes are needed in Elasticsearch, we can explore how refresh behavior differs between stateful Elasticsearch and <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">serverless Elasticsearch</a>.</p><h2>Refreshes in stateful Elasticsearch</h2><p>In Elasticsearch, indices are divided into multiple shards, each consisting of a primary shard and potentially multiple replica shards. In stateful Elasticsearch, when a document is indexed, it is first routed to the primary shard, where Lucene processes and indexes it. After indexing on the primary shard, the document is then routed to the replica shards, where it is indexed by these copies.</p><p>As mentioned earlier, a refresh is needed to make these indexed documents searchable. In stateful Elasticsearch, a refresh writes the Lucene in-memory data structures to disk without performing an fsync. Refreshes are scheduled periodically, with each node executing them at different times. This process will create distinct Lucene segment files on each node, all containing the same set of documents.</p><h2>Refreshes in serverless Elasticsearch</h2><p>In contrast, serverless Elasticsearch employs a segment-based replication model. In this approach, one node per shard handles document indexing and generates Lucene segments. These segments are uploaded to the blob store once a refresh is initiated. Subsequently, search nodes are informed about these new Lucene segments, which they can read directly from the blob store.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7cc4aefbc63dca3c/6a17d7783e03d793524f2ac9/efc718c485d64c1911365a7e20a9435aeb1fbdc0-1440x754.png" alt="Refresh flow before optimizations in Elasticsearch" /><p>The illustration above demonstrates how a refresh works in serverless Elasticsearch:</p><ol><li><p>The indexing node, where all the documents were indexed, receives the refresh request and Lucene writes the in-memory data structures to disk, similar to how a stateful refresh operates.</p></li><li><p>The segment files are uploaded to the blob store as a single file (known as a stateless compound commit). In the illustration, S4 is uploaded.</p></li><li><p>Once the segment files are uploaded to the blob store, the indexing node sends a message to each search node, notifying them of the new segment files so they can perform searches on the newly indexed documents.</p></li><li><p>The search nodes fetch the necessary data from the blob store when executing searches.</p></li></ol><p>This model offers the advantage of lightweight nodes, as data is stored in the blob store. This makes scaling or reallocating workloads between nodes more cost-effective compared to stateful Elasticsearch, where data must be transferred to the new node containing the new shard.</p><p>One aspect worth considering is the additional object store request costs associated with each refresh in serverless Elasticsearch. Every refresh operation created a new object in the object store, resulting in an object store PUT request that incurs associated costs. This led to a linear relationship between the number of indices and the number of object store PUT requests. With enough refreshes, object store costs could surpass the cost of the hardware itself. To address this, we initially implemented refresh throttling measures to manage costs effectively and mitigate potential issues over time. This blog post describes the next step in that effort, which allowed us to refresh faster and at a manageable cost.</p><h2>Refresh cost optimizations in serverless Elasticsearch</h2><p>As previously mentioned, the serverless Elasticsearch architecture provides numerous benefits. However, to manage refresh costs effectively, we made decisions that occasionally impacted user experience. One such decision was enforcing a default refresh interval of 15 seconds, meaning that in some cases, newly indexed data won't become searchable until 15 seconds have passed. Despite our efforts, scenarios arose where object store expenses became prohibitive, prompting us to reassess our approach. In this section, we will delve into how we successfully decoupled refresh operations from object store calls to address these challenges without compromising user experience.</p><p>After evaluating various solutions—from temporary storage of segments in distributed file systems like NFS to direct pushing of segments into search nodes—we settled on an approach relying on serving segment data from indexing nodes directly to search nodes.</p><p>Rather than letting refresh immediately upload new Lucene segments to the blob store, index nodes now accumulate segments from refreshes and upload them as a single blob later. This enables index nodes to serve reads from search nodes in a manner akin to a blob store, delaying segment uploads until sufficient data accumulates or a predetermined time interval elapses.</p><p>This strategy grants us complete control over the size of the blobs uploaded to the blob store, enabling us to determine when request costs become negligible in comparison to hardware costs.</p><h3>Batched compound commits</h3><p>We aimed to implement this enhancement incrementally and ensure backward compatibility with existing data stored in the blob store. Therefore, we opted to maintain the same file format for storing Lucene segments in the blob store. For context, Lucene segments comprise multiple files, each serving a distinct role. To streamline the upload process and minimize PUT requests, we introduced compound commits: single blobs containing all segment files consecutively, accompanied by a metadata header, including a directory of the files in the compound commit.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7207e2d6008df66e/6a17d779ec0f8984445a6446/a503ec1c16498a4ad86de3c1fce4d13e472144da-1440x441.png" alt="Compound commit file format" /><p>When retrieving a compound commit from the blob store, such as during shard relocation, our primary focus is typically on the compound commit header. This header is crucial as it contains the essential data needed to promptly populate internal data structures. With this in mind, we realized we could maintain the existing file format but streamline it so that each blob would sequentially append one compound commit after another. We denominated this new file format, batched compound commit.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt321e94e2043ef619/6a17d77aec0f8907455a644a/3c264c4b43632f508c96a6ebd2c381402a83fd5f-1440x374.png" alt="Batched compound commit file format" /><p>Since each compound commit's size is stored in its header, retrieving the headers of all compound commits within a batched compound commit is straightforward; we can sequentially read each header by simply seeking the next entry. When handling blobs in the old format, they are treated as singleton batched compound commits. Another critical aspect of our file format is maintaining fixed offsets for each Lucene segment file once it's appended into a batched compound commit. This ensures consistency whether the file is served from the index node or the blob store. It also prevents the need to evict cached entries on search nodes when the batched compound commit is eventually uploaded to the blob store.</p><h3>New refresh lifecycle</h3><p>Index nodes will now accumulate Lucene segments from refreshes until enough data is gathered to upload them as a single blob. Let us explore how index and search nodes coordinate to determine where to access this data from.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta3c527801c519b6d/6a17d77ce9ea8761a5a9c41d/578e9dbfeb31673e86429820479c211db5a27434-1440x802.png" alt="Optimized refresh flow in Elasticsearch serverless" /><p>As shown in the illustration above, the following steps occur during the optimized refresh process in serverless Elasticsearch:</p><ol><li><p>The index node receives a refresh request, writes a new set of Lucene segments to its local disk, and adds these segments to the pending batched compound commit for eventual upload.</p></li><li><p>The index node notifies the search node about these new segments, providing details about the involved segments and their locations (blob store or index node).</p></li><li><p>When a search node needs a segment to fulfill a query, it decides whether to get it from the blob store or the index node and caches the data locally.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt782c93954782c163/6a17d77d4b055d0b68432050/8e051b6251fe96510dd1d31f679c54c042cd95e5-1440x721.png" alt="Optimized refresh upload flow in Elasticsearch serverless" /><p>The image above illustrates the process of uploading data to the blob store in serverless Elasticsearch once enough segments have accumulated in the indexing nodes or after a specified amount of time has elapsed.</p><ol><li><p>A refresh adds a new segment to the batched compound commit and the accumulated data reaches 16 MB, or a certain amount of time has passed since the last refresh, from this point onwards new segments are accumulated into a new batched compound commit.</p></li><li><p>The indexing node begins uploading the accumulated segments as a single blob to the object store.</p></li><li><p>The indexing node notifies the search node replicas of the latest segment uploaded to the object store, instructing them to fetch data from these segments from the blob store going forward.</p></li><li><p>If a search requires data that isn't cached locally, it will retrieve the necessary information from the blob store, while any previously fetched data from the indexing node remains valid even after the upload.</p></li></ol><h3>Considerations and tradeoffs</h3><p>The approach chosen blurs the clear separation between storage and compute, requiring index nodes to handle storage requests until Lucene segments are eventually uploaded to the blob store. However, the overhead from these storage requests is minimal and we have not observed impact on indexing throughput.</p><p>We'll note that we keep translog entries until corresponding data has been uploaded to the blob store, hence the approach maintains existing data safety guarantees. Recovery times after a crash may be slightly longer, but we consider this an acceptable trade-off.</p><h2>Conclusions</h2><p>This blog post has explored our transition towards a more cloud-native approach, emphasizing its many benefits alongside the critical cost consideration. We traced our evolution from a model where each new Lucene segment generated a distinct object in the object store. This led to cost and user experience challenges in specific serverless workloads compared to stateful Elasticsearch. Batching object store uploads enabled us to minimize the number of object store requests and enhance the cost efficiency of our serverless offering.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6478e2975a6ccedb/6a17d77f3e03d7925a4f2acd/58096951dd3f12d0b60072867febc6b7c90ecc26-1440x673.png" alt="Reduction in PUT requests" /><h2>Acknowledgments</h2><p>We would like to acknowledge the contributions of Iraklis Psaroudakis, Tanguy Leroux, and Yang Wang. Their efforts were instrumental in the success of this project.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-refresh-costs-serverless</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-refresh-costs-serverless</guid>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <dc:creator><![CDATA[Francisco Fernández Castaño,Henning Andersen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt88526af16bafdb7c/6a17d7807f6f15825dc0998d/d11e1ba058784ec92b8953fb8db62e1bad21c210-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 31 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Ingest autoscaling in Elasticsearch]]></title>
    <description><![CDATA[Learn more about how Elasticsearch autoscales to address ingestion load.]]></description>
    <content:encoded><![CDATA[<p>Sizing an Elasticsearch cluster correctly is not easy. The optimal size of the cluster depends on the workload that the cluster is experiencing, which may change over time. Autoscaling adapts the cluster size to the workload automatically without human intervention. It avoids over-provisioning resources for the cluster only to accommodate peak usage and it also prevents degrading cluster performance in case of under-provisioning.</p><p>We rely on this mechanism to free users of our <a href="https://www.elastic.co/docs/current/serverless">Elastic Cloud Serverless</a> offering from having to make sizing decisions for the <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">indexing tier</a>. Ingest autoscaling requires continuously estimating the resources required to handle the incoming workload, and provisioning and de-provisioning these resources in a timely manner.</p><p>In this blog post we explore ingest autoscaling in Elasticsearch, covering the following:</p><ul><li><p>How ingest autoscaling works in Elasticsearch</p></li><li><p>Which metrics we use to quantify the indexing workload the cluster experiences in order to estimate resources required to handle that workload</p></li><li><p>How these metrics drive the autoscaling decisions.</p></li></ul><h2>Ingest autoscaling overview</h2><p>Ingest autoscaling in Elasticsearch is driven by a set of metrics that is exposed by Elasticsearch itself. These metrics reflect the ingestion load and the memory requirement of the indexing tier. Elasticsearch provides an autoscaling metrics API that serves these metrics which allows an external component to monitor these metrics and make decisions whether the cluster size needs to change (see Figure 1).</p><p>In the Elastic Cloud Serverless service, there is an autoscaler component which is a Kubernetes Controller. The autoscaler polls the Elasticsearch autoscaling metrics API periodically and calculates the desired cluster size based on these metrics. If the desired cluster size is different from the current one, the autoscaler changes the cluster size to consolidate the available resources in the cluster towards the desired resources. This change is both in terms of the number of Elasticsearch nodes in the cluster and the CPU, memory and disk available to each node.</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltce3b99ecd11442dd/6a17d7126df73179750a0d41/7c0cb0b822ca2a6531d35df15a0226a51d18a1cb-1440x1119.png" alt="overview" /><p><strong>Figure 1</strong>: ingestion autoscaling overview</p><p></p><p>An important consideration for ingest autoscaling is that when the cluster receives a spike in the indexing load the autoscaling process can take some time until it effectively adapts the cluster size. While we try to keep this reaction time as low as possible, it cannot be instantaneous. Therefore, while the cluster is scaling up, the Elasticsearch cluster should be able to temporarily push back on the load it receives if the increased load is otherwise going to cause cluster instability issues. The increase in the indexing load can manifest itself in the cluster requiring more resources, i.e., CPU, memory or disk. Elasticsearch has protection mechanisms that allows nodes to push back on the indexing load if any of these resources becomes a bottleneck.</p><p>To handle indexing requests Elasticsearch uses dedicated thread pools sized based on the number of cores available to the node. If the increased indexing load results in CPU or other resources becoming a bottleneck, incoming indexing requests are queued. <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html">The maximum size of this queue</a> is limited and any request arriving at the node when the queue is full will be rejected with a 429 HTTP code.</p><p>Elasticsearch also keeps track of the required memory to address ongoing indexing requests and rejects incoming requests (with a 429) if the indexing buffer <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-indexing-pressure.html">grows beyond 10% of the available heap memory</a>. This limits the memory used for indexing and ensures the node will not go out of memory.</p><p>The Elastic Cloud Serverless offering relies on the object store as the main storage for indexed data. The local disk on the nodes are used temporarily to hold indexed data. Periodically, Elasticsearch uploads the indexed data to the object store which allows freeing up the local disk space as we rely on the object store for durability of the indexed document. Nonetheless, under high indexing load, it is possible for the node to run out of disk space before the periodic upload task gets a chance to run and free up the local disk space. To handle these cases, Elasticsearch monitors the available local disk space and if necessary throttles the indexing activity while it attempts to free up space by enforcing an upload to the object store rather than waiting for the periodic upload to take place. Note that this throttling in turn results in queueing of the incoming indexing requests.</p><p>These protection mechanisms allow an Elasticsearch cluster to temporarily reject requests and provide the client with a response that indicates that the cluster is overloaded while the cluster tries to scale up. This push-back signal from Elasticsearch provides the client with a chance to react by reducing the load if possible or retrying the request which should eventually succeed if retried when the cluster is scaled up.</p><h2>Metrics</h2><p>The two metrics that are used for ingest autoscaling in Elasticsearch are ingestion load and memory.</p><h3>Ingestion load</h3><p>Ingestion load represents the number of threads that is needed to cope with the current indexing load. The autoscaling metrics API exposes a list of ingestion load values, one for each indexing node. Note that as the write thread pools (which handle indexing requests) are sized based on the number of CPU cores on the node, this essentially determines the total number of cores that is needed in the cluster to handle the indexing workload.</p><p>The ingestion load on each indexing node consists of two components:</p><ul><li><p>Thread pool utilization: the average number of threads in the write thread pool processing indexing requests during that sampling period.</p></li><li><p>Queued ingestion load: the estimated number of threads needed to handle queued write requests.</p></li></ul><p>The ingestion load of each indexing node is calculated as the sum of these two values for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html">all the three write thread pools</a>. The total ingestion load of the Elasticsearch cluster is the sum of the ingestion load of the individual nodes.</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e0ba9f4e7ae59b9/6a17d71463baff6110741ab5/b1ab391f2639aaa4b7807a37a23fb9c5375f9690-1440x831.png" alt="Figure 2: ingestion load components" /><p><strong>Figure 2</strong>: ingestion load components</p><p></p><p>The thread pool utilization is an <a href="https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average">exponentially weighted moving average (EWMA)</a> of the number of busy threads in the thread pool, sampled every second. The EWMA of the sampled thread pool utilization values is configured such that the sampled values of the past 10 seconds have the most effect on the thread pool utilization component of the ingestion load and samples older than 60 seconds have very negligible impact.</p><p>To estimate the resources required to handle the queued indexing requests in the thread pool, we need to have an estimate for how long each queued task can take to execute. To achieve this, each thread pool also provides an EWMA of the request execution time. The request execution time for an indexing request is the (wall-clock) time taken for the request to finish once it is out of the queue and a worker thread starts executing it. As some queueing is acceptable and should be manageable by the thread pool, we try to estimate the resources needed to handle the excess queueing. We consider up to 30s worth of tasks in the queue manageable by the existing number of workers and account for an extra thread proportional to this value. For example, if the average task execution time is 200ms, we estimate that each thread is able to handle 150 indexing requests within 30s, and therefore account for one extra thread for each 150 queued items.</p><p>Note that since the indexing nodes rely on pushing indexed data into the object store periodically, we do not need to scale the indexing tier based on the total size of the indexed data. However, the disk IO requirements of the indexing workload needs to be considered for the autoscaling decisions. The ingestion load represents both CPU requirements of the indexing nodes as well as disk IO since both CPU and IO work is done by the write thread pool workers and we rely on the wall clock time to estimate the required time to handle the queued requests.</p><p>Each indexing node calculates its ingestion load and publishes this value to the master node periodically. The master node serves the per node ingestion load values via the autoscaling metrics API to the autoscaler.</p><h3>Memory</h3><p>The memory metrics exposed by the autoscaling metrics API are node memory and tier memory. The node memory represents the minimum memory requirement for each indexing node in the cluster. The tier memory metric represents the minimum total memory that should be available in the indexing tier. Note that these values only indicate the minimum to ensure that each node is able to handle the basic indexing workload and hold the cluster and indices metadata, while ensuring that the tier includes enough nodes to accommodate all index shards.</p><p>Node memory must have a minimum of 500MB <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html#_consider_additional_heap_overheads">to be able to handle indexing workloads</a>, as well as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html#shard-count-recommendation">a fixed amount of memory per each index</a>. This ensures all nodes can hold metadata for the cluster, which includes metadata for every index. Tier memory is determined by accounting for the memory overhead of the field mappings of the indices and the amount of memory needed for each open shard allocated on a node in the cluster. Currently, the per-shard memory requirement uses a fixed estimate of 6MB. We plan to refine this value.</p><p>The estimate for the memory requirements for the mappings of each index is calculated by one of the data nodes that hosts a shard of the index. The calculated estimates are sent to the master node. Whenever there is a mapping change this estimate is updated and published to the master node again. The master node serves the node and total memory metrics based on these information via the autoscaling metrics API to the autoscaler.</p><h2>Scaling the cluster</h2><p>The autoscaler is responsible for monitoring the Elasticsearch cluster via the exposed metrics, calculating the desirable cluster size to adapt to the indexing workload, and updating the deployment accordingly. This is done by calculating the total required CPU and memory resources based on the ingestion load and memory metrics. The sum of all the ingestion load per node values determines the total number of CPU cores needed for the indexing tier.</p><p>The calculated CPU requirement and the provided minimum node and tier memory resources are mapped to a predetermined set of cluster sizes. Each cluster size determines the number of nodes and the CPU, memory and disk size of each node. All nodes within a certain cluster size have the same hardware specification. There is a fixed ratio between CPU, memory and disk, thus always scaling all 3 resources linearly. The existing cluster sizes for the indexing tier are based on node sizes starting from 4GB/2vCPU/100GB disk to 64GB/32vCPU/1600GB disk. Once the Elasticsearch cluster scales up to the largest node size (64GB memory), any further scale-up adds new 64GB nodes, allowing a cluster to scale up to 32 nodes of 64GB. Note that this is not a hard upper bound on the number of Elasticsearch nodes in the cluster and can be increased if necessary.</p><p>Every 5 seconds the autoscaler polls metrics from the master node, calculates the desirable cluster size and if it is different from the current cluster size, it updates the Elasticsearch Kubernetes Deployment accordingly. Note that the actual reconciliation of the deployment towards the desired cluster size and adding and removing the Elasticsearch nodes to achieve this is done by Kubernetes. In order to avoid very short-lived changes to the cluster size, we account for a 10% headroom when calculating the desired cluster size during a scale down and a scale down takes effect only if all desired cluster size calculations within the past 15 minute have indicated a scale-down.</p><p>Currently, the time that it takes for an increase in the metrics to lead to the first Elasticsearch node being added to the cluster and ready to process indexing load is under 1 minute.</p><h2>Conclusion</h2><p>In this blog post, we explained how ingest autoscaling works in Elasticsearch, the different components involved, and the metrics used to quantify the resources needed to handle the indexing workload. We believe that such an autoscaling mechanism is crucial to reduce the operational overhead of an Elasticsearch cluster for the users by automatically increasing the available resources in the cluster when necessary. Furthermore, it leads to cost reduction by scaling down the cluster when the available resources in the cluster are not required anymore.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-ingest-autoscaling</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-ingest-autoscaling</guid>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <dc:creator><![CDATA[Pooya Salehi,Henning Andersen,Francisco Fernández Castaño]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb2cd04a86700d5c2/6a17d715e3179138882d567d/016f5f2a90974dd53416d7d12f8776e802a0eef8-1440x823.png" length="0" type="image/png"/>
    <pubDate>Mon, 29 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[The sparse vector query: Searching sparse vectors with inference or precomputed query vectors]]></title>
    <description><![CDATA[Learn about the Elasticsearch sparse vector query, how it works, and how to effectively use it.]]></description>
    <content:encoded><![CDATA[<p>Sparse vector queries take advantage of Elasticsearch’s powerful <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-apis.html">inference API</a>, allowing easy built-in setup for Elastic-hosted models such as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-elser.html">ELSER</a> and <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-e5.html">E5</a>, as well as the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-inference.html">flexibility</a> to host other models.</p><h2>Background</h2><p>Vector search is evolving, and as our needs for vector search evolve so does the need for a consistent and forward thinking vector search API.</p><p>When Elastic first launched semantic search, we leveraged existing <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rank-features.html">rank_features</a> fields using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-text-expansion-query.html">text_expansion</a> query. We then reintroduced the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/sparse-vector.html">sparse_vector field type</a> for semantic search use cases.</p><p>As we think about what sparse vector search is going forward, we’ve introduced a new sparse vector query. As of Elasticsearch 8.15.0, both the text_expansion query and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-weighted-tokens-query.html">weighted_tokens</a> query have been deprecated in favor of the new sparse vector query.</p><p>The sparse vector query supports two modes of querying: using an inference ID and using precomputed query vectors. Both modes of querying require data to be indexed in a sparse_vector mapped field.</p><p>These token-weight pairs are then used in a query against a sparse vector. At query time, query vectors are calculated using the same inference model that was used to create the tokens.</p><p>Let’s look at an example: let’s say we’ve indexed a document detailing when Orion is most visible in the night sky:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltecb4be95e0c08699/6a17d72225daabcc8908a09a/8c7b9a7fa9db92e2e8067d5c84854940bf037062-1440x320.png" alt="Indexing sparse vectors encoding example" /><p>Now, assume we’re looking for constellations that are visible in the northern hemisphere, and we run this query through the same learned sparse encoder model. The output might look similar to this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3683b4b6d0d85683/6a17d724505ac32a60ad8950/54561b412a1c1f57157290a339dfe387c2f95dad-1440x424.png" alt="Searching sparse vectors encoding example" /><p>At query time, these vectors are ORed together, and scoring is effectively a <a href="https://en.wikipedia.org/wiki/Dot_product">dot product</a> calculation between the stored dimensions and the query dimensions, which would score this example at 10.84:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt03108313e79d6e7d/6a17d725a29299397cd02ae1/53baae570e66ebc66f0fa47b406436e2c22b3162-816x452.png" alt="Dot product scoring example" /><h2>Sparse vector queries with inference</h2><p>Sparse vector queries using inference work in a very similar way to the previous text expansion query, instead of sending in a trained model, we <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/put-inference-api.html">create an inference endpoint</a> associated with the model we want to use.</p><p>Here’s an example of how to create an inference endpoint for ELSER:</p>PUT _inference/sparse_embedding/my-elser-endpoint
{
  "service": "elser",
  "service_settings": {
    "num_allocations": 1,
    "num_threads": 1
  }
}
<p>You should use an inference endpoint to index your sparse vector data, and use the same endpoint as input to your sparse_vector query. For example:</p>POST my-index/_search
{
  "query": {
    "sparse_vector": {
      "field": "embeddings",
      "inference_id": "my-elser-endpoint",
      "query": "constellations in the northern hemisphere"
    }
  }
}
<h2>Sparse vector queries with precomputed query vectors</h2><p>You may have precomputed vectors that don’t require inference at query time. These can be sent into the sparse_vector query instead of using inference. Here is an example:</p>POST my-index/_search
{
  "query": {
    "sparse_vector": {
      "field": "embeddings",
      "query_vector": {
        "constellation": 2.5,
        "northern": 1.9,
        "hemisphere": 1.8,
        "orion": 1.5,
        "galaxy": 1.4,
        "astronomy": 0.9,
        "telescope": 0.3,
        "star": 0.01
      }
    }
  }
}
<h2>Query optimization with token pruning</h2><p>Like text expansion search, the sparse vector query is subject to performance penalties from huge boolean queries. Therefore the same <a href="https://www.elastic.co/search-labs/blog/text-expansion-pruning">token pruning strategies</a> available for text expansion strategies are available in the sparse vector query. You can see the impact of token pruning in our <a href="https://elasticsearch-benchmarks.elastic.co/index.html#tracks/msmarco-passage-ranking/nightly/default/90d">nightly MS Marco Passage Ranking benchmarks</a>.</p><p>In order to enable pruning with the default pruning configuration (which has been tuned for ELSER V2), simply add <code>prune: true</code> to your request:</p>POST my-index/_search
{
  "query": {
    "sparse_vector": {
      "field": "embeddings",
      "inference_id": "my-elser-endpoint",
      "query": "constellations in the northern hemisphere",
      "prune": true
    }
  }
}
<p>Alternately, you can adjust the pruning configuration by sending it directly in with the request:</p>GET my-index/_search
{
   "query":{
      "sparse_vector":{
         "field": "embeddings",
         "inference_id": "my-elser-endpoint",
         "query": "constellations in the northern hemisphere",
         "prune": true,
         "pruning_config": {
           "tokens_freq_ratio_threshold": 5,
           "tokens_weight_threshold": 0.4,
           "only_score_pruned_tokens": false
         }
      }
   }
}
<p>Because token pruning will incur a recall penalty, we recommend adding the pruned tokens back in a rescore:</p>GET my-index/_search
{
   "query":{
      "sparse_vector":{
         "field": "embeddings",
         "inference_id": "my-elser-endpoint",
         "query": "constellations in the northern hemisphere",
         "prune": true,
         "pruning_config": {
           "tokens_freq_ratio_threshold": 5,
           "tokens_weight_threshold": 0.4,
           "only_score_pruned_tokens": false
         }
      }
   },
   "rescore": {
      "window_size": 100,
      "query": {
         "rescore_query": {
            "sparse_vector": {
               "field": "embeddings",
               "inference_id": "my-elser-endpoint",
               "query": "constellations in the northern hemisphere",
               "prune": true,
               "pruning_config": {
                   "tokens_freq_ratio_threshold": 5,
                   "tokens_weight_threshold": 0.4,
                   "only_score_pruned_tokens": true
               }
            }
         }
      }
   }
}
<h2>What's next?</h2><p>While the <code>text_expansion</code> query is GA’d and will be supported throughout Elasticsearch 8.x, we recommend updating to the <code>sparse_vector</code> query as soon as possible in order to ensure you’re using the most up to date features as we continually improve the vector search experience in Elasticsearch.</p><p>If you are using the <code>weighted_tokens</code> query, this was never GA’d and will be replaced by the sparse_vector query very soon.</p><p>The <code>sparse_vector</code> query will be available starting with 8.15.0 and is already available in Serverless - try it out today!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-sparse-vector-query</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-sparse-vector-query</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt03108313e79d6e7d/6a17d725a29299397cd02ae1/53baae570e66ebc66f0fa47b406436e2c22b3162-816x452.png" length="0" type="image/png"/>
    <pubDate>Tue, 23 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Evaluating search relevance part 1 - The BEIR benchmark]]></title>
    <description><![CDATA[Learn to evaluate your search system in the context of better understanding the BEIR benchmark, with tips &amp; techniques to improve your search evaluation processes.]]></description>
    <content:encoded><![CDATA[<p>This is the first in a series of blog posts discussing how to think about evaluating your own search systems in the context of better understanding the BEIR benchmark. We will introduce specific tips and techniques to improve your search evaluation processes in the context of better understanding BEIR. We will also introduce common gotchas which make evaluation less reliable. Finally, we note that LLMs provide a powerful new tool in the search engineers' arsenal and we will show by example how one can use them to help evaluate search.</p><h2>Understanding the BEIR benchmark in search relevance evaluation</h2><p>To improve any system you need to be able to measure how well it is doing. In the context of search <a href="https://arxiv.org/abs/2104.08663">BEIR</a> (or equivalently the Retrieval section of the <a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB</a> leaderboard) is considered the “holy grail” for the information retrieval community and there is no surprise in that. It’s a very well-structured benchmark with varied datasets across different tasks. More specifically, the following areas are covered:</p><ul><li><p>Argument retrieval (ArguAna, Touche2020)</p></li><li><p>Open-domain QA (HotpotQA, Natural Questions, FiQA)</p></li><li><p>Passage retrieval (MSMARCO)</p></li><li><p>Duplicate question retrieval (Quora, CQADupstack)</p></li><li><p>Fact-checking (FEVER, Climate-FEVER, Scifact)</p></li><li><p>Biomedical information retrieval (TREC-COVID, NFCorpus, BioASQ)</p></li><li><p>Entity retrieval (DBPedia)</p></li><li><p>Citation prediction (SCIDOCS)</p></li></ul><p>It provides a single statistic, nDCG@10, related to how well a system matches the most relevant documents for each task example in the top results it returns. For a search system that a human interacts with relevance of top results is critical. However, there are many nuances to evaluating search that a single summary statistic misses.</p><h2>Structure of a BEIR dataset</h2><p>Each benchmark has three artefacts:</p><ul><li><p>the corpus or documents to retrieve</p></li><li><p>the queries</p></li><li><p>the relevance judgements for the queries (aka <code>qrels</code>).</p></li></ul><p>Relevance judgments are provided as a score which is zero or greater. Non-zero scores indicate that the document is somewhat related to the query.</p><p>Dataset</p><p>Corpus size</p><p>#Queries in the test set</p><p>#qrels positively labeled</p><p>#qrels equal to zero</p><p>#duplicates in the corpus</p><p>Arguana</p><p>8,674</p><p>1,406</p><p>1,406</p><p>0</p><p>96</p><p>Climate-FEVER</p><p>5,416,593</p><p>1,535</p><p>4,681</p><p>0</p><p>0</p><p>DBPedia</p><p>4,635,922</p><p>400</p><p>15,286</p><p>28,229</p><p>0</p><p>FEVER</p><p>5,416,568</p><p>6,666</p><p>7,937</p><p>0</p><p>0</p><p>FiQA-2018</p><p>57,638</p><p>648</p><p>1,706</p><p>0</p><p>0</p><p>HotpotQA</p><p>5,233,329</p><p>7,405</p><p>14,810</p><p>0</p><p>0</p><p>Natural Questions</p><p>2,681,468</p><p>3,452</p><p>4,021</p><p>0</p><p>16,781</p><p>NFCorpus</p><p>3,633</p><p>323</p><p>12,334</p><p>0</p><p>80</p><p>Quora</p><p>522,931</p><p>10,000</p><p>15,675</p><p>0</p><p>1,092</p><p>SCIDOCS</p><p>25,657</p><p>1,000</p><p>4,928</p><p>25,000</p><p>2</p><p>Scifact</p><p>5,183</p><p>300</p><p>339</p><p>0</p><p>0</p><p>Touche2020</p><p>382,545</p><p>49</p><p>932</p><p>1,982</p><p>5,357</p><p>TREC-COVID</p><p>171,332</p><p>50</p><p>24,763</p><p>41,663</p><p>0</p><p>MSMARCO</p><p>8,841,823</p><p>6,980</p><p>7,437</p><p>0</p><p>324</p><p>CQADupstack (sum)</p><p>457,199</p><p>13,145</p><p>23,703</p><p>0</p><p>0</p><p><strong>Table 1</strong>: Dataset statistics. The numbers were calculated on the test portion of the datasets (<code>dev</code> for <code>MSMARCO</code>).</p><p><strong>Table 1</strong> presents some statistics for the datasets that comprise the <code>BEIR</code> benchmark such as the number of documents in the corpus, the number of queries in the test dataset and the number of positive/negative (query, doc) pairs in the <code>qrels</code> file. From a quick a look in the data we can immediately infer the following:</p><ul><li><p>Most of the datasets do not contain any negative relationships in the <code>qrels</code> file, i.e. zero scores, which would explicitly denote documents as irrelevant to the given query.</p></li><li><p>The average number of document relationships per query (<code>#qrels</code> / <code>#queries</code>) varies from 1.0 in the case of <code>ArguAna</code> to 493.5 (<code>TREC-COVID</code>) but with a value <code>&lt;</code>5 for the majority of the cases.</p></li><li><p>Some datasets suffer from duplicate documents in the corpus which in some cases may lead to incorrect evaluation i.e. when a document is considered relevant to a query but its duplicate is not. For example, in <code>ArguAna</code> we have identified 96 cases of duplicate doc pairs with only one doc per pair being marked as relevant to a query. By “expanding” the initial qrels list to also include the duplicates we have observed a relative increase of ~1% in the <code>nDCG@10</code> score on average.</p></li></ul>{
  "_id": "test-economy-epiasghbf-pro02b",
  "title": "economic policy international africa society gender house believes feminisation",
  "text": "Again employment needs to be contextualised with …",
  "metadata": {}
}
{
  "_id": "test-society-epiasghbf-pro02b",
  "title": "economic policy international africa society gender house believes feminisation",
  "text": "Again employment needs to be contextualised with …",
  "metadata": {}
}
<p><strong>Example of duplicate pairs in ArguAna. In the qrels file only the first appears to be relevant (as counter-argument) to query (“test-economy-epiasghbf-pro02a”)</strong></p><p>When comparing models on the MTEB leaderboard it is tempting to focus on average retrieval quality. This is a good proxy to the overall quality of the model, but it doesn't necessarily tell you how it will perform for you. Since results are reported per data set, it is worth understanding how closely the different data sets relate to your search task and rescore models using only the most relevant ones. If you want to dig deeper, you can additionally check for topic overlap with the various data set corpuses. Stratifying quality measures by topic gives a much finer-grained assessment of their specific strengths and weaknesses.</p><p>One important note here is that when a document is not marked in the <code>qrels</code> file then by default it is considered irrelevant to the query. We dive a little further into this area and collect some evidence to shed more light on the following question: “How often is an evaluator presented with (query, document) pairs for which there is no ground truth information?". The reason that this is important is that when only shallow markup is available (and thus not every relevant document is labeled as such) one Information Retrieval system can be judged worse than another just because it “chooses” to surface different relevant (but unmarked) documents. This is a common gotcha in creating high quality evaluation sets, particularly for large datasets. To be feasible manual labelling usually focuses on top results returned by the current system, so potentially misses relevant documents in its blind spots. Therefore, it is usually preferable to focus more resources on fuller mark up of fewer queries than broad shallow markup.</p><h2>Leveraging the BEIR benchmark for search relevance evaluation</h2><p>To initiate our analysis we implement the following scenario (see the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/evaluating-search-relevance-part-1/retrieve-and-rerank.ipynb">notebook</a>):</p><ol><li><p>First, we load the corpus of each dataset into an Elasticsearch index.</p></li><li><p>For each query in the test set we retrieve the top-100 documents with BM25.</p></li><li><p>We rerank, the retrieved documents using a variety of SOTA reranking models.</p></li><li><p>Finally, we report the “judge rate” for the top-10 documents coming from steps 2 (after retrieval) and 3 (after reranking). In other words, we calculate the average percentage of the top-10 documents that have a score in the <code>qrels</code> file.</p></li></ol><p>The list of reranking of models we used is the following:</p><ul><li><p><a href="https://docs.cohere.com/reference/rerank">Cohere's</a> <code>rerank-english-v2.0</code> and <code>rerank-english-v3.0</code></p></li><li><p><a href="https://huggingface.co/BAAI/bge-reranker-base">BGE-base</a></p></li><li><p><a href="https://huggingface.co/mixedbread-ai/mxbai-rerank-xsmall-v1">mxbai-rerank-xsmall-v1</a></p></li><li><p><a href="https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2">MiniLM-L-6-v2</a></p></li></ul><p></p><p>Retrieval</p><p>Reranking</p><p></p><p></p><p></p><p></p><p>Dataset</p><p>BM25 (%)</p><p>Cohere Rerank v2 (%)</p><p>Cohere Rerank v3 (%)</p><p>BGE-base (%)</p><p>mxbai-rerank-xsmall-v1 (%)</p><p>MiniLM-L-6-v2 (%)</p><p>Arguana</p><p>7.54</p><p>4.87</p><p>7.87</p><p>4.52</p><p>4.53</p><p>6.84</p><p>Climate-FEVER</p><p>5.75</p><p>6.24</p><p>8.15</p><p>9.36</p><p>7.79</p><p>7.58</p><p>DBPedia</p><p>61.18</p><p>60.78</p><p>64.15</p><p>63.9</p><p>63.5</p><p>67.62</p><p>FEVER</p><p>8.89</p><p>9.97</p><p>10.08</p><p>10.19</p><p>9.88</p><p>9.88</p><p>FiQa-2018</p><p>7.02</p><p>11.02</p><p>10.77</p><p>8.43</p><p>9.1</p><p>9.44</p><p>HotpotQA</p><p>12.59</p><p>14.5</p><p>14.76</p><p>15.1</p><p>14.02</p><p>14.42</p><p>Natural Questions</p><p>5.94</p><p>8.84</p><p>8.71</p><p>8.37</p><p>8.14</p><p>8.34</p><p>NFCorpus</p><p>31.67</p><p>32.9</p><p>33.91</p><p>30.63</p><p>32.77</p><p>32.45</p><p>Quora</p><p>12.2</p><p>10.46</p><p>13.04</p><p>11.26</p><p>12.58</p><p>12.78</p><p>SCIDOCS</p><p>8.62</p><p>9.41</p><p>9.71</p><p>8.04</p><p>8.79</p><p>8.52</p><p>Scifact</p><p>9.07</p><p>9.57</p><p>9.77</p><p>9.3</p><p>9.1</p><p>9.17</p><p>Touche2020</p><p>38.78</p><p>30.41</p><p>32.24</p><p>33.06</p><p>37.96</p><p>33.67</p><p>TREC-COVID</p><p>92.4</p><p>98.4</p><p>98.2</p><p>93.8</p><p>99.6</p><p>97.4</p><p>MSMARCO</p><p>3.97</p><p>6.00</p><p>6.03</p><p>6.07</p><p>5.47</p><p>6.11</p><p>CQADupstack (avg.)</p><p>5.47</p><p>6.32</p><p>6.87</p><p>5.89</p><p>6.22</p><p>6.16</p><p><strong>Table 2</strong>: Judge rate per (dataset, reranker) pairs calculated on the top-10 retrieved/reranked documents</p><p>From <strong>Table 2</strong>, with the exception of <code>TREC-COVID</code> (&gt;90% coverage), <code>DBPedia</code> (~65%), <code>Touche2020</code> and <code>nfcorpus</code> (~35%), we see that the majority of the datasets have a labeling rate between 5% and a little more than 10% after retrieval or reranking. This doesn’t mean that all these unmarked documents are relevant but there might be a subset of them -especially those placed in the top positions- that could be positive.</p><p>With the arrival of general purpose instruction tuned language models, we have a new powerful tool which can potentially automate judging relevance. These methods are typically far too computationally expensive to be used online for search, but here we are concerned with offline evaluation. In the following we use them to explore the evidence that some of the BEIR datasets suffer from shallow markup.</p><p>In order to further investigate this hypothesis we decided to focus on MSMARCO and select a subset of 100 queries along with the top-5 reranked (with Cohere v2) documents which are currently not marked as relevant. We followed two different paths of evaluation: First, we used a carefully tuned prompt (more on this in a later post) to prime the recently released <a href="https://huggingface.co/microsoft/Phi-3-mini-4k-instruct">Phi-3-mini-4k</a> model to predict the relevance (or not) of a document to the query. In parallel, these cases were also manually labeled in order to also assess the agreement rate between the LLM output and human judgment. Overall, we can draw the following two conclusions:</p><ul><li><p>The agreement rate between the LLM responses and human judgments was close to 80% which seems good enough as a starting point in that direction.</p></li><li><p>In 57.6% of the cases (based on human judgment) the returned documents were found to be actually relevant to the query. To state this in a different way: For 100 queries we have 107 documents judged to be relevant, but at least 0.576 x 5 x 100 = 288 extra documents which are actually relevant!</p></li></ul><p>Here, some examples drawn from the <code>MSMARCO</code>/<code>dev</code> dataset which contain the query, the annotated positive document (from <code>qrels</code>) and a false negative document due to incomplete markup:</p><p>Example 1:</p>{
  "query":
    {
        "_id": 155234,
        "text": "do bigger tires affect gas mileage"
    },
  "positive_doc":
    {
        "_id": 502713,
        "text": "Tire Width versus Gas Mileage. Tire width is one of the only tire size factors that can influence gas mileage in a positive way. For example, a narrow tire will have less wind resistance, rolling resistance, and weight; thus increasing gas mileage.",
    },
    "negative_doc":
    {
        "_id": 7073658,
        "text": "Tire Size and Width Influences Gas Mileage. There are two things to consider when thinking about tires and their effect on gas mileage; one is wind resistance, and the other is rolling resistance. When a car is driving at higher speeds, it experiences higher wind resistance; this means lower fuel economy."
    }
}
<p>Example 2:</p>{
  "query":
    {
        "_id": 300674,
        "text": "how many years did william bradford serve as governor of plymouth colony?"
    },
  "positive_doc":
    {
        "_id": 7067032,
        "text": "http://en.wikipedia.org/wiki/William_Bradford_(Plymouth_Colony_governor) William Bradford (c.1590 \u00e2\u0080\u0093 1657) was an English Separatist leader in Leiden, Holland and in Plymouth Colony was a signatory to the Mayflower Compact. He served as Plymouth Colony Governor five times covering about thirty years between 1621 and 1657."
    },
    "negative_doc":
    {
        "_id": 2495763,
        "text": "William Bradford was the governor of Plymouth Colony for 30 years. The colony was founded by people called Puritans. They were some of the first people from England to settle in what is now the United States. Bradford helped make Plymouth the first lasting colony in New England."
    }
}
<p>Manually evaluating specific queries like this is a generally useful technique for understanding search quality that complements quantitive measures like nDCG@10. If you have a representative set of queries you always run when you make changes to search, it gives you important qualitative information about how performance changes, which is invisible in the statistics. For example, it gives you much more insight into the false results your search returns: it can help you spot obvious howlers in retrieved results, classes of related mistakes, such as misinterpreting domain-specific terminology, and so on.</p><p>Our result is in agreement with relevant research around <code>MSMARCO</code> evaluation. For example, <a href="https://arxiv.org/pdf/2109.00062">Arabzadeh et al.</a> follow a similar procedure where they employ crowdsourced workers to make preference judgments: among other things, they show that in many cases the documents returned by the reranking modules are preferred compared to the documents in the MSMARCO <code>qrels</code> file. Another piece of evidence comes from the authors of the <a href="https://arxiv.org/pdf/2010.08191">RocketQA</a> reranker who report that more than 70% of the reranked documents were found relevant after manual inspection.</p><p> Update - September 9th: After a careful re-evaluation of the dataset we identified 15 more cases of relevant documents, increasing their total number from 273 to 288</p><h2>Main takeaways &amp; next steps</h2><ul><li><p>The pursuit for better ground truth is never-ending as it is very crucial for benchmarking and model comparison. LLMs can assist in some evaluation areas if used with caution and tuned with proper instructions</p></li><li><p>More generally, given that benchmarks will never be perfect, it might be preferable to switch from a pure score comparison to more robust techniques capturing statistically significant differences. The work of <a href="https://arxiv.org/pdf/2109.00062">Arabzadeh et al.</a> provides a nice of example of this where based on their findings they build 95% confidence intervals indicating significant (or not) differences between the various runs. In the accompanying <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/evaluating-search-relevance-part-1/retrieve-and-rerank.ipynb">notebook</a> we provide an implementation of confidence intervals using <a href="https://en.wikipedia.org/wiki/Bootstrapping_(statistics)">bootstrapping</a>.</p></li><li><p>From the end-user perspective it’s useful to think about task alignment when reading benchmark results. For example, for an AI engineer who builds a RAG pipeline and knows that the most typical use case involves assembling multiple pieces of information from different sources, then it would be more meaningful to assess the performance of their retrieval model on multi-hop QA datasets like HotpotQA instead of the global average across the whole BEIR benchmark</p></li></ul><p>In the <a href="https://www.elastic.co/search-labs/blog/evaluating-search-relevance-part-2">next blog post</a> we will dive deeper into the use of Phi-3 as LLM judge and the journey of tuning it to predict relevance.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/evaluating-search-relevance-part-1</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/evaluating-search-relevance-part-1</guid>
    <category><![CDATA[ML Research]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Thanos Papaoikonomou,Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9d9912c8d4187096/6a1704f5b0367d30e672bc17/54a6e5197f5721b36fc65f27387d29803ed35589-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 16 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Introducing Learning To Rank (LTR) in Elasticsearch]]></title>
    <description><![CDATA[Discover how Learning To Rank (LTR) can help you to improve your search ranking and how to implement it in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Starting with Elasticsearch 8.13, we provide an implementation of <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a> (LTR) natively integrated into Elasticsearch. LTR uses a trained machine learning (ML) model to build a ranking function for your search engine. Typically, the model is used as a second stage re-ranker, to improve the relevance of search results returned by a simpler, first stage retrieval algorithm.</p><p>This blog post will explain how this new feature can help in improving your document ranking in text search and how to implement it in Elasticsearch.</p><p>Whether you are trying to optimize an eCommerce search, build the best context for a Retrieval Augmented Generation(RAG) application or craft a question answering based search on millions of academic papers, you have probably realized how challenging it can be to accurately optimize document ranking in a search engine. That's where Learning to Rank comes in.</p><h2>Understanding relevance features and how to build a scoring function</h2><p>Relevance features are the signals to determine how well a document matches a user's query or interest, all of which impact <a href="https://www.elastic.co/what-is/search-relevance">search relevance</a>. These features can vary significantly depending on the context, but they generally fall into several categories. Let’s take a look at some common relevance features used across different domains:</p><ul><li><p><strong>Text Relevance Scores</strong> (e.g., <a href="https://www.elastic.co/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables">BM25</a>, TF-IDF): Scores derived from text matching algorithms that measure the similarity of document content to the search query. These scores can be obtained from Elasticsearch.</p></li><li><p><strong>Document Properties</strong> (e.g., price of a product, publication date): Features that can be extracted directly from the stored document.</p></li><li><p><strong>Popularity Metrics</strong> (e.g., click-through rate, views): Indicators of how popular or frequently accessed a document is. Popularity metrics can be obtained with <a href="https://www.elastic.co/enterprise-search/search-analytics">Search analytics</a> tools, of which Elasticsearch provides out-of-the-box.</p></li></ul><p>The scoring function combines these features to produce a final relevance score for each document. Documents with higher scores are ranked higher in search results.</p><p>When using the Elasticsearch Query DSL, you are implicitly writing a scoring function that weights relevance features and ultimately defines your search relevance</p><h2>Scoring in the Elasticsearch Query DSL</h2><p>Consider the following example query:</p>{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "the quick brown fox",
          "fields": ["title^10", "content"]
        }
      },
      "field_value_factor": {
        "field": "monthly_views",
        "modifier": "log1p"
      }
    }
  }
}
<p>This query translates into the following scoring function:</p>score = 10 x title_bm25_score + content_bm25_score + log(1+ monthly_views)
<p>While this approach works well, it has a few limitations:</p><ul><li><p><strong>Weights are estimated</strong>: The weights assigned to each feature are often based on heuristics or intuition. These guesses may not accurately reflect the true importance of each feature in determining relevance.</p></li><li><p><strong>Uniform Weights Across Documents</strong>: Manually assigned weights apply uniformly to all documents, ignoring potential interactions between features and how their importance might vary across different queries or document types. For instance, the relevance of recency might be more significant for news articles but less so for academic papers.</p></li></ul><p>As the number of features and documents increases, these limitations become more pronounced, making it increasingly challenging to determine accurate weights. Ultimately, the chosen weights become a compromise, potentially leading to suboptimal ranking in many scenarios.</p><p>A compelling alternative is to replace the scoring function that uses manual weights by a ML-based model that computes the score using relevance features.</p><h2>Hello Learning To Rank (LTR)!</h2><p><a href="https://www.microsoft.com/en-us/research/uploads/prod/2016/02/MSR-TR-2010-82.pdf">LambdaMART</a> is a popular and effective LTR technique that uses gradient boosting decision trees <a href="https://en.wikipedia.org/wiki/Gradient_boosting#Gradient_tree_boosting">(GBDT</a>) to learn the optimal scoring function from a judgment list.</p><p>The judgment list is a dataset that contains pairs of queries and documents, along with their corresponding relevance labels or grades. Relevance labels are typically either binary, (e.g. relevant/irrelevant) or graded (e.g between 0 for completely irrelevant and 4 for highly relevant). Judgment lists can be created manually by humans or be generated from user engagement data, such as clicks or conversions.</p><p>The example below uses a graded relevance judgment.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt36917248d619fee7/6a170e0b66c4f9484cf8c0d0/297f931b6b1565aaf4b7de9648fa73f145337c45-798x560.png" alt="judment list example" /><p>LambdaMART treats the ranking problem as a regression task using a decision tree where the inner nodes of the tree are conditions over the relevance features, and the leaves are the predicted scores.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c683e8ebff2a960/6a170e0d4a531be33636a9e7/07ce40d7902e8b7cbd6246a369c7f36191c18937-1440x864.png" alt="decision tree example" /><p>LambdaMART uses a gradient boosted tree approach, and in the training process it builds multiple decision trees where each tree corrects errors of its predecessors. This process aims to optimize a ranking metric like NDCG, based on examples from the judgment list. The final model is a weighted sum of individual trees.</p><p><a href="https://xgboost.readthedocs.io/en/stable/">XGBoost</a> is a well known library that provides an <a href="https://xgboost.readthedocs.io/en/stable/tutorials/learning_to_rank.html">implementation</a> of LambdaMART, making it a popular choice to implement ranking based on gradient boosting decision trees.</p><h2>Getting started with LTR in Elasticsearch</h2><p>Starting with version 8.13, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a> is integrated directly into Elasticsearch and associated tooling as a technical preview feature.</p><h3>Train and deploy an LTR model to Elasticsearch</h3><p><a href="https://eland.readthedocs.io/en/v8.13.1/">Eland</a> is our Python client and toolkit for DataFrames and machine learning in Elasticsearch. Eland is compatible with most of the standard Python data science tools like Pandas, scikit-learn and XGBoost.</p><p>We highly recommend using it to train and deploy your LTR XGBoost model, as it provides features to simplify this process:</p><ol><li><p>The first step of the training process is to define the relevant features of the LTR model. Using the Python code below, you can specify the relevant features using the Elasticsearch Query DSL.</p></li></ol>from eland.ml.ltr import LTRModelConfig, QueryFeatureExtractor

feature_extractors=[
    # We want to use the score of the match query for the fields title and content as a feature:
    QueryFeatureExtractor(
        feature_name="title_bm25_score",
        query={"match": {"title": "{{query_text}}"}}
    ),
    QueryFeatureExtractor(
        feature_name="content_bm25_score",
        query={"match": {"content": "{{query_text}}"}}
    ),
    # We can use a script_score query to get the value
    # of the field popularity directly as a feature
    QueryFeatureExtractor(
        feature_name="popularity",
        query={
            "script_score": {
                "query": {"exists": {"field": "popularity"}},
                "script": {"source": "return doc['popularity'].value;"},
            }
        },
    )
]

ltr_config = LTRModelConfig(feature_extractors)
<ol><li><p>The second step of the process is to build your training dataset. At this step you will compute and add relevance features for each rows of your judgment list:</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a1668945beda7f7/6a170e0e8b73cb45ff18a0c0/9648cf6585f91bba82502548bf48092c5d3ce251-1360x718.png" alt="judgment kist with features example" /><p>To help you with this task, Eland provides the FeatureLogger class:</p>from eland.ml.ltr import FeatureLogger

feature_logger = FeatureLogger(es_client, MOVIE_INDEX, ltr_config)

feature_logger.extract_features(
    query_params={"query": "foo"},
    doc_ids=["doc-1", "doc-2"]
)
<ol><li><p>When the training dataset is built, the model is trained very easily (as also shown in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">notebook</a>):</p></li></ol>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,
)
<ol><li><p>Deploy your model to Elasticsearch once the training process is complete:</p></li></ol>from eland.ml import MLModel

LEARNING_TO_RANK_MODEL_ID = "ltr-model-xgboost"

MLModel.import_ltr_model(
    es_client=es_client,
    model=trained_model,
    model_id=LEARNING_TO_RANK_MODEL_ID,
    ltr_model_config=ltr_config,
    es_if_exists="replace",
)
<p>To learn more about how our tooling can help you to train and deploy the model, check out this end-to-end <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">notebook</a>.</p><h3>Use your LTR model as a rescorer in Elasticsearch</h3><p>Once you deploy your model in Elasticsearch, you can enhance your search results through a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.13/filter-search-results.html#rescore">rescorer</a>. The rescorer allows you to refine a first-pass ranking of search results using the more sophisticated scoring provided by your LTR model:</p>GET my-index/_search
{
  "query": {
    "multi_match": {
      "fields": ["title", "content"],
      "query": "the quick brown fox"
    }
  },
  "rescore": {
    "learning_to_rank": {
      "model_id": "ltr-model-xgboost",
      "params": {
        "query_text": "the quick brown fox"
      }
    },
    "window_size": 100
  }
}
<p>In this example:</p><ul><li><p>First-pass query: <code>The multi_match</code> query retrieves documents that match the query <code>the quick brown fox</code> in the title and content fields. This query is designed to be fast and capture a large set of potentially relevant documents.</p></li><li><p>Rescore phase: The <code>learning_to_rank</code> rescorer refines the top results from the first-pass query using the LTR model. </p><ul><li><p><code>model_id</code>: Specifies the ID of the deployed LTR model (<code>ltr-model-xgboost</code> in our example).</p></li><li><p><code>params</code>: Provides any parameters required by the LTR model to extract features relevant to the query. Here <code>query_text</code> allows you to specify the query issued by the user that some of our features extractors expect.</p></li><li><p><code>window_size</code>: Defines the number of top documents from the search results issued by the first-pass query to be rescored. In this example, the top 100 documents will be rescored.</p></li></ul></li></ul><p>By integrating LTR as a two stage retrieval process, you can can optimize both performance and accuracy of your retrieval process by combining:</p><ul><li><p>Speed of Traditional Search: The first-pass query retrieves a large number of documents with a broad match very quickly, ensuring fast response times.</p></li><li><p>Precision of Machine Learning Models: The LTR model is applied only to the top results, refining their ranking to ensure optimal relevance. This targeted application of the model enhances precision without compromising overall performance.</p></li></ul><h2>Try LTR yourself!?</h2><p>Whether you are struggling to configure search relevance for an eCommerce platform, aiming to improve the context relevance of your RAG application, or you are simply curious about enhancing your existing search engine's performance, you should consider LTR seriously.</p><p>To start your journey with implementing LTR, make sure to visit our <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">notebook</a> detailing how to train, deploy, and use an LTR model in Elasticsearch and to read our <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb#Building-the-training-dataset">documentation</a>. Let us know if you built anything based on this blog post or if you have questions on our <a href="https://discuss.elastic.co/">Discuss forums</a> and <a href="https://communityinviter.com/apps/elasticstack/elastic-community">the community Slack channel</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction</guid>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Aurélien Foucret]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4a3fba607900049/6a170e0fcdacbf8fe17d2a7a/8b3b5910abfe16d48d309341a0027008b16c4340-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 15 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch vs. OpenSearch: Vector Search Performance Comparison]]></title>
    <description><![CDATA[Elasticsearch is out-of-the-box 2x–12x faster than OpenSearch for vector search]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-opensearch-vector-search-performance-comparison#up-to-12x-faster-out-of-the-box">TLDR: Elasticsearch is up to 12x faster</a> - We at Elastic have received numerous requests from our community to clarify the performance differences between Elasticsearch and OpenSearch, particularly in the realm of Semantic Search / Vector Search, so we have undertaken this performance testing to provide a clear, data-driven comparison — no ambiguity, just straightforward facts to inform our users. The results show that <strong>Elasticsearch is up to 12x faster</strong> than OpenSearch for vector search and therefore requires fewer computational resources. This reflects Elastic's focus on consolidating Lucene as the best vector database for search and retrieval use cases.</p><p>Vector search is revolutionizing the way we conduct similarity searches, particularly in fields like AI and machine learning. With the increasing adoption of vector embedding models, the ability to efficiently search through millions of high-dimension vectors becomes critical.</p><p>When it comes to powering vector databases, Elastic and OpenSearch have taken notably different approaches. Elastic has invested heavily in optimizing Apache Lucene together with Elasticsearch to elevate them as the top-tier choice for vector search applications. In contrast, OpenSearch has broadened its focus, integrating other vector search implementations and exploring beyond Lucene's scope. Our focus on Lucene is strategic, enabling us to provide highly integrated support in our version of Elasticsearch, resulting in an enhanced feature set where each component complements and amplifies the capabilities of the other.</p><p>This blog presents a detailed comparison between Elasticsearch 8.14 and OpenSearch 2.14 accounting for different configurations and vector engines. In this performance analysis, Elasticsearch proved to be the superior platform for vector search operations, and upcoming <a href="https://www.elastic.co/search-labs/blog/vector-similarity-computations-ludicrous-speed">features</a> will widen the differences even more <a href="https://www.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains">significantly</a>. When pitted against OpenSearch, it excelled in every benchmark track — <strong>offering 2x to 12x faster performance on average</strong>. This was across scenarios using varying vector amounts and dimensions including <code>so_vector</code> (2M vectors, 768D), <code>openai_vector</code> (2.5M vectors, 1536D), and <code>dense_vector</code> (10M vectors, 96D), all available in <a href="https://github.com/elastic/elasticsearch-opensearch-vector-performance">this repository</a> alongside the Terraform scripts for provisioning all the required infrastructure on Google Cloud and Kubernetes manifests for running the tests.</p><p>The results detailed in this blog complement the results from a <a href="https://www.elastic.co/blog/elasticsearch-opensearch-performance-gap">previously published and third-party validated study</a> that shows Elasticsearch is 40%–140% faster than OpenSearch for the most common search analytics operations: Text Querying, Sort, Range, Date Histogram and Terms filtering. Now we can add another differentiator: Vector Search.</p><h2>Up to 12x faster out-of-the-box</h2><p>Our focused benchmarks across the four vector data sets involved both Approximate KNN and Exact KNN searches, considering different sizes, dimensions and configurations, totaling <code>40.189.820</code> uncached search requests. The results: <strong>Elasticsearch is up to 12x faster</strong> than OpenSearch for vector search and therefore requires fewer computational resources.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt34b83c6eba3bcb6e/6a17d727dbb4ff18d6fb54fb/cdb26e91f085b90e9b12aeb8fee53b04d365ecae-1440x1156.webp" alt="p90 average" /><p>Figure 1: Grouped tasks for ANN and Exact KNN across different combinations in Elasticsearch and OpenSearch.</p><p>The groups like <code>knn-10-100</code> means KNN search with  and . In HNSW vector search,  determines the number of nearest neighbors to retrieve for a query vector. It specifies how many similar vectors to find as a result.  sets the number of candidate vectors to retrieve at each segment. More candidates can enhance accuracy but require greater computational resources.</p><p>We also tested with different quantization techniques and leveraged engine-specific optimizations, the detailed results for each track, task and vector engine are available below.</p><h2>Exact KNN and Approximate KNN</h2><p>When dealing with varying data sets and use cases, the right approach for vector search will differ. In this blog all tasks stated as <code>knn-*</code> like <code>knn-10-100</code> use <strong>Approximate KNN</strong> and <code>script-score-*</code> refer to <strong>Exact KNN</strong>, but what is the difference between them, and why are they important?</p><p>In essence, if you're handling more substantial data sets, the preferred method is the Approximate K-Nearest Neighbor (ANN) due to its superior scalability. For more modest data sets that may require a filtration process, Exact KNN method is ideal.</p><p>Exact KNN uses a brute-force method, calculating the distance between one vector and every other vector in the data set. It then ranks these distances to find the  nearest neighbors. While this method ensures an exact match, it suffers from scalability challenges for large, high-dimensional data sets. However, there are many cases in which Exact KNN is needed:</p><ul><li><p><strong>Rescoring</strong>: In scenarios involving lexical or semantic searches followed by vector-based rescoring, Exact KNN is essential. For example, in a product search engine, initial search results can be filtered based on textual queries (e.g., keywords, categories), and then vectors associated with the filtered items are used for a more accurate similarity assessment.</p></li><li><p><strong>Personalization</strong>: When dealing with a large number of users, each represented by a relatively small number (like 1 million) of distinct vectors, sorting the index by user-specific metadata (e.g., user_id) and brute-force scoring with vectors becomes efficient. This approach allows for personalized recommendations or content delivery based on precise vector comparisons tailored to individual user preferences.</p></li></ul><p>Exact KNN therefore ensures that the final ranking and recommendations based on vector similarity are precise and tailored to user preferences.</p><p>Approximate KNN (or ANN) on the other hand employs methods to make data searching faster and more efficient than Exact KNN, especially in large, high-dimensional data sets. Instead of a brute-force approach, which measures the exact nearest distance between a query and all points leading to computation and scaling challenges, ANN uses certain techniques to efficiently restructure the indexes and dimensions of searchable vectors in the data set. While this may cause a slight inaccuracy, it significantly boosts the speed of the search process, making it an effective alternative for dealing with large data sets.</p><p>In this blog all tasks stated as <code>knn-*</code> like <code>knn-10-100</code> use <strong>Approximate KNN</strong> and <code>script-score-*</code> refer to <strong>Exact KNN</strong>.</p><h2>Testing methodology</h2><p>While Elasticsearch and OpenSearch are similar in terms of API for BM25 search operations, since the latter is a fork of the former, it is not the case for Vector Search, which was introduced after the fork. OpenSearch took a different approach than Elasticsearch when it comes to algorithms, by introducing two other engines — <code>nmslib</code> and <code>faiss</code> — apart from <code>lucene</code>, each with their specific configurations and limitations (e.g., <code>nmslib</code> in OpenSearch does not allow for filters, an essential feature for many use cases).</p><p>All three engines use the Hierarchical Navigable Small World (HNSW) algorithm, which is efficient for approximate nearest neighbor search, and especially powerful when dealing with high-dimensional data. It's important to note that <code>faiss</code> also supports a second algorithm, <code>ivf</code>, but since it requires pre-training on the data set, we are going to focus solely on HNSW. The core idea of HNSW is to organize the data into multiple layers of connected graphs, with each layer representing a different granularity of the data set. The search begins at the top layer with the coarsest view and progresses down to finer and finer layers until reaching the base level.</p><p>Both search engines were tested under identical conditions in a controlled environment to ensure fair testing grounds. The method applied is similar to <a href="https://www.elastic.co/blog/elasticsearch-opensearch-performance-gap#testing-methodology">this previously published performance comparison</a>, with dedicated node pools for Elasticsearch, OpenSearch, and Rally. The <a href="https://github.com/elastic/elasticsearch-opensearch-vector-performance/blob/main/terraform/main.tf">terraform script</a> is available (alongside all sources) to provision a Kubernetes cluster with:</p><ul><li><p>1 Node pool for Elasticsearch with 3 <code>e2-standard-32</code> machines (128GB RAM and 32 CPUs)</p></li><li><p>1 Node pool for OpenSearch with 3 <code>e2-standard-32</code> machines (128GB RAM and 32 CPUs)</p></li><li><p>1 Node pool for Rally with 2 <code>t2a-standard-16</code> machines (64GB RAM and 16 CPUs)</p></li></ul><p>Each "track" (or test) ran for 10 times for each configuration, which included different engines, different configurations and different vector types. The tracks have tasks that repeat between 1000 and 10000 times, depending on the track. If one of the tasks in a track failed for instance due to a network timeout, then all tasks were discarded, so all results represent tracks that started and finished without problems. All test results are statistically validated, ensuring that improvements aren’t coincidental.</p><h2>Detailed findings</h2><p>Why compare using the 99th percentile and not the average latency? Consider a hypothetical example of average house prices in a certain neighborhood. The average price may indicate an expensive area, but on closer inspection, it may turn out that most homes are valued much lower, with only a few luxury properties inflating the average figure. This illustrates how the average price can fail to accurately represent the full spectrum of house values in the area. This is akin to examining response times, where the average can conceal critical issues.</p><h4>Tasks</h4><ul><li><p>Approximate KNN with k:10 n:50</p></li><li><p>Approximate KNN with k:10 n:100</p></li><li><p>Approximate KNN with k:100 n:1000</p></li><li><p>Approximate KNN with k:10 n:50 and keyword filters</p></li><li><p>Approximate KNN with k:10 n:100 and keyword filters</p></li><li><p>Approximate KNN with k:100 n:1000 and keyword filters</p></li><li><p>Approximate KNN with k:10 n:100 in conjunction with indexing</p></li><li><p>Exact KNN (script score)</p></li></ul><h4>Vector engines</h4><ul><li><p><code>lucene</code> in Elasticsearch and OpenSearch, both on version 9.10</p></li><li><p><code>faiss</code> in OpenSearch</p></li><li><p><code>nmslib</code> in OpenSearch</p></li></ul><h4>Vector types</h4><ul><li><p><code>hnsw</code> in Elasticsearch and OpenSearch</p></li><li><p><code>int8_hnsw</code> in Elasticsearch (HNSW with automatic 8 bit quantization: <a href="https://www.elastic.co/search-labs/blog/evaluating-scalar-quantization">link</a>)</p></li><li><p><code>sq_fp16 hnsw </code>in OpenSearch (HNSW with automatic 16 bit quantization: <a href="https://opensearch.org/docs/2.14/search-plugins/knn/knn-vector-quantization#faiss-16-bit-scalar-quantization">link</a>)</p></li></ul><h4>Out-of-the-box and Concurrent Segment Search</h4><p>As you probably know, Lucene is a highly performant text search engine library written in Java that serves as the backbone for many search platforms like Elasticsearch, OpenSearch, and Solr. At its core, Lucene organizes data into segments, which are essentially self-contained indices that allow Lucene to execute searches more efficiently. So when you issue a search to any Lucene-based search engine, your search will end up being executed in those segments, either sequentially or in parallel.</p><p>OpenSearch introduced concurrent segment search as an optional flag, and does not use it by default, you must enable it using a special index setting <code>index.search.concurrent_segment_search.enabled</code> as detailed <a href="https://opensearch.org/docs/latest/search-plugins/concurrent-segment-search/">here</a>, with some <a href="https://opensearch.org/docs/latest/search-plugins/concurrent-segment-search/#other-considerations">limitations</a>.</p><p>Elasticsearch on the other hand searches on segments concurrently <a href="https://github.com/elastic/elasticsearch/pull/101230">out-of-the-box</a>, therefore the comparisons we make in this blog will take into consideration, on top of the different vector engines and vector types, also the different configurations:</p><ul><li><p>Elasticsearch ootb: Elasticsearch out-of-the-box, with concurrent segment search;</p></li><li><p>OpenSearch ootb: without concurrent segment search enabled;</p></li><li><p>OpenSearch css: with concurrent segment search enabled</p></li></ul><p>Now, let’s dive into some detailed results for each vector data set tested:</p><h2>2.5 million vectors, 1536 dimensions (openai_vector)</h2><p>Starting with the simplest track, but also the largest in terms of dimensions, <a href="https://github.com/elastic/rally-tracks/edit/master/openai_vector">openai_vector</a> - which uses the <a href="https://huggingface.co/datasets/BeIR/nq">NQ data set</a> enriched with embeddings generated using OpenAI's <a href="https://openai.com/blog/new-and-improved-embedding-model">text-embedding-ada-002 model</a>. It is the simplest since it tests only Approximate KNN and has only 5 tasks. It tests in standalone (without indexing) as well as alongside indexing, and using a single client and 8 simultaneous clients.</p><h3>Tasks</h3><ul><li><p><strong>standalone-search-knn-10-100-multiple-clients</strong>: searching on 2.5 million vectors with 8 clients simultaneously, k: 10 and n:100</p></li><li><p><strong>standalone-search-knn-100-1000-multiple-clients</strong>: searching on 2.5 million vectors with 8 clients simultaneously, k: 100 and n:1000</p></li><li><p><strong>standalone-search-knn-10-100-single-client</strong>: searching on 2.5 million vectors with a single client, k: 10 and n:100</p></li><li><p><strong>standalone-search-knn-100-1000-single-client</strong>: searching on 2.5 million vectors with a single client, k: 100 and n:1000</p></li><li><p><strong>parallel-documents-indexing-search-knn-10-100</strong>: searching on 2.5 million vectors while also indexing additional 100000 documents, k:10 and n:100</p></li></ul><p>The averaged p99 performance is outlined below:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf5791848eb0c12fb/6a17d72925daab9cae08a09e/eea0b2b49c690baada3e09d6968e513bfffe51a9-1440x318.webp" alt="openai_vector table" /><p>Here we observed that Elasticsearch is between <strong>3x-8x faster</strong> than OpenSearch when performing vector search alongside indexing (i.e. read+write) with :10 and :100 and <strong>2x-3x faster</strong> without indexing for the same k and n. For :100 and :1000 (<em>standalone-search-knn-100-1000-single-client</em> and <em>standalone-search-knn-100-1000-multiple-clients</em> Elasticsearch is <strong>2x to 7x</strong> faster than OpenSearch, on average.</p><p>The detailed results show the exact cases and vector engines compared:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdbe41d3187ced7ec/6a17d72a445de951c44cff4c/a7a761ed631d3e6211beb83d9d93d752d10123c9-1440x1728.webp" alt="openai_vector" /><h4>Recall</h4><p></p><p>knn-recall-10-100</p><p>knn-recall-100-1000</p><p>Elasticsearch-8.14.0@lucene-hnsw</p><p>0.969485</p><p>0.995138</p><p>Elasticsearch-8.14.0@lucene-int8_hnsw</p><p>0.781445</p><p>0.784817</p><p>OpenSearch-2.14.0@lucene-hnsw</p><p>0.96519</p><p>0.995422</p><p>OpenSearch-2.14.0@faiss</p><p>0.984154</p><p>0.98049</p><p>OpenSearch-2.14.0@faiss-sq_fp16</p><p>0.980012</p><p>0.97721</p><p>OpenSearch-2.14.0@nmslib</p><p>0.982532</p><p>0.99832</p><h2>10 million vectors, 96 dimensions (dense_vector)</h2><p>In <a href="https://github.com/elastic/rally-tracks/tree/master/dense_vector">dense_vector</a> with 10M vectors and 96 dimensions. It is based on the <a href="https://big-ann-benchmarks.com/">Yandex DEEP1B</a> image data set. The data set is created from the first 10 million vectors of the "sample data" file called <code>learn.350M.fbin</code>. The search operations use vectors from the "query data" file query.<code>public.10K.fbin</code>.</p><p>Both Elasticsearch and OpenSearch perform very well on this data set, especially after a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html">force merge</a> which is usually done on read-only indices and it’s similar to defragmenting the index to have a single "table" to search on.</p><h3>Tasks</h3><p>Each task warms up for 100 requests and then 1000 requests are measured</p><ul><li><p><strong>knn-search-10-100</strong>: searching on 10 million vectors, k: 10 and n:100</p></li><li><p><strong>knn-search-100-1000</strong>: searching on 10 million vectors, k: 100 and n:1000</p></li><li><p><strong>knn-search-10-100-force-merge</strong>: searching on 10 million vectors after a force merge, k: 10 and n:100</p></li><li><p><strong>knn-search-100-1000-force-merge</strong>: searching on 10 million vectors after a force merge, k: 100 and n:1000</p></li><li><p><strong>knn-search-100-1000-concurrent-with-indexing</strong>: searching on 10 million vectors while also updating <a href="https://github.com/elastic/rally-tracks/blob/master/dense_vector/challenges/default.json#L76C36-L76C37">5% of the data set</a>, k: 100 and n:1000</p></li><li><p><strong>script-score-query</strong>: Exact KNN search of <a href="https://github.com/elastic/rally-tracks/blob/master/dense_vector/queries.json">2000 specific vectors</a>.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4629d06af85eb96c/6a17d72c6864a423e7b685dc/174995e0a2156d86359cdb7aa446dfaae6312ea4-1440x316.webp" alt="dense_vector" /><p>Both Elasticsearch and OpenSearch performed well for Approximate KNN. When the index is merged (i.e. has just a single segment) in <em>knn-search-100-1000-force-merge</em> and <em>knn-search-10-100-force-merge</em>, OpenSearch performs better than the others when using <code>nmslib</code> and <code>faiss</code>, even though they are all around 15ms and all very close.</p><p>However, when the index has multiple segments (a typical situation where an index receives updates to its documents) in <em>knn-search-10-100</em> and <em>knn-search-100-1000</em>, Elasticsearch keeps the latency in about ~7ms and ~16ms, while all other OpenSearch engines are slower.</p><p>Also when the index is being searched and written to at the same time (<em>knn-search-100-1000-concurrent-with-indexing</em>), Elasticsearch maintains the latency below 15ms (at 13.8ms), being almost <strong>4x faster</strong> than OpenSearch out-of-the-box (49.3ms) and still faster when concurrent segment search is enabled (17.9ms), but too close to be significative.</p><p>As for Exact KNN, the difference is much larger: Elasticsearch <strong>is 6x faster</strong> than OpenSearch (~260ms vs ~1600ms).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt254f43bcaa3dbfc2/6a17d72ddbb4ffc780fb54ff/17aec6be31117440bc4d1f99984aed95df1c4f6b-1440x1728.webp" alt="dense_vector" /><h4>Recall</h4><p></p><p>knn-recall-10-100</p><p>knn-recall-100-1000</p><p>Elasticsearch-8.14.0@lucene-hnsw</p><p>0.969843</p><p>0.996577</p><p>Elasticsearch-8.14.0@lucene-int8_hnsw</p><p>0.775458</p><p>0.840254</p><p>OpenSearch-2.14.0@lucene-hnsw</p><p>0.971333</p><p>0.996747</p><p>OpenSearch-2.14.0@faiss</p><p>0.9704</p><p>0.914755</p><p>OpenSearch-2.14.0@faiss-sq_fp16</p><p>0.968025</p><p>0.913862</p><p>OpenSearch-2.14.0@nmslib</p><p>0.9674</p><p>0.910303</p><h2>2 million vectors, 768 dimensions (so_vector)</h2><p>This <a href="https://github.com/elastic/rally-tracks/tree/master/so_vector">track</a>, <code>so_vector</code>, is derived from a <a href="https://archive.org/download/stackexchange/stackoverflow.com-Posts.7z">dump of StackOverflow posts downloaded</a> on April, 21st 2022. It only contains question documents — all documents representing answers have been removed. Each question title was encoded into a vector using the sentence transformer model <a href="https://huggingface.co/sentence-transformers/multi-qa-mpnet-base-cos-v1">multi-qa-mpnet-base-cos-v1</a>. This data set contains the first 2 million questions.</p><p>Unlike the previous track, each document here contains other fields besides vectors to support testing features like Approximate KNN with filtering and hybrid search. <code>nmslib</code> for OpenSearch is notably absent in this test since <a href="https://opensearch.org/docs/latest/search-plugins/knn/filter-search-knn/#k-nn-search-with-filters">it does not support filters</a>.</p><h3>Tasks</h3><p>Each task warms up for 100 requests and then 100 requests are measured. Note the tasks were grouped for sake of simplicity, since the test contains 16 search types * 2 different k values * 3 different n values.</p><ul><li><p><strong>knn-10-50</strong>: searching on 2 million vectors without filters, k:10 and n:50</p></li><li><p><strong>knn-10-50-filtered</strong>: searching on 2 million vectors <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json">with filters</a>, k:10 and n:50</p></li><li><p><strong>knn-10-50-after-force-merge</strong>: searching on 2 million vectors with filters and after a force merge, k:10 and n:50</p></li><li><p><strong>knn-10-100</strong>: searching on 2 million vectors without filters, k:10 and n:100</p></li><li><p><strong>knn-10-100-filtered</strong>: searching on 2 million vectors <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json">with filters</a>, k:10 and n:100</p></li><li><p><strong>knn-10-100-after-force-merge</strong>: searching on 2 million vectors with filters and after a force merge, k:10 and n:100</p></li><li><p><strong>knn-100-1000</strong>: searching on 2 million vectors without filters, k:100 and n:1000</p></li><li><p><strong>knn-100-1000-filtered</strong>: searching on 2 million vectors <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json">with filters</a>, k:100 and n:1000</p></li><li><p><strong>knn-100-1000-after-force-merge</strong>: searching on 2 million vectors with filters and after a force merge, k:100 and n:1000</p></li><li><p><strong>exact-knn</strong>: Exact KNN search <a href="https://github.com/elastic/rally-tracks/blob/master/so_vector/operations/default.json#L56">with and without filters</a>.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8b44e1306af35877/6a17d72f577262aca11bca3e/d4ed2982d55370cad4b2048b23ce97caa55017c0-1440x316.webp" alt="so_vector table" /><p>Elasticsearch is <strong>consistently faster</strong> than OpenSearch out-of-the-box on this test, only in two cases OpenSearch is faster, and not by much (<em>knn-10-100</em> and <em>knn-100-1000</em>). Tasks involving <em>knn-10-50</em>, <em>knn-10-100</em> and <em>knn-100-1000</em> in combination with filters show a difference of up to <strong>7x</strong> (112ms vs 803ms).</p><p>The performance of both solutions seems to even out after a "force merge", understandably, as evidenced by <em>knn-10-50-after-force-merge</em>, <em>knn-10-100-after-force-merge</em> and <em>knn-100-1000-after-force-merge.</em> On those tasks <code>faiss</code> is faster.</p><p>The performance for Exact KNN once again is very different, Elasticsearch being <strong>13 times faster</strong> than OpenSearch this time (~385ms vs ~5262ms).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf80ccc7c2df84559/6a17d7314b055de00d43203f/615cb9228eb05ddd2e9512b3a6a5bc88d4088a1a-1440x1440.webp" alt="so_vector" /><h4>Recall</h4><p></p><p>knn-recall-10-100</p><p>knn-recall-100-1000</p><p>knn-recall-10-50</p><p>Elasticsearch-8.14.0@lucene-hnsw</p><p>1</p><p>1</p><p>1</p><p>Elasticsearch-8.14.0@lucene-int8_hnsw</p><p>1</p><p>0.986667</p><p>1</p><p>OpenSearch-2.14.0@lucene-hnsw</p><p>1</p><p>1</p><p>1</p><p>OpenSearch-2.14.0@faiss</p><p>1</p><p>1</p><p>1</p><p>OpenSearch-2.14.0@faiss-sq_fp16</p><p>1</p><p>1</p><p>1</p><p>OpenSearch-2.14.0@nmslib</p><p>0.9674</p><p>0.910303</p><p>0.976394</p><h2>Elasticsearch and Lucene as clear victors</h2><p>At Elastic, we are relentlessly innovating Apache Lucene and Elasticsearch to ensure we are able to provide the premier vector database for search and retrieval use cases, including RAG (Retrieval Augmented Generation). Our recent advancements have dramatically increased performance, making vector search <a href="https://search-labs.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains">faster and more space efficient</a> than before, building upon the gains from Lucene 9.10. This blog presented a study that shows when comparing up-to-date versions Elasticsearch is up to 12 times faster than OpenSearch.</p><p>It's worth noting both products use the same version of Lucene (<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/release-notes-8.14.0.html">Elasticsearch 8.14 Release Notes</a> and <a href="https://github.com/opensearch-project/OpenSearch/blob/2.14/release-notes/opensearch.release-notes-2.14.0.md">OpenSearch 2.14 Release Notes</a>).</p><p>The pace of innovation at Elastic will deliver even more not only for our on-premises and Elastic Cloud customers but those using our <a href="https://www.elastic.co/search-labs/blog/stateless-your-new-state-of-find-with-elasticsearch">stateless platform</a>. Features like support for <a href="https://www.elastic.co/search-labs/blog/int4-scalar-quantization-in-lucene">scalar quantization to int4</a> will be offered with rigorous testing to ensure customers can utilize these techniques without a significant drop in recall, similar to <a href="https://www.elastic.co/search-labs/blog/evaluating-scalar-quantization">our testing for int8</a>.</p><p>Vector search efficiency is becoming a non-negotiable feature in modern search engines due to the proliferation of AI and machine learning applications. For organizations looking for a powerful search engine capable of keeping up with the demands of high-volume, high-complexity vector data, Elasticsearch is the definitive answer.</p><p>Whether expanding an established platform or initiating new projects, integrating Elasticsearch for vector search needs is a strategic move that will yield tangible, long-term benefits. With its proven performance advantage, Elasticsearch is poised to underpin the next wave of innovations in search.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-opensearch-vector-search-performance-comparison</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-opensearch-vector-search-performance-comparison</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Ugo Sangiorgi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5d70b25967c2194e/6a17d732b1e11383f879f0ca/13c3c0053e2968fb835ba2f90f34bec3a011b5c0-880x592.webp" length="0" type="image/webp"/>
    <pubDate>Wed, 26 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch new semantic_text mapping: Simplifying semantic search]]></title>
    <description><![CDATA[Learn how to use the new semantic_text field type and semantic query for simplifying semantic search in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<h2>semantic_text - You know, for semantic search!</h2><p>Do you want to start using semantic search for your data, but focus on your model and results instead of on the technical details? We’ve introduced the <code>semantic_text</code> field type that will take care of the details and infrastructure that you need.</p><p><a href="https://www.elastic.co/what-is/semantic-search">Semantic search</a> is a sophisticated technique designed to enhance the relevance of search results by utilizing <a href="https://www.elastic.co/elasticsearch/machine-learning">machine learning models</a>. Unlike traditional keyword-based search, semantic search focuses on understanding the meaning of words and the context in which they are used. This is achieved through the application of machine learning models that provide a deeper semantic understanding of the text.</p><p>These models generate <a href="https://www.elastic.co/what-is/vector-embedding">vector embeddings</a>, which are numeric representations capturing the text meaning. These embeddings are stored alongside your document data, enabling <a href="https://www.elastic.co/what-is/vector-search">vector search techniques</a> that take into account the word meaning and context instead of pure lexical matches.</p><h2>How to perform semantic search</h2><p>To perform semantic search, you need to go through the following steps:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text#choosing-an-inference-model">Choose an inference mode</a>l to create embeddings, both for indexing documents and performing queries.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text#creating-your-index-mapping">Create your index mapping</a> to store the inference results, so they can be efficiently searched afterwards.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text#setting-up-indexing">Setting up indexing</a> so inference results are calculated for new documents added to your index.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text#automatically-handling-long-text-passages">Automatically handle long text documents</a>, so search can be accurate and cover the entire document.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text#querying-your-data">Querying your data</a> to retrieve results.</p></li></ul><p>Configuring semantic search from the ground up can be complex. It requires setting up mappings, ingestion pipelines, and queries tailored to your chosen inference model. Each step offers opportunities for fine-tuning and optimization, but also demands careful configuration to ensure all components work together seamlessly.</p><p>While this offers a great degree of control, it makes using semantic search a detailed and deliberate process, requiring you to configure separate pieces that are all related to each other and to the inference model.</p><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html"><code>semantic_text</code></a> simplifies this process by focusing on what matters: the inference model. Once you have selected the inference model, <code>semantic_text</code> will make it easy to start using semantic search by providing sensible defaults, so you can focus on your search and not on how to index, generate, or query your embeddings.</p><p>Let's take a look at each of these steps, and how <code>semantic_text</code> simplifies this setup.</p><h3>Choosing an inference model</h3><p>The inference model will generate embeddings for your documents and queries. Different models have different tradeoffs in terms of:</p><ul><li><p>Accuracy and relevance of the results</p></li><li><p>Scalability and performance</p></li><li><p>Language and multilingual support</p></li><li><p>Cost</p></li></ul><p>Elasticsearch supports both internal and external inference services:</p><ul><li><p>Internal services are deployed in the Elasticsearch cluster. You can use already included models like <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html">ELSER</a> and <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-e5.html">E5</a>, or import <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-model-ref.html#ml-nlp-model-ref-text-embedding">external models</a> into the cluster using <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-import-model.html">eland</a>.</p></li><li><p>External services are deployed by model providers. Elasticsearch supports the following:   </p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-embeddings-support">Cohere</a></p></li><li><p><a href="https://www.elastic.co/search-labs/integrations/hugging-face">Hugging Face</a></p></li><li><p><a href="https://www.elastic.co/search-labs/integrations/mistral">Mistral</a></p></li><li><p><a href="https://www.elastic.co/search-labs/integrations/open-ai">OpenAI</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-ai-studio-support">Azure AI Studio</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-embeddings-support">Azure OpenAI</a></p></li><li><p>Google AI Studio</p></li></ul></li></ul><p>Once you have chosen the inference mode, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/put-inference-api.html">create an inference endpoint</a> for it. The inference endpoint identifier will be the only configuration detail that you will need to set up <code>semantic_text</code>.</p>PUT _inference/sparse_embedding/my-elser-endpoint
{
  "service": "elser",
  "service_settings": {
    "num_allocations": 1,
    "num_threads": 1
  }
}
<h3>Creating your index mapping</h3><p>Elasticsearch will need to index the embeddings generated by the model so they can be efficiently queried later.</p><p>Before semantic_text, you needed to understand about the two main field types used for storing embeddings information:</p><ul><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/sparse-vector.html"><code>sparse_vector</code></a>: It indexes sparse vector embeddings, like the ones generated by ELSER. Each embedding consists of pairs of tokens and weights. There is a small number of tokens generated per embedding.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html"><code>dense_vector</code></a>: It indexes vectors of numbers, which contains the embedding information. A model produces vectors of a fixed size, called the vector dimension.</p></li></ul><p>The field type to use is conditioned by the model you have chosen. If using dense vectors, you will need to <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-params">configure</a> the field to include the dimension count, the similarity function used to calculate vectors proximity, and storage customizations like quantization or the specific data type used for each element.</p><p>Now, if you're using semantic_text, you define a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html">semantic_text field mapping</a> by just specifying the inference endpoint identifier for your model:</p>PUT test-index
{
  "mappings": {
    "properties": {
      "infer_field": {
        "type": "semantic_text",
        "inference_id": "my-elser-endpoint"
      }
    }
  }
}
<p>That's it. No need for you to define other mapping options, or to understand which field type you need to use.</p><h3>Setting up indexing</h3><p>Once your index is ready to store the embeddings, it's time to generate them.</p><p>Before <code>semantic_text</code>, to generate embeddings automatically on document ingestion you needed to set up an <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html">ingestion pipeline</a>.</p><p>Ingestion pipelines are used to automatically enrich or transform documents when ingested into an index, or when explicitly specified as part of the ingestion process.</p><p>You need to use the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-processor.html">inference processor</a> to generate embeddings for your fields. The processor needs to be configured using:</p><ul><li><p>The text fields from which to generate the embeddings</p></li><li><p>The output fields where the generated embeddings will be added</p></li><li><p>Specific inference configuration for text embeddings or sparse embeddings, depending on the model type</p></li></ul><p>With <code>semantic_text</code>, you simply add documents to your index. semantic_text fields will automatically calculate the embeddings using the specified inference endpoint.</p><p>This means there's no need to create an inference pipeline to generate the embeddings. Using bulk, index, or update APIs will do that for you automatically:</p>PUT test-index/_doc/doc1
{
  "infer_field": "These are not the droids you're looking for. He's free to go around"
}
<p>Inference requests in <code>semantic_text</code> fields are also batched. If you have 10 documents in a bulk API request, and each document contains 2 <code>semantic_text</code> fields, then that request will perform a single inference request with 20 texts to your inference service in one go, instead of making 10 separate inference requests of 2 texts each.</p><h3>Automatically handling long text passages</h3><p>Part of the challenge of selecting a model is the number of tokens that the model can generate embeddings for. Models have a limited number of tokens they can process. This is referred to as the model’s context window.</p><p>If the text you need to work with is longer than the model’s context window, you may <strong>truncate</strong> the text and use just part of it to generate embeddings. This is not ideal as you'll lose information; the resulting embeddings will not capture the full context of the input text.</p><p>Even if you have a long context window, having a long text means a lot of content will be reduced to a single embedding, making it an inaccurate representation.</p><p>Also, returning a long text will be difficult for the users to understand, as they will have to scan the text to check it's what they are looking for. Using smaller snippets would be preferable instead.</p><p>Another option is to use <strong>chunking</strong> to divide long texts into smaller fragments. These smaller chunks are added to each document to provide a better representation of the complete text. You can then use a nested query to search over all the individual fragments and retrieve the documents that contain the best-scoring chunks.</p><p>Before <code>semantic_text</code>, chunking was not done out of the box - the inference processor did not support chunking. If you needed to use chunking, you needed to do it before ingesting your documents or use the script processor to perform the chunking in Elasticsearch.</p><p>Using semantic_text means that chunking will be done on your behalf when indexing. Long documents will be split into 250-word sections with a 100-word overlap so that each section shares 100 words with the previous section. This overlap ensures continuity and prevents vital contextual information in the input text from being lost by a hard break.</p><p>If the model and inference service support batching the chunked inputs are automatically batched together into as few requests as possible, each optimally sized for the Inference Service. The resulting chunks will be stored in a nested object structure so you can check the text contained in each chunk.</p><h3>Querying your data</h3><p>Now that the documents and their embeddings are indexed in Elasticsearch, it's time to do some queries!</p><p>Before <code>semantic_text</code>, you needed to use a different query depending on the type of embeddings the model generates (dense or sparse). A <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html">sparse vector query</a> is needed to query sparse_vector field types, and either a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">knn search</a> or a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-knn-query.html">knn query</a> can be used to search dense_vector field types.</p><p>The query process can be further customized for performance and relevance. For example, sparse vector queries can define <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html#sparse-vector-query-with-pruning-config-and-rescore-example">token pruning</a> to avoid considering irrelevant tokens. Knn queries can specify the number of candidates to consider and the top k results to be returned from each shard.</p><p>You don't need to deal with those details when using <code>semantic_text</code>. You use a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html">single query type</a> to search your documents:</p>GET test-index/_search
{
  "query": {
    "semantic": {
      "field": "infer_field",
      "query": "robots you're searching for"
    }
  }
}
<p>Just include the field and the query text. There’s no need to decide between sparse vector and knn queries, semantic text does this for you.</p><p>Compare this with using a specific <code>knn</code> search with all its configuration parameters:</p>{
  "knn": {
    "field": "infer_field",
    "k": 10,
    "num_candidates": 100,
    "query_vector_builder": {
      "text_embedding": { 
        "model_id": "my-dense-vector-embedding-model", 
        "model_text": "robots you're searching for" 
      }
    }
  }
}
<h2>Under the hood: How <code>semantic_text</code> works</h2><p>To understand how <code>semantic_text</code> works, you can create a <code>semantic_text</code> index and check what happens when you ingest a document. When the first document is ingested, the inference endpoint calculates the embeddings. When indexed, you will notice changes in the index mapping:</p>GET test-index
{
  "test-index": {
    "mappings": {
      "properties": {
        "infer_field": {
          "type": "semantic_text",
          "inference_id": "my-elser-endpoint",
          "model_settings": {
            "task_type": "sparse_embedding"
          }
        }
      }
    }
  }
}
<p>Now there is additional information about the model settings. Text embedding models will also include information like the number of dimensions or the similarity function for the model.</p><p>You can check the document already includes the embedding results:</p>GET test-index/_doc/doc1
{
  "_index": "test-sparse",
  "_id": "doc1",
  "_source": {
    "infer_field": {
      "text": "these are not the droids you're looking for. He's free to go around",
      "inference": {
        "inference_id": "my-elser-endpoint",
        "model_settings": {
          "task_type": "sparse_embedding"
        },
        "chunks": [
          {
            "text": "these are not the droids you're looking for. He's free to go around",
            "embeddings": {
              "##oid": 1.9103845,
              "##oids": 1.768872,
              "free": 1.693662,
              "dr": 1.6103356,
              "around": 1.4376559,
              "these": 1.1396849

              …
            }
          }
        ]
      }
    }
  }
}
<p>The field does not just contain the input text, but also a structure storing the original text, the model settings, and information for each chunk the input text has been divided into.</p><p>This structure consists of an object with two elements:</p><ul><li><p><em>text</em>: Contains the original input text</p></li><li><p><em>inference</em>: Inference information added by the inference endpoint, that consists of: </p><ul><li><p><em>inference_id</em> of the inference endpoint</p></li><li><p><em>model_settings</em> that contain model properties</p></li><li><p><em>chunks</em>: Nested object that contains an element for each chunk that has been created from the input text. Each chunk contains:</p><ul><li><p>The <em>text</em> for the chunk</p></li><li><p>The calculated <em>embeddings</em> for the chunk text</p></li></ul></li></ul></li></ul><h2>Customizing <code>semantic_text</code></h2><p><code>semantic_text</code> simplifies semantic search by making default decisions about indexing and querying your data:</p><ul><li><p>uses <code>sparse_vector</code> or <code>dense_vector</code> field types depending on the inference model type</p></li><li><p>Automatically defines the number of dimensions and similarity according to the inference results</p></li><li><p>Uses <code>int8_hnsw</code> index type for dense vector field types to leverage <a href="https://www.elastic.co/search-labs/blog/evaluating-scalar-quantization">scalar quantization</a>.</p></li><li><p>Uses query defaults. No token pruning is applied for <code>sparse_vector</code> queries, nor custom <code>k</code> and <code>num_candidates</code> are set for knn queries.</p></li></ul><p>Those are sensible defaults and allow you to quickly and easily start working with semantic search. Over time, you may want to customize your queries and data types to optimize search relevance, index and query performance, and index storage.</p><h3>Query customization</h3><p>There are no customization options - yet - for semantic queries. If you want to customize queries against <code>semantic_text</code> fields, you can perform <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html#advanced-search">advanced semantic_text search</a> using explicit knn and sparse vector queries.</p><p>We're planning to add <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/retrievers-overview.html">retrievers support</a> for <code>semantic_text</code>, and adding configuration options to the <code>semantic_text</code> field so they won't be needed at query time. Stay tuned!</p><h3>Data type customization</h3><p>If you need deeper customization for the data indexing, you can use the <code>sparse_vector</code> or <code>dense_vector</code> field types. These field types give you full control over how embeddings are generated, indexed, and queried.</p><p>You need to create an ingest pipeline with an inference processor to generate the embeddings. <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-inference.html">This tutorial</a> walks you through the process.</p><h2>What's next with <code>semantic_text</code>?</h2><p>We're just getting started with <code>semantic_text</code>! There are quite a few enhancements that we will keep working on, including:</p><ul><li><p>Better inference error handling</p></li><li><p>Customize the chunking strategy</p></li><li><p>Hiding embeddings in _source by default, to avoid cluttering the search responses</p></li><li><p>Inner hits support, to retrieve the relevant chunks of information for a query</p></li><li><p>Filtering and retrievers support</p></li><li><p>Kibana support</p></li></ul><h2>Try it out!</h2><p><code>semantic_text</code>is available on <a href="https://www.elastic.co/elasticsearch/serverless">Elasticsearch Serverless</a> now! It will be available soon on Elasticsearch 8.15 version for <a href="https://www.elastic.co/cloud">Elastic Cloud</a> and on <a href="https://www.elastic.co/downloads/elasticsearch">Elasticsearch downloads</a>.</p><p>If you already have an Elasticsearch serverless cluster, you can see a complete example for testing semantic search using <code>semantic_text</code> in <a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank">this tutorial</a>, or try it with <a href="https://colab.research.google.com/github/elastic/elasticsearch-labs/blob/main/notebooks/search/09-semantic-text.ipynb">this notebook</a>.</p><p>We'd love to hear about your experience with <code>semantic_text</code>! Let us know what you think in the <a href="https://www.elastic.co/community">forums</a>, or open an issue in the <a href="https://github.com/elastic/elasticsearch">GitHub repository</a>. Let's make semantic search easier together!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Carlos Delgado,Mike Pellegrini]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt31515f5dc8f12092/6a170c170c48570fa101aabd/dc08f5c15b12a0e686b8922ad8d2b997ca1227d7-1024x1024.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 24 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Building RAG with Llama 3 open-source and Elastic]]></title>
    <description><![CDATA[Learn how to build a RAG system with Llama3 open source and Elastic. This blog provides practical examples of RAG using Llama3 as an LLM.]]></description>
    <content:encoded><![CDATA[<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt902f708eddf7ee15/6a17d71742022952cf29f44d/52f1c7bb9a419f6468f5ea92d28b8e28ef33afc7-966x321.png" alt="Building RAG with Llama 3 open-source and Elastic" /><p></p><p>This blog will walk through implementing RAG using two approaches.</p><ol><li><p>Elastic, Llamaindex, Llama 3 (8B) version running locally using Ollama.</p></li><li><p>Elastic, Langchain, ELSER v2, Llama 3 (8B) version running locally using Ollama.</p></li></ol><p>The notebooks are available at this <a href="https://github.com/elastic/elasticsearch-labs/tree/main/notebooks/integrations/llama3">GitHub</a> location.</p><p>Before we get started, let's take a quick dive into Llama 3.</p><h2>Llama 3 overview</h2><p>Llama 3 is an open source large language model recently launched by Meta. This is a successor to Llama 2 and based on published metrics, is a significant improvement. It has good evaluation metrics, when compared to some of the recently published models such as Gemma 7B Instruct, Mistral 7B Instruct, etc. The model has two variants, which are the 8 billion and 70 billion parameter. An interesting thing to note is that at the time of writing this blog, Meta was still in the process of training 400B+ variant of Llama 3.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d94caf1ea093092/6a17d718abe0f2e686dfe858/efe54db6b84b063d48708e47d323c3d37bc162cf-1440x810.png" alt="Meta Llama 3 Instruct Model Performance. (from https://ai.meta.com/blog/meta-llama-3/)" /><p>Meta Llama 3 Instruct Model Performance. (from<a href="https://ai.meta.com/blog/meta-llama-3/"> https://ai.meta.com/blog/meta-llama-3/</a>)</p><p>The above figure shows data on Llama3 performance across different datasets as compared to other models. In order to be optimized for performance for real world scenarios, Llama3 was also evaluated on a high quality human evaluation set.</p><p>Aggregated results of Human Evaluations across multiple categories and prompts (from<a href="https://ai.meta.com/blog/meta-llama-3/"> https://ai.meta.com/blog/meta-llama-3/</a>)</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt549890dd4cb8215c/6a17d71a1d1b83f13393e2d1/612561b2e8c35f19d3161ae867235fedf93b7e9e-1440x903.png" alt="" /><h2>How to build RAG with Llama 3 open-source and Elastic</h2><h3>Dataset</h3><p>For the dataset, we will use a fictional organization policy document in json format, available at this <a href="https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/datasets/workplace-documents.json">location</a>.</p><h3>Configure Ollama and Llama3</h3><p>As we are using the Llama 3 8B parameter size model, we will be running that using Ollama. Follow the steps below to install Ollama.</p><ol><li><p>Browse to the URL<a href="https://ollama.com/download"> https://ollama.com/download</a> to download the Ollama installer based on your platform.</p></li></ol><p><em>Note: The Windows version is in preview at the moment.</em></p><ol><li><p>Follow the instructions to install and run Ollama for your OS.</p></li><li><p>Once installed, follow the commands below to download the Llama3 model.</p></li></ol>    ollama run llama3
<p>This should take some time depending upon your network bandwidth. Once the run completes, you should end with the interface below.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte70e67e2dd184948/6a17d71b4b055d4b0843203b/6f9cce46c6a7442bbfd4cef6c6250e0b4e697376-777x595.png" alt="" /><p>To test Llama3, run the following command from a new terminal or enter the text at the prompt itself.</p>    curl -X POST http://localhost:11434/api/generate -d '{ "model": "llama3", "prompt":"Why is the sky blue?" }'
<p>At the prompt, the output looks like below.</p>    ❯ ollama run llama3
    &gt;&gt;&gt; Why is the sky blue?
    The color of the sky appears blue to our eyes because of a fascinating combination of scientific factors. Here's the short answer:

    **Scattering of Light**: When sunlight enters Earth's atmosphere, it encounters tiny molecules of gases like nitrogen (N2) and oxygen (O2).
    These molecules scatter the light in all directions, but they do so more efficiently for shorter wavelengths (like blue and violet light) than
    longer wavelengths (like red and orange light).

    **Rayleigh Scattering**: This scattering effect is known as Rayleigh scattering, named after the British physicist Lord Rayleigh, who first
    described it in the late 19th century. It's responsible for the blue color we see in the sky.

    **Atmospheric Composition**: The Earth's atmosphere is composed of approximately 78% nitrogen, 21% oxygen, and small amounts of other gases.
    These gases are more abundant at lower altitudes, where they scatter shorter wavelengths (like blue light) more effectively than longer
    wavelengths (like red light).

    **Sunlight's Wavelengths**: When sunlight enters the Earth's atmosphere, it contains a broad spectrum of wavelengths, including visible light
    with colors like red, orange, yellow, green, blue, indigo, and violet. The shorter wavelengths (blue and violet) are scattered more than the
    longer wavelengths (red and orange), due to Rayleigh scattering.

    **What We See**: As our eyes look up at the sky, we see the combined effect of these factors: the shorter wavelengths (blue light) being
    scattered in all directions by the atmospheric gases, while the longer wavelengths (red and orange light) continue to travel in a more direct
    path to our eyes. This results in the blue color we perceive as the sky.

    So, to summarize: the sky appears blue because of the scattering of sunlight's shorter wavelengths (blue light) by the tiny molecules in the
    Earth's atmosphere, combined with the atmospheric composition and the original wavelengths present in sunlight.

    Now, go enjoy that blue sky!

    &gt;&gt;&gt; Send a message (/? for help)
<p>We now have Llama3 running locally using Ollama.</p><h3>Elasticsearch setup</h3><p>We will use Elastic cloud setup for this. Please follow the instructions <a href="https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud">here</a>. Once successfully deployed, note the API Key and the Cloud ID, we will require them as part of our setup.</p><h3>Application setup</h3><p>There are two notebooks, one for RAG implemented using Llamaindex and Llama3, the other one with Langchain, ELSER v2 and Llama3. In the first notebook, we use Llama3 as a local LLM as well as provide embeddings. For the second notebook, we use ELSER v2 for the embeddings and Llama3 as the local LLM.</p><h4>Method 1: Elastic, Llamaindex, Llama 3 (8B) version running locally using Ollama.</h4><p>Step 1 : Install required dependencies</p>    !pip install llama-index
    !pip install llama-index-cli
    !pip install llama-index-core
    !pip install llama-index-embeddings-elasticsearch
    !pip install llama-index-embeddings-ollama
    !pip install llama-index-legacy
    !pip install llama-index-llms-ollama
    !pip install llama-index-readers-elasticsearch
    !pip install llama-index-readers-file
    !pip install llama-index-vector-stores-elasticsearch
    !pip install llamaindex-py-client
<p>The above section installs the required llamaindex packages.</p><p>Step 2: Import required dependencies</p><p>We start with importing the required packages and classes for the app.</p>    from llama_index.core.node_parser import SentenceSplitter
    from llama_index.core.ingestion import IngestionPipeline
    from llama_index.embeddings.ollama import OllamaEmbedding
    from llama_index.vector_stores.elasticsearch import ElasticsearchStore
    from llama_index.core import VectorStoreIndex, QueryBundle
    from llama_index.llms.ollama import Ollama
    from llama_index.core import Document, Settings
    from getpass import getpass
    from urllib.request import urlopen
    import json
<p>We start with providing a prompt to the user to capture the Cloud ID and API Key values.</p>    #https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id
    ELASTIC_CLOUD_ID = getpass("Elastic Cloud ID: ")

    #https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key
    ELASTIC_API_KEY = getpass("Elastic Api Key: ")
<p>If you are not familiar with obtaining the Cloud ID and API Key, please follow the links in the code snippet above to guide you with the process.</p><p>Step 3: document processing</p><p>We start with downloading the json document and building out <a href="https://docs.llamaindex.ai/en/stable/module_guides/loading/documents_and_nodes/">Document</a> objects with the payload.</p>    url = "https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/datasets/workplace-documents.json"
    response = urlopen(url)
    workplace_docs = json.loads(response.read())
    documents = [Document(text=doc['content'],
                              metadata={"name": doc['name'],"summary": doc['summary'],"rolePermissions": doc['rolePermissions']})
                     for doc in workplace_docs]
<p>We now define the Elasticsearch vector store (<a href="https://docs.llamaindex.ai/en/stable/api_reference/storage/vector_store/elasticsearch/#llama_index.vector_stores.elasticsearch.ElasticsearchStore">ElasticsearchStore</a>), the embedding created using Llama3 and a <code>pipeline</code> to help process the payload constructed above and ingest into Elasticsearch.</p><p>The ingestion pipeline allows us to compose pipelines using different components, one of which allows us to generate embeddings using Llama3.</p>    es_vector_store = ElasticsearchStore(index_name="workplace_index",
                                         vector_field='content_vector',
                                         text_field='content',
                                         es_cloud_id=ELASTIC_CLOUD_ID,
                                         es_api_key=ELASTIC_API_KEY)

    # Embedding Model to do local embedding using Ollama.
    ollama_embedding = OllamaEmbedding("llama3")
    # LlamaIndex Pipeline configured to take care of chunking, embedding
    # and storing the embeddings in the vector store.
    pipeline = IngestionPipeline(
        transformations=[
            SentenceSplitter(chunk_size=512, chunk_overlap=100),
            ollama_embedding
        ], vector_store=es_vector_store
    )
<p><a href="https://docs.llamaindex.ai/en/stable/api_reference/storage/vector_store/elasticsearch/#llama_index.vector_stores.elasticsearch.ElasticsearchStore">ElasticsearchStore</a> is defined with the name of the index to be created, the vector field and the content field. And this index is created when we run the pipeline.</p><p>The index mapping created is as below:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta88debbd9af3ac96/6a17d71d3e03d7868c4f2ab7/5af30a28a7caa99c5fb4c5c691320d9f9d0644e9-367x589.png" alt="" /><p>The pipeline is executed using the step below. Once this pipeline run completes, the index <code>workplace_index</code> is now available for querying. Do note that the vector field <code>content_vector</code> is created as a dense vector with dimension <code>4096</code>. The dimension size comes from the size of the embeddings generated from Llama3.</p>    pipeline.run(show_progress=True,documents=documents)
<p>Step 4: LLM configuration</p><p>We now setup Llamaindex to use the Llama3 as the LLM. This as we covered before is done with the help of Ollama.</p>    Settings.embed_model = ollama_embedding
    local_llm = Ollama(model="llama3")
<p>Step 5: Semantic search</p><p>We now configure Elasticsearch as the vector store for the Llamaindex query engine. The query engine is then used to answer your questions with contextually relevant data from Elasticsearch.</p>    index = VectorStoreIndex.from_vector_store(es_vector_store)
    query_engine = index.as_query_engine(local_llm, similarity_top_k=10)

    # Customer Query
    query = "What are the organizations sales goals?"
    bundle = QueryBundle(query_str=query,
    embedding=Settings.embed_model.get_query_embedding(query=query))

    response = query_engine.query(bundle)

    print(response.response)
<p>The response I received with Llama3 as the LLM and Elasticsearch as the Vector database is below.</p>    According to the "Fy2024 Company Sales Strategy" document, the organization's primary goal is to:

    * Increase revenue by 20% compared to fiscal year 2023.
    * Expand market share in key segments by 15%.
    * Retain 95% of existing customers and increase customer satisfaction ratings.
    * Launch at least two new products or services in high-demand market segments.
<p>This concludes the RAG setup based on using Llama3 as a local LLM and to generate embeddings.</p><p>Let's now move to the second method, which uses Llama3 as a local LLM, but we use Elastic’s ELSER v2 to generate embeddings and for semantic search.</p><h4>Method 2: Elastic, Langchain, ELSER v2, Llama 3 (8B) version running locally using Ollama.</h4><p>Step 1: Install required dependencies</p>    !pip install langchain
    !pip install langchain-elasticsearch
    !pip install langchain-community
    !pip install tiktoken
<p>The above section installs the required langchain packages.</p><p>Step 2: Import required dependencies</p><p>We start with importing the required packages and classes for the app. This step is similar to Step 2 in Method 1 above.</p>    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from langchain_elasticsearch import ElasticsearchStore
    from langchain_community.llms import Ollama
    from langchain.prompts import ChatPromptTemplate
    from langchain.schema.output_parser import StrOutputParser
    from langchain.schema.runnable import RunnablePassthrough
    from langchain_elasticsearch import ElasticsearchStore
    from langchain_elasticsearch import SparseVectorStrategy
    from getpass import getpass
    from urllib.request import urlopen
    import json
<p>Next, provide a prompt to the user to capture the Cloud ID and API Key values.</p>    #https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id
    ELASTIC_CLOUD_ID = getpass("Elastic Cloud ID: ")

    #https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key
    ELASTIC_API_KEY = getpass("Elastic Api Key: ")
<p>Step 3: Document processing</p><p>Next, we move to downloading the json document and building the payload.</p>    url = "https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/datasets/workplace-documents.json"

    response = urlopen(url)
    workplace_docs = json.loads(response.read())
    metadata = []
    content = []
    for doc in workplace_docs:
        content.append(doc["content"])
        metadata.append(
            {
                "name": doc["name"],
                "summary": doc["summary"],
                "rolePermissions": doc["rolePermissions"],
            }
        )
    text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
        chunk_size=512, chunk_overlap=256
    )
    docs = text_splitter.create_documents(content, metadatas=metadata)
<p>This step differs from the Method 1 approach, from how we use the LlamaIndex provided pipeline to process the document. Here we use the <code>RecursiveCharacterTextSplitter</code> to generate the chunks.</p><p>We now define the Elasticsearch vector store <a href="https://api.python.langchain.com/en/latest/vectorstores/langchain_elasticsearch.vectorstores.ElasticsearchStore.html">ElasticsearchStore</a>.</p>    es_vector_store = ElasticsearchStore(
        es_cloud_id=ELASTIC_CLOUD_ID,
        es_api_key=ELASTIC_API_KEY,
        index_name="workplace_index_elser",
        strategy=SparseVectorStrategy(
            model_id=".elser_model_2_linux-x86_64"
        )
    )
<p>The vector store is defined with the index to be created and the model to be used for embedding and retrieval. You can retrieve the <code>model_id</code> by navigating to Trained Models under Machine Learning.</p><p>This also results in the creation of an ingest pipeline in Elastic, which generates and stores the embeddings as the documents are ingested into Elastic.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt392d48d602cf0458/6a17d71f3e03d7e0754f2abb/d924e59ecb3f918be3ca7264a316a801d4ee551d-1440x487.png" alt="" /><p>We now add the documents processed above.</p>    es_vector_store.add_documents(documents=docs)
<p>Step 4: LLM configuration</p><p>We set up the LLM to be used with the following. This is again different from method 1, where we used Llama3 for embeddings too.</p>    llm = Ollama(model="llama3")
<p>Step 5: Semantic search</p><p>The necessary building blocks are all in place now. We tie them up together to perform semantic search using ELSER v2 and Llama3 as the LLM. Essentially, Elasticsearch ELSER v2 provides the contextually relevant response to the users question using its semantic search capabilities. The user's question is then enriched with the response from ELSER and structured using a template. This is then processed with Llama3 to generate relevant responses.</p>    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    retriever = es_vector_store.as_retriever()
    template = """Answer the question based only on the following context:\n

                    {context}
                    
                    Question: {question}
                   """
    prompt = ChatPromptTemplate.from_template(template)
    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )

    chain.invoke("What are the organizations sales goals?")
<p>The response with Llama3 as the LLM and ELSER v2 for semantic search is as below:</p>    According to the provided context, the organization's sales goals for Fiscal Year 2024 are:

    1. Increase revenue by 20% compared to fiscal year 2023.
    2. Expand market share in key segments by 15%.
    3. Retain 95% of existing customers and increase customer satisfaction ratings.

    These goals are outlined under "Objectives for Fiscal Year 2024" in the provided document.
<p>This concludes the RAG setup based on using Llama3 as a local LLM and ELSER v2 for semantic search.</p><h2>Conclusion</h2><p>In this blog we looked at two approaches to RAG with Llama3 and Elastic. We explored Llama3 as an LLM and to generate embeddings. Next we used Llama3 as the local LLM and ELSER for embeddings and semantic search. We utilized two different frameworks, LlamaIndex and Langchain. You could implement the two methods using either of these frameworks. The notebooks were tested with the Llama3 8B parameter version. Both the notebooks are available at this <a href="https://github.com/elastic/elasticsearch-labs/tree/main/notebooks/integrations/llama3">GitHub</a> location.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-rag-with-llama3-opensource-and-elastic</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-rag-with-llama3-opensource-and-elastic</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Rishikesh Radhakrishnan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte43bb958addae113/6a17d720be60862c01004598/cdae9e56c803a0765f7fd0c2856bce018bbbaa59-1080x1080.png" length="0" type="image/png"/>
    <pubDate>Thu, 20 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Intelligent RAG data chunking: Fetch surrounding chunks]]></title>
    <description><![CDATA[Learn about data chunking in RAG and explore fetch surrounding chunking, a pattern in RAG that uses chunking and Elasticsearch to refine LLM responses.]]></description>
    <content:encoded><![CDATA[<p>In the realm of Retrieval-Augmented Generation (RAG), one persistent challenge is finding the optimal amount of data to feed into a Large Language Model (LLM). Too little data results in insufficient or inaccurate responses, while too much data leads to vague answers. This delicate balance inspired me to develop a <a href="https://ela.st/fetch-surrounding-chunks">notebook</a> focusing on intelligent chunking and leveraging Elasticsearch vector database.</p><p>This blog builds on that notebook and explores fetch surrounding chunking, an emerging pattern in RAG that uses intelligent chunking and Elasticsearch vector database to optimize LLM responses. The approach balances data input to enhance the accuracy and relevance of LLM-generated answers through semantic hybrid search.</p><h2>The motivation: A refined approach to RAG data chunking</h2><p>The primary motivation behind building <a href="https://ela.st/fetch-surrounding-chunks">this notebook</a> was to demonstrate a refined approach to RAG by addressing the challenge of data chunking. Traditional methods often fall short in dynamically adjusting the data size fed to LLMs, either overwhelming the model with too much context or starving it with too little. This notebook aims to strike the right balance, providing just enough information for the LLM to generate precise and contextually relevant responses. However, it must be noted that there is no one-size-fits-all solution.</p><p>This method works especially well with books and similar texts where content flows within longer sections or chapters. However, it may require adaptation for texts structured into shorter, distinct sections, such as research papers or articles, where each segment might cover a different topic. In such cases, additional strategies may be necessary to effectively chunk and retrieve related content.</p><h2>The methodology: Intelligent RAG data chunking</h2><h3>Fetch surrounding chunks</h3><p>The core idea is to partition the source text into manageable chunks, ensuring each chunk contains just the right amount of information. For this demonstration, I used text from "Harry Potter and the Sorcerer's Stone." The text was partitioned into chapters, and each chapter was further divided into smaller chunks. These chunks, along with their dense and sparse (ELSER) vector representations, were indexed in the Elasticsearch vector database.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6bcc905a1653ca3a/6a1711357d8d670d9970e846/23b210ce29f47f8a872d300ef01fca901d1e80ab-1163x548.png" alt="architecture" /><h3>Assigning numbers to chunks</h3><p>Each chunk within a chapter was assigned a sequential integer, allowing us to identify its position. When a matching chunk is found, the chapter number and chunk number are used to retrieve surrounding chunks, providing additional context for the LLM.</p><h3>Vector database in Elasticsearch</h3><p>These chunks and their vector representations were ingested into an Elasticsearch Cloud instance. Elasticsearch's robust vector search capabilities make it ideal for hosting these chunks, allowing for efficient retrieval of the most relevant chunks based on the semantic content or text match of a user's query.</p><h3>AI search</h3><p>To retrieve the relevant chunks, I employed a hybrid search strategy using dense vector comparisons, sparse vector comparisons, and text search in parallel. This multi-faceted approach ensures that the search results are both semantically rich and contextually accurate. A query is issued to find the matched chunk, which returns the chunk number and chapter. Surrounding chunks for that chapter are then fetched based on the matched chunk.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte879d45ec9558b9e/6a171137b339d50e9776a0be/62d3cb6d9cddcecda359bc1fd808b52cd0f23864-1440x778.png" alt="architecture" /><h2>The RAG pattern</h2><p>When a query is made, the search flow performs the following steps:</p><ol><li><p><strong>Query analysis:</strong> The user's query is translated into dense and sparse vectors to retrieve the most relevant chunks from the Elasticsearch index.</p></li><li><p><strong>Chunk retrieval:</strong> Using the AI search strategy, the system retrieves the top relevant chunks.</p></li><li><p><strong>Contextual expansion:</strong> Adjacent chunks (n-1 and n+1) are also retrieved to provide a more comprehensive context. If the chunk is the last in the chapter, it fetches n-1 and n-2; if it's the first, it fetches n+1 and n+2.</p></li><li><p><strong>LLM response:</strong> These intelligently selected chunks are then fed into the LLM, ensuring it receives the optimal amount of information to generate a precise and contextually relevant response.</p></li></ol><h2>Why intelligent RAG data chunking matters</h2><p>This approach addresses a critical aspect of RAG by optimizing the input data fed to LLMs. By leveraging intelligent chunking and hybrid semantic search, this method enhances the accuracy and relevance of the responses generated by LLMs. It showcases a pattern that can be widely applied in various applications within the RAG space, from customer support to content generation and beyond.</p><h2>Conclusion</h2><p><a href="https://ela.st/fetch-surrounding-chunks">This notebook</a> underscores the importance of intelligent data chunking in the RAG framework and demonstrates how Elasticsearch vector database can be leveraged to achieve optimal results. By ensuring the LLM receives just the right amount of information, this methodology paves the way for more accurate and contextually rich responses, enhancing the overall effectiveness of RAG systems.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/advanced-chunking-fetch-surrounding-chunks</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/advanced-chunking-fetch-surrounding-chunks</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Sunile Manjee]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt17ba5b693b94a883/6a171139acf0880723be9c49/4467ccd71baaae7422b9b5df9a8612eec4af1bd2-1024x1024.png" length="0" type="image/png"/>
    <pubDate>Tue, 11 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Automatically updating your Elasticsearch index using Node.js and an Azure Function App]]></title>
    <description><![CDATA[Learn how to update your Elasticsearch index automatically using Node.js and an Azure Function App. Follow these steps to ensure your index stays current.]]></description>
    <content:encoded><![CDATA[<p>Maintaining an up-to-date Elasticsearch index is crucial, especially when dealing with frequently changing dynamic datasets. This blog post will guide you through automatically updating your Elasticsearch index using Node.js and an Azure Function App.</p><p>First, we'll load the data using Node.js and ensure it remains current through regular updates. Then, we'll leverage the capabilities of <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview?pivots=programming-language-javascript">Azure Function Apps</a> to automate these updates, thereby ensuring your index is always fresh and reliable.</p><p>For this blog post, we will be using the <a href="https://data.nasa.gov/Space-Science/Asteroids-NeoWs-API/73uw-d9i8/about_data">Near Earth Object Web Service (NeoWs</a>), a RESTful web service offering detailed information about near-earth asteroids. By integrating NeoWs with Node.js services integrated as Azure serverless functions, this example will provide you with a robust framework to handle the complexities of managing dynamic data effectively. This approach will help you minimize the risks of working with outdated information and maximize the accuracy and usefulness of your data.</p><h2>Prerequisites</h2><ul><li><p>This example uses Elasticsearch version 8.13; if you are new to Elasticsearch, check out our Quick Start on <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started.html">Elasticsearch</a>. Any 8.0 version should work for this blog post.</p></li><li><p>Download the latest <a href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm">NPM and Node.js version</a>. This tutorial uses Node v21.6.1 and npm 10.5.0.</p></li><li><p><a href="https://api.nasa.gov/">An API key</a> for NASA's APIs.</p></li><li><p>An active <a href="https://azure.microsoft.com/en-us/">Azure account</a> with access to create a Function App.</p></li><li><p>Access to the <a href="https://azure.microsoft.com/en-us/get-started/azure-portal">Azure portal</a> or <a href="https://learn.microsoft.com/en-us/cli/azure/">Azure CLI</a></p></li></ul><h2>Setting up locally</h2><p>Before you begin indexing and loading your data locally, setting up your environment is essential. First, create a directory and initialize it. Then, download the necessary packages and create a <code>.env</code> file to store your configuration settings. This preliminary setup ensures your local environment is prepared to handle the data efficiently.</p>mkdir Introduction-to-Data-Loading-in-Elasticsearch-with-Nodejs
cd Introduction-to-Data-Loading-in-Elasticsearch-with-Nodejs
npm init
<p>You will be using the <a href="https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html">Elasticsearch node client</a> to connect to Elastic, <a href="https://www.npmjs.com/package/axios">Axios</a> to connect to the NASA APIs and <a href="https://www.npmjs.com/package/dotenv">dotenv</a> to parse your secrets. You will want to download the required packages running the following commands:</p>npm install @elastic/elasticsearch axios dotenv
<p>After downloading the required packages, you can create a .<code>env</code> file at the root of the project directory. The .<code>env</code> file allows you to keep your credentials secure locally. Check out the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/automatically-updating-your-index-nodejs-azure/env.example">example .env file</a> to learn more. To learn more about connecting to Elasticsearch, be sure to take a look at the <a href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm">documentation on the subject</a>.</p><p>To create a <code>.env</code> file, you can use this command at the root of your project:</p>touch .env
<p>In your <code>.env </code>, be sure to have the following entered in. Be sure to add your complete endpoint:</p>ELASTICSEARCH_ENDPOINT="https://...."
ELASTICSEARCH_API_KEY="YOUR_ELASTICSEARCh_API_KEY"
NASA_API_KEY="YOUR_NASA_API_KEY"
<p>You will also want to create a new JavaScript file as well:</p>touch loading_data_into_a_index.js
<h2>Creating your index and loading your data in</h2><p>Now that you have set up the proper file structure and downloaded the required packages, you are ready to create a script that creates an index and loads data into the index. If you get stuck along the way be sure to check out the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/automatically-updating-your-index-nodejs-azure/loading_data_into_a_index.js">full version of the file</a> you are creating in this section.</p><p>In the file <code>loading_data_into_a_index.js,</code> configure the <a href="https://www.npmjs.com/package/dotenv">dotenv</a> package to use the keys and tokens stored in your .<code>env </code>file. You should also import the <a href="https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html">Elasticsearch client</a> to connect to Elasticsearch and <a href="https://www.npmjs.com/package/axios">Axios</a> and make HTTP requests.</p>require('dotenv').config();

const { Client } = require('@elastic/elasticsearch');
const axios = require('axios');
<p>Since your keys and tokens are currently stored as environment variables, you will want to retrieve them and create a client to authenticate to Elasticsearch.</p>const elasticsearchEndpoint = process.env.ELASTICSEARCH_ENDPOINT;
const elasticsearchApiKey = process.env.ELASTICSEARCH_API_KEY;
const nasaApiKey = process.env.NASA_API_KEY;

const client = new Client({
  node: elasticsearchEndpoint,
  auth: {
    apiKey: elasticsearchApiKey
  }
});
<p>You can develop a function to retrieve data from NASA's NEO (Near Earth Object) Web Service asynchronously. You will first configure the base URL for the NASA API request and create date objects for today and the previous week to establish the query period. After you format these dates in the YYYY-MM-DD format required for the API request, set up the dates as query parameters and execute the GET request to the NASA API. Additionally, the function includes error-handling mechanisms to aid debugging should any issues arise.</p>async function fetchNasaData() {
  const url = "https://api.nasa.gov/neo/rest/v1/feed";
  const today = new Date();
  const lastWeek = new Date(today);
  lastWeek.setDate(today.getDate() - 7);

  const startDate = lastWeek.toISOString().split('T')[0];
  const endDate = today.toISOString().split('T')[0];
  const params = {
    api_key: nasaApiKey,
    start_date: startDate,
    end_date: endDate,
  };

  try {
    const response = await axios.get(url, { params });
    return response.data;
  } catch (error) {
    console.error('Error fetching data from NASA:', error);
    return null;
  }
}
<p>Now, you can create a function to transform the raw data from the NASA API into a structured format. Since the data you get back is currently nested in a complex JSON response. A more straightforward array of objects makes handling data easier.</p>function createStructuredData(response) {
  const allObjects = [];
  const nearEarthObjects = response.near_earth_objects;

  Object.keys(nearEarthObjects).forEach(date =&gt; {
    nearEarthObjects[date].forEach(obj =&gt; {
      const simplifiedObject = {
        close_approach_date: date,
        name: obj.name,
        id: obj.id,
        miss_distance_km: obj.close_approach_data.length &gt; 0 ? obj.close_approach_data[0].miss_distance.kilometers : null,
      };

      allObjects.push(simplifiedObject);
    });
  });

  return allObjects;
}
<p>You will want to create an index to store the data from the API. An <a href="https://www.elastic.co/blog/what-is-an-elasticsearch-index">index</a> inside Elasticsearch is where you can store your data in documents. In this function, you will check to see if an index exists and create a new one if needed. You will also specify the proper <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html">mapping</a> of fields for your index. This function also loads the data into the index as documents and maps the id field from the NASA data to the<code> _id</code> field in Elasticsearch.</p>async function indexDataIntoElasticsearch(data) {
  const indexExists = await client.indices.exists({ index: 'nasa-node-js' });
  if (!indexExists.body) {
    await client.indices.create({
      index: 'nasa-node-js',
      body: {
        mappings: {
          properties: {
            close_approach_date: { type: 'date' },
            name: { type: 'text' },
            miss_distance_km: { type: 'float' },
          },
        },
      },
    });
  }

  const body = data.flatMap(doc =&gt; [{ index: { _index: 'nasa-node-js', _id: doc.id } }, doc]);
  await client.bulk({ refresh: false, body });
}
<p>You will want to create a main function to fetch, structure, and index the data. This function will also print out the number of records being uploaded and log whether the data is indexed, whether there is no data to index, or whether it failed to get data back from the NASA API. After creating the <code>run</code> function, you will want to call the function and catch any errors that may come up.</p>async function run() {
  const rawData = await fetchNasaData();
  if (rawData) {
    const structuredData = createStructuredData(rawData);
    console.log(`Number of records being uploaded: ${structuredData.length}`);
    if (structuredData.length &gt; 0) {
      await indexDataIntoElasticsearch(structuredData);
      console.log('Data indexed successfully.');
    } else {
      console.log('No data to index.');
    }
  } else {
    console.log('Failed to fetch data from NASA.');
  }
}

run().catch(console.error);
<p>You can now run the file from your command line by running the following:</p>node loading_data_into_a_index.js
<p>To confirm that your index has been successfully loaded, you can check in the Elastic Dev Tools by executing the following API call:</p>GET /nasa-node-js/_search
<h2>Keeping your index updated with an Azure Function App</h2><p>Now that you've successfully loaded your data into your index locally, this data can quickly become outdated. To ensure your information remains current, you can set up an Azure Function App to automatically fetch new data daily and upload it to your Elasticsearch index.</p><p>The first step is to configure your Function app in Azure Portal. A helpful resource for getting started is the <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-create-function-app-portal?pivots=programming-language-javascript">Azure quick start guide</a>.</p><p>After you've set up your function, you can ensure that you have environment variables set up for <code>ELASTICSEARCH_ENDPOINT</code>, <code>ELASTICSEARCH_API_KEY</code>, and <code>NASA_API_KEY</code>. In Function Apps, environment variables are called Application settings. Inside your function app, click on the "Configuration" option in the left panel under "Settings." Under" the "Application settings" tab, click on "+ New application setting."</p><p>You will want to make sure the required libraries are installed as well. If you go to your terminal on the Azure Portal, you can install the necessary packages by entering the following:</p>npm install @elastic/elasticsearch axios
<p>The packages you are installing should look very similar to the previous install, except you will be using the moment to parse dates, and you no longer need to load an env file since you just set your secrets to be Application settings.</p><p>You can click where it says create to create a new function inside your Function App select the template entitled “Timer trigger”. You will now have a file called function.json set for you. You will want to adjust it to look as follows to run this application every day at 10 am.</p>{
    "bindings": [
      {
        "name": "myTimer",
        "type": "timerTrigger",
        "direction": "in",
        "schedule": "0 0 10 * * *"
      }
    ]
  }
<p>You'll also want to upload your <code>package.json</code> file and ensure it appears as follows:</p>{
  "name": "introduction-to-data-loading-in-elasticsearch-with-nodejs",
  "version": "1.0.0",
  "description": "A simple script for loading data in Elasticsearch",
  "main": "loading_data_into_a_index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" &amp;&amp; exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/JessicaGarson/Introduction-to-Data-Loading-in-Elasticsearch-with-Nodejs.git"
  },
  "author": "Jessica Garson",
  "license": "Apache-2.0",
  "bugs": {
    "url": "https://github.com/JessicaGarson/Introduction-to-Data-Loading-in-Elasticsearch-with-Nodejs/issues"
  },
  "homepage": "https://github.com/JessicaGarson/Introduction-to-Data-Loading-in-Elasticsearch-with-Nodejs#readme",
  "dependencies": {
    "@elastic/elasticsearch": "^8.12.0",
    "axios": "^0.21.1"
  }
}
<p>The next step is to create a <code>index.js</code> file. This script is designed to automatically update the data daily. It accomplishes this by systematically fetching and parsing new data each day and then seamlessly updating the dataset accordingly. Elasticsearch can use the same method to ingest time series or immutable data, such as webhook responses. This method ensures the information remains current and accurate, reflecting the latest available data.You can can check out the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/automatically-updating-your-index-nodejs-azure/loading_data_into_a_index.js">full code</a> as well.</p><p>The main differences between the script you run locally and this one are as follows:</p><ul><li><p>You will no longer need to load a <code>.env</code> file, since you have already set your environment variables</p></li><li><p>There is also different logging designed more towards creating a more sustainable script</p></li><li><p>You keep your index updated based on the most recent <code>close approach date</code></p></li><li><p>There is an entry point for an Azure Function App</p></li></ul><p>You will first want to set up your libraries and authenticate to Elasticsearch as follows:</p>const elasticsearchEndpoint = process.env.ELASTICSEARCH_ENDPOINT;
const elasticsearchApiKey = process.env.ELASTICSEARCH_API_KEY;
const nasaApiKey = process.env.NASA_API_KEY;

const client = new Client({
 node: elasticsearchEndpoint,
 auth: {
   apiKey: elasticsearchApiKey
 }
});
<p>Afterward, you will want to obtain the last date update date from Elasticsearch and configure a backup method to get data from the past day if anything goes wrong.</p>async function getLastUpdateDate() {
  try {
    const response = await client.search({
      index: 'nasa-node-js',
      body: {
        size: 1,
        sort: [{ close_approach_date: { order: 'desc' } }],
        _source: ['close_approach_date']
      }
    });

    if (response.body &amp;&amp; response.body.hits &amp;&amp; response.body.hits.hits.length &gt; 0) {
      return response.body.hits.hits[0]._source.close_approach_date;
    } else {
      // Default to one day ago if no records found
      const today = new Date();
      const lastWeek = new Date(today);
      lastWeek.setDate(today.getDate() - 1);
      return lastWeek.toISOString().split('T')[0];
    }
  } catch (error) {
    console.error('Error fetching last update date from Elasticsearch:', error);
    throw error;
  }
}
<p>The following function connects to NASA's NEO (Near Earth Object) Web Service to get the data to keep your index updated. There is also some additional error handling that can capture any API errors that might come up.</p>async function fetchNasaData(startDate) {

  const url = "https://api.nasa.gov/neo/rest/v1/feed";
  const today = new Date();

  const endDate = today.toISOString().split('T')[0];

  const params = {
    api_key: nasaApiKey,
    start_date: startDate,
    end_date: endDate,
  };

  try {
    // Perform the GET request to the NASA API with query parameters
    const response = await axios.get(url, { params });
    return response.data;
  } catch (error) {
    // Log any errors encountered during the request
    console.error('Error fetching data from NASA:', error);
    return null;
  }
}
<p>Now, you will want to create a function to organize your data by iterating over the objects of each date.</p>function createStructuredData(response) {
  const allObjects = [];
  const nearEarthObjects = response.near_earth_objects;

  Object.keys(nearEarthObjects).forEach(date =&gt; {
    nearEarthObjects[date].forEach(obj =&gt; {
      const simplifiedObject = {
        close_approach_date: date,
        name: obj.name,
        id: obj.id,
        miss_distance_km: obj.close_approach_data.length &gt; 0 ? obj.close_approach_data[0].miss_distance.kilometers : null,
      };

      allObjects.push(simplifiedObject);
    });
  });

  return allObjects;
}
<p>Now, you will want to load your data into Elasticsearch using the bulk indexing operation. This function should look similar to the one in the previous section.</p>async function indexDataIntoElasticsearch(data) {
  const body = data.flatMap(doc =&gt; [{ index: { _index: 'nasa-node-js', _id: doc.id } }, doc]);
  await client.bulk({ refresh: false, body });
}
<p>Finally, you will want to create an entry point for the function that will run according to the timer you set. This function is similar to a main function, as it calls the functions created previously in the file. There is also some additional logging, such as printing the number of records and informing you if the data was indexed correctly.</p>module.exports = async function (context, myTimer) {
  try {
    const lastUpdateDate = await getLastUpdateDate();
    context.log(`Last update date from Elasticsearch: ${lastUpdateDate}`);

    const rawData = await fetchNasaData(lastUpdateDate);
    if (rawData) {
      const structuredData = createStructuredData(rawData);
      context.log(`Number of records being uploaded: ${structuredData.length}`);
      
      if (structuredData.length &gt; 0) {

        const flatFileData = JSON.stringify(structuredData, null, 2);
        context.log('Flat file data:', flatFileData);

        await indexDataIntoElasticsearch(structuredData);
        context.log('Data indexed successfully.');
      } else {
        context.log('No data to index.');
      }
    } else {
      context.log('Failed to fetch data from NASA.');
    }
  } catch (error) {
    context.log('Error in run process:', error);
  }
<h2>Conclusion</h2><p>Using Node.js and <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview?pivots=programming-language-javascript">Azure's Function App</a>, you should be able to ensure that your Elasticsearch index is updated regularly. By utilizing Node.js's capabilities in conjunction with Azure's Function App, you can efficiently maintain your index's regular updates. This powerful combination offers a streamlined, automated process, reducing the manual effort involved in keeping your index regularly updated. Full code for this example can be found on <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/automatically-updating-your-index-nodejs-azure">Search Labs GitHub</a>. Let us know if you built anything based on this blog or if you have questions on our <a href="https://discuss.elastic.co/">forums</a> and <a href="https://communityinviter.com/apps/elasticstack/elastic-community">the community Slack channel</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-index-node-js-automatic-updates</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-index-node-js-automatic-updates</guid>
    <category><![CDATA[Javascript]]></category>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Jessica Garson]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt373f9290a1371dd7/6a17122e0c48579b2001abba/fd87bff40e296ebce871d631c86fd0245f11c796-1440x960.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 04 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elastic Connectors: Performance impact of incremental syncs]]></title>
    <description><![CDATA[Learn about full sync and incremental sync for connectors. Discover how incremental sync can boost the performance of Elastic connectors.]]></description>
    <content:encoded><![CDATA[<h2>Elastic Connectors overview</h2><p><a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors.html">Elastic Connectors</a> are a type of Elastic integrations that sync data from an original data source to an Elasticsearch index. Connectors enable you to create searchable, read-only replicas of your data sources.</p><p>There are a number of connectors that are supported for variety of 3rd-parties, such as:</p><ul><li><p><a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-mongodb.html">MongoDB</a></p></li><li><p>Various SQL DBMS such as <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-mysql.html">MySQL</a>, <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-postgresql.html">PostgreSQL</a>, <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-ms-sql.html">MSSQL</a> and <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-oracle.html">OracleDB</a></p></li><li><p><a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-sharepoint-online.html">Sharepoint Online</a></p></li><li><p><a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors-s3.html">Amazon S3</a></p></li><li><p>And many more. The full list is available <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors.html#connectors-build">here</a>.</p></li></ul><h2>Connectors content synchronization jobs</h2><p>Connectors support two types of content synchronization jobs: full syncs and incremental syncs.</p><h3>1. Full syncs</h3><p>Full sync is a sync that extracts all desired documents from a 3rd-party service and ingests them into Elasticsearch. So if you've set up your Network Drive connector to ingest all documents from a folder "\Documents/Reports\2022**.docx", during a full sync the connector will fetch all the documents that match this criteria and send all of them to Elasticsearch. Simplified pseudocode for this would look like:</p>connector = NetworkDriveConnector(
  host="192.168.0.105",
  path="\\Documents\Reports\2025\**.doc"
)

for incoming_document_metadata in connector.extract_documents():
    content = connector.download(incoming_document_metadata)

    document = {
      "id": incoming_document_metadata["id"]
      "content": content
    }

    elasticsearch.ingest(document)
<p>This works well until the sync starts to take too long. This could happen because the connector fetches more data than needed. For instance, why fetch old files that have not changed and send them to Elasticsearch? One could argue that the metadata for files could be unreliable, so all files need to be fetched again and sent to Elasticsearch. Indeed, that could be the case, but if we can trust the metadata of the data fetched from 3rd-party, we can ingest less data. Incremental sync is the way to do so.</p><h3>2. Incremental syncs</h3><p>Most of the time, if written well, connector spends doing IO operations. Returning to the example code there are 3 places where IO happens:</p>connector = NetworkDriveConnector(
  host="192.168.0.105",
  path="\\Documents\Reports\2025\**.doc"
)

# Place #1: reading document metadata from 3rd-party system
for incoming_document_metadata in connector.extract_documents():
    # Place #2: reading document content from 3rd-party system
    content = connector.download(incoming_document_metadata)

    document = {
      "id": incoming_document_metadata["id"]
      "content": content
    }

    # Place #3: ingesting the resulting document into Elasticsearch
    elasticsearch.ingest(document)
<p>Each of these places can become a bottleneck and take a significant amount of time during the sync.</p><p>Here's where incremental sync comes into play. Its purpose is to decrease the amount of IO on any of the stages, if possible.</p><h2>Potential optimizations for incremental sync</h2><h3>Fetch fewer documents from 3rd-party systems</h3><p>Modifying the example above, the code could look like this:</p>connector = NetworkDriveConnector(
  host="192.168.0.105",
  path="\\Documents\Reports\2025\**.doc"
)

# We can store last sync time somewhere
last_sync_time = connector.fetch_last_sync_time()

# And later use it querying Network Drive
for incoming_document_metadata in connector.extract_documents(from=last_sync_time):
    content = connector.download(incoming_document_metadata)

    document = {
      "id": incoming_document_metadata["id"]
      "content": content
    }

    elasticsearch.ingest(document)
<p>In cases where only a small number of documents change in our 3rd-party system, we can speed up the ingestion process significantly. However, for Network Drive it's not possible - its API does not support filtering documents by metadata. We won't be able to avoid scanning through the full content of Network Drive.</p><h3>Skip download of content of files that haven't changed since previous sync</h3><p>Downloading file content takes a significant amount of time in the syncs. If files are reasonably large, the connection is unstable or throughput is low, downloading the content of files would take most of the time when syncing the content from the 3rd-party. If we skip downloading some of them, it can already significantly speed up the connector.</p><p>Consider the following example pseudocode:</p>connector = NetworkDriveConnector(
  host="192.168.0.105",
  path="\\Documents\Reports\2025\**.doc"
)

last_sync_time = connector.fetch_last_sync_time()

for incoming_document_metadata in connector.extract_documents():
    # If document timestamp did not change then not fetching
    # document content can save us a lot of time
    if incoming_document_metadata["last_updated_at"] &gt; last_sync_time
        content = connector.download(incoming_document_metadata)

    document = {
      "id": incoming_document_metadata["id"]
      "content": content
    }

    elasticsearch.ingest(document)
<p>If no documents were updated, the sync will actually be magnitudes faster than fully syncing the content.</p><h3>Skip ingestion of non-modified documents into Elasticsearch</h3><p>While it may seem minor, ingestion of data into Elasticsearch takes a significant amount of time - albeit normally less than downloading the content from the 3rd-party system. We can start storing timestamps per each document and not send the documents into Elasticsearch if their timestamp did not change.</p><p>We can combine this approach with the previous approach to save the most time possible during the sync.</p>connector = NetworkDriveConnector(
  host="192.168.0.105",
  path="\\Documents\Reports\2025\**.doc"
)

# We need to fetch only IDs and timestamps as it's sufficient to make a decision.
# For large indices it can still take a good amount of RAM, but that's the price.
existing_documents = connector.fetch_existing_documents(fields=["id", "_timestamp"])

for incoming_document_metadata in connector.extract_documents():
    existing_document_metadata = existing_documents[document_metadata["id"]]
    
    # If a document for this 3rd-party record exists in Elasticsearch index
    # and timestamp did not change, then skip downloading its content
    # and skip ingesting the document
    if existing_document_metadata:
        incoming_document_timestamp = incoming_document_metadata["last_updated_at"]
        existing_document_timestamp = existing_document_metadata["_timestamp"]

        if incoming_document_timestamp == existing_document_timestamp:
            # Skip the document for good
            continue;

    content = connector.download(incoming_document_metadata)

    document = {
      "id": incoming_document_metadata["id"]
      "content": content,
      "_timestamp" = incoming_document_metadata["last_updated_at"]
    }

    elasticsearch.ingest(document)
<p>This approach helps save even more time when running a sync. Now let's take a look into performance considerations for such improvements.</p><h2>Measuring incremental sync performance</h2><p>Now since we've taken a look into simplified code that shows how incremental syncs can work, we can try to estimate potential performance improvements.</p><p>For some connectors, incremental sync is implemented in a certain manner that optimizes the way data is fetched from a 3rd-party. For example, the Sharepoint Online connector fetches some data via delta API - only collecting documents that changed after the last sync. This improves performance in an obvious manner - less data -&gt; less time to sync the data to latest.</p><p>For other connectors (currently all connectors except Sharepoint Online connector) incremental sync is done by framework in a generic way which was described in one of previous sections <a href="https://www.elastic.co/search-labs/blog/elastic-connectors-performance-impact-of-incremental-syncs#skip-ingestion-of-non-modified-documents-into-elasticsearch">"Skip ingestion of non-modified documents into Elasticsearch"</a>.</p><p>Connectors still collect all the data from 3rd-party data source (as it does not provide a way to fetch only the changed records). However if this data contains timestamps, the connector framework compares document IDs and timestamps of already ingested documents with incoming documents. If the document exists in Elasticsearch with the same timestamp that was received from the 3rd-party data source, then this document will not be sent to Elasticsearch.</p><p>We've described abstract approach for the performance improvements with incremental syncs, but we already have these implemented in connectors, so let's dive into real numbers!</p><h3>Performance tests</h3><p>We will estimate the rough magnitude of improvement for incremental syncs with these performance tests, not aiming at high precision.</p><p>The two connectors chosen for this test, Google Drive and Github, were chosen because they have different IO profiles.</p><p>Google Drive acts like a file storage. It:</p><ul><li><p>Has a fast API that does not throttle too soon</p></li><li><p>Normally stores a lot of binary content of variable size - from small to really large</p></li><li><p>Normally stores a small number of records - tens or hundreds of thousands rather than millions</p></li></ul><p>GitHub data is ingested via a more of a classic API, that:</p><ul><li><p>Throttles quite often</p></li><li><p>Contains many records that are much smaller than those in Google Drive</p></li><li><p>Does not send binary content at all</p></li></ul><p>Due to these differences, the incremental sync performance will majorly differ.</p><p>Both tests will contain these mandatory steps:</p><ol><li><p>Do a full sync against a 3rd-party system</p></li><li><p>Modify some documents on the 3rd-party system</p></li><li><p>Run an incremental sync and check the amount of time it takes</p></li></ol><p>This setup is very bare bones but will already give a good indication of the magnitude of performance improvement. Both tests will be slightly different and I will provide results with commentary in the next section.</p><h4>Setup #1 - Google Drive Connector</h4><p>Initial setup will be:</p><ul><li><p>1 folder is on Google Drive with 1553 files (100 of them are 2MB in size, 1443 are 5KB in size)</p></li><li><p>A full sync is executed and this data gets into Elasticsearch</p></li><li><p>More files are added into Google Drive to make it 10144 files (100 of them are 2MB in size, all the rest are 5KB in size)</p></li><li><p>Incremental sync is executed again to pull the new data</p></li><li><p>Then some minor changes are made to files on Google Drive (1 added, 2 deleted)</p></li><li><p>Incremental sync is executed again</p></li><li><p>Full sync is executed to compare the run time against incremental sync again</p></li></ul><p>The following table contains the results of the described test with commentary:</p><p>Sync Description</p><p>Run time</p><p>Documents Added</p><p>Documents Deleted</p><p>Comment</p><p>Initial Full Sync</p><p>0h 4m 0s</p><p>1553</p><p>0</p><p>This is initial sync - it pulls all documents</p><p>Incremental Sync after more data was added to Google Drive</p><p>0h 20m 9s</p><p>7939</p><p>0</p><p>Run time was high as expected - a lot of documents went in</p><p>Incremental Sync after some data was slightly changed in Google Drive</p><p>0h 1m 25s</p><p>1</p><p>2</p><p>Run was very fast. It still called Google Drive API a lot, but did not have to ingest 200+MB of data into Elasticsearch</p><p>Full Sync to compare performance</p><p>0h 23m 23s</p><p>10144</p><p>0</p><p>As expected, it takes a lot of time - all the data is downloaded from Google Drive and is sent to Elasticsearch, even if it did not change. We can assume that it takes 22 minutes to download and then upload the data into Elasticsearch for the setup</p><p>In summary, incremental sync significantly improved the performance of the connector because most of the time is spent on the connector downloading the content of the files and sending this content to Elasticsearch. Full sync brings 2 * 100 + 1443 * 5 / 1024 = 207MB of content - both downloaded by connector and ingested into Elasticsearch. If only 1 large file is changed, this amount changes to only 2MB - a magnitude of 100 change. This explains the performance improvement well.</p><h4>Setup #2: GitHub connector</h4><p>The GitHub connector is very different since the actual volume of data it syncs is relatively small - issues, pull requests and such are reasonably small, while there are lots of them. Additionally, GitHub has strict throttling policies and throttles connector a lot.</p><p>To give a good real-world example we’ll use the Kibana Github repository with the GitHub connector and observe its performance.</p><p>Sync Description</p><p>Run time</p><p>Documents Added</p><p>Documents Deleted</p><p>Comment</p><p>Initial Full Sync</p><p>8h 40m 1s</p><p>147421</p><p>0</p><p>---</p><p>Incremental Sync ran immediately after</p><p>9h 6m 7s</p><p>59</p><p>0</p><p>This sync took even more time to run, mostly because it was constantly throttled. Connector had to fetch all the data from GitHub but sent only 59 records with a total volume of less than 1MB</p><p>Next incremental sync</p><p>9h 2m 52s</p><p>191</p><p>1</p><p>This sync was triggered immediately after previous incremental sync finished. Run time is the same due to data being almost the same and throttling being a major factor in the connector run time</p><h3>Key takeaways</h3><ul><li><p>As you can see, there is no performance improvement for incremental sync for the Github connector - there is barely any space for optimization as most of the time is spent by the connector querying the system and waiting for the throttling to stop.</p></li><li><p>Documents that are extracted are reasonably small, so network throughput usage is minimal. To improve the connector run time, the incremental sync would actually have to limit the number of queries to Github, but at this point it's not implemented in the connector.</p></li></ul><h2>Summary</h2><p>What is the primary factor that impacts the performance of incremental sync? In simplified terms, it's the raw volume of data that is ingested.</p><p>For Sharepoint Online connector there is a special logic to fetch less data via the <a href="https://learn.microsoft.com/en-us/graph/api/driveitem-delta?view=graph-rest-1.0&amp;tabs=http">delta API</a>. This saves good amount of time because the delta API allows connectors to not fetch files that were not changed. Files tend to be large, thus not downloading and ingesting them will save a lot of time.</p><p>For other connectors, incremental sync is generic - it just checks document timestamps before ingesting them to Elasticsearch - if this document is already in the index and the timestamp did not change, then it is not ingested. It saves less time than the previous approach that Sharepoint Online employs but works generically for all connectors. Some connectors - ones that contain large documents - benefit from this logic a lot, while others - that get throttled by a 3rd-party system and contain relatively small documents - get no benefit from incremental syncs.</p><p>Additionally, if Elasticsearch is under heavy load, incremental sync is less likely to be throttled by Elasticsearch, thus making it more performant under load.</p><p>Let's look at the following graph:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc3a8743a67731c2f/6a171206dc55de4ca7e00f0d/b027636b0c73487b828a6c0390c808cc7da44420-1440x467.png" alt="" /><p>In the graph you can see how much time each part of content extraction and ingestion takes on the timeline. In the example above the connector is spending the most time on ingesting the data, even pausing for extraction and content download. In this case incremental sync has a potential of improving the run time of the sync by 30-40%.</p><p>Let's look at another example - a system that has throttling and low throughput, but stores very little data in Elasticsearch (Sharepoint Online, GitHub, Jira, Confluence):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbaf686e760c07694/6a17120766c4f9a936f8c14b/c29df18ba0b125df44bf8e5895d910da85f9a585-1440x467.png" alt="" /><p>This system will not benefit from generic incremental syncs a lot - most of the time is spent extracting content from the 3rd-party system.</p><p>And the last example - fast and accessible system that stores huge amounts of data in Elasticsearch (Google Drive, Box, OneDrive, Network Drive):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd2fc28e350c89c02/6a1712092b835fa96ff4b333/524f5a5c1a547dc59d65fcdcb591a432dd8dfa11-1440x467.png" alt="" /><p>If there aren't too many items that change in such a system between syncs, this system will benefit a lot from generic incremental syncs.</p><p>Currently connectors that potentially get the most of incremental sync are:</p><ul><li><p>Azure Blob Storage</p></li><li><p>Box</p></li><li><p>Dropbox</p></li><li><p>Google Cloud Storage</p></li><li><p>Google Drive</p></li><li><p>Network Drive</p></li><li><p>OneDrive</p></li><li><p>S3</p></li><li><p>Sharepoint Online</p></li></ul><p>Other connectors will benefit less from incremental syncs, or will not benefit at all, but there's no one-size-fits-all answer here. Performance heavily depends on the profile of data ingested. The bigger each individual document is, the bigger the benefit.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-connectors-performance-impact-of-incremental-syncs</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-connectors-performance-impact-of-incremental-syncs</guid>
    <category><![CDATA[Index Data]]></category>
    <dc:creator><![CDATA[Artem Shelkovnikov]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc53d5b63416b4a59/6a17120ab0367df6a672be40/c5f9995397d0425d6e66399d4818a259bdeacc40-1280x611.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 27 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to choose the best k and num_candidates for kNN search]]></title>
    <description><![CDATA[Learn strategies for selecting the optimal values for `k` and `num_candidates` parameters in kNN search, illustrated with practical examples.]]></description>
    <content:encoded><![CDATA[<h2>How to choose the best <code>k</code> and <code>num_candidates</code> for kNN search?</h2><p>Vector search has emerged as a game-changer in the current generative AI/ML world. It allows us to find similar items based on their semantic meaning rather just exact keyword matches.</p><p>Elasticsearch's k-Nearest Neighbors (kNN) algorithm is a foundational ML technique for classification and regression tasks. It found a significant place within Elasticsearch's ecosystem with the introduction of vector search capabilities. Introduced in Elasticsearch 8.5, kNN based vector search allows users to perform high-speed similarity searches on dense vector fields.</p><p>Users can find documents in the index "closest" to a given vector by leveraging the kNN algorithm using an underlying specified distance metric such as Euclidean or Cosine similarity. This feature marked a pivotal advancement as it is particularly useful in applications requiring semantic search, recommendations and other use cases such as anomaly detection.</p><p>The introduction of dense vector fields and k-nearest neighbor (kNN) search functionality in Elasticsearch has opened new horizons for implementing sophisticated search capabilities that go beyond traditional text search.</p><p>This article delves into strategies for selecting the optimal values for <code>k</code> and <code>num_candidates</code> parameters, illustrated with practical examples using Kibana.</p><h2>kNN search query</h2><p>Elasticsearch provides a kNN search option for nearest-neighbors - something like the following:</p>POST movies/_search
{
  "knn": {
    "field": "title_vector.predicted_value",
    "query_vector_builder": {
      "text_embedding": {
        "model_id": ".multilingual-e5-small",
        "model_text": "Good Ugly"
      }
    },
    "k": 3,
    "num_candidates": 100
  },
  "_source": [
    "id",
    "title"
  ]
}
<p>As the snippet shows, the <code>knn</code> query fetches the relevant results for the query in question (having a movie title as "Good Ugly") using vector search. The search is conducted in a multi-dimensional space, producing the closest vectors to the given query vector.</p><p>From the above query, notice two attributes: <code>num_candidates</code> which is the initial pool of candidates to consider and <code>k</code>, the number of nearest neighbors.</p><h2>kNN critical parameters - k and num_candidates</h2><p>To leverage the kNN feature effectively, one requires a nuanced understanding of the two critical parameters: <code>k</code> - the number of global nearest neighbors to retrieve, and <code>num_candidates</code> - the number of candidate neighbors considered for each shard during the search.</p><p>Choosing the optimal values for the <code>k</code> and <code>num_candidates</code> involves balancing precision, recall, and performance. These parameters play a crucial role to efficiently handle high-dimensional vector spaces commonly found in machine learning applications.</p><p>The optimal value for <code>k</code> largely depends on the specific use case. For example, if you're building a recommendation system, a smaller <code>k</code> (e.g., 10-20) might be sufficient to provide relevant recommendations. In contrast, for a use case where you'd want clustering or outlier detection capabilities, you might need a larger <code>k</code>.</p><p>Note that the higher <code>k</code> value can significantly increase both computation and memory usage, especially with large datasets. It's important to test different values of <code>k</code> to find a balance between result relevance and system resource usage.</p><h2>K: Unveiling the closest neighbors</h2><p>We have an option of choosing the <code>k</code> value as per our requirements. Sometimes, setting up a lower <code>k</code> value receives more or less exactly what you want with the exception that a few results might not make it to the final output. However, setting up a higher <code>k</code> value might broaden your search results in numbers, with a caveat that you may receive diversified results at times.</p><p>Imagine you're searching for a new book in the vast library of recommendations. <code>k</code>, also known as the number of nearest neighbors, determines how many books you'll be presented with. Think of it as the inner circle of your search results. Let's see how setting the lower and higher <code>k</code> values affects the number of books that the query returns.</p><h3>Setting lower K</h3><p>The lower K setting prioritizes extreme precision - meaning we will receive a handful of books that are the most similar to our query vector. This ensures a high degree of relevance to our specific interests. This might be ideal if you're searching for a book with a very specific theme or writing style.</p><h3>Setting higher K</h3><p>With a larger K value, we will be fetching a broader exploration result set. Note that the results might not be as tightly focused on your exact query. However, you'll encounter a wider range of potentially interesting books. This approach can be valuable for diversifying your reading list and discovering unexpected gems, perhaps.</p>Whenever we say higer or lower values of <code>k</code>, we mean the actual values depends on multiple factors, such as size of the data sets, available computing power and other factors. In some cases, the k=10 might be a large but in others it might be small too. So, do keep a note of the environmnet that this parateter is expected to operate.<h2>The <code>num_candidates</code> attribute: Behind the curtain</h2><p>While <code>k</code> determines the final number of books you see, <code>num_candidates</code> plays a crucial role under the hood. It essentially defines the search space per shard – the initial pool of books in a shard from which the most relevant K neighbors are identified. When we issue the query, we are expected to hint Elasticsearch to run the query amongst top "x" number of candidates on each shard.</p><p>For example, say our books index contains 5000 books evenly distributed amongst five primary shards (i.e., ~1000 books per shard). When we are performing a search, obviously choosing all 1000 documents for each shard is neither a viable nor a correct option. Instead, we will be pick up to say 25 documents (which is our <code>num_candidates</code>) from the 1000 documents. That amounts to 125 documents as our total search space (5 shards times 25 documents each).</p><p>We will let the kNN query know to choose the 25 documents from each shard and this number is the <code>num_candidates</code> parameter. When the kNN search is executed, the "coordinator" node sends the request query to all of the involved shards. The <code>num_candidates</code> documents from each shard will constitute the search space and the top <code>k</code> documents will be fetched from that space. Say, if <code>k</code> is 3, the top 3 documents out of the 25 candidate documents will be selected in each shard and returned to the coordinator node. That is, the coordinator node will receive 15 documents in total from all the involved nodes. These top 15 documents are then ranked to fetch the global top 3 (<code>k</code>==3) documents.</p><p>The process is depicted in the following figure:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb33aae4fd971e9f6/6a17d788e8fbce3ffd3a1750/41e690519fbef503742014b61b42ab193b6ff310-1440x836.jpg" alt="" /><p>Here's what <code>num_candidates</code> means for your search:</p><h3>Setting the lower num_candidates</h3><p>This approach might restrict the search space, potentially missing some relevant books that fall outside the initial exploration set. Think of it as surveying a smaller portion of the library's shelves.</p><h3>Setting the higher num_candidates</h3><p>A higher <code>num_candidates</code> value increases the likelihood of finding the true nearest neighbors within our chosen K. It expands the search space - that is - more number of candidates are considered - and hence leads to a slight increase in search time. So, a higher value generally increases accuracy (as the chance of missing relevant vectors decreases) but at the cost of performance.</p><h2>Balancing precision &amp; performance for kNN parameters</h2><p>The optimal values for <code>k</code> and <code>num_candidates</code> depend on a few factors and specific needs. If we prioritize extreme precision with a smaller set of highly relevant results, a lower <code>k</code> with a moderate <code>num_candidates</code> might be ideal. Conversely, if exploration and discovering unexpected books are your goals, a higher K with a larger <code>num_candidates</code> could be more suitable.</p><p>While there is no hard-and-fast rule to define the "lower" or "higher" number for the <code>num_candidates</code>, you need to decide this number based on your dataset, computing power and the expected precision.</p><h2>Experimentation to optimize kNN parameters</h2><p>By experimenting with different K and <code>num_candidates</code> combinations and monitoring search results and performance, you can fine-tune your searches to achieve the perfect balance between precision, exploration, and speed. Remember, there's no one-size-fits-all solution – the best approach depends on your unique goals and data characteristics.</p><h2>Practical example: Using kNN for movie recommendations</h2><p>Let's consider an example of movies to create a manual "simple" framework for understanding the effect of k and <code>num_candidates</code> attributes while searching for movies.</p><h3>Manual framework</h3><p>Let's understand how we can develop a home grown framework for tweaking the <code>k</code> and <code>num_of_candidates</code> attributes for a kNN search.</p><p>The mechanics of the framework is as follows:</p><ul><li><p>Create a movies index with a couple of <code>dense_vector</code> fields in the mapping to hold our vectorised data.</p></li><li><p>Create an embedding pipeline so each and every movie's title and synopsis fields will be embedded with a <code>multilingual-e5-small</code> model to store vectors.</p></li><li><p>Perform the indexing operation,which goes through the above embedding pipeline. The respective fields will be vectorised</p></li><li><p>Create a search query using kNN feature</p></li><li><p>Tweak the <code>k</code> and <code>num_candidates</code> options as you'd want</p></li></ul><p>Let's dig in.</p><h3>Creating an inference pipeline</h3><p>We will need to index data via Kibana - far from ideal - but it will do for this manual framework understanding. However, every movie that gets indexed must have the title and synopsis field vectorised to enable semantic search on our data. We can do this by elegantly creating a inference pipeline processor and attaching it to our batch indexing operation.</p><p>Let's create an inference pipeline:</p># Creating an inference pipeline processor
# The title and synopsis fields gets vectorised and stored in respective fields

PUT _ingest/pipeline/movie_embedding_pipeline
{
  "processors": [
    {
      "inference": {
        "model_id": ".multilingual-e5-small",
        "target_field": "title_vector",
        "field_map": { "title": "text_field" }
      }
    },
    {
      "inference": {
        "model_id": ".multilingual-e5-small",
        "target_field": "synopsis_vector",
        "field_map": { "synopsis": "text_field" }
      }
    }
  ]
}
<p>The inference pipeline <code>movie_embedding_pipeline</code>, as shown above, creates vector fields text embedding for title and synopsis fields. It uses the inbuilt <code>multilingual-e5-small</code> model to create the text embeddings.</p><h3>Creating index mappings</h3><p>We will need to create a mapping with couple of properties as <code>dense_vector</code> fields. The following code snippet does the job:</p># Creating a movies index
# Note the vector fields
PUT movies
{
  "mappings": { 
    "properties": { 
      "title": {
        "type": "text",
        "fields": { 
          "original": {
            "type": "keyword"
          }
        }
      },
      "title_vector.predicted_value": {
        "type": "dense_vector",
        "dims": 384,
        "index": true
      },
      "synopsis": {
        "type": "text"
      },
      "synopsis_vector.predicted_value": {
        "type": "dense_vector",
        "dims": 384,
        "index": true
      },
      "actors": {
        "type": "text"
      },
      "director": {
        "type": "text"
      },
      "rating": {
        "type": "half_float"
      },
      "release_date": {
        "type": "date",
        "format": "dd-MM-yyyy"
      },
      "certificate": {
        "type": "keyword"
      },
      "genre": {
        "type": "text"
      }
    }
  }
}

<p>Once the above command gets executed, we have a new movies index with the appropriate dense vector fields, including <code>title_vector.predicted_value</code> and <code>synopsis_vector.predicted_value</code> fields that hold respective vectors.</p>The <code>index</code> mapping parameter was set to false by default up to release 8.10. This has been changed in release 8.11, where the parameter is set to true by default, which makes it unnecessary to specify it.<p>Next step is to ingest the data.</p><h3>Indexing movies</h3><p>We can use <code>_bulk</code> operation to index a set of movies - I'm reusing a dataset that I had created for my Elasticsearch in Action 2nd edition book - which is available <a href="https://github.com/madhusudhankonda/elasticsearch-in-action/blob/main/datasets/movie_bulk_data.json">here</a>:</p><p>For completeness, a snippet of the ingestion using the <code>_bulk</code> operation is provided here:</p>POST _bulk?pipeline=movie_embedding_pipeline
{"index":{"_index":"movies","_id":"1"}}
{"title": "The Shawshank Redemption","synopsis": "Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.","actors": ["Tim Robbins", "Morgan Freeman", "Bob Gunton", "William Sadler"] ,"director":" Frank Darabont ","rating":"9.3","certificate":"R","genre": "Drama "}
{"index":{"_index":"movies","_id":"2"}}
{"title": "The Godfather","synopsis": "An organized crime dynasty's aging patriarch transfers control of his clandestine empire to his reluctant son.","actors": ["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"] ,"director":" Francis Ford Coppola ","rating":"9.2","certificate":"R","genre": ["Crime", "Drama"] }
{"index":{"_index":"movies","_id":"3"}}
{"title": "The Dark Knight","synopsis": "When the menace known as the Joker wreaks havoc and chaos on the people of Gotham, Batman must accept one of the greatest psychological and physical tests of his ability to fight injustice.","actors": ["Christian Bale", "Heath Ledger", "Aaron Eckhart", "Michael Caine"] ,"director":" Christopher Nolan ","rating":"9.0","certificate":"PG-13","genre": ["Action", "Crime", "Drama"] }

<p>Make sure you replace the script with the full dataset.</p>Note that the <code>_bulk</code> operation is suffixed with the pipeline (<code>?pipeline=movie_embedding_pipeline</code>) so the every movie gets passed through this pipeline, thus producing the vectors.<p>As we primed our <code>movies</code> indexed with vector embeddings, it's time to start our experiments on fine tuning <code>k</code> and <code>num_candidates</code> attributes.</p><h3>kNN search</h3><p>As we have vector data in our movies index, we will be using approximate k-nearest neighbor (kNN) search. For example, to recommend movies similar that has father-son sentiment ("Father and son" as search query), we'll use a kNN search to find the nearest neighbors:</p>POST movies/_search
{
  "_source": ["title"], 
  "knn": {
    "field": "title_vector.predicted_value",
    "query_vector_builder": {
      "text_embedding": {
        "model_id": ".multilingual-e5-small",
        "model_text": "Father and son"
      }
    },
    "k": 5,
    "num_candidates": 10
  }
}
<p>In the given example, the query leverages the top-level kNN search option parameter that directly focuses on finding documents closest to a given query vector. One key difference between this search with knn query at the top level as opposed to query at the top level is that in the former case, the query vector will be generated on-the-fly by a machine learning model.</p><p>The part in bold is not technically correct. On-the-fly vector generation is only achieved by using <code>query_vector_builder</code> instead of <code>query_vector</code> where you pass in the vector (computed outside of ES) but both the top-level knn search option and the knn search query provide this capability.</p><p>The script fetches the relevant results based on our search query (which is built using the <code>query_vector_builder</code> block). We are using a random <code>k</code> and <code>num_candidates</code> values set to 5 and 10 respectively.</p><h3>kNN query attributes</h3><p>The above query has a set of attributes that would make up the kNN query. The following information about these attributes will help you understand the query better:</p><p>The <code>field</code> attribute specifies the field in the index that contains the vector representations of our documents. In this case, <code>title_vector.predicted_value</code> is the field storing the document vectors.</p><p>The <code>query_vector_builder</code> attribute is where the example significantly diverges from simpler kNN queries. Instead of providing a static query vector, this configuration dynamically generates a query vector using a text embedding model. The model transforms a piece of text ("Father and son" in the example) into a vector that represents its semantic meaning.</p><p>The <code>text_embedding</code> indicates that a text embedding model will be used to generate the query vector.</p><p>The <code>model_id</code> is the identifier for the pre-trained machine learning model to use, It is the <code>.multilingual-e5-small</code> model in this example.</p><p>The <code>model_text</code> attribute is the text input that will be converted into a vector by the specified model. Here, it's the words "Father and son", which the model will interpret semantically to find similar movie titles.</p><p>The <code>k</code> is the number of nearest neighbors to retrieve - that is, it determines how many of the most similar documents to return based on the query vector.</p><p>The <code>num_candidates</code> attribute is the broader set of candidate documents per shard as potential matches to ensure the final results are as accurate as possible.</p><h3>kNN results</h3><p>Executing the kNN basic search script should get us top 5 results - for brevity, I'm providing just the list of the movies.</p># The results should get you a set of 5 movies as shown in the list below:

"title": "The Godfather"
"title": "The Godfather: Part II"
"title": "Pulp Fiction"
"title": "12 Angry Men"
"title": "Life Is Beautiful"
<p>As you can expect, Godfather (both parts) are part of the father-and-son bonding while Pulp Fiction shouldn't have been part of the results (though the query is asking about "bonding" - Pulp Fiction is all about the bonding between few people).</p><p>Now that we have a basic framework setup, we can tweak the parameters appropriate and deduce the approximate settings. Before we tweak the settings, let's understand the optimal setting of <code>k</code> attribute.</p><h3>Choosing optimal K value</h3><p>Choosing the optimal value of k in k-Nearest Neighbors (kNN) algorithms is crucial for attaining the best possible performance on our dataset with minimal errors. However, there isn't a one-size-fits-all answer, as the best <code>k</code> value can depend on a few factors such as specifics of our data and what we are trying to predict.</p><p>To choose an optimal <code>k</code> value, one must create a custom framework with several strategies and considerations.</p><ul><li><p>k = 1: Try running the search query with k=1 as a first step. Make sure you change the input query for each run. The query should give you unreliable results as changing the input query will return incorrect results over time. This leads to a ML pattern called "overfitting" where the model becomes overly reliant on the specific data points in the immediate neighborhood. Model, thus, struggles to generalize to unseen examples.</p></li><li><p>k = 5: Run the search query with k=5 and check the predictions. The stability of the search query should ideally improved and you should be getting adequate reliable predictions.</p></li></ul><p>You can either incrementally increase the value of <code>k</code> - may be increase in the steps of 5 or x - until you find that sweet spot where you'd find the results for the input queries are pretty much spot on with less number of errors.</p><p>You can go to extreme values of <code>k</code> too, for example, pick a higher value of <code>k=50</code>, as discussed below:</p><ul><li><p>k = 50: Increase the <code>k</code> value to 50 and check the search results. The errored results most likely outshine the actual/expected predictions. This is when you know that you are hitting the hard boundary of the <code>k</code> value. Larger <code>k</code> values leads to a ML feature called "underfitting" - a underfitting in KNN happens when the model is too simplistic and fails to capture the underlying patterns in the data.</p></li></ul><h3>Choosing the optimal <code>num_candidates</code> value</h3><p>The <code>num_candidates</code> parameter plays a crucial role in finding the optimal balance between search accuracy and performance. Unlike k, which directly influences the number of search results returned, <code>num_candidates</code> determines the size of the initial candidate set from which the final k nearest neighbors are selected. As discussed earlier, the <code>num_candidates</code> parameter defines how many nearest neighbors will be selected on each shard.</p><p>Adjusting this parameter is essential for ensuring that the search process is both efficient and yields high-quality results.</p><ul><li><p><code>num_candidates</code> = Small Value (e.g., 10): Start with a low value ("low-value-exploration") for <code>num_candidates</code> as a preliminary step. The aim is to establish a baseline for performance at this stage. As the candidate bunch is just a handful of candidates, the search will be fast but might miss relevant results - which leads to poor accuracy. This scenario helps us to understand the minimum threshold where the search quality is noticeably compromised.</p></li><li><p><code>num_candidates</code> = Moderate Value (e.g., 25?): Increase the <code>num_candidates</code> to a moderate value ("moderate-value-exploration") and observe the changes in search quality and execution time. A moderate number of candidates is likely to improve the accuracy of the results by considering a wider pool of potential neighbors. As the number of candidates increased, there's going to be cost of resources, be mindful of that. So, keep monitoring the performance metrics closely. However, as the search accuracy increases, perhaps the increase in computational cost could be justifiable.</p></li><li><p><code>num_candidates</code> = Step Increase: Continue to incrementally increase <code>num_candidates</code> (incremental-increase-exploration), possibly in steps of 20 or 50 (depending on the size of your dataset). Evaluate whether the additional candidates contribute to a meaningful improvement in search accuracy with each of the increments. There will be a a point of diminishing returns where increasing <code>num_candidates</code> further yields little to no improvement in result quality. At the same time you may have noticed, this will strain our resources and significantly impacts performance.</p></li><li><p><code>num_candidates</code> = High Value (say, 1000, 5000): Experiment with a high value for <code>num_candidates</code> to understand the upper bounds of the impact of choosing the higher settings. There's a possibility of your search accuracy stabilizing or degrading slightly due to the inclusion of less relevant candidates. This may lead to dilute the precision of the final k results. Do note that, as we've been talking about it, the high values of <code>num_candidates</code> will always increase the computational load - thus longer query times and potential resource constraints.</p></li></ul><h3>Finding the optimal balance</h3><p>We now know how to adjust the <code>k</code> and <code>num_candidates</code> attributes and how our experiments to different settings would change the outcome of search accuracy.</p><p>The goal is to find a sweet spot where the search results are consistently accurate with lower performance overhead from processing a large candidate set is manageable.</p><p>Of course, the optimal value will vary depending on the specifics of our data, the dimensionality of the vectors, and other performance requirements.</p><h2>Wrap up</h2><p>The optimal K value lies in finding the sweet spot by experiment and trials. You want to use enough neighbors (K being lower side) to capture the essential patterns but not so many (<code>k</code> being on the higher side) that the model becomes overly influenced by noise or irrelevant details. You also want to tweak the candidates so that the search results are accurate at a given <code>k</code> value.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-knn-and-num-candidates-strategies</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-knn-and-num-candidates-strategies</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Madhusudhan Konda]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb33aae4fd971e9f6/6a17d788e8fbce3ffd3a1750/41e690519fbef503742014b61b42ab193b6ff310-1440x836.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 24 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch open inference API adds support for Azure OpenAI embeddings]]></title>
    <description><![CDATA[Elasticsearch open inference API adds support for Azure OpenAI embeddings to be stored in the world's most downloaded vector database.]]></description>
    <content:encoded><![CDATA[<p>We're happy to announce that Elasticsearch now supports <a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/overview">Azure OpenAI embeddings</a> in our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-apis.html">open inference API</a>, enabling developers to store generated embeddings into our highly scalable and performant <a href="https://www.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains">vector database</a>.</p><p>This new functionality further solidifies our commitment to not only working with Microsoft and the Azure platform, but also toward our commitment to offering our customers more flexibility with their AI solutions.</p><h2>Ongoing Investment in AI at Elastic</h2><p>This is the latest in a series of additional features and integrations on AI enablement for Elasticsearch following on from:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-ai-studio-support">Elasticsearch open inference API adds Azure AI Studio support</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-completion-support">Elasticsearch open inference API adds support for Azure OpenAI chat completions</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-openai-completion-support">Elasticsearch open inference API adds support for OpenAI chat completions</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-embeddings-support">Elasticsearch open inference API adds support for Cohere Embeddings</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/azure-openai-on-your-data-elasticsearch-vector-database">Introducing Elasticsearch vector database to Azure OpenAI Service On Your Data (preview)</a></p></li></ul><p>The new <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-apis.html">inference</a> embeddings service provider for Azure OpenAI is already available in our <a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">stateless offering</a> on Elastic Cloud, and will be soon available to everyone in an upcoming Elastic release.</p><h2>Using Azure OpenAI Embeddings with the Elasticsearch Inference API</h2><h3>Deploying an Azure OpenAI Embeddings Model</h3><p>To get started, you will need a <a href="https://azure.microsoft.com/">Microsoft Azure Subscription</a> as well as access to <a href="https://aka.ms/oai/access">Azure OpenAI service</a>. Once you have registered and have access, you will need to create a resource in your <a href="https://azure.microsoft.com/en-us/get-started/azure-portal">Azure Portal</a>, and then deploy an embedding model to <a href="https://oai.azure.com/">Azure OpenAI Studio</a>. To do this, if you do not already have an Azure OpenAI resource in your Azure Portal, create a new one from the “Azure OpenAI” type which can be found in the Azure Marketplace, and take note of your resource name as you will need this later. When you create your resource, the region you choose may impact what models you have access to. See the Azure OpenAI deployment <a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#standard-deployment-model-availability">model availability table</a> for additional details.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt35aedf6095d45d06/6a17d706abe0f20405dfe854/53dfbc95b12448541a816f78f8d996c2a3c24399-416x220.png" alt="Azure OpenAI on Marketplace" /><p>Once you have your resource, you will also need one of your API keys which can be found in the “Keys and Endpoint” information from the Azure Portal's left side navigation:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d58e5f2c964df0a/6a17d707e31791350e2d5678/c45e937ec65b16818168264b30e0ec419f0f7eac-724x404.png" alt="Keys and Endpoint" /><p>Now, to deploy your Azure OpenAI Embedding model, go into your <a href="https://oai.azure.com/">Azure OpenAI Studio's</a> console and create your deployment using an <a href="https://platform.openai.com/docs/guides/embeddings/embedding-models">OpenAI Embeddings model</a> such as <code>text-embedding-ada-002</code>. Once your deployment is created, you should see the deployment overview. Also take note of the deployment name, in the example below it is “example-embeddings-model”.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9541eda646e4cfb3/6a17d709be608642de004594/ab8c1d64aaa3a41436f5c6be5a2ea867c648ae11-938x311.png" alt="Example Deployment" /><h3>Using your deployed Azure OpenAI embeddings model with the Elasticsearch Inference API</h3><p>With an Azure OpenAI embeddings model deployed, we can now configure your Elasticsearch deployment's <code>_inference</code> API and create a pipeline to index embeddings vectors in your documents. Please refer to the <a href="https://github.com/elastic/elasticsearch-labs/">Elastic Search Labs GitHub repository</a> for more in-depth guides and interactive notebooks.</p><p>To perform these tasks, you can use the Kibana Dev Console, or any REST console of your choice.</p><p>First, configure your inference endpoint using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.14/put-inference-api.html">create inference model endpoint</a> - we'll call this “example_model”:</p>PUT _inference/text_embedding/example_model
{
    "service": "azureopenai",
    "service_settings": {
        "api_key": "&lt;api-key&gt;",
        "resource_name": "&lt;resource-name&gt;",
        "deployment_id": "&lt;deployment-id&gt;",
        "api_version": "2024-02-01"
    },
    "task_settings": {
        "user": "&lt;optional-username&gt;"
    }
}
<p>For your inference endpoint, you will need your API key, your resource name, and the deployment id that you created above. For the “api_version”, you will want to use an available API version from the <a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#embeddings">Azure OpenAI embeddings documentation</a> - we suggest always using the latest version which is “2024-02-01” as of this writing. You can also optionally add a username in the task setting's “user” field which should be a unique identifier representing your end-user to help Azure OpenAI to monitor and detect abuse. If you do not want to do this, omit the entire “task_settings” object.</p><p>After running this command you should receive a <code>200 OK</code> status indicating that the model is properly set up.</p><p>Using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.14/post-inference-api.html">perform inference endpoint</a>, we can see an example of your inference endpoint at work:</p>POST _inference/text_embedding/example_model
{
  "input": "What is Elastic?"
}
<p>The output from the above command should provide the embeddings vector for the input text:</p>{
    "text_embedding": [
        {
            "embedding": [
                -0.0038039694,
                0.0054465225,
                -0.0018359756,
                -0.02274399,
                -0.01969836,
                ...
            ]
        }
    ]
}
<p>Now that we know our inference endpoint works, we can create a pipeline that uses it:</p>PUT _ingest/pipeline/azureopenai_embeddings
{
  "processors": [
    {
      "inference": {
        "model_id": "example_model", 
        "input_output": { 
          "input_field": "name",
          "output_field": "name_embedding"
        }
      }
    }
  ]
}
<p>This will create an ingestion pipeline named “azureopenai_embeddings” that will read the contents of the “name” field upon ingestion and apply the embeddings inference from our model to the “name_embedding” output field. You can then use this ingestion pipeline when documents are ingested (e.g. via the _bulk ingest endpoint), or when reindexing an index that is already populated.</p><p>This is currently available through the open inference API in our <a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">stateless offering</a> on Elastic Cloud. It'll also be soon available to everyone in an upcoming versioned Elasticsearch release, with additional semantic text capabilites that will make this step even simpler to integrate into your existing workflows.</p><p>For an additional use case, you can walk through the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.14/semantic-search-inference.html">semantic search with inference tutorial</a> for how to perform ingestion and semantic search on a larger scale with Azure OpenAI and other services such as reranking or chat completions.</p><h2>Plenty more on the horizon</h2><p>This new extensibility is only one of many new features we are bringing to the AI table from Elastic. Bookmark <a href="https://www.elastic.co/search-labs">Elastic Search Labs</a> now to stay up to date! Ready to build RAG into your apps? Want to try different LLMs with a vector database? Check out our sample notebooks for LangChain, Cohere and more <a href="https://github.com/elastic/elasticsearch-labs">on Github</a>, and join the Elasticsearch <a href="https://www.elastic.co/training/elasticsearch-engineer">Engineer training</a> starting soon!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-embeddings-support</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-embeddings-support</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Mark Hoy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdf867d9327b3b843/6a17d70b3e9e450670ba12da/1ea2acd6fcfea41d4f57ce576c0aebd416724129-1440x660.png" length="0" type="image/png"/>
    <pubDate>Wed, 22 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch open inference API adds support for Azure OpenAI chat completions]]></title>
    <description><![CDATA[Azure OpenAI chat completions is available via the Elasticsearch inference API. Learn how to use this feature to answer questions.]]></description>
    <content:encoded><![CDATA[<p>We’ve integrated <a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions">Azure OpenAI chat completions</a> in the inference API, which allows our customers to build powerful GenAI applications based on chat completion using large language models like GPT-4 Azure and Elasticsearch developers can utilize the unique capabilities of the Elasticsearch vector database and the Azure AI ecosystem to power unique GenAI applications with the model of their choice.</p><p>This blog quickly goes over the catalog of supported providers in the open inference API and explains how to use Azure’s OpenAI chat completions to answer questions through an example.</p><h2>The inference API is growing…fast!</h2><p>We’re heavily extending the catalog of supported providers in the open inference API. Check out some of our latest blog posts on <a href="https://www.elastic.co/search-labs">Elastic Search labs</a> to learn more about recent integrations around embeddings, completions and reranking:</p><ul><li><p><a href="https://elastic.co/search-labs/blog/elasticsearch-azure-ai-studio-support">Elasticsearch open inference API adds support for Azure Open AI Studio</a></p></li><li><p><a href="https://elastic.co/search-labs/blog/elasticsearch-azure-openai-embeddings-support">Elasticsearch open inference API adds support for Azure Open AI embeddings</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-openai-completion-support">Elasticsearch open inference API adds support for OpenAI chat completions</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank">Elasticsearch open Inference API adds support for Cohere’s Rerank 3 model</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-embeddings-support">Elasticsearch open inference API adds support for Cohere Embeddings</a></p></li><li><p>...more to come!</p></li></ul><p>Azure OpenAI chat completions support is available through the open inference API in our <a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">stateless offering</a> on Elastic Cloud. It’ll also be soon available to everyone in an upcoming versioned Elasticsearch release. This also complements the capability to use the <a href="https://www.elastic.co/search-labs/blog/azure-openai-on-your-data-elasticsearch-vector-database">Elasticsearch vector database in the Azure OpenAI service.</a></p><h2>Using Azure’s OpenAI chat completions to answer questions</h2><p>In my last blog post about <a href="https://www.elastic.co/search-labs/blog/elasticsearch-openai-completion-support">OpenAI chat completions</a> we’ve learned how to summarize text using OpenAI’s chat completions. In this guide we’ll use Azure OpenAI chat completions to answer questions during ingestion to have answers ready ahead of searching. Make sure you have your Azure OpenAI api key, deployment id and resource name ready by <a href="https://azure.microsoft.com/en-us/free">creating a free Azure account</a> first and setting up a model suited for chat completions. You can follow <a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart">Azure's OpenAI Service GPT quickstart guide</a> to get a model up and running. In the following example we’ve used `gpt-4` with the version `2024-02-01`. You can read more about supported models and versions <a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions">here</a>.</p><p>In Kibana, you'll have access to a console for you to input these next steps in Elasticsearch without even needing to set up an IDE.</p><p>First, we configure a model, which will perform completions:</p>PUT _inference/completion/azure_openai_completion
{
    "service": "azureopenai",
    "service_settings": {
        "resource_name":"&lt;resource-name&gt;",
        "deployment_id": "&lt;deployment-id&gt;",
        "api_version": "2024-02-01",
        "api_key": "&lt;api-key&gt;"
    }
}
<p>You’ll get back a response similar to the following with status code `200 OK` on successful inference creation:</p>{
    "model_id": "azure_openai_completion",
    "task_type": "completion",
    "service": "azureopenai",
    "service_settings": {
        "resource_name": "&lt;resource-name&gt;",
        "deployment_id": "&lt;deployment-id&gt;",
        "api_version": "2024-02-01"
    },
    "task_settings": {}
}
<p>You can now call the configured model to perform completion on any text input. Let’s ask the model what’s inference in the context of GenAI:</p>POST _inference/completion/azure_openai_completion
{
    "input": "What is inference in the context of GenAI?"
}
<p>You should get back a response with status code `200 OK` explaining what inference is:</p>{
    "completion": [
        {
            "result": "In the context of generative AI, inference refers to the process of generating new data based on the patterns, structures, and relationships the AI has learned from the training data. It involves using a model that has been trained on a lot of data to infer or generate new, similar data. For instance, a generative AI model trained on a collection of paintings might infer or generate new, similar paintings. This is the useful part of machine learning where the actual task is performed."
        }
    ]
}
<p>Now we can set up a small catalog of questions, which we want to be answered during ingestion. We’ll use the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html">Bulk API</a> to index three questions about products of Elastic:</p>POST _bulk
{ "index" : { "_index" : "questions" } }
{"question": "What is Elasticsearch?"}
{ "index" : { "_index" : "questions" } }
{"question": "What is Kibana?"}
{ "index" : { "_index" : "questions" } }
{"question": "What is Logstash?"}
<p>You’ll get back a response with status `200 OK` back similar to the following upon successful indexing:</p>{
    "errors": false,
    "took": 385,
    "items": [
        {
            "index": {
                "_index": "questions",
                "_id": "4RO6YY8Bv2OsAP2iNusn",
                "_version": 1,
                "result": "created",
                "_shards": {
                    "total": 2,
                    "successful": 1,
                    "failed": 0
                },
                "_seq_no": 0,
                "_primary_term": 1,
                "status": 201
            }
        },
        {
            "index": {
                "_index": "questions",
                "_id": "4hO6YY8Bv2OsAP2iNuso",
                "_version": 1,
                "result": "created",
                "_shards": {
                    "total": 2,
                    "successful": 1,
                    "failed": 0
                },
                "_seq_no": 1,
                "_primary_term": 1,
                "status": 201
            }
        },
        {
            "index": {
                "_index": "questions",
                "_id": "4xO6YY8Bv2OsAP2iNuso",
                "_version": 1,
                "result": "created",
                "_shards": {
                    "total": 2,
                    "successful": 1,
                    "failed": 0
                },
                "_seq_no": 2,
                "_primary_term": 1,
                "status": 201
            }
        }
    ]
}
<p>We’ll create now our question and answering <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html">ingest pipeline</a> using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/script-processor.html">script-</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/inference-processor.html">inference-</a> and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/remove-processor.html">remove-processor</a>:</p>PUT _ingest/pipeline/question_answering_pipeline

{
    "processors": [
        {
            "script": {
                "source": "ctx.prompt = 'Please answer the following question: ' + ctx.question"
                }
        },
        {
            "inference": {
                "model_id": "azure_openai_completion",
                "input_output": {
                    "input_field": "prompt",
                    "output_field": "answer"
                }
            }
        },
        {
            "remove": {
                "field": "prompt"
            }
        }
    ]
}
<p>This pipeline prefixes the content with the instruction “Please answer the following question: “ in a temporary field named `prompt`. The content of this temporary `prompt` field will be sent to Azure’s OpenAI Service through the inference API to perform a completion. Using an ingest pipeline allows for immense flexibility as you can change the pre-prompt to anything you would like. This allows you to summarize documents for example, too. Check out <a href="https://www.elastic.co/search-labs/blog/elasticsearch-openai-completion-support">Elasticsearch open inference API adds support for OpenAI chat completions</a> to learn about how to build a summarisation ingest pipeline!</p><p>We now send our documents containing questions through the question and answering pipeline by calling the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html">reindex API</a>.</p>POST _reindex

{
  "source": {
    "index": "questions",
    "size": 50
  },
  "dest": {
    "index": "answers",
    "pipeline": "question_answering_pipeline"
  }
}
<p>You'll get back a response with status <code>200 OK</code> similar to the following:</p>{
    "took": 10651,
    "timed_out": false,
    "total": 3,
    "updated": 0,
    "created": 3,
    "deleted": 0,
    "batches": 1,
    "version_conflicts": 0,
    "noops": 0,
    "retries": {
        "bulk": 0,
        "search": 0
    },
    "throttled_millis": 0,
    "requests_per_second": -1.0,
    "throttled_until_millis": 0,
    "failures": []
}
<p>In a real world setup you’ll probably use another ingestion mechanism to ingest your documents in an automated way. Check out our <a href="https://www.elastic.co/guide/en/cloud/current/ec-cloud-ingest-data.html">Adding data to Elasticsearch guide</a> to learn more about the various options offered by Elastic to ingest data into Elasticsearch. We’re also committed to showcase ingest mechanisms and provide guidance on how to bring data into Elasticsearch using 3rd party tools. Take a look at <a href="https://www.elastic.co/search-labs/blog/data-ingestion-from-snowflake-to-elasticsearch-using-meltano">Ingest Data from Snowflake to Elasticsearch using Meltano: A developer’s journey</a> for example on how to use Meltano for ingesting data.</p><p>You're now able to search for your pre-generated answers using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html">Search API</a>:</p>POST answers/_search

{
  "query": {
    "match_all": { }
  }
}
<p>In the response you'll get back your pre-generated answers:</p>{
    "took": 11,
    "timed_out": false,
    "_shards": { ... },
    "hits": {
        "total": { ... },
        "max_score": 1.0,
        "hits": [
            {
                "_index": "answers",
                "_id": "4RO6YY8Bv2OsAP2iNusn",
                "_score": 1.0,
                "_ignored": [
                    "answer.keyword"
                ],
                "_source": {
                    "model_id": "azure_openai_completion",
                    "question": "What is Elasticsearch?",
                    "answer": "Elasticsearch is an open-source, RESTful, distributed search and analytics engine built on Apache Lucene. It can handle a wide variety of data types, including textual, numerical, geospatial, structured, and unstructured data. Elasticsearch is scalable and designed to operate in real-time, making it an ideal choice for use cases such as application search, log and event data analysis, and anomaly detection."
                }
            },
            { ... },
            { ... }
        ]
    }
}
<p>Pre-generating answers for frequently asked questions is particularly effective in reducing operational costs. By minimizing the need for on-the-fly response generation, you can significantly cut down on the amount of computational resources required like token usage. Additionally, this method ensures that every user receives the same, precise information. Consistency is crucial, especially in fields requiring high reliability and accuracy such as medical, legal, or technical support.</p><h2>More to come!</h2><p>We’re already working on adding support for more task types using Cohere, Google Vertex AI and many more. Furthermore we’re actively developing an intuitive UI in Kibana for managing Inference endpoints. Lots of exciting stuff to come! Bookmark <a href="https://www.elastic.co/search-labs">Elastic Search Labs</a> now to keep with Elastic’s innovations in the GenAI space!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-completion-support</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-completion-support</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Tim Grein]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt348f5acc6a75137a/6a17d7041d1b83fac593e2cd/88c9d88ac1e3c32b8a91732cfcad2c2093b6a6f6-1440x747.png" length="0" type="image/png"/>
    <pubDate>Wed, 22 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch open inference API adds Azure AI Studio support]]></title>
    <description><![CDATA[Elasticsearch open inference API now supports Azure AI Studio. Learn how to use Azure AI Studio capabilities with Elasticsearch in this blog.]]></description>
    <content:encoded><![CDATA[<p>As part of our ongoing commitment to serve the Microsoft Azure developers with the tools of their choice, we are happy to announce that Elasticsearch now provides integration of the <a href="https://learn.microsoft.com/en-us/azure/ai-studio/how-to/model-catalog-overview">hosted model catalog</a> on Microsoft Azure AI Studio into our open inference API. This complements the ability for developers to bring their<a href="https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/azure-openai-service-expands-quot-on-your-data-quot-with/ba-p/4097023"> Elasticsearch vector database to be used in Azure OpenAI</a>.</p><p>Developers can use the capabilities of the world's most downloaded vector database to store and utilize embeddings generated from OpenAI models from Azure AI studio or access the wide array of chat completion model deployments for quick access to conversational models like <code>mistral-small</code>.</p><p>Just recently we've added support for Azure OpenAI <a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-embeddings-support">text embeddings</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-completion-support">completion</a>, and now we've added support for utilizing Azure AI Studio. Microsoft Azure developers have complete access to Azure OpenAI &amp; Microsoft Azure AI Studio service capabilities and can <a href="https://www.elastic.co/search-labs/blog/azure-openai-on-your-data-elasticsearch-vector-database">bring their Elasticsearch</a> data to <a href="https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/azure-openai-service-expands-quot-on-your-data-quot-with/ba-p/4097023">revolutionize conversational search</a>.</p><p>Let's walk you through just how easily you can use these capabilities with Elasticsearch.</p><h2>Deploying a model in Azure AI Studio</h2><p>To get started, you'll need a <a href="https://azure.microsoft.com/">Microsoft Azure</a> subscription as well as access to <a href="https://ai.azure.com/">Azure AI Studio</a>. Once you are set up, you'll need to deploy either a text embedding model or a chat completion model from the <a href="https://ai.azure.com/explore/models">Azure AI Studio model catalog</a>. Once your model is deployed, on the deployment overview page take note of the target URL and your deployment's API key - you'll need these later to create your inference endpoint in Elasticsearch.</p><p>Furthermore, when you deploy your model, Azure offers two different types of deployment options - a “pay as you go” model (where you pay by the token), and a “realtime” deployment which is a dedicated VM that is billed by the hour. Not all models will have both deployment types available, so be sure to take note as well as which deployment type is used.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt51abed9b55573072/6a17d7016864a46975b685d7/fb111aa1bb5cf45f78b7eabb1c2eb0498c441773-763x319.png" alt="Azure AI Studio Deployment" /><h2>Creating an Inference API Endpoint in Elasticsearch</h2><p>Once your model is deployed, we can now create an endpoint for your inference task in Elasticsearch. For the examples below we are using the <a href="https://ai.azure.com/explore/models/Cohere-command-r/version/3/registry/azureml-cohere">Cohere Command R model</a> to perform chat completion.</p><p>In Elasticsearch, create your endpoint by providing the service as “azureaistudio”, and the service settings including your API key and target from your deployed model. You'll also need to provide the model provider, as well as the endpoint type from before (either “token” or “realtime”). In our example, we've deployed a Cohere model with a token type endpoint.</p>PUT _inference/completion/test_cohere_chat_completion
{
  "service": "azureaistudio",
  "service_settings": {
    "api_key": "&lt;&lt;API_KEY&gt;&gt;",
    "target": "&lt;&lt;TARGET_URL&gt;&gt;",
    "provider": "cohere",
    "endpoint_type": "token"
  }
}
<p>When you send Elasticsearch the command, it should return back the created model to confirm that it was successful. Note that the API key will never be returned and is stored in Elasticsearch's secure settings.</p>{
    "model_id": "test_cohere_chat_completion",
    "task_type": "completion",
    "service": "azureaistudio",
    "service_settings": {
        "target": "&lt;&lt;TARGET_URL&gt;&gt;",
        "provider": "cohere",
        "endpoint_type": "token"
    },
    "task_settings": {}
}
<p>Adding a model for using text embeddings is just as easy. For reference, if we had deployed the <a href="https://ai.azure.com/explore/models/Cohere-embed-v3-english/version/3/registry/azureml-cohere">Cohere-embed-v3-english model</a>, we can create our inference model in Elasticsearch with the “text_embeddings” task type by providing the appropriate API key and target URL from that deployment's overview page:</p>PUT _inference/text_embeddings/test_cohere_embeddings
{
  "service": "azureaistudio",
  "service_settings": {
    "api_key": "&lt;&lt;API_KEY&gt;&gt;",
    "target": "&lt;&lt;TARGET_URL&gt;&gt;",
    "provider": "cohere",
    "endpoint_type": "token"
  }
}
<h2>Let's perform some inference</h2><p>That's all there is to setting up your model. Now that that's out of the way, we can use the model. First, let's test the model out by asking it to provide some text given a simple prompt. To do this, we'll call the _inference API with our input text:</p>POST _inference/completion/test_cohere_chat_completion
{
  "input": "The answer to the universe is"
}
<p>And we should see Elasticsearch provide a response. Behind the scenes, Elasticsearch is calling out to Azure AI Studio with the input text and processes the results from the inference. In this case, we received the response:</p>{
    "completion": [
        {
            "result": "42. \n\nIn Douglas Adams' *The Hitchhiker's Guide to the Galaxy*, a super-computer named Deep Thought is asked what the answer to the ultimate question of life, the universe, and everything is. After calculating for 7.5-million years, Deep Thought announces that the answer is 42. \n\nThe number 42 has since become a reference to the novel, and many fans of the book series speculate as to what the actual question might be."
        }
    ]
}
<p>We've tried to make it easy for the end user to not have to deal with all the technical details behind the scenes, but we can also control our inference a bit more by providing additional parameters to control the processing such as sampling temperature and requesting the maximum number of tokens to be generated:</p>POST _inference/completion/test_cohere_chat_completion
{
  "input": "The answer to the universe is",
  "task_settings": {
    "temperature": 1.0,
    "do_sample": true,
    "max_new_tokens": 50
  }
}
<h2>That was easy. What else can we do?</h2><p>This becomes even more powerful when we are able to use our new model in other ways such as adding additional text to a document when it's used in an Elasticsearch ingestion pipeline. For example, the following pipeline definition will use our model and anytime a document using this pipeline is ingested, any text in the field “question_field” will be sent through the inference API and the response will be written to the “completed_text_answer” field in the document. This allows large batches of documents to be augmented.</p>PUT _ingest/pipeline/azure_ai_studio_cohere_completions
{
  "processors": [
    {
      "inference": {
        "model_id": "test_cohere_chat_completion", 
        "input_output": { 
          "input_field": "question_field",
          "output_field": "completed_text_answer"
        }
      }
    }
  ]
}
<h2>Limitless possibilities</h2><p>By harnessing the power of Azure AI Studio deployed models in your Elasticsearch inference pipelines, you can enhance your search experience's natural language processing and predictive analytics capabilities.</p><p>In upcoming versions of Elasticsearch, users can take advantage of new field mapping types that simplify the process even further where designing an ingest pipeline would no longer be necessary. Also, as alluded to in our <a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank#elasticsearchs-accelerated-roadmap-to-semantic-reranking-and-retrievers">accelerated roadmap for semantic search</a> the future will provide dramatically simplified support for inference tasks with Elasticsearch retrievers at query time.</p><p>These capabilities are available through the open inference API in our <a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">stateless offering</a> on Elastic Cloud. It'll also be soon available to everyone in an upcoming versioned Elasticsearch release.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-azure-ai-studio-support</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-azure-ai-studio-support</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Mark Hoy]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt857508512ce282f5/6a17d703e9ea87a89fa9c415/d5cfda5d59f5812a9819831938219a34c11a0bd9-1440x962.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 22 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to detect which index template Elasticsearch will use before an index creation]]></title>
    <description><![CDATA[Learn about Elasticsearch index templates and how to detect which index template Elasticsearch will use before creating the index itself.]]></description>
    <content:encoded><![CDATA[<h2>Overview</h2><p>Elasticsearch offers two types of index templates: <code>legacy</code> and <code>composable</code>. Composable templates introduced in Elasticsearch 7.8 that are set to replace legacy templates, both can still be used in Elasticsearch 8.</p><p>This article explores the differences between these templates and how they interact. In particular, we will focus on how you can detect which template will be used when you are creating an index. Let's get started by looking at how to create the different types of index templates.</p><h2>Index templates in Elasticsearch</h2><p>Legacy templates can be created using the following API:</p>PUT _template/t1
{
  "order": 1,
  "index_patterns": [...],
  "mappings": {...},
  "settings": {...},
  "alias": {...}
}
<p>Composable templates can be created using this API:</p>PUT _index_template/ct1
{
  "priority": 1,
  "index_patterns": [...],
  "template": {
    "mappings": {...},
    "settings": {...},
    "alias": {...}
  }
}
<p>Component templates are a third type, which are typically used for managing multiple templates with similar structures. For example, if you need to create hundreds of templates with similar structures, you can create a component template with the common settings, mappings, and aliases, and then include it in your index templates. Component templates can be created using this API:</p>PUT _component_template/template_1
{
  "template": {
    "mappings": {...},
    "settings": {...},
    "alias": {...}
  }
}
<h3>Important!</h3><strong>When both legacy and composable templates exist and they match with the same index pattern, the legacy template will be ignored.</strong> If two composable templates point to the same index pattern, the template with the highest priority will be used. If two legacy templates point to the same index pattern, the templates are merged, with higher-order templates overriding lower-order ones. If the order is the same, the templates are sorted by name and merged accordingly.<h2>Determining which template an index will use when it is created</h2><p>To determine which template an index will use upon creation, you can use the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-simulate-index.html"><code>_simulate_index</code></a> API. This API will return the template that will be used, along with any overlapping templates. However, if no composable templates are present, the API will return an empty body. In that case, you can create a dummy index and check the logs of the elected master node to determine which template will be used.</p><h2>What happens if you have both legacy templates and composable templates?</h2><p>As noted above, if you have both legacy and composable templates, the legacy template will be ignored as if it did not exist.</p>PUT _template/t1
{
  "index_patterns": [
    "test_index-*"
  ],
  "mappings": {
    "properties": {
      "field_1": {
        "type": "integer"
      },
      "field_2": {
        "type": "integer"
      }
    }
  }
}
<p>In such a case, you would get a warning message like the following when you run the command:</p>legacy template [t1] has index patterns [test_index-<em>] matching patterns from existing composable templates [ct1] with patterns (ct1 =&gt; [test_index-</em>]); this template [t1] may be ignored in favor of a composable template at index creation timePUT _index_template/ct1
{
  "index_patterns": [
    "test_index-*"
  ],
  "template": {
    "mappings": {
      "properties": {
        "field_1": {
          "type": "integer"
        }
      }
    },
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    }
  }
}
<p>If a newly created composable template matches an existing legacy template with the same or includes an index pattern you will get a warning message like the following:</p>index template [ct1] has index patterns [test_index-<em>] matching patterns from existing older templates [t1] with patterns (t1 =&gt; [test_index-</em>]); this template [ct1] will take precedence during new index creationPOST _index_template/_simulate_index/test_index-1
#response:
{
  "template": {
    "settings": {
      "index": {
        "number_of_shards": "1",
        "number_of_replicas": "0",
        "routing": {
          "allocation": {
            "include": {
              "_tier_preference": "data_content"
            }
          }
        }
      }
    },
    "mappings": {
      "properties": {
        "field_1": {
          "type": "integer"
        }
      }
    },
    "aliases": {}
  },
  "overlapping": [
    {
      "name": "t1",
      "index_patterns": [
        "test_index-*"
      ]
    }
  ]
}
<p>Use this command if you want to test it:</p>PUT test_index-1
GET test_index-1
<h3>Notes from real life scenario</h3><p>Conflicts can be annoying, and they can crash the application. Imagine that you have <code>logstash-dev-*</code>, <code>logstash-prd-*</code>, <code>logstash-stg-*</code> legacy templates that all working fine. If someone adds a single composable template that include index pattern like a <code>logstash-*</code> all legacy templates will be ignored, the fields types can be change and finally it can break the application. Because of that, it’s recommended to switch from legacy to composable templates if you are using Elasticsearch 7 and onwards.</p><p>Another good point to keep in mind is that if you run the Logstash in Elasticsearch 8 or higher, Logstash will add it's template as composable template by default. Because <a href="https://www.elastic.co/guide/en/logstash/current/plugins-outputs-elasticsearch.html#plugins-outputs-elasticsearch-manage_template"><code>manage_template</code></a> is set to <code>true</code> by default and Logstash <a href="https://www.elastic.co/guide/en/logstash/current/plugins-outputs-elasticsearch.html#plugins-outputs-elasticsearch-template_api"><code>template_api</code></a> is set to<code>composable</code> for Elasticsearch 8 and onwards. It will create a Logstash composable template with <code>logstash-*</code> index pattern if the composable template does not exist. Yes, it will ignore all legacy templates covering <code>logstash-*</code> and overlap them.</p><p>Template Overlapping</p><h2>1. What happens if you have two composable templates that point to the same index pattern?</h2><p>As previously mentioned, if you have two composable templates that point to the same index pattern, the composable template with the highest priority will take precedence.</p>PUT _index_template/ct1
{
  "priority": 0,
  "index_patterns": [
    "test_index-*"
  ],
  "template": {
    "mappings": {
      "properties": {
        "field_1": {
          "type": "integer"
        }
      }
    },
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    }
  }
}
PUT _index_template/ct2
{
  "priority": 1,
  "index_patterns": [
    "test_index-*"
  ],
  "template": {
    "mappings": {
      "properties": {
        "field_1": {
          "type": "keyword"
        },
        "field_2": {
          "type": "integer"
        }
      }
    },
    "settings": {
      "number_of_shards": 2,
      "number_of_replicas": 0
    }
  }
}
POST _index_template/_simulate_index/test_index-1
#response:
{
  "template": {
    "settings": {
      "index": {
        "number_of_shards": "2",
        "number_of_replicas": "0",
        "routing": {
          "allocation": {
            "include": {
              "_tier_preference": "data_content"
            }
          }
        }
      }
    },
    "mappings": {
      "properties": {
        "field_1": {
          "type": "keyword"
        },
        "field_2": {
          "type": "integer"
        }
      }
    },
    "aliases": {}
  },
  "overlapping": [
    {
      "name": "ct1",
      "index_patterns": [
        "test_index-*"
      ]
    }
  ]
}
<p>In this example, you have two templates—ct1 and ct2—both targeting the same index pattern test_index-<em>. However, ct2 has a higher priority (1) than ct1 (0). Therefore, when you create an index that matches the pattern test_index-</em>, the settings and mappings defined in ct2 will be applied before ct1. If there are the same settings in the ct1 and ct2 templates, the ct2 template will overwrite.</p><h2>2. What happens if you have two legacy templates that point to the same index pattern?</h2><p>As highlighted above, if you have multiple templates that point to the same index pattern, the templates with lower-order values are merged first. Templates with higher-order values are merged later, overriding templates with lower values.</p><p>If two legacy templates have the same order value, they will be sorted by name. For example, in a case with [t2, t1], t1 would be merged first, t2 would be merged later, and t2 would override t1 if there are any same mapping/settings/aliases.</p>PUT _template/t1
{
  "index_patterns": ["test_index-*"],
  "mappings": {
    "properties": {
      "field_1": {
        "type": "integer"
      }
    }
  },
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  }
}
PUT _template/t2
{
  "index_patterns": [
    "test_index-*"
  ],
  "mappings": {
    "properties": {
      "field_1": {
        "type": "geo_point"
      },
      "field_2": {
        "type": "long"
      }
    }
  },
  "settings": {
    "number_of_shards": 2,
    "number_of_replicas": 0
  }
}
POST _index_template/_simulate_index/test_index-1
#response
{}
<p>Unfortunately, if you don't have composable templates, this API call responds with an empty body. So how you can check that?</p><p>The answer is to create a dummy index and check the Elasticsearch elected-master logs.</p>PUT test_index-test
2023-11-14 14:14:27 {"@timestamp":"2023-11-14T11:14:27.535Z", "log.level": "WARN",  "data_stream.dataset":"deprecation.elasticsearch","data_stream.namespace":"default","data_stream.type":"logs","elasticsearch.event.category":"templates","event.code":"index_template_multiple_match","message":"index [test_index-1] matches multiple legacy templates [t1, t2], composable templates will only match a single template" , "ecs.version": "1.2.0","service.name":"ES_ECS","event.dataset":"deprecation.elasticsearch","process.thread.name":"elasticsearch[elasticsearch][masterService#updateTask][T#3]","log.logger":"org.elasticsearch.deprecation.cluster.metadata.MetadataCreateIndexService","trace.id":"85e0a432ec11e2f2d3c7883f510376ac","elasticsearch.cluster.uuid":"Jc-a46VUSjOwuxWmbnSDZQ","elasticsearch.node.id":"MTX1x5-OTlWhiGa9lwUJPw","elasticsearch.node.name":"elasticsearch","elasticsearch.cluster.name":"elasticsearch-cluster1"}

2023-11-14 14:14:27 {"@timestamp":"2023-11-14T11:14:27.605Z", "log.level": "INFO", "message":"[test_index-1] creating index, cause [api], templates [t2, t1], shards [2]/[0]", "ecs.version": "1.2.0","service.name":"ES_ECS","event.dataset":"elasticsearch.server","process.thread.name":"elasticsearch[elasticsearch][masterService#updateTask][T#3]","log.logger":"org.elasticsearch.cluster.metadata.MetadataCreateIndexService","trace.id":"85e0a432ec11e2f2d3c7883f510376ac","elasticsearch.cluster.uuid":"Jc-a46VUSjOwuxWmbnSDZQ","elasticsearch.node.id":"MTX1x5-OTlWhiGa9lwUJPw","elasticsearch.node.name":"elasticsearch","elasticsearch.cluster.name":"elasticsearch-cluster1"}
<p>From the logs, we can see that "[test_index-1] creating index, cause [api], templates [t2, t1]".</p>GET _cat/templates/t*?v
name index_patterns order version composed_of
t2   [test_index-*] 0
t1   [test_index-*] 0
<p>As you can see, both legacy templates t1 and t2 have the same order; so, which one will override the other?</p><p>In this case, Elasticsearch will sort the legacy index templates according to their names and apply them. Both templates will be applied, and the first one in the list, which is t2 in this example, will override the template.</p><h4>Bonus: What happens if you have two legacy templates that point to the same index pattern with same field name but inappropriate type?</h4><p>Attempting to merge attributes within the legacy template, regardless of the order, is likely to fail since field definitions should remain atomic. This issue is a primary motivator for introducing the new composable templates. See the below example. We thank Philipp Krenn for adding these comments to the article.</p>PUT _template/test1
{
  "order": 3,
  "index_patterns": [
    "test-*"
  ],
  "mappings": {
    "properties": {
      "my_field": {
        "type": "integer",
        "ignore_malformed": true
      }
    }
  }
}
PUT _template/test2
{
  "order": 2,
  "index_patterns": [
    "test-*"
  ],
  "mappings": {
    "properties": {
      "my_field": {
        "type": "keyword",
        "ignore_above": 1024
      }
    }
  }
}
PUT test-1/_doc/1
{
  "my_field": "a string..."
}
#response:
{
  "error": {
    "root_cause": [
      {
        "type": "mapper_parsing_exception",
        "reason": "unknown parameter [ignore_above] on mapper [my_field] of type [integer]"
      }
    ],
    "type": "mapper_parsing_exception",
    "reason": "Failed to parse mapping: unknown parameter [ignore_above] on mapper [my_field] of type [integer]",
    "caused_by": {
      "type": "mapper_parsing_exception",
      "reason": "unknown parameter [ignore_above] on mapper [my_field] of type [integer]"
    }
  },
  "status": 400
}
<h2>Notes and good things to know</h2><ol><li><p>Using legacy templates in the same order can cause a lot of confusion. That’s why it's recommended to add order to the template.</p></li><li><p>Templates with lower-order values are merged first. Templates with higher order values are merged later, overriding templates with lower values.</p></li><li><p>You can't create two composable templates with the same priority.</p></li></ol>{
  "type": "illegal_argument_exception",
  "reason": "index template [ct2] has index patterns [test_index-*] matching patterns from existing templates [ct1] with patterns (ct1 =&gt; [test_index-*]) that have the same priority [0], multiple index templates may not match during index creation, please use a different priority"
}
<h2>Conclusion</h2><p>In conclusion, understanding how Elasticsearch's index templates work is crucial for effective index management. By knowing how to determine which template an index will use upon creation, you can ensure that your indices are created with the correct settings, mappings, and aliases.</p><h4>Resources</h4><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-template.html">https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-template.html</a> <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates-v1.html">https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates-v1.html</a> <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-simulate-index.html">https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-simulate-index.html</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-index-template</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-index-template</guid>
    <category><![CDATA[Index Data]]></category>
    <dc:creator><![CDATA[Musab Dogan]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt298e839e708ca11c/6a170b0fb339d560c2769fc2/38bc0377a6adce7eae0099f61902fdbbe644eb4a-1440x960.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 09 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Red Hat & Elastic: Red Hat OpenShift AI integration with Elasticsearch]]></title>
    <description><![CDATA[Red Hat OpenShift users can now implement Elasticsearch for vector search &amp; RAG applications via the Red Hat Ecosystem Catalog. Explore this integration here.]]></description>
    <content:encoded><![CDATA[<p>Red Hat and Elastic have <a href="https://www.redhat.com/en/about/press-releases/red-hat-and-elastic-fuel-retrieval-augmented-generation-genai-use-cases">collaborated</a> to enable integration for the Elasticsearch vector database on <a href="https://www.redhat.com/en/technologies/cloud-computing/openshift/openshift-ai">Red Hat OpenShift AI</a>. Red Hat OpenShift users can implement Elasticsearch for vector search and Retrieval-Augmented Generation (RAG) applications via the <a href="https://catalog.redhat.com/software/container-stacks/detail/5f32f067651c4c0bcecf1bfe">Red Hat Ecosystem Catalog</a>.</p><p>Elastic Cloud on Kubernetes (ECK) is a certified offering on Red Hat OpenShift. Elastic is an IBM <a href="https://cloud.ibm.com/docs/databases-for-elasticsearch">partner</a>, and IBM Watsonx Assistant and Watsonx Discovery use Elastic <a href="https://www.ibm.com/docs/en/announcements/watsonx-discovery-10">vector search</a> for question-answering and retrieval augmentation use cases.</p><p>With this collaboration, Elasticsearch users can benefit from Red Hat OpenShift AI, a flexible, scalable MLOps platform for building, training, testing, and serving models for AI-enabled applications.</p><h2>Elasticsearch vector database for generative AI and RAG apps</h2><p>Elasticsearch Relevance Engine (ESRE) is a comprehensive suite of developer tools for building generative AI and RAG applications. ESRE incorporates a <a href="https://www.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains">vector database</a> that stores embeddings for text, image, and video data. ESRE’s native hybrid search can effectively combine results containing text, vectors, and geospatial data, with filtering, aggregations, and document-level security.</p><p>With ESRE, developers can implement vector search and semantic search, including k-nearest neighbors (<a href="https://www.elastic.co/search-labs/blog/simplifying-knn-search?trk=feed-detail_main-feed-card_feed-article-content">kNN</a>) and approximate nearest neighbor (ANN) search, along with support for both built-in and third-party natural language processing (<a href="https://www.elastic.co/search-labs/blog/how-to-deploy-nlp-text-embeddings-and-vector-search">NLP</a>) models. ESRE also seamlessly integrates with key third-party ecosystem products from providers such as <a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank">Cohere</a>, LangChain, and LlamaIndex. Elasticsearch can be self-managed or deployed with <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">Elastic Cloud</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc88e752ba4c25d75/6a17d774445de9105c4cff50/4387b921978cde8ce8cdcf9dcb435d4fdaec6229-1440x663.png" alt="Elasticsearch as the preferred vector database solution on Red Hat OpenShift AI" /><p>As part of this collaboration, users are now able to leverage ESRE capabilities by downloading Elasticsearch directly from the <a href="https://catalog.redhat.com/software/container-stacks/detail/5f32f067651c4c0bcecf1bfe">Red Hat Ecosystem Catalog</a>.</p><h2>What is Red Hat OpenShift AI for generative AI apps</h2><p>Red Hat OpenShift AI is a hybrid MLOps platform that brings IT, data science, and app dev teams together. Designed to simplify Generative AI application development and deployment, it provides a comprehensive infrastructure stack tailored for distributed workloads. This includes training, optimizing, fine-tuning, and deploying foundational and predictive AI models. Collaborating with model builders helps provide access to a variety of pre-built models. Developers and data scientists can work together on the same platform, greatly enhancing collaboration. The platform facilitates end-to-end AI lifecycle management—from model development and training to deployment, serving, and continuous monitoring.</p><ul><li><p><strong>Model development</strong>: Conduct exploratory data science in JupyterLab with access to core AI / ML libraries and frameworks, including TensorFlow and PyTorch using our notebook images or your own.</p></li><li><p><strong>Model serving &amp; monitoring</strong>: Deploy models across on-premise or any cloud, either in a fully managed or self-managed Red Hat OpenShift footprint and centrally monitor their performance.</p></li><li><p><strong>Lifecycle Management</strong>: Create repeatable data science pipelines for model training and validation and integrate them with DevOps pipelines for the delivery of models across your enterprise.</p></li><li><p><strong>Increased capabilities and collaboration</strong>: Create projects and share them across teams. Combine Red Hat components, open-source software, and ISV-certified software.</p></li></ul><h2>Get started with Red Hat and Elasticsearch</h2><p>To get started, just follow the installation instructions provided in the <a href="https://catalog.redhat.com/software/container-stacks/detail/5f32f067651c4c0bcecf1bfe">Red Hat Ecosystem Catalog</a>, and start building your next generative AI application with RAG!</p><p>Visit <a href="https://www.elastic.co/search-labs">Elasticsearch Labs</a> for articles and sample notebooks on vector search, RAG, and more.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-redhat-openshift-ai-vector-database</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-redhat-openshift-ai-vector-database</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Aditya Tripathi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd5dd849e60ee215c/6a17d776faa913959493c6d3/56eeb9068e892907fa03ccda7556f9c0eae66f0b-1401x841.png" length="0" type="image/png"/>
    <pubDate>Tue, 07 May 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Making Elasticsearch and Lucene the best vector database: up to 8x faster and 32x efficient]]></title>
    <description><![CDATA[Discover the recent enhancements and optimizations that notably improve vector search performance in Elasticsearch &amp; Lucene vector database.]]></description>
    <content:encoded><![CDATA[<h2>Elasticsearch and Lucene report card: noteworthy speed and efficiency investments</h2><p>Our mission at Elastic is to make Apache Lucene the best vector database out there, and to continue to make Elasticsearch the best retrieval platform out there for search and RAG. Our investments into Lucene are key to ensure that every release of Elasticsearch brings increasing faster performance and scale.</p><p>Customers are already building the next generation of AI enabled search applications with Elastic’s vector database and vector search technology. <a href="https://roboflow.com/">Roboflow</a> is used by over 500,000 engineers to create datasets, train models, and deploy computer vision models to production. Roboflow uses Elastic vector database to store and search billions of vector embeddings.</p><p>In this blog we summarize recent enhancements and optimisations that significantly improve vector search performance in Elasticsearch and Apache Lucene, over and above performance gains <a href="https://www.elastic.co/search-labs/blog/apache-lucene-9.9-search-speedups">delivered with Lucene 9.9</a> and Elasticsearch 8.12.x.</p><p>The integration of vector search into Elasticsearch relies on Apache Lucene, the layer that orchestrates data storage and retrieval. <a href="https://www.elastic.co/search-labs/blog/vector-search-elasticsearch-rationale">Lucene's architecture</a> organizes data into segments, immutable units that undergo periodic merging. This structure allows for efficient management of inverted indices, essential for text search. With vector search, Lucene extends its capabilities to handle multi-dimensional points, employing the hierarchical navigable small world (HNSW) algorithm to index vectors.</p><p>This approach facilitates scalability, enabling data sets to exceed available RAM size while maintaining performance. Additionally, Lucene's segment-based approach offers lock-free search operations, supporting incremental changes and ensuring visibility consistency across various data structures. The integration however comes with its own engineering challenges. Merging segments requires recomputing HNSW graphs, incurring index-time overhead. Searches must cover multiple segments, leading to possible latency overhead. Moreover, optimal performance requires scaling RAM as data grows, which may raise resource management concerns.</p><p>Lucene's integration into Elasticsearch comes with the benefit of robust vector search capabilities. This includes aggregations, document level security, geo-spatial queries, pre-filtering, to full compatibility with various Elasticsearch features. Imagine running vector searches using a geo bounding box, this is an example usecase enabled by Elasticsearch and Lucene.</p><p>Lucene's architecture lays a solid foundation for efficient and versatile vector search within Elasticsearch. Let’s explore optimization strategies and enhancements we have implemented to integrate vector search into Lucene, which delivers a high performance and comprehensive feature-set for developers.</p><h2>Harnessing Lucene's architecture for multi-threaded search</h2><p>Lucene's segmented architecture enables the implementation of multi-threaded search capabilities. Elasticsearch’s performance gains come from efficiently searching multiple segments simultaneously. Latency of individual searches is significantly reduced by using the processing power of all available CPU cores. While it may not directly improve overall throughput, this enhancement prioritizes minimizing response times, ensuring that users receive their search results as swiftly as possible.</p><p>Furthermore, this optimization is particularly beneficial for Hierarchical Navigable Small World (HNSW) searches, as each graph is independent of the others and can be searched in parallel, maximizing efficiency and speeding up retrieval times even further.</p><p>The advantage of having multiple independent segments extends to the architectural level, especially in serverless environments. In this <a href="https://www.elastic.co/blog/elastic-serverless-architecture">new architecture,</a> the indexing tier is responsible for creating new segments, each containing its own HSNW graph. The search tier can simply replicate these segments without incurring the CPU cost of indexation. This separation allows a significant portion of compute resources to be dedicated to searches, optimizing overall system performance and responsiveness.</p><h2>Accelerating multi-graph vector search</h2><p>In spite of gains achieved with parallelization, each segment's searches would remain independent, unaware of progress made by other segment searches. So our focus shifted towards optimizing the efficiency of concurrent searches across multiple segments.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt35f29a49abcd2e22/6a17d78ae31791cd572d5682/103e9a7a97e9c219edb028e0fc675346920002cc-974x407.png" alt="" /><p>The graph shows that the number queries per second increased from 104 queries/sec to 219 queries/sec.</p><p>Recognizing the potential for further speedups, we leveraged our insights from optimizing lexical search, to enable information exchange among segment searches allowing for better coordination and efficiency in vector search.</p><p><a href="https://www.elastic.co/search-labs/blog/multi-graph-vector-search">Our strategy for accelerating multi-graph vector search</a> revolves around balancing exploration and exploitation within the proximity graph. By adjusting the size of the expanded match set, we control the trade-off between runtime and recall, crucial for achieving optimal search performance across multiple graphs.</p><p>In multi-graph search scenarios, the challenge lies in efficiently navigating individual graphs, while ensuring comprehensive exploration to avoid local minima. While searching multiple graphs independently yields higher recall, it incurs increased runtime due to redundant exploration efforts. To mitigate this, we devised a strategy to intelligently share state between searches, enabling informed traversal decisions based on global and local competitive thresholds.</p><p>This approach involves maintaining shared global and local queues of distances to closest vectors, dynamically adapting search parameters based on the competitiveness of each graph's local search. By synchronizing information exchange and adjusting search strategies accordingly, we achieve significant improvements in search latency while preserving recall rates comparable to single-graph searches.</p><p>The impact of these optimizations is evident in our benchmark results. In concurrent search and indexing scenarios, we notice up to 60% reduction in query latencies! Even for queries conducted outside of indexing operations, we observed notable speedups and a dramatic decrease in the number of vector operations required. These enhancements, integrated into Lucene 9.10 and subsequently Elasticsearch 8.13, mark significant strides towards enhancing vector database performance for search while maintaining excellent recall rates.</p><h2>Harnessing Java's latest advancements for ludicrous speed</h2><p>In the area of Java development, automatic vectorization has been a boon, optimizing scalar operations into SIMD (Single Instruction Multiple Data) instructions through the HotSpot C2 compiler. While this automatic optimization has been beneficial, it has its limitations, particularly in scenarios where explicit control over code shape yields superior performance. Enter Project Panama Vector API, a recent addition to the JDK offering an API for expressing computations reliably compiled to SIMD instructions at runtime.</p><p>Lucene's vector search implementation relies on fundamental operations like dot product, square, and cosine distance, both in floating point and binary variants. Traditionally, these operations were backed by scalar implementations, leaving performance enhancements to the JIT compiler. However, recent advancements introduce a paradigm shift, enabling developers to express these operations explicitly for optimal performance.</p><p>Consider the dot product operation, a fundamental vector computation. Traditionally implemented in Java with scalar arithmetic, recent innovations leverage the Panama Vector API to express dot product computations in a manner conducive to SIMD instructions. This revised implementation iterates over input arrays, multiplying and accumulating elements in batches, aligning with the underlying hardware capabilities.</p><p><a href="https://www.elastic.co/blog/accelerating-vector-search-simd-instructions">By harnessing Panama Vector API,</a> Java code now interfaces seamlessly with SIMD instructions, unlocking the potential for significant performance gains. The compiled code, when executed on compatible CPUs, leverages advanced vector instructions like AVX2 or AVX 512, resulting in accelerated computations. Disassembling the compiled code reveals optimized instructions tailored to the underlying hardware architecture.</p><p>Microbenchmarks comparing traditional Java implementations to those leveraging Panama Vector API illustrate dramatic performance improvements. Across various vector operations and dimension sizes, the optimized implementations outperform their predecessors by significant margins, offering a glimpse into the transformative power of SIMD instructions.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8efb30efc7e6c157/6a17d78bfbc5f8285d49190c/d2a5f15bb0d16608b67753a82312d2f254370622-1204x120.png" alt="" /><p>Micro-benchmark comparing dot product with the new Panama API (dotProductNew) and the scalar implementation (dotProductOld).</p><p>Beyond microbenchmarks, the real-world impact of these optimizations is quite exciting to think about. Vector search benchmarks, such as <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/so_vector/nightly/default/90d">SO Vector,</a> demonstrate notable enhancements in indexing throughput, merge times, and query latencies. Elasticsearch, embracing these advancements, incorporates the faster implementations by default, ensuring users reap the performance benefits seamlessly.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdd3a97bf5f0cd24a/6a17d78d3e9e452c84ba12e6/e82a7ee152fdebfc856106bdc1f68c6eab9b5798-1349x882.png" alt="" /><p>The graph shows indexing throughput increased from about 900 documents/sec to about 1300 documents/sec.</p><p>Despite the incubating status of Panama Vector API, its quality and potential benefits are undeniable. Lucene's pragmatic approach allows for selective adoption of non-final JDK APIs, balancing the promise of performance improvements with maintenance considerations. With Lucene and Elasticsearch, users can leverage these advancements effortlessly, with performance gains translating directly to real-world workloads.</p><p>The integration of Panama Vector API into Java development yields a new era of performance optimization, particularly in vector search scenarios. By embracing hardware-accelerated SIMD instructions, developers can unlock efficiency gains, visible both in microbenchmarks and macro-level benchmarks. As Java continues to evolve, leveraging its latest features promises to propel performance to new heights, enriching user experiences across diverse applications.</p><h2>Maximizing memory efficiency with scalar quantization</h2><p>Memory consumption has long been a concern for efficient vector database operations, particularly for searching large datasets. Lucene introduces a breakthrough optimization technique - scalar quantization - aimed at significantly reducing memory requirements without sacrificing search performance.</p><p>Consider a scenario where querying millions of float32 vectors of high dimensions demands substantial memory, leading to significant costs. By embracing byte quantization, Lucene slashes memory usage by approximately 75%, offering a viable solution to the memory-intensive nature of vector search operations.</p><p>For quantizing floats to bytes, Lucene implements <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-in-lucene">Scalar quantization</a> a lossy compression technique that transforms raw data into a compressed form, sacrificing some information for space efficiency. Lucene's implementation of scalar quantization achieves remarkable space savings with minimal impact on recall, making it an ideal solution for memory-constrained environments.</p><p>Lucene's architecture, consisting of nodes, shards, and segments, which facilitates efficient distribution and management of documents for search. Each segment stores raw vectors, quantized vectors, and metadata, ensuring optimized storage and retrieval mechanisms.</p><p>Lucene's vector quantization adapts dynamically over time, adjusting quantiles during segment merge operations to maintain optimal recall. By intelligently handling quantization updates and re-quantization when necessary, Lucene ensures consistent performance while accommodating changes in data distribution.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4680278043366ea4/6a17d78e1d1b83ebd893e2d5/73fb017cce8096a108a7a7297c86cfb26866922c-1440x447.png" alt="" /><p>Example of merged quantiles where segments A and B have 1000 documents and C only has 100.</p><p>Experimental results demonstrate the efficacy of scalar quantization in reducing memory footprint while maintaining search performance. Despite minor differences in recall compared to raw vectors, Lucene's quantized vectors offer significant speed improvements and recall recovery with minimal additional vectors.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt62d403efee1b4ebb/6a17d790e317918e322d5686/aacf329d8eb54a9b73a1e4722e14f27379dd80d7-576x455.png" alt="" /><p>Recall@10 for quantized vectors vs raw vectors. The search performance of quantized vectors is significantly faster than raw, and recall is quickly recoverable by gathering just 5 more vectors; visible by quantized@15.</p><p>Lucene's scalar quantization presents a revolutionary approach to memory optimization in vector search operations. With no need for training or optimization steps, Lucene seamlessly integrates quantization into its indexing process, automatically adapting to changes in data distribution over time. As Lucene and Elasticsearch continue to evolve, widespread adoption of scalar quantization will revolutionize memory efficiency for vector database applications, paving the way for enhanced search performance at scale.</p><h2>Achieving seamless compression with minimal impact on recall</h2><p>To make compression even better, we aimed to reduce each dimension from 7 bits to just 4 bits. Our main goal was to compress data further while still keeping search results accurate. By making some improvements, we managed to compress data by a factor of 8 without making search results worse. Here's how we did it.</p><p>We focused on keeping search results accurate while making data smaller. By making sure we didn't lose important information during compression, we could still find things well even with less detailed data. To make sure we didn't lose any important information, we added a smart error correction system.</p><p>We checked our compression improvements by testing them with different types of data and real search situations. This helped us see how well our searches worked with different compression levels and what we might lose in accuracy by compressing more.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta71070ad46fd4573/6a17d791be608665ca00459d/7c32834cfb733a6ad3deb64eb9813d4855539823-972x602.png" alt="" /><p>Comparison of int4 dot product values to the corresponding float values for a random sample of 100 documents and their 10 nearest neighbors.</p><p>These compression features were created to easily work with existing vector search systems. They help organizations and users save space without needing to change much in their setup. With this simple compression, organizations can expand their search systems without wasting resources.</p><p>In short, moving to 4 bits per dimension for scalar quantization was a big step in making compression more efficient. It lets users compress their original vectors by 8 times. By optimizing carefully, adding error correction, testing with real data, and offering scalable deployment, organizations could save a lot of storage space without making search results worse. This opens up new chances for efficient and scalable search applications.</p><h2>Paving the way for binary quantization</h2><p>The optimization to reduce each dimension to 4 bits not only delivers significant compression gains but also lays the groundwork for further advancements in compression efficiency. Specifically, future advancements like binary quantization into Lucene, a development that has the potential to revolutionize vector storage and retrieval.</p><p>In an ongoing effort to push the boundaries of compression in vector search, we are actively working on integrating binary quantization into Lucene using the same techniques and principles that underpin our existing optimization strategies. The goal is to achieve binary quantization of vector dimensions, thereby reducing the size of the vector representation by a factor of 32 compared to the original floating-point format.</p><p>Through our iterations and experiments, we want to deliver the full potential of vector search while maximizing resource utilization and scalability. Stay tuned for further updates on our progress towards integrating binary quantization into Lucene and Elasticsearch, and the transformative impact it will have on vector database storage and retrieval.</p><h2>Multi-vector integration in Lucene and Elasticsearch</h2><p>Several real world applications rely on text embedding models and large text inputs. Most embedding models have token limits, which necessitate chunking of longer text into passages. Therefore, instead of a single document, multiple passages and embeddings must be managed, potentially complicating metadata preservation.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte7cf9100604bef40/6a17d793033c8d5c696baff7/b8a6073b44c078ef8ee5294e559cf8092bf40e38-1440x903.png" alt="" /><p>Now instead of having a single piece of metadata indicating, for example the first chapter of the book “Little Women”, you have to index that information data for every sentence.</p><p>Lucene's "join" functionality, integral to Elasticsearch's nested field type, offers a solution. This feature enables multiple nested documents within a top-level document, allowing searches across nested documents and subsequent joins with their parent documents. So, how do we deliver support for vectors in nested fields with Elasticsearch?</p><p>The key lies in how Lucene joins back to parent documents when searching child vector passages. The parallel concept here is the debate around pre-filtering versus post-filtering in kNN methods, as the timing of joining significantly impacts result quality and quantity. To address this, <a href="https://www.elastic.co/search-labs/blog/adding-passage-vector-search-to-lucene">recent enhancements to Lucene</a> enable pre-joining against parent documents while searching the HNSW graph.</p><p>Practically, pre-joining ensures that when retrieving the k nearest neighbors of a query vector, the algorithm returns the k nearest documents instead of passages. This approach diversifies results without complicating the HNSW algorithm, requiring only a minimal additional memory overhead per stored vector.</p><p>Efficiency is improved by leveraging certain restrictions, such as disjoint sets of parent and child documents and the monotonicity of document IDs. These restrictions allow for optimizations using bit sets, providing rapid identification of parent document IDs.</p><p>Searching through a vast number of documents efficiently required investing in nested fields and joins in Lucene. This work helps storage and search for dense vectors that represent passages within long texts, making document searches in Lucene more effective. Overall, these advancements represent an exciting step forward in the area of vector database retrieval within Lucene.</p><h2>Wrapping up (for now)</h2><p>We're dedicated to making Elasticsearch and Lucene the best vector database with every release. Our goal is to make it easier for people to search for things. With some of the investments we discuss in this blog, there is significant progress, but we're not done!</p><p>To say that the gen AI ecosystem is rapidly evolving is an understatement. At Elastic, we want to give developers the most flexible and open tools to keep up with all the innovation—with features available across recent releases until 8.13 and <a href="https://www.elastic.co/blog/elastic-serverless-architecture">serverless</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-lucene-vector-database-gains</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Mayya Sharipova,Benjamin Trent,Jim Ferenczi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt35f29a49abcd2e22/6a17d78ae31791cd572d5682/103e9a7a97e9c219edb028e0fc675346920002cc-974x407.png" length="0" type="image/png"/>
    <pubDate>Fri, 26 Apr 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elastic Cloud adds Elasticsearch Vector Database optimized instance to Google Cloud]]></title>
    <description><![CDATA[Elasticsearch's vector search optimized profile for GCP is available. Learn more about it and how to use it in this blog.]]></description>
    <content:encoded><![CDATA[<p>Elastic Cloud Vector Search optimized hardware profile is available for Google Elastic Cloud users. This hardware profile is optimized for applications that require the storage of dense or sparse embeddings for search and Generative AI use cases powered by RAG (retrieval augmented generation). This release follows the previous release of a Vector Search optimized hardware profile for AWS Elastic Cloud users in Nov 2023.</p><h2>GCP Vector Search optimized instances: what you need to know</h2><p>Elastic Cloud users benefit from having Elastic managed infrastructure across all major cloud providers (GCP, AWS and Azure) along with <a href="https://www.elastic.co/guide/en/cloud/current/ec-regions-templates-instances.html">wide region support</a> for GCP users. For more specific details on the instance configuration for this hardware profile, refer to our documentation for instance type: <a href="https://www.elastic.co/guide/en/cloud/current/ec-default-gcp-configurations.html">gcp.es.datahot.n2d.64x8x11</a></p><h2>Vector Search, HNSW, and memory</h2><p>Elasticsearch uses the <a href="https://www.elastic.co/search-labs/blog/vector-search-elasticsearch-rationale">Hierarchical Navigable Small World</a> graph (HNSW) data structure to implement its Approximate Nearest Neighbor search (ANN). Because of its layered approach, HNSW's hierarchical aspect offers excellent query latency. To be most performant, HNSW requires the vectors to be cached in the node's memory. This caching is done automatically and uses the available RAM not taken up by the Elasticsearch JVM. Because of this, memory optimizations are important steps for scalability.</p><p>Consult our vector search <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-knn-search.html#_ensure_data_nodes_have_enough_memory">tuning guide</a> to determine the right setup for your vector search embeddings and whether you have adequate memory for your deployment.</p><p>With this in mind, the Vector Search optimized hardware profile is configured with a smaller than standard Elasticsearch JVM heap setting. This provides more RAM for caching vectors on a node, allowing users to provision fewer nodes for their vector search use cases.</p><p>If you’re using compression techniques like <a href="https://www.elastic.co/search-labs/blog/scalar-quantization-in-lucene">scalar quantization</a>, the memory requirement is lowered by a factor of 4. To store quantized embeddings (available in versions Elasticsearch 8.12 and later) simply ensure that you’re storing in the correct <code>element_type: byte</code>. To utilize our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-quantization">automatic quantization</a> of <code>float</code> vectors update your embeddings to use index type: <code>int8_hnsw</code> like in the following mapping example.</p>PUT my-byte-quantized-index
{
  "mappings": {
    "properties": {
      "my_vector": {
        "type": "dense_vector",
        "dims": 512,
        "index_options": {
          "type": "int8_hnsw"
        }
      }
    }
  }
}
<p>In upcoming versions, Elasticsearch will provide this as the default mapping, removing the need for users to adjust their mapping.</p><p>Combining this optimized hardware profile with Elasticsearch’s <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-quantization">automatic quantization</a> are two examples where Elastic is focused on vector search to be cost-effective while still being extremely performant.</p><h2>Getting Started with Elastic Cloud vector search optimized profile for GCP</h2><p>Start a <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">free trial</a> on Elastic Cloud and simply select the new Vector Search optimized profile to get started.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a33219437748674/6a17d7823e9e458302ba12de/c0434f399ee75c99b290060d7b0e613cbcd0829b-1440x1390.png" alt="cloud UI view for new deployments" /><h2>Migrating existing Elastic Cloud deployments</h2><p>Migrating to this new Vector Search optimized hardware profile is a few clicks away. Simply navigate to your Elastic Cloud management UI, click to manage the specific deployment, and edit the hardware profile. In this example, we are migrating from a ‘Storage optimized’ profile to the new ‘Vector Search’ optimized profile. When choosing to do so, while there is a reduction to available storage and vCPU, what is gained is the ability to store more vectors per memory with vector search.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt18139bf4f13d62da/6a17d7843e9e4537e0ba12e2/f13962f914d5d9a3be765bde2ac95a9e2d797d3f-1440x561.png" alt="cloud UI view for migrating deployments" /><p>Migrating to a new hardware profile uses the grow and shrink approach for deployment changes. This approach adds new instances, migrates data from old instances to the new ones, and then shrinks the deployment by removing the old instances. This approach allows for high availability during configuration changes even for single availability zones.</p><p>The following image shows a typical architecture for a deployment running in Elastic Cloud, where vector search will be the primary use case.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbaac029d716db095/6a17d785e9ea874ffca9c421/58e00f32bef1411dbc11849a78b5ecd3c334528a-1440x570.png" alt="deployment view" /><p>This example deployment uses our new Vector Search optimized hardware profile, now available in GCP. This setup includes:</p><ul><li><p>Two data nodes in our hot tier with our vector search profile</p></li><li><p>One Kibana node</p></li><li><p>One Machine Learning node</p></li><li><p>One integration server</p></li><li><p>One master tiebreaker</p></li></ul><p>By deploying these two “full-sized” data nodes with the Vector Search optimized hardware profile and while taking advantage of Elastic’s automatic dense vector <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-quantization">scalar quantization</a>, you can index roughly 60 million vectors, including one replica (with 768 dimensions).</p><h2>Conclusion</h2><p>Vector search is a powerful tool when building modern search applications, be it for semantic document retrieval on its own or integrating with an LLM service provider in a <a href="https://www.elastic.co/search-labs/blog/retrieval-augmented-generation-rag">RAG setup</a>. Elasticsearch provides a full-featured vector database natively integrated with a full-featured search platform. Along with improving vector search feature set and usability, Elastic continues to improve scalability. The vector search node type is the latest example, allowing users to scale their search application.</p><p>Elastic is committed to providing scalable, price effective infrastructure to support enterprise grade search experiences. Customers can depend on us for reliable and easy to maintain infrastructure and cost levers like vector compression, so you benefit from the lowest possible total cost of ownership for building search experiences powered by AI.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-vector-profile-gcp</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-vector-profile-gcp</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Elastic Cloud Hosted]]></category>
    <dc:creator><![CDATA[Serena Chou,Jeff Vestal,Yuvraj Gupta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbaac029d716db095/6a17d785e9ea874ffca9c421/58e00f32bef1411dbc11849a78b5ecd3c334528a-1440x570.png" length="0" type="image/png"/>
    <pubDate>Thu, 25 Apr 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: Creating custom GPTs with Elastic data]]></title>
    <description><![CDATA[Get started with custom GPTs using ChatGPT and Elasticsearch. Learn how to create custom GPTs that interact seamlessly with your Elasticsearch data.]]></description>
    <content:encoded><![CDATA[<p>ChatGPT Plus subscribers now have the opportunity to create their own customized versions of ChatGPT, known as <a href="https://openai.com/blog/introducing-gpts">GPTs</a>, replacing plugins as discussed in a <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-plugin-elastic-data">previous blog post</a>. Building upon a foundation from the <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data">first installment of this series</a>—where we delved into setting up Elasticsearch data and creating vector embeddings in <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">Elastic Cloud</a>—this blog will guide you through the process of developing a custom GPT designed to interact seamlessly with your Elasticsearch data.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt476976f187bd3697/6a17118ca6c2b920c3e797f0/00df7e59eea6ddfd3b07f4033e24c440ec896785-1416x1150.png" alt="screenshot of custom gpt in chatgpt interface" /><h2>Custom GPTs</h2><p>GPTs mark a significant advancement from the plugin system, offering an easier way for users to create custom versions of ChatGPT. Facilitated by an intuitive user interface, this enhancement simplifies the customization process, often eliminating the need for coding skills for a broad range of applications. Beyond basic personalization, those aiming to integrate ChatGPT with external data can do so through custom actions. Users have the option to share these tailored GPTs on the GPT store, keep them private for personal use, or only share them within your company’s workspace with the ChatGPT Team plan.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1dd762c84ba56010/6a17118ec1e8a59961f883bd/a168cd7373e33094ba22ee20843e5015f89437fa-1428x2538.png" alt="screenshot of gpt store in chatgpt interface" /><h2>How ChatGPT communicates with Elasticsearch</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5e1f6150f6b585ab/6a17118f8b73cbd5e118a127/2082b5c37896f56123ba834128f5aa36c5163f1b-1440x902.png" alt="illustration of architecture" /><ol><li><p>ChatGPT initiates a call to the <code>/search</code> endpoint in the Cloud Run service.</p></li><li><p>The service takes this input to create an Elasticsearch search request.</p></li><li><p>The query response with the documentation body and URL are returned to the service.</p></li><li><p>The service returns the document body and URL in text form to the custom ChatGPT.</p></li><li><p>This response is then relayed back to the GPT in text form, ready for interpretation.</p></li></ol><p>Again, this blog post assumes that you have set up your <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">Elastic Cloud</a> account, vectorized your content, and have an Elasticsearch cluster filled with data ready to be used. If you haven’t set all that up, see our <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data">previous post</a> for detailed steps to follow.</p><h2>Code</h2><p>To bring our custom GPT to life, we create a service that acts as the intermediary between ChatGPT and our Elasticsearch data. The core of this service is a Python application which sets up a Quart app and defines the <code>/search</code> endpoint. In addition, we use a Dockerfile to facilitate the deployment of the app on Cloud Run.</p><p>The Python app connects to our Elastic Cloud cluster, executes a hybrid search combining BM25 and kNN queries, and returns the relevant documentation body and URL. This allows our custom GPT to access and utilize Elasticsearch data in real time.</p><p>For the complete code refer to the <a href="https://github.com/elastic/ElasticDocs_CustomGPT">GitHub repository</a>. This includes the Python app and the Dockerfile necessary for Cloud Run deployment.</p><h2>Deploy a service</h2><p>For detailed steps on deploying the service using Google Cloud Platform (GCP), refer to the <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-plugin-elastic-data#deploying-the-elastic-plugin-in-google-cloud-platform-gcp">deployment section in our previous blog post</a> on ChatGPT Plugins. There, you’ll find a step-by-step guide for setting up and deploying your service on GCP.</p><h2>Create a custom GPT</h2><p>After logging into your ChatGPT Plus account, navigate to “My GPTs” via your profile to find the “Create a GPT” link. Alternatively, the “Explore GPTs” section above your conversations also leads to the GPT store, where you can find a link to create a GPT.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d285f4d5cab9105/6a1711912b835f5e44f4b303/1ac7ca63ef7fb3f63c1225b2d35f570d5b5a7bc4-1406x302.png" alt="screenshot of create a gpt button in chatgpt" /><h3>Configure the custom GPT</h3><p>The GPT editor provides two ways to configure your GPT: the "Create" tab for a guided setup through conversational prompts and the "Configure" tab for direct configuration input. For configuring the Elastic Docs Assistant, we'll primarily use manual configuration to precisely define our GPT's settings.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt29c11c5416250a42/6a1711920e2e49050241a236/6f4329795f9f16246689a0c8e450cf46040e7a28-1440x1234.png" alt="screenshot of gpt editor in chatgpt" /><p>Assign a name to your GPT, such as “Elastic Docs Assistant,” and add a brief description highlighting its function.</p><p>Under instructions, define the primary role of your GPT and provide it with instructions on how to present information:</p>You are an Elasticsearch Docs Assistant.  Your function is to assist users with docs on Elastic products by querying the defined /search action. Answer the user's query using only the information from the /search action response. If the response contains no results, respond "I'm unable to answer the question based on the information I have from Elastic Docs." and nothing else.  Be sure to include the URL at the bottom of each response.
<p>Let’s switch to the “Create” tab and ask ChatGPT to generate conversation starters and a logo. Perhaps I’ll upload my own logo instead.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9b3121bbc52543f/6a171194a6c2b94cdde797f4/5f4692207d02797db336d7e071fc158f6758aed0-1440x1278.png" alt="screenshot of gpt conversation editor in chatgpt" /><p>We won’t be uploading any knowledge files as all the data we use is in Elasticsearch. Instead, we'll define an action.</p><h3>Define an action</h3><p>This is where we connect our data to Elasticsearch. Clicking “Create a new action” will take us to the action editor.</p><p>First, I define my API key that I’m using in my endpoint service with a custom header name I set in my environment.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta6000165bc041729/6a171195dc55ded100e00eed/9c5ca2d2fa4266982eccb0ef03f626acb8082b3d-740x710.png" alt="screenshot of api key editor in chatgpt" /><p>Then I copy in my OpenAPI specification:</p>openapi: 3.0.1
info:
  title: ElasticDocs_CustomGPT
  description: Retrieve information from the most recent Elastic documentation
  version: 'v1'
servers:
  - url: YOUR_SERVICE_URL
paths:
  /search:
    get:
      operationId: search
      summary: retrieves the document matching the query
      parameters:
      - in: query
        name: query
        schema:
            type: string
        description: use to filter relevant part of the elasticsearch documentation
      responses:
        "200":
          description: OK
<p>Upon entering this information our schema will be automatically validated and display a search action, with any errors in red. If everything looks good, this is where the preview pane becomes particularly useful. Not only can you test the action to confirm its functionality, but the assistant also provides debugging information about the request. This is helpful for refining your GPT’s responses based on the service's response.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta65b77db6bd29180/6a17119714b270b574e3c6e1/912de55175b968f19a3d758b8b0e40e7e90471b8-1440x1010.png" alt="screenshot of chatgpt action editor" /><p>Further customization can be achieved by configuring the GPT instructions to dynamically modify its action requests, such as rewriting the user input before it's sent to the service or adding request query parameters based on some condition in the user input. This eliminates the need for traditional coding logic, assuming your endpoint is designed to support these modifications.</p><h3>Publish the custom GPT</h3><p>Click “Publish” in the top right corner above the preview pane to be taken to your newly created GPT.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt476976f187bd3697/6a17118ca6c2b920c3e797f0/00df7e59eea6ddfd3b07f4033e24c440ec896785-1416x1150.png" alt="screenshot of custom gpt in chatgpt interface" /><h2>What's next for custom GPTs</h2><p>This exploration of Custom GPTs, leveraging Elasticsearch for dynamic, data-driven conversations, has only begun to reveal the potential of what's possible. By harnessing the power of ChatGPT's interface and connecting it to external data, we introduce a new dimension of customization and contextually rich interactions with state-of-the-art AI models.</p><p>You can try all of the capabilities discussed in this blog today! Get started by signing up for a <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">free Elastic Cloud trial</a>.</p><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-creating-custom-gpts-with-elastic-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-creating-custom-gpts-with-elastic-data</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Sandra Gonzales]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9839a0a8271bf582/6a171199ab7f088457db9fa7/bd6a04b5eac462c2096f6b27aeef58847159595d-1128x1020.png" length="0" type="image/png"/>
    <pubDate>Fri, 12 Apr 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch open Inference API adds support for Cohere’s Rerank 3 model]]></title>
    <description><![CDATA[“Learn about Cohere reranking, how to use Cohere's Rerank 3 model with the Elasticsearch open inference API and Elastic's roadmap for semantic reranking.”]]></description>
    <content:encoded><![CDATA[<p>Cohere's <a href="https://txt.cohere.com/rerank-3/">Rerank 3 model</a> <code>rerank-english-v3.0</code> is now available in their Rerank <a href="https://docs.cohere.com/reference/rerank-1">endpoint</a>. As the only vector database included in Cohere’s Rerank 3 launch, Elasticsearch has integrated seamless support for this new model into our open Inference API.</p><p>So briefly, what is reranking? Rerankers take the ‘top n’ search results from existing vector search and keyword search systems, and provide a semantic boost to those results. With good reranking in place, you have better ‘top n’ results without requiring you to change your model or your data indexes – ultimately providing better search results you can send to large language models (LLMs) as context.</p><p>Recently, we collaborated with the Cohere team to make it easy for Elasticsearch developers to use Cohere’s <a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-embeddings-support">embeddings</a> (available in <a href="https://www.elastic.co/blog/whats-new-elastic-search-8-13-0">Elasticsearch 8.13</a> and Serverless!). It is a natural evolution to include Cohere’s incredible reranking capabilities to unlock all of the tools necessary for true refinement of results past the first-stage of retrieval.</p><p>Cohere’s Rerank 3 model can be added to <em>any</em> existing Elasticsearch retrieval flow without requiring any significant code changes. Given Elastic’s vector database and hybrid search capabilities, users can also bring embeddings from any 3rd party model to Elastic, to use with Rerank 3.</p><h2>Elastic’s approach to hybrid search</h2><p>When looking to implement RAG (Retrieval Augmented Generation), the strategy for retrieval and reranking is a key optimization for customers to ground LLMs and achieve accurate results. Customers have trusted Elastic for years with their private data, and are able to leverage several first-stage retrieval algorithms (e.g. for BM25/keyword, dense, and sparse vector retrieval). More importantly, most real-world search use cases benefit from <a href="https://www.elastic.co/blog/improving-information-retrieval-elastic-stack-hybrid">hybrid search</a> which we have supported since Elasticsearch <a href="https://www.elastic.co/blog/whats-new-elastic-enterprise-search-8-9-0">8.9</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1eee7a702c738a0a/6a171220d7c022784fde65d8/855663e958a2100d87f534883507bdd6cca46686-1440x897.png" alt="reranking" /><p>For mid-stage reranking, we also offer native support for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank </a>and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/filter-search-results.html#rescore">query rescore</a>. In this walkthrough, we will focus on Cohere’s last stage reranking capabilities, and will cover Elastic’s mid stage reranking capabilities in a subsequent blog post!</p><h2>Cohere’s approach to reranking</h2><p>Cohere has seen phenomenal results with their new Rerank model. In the testing, Cohere is reporting that reranking models in particular benefit from long context. Chunking for model token limits is a necessary constraint when preparing your document for dense vector retrieval. But with Cohere’s approach for reranking, a considerable benefit to reranking can be seen based on context contained in the full document, rather than a specific chunk within the document. Rerank has a 4k token limit to enable the input of more context to unlock the full relevance benefits of incorporating this model into your Elasticsearch based search system.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8acce02c4315035c/6a171221acf08840f7be9c79/485637d45e3b3aa0d6f7d7ad53434144ac590361-1440x883.png" alt="cohere results" /><p>(i) General retrieval based on BEIR benchmark; accuracy measured as nDCG@10</p><p>(ii) Code retrieval based on 6 common code benchmarks; accuracy measured as nDCG@10</p><p>(iii) Long context retrieval based on 7 common benchmarks; accuracy measured as nDCG@10</p><p>(iv) Semi-structured (JSON) retrieval based on 4 common benchmarks; accuracy measured as nDCG@10</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt22ff716baf6d1636/6a1712231949f7ead1e7ab62/8b5f5438bcb5dbc103c2a2e34088dedf59697025-571x326.png" alt="rag" /><p>If you’re interested in how to chunk with <a href="https://www.elastic.co/search-labs/integrations/langchain">LangChain</a> and <a href="https://www.elastic.co/search-labs/integrations/llama-index">LlamaIndex</a>, we provide chat application reference code, integrations and more in <a href="https://www.elastic.co/search-labs">Search Labs</a> and our open source <a href="https://github.com/elastic/elasticsearch-labs">repository</a>. Alternatively, you can leverage Elastic’s <a href="https://www.elastic.co/search-labs/blog/adding-passage-vector-search-to-lucene">passage retrieval</a> capabilities and chunk with <a href="https://www.elastic.co/search-labs/blog/chunking-via-ingest-pipelines">ingest pipelines</a>.</p><h2>Building a RAG implementation with Elasticsearch and Cohere</h2><p>Now that you have a general understanding of how these capabilities can be leveraged, let’s jump into an example on building a RAG implementation with Elasticsearch and Cohere.</p><p>You'll need a <code>Cohere</code> account and some working knowledge of the Cohere <a href="https://docs.cohere.com/reference/rerank-1">Rerank endpoint</a>. If you’re intending to use Cohere’s newest generative model <code>Command R+</code> familiarize yourself with the <a href="https://docs.cohere.com/reference/chat">Chat endpoint</a>.</p><p>In <a href="https://www.elastic.co/kibana">Kibana</a>, you'll have access to a console for you to input these next steps in Elasticsearch even without an IDE set up. If you prefer to use a language client - you can revisit these steps in the <a href="https://docs.cohere.com/docs/elasticsearch-and-cohere">provided guide</a>.</p><h2>Elasticsearch vector database</h2><p>In an earlier announcement, we had some steps to get you started with the Elasticsearch vector database. You can review the steps to cover ingesting a sample <code>books</code> catalog, and generate embeddings using Cohere’s Embed capabilities by reading the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-embeddings-support">announcement</a>. Alternatively, if you prefer we also provide a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search-inference.html">tutorial</a> and <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/integrations/cohere/inference-cohere.ipynb">Jupyter notebook</a> to get you started on this process.</p><h2>Cohere reranking</h2><p>The following section assumes that you’ve ingested data and have issued your first search. This will give you a baseline as to how the search results are ranked with your first dense vector retrieval.</p><p>The previous announcement concluded with a query issued against the sample <code>books</code> catalog, and, and generated the following results in response to the query string “Snow”. These results are returned in descending order of relevance.</p>    {
      "took": 201,
      "timed_out": false,
      "_shards": {
        "total": 3,
        "successful": 3,
        "skipped": 0,
        "failed": 0
      },
      "hits": {
        "total": {
          "value": 6,
          "relation": "eq"
        },
        "max_score": 0.80008936,
        "hits": [
          {
            "_index": "cohere-embeddings",
            "_id": "3VAixI4Bi8x57NL3O03c",
            "_score": 0.80008936,
            "_source": {
              "name": "Snow Crash",
              "author": "Neal Stephenson"
            }
          },
          {
            "_index": "cohere-embeddings",
            "_id": "4FAixI4Bi8x57NL3O03c",
            "_score": 0.6495671,
            "_source": {
              "name": "Fahrenheit 451",
              "author": "Ray Bradbury"
            }
          },
          {
            "_index": "cohere-embeddings",
            "_id": "31AixI4Bi8x57NL3O03c",
            "_score": 0.62768984,
            "_source": {
              "name": "1984",
              "author": "George Orwell"
            }
          },
          {
            "_index": "cohere-embeddings",
            "_id": "4VAixI4Bi8x57NL3O03c",
            "_score": 0.6197722,
            "_source": {
              "name": "Brave New World",
              "author": "Aldous Huxley"
            }
          },
          {
            "_index": "cohere-embeddings",
            "_id": "3lAixI4Bi8x57NL3O03c",
            "_score": 0.61449933,
            "_source": {
              "name": "Revelation Space",
              "author": "Alastair Reynolds"
            }
          },
          {
            "_index": "cohere-embeddings",
            "_id": "4lAixI4Bi8x57NL3O03c",
            "_score": 0.59593034,
            "_source": {
              "name": "The Handmaid's Tale",
              "author": "Margaret Atwood"
            }
          }
        ]
      }
    }
<p>You’ll next want to configure an inference endpoint for Cohere Rerank by specifying the Rerank 3 model and API key.</p>    PUT _inference/rerank/cohere_rerank 
    {
        "service": "cohere",
        "service_settings": {
            "api_key": &lt;API-KEY&gt;, 
            "model_id": "rerank-english-v3.0"
        },
        "task_settings": {
            "top_n": 10,
            "return_documents": true
        }
    }
<p>Once this inference endpoint is specified, you’ll now be able to rerank your results by passing in the original query used for retrieval, “Snow” along with the documents we just retrieved with the kNN search. Remember, you can repeat this with any <a href="https://www.elastic.co/blog/improving-information-retrieval-elastic-stack-hybrid">hybrid</a> <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/02-hybrid-search.ipynb">search</a> query as well!</p><p>To demonstrate this while still using the dev console, we’ll do a little cleanup on the JSON response above.</p><p>Take the <code>hits</code> from the JSON response and form the following JSON for the <code>input</code>, and then POST to the cohere_rerank endpoint we just configured.</p>    POST _inference/rerank/cohere_rerank
    {
      "input": ["Snow Crash", "Fahrenheit 451", "1984", "Brave New World","Revelation Space", "The Handmaid's Tale"], 
      "query": "Snow" 
    }
<p>And there you have it, your results have been reranked using Cohere's Rerank 3 model.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd73a44a4694ab658/6a171224964cea5e0108bcef/7b1f1c0c012efa2d029051bda0c72e4a75834793-1440x874.png" alt="Kibana rerank" /><p>The <code>books</code> corpus that we used to illustrate these capabilities does not contain large passages, and is a relatively simple example. When instrumenting this for your own search experience, we recommend that you follow Cohere’s approach to populate your <code>input</code> with the context from the full documents returned from the first retrieved result set, not just a retrieved chunk within the documents.</p><h2>Elasticsearch’s accelerated roadmap to semantic reranking and retrievers</h2><p>In <strong>upcoming</strong> versions of Elasticsearch we will continue to build seamless support for mid and final stage rerankers. Our end goal is to enable developers to have the ability to use semantic reranking to improve the results from any search whether it is BM25, dense or sparse vector retrieval, or a combination with hybrid retrieval. To provide this experience, we are building a concept called <code>retrievers</code> into the query DSL. Retrievers will provide an intuitive way to execute semantic reranking, and will also enable direct execution of what you’ve configured in the open inference API in the Elasticsearch stack without relying on you to execute this in your application logic.</p><p>When incorporating the use of retrievers in the earlier dense vector example, this is how different the reranking experience can be:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt436f1768726bf40d/6a171226e8fbcedab039fd7d/f80a4ef6f5793f1c3fb8f84706ca9a87336acdbd-611x223.png" alt="rag roadmap" /><p>(i) <strong>Elastic’s roadmap:</strong> The indexing step is simplified with the addition of Elastic’s future capabilities to automatically chunk indexed data</p><p>(ii) <strong>Elastic’s roadmap:</strong> The kNN retriever specifies the model (in this case Cohere’s Rerank 3) that was configured as an inference endpoint</p><p>(iii) <strong>Cohere’s roadmap:</strong> The step between sending the resulting data to Cohere’s Command R+ will benefit from a planned feature named <code>extractive snippets</code> which will enable the user to return a relevant chunk of the reranked document to the Command R+ model</p><p>This was our original kNN dense vector search executed on the <code>books</code> corpus to return the first set of results for “Snow”.</p>    GET cohere-embeddings/_search
    {
      "knn": {
        "field": "name_embedding",
        "query_vector_builder": {
          "text_embedding": {
            "model_id": "cohere_embeddings",
            "model_text": "Snow"
          }
        },
        "k": 10,
        "num_candidates": 100
      },
      "_source": [
        "name",
        "author"
      ]
    }
<p>As explained in this blog, there are a few steps to retrieve the documents and pass on the correct response to the inference endpoint. At the time of this publication, this logic should be handled in your application code.</p><p>In the future, retrievers can be configured to use the Cohere rerank inference endpoint directly within a single API call.</p>    {
      "retriever": {
        "text_similarity_rank": {
          "retriever": {
            "knn": {
              "field": "name_embedding",
              "query_vector_builder": {
                "text_embedding": {
                  "model_id": "cohere_embeddings",
                  "model_text": "Snow"
                }
              },
              "k": 10,
              "num_candidates": 100
            }
          },
          "field": "name",
          "window_size": 10,
          "inference_id": "cohere_rerank",
          "inference_text": "Snow"
        }
      },
      "_source": [
        "name",
        "author"
      ]
    }
<p>In this case, the kNN query is exactly the same as my original, but the cleansing of the response before input to the rerank endpoint will no longer be a necessary step. A retriever will know that a kNN query has been executed and seamlessly rerank using the Cohere rerank inference endpoint specified in the configuration. This same principle can be applied to <strong>any</strong> search, BM25, dense, sparse and hybrid.</p><p>Retrievers as an enabler of great semantic reranking is on our active and near term roadmap.</p><h2>Cohere’s generative model capabilities</h2><p>Now you’re ready with a semantically reranked set of documents that can be used to ground the responses for the large language model of your choice! We recommend Cohere’s newest generative model <code>Command R+</code>. When building the full RAG pipeline, in your application code you can easily issue a command to Cohere’s Chat API with the user query and the reranked documents.</p><p>An example of how this might be achieved in your <a href="https://elasticsearch-py.readthedocs.io/en/v8.13.0/">Python</a> application code can be seen below:</p>    response = co.chat(message=query, documents=documents, model='command-r-plus')

    source_documents = []
    for citation in response.citations:
        for document_id in citation.document_ids:
            if document_id not in source_documents:
                source_documents.append(document_id)

    print(f"Query: {query}")
    print(f"Response: {response.text}")
    print("Sources:")
    for document in response.documents:
        if document['id'] in source_documents:
            print(f"{document['title']}: {document['text']}")
<p>This integration with Cohere is offered in <a href="https://www.elastic.co/blog/elastic-serverless-architecture">Serverless</a> and soon will be available to try in a versioned Elasticsearch release either on Elastic Cloud or on your laptop or self-managed environment. We recommend you use our <a href="https://github.com/elastic/elasticsearch-serverless-python/releases/tag/v0.2.0.20231031">Elastic Python client v0.2.0</a> against your Serverless project to get started!</p><p>Happy reranking!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Serena Chou,Max Hniebergall]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte1f1748575c25298/6a1712272b835f8974f4b33b/808a666fc35b91149ce28e0a37769cff2554b6f5-1440x863.png" length="0" type="image/png"/>
    <pubDate>Thu, 11 Apr 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Introducing Elasticsearch vector database to Azure OpenAI Service On Your Data (preview)]]></title>
    <description><![CDATA[Microsoft and Elastic partner to add Elasticsearch (preview) as an officially supported vector database and retrieval augmentation technology for Azure OpenAI On Your Data, enabling users to build chat experiences with advanced AI models grounded by enterprise data.]]></description>
    <content:encoded><![CDATA[<p>Microsoft and Elastic are thrilled to announce that Elasticsearch, the world's most downloaded <a href="https://www.elastic.co/elasticsearch/vector-database">vector database</a> is an officially supported vector store and retrieval augmented search technology for Azure OpenAI Service On Your Data in public preview. The groundbreaking feature empowers you to leverage the power of OpenAI models, such as GPT-4, and incorporates the advanced capabilities of RAG (Retrieval Augmented Generation) model, directly on your data with enterprise-grade security on Azure. Read the announcement from Microsoft <a href="https://aka.ms/elasticsearch">here</a>.</p><p>Azure OpenAI Service On Your Data makes conversational experiences come alive for your employees, customers and users. With the addition of Elasticsearch vector database and vector search technology, LLMs are enriched by your business data, and conversations deliver superior quality responses out-of-the-box. All of this adds up to helping you better understand your data, and make more informed decisions.</p><h2>Build powerful conversational chat experiences, fast</h2><p>Business users, such as users on e-commerce teams, product managers, and others can add documents from an Elasticsearch index to build a conversational chat experience very quickly. All it takes is a few simple steps to configure the chat experience with parameters such as message history, and you're good to go! Customers can realize benefits pretty much right away..</p><ul><li><p>Quickly roll out conversational experiences to your users, customers, or employees--backed by context from your business data</p></li><li><p>Common use cases include offering internal knowledge search, users self-service, or chatbots that help process common business workflows</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt51bcf5a45f392fd2/6a171175d7c022e889de659e/ff0350e74200eafb55193a2ad4e38f11992cc4ce-1440x776.png" alt="build a chatbot" /><h2>How Elasticsearch vector database works with On Your Data</h2><p>The new native experience within Azure OpenAI Studio makes adding an Elastic index a simple matter. Developers can pick Elasticsearch as their chosen vector database option from the drop-down menu..</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt33a0d7ef3c3c8b4a/6a171176a2929923c6d01114/fe8a099b84c3f25a5f69b9b17e82e2932d4a6597-1224x1019.png" alt="pick Elastic as your vector database" /><p>You can bring your existing Elasticsearch indexes to On Your Data—whether those indexes live on Azure or on-prem. Just select Elasticsearch as your data source, add your Elastic endpoint and API key, add an Elastic index, and you're all set!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7399d9c89eb0f821/6a1711770e2e493d7741a232/378dcda8ebce6385979b358af8144f0ba5f9fb3b-1440x1184.png" alt="add your Elastic credentials and Elastic index" /><p>With the Elasticsearch vector database running in the background, users get all the Elastic advantages you'd expect.</p><ul><li><p>Precision of BM25 (text) search, the semantic understanding of vector search, and the best of both worlds with hybrid search</p></li><li><p>Document and field level security, so users can only access information they're entitled to based on their permissions</p></li><li><p>Filters, facets, and aggregations that add a real boost to how quickly relevant context is pulled from your organisation's data, and sent to an LLM</p></li><li><p>Choice of leveraging a range of large language model providers, including Azure OpenAI, Hugging Face, or other 3rd party models</p></li></ul><h2>Elastic on Microsoft Azure: a proven combination</h2><p>Elastic is a proud winner of the Worldwide Microsoft Partner of the Year award for Commercial Marketplace. Elastic and Microsoft customers have been using Elasticsearch and Azure OpenAI to build futuristic search experiences, that leverage the best of AI and machine learning, <a href="https://www.elastic.co/search-labs/blog/articles/relativity-elasticsearch-azure-openai">today</a>.</p><p>Ali Dalloul, VP, Azure AI Customer eXperience Engineering had this to say about the collaboration, "By harnessing the power of Azure Cloud and OpenAI, Elastic is driving the development of AI-driven solutions that redefine customer experiences. This partnership is more than just a collaboration; it's a feedback loop of innovation, benefiting customers, Elastic, and Microsoft, while empowering the broader partner ecosystem. We're delighted to offer customers Elasticsearch's strong vector database and retrieval augmentation capabilities to store and search vector embeddings for On Your Data."</p><p>"This really helps customers connect data wherever it lives. We are happy to open the spectrum of building conversational AI solutions, agnostic to location, including Elasticsearch. We are excited to see how developers build upon this integration." Adds Pavan Li, Principal Product Manager of Azure OpenAI Service On Your Data.</p><p>Elastic's clear strengths in hybrid search--combining BM25/text search with vector search for semantic relevance, was an important differentiator. With the backing of the open source Apache Lucene community, Elastic's vector database has already been widely adopted by large companies for enterprise scale use cases.</p><h2>Try On Your Data with Elasticsearch vector database today</h2><p>Unlock the insights with conversational AI, using Elasticsearch and Azure OpenAI On Your Data today!</p><ul><li><p>Visit <a href="http://oai.azure.com/">Azure OpenAI Studio</a> to build your first conversational copilot</p></li><li><p>Connect <a href="https://www.elastic.co/search-labs/blog/articles/chatgpt-elasticsearch-openai-meets-private-data">Elasticsearch with OpenAI models</a></p></li><li><p>Read more on the <a href="https://aka.ms/elasticsearch">Microsoft Tech Community blog</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/azure-openai-on-your-data-elasticsearch-vector-database</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/azure-openai-on-your-data-elasticsearch-vector-database</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Aditya Tripathi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt33a0d7ef3c3c8b4a/6a171176a2929923c6d01114/fe8a099b84c3f25a5f69b9b17e82e2932d4a6597-1224x1019.png" length="0" type="image/png"/>
    <pubDate>Tue, 26 Mar 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Keeping your Elasticsearch index current with Python and Google Cloud Platform Functions]]></title>
    <description><![CDATA[Keep your Elasticsearch index updated with Python &amp; Google Cloud Functions. Follow these steps to automatically update an index when new data is present.]]></description>
    <content:encoded><![CDATA[<h2>Background</h2><p>An <a href="https://www.elastic.co/blog/what-is-an-elasticsearch-index">index</a> inside Elasticsearch is where you can store your data in documents. While working with an index, the data can quickly grow old if you are working with a dynamic dataset. To avoid this issue, you can create a Python script to update your index and deploy it using <a href="https://cloud.google.com/">Google Cloud Platform's</a> (GCP) <a href="https://cloud.google.com/functions/docs#docs">Cloud Functions </a>and <a href="https://cloud.google.com/scheduler/docs">Cloud Scheduler</a> in order to keep your index up-to-date automatically.</p><p>To keep your index current, you can first set up a Jupyter Notebook to test locally and create a framework of a script that will update your index if new information is present. You can adjust your script to make it more reusable and run it as a Cloud Function. With Cloud Scheduler, you can set the code in your Cloud Function to run on a schedule using a cron-type format.</p><h2>Prerequisites for automating index updates</h2><ul><li><p>This example uses Elasticsearch version 8.12; if you are new, check out our Quick Start on <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started.html">Elasticsearch</a>.</p></li><li><p>Download the latest version of Python if you don't have it installed on your machine. This example utilizes Python 3.12.1.</p></li><li><p><a href="https://api.nasa.gov/">An API key</a> for NASA's APIs.</p></li><li><p>You will use the <a href="https://requests.readthedocs.io/en/latest/">Requests</a> package to connect to a NASA API, <a href="https://pandas.pydata.org/">Pandas</a> to manipulate data, the <a href="https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/getting-started-python.html">Elasticsearch Python Client</a> to load data into an index and keep it up to date, and <a href="https://docs.jupyter.org/en/latest/">Jupyter Notebooks</a> to work with your data interactively while testing. You can run the following line to install these required packages:</p></li></ul>pip3 install requests pandas elasticsearch notebook
<h2>Loading and updating your dataset</h2><p>Before you can run your update script inside of GCP, you will want to upload your data and test the process you will use to keep your script updated. You will first connect to data from an API, save it as a Pandas DataFrame, connect to Elasticsearch, upload the DataFrame into an index, check to see when the index is last updated, and update it if new data is available. You can find the complete code of this section in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/keeping-your-index-current/local_testing.ipynb">this search labs notebook</a>.</p><h3>Loading your data</h3><p>Let's start testing locally with a Jupyter Notebook to work with your data interactively. To do so, you can run the following in your terminal.</p>jupyter notebook
<p>In the right-hand corner, you can select where it says “New” to create a new Jupyter Notebook.</p><p>First, you will need to import the packages you will be using. You will import all the packages you installed earlier, plus <code>getpass</code> to work with secrets such as API keys and <code>datetime</code> to work with date objects.</p>import requests
from getpass import getpass
import pandas as pd
from datetime import datetime, timedelta
from elasticsearch import Elasticsearch, helpers
<p>The dataset you will use is<a href="https://data.nasa.gov/Space-Science/Asteroids-NeoWs-API/73uw-d9i8/about_data"> Near Earth Object Web Service (NeoWs)</a>, a RESTful web service that provides near-earth Asteroid information. This dataset lets you search for asteroids based on their closest approach date to Earth, look up a specific asteroid, and browse the overall dataset.</p><p>With the following function, you can connect to NASA's NeoWs API, get data from the past week, and convert your response to a JSON object.</p>def connect_to_nasa():
    url = "https://api.nasa.gov/neo/rest/v1/feed"
    nasa_api_key = getpass("NASA API Key: ")
    today = datetime.now()
    params = {
        "api_key": nasa_api_key,
        "start_date": today - timedelta(days=7),
        "end_date": datetime.now(),
    }
    return requests.get(url, params).json()
<p>Now, you can save the results of your API call to a variable called response.</p>response = connect_to_nasa()
<p>To convert the JSON object into a pandas DataFrame, you must normalize the nested objects into one DataFrame and drop the column containing the nested JSON.</p>def create_df(response):
    all_objects = []
    for date, objects in response["near_earth_objects"].items():
        for obj in objects:
            obj["close_approach_date"] = date
            all_objects.append(obj)
    df = pd.json_normalize(all_objects)
    return df.drop("close_approach_data", axis=1)
<p>To call this function and view the first five rows of your dataset, you can run the following:</p>df = create_df(response)
df.head()
<h3>Connecting to Elasticsearch</h3><p>You can access Elasticsearch from the Python Client by providing your Elastic Cloud ID and API key for authentication.</p>def connect_to_elastic():
    elastic_cloud_id = getpass("Elastic Cloud ID: ")
    elastic_api_key = getpass("Elastic API Key: ")
    return Elasticsearch(cloud_id=elastic_cloud_id, api_key=elastic_api_key)
<p>Now, you can save the results of your connection function to a variable called <code>es</code>.</p>es = connect_to_elastic()
<p>An index in Elasticsearch is the main container for your data. You can name your index called <code>asteroid_data_set</code>.</p>index_name = "asteroid_data_set"
es.indices.create(index=index_name)
<p>The result you get back will look like the following:</p>ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'asteroids_data'})
<p>Now, you can create a helper function that will allow you to convert your DataFrame to the correct format to upload into your index.</p>def doc_generator(df, index_name):
    for index, document in df.iterrows():
        yield {
            "_index": index_name,
            "_id": f"{document['id']}",
            "_source": document.to_dict(),
        }
<p>Next, you can bulk upload the contents of your DataFrame into Elastic, calling the helper function you just created.</p>helpers.bulk(es, doc_generator(df, index_name))
<p>You should get a result that looks similar to the following, which tells you how many rows you’ve uploaded:</p>(146, [])
<h3>When was the last time you updated your data?</h3><p>Once you've uploaded data into Elastic, you can check the last time your index was updated and format the date so it can work with NASA API.</p>def updated_last(es, index_name):
    query = {
        "size": 0,
        "aggs": {"last_date": {"max": {"field": "close_approach_date"}}},
    }
    response = es.search(index=index_name, body=query)
    last_updated_date_string = response["aggregations"]["last_date"]["value_as_string"]
    datetime_obj = datetime.strptime(last_updated_date_string, "%Y-%m-%dT%H:%M:%S.%fZ")
    return datetime_obj.strftime("%Y-%m-%d")
<p>You can save the date your index was last updated to a variable and print out the date.</p>last_update_date = updated_last(es, index_name)
print(last_update_date)
<h3>Updating your data</h3><p>Now, you can create a function that checks to see if there is any new data since the last time the index was updated and the current date. If the object is valid and the data is not empty, it will update the index and let you know if there is no new data to update or if the DataFrame returns a type of <code>None</code> indicating that there may have been a problem.</p>def update_new_data(df, es, last_update_date, index_name):
    if isinstance(last_update_date, str):
        last_update_date = datetime.strptime(last_update_date, "%Y-%m-%d")

    last_update_date = pd.Timestamp(last_update_date).normalize()

    if not df.empty and "close_approach_date" in df.columns:
        df["close_approach_date"] = pd.to_datetime(df["close_approach_date"])

    today = pd.Timestamp(datetime.now().date()).normalize()

    if df is not None and not df.empty:
        update_range = df.loc[
            (df["close_approach_date"] &gt; last_update_date)
            &amp; (df["close_approach_date"] &lt; today)
        ]
        if not update_range.empty:
            helpers.bulk(es, doc_generator(update_range, index_name))
        else:
            print("No new data to update.")
    else:
        print("The DataFrame is None.")
<p>If the DataFrame is a valid object, it will call the function you wrote and update the index if applicable. It will also print out the date of the index's last update to help you debug if needed. If not, it will tell you there may be a problem.</p>try:
    if df is None:
        raise ValueError("DataFrame is None. There may be a problem.")
    update_new_data(df, es, last_update_date, index_name)
    print(updated_last(es, index_name))
except Exception as e:
    print(f"An error occurred: {e}")
<h2>Keeping your index current</h2><p>Now that you've created a framework for local testing, you are ready to set up an environment where you can run your script daily to check to see if any new data is available and update your index accordingly.</p><h3>Creating a Cloud Function</h3><p>You are now ready to deploy your Cloud Function. To do so, you will want to select the environment as a 2nd gen function, name your function, and select a cloud region. You can also tie it to a Cloud Pub/Sub trigger and choose to create a new topic if you haven't made it already. You can check out the <a href="https://github.com/JessicaGarson/Keeping-Your-Elasticsearch-Index-Current">complete code for this section on GitHub</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd01cb110679df057/6a170b30ab7f084f76db9e9a/5e5548faac7d7ed20166e853db9a81d4ed51d60b-1116x1188.jpg" alt="" /><h3>Creating a Pub/Sub topic</h3><p>When creating a new topic, you can name your topic ID and select the encryption using a Google-managed encryption key.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93a679b113dc8eee/6a170b321949f751eee7aa3e/5a32c2efaf32f496fa201d3c3e7f97de29b59f66-1114x874.jpg" alt="" /><h3>Setting your Cloud Function's environment variables</h3><p>Under where it says “Runtime environment variables,” you can add in the environment variables for your <code>NASA_API_KEY,</code> <code>ELASTIC_CLOUD_ID</code>, and <code>ELASTIC_API_KEY.</code> You will want to save these as the raw values without single quotes around them. So if you entered a value of <code>'xxxxlsdgzxxxxx'</code> into your terminal earlier, you would want it to be <code>xxxxlsdgzxxxxx</code>.</p><h3>Adjusting your code and adding it to your Cloud Function</h3><p>After you enter your environment variables, you can press the button that says next, which will take you to a code editor. You will want to select the runtime of Python 3.12.1 or match the version of Python you are using. After that, update the entry point to <code>update_index</code>. The entry point serves a similar role to the main function in Python.</p><p>Instead of using <code>getpass</code> to retrieve secrets, you will want to use <code>os</code> to perform a more automated process. An example will look like the following:</p>elastic_cloud_id = os.getenv("ELASTIC_CLOUD_ID")
elastic_api_key = os.getenv("ELASTIC_API_KEY")
<p>You will want to adjust the order of your script to have the function that connects to Elasticsearch first. Afterward, you will want to know when your index was last updated, connect to the NASA API you are using, save it to DataFrame, and load any new data that might be available.</p><p>You may notice a new function at the bottom called <code>update_index</code> that ties your code together. In this function, you define the name of your index, connect to Elastic, figure out the last date the index was updated, connect to the NASA API, save the results into a data frame, and update the index if needed. To indicate the entry point function is a cloud event, you can denote it with the decorator <code>@functions_framework.cloud_event</code>.</p>@functions_framework.cloud_event
def update_index(cloud_event):
    index_name = "asteroid_data_set"
    es = connect_to_elastic()
    last_update_date = updated_last(es, index_name)
    print(last_update_date)
    response = connect_to_nasa(last_update_date)
    df = create_df(response)
    if df is not None:
      update_new_data(df, es, last_update_date, index_name)
      print(updated_last(es, index_name)) 
    else:
      print("No new data was retrieved.")
<p>Here is the full updated code sample:</p>import functions_framework
import requests
import os
import pandas as pd
from datetime import datetime
from elasticsearch import Elasticsearch, helpers


def connect_to_elastic():
    elastic_cloud_id = os.getenv("ELASTIC_CLOUD_ID")
    elastic_api_key = os.getenv("ELASTIC_API_KEY")
    return Elasticsearch(cloud_id=elastic_cloud_id, api_key=elastic_api_key)


def connect_to_nasa(last_update_date):
    url = "https://api.nasa.gov/neo/rest/v1/feed"
    nasa_api_key = os.getenv("NASA_API_KEY")
    params = {
        "api_key": nasa_api_key,
        "start_date": last_update_date,
        "end_date": datetime.now(),
    }
    return requests.get(url, params).json()


def create_df(response):
    all_objects = []
    for date, objects in response["near_earth_objects"].items():
        for obj in objects:
            obj["close_approach_date"] = date
            all_objects.append(obj)
    df = pd.json_normalize(all_objects)
    return df.drop("close_approach_data", axis=1)


def doc_generator(df, index_name):
    for index, document in df.iterrows():
        yield {
            "_index": index_name,
            "_id": f"{document['close_approach_date']}",
            "_source": document.to_dict(),
        }


def updated_last(es, index_name):
    query = {
        "size": 0,
        "aggs": {"last_date": {"max": {"field": "close_approach_date"}}},
    }
    response = es.search(index=index_name, body=query)
    last_updated_date_string = response["aggregations"]["last_date"]["value_as_string"]
    datetime_obj = datetime.strptime(last_updated_date_string, "%Y-%m-%dT%H:%M:%S.%fZ")
    return datetime_obj.strftime("%Y-%m-%d")


def update_new_data(df, es, last_update_date, index_name):
    if isinstance(last_update_date, str):
        last_update_date = datetime.strptime(last_update_date, "%Y-%m-%d")

    last_update_date = pd.Timestamp(last_update_date).normalize()

    if not df.empty and "close_approach_date" in df.columns:
        df["close_approach_date"] = pd.to_datetime(df["close_approach_date"])

    today = pd.Timestamp(datetime.now().date()).normalize()

    if df is not None and not df.empty:
        update_range = df.loc[
            (df["close_approach_date"] &gt; last_update_date)
            &amp; (df["close_approach_date"] &lt; today)
        ]
        print(update_range)
        if not update_range.empty:
            helpers.bulk(es, doc_generator(update_range, index_name))
        else:
            print("No new data to update.")
    else:
        print("The DataFrame is empty or None.")


# Triggered from a message on a Cloud Pub/Sub topic.
@functions_framework.cloud_event
def hello_pubsub(cloud_event):
    index_name = "asteroid_data_set"
    es = connect_to_elastic()
    last_update_date = updated_last(es, index_name)
    print(last_update_date)
    response = connect_to_nasa(last_update_date)
    df = create_df(response)
    try:
        if df is None:
            raise ValueError("DataFrame is None. There may be a problem.")
        update_new_data(df, es, last_update_date, index_name)
        print(updated_last(es, index_name))
    except Exception as e:
        print(f"An error occurred: {e}")
<h3>Adding a requirements.txt file</h3><p>You will also want to define a <code>requirements.txt</code> file with all the specified packages needed to run the code.</p>functions-framework==3.*
requests==2.31.0
elasticsearch==8.12.0
pandas==2.1.4
<h3>Scheduling your Cloud Function</h3><p>In Cloud Scheduler, you can set up your function to run at a regular interval using unix cron format. I have the code set to run every morning at 8 am in my timezone.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt031810158f88fd08/6a170b34a929cf4718ae09d0/a6b9c490a804389ec15366ff96adb9c992cb561a-1160x1014.jpg" alt="" /><p>You will also want to configure the execution to connect to the Pub/Sub topic you created previously. I currently have the message body set to say “hello.”</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte5c6c0061dc955fc/6a170b35b0367d9d5672bd37/b12a4a782976edbf9b6a65992f0d3d73df2d15c0-1116x518.jpg" alt="" /><p>Now that you have set up your Pub/Sub topic and your Cloud Function and set that Cloud Function to run on a schedule, your index should automatically update whenever new data is present.</p><h2>Conclusion</h2><p>Using Python, Google Cloud Platform Functions, and Google Cloud Scheduler you should be able to ensure that your index is updated regularly. You can find the complete code <a href="https://github.com/JessicaGarson/Keeping-Your-Elasticsearch-Index-Current">here</a> and <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/keeping-your-index-current/local_testing.ipynb">the search labs notebook for local testing</a>. We are also running an on-demand webinar with <a href="https://www.elastic.co/virtual-events/architecting-search-apps-on-google-cloud">Google Cloud</a> which might be a good next step if you are looking to build search apps. Let us know if you built anything based on this blog or if you have questions on our <a href="https://discuss.elastic.co/">Discuss forums</a> and <a href="https://communityinviter.com/apps/elasticstack/elastic-community">the community Slack channel</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/keeping-your-elasticsearch-index-current-with-python-and-google-cloud-platform-functions</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/keeping-your-elasticsearch-index-current-with-python-and-google-cloud-platform-functions</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Jessica Garson]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt315cf663d39ca57f/6a170b3760084b95c23c4576/b839822139ab0769a7fcf1d62102c984af87bf0d-1440x954.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 13 Mar 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Avatar assisted & dialogue driven voice to RAG search]]></title>
    <description><![CDATA[Create avatar-assisted voice search experience by integrating speech-to-text, semantic search, RAG and a synthesized avatar for responses.]]></description>
    <content:encoded><![CDATA[<h2>The evolution of search</h2><p>Search has evolved from simple text queries yielding straightforward results to a complex system accommodating various formats like text, images, videos, and questions.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt24559d31d5b7b8f6/6a170b206f7f04db68914857/128bd2fe93461638e6ac28ca994a07c5a21e9c70-1440x659.png" alt="Legacy Search" /><p>Search not too long ago comprised of a text query and relevant results. Today's search results are enhanced with generative AI, machine learning, and interactive chat features, offering a richer, more dynamic, and contextually relevant user experience. Additionally, voice search and speech avatars have transformed traditional search, offering a more interactive and convenient user experience.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd19b4894abd43db2/6a170b22cf4f25868db2d18b/1ebe15a22c031bc439cb2500c21d1c60f79b008c-1440x781.png" alt="Search today" /><h3>The desire for dialogue in search</h3><p>In a realm where dialogue underpins every interaction, whether with fellow humans or bots, shouldn't our search experiences reflect this fundamental aspect? Envision the vast array of document corpora residing within an enterprise. Naturally, this environment sparks curiosity and a multitude of questions, leading to subsequent inquiries. This innate human trait drives us to seek answers, delve deeper following initial responses, and continuously explore. Yet, traditional question-and-answer mechanisms fall short, as they often disregard the context of preceding exchanges, leading to a disjointed and laborious process that feels unnatural and prompts users to disengage prematurely.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1ec8ee317d02777e/6a170b238b73cb33a818a046/f3feb0843430a550151182aba95648ce5b45820b-1440x766.png" alt="you have questions" /><h3>Beyond question and answer search</h3><p>Consider the act of using a television to search for content, such as seeking action movies featuring Nicolas Cage. While most current systems adeptly provide relevant results, the inquiry rarely ends there. Subsequent questions, such as inquiring about the runtime or release dates of these movies, are a natural progression in our quest for information. However, standard search applications are not designed to facilitate a continuous dialogue; they are structured around isolated question-and-answer formats, which limits the depth of interaction and exploration.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8ae209de746281fb/6a170b2566c4f994d9f8c043/60e914ec7dc1809b0f8a18eb2ec186fa3f2e890b-1006x430.png" alt="results" /><h2>Avatar assisted voice search experience</h2><p>This is where the concept of an avatar-assisted search experience comes into play, especially in scenarios where users, myself included, prefer direct answers without the need to sift through information. Occasionally, we desire the convenience of having answers delivered to us, bypassing the effort of reading through content. The development of an avatar to generate responses could further modernize this interaction, providing a more engaging, efficient, and natural user experience.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3a6145eda1e0487f/6a170b2714b2704159e3c625/4e77349fb83be542c9817a65cb6a905e18879f6c-1116x1174.png" alt="results" /><h2>Live demo: creating an avatar assisted voice search experience</h2><p>This demo showcases a seamless integration of speech-to-text, Elasticsearch's semantic search capabilities, Azure OpenAI's RAG, and a synthesized avatar for responses.</p><h2>Integration details</h2><h4>Speech to search</h4><p>The advanced search experience begins with user voice interactions, which are converted into text by Azure Speech to Text, forming the basis of the search query. This query is then processed through Elasticsearch, using the ELSER, to retrieve relevant documents, such as TV guides listing “action movies featuring Nicolas Cage.” This ensures precision and relevance in the search results.</p><h4>RAG &amp; cache</h4><p>In the enhanced search framework, merely fetching documents isn't enough. Azure OpenAI's GPT-4 refines raw data into understandable responses, ensuring smooth conversation flow. Additionally, Elasticsearch boosts efficiency as a GenAI caching layer, recycling answers for related queries, thus conserving resources. For example, if there's a cached response for "action movies featuring Nicolas Cage," the caching API will swiftly use this for similar questions like “Nicolas Cage high-intensity movies,” accelerating the search experience.</p><h4>Avatar response generation</h4><p>The experience is further enriched with an avatar response feature, powered by Azure Synthesizer, adding a visual and auditory dimension that surpasses traditional text-based interfaces. This creates a more engaging and interactive user experience, integrating various advanced technologies to deliver a dynamic, intuitive, and compelling search experience.</p><h2>Summary</h2><p>The shift from traditional Google searches to platforms like ChatGPT for answering queries illustrates a broader trend: our preference for dialogue over static information retrieval. This predilection underscores the importance for enterprises to adopt a more intuitive and conversational approach in their search functionalities. By embracing this paradigm, businesses can better align with the natural human inclination towards dialogue, thereby enhancing the overall search and discovery process within their data ecosystems.</p><h2>Demo assets</h2><p>Still curious, here is the <a href="https://github.com/sunileman/voice-movie-search">link to the source code</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/avatar-assisted-dialogue-driven-voice-to-rag-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/avatar-assisted-dialogue-driven-voice-to-rag-search</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Sunile Manjee]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd3572b96ad92d331/6a170b29b339d560d0769fd3/9b274d1191d203babb55dc7693897fd278df1a09-1024x1024.png" length="0" type="image/png"/>
    <pubDate>Fri, 08 Mar 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Adding document level security (DLS) to your internal knowledge search]]></title>
    <description><![CDATA[Learn how to secure your internal knowledge lake and offer personalized search for your end-users using document level security (DLS).]]></description>
    <content:encoded><![CDATA[<p>There's a good chance that your enterprise is drowning in internal data.</p><p>You've got your issue-tracking, your note-taking, your meeting transcripts, your wiki pages, your video recordings, your chats and IMs and DMs. And don't forget the emails!</p><p>It's no wonder that so many enterprises are trying to create workplace search experiences - giving their employees a centralized, one-stop-shop for searching for internal information.</p><p>With Elastic's catalog of <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors.html">connectors</a>, this is relatively easy to do. But after you get all your data indexed and ready to be searched, how do you ensure that it is secured? After all, Tess (from Engineering) shouldn't be looking at Bob's (from HR) notes on performance reviews. How can you make sure that each separate user who comes to this unified search bar does gets their own unique view into only the data that they're authorized to view?</p><p>Enter, Document Level Security (DLS).</p><h2>Understanding document level security (DLS) in Elasticsearch</h2><p>Folks who've followed Elasticsearch for a while may already be aware that <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/document-level-security.html">DLS</a> has been an Elasticsearch feature for quite a long time. It's part of the larger theme of <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/authorization.html">user authorization</a>, and is really quite simple. You embed metadata in Elasticsearch documents, and then you craft an Elasticsearch query, filtering based on that document metadata, that describes the user's authorization. That query is used to create an <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.12/defining-roles.html">Elasticsearch Role</a>.</p><p>At query time, when the search user authenticates, their role(s) (if any) is identified, and the embedded query filter (if any) is applied to their searches.</p><p>Let's look at a simplistic example. Say we have two documents:</p>PUT example/_doc/1
{
  "my-data": true,
  "text": "This data is mine"
}

PUT example/_doc/2
{
  "my-data": false,
  "text": "This data belongs to someone else"
}
<p>A query that would fetch only <em>my</em> data would be:</p>GET example/_search
{
  "query": {
    "term": {
      "my-data": {
        "value": true
      }
    }
  }
}
<p>That query can be embedded into a Role, like:</p>POST /_security/role/my_role
{
  "indices": [
    {
      "names": [ "example" ],
      "privileges": ["read"],
      "query": {
        "term": {
          "my-data": {
            "value": true
          }
        }
      }
    }
  ]
}
<p>So if my user is assigned the role <code>my_role</code>, if I just do</p>GET example/_search
<p>I will only see document <code>1</code>, but not document <code>2</code>.</p><p>While this example is simple in theory, it has a relatively large number of moving pieces.</p><ul><li><p>you must ensure that the documents contain the relevant metadata (<code>"my-data": true</code> vs <code>"my-data": false</code>)</p></li><li><p>you must <em>trust</em> that the metadata on those documents is accurate</p></li><li><p>you must create a Role for every search user with a finely crafted Elasticsearch query</p></li><li><p>you must ensure that every role you create correctly maps to the right user at query time</p></li><li><p>you mush ensure that all of the above stays up to date.</p></li></ul><p>That last one is particularly difficult. When people in your enterprise join, leave, switch teams, or get promoted, that requires changes - potentially to both your (meta)data AND your Roles. And if you add in data sources that support sharing or access editing, you're definitely needing to make sure that your (meta)data stays up-to-date.</p><h2>DLS with Elastic connectors</h2><p><a href="https://www.elastic.co/guide/en/enterprise-search/current/dls.html">Connector document level security</a> builds off of the Elasticsearch DLS primitives. For many connectors, this includes syncing the relevant metadata and Role Descriptors to support DLS. This results in the documents in your content index automatically containing metadata (usually in a <code>_allow_access_control</code> field) to describe the people/groups who are authorized to search for this document, as well as documents in a special <code>.search-acl-filter-&lt;index-name&gt;</code> index that contain the Role Descriptor JSON necessary to build a concrete Role or an API key for a given search user.</p><p>You can find <a href="https://www.elastic.co/guide/en/enterprise-search/current/dls.html#dls-availability-prerequisites">which connectors have DLS available here</a>. For this blog, we're going to reference an example application which utilizes the Sharepoint Online connector. This was the first connector we enabled DLS on, but the example could be easily adapted to work with any DLS-enabled connector.</p><p>If your connector is eligible, and you have a Platinum+ Elasticsearch license, you can enable DLS through a toggle on the connector configuration page.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta4d4869b2ef7a27b/6a1711f7964cea84be08bce1/a8d709d6d559891d623d108484326201a7d048d0-1440x666.png" alt="enable-dls" /><p>From there, it's just a matter of running a Full sync and an Access Control sync, and Elasticsearch will have all the data it needs.</p><h2>DLS implementation example</h2><p>And then what?</p><p>Once Elasticsearch has Role Descriptors and document data with sufficient metadata for those role filters, you're ready to build a secure search experience.</p><p>We've built <a href="https://github.com/elastic/elasticsearch-labs/tree/main/example-apps/internal-knowledge-search">an example knowledge search app</a> that we'll use for this blog, and you're welcome to go take a look at its source code. However, we do want to stress that this is an example only - it is not ready to be run in production on its own. Please exercise good judgement and do not run code that you have not read or do not understand.</p><p>This application has a pretty simple architecture.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4ddcfc0dd7551129/6a1711f867045b814245c303/2ee525509acb672b89f36a2b05ad2b75872745d6-864x408.png" alt="dls-simple-architecture" /><p>It is composed of a <a href="https://github.com/elastic/elasticsearch-labs/blob/main/example-apps/internal-knowledge-search/api/app.py">Flask backend</a> and a <a href="https://github.com/elastic/elasticsearch-labs/tree/main/example-apps/internal-knowledge-search/app-ui">React frontend</a>. The backend is configured with environment variables to establish a connection with Elasticsearch.</p>export ELASTICSEARCH_URL=...
export ELASTIC_USERNAME=...
export ELASTIC_PASSWORD=...
<p>Using this connection, the backend provides three endpoints:</p><ol><li><p><code>GET /api/persona</code> This endpoint lists the identifiers for the "identities" or "personas" that the connector found during the Access Control sync. The frontend uses this list to populate a dropdown of personas so to demonstrate how search results change depending on the selected persona.</p></li><li><p><code>GET /api/indices</code> This endpoint lists which indices have been included in your Search Application. The frontend uses this list to allow you to choose which ones to search against.</p></li><li><p><code>GET /api/api_key?persona=&lt;persona&gt;</code> This endpoint creates and returns an Elasticsearch API key based off of a selected persona. In a production system, <code>persona</code> wouldn't be a request argument, but would be inferred from the authentication credentials. This API key is then used by the frontend to issue search requests to Elasticsearch.</p></li></ol><h3>Caveats</h3><p>As stated above, this example should not be used in production. Gaps include:</p><ul><li><p>It does not implement authentication. A production-ready app would need a way for users to authenticate, and have their identities verified, rather than selecting a user from a dropdown.</p></li><li><p>It does not utilze SSL/TLS. The backend currently transmits Elasticsearch API Keys to the frontend over HTTP, not HTTPS.</p></li><li><p>The frontend issues <code>/_search</code> requests directly to Elasticsearch. Depending on the production use case, you may not want to expose Elasticsearch to your end user like this. Instead, it may be advisable to issue requests from the frontend to your backend (again, with authentication implemented), and have the backend translate those requests to Elasticsearch queries.</p></li></ul><h3>Source Code</h3><p>Below we link to the critical pieces of code that are necessary to implement search with DLS.</p><h4>Creating the authenticated user's role descriptor</h4><p><a href="https://github.com/elastic/elasticsearch-labs/blob/ebd2e96de3dc8d56624e70248de4bbac35e2ec71/example-apps/internal-knowledge-search/api/app.py#L121-L166">Code link</a></p>
            identity = elasticsearch_client.get(
                index=identities_index, id=persona)
            permissions = identity["_source"]["query"]["template"]["params"][
                "access_control"
            ]
            role_descriptor = {
                "dls-role": {
                    "cluster": ["all"],
                    "indices": [
                        {
                            "names": [search_app_name],
                            "privileges": ["read"],
                            "query": {
                                "template": {
                                    "params": {"access_control": permissions},
                                    "source": """{
                                        "bool": {
                                            "should": [
                                                {
                                                    "bool": {
                                                        "must_not": {
                                                            "exists": {
                                                                "field": "_allow_access_control"
                                                            }
                                                        }
                                                    }
                                                },
                                                {
                                                    "terms": {
                                                        "_allow_access_control.enum": {{#toJson}}access_control{{/toJson}}
                                                    }
                                                }
                                            ]
                                        }
                                    }""",
                                }
                            },
                        }
                    ],
                    "restriction": {"workflows": ["search_application_query"]},
                }
            }
<p>You may notice that the query template in this role descriptor is significantly more complex than the simple example provided earlier in this blog. This query does several things:</p><ol><li><p>It uses a query template, instead of an explicit query. This makes it easier when reading to separate a long list of permissions from the query syntax.</p></li><li><p>It uses a <code>bool</code> query. This allows us to combine several logical checks.</p></li><li><p>It grants access to any documents that do not contain the <code>_allow_access_control</code> field</p></li><li><p>It grants access to documents where the <code>_allow_access_control</code> field contains a value found in this user's <code>permissions</code></p></li></ol><h4>Creating an API Key from that Role Descriptor</h4><p><a href="https://github.com/elastic/elasticsearch-labs/blob/ebd2e96de3dc8d56624e70248de4bbac35e2ec71/example-apps/internal-knowledge-search/api/app.py#L167-L169">Code link</a></p>
        api_key = elasticsearch_client.security.create_api_key(
            name=search_app_name+"-internal-knowledge-search-example-"+persona, expiration="1h", role_descriptors=role_descriptor)
        return {"api_key": api_key['encoded']}
<h4>Searching with the API Key</h4><p><a href="https://github.com/elastic/elasticsearch-labs/blob/ebd2e96de3dc8d56624e70248de4bbac35e2ec71/example-apps/internal-knowledge-search/app-ui/src/pages/SearchPage.tsx#L76-L107">Code link</a></p>
      const apiKey = searchPersonaAPIKey;

      const client = SearchApplicationClient(
        appName,
        searchEndpoint,
        apiKey,
        {
          facets: {
            description: {
              type: "text",
            },
          },
        },
        {
          disableCache: true,
        }
      );

      const sortArray = Object.values(sorts).map((sort) =&gt; ({
        [sort.title]: sort.sortDirection,
      }));

      const rawResults = await client()
        .query(query)
        .setSort(sortArray)
        .setPageSize(10)
        .addParameter("indices", indexFilter)
        .search();

      const searchResults = rawResults.hits.hits.map((hit: any) =&gt; {
        return mapHitToSearchResult(hit);
      });
]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/dls-internal-knowledge-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dls-internal-knowledge-search</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Sean Story]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a0bf009f00b1c80/6a1711fa4a531b3db636aa9f/c7c174d6408b23fca482664c608f9e8849243d96-1440x720.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 22 Jan 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to ingest data from Snowflake to Elasticsearch]]></title>
    <description><![CDATA[Learn how to ingest data from Snowflake to Elasticsearch using Logstash or a Snowflake Elasticsearch Python Script.]]></description>
    <content:encoded><![CDATA[<p>To take advantage of the powerful search capabilities offered by Elasticsearch, many businesses keep a copy of searchable data in Elasticsearch. Elasticsearch is a scalable data store and vector database, proven for traditional text search and vector search in semantic search use cases. The Elasticsearch Relevance Engine</p><p>TM (ESRE) enables you to add semantic search on proprietary data that can be integrated with generative AI technologies to build modern search experiences.</p><p></p><p><a href="https://www.snowflake.com/">Snowflake</a> is a fully managed SaaS (software as a service) that provides a single platform for data warehousing, data lakes, data engineering, data science, data application development, and secure sharing and consumption of real-time/shared data.</p><p>In this blog, we will see how to bring your snowflake data to Elasticsearch using below methods:</p><ol><li><p>Using <a href="https://www.elastic.co/logstash">Logstash</a> (periodic sync)</p></li><li><p>Using <a href="https://github.com/ashishtiwari1993/snowflake-elasticsearch-connector">Snowflake Elasticsearch Python Script</a> (one time sync)</p></li></ol><h2>Prerequisites</h2><h3>Snowflake credentials</h3><p>You will have received all below credentials after <a href="https://signup.snowflake.com/">signup</a>, or you can get them from the Snowflake panel.</p><ul><li><p>Account username</p></li><li><p>Account password</p></li><li><p>Account Identifier</p></li></ul><h3>Elastic credentials</h3><ol><li><p>Visit <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">https://cloud.elastic.co</a> and sign up.</p></li><li><p>Click on <strong>Create deployment</strong>. In the pop-up, you can change the settings or keep the default settings.</p></li><li><p>Download or copy the deployment credentials (both username and password).</p></li><li><p>Also copy the <a href="https://www.elastic.co/guide/en/cloud/current/ec-cloud-id.html">Cloud ID</a>.</p></li><li><p>Once you’re ready for deployment, click on <strong>Continue</strong> (or click on <strong>Open Kibana</strong>). It will redirect you to the Kibana dashboard.</p></li></ol><h2>Methods to ingest data from Snowflake to Elasticsearch</h2><h3>Method 1: Using Logstash</h3><p>Logstash is an open source ETL tool where you can provide multiple sources as an input, transform (modify) it, and push to your favorite stash. One of the famous use cases of Logstash is reading logs from the file and pushing to Elasticsearch. We can also modify the data on the fly using a <a href="https://www.elastic.co/guide/en/logstash/current/filter-plugins.html">filter</a> plugin, and it will push updated data to the output.</p><p>We’re going to use the <a href="https://www.elastic.co/guide/en/logstash/current/plugins-inputs-jdbc.html">JDBC input plugin</a> to pull the data from Snowflake and push to Elasticsearch using the <a href="https://www.elastic.co/guide/en/logstash/current/plugins-outputs-elasticsearch.html">Elasticsearch output plugin</a>.</p><ol><li><p>Install Logstash by referring to the <a href="https://www.elastic.co/guide/en/logstash/current/installing-logstash.html">documentation</a>.</p></li><li><p>Go to the Maven Central Repository and download: <a href="https://repo1.maven.org/maven2/net/snowflake/snowflake-jdbc">https://repo1.maven.org/maven2/net/snowflake/snowflake-jdbc</a>.</p></li><li><p>Click on the directory for the version that you need and download the <strong>snowflake-jdbc-#.#.#.jar</strong> file. In my case, I have downloaded <code>snowflake-jdbc-3.9.2.jar</code>. (Refer to official documentation to learn more about the <a href="https://docs.snowflake.com/en/developer-guide/jdbc/jdbc">Snowflake JDBC Driver</a>.)</p></li><li><p>Create a pipeline by creating file <code>sf-es.conf</code>. Add the below snippet and replace all credentials.</p></li></ol>input {
  jdbc {
    jdbc_driver_library =&gt; "/usr/share/logstash/logstash_external_configs/driver/snowflake-jdbc-3.9.2.jar"
    jdbc_driver_class =&gt; "net.snowflake.client.jdbc.SnowflakeDriver"
    jdbc_connection_string =&gt; "jdbc:snowflake://&lt;account_identifier&gt;.snowflakecomputing.com/?db=SNOWFLAKE_SAMPLE_DATA&amp;warehouse=COMPUTE_WH&amp;schema=TPCH_SF1"
    jdbc_user =&gt; "&lt;snowflake_username&gt;"
    jdbc_password =&gt; "&lt;snowflake_password&gt;"
    schedule =&gt; "* * * * *"
    statement =&gt; "select * from customer limit 10;"
  }
}

filter {}

output {
  elasticsearch {
    cloud_id =&gt; "&lt;elastic cloud_id&gt;"
    cloud_auth =&gt; "&lt;elastic_username&gt;:&lt;elastic_password&gt;"
    index =&gt; "sf_customer"
  }
}
<p><strong>jdbc_connection_string</strong> :</p>db=SNOWFLAKE_SAMPLE_DATA
warehouse=COMPUTE_WH
schema=TPCH_SF1
<p><strong>Schedule:</strong> Here you can schedule to run this flow periodically using cron syntax. On every run, your data will be moved incrementally. You can check more on <a href="https://www.elastic.co/guide/en/logstash/current/plugins-inputs-jdbc.html#_scheduling_2">scheduling</a>.</p><p>Please change according to your requirements.</p><p><a href="https://www.elastic.co/guide/en/logstash/current/plugins-inputs-jdbc.html#plugins-inputs-jdbc-jdbc_paging_enabled"><strong>JDBC Paging</strong></a> <strong>(Optional):</strong> This will cause a sql statement to be broken up into multiple queries. Each query will use limits and offsets to collectively retrieve the full result-set. You can use this to move all data in a single run.</p><p>Enable JDBC paging by adding below configurations:</p>jdbc_paging_enabled =&gt; true,
jdbc_paging_mode =&gt; "explicit",
jdbc_page_size =&gt; 100000

<ol><li><p>Run Logstash</p></li></ol>bin/logstash -f sf-es.conf
<h3>Method 2: Using Snowflake-Elasticsearch Python script</h3><p>If Logstash is not currently in place or has not been implemented, I have written a small Python utility, which is available <a href="https://github.com/ashishtiwari1993/snowflake-elasticsearch-connector">here on GitHub</a>, to pull data from Snowflake and push it to Elasticsearch. This will pull all your data at one time. So if you have a small amount of data to be migrated in a non-periodic manner, you can use this utility.</p><p><strong>Note:</strong> This is not a part of the official <a href="https://www.elastic.co/guide/en/enterprise-search/current/connectors.html">Elastic connectors</a>. Elastic connectors provide support for various data sources. You can use this connector if you have a requirement to sync data from any supported data sources.</p><ol><li><p>Installation</p></li></ol>git clone https://github.com/ashishtiwari1993/snowflake-elasticsearch-connector.git
cd snowflake-elasticsearch-connector
<ol><li><p>Installing dependencies</p></li></ol>pip install -r requirements.txt
<ol><li><p>Change configs</p></li></ol><ul><li><p>Open <code>config/connector.yml</code>.</p></li><li><p>Replace credentials with the following:</p></li></ul>snowflake:
  username: &lt;sf_username&gt;
  password: &lt;sf_password&gt;
  account: &lt;sf_account_identifier&gt;
  database: &lt;db_name&gt;
  table: &lt;table_name&gt;
  columns: ""
  warehouse: ""
  scheme: ""
  limit: 50

elasticsearch:
  host: https://localhost:9200
  username: elastic
  password: elastic@123
  ca_cert: /path/to/elasticsearch/config/certs/http_ca.crt
  index: &lt;sf_customer&gt;

<ol><li><p>Run connector</p></li></ol>python __main__.py
<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3056444664f99151/6a170b39ab7f08e6a0db9ea0/26faefb842a5d9af87d659c763adb79207980dc8-1475x582.gif" alt="Snowflake to Elasticsearch python script" /><h2>Verify data</h2><ol><li><p>Log in to Kibana and go to <strong>☰ &gt; Management &gt; Dev Tools</strong>.</p></li><li><p>Copy and paste the following API GET request into the Console pane, and then click the ▶ (play) button. This queries all records in the new index.</p></li></ol>GET sf_customer/_search
{
  "query": {
    "match_all": {}
  }
}

<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltceca98d9be170ff7/6a170b3b0c485744e701aa9a/e45399d16b795dbb936f95615e69590d2e6882dd-1440x697.png" alt="Output snowflake to elasticsearch" /><h2>Conclusion</h2><p>We have successfully migrated the data from Snowflake to Elastic Cloud. You can achieve the same on any Elasticsearch instance, whether it is in the cloud or on prem.</p><p>Start leveraging full text and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search.html">semantic search capabilities</a> on your data set. You can also connect your data with LLMs to build <a href="https://www.elastic.co/search-labs/chatgpt-elasticsearch-openai-meets-private-data">Question - Answer</a> capabilities.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ingest-data-from-snowflake-to-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ingest-data-from-snowflake-to-elasticsearch</guid>
    <category><![CDATA[Index Data]]></category>
    <dc:creator><![CDATA[Ashish Tiwari]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltac4b4ee213b421df/6a170b3c2867145fc693e31f/863d959e4481788dac10ed6abad63de2e823f2d0-1440x810.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 20 Dec 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI plagiarism: Plagiarism detection with Elasticsearch]]></title>
    <description><![CDATA[Here's how to check for AI plagiarism using Elasticsearch, focusing on use cases with NLP models and Vector Search.]]></description>
    <content:encoded><![CDATA[<p>Plagiarism can be <strong>direct</strong>, involving the copying of parts or the entire content, or <strong>paraphrased</strong>, where the author's work is rephrased by changing some words or phrases.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb6e139760e56ec98/6a171147dc55de0ad2e00edf/5d7073187fda829438aeec8d3a1194a5bea2ba57-1440x347.png" alt="" /><p>There is a distinction between inspiration and paraphrasing. It is possible to read a content, get inspired, and then explore the idea with your own words, even if you come to a similar conclusion.</p><p>While plagiarism has been a topic of discussion for a long time, the accelerated production and publication of content have kept it relevant and posed an ongoing challenge.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc2af1678cb04506b/6a171149cf4f251c2ab2d257/a0b9a98d729db09dae0a79315c001e6763c12704-1400x1016.png" alt="" /><p>This challenge isn't limited to books, academic research, or judicial documents, where plagiarism checks are frequently conducted. It can also extend to newspapers and even social media.</p><p>With the abundance of information and easy access to publishing, how can plagiarism be effectively checked on a scalable level?</p><p>Universities, government entities, and companies employ diverse tools, but while a straightforward <a href="https://www.elastic.co/search-labs/lexical-and-semantic-search-with-elasticsearch">lexical search</a> can effectively detect direct plagiarism, the primary challenge lies in identifying <strong>paraphrased content.</strong></p><h2>Plagiarism detection with Generative AI</h2><p>A new challenge emerges with Generative AI. Is content generated by AI considered plagiarism when copied?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8a1ed1b6fa3f1a56/6a17114b4a531bc40736aa69/9345b28d6d27c37469bc38e823c41780b4eabfe5-1440x875.png" alt="" /><p>The <a href="https://openai.com/">OpenAI</a> <a href="https://openai.com/policies/terms-of-use">terms of use</a>, for example, specify that OpenAI will not claim copyright over content generated by the API for users. In this case, individuals using their Generative AI can use the generated content as they prefer without citation.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc2e08ab5b808e38/6a17114dab7f086cbadb9f93/e1f415f69a247666f02ddc81468944920c874cd7-968x814.png" alt="" /><p>However, the acceptance of using Generative AI to improve efficiency remains a topic of discussion.</p><p>In an effort to contribute to plagiarism detection, OpenAI developed a <a href="https://huggingface.co/roberta-base-openai-detector">detection model</a> but later acknowledged that its accuracy is not sufficiently high.</p><p><em>"We believe this is not high enough accuracy for standalone detection and needs to be paired with metadata-based approaches, human judgment, and public education to be more effective."</em></p><p>The challenge persists; however, with the availability of more tools, there are now increased options for detecting plagiarism, even in cases of paraphrased and AI content.</p><h2>Detecting plagiarism with Elasticsearch</h2><p>Recognizing this, in this blog we are exploring one more use case with Natural Language Processing (NLP) models and Vector Search, plagiarism detection, beyond metadata searches.</p><p>This is demonstrated with <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/plagiarism-detection-with-elasticsearch/plagiarism_detection_es.ipynb">Python examples</a>, where we utilize a <a href="https://sbert.net/datasets/emnlp2016-2018.json">dataset</a> from <a href="https://www.sbert.net/">SentenceTransformers</a> containing NLP-related articles. We check the abstracts for plagiarism by performing 'semantic textual similarity' considering 'abstract' embeddings generated with a <a href="https://huggingface.co/sentence-transformers/all-mpnet-base-v2">text embedding model</a> previously imported into Elasticsearch. Additionally, to identify AI-generated content — AI plagiarism, an <a href="https://huggingface.co/roberta-base-openai-detector">NLP model</a> developed by OpenAI was also imported into Elasticsearch.</p><p>The following image illustrates the data flow:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0464f88d3ed12070/6a17114fab7f084905db9f97/1ad89c98a2f42a497548ca3947749bad54ec1172-1440x880.png" alt="" /><p>During the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html">ingest pipeline</a> with an <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-processor.html">inference processor</a>, the 'abstract' paragraph is mapped to a 768-dimensional vector, the 'abstract_vector.predicted_value'.</p><p>Mapping:</p>"abstract_vector.predicted_value": { # Inference results field
"type": "dense_vector", 
"dims": 768, # model embedding_size
"index": "true", 
"similarity": "dot_product" # When indexing vectors for approximate kNN search, you need to specify the similarity function for comparing the vectors.
<p>The similarity between vector representations is measured using a vector similarity metric, defined using the 'similarity' <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-params">parameter</a>.</p><p><a href="https://en.wikipedia.org/wiki/Cosine_similarity">Cosine</a> is the default similarity metric, computed as '(1 + cosine(query, vector)) / 2'. Unless you need to preserve the original vectors and cannot normalize them in advance, the most efficient way to perform cosine similarity is to normalize all vectors to unit length. This helps avoid performing extra vector length computations during the search, instead use 'dot_product'.</p><p>In this same pipeline, another inference processor containing the <a href="https://huggingface.co/roberta-base-openai-detector">text classification model</a> detects whether the content is 'Real' probably written by humans, or 'Fake' probably written by AI, adding the 'openai-detector.predicted_value' to each document.</p><p>Ingest Pipeline:</p>client.ingest.put_pipeline( 
    id="plagiarism-checker-pipeline",
    processors = [
    {
      "inference": { #for ml models - to infer against the data that is being ingested in the pipeline
        "model_id": "roberta-base-openai-detector", #text classification model id
        "target_field": "openai-detector", # Target field for the inference results
        "field_map": { #Maps the document field names to the known field names of the model.
        "abstract": "text_field" # Field matching our configured trained model input. 
        }
      }
    },
    {
      "inference": {
        "model_id": "sentence-transformers__all-mpnet-base-v2", #text embedding model id
        "target_field": "abstract_vector", # Target field for the inference results
        "field_map": {
        "abstract": "text_field" # Field matching our configured trained model input. Typically for NLP models, the field name is text_field.
        }
      }
    }
    
  ]
)
<p>At query time, the same text embedding model is also employed to generate the vector representation of the query 'model_text' in a 'query_vector_builder' object.</p><p>A k-nearest neighbor (kNN) search finds the k nearest vector to the query vector measured by the similarity metric.</p><p>The _score of each document is derived from the similarity, ensuring that a larger score corresponds to a higher ranking. This means that the document is more similar semantically. As a result, we are printing three possibilities: if score &gt; 0.9, we are considering 'high similarity'; if &lt; 0.7, 'low similarity’, otherwise, 'moderate similarity’. You have the flexibility to set different threshold values to determine what level of _score qualifies as plagiarism or not, based on your use case.</p><p>Additionally, text classification is performed to also check for AI-generated elements in the text query.</p><p>Query:</p>from elasticsearch import Elasticsearch
from elasticsearch.client import MlClient

#duplicated text - direct plagiarism test

model_text = 'Understanding and reasoning about cooking recipes is a fruitful research direction towards enabling machines to interpret procedural text. In this work, we introduce RecipeQA, a dataset for multimodal comprehension of cooking recipes. It comprises of approximately 20K instructional recipes with multiple modalities such as titles, descriptions and aligned set of images. With over 36K automatically generated question-answer pairs, we design a set of comprehension and reasoning tasks that require joint understanding of images and text, capturing the temporal flow of events and making sense of procedural knowledge. Our preliminary results indicate that RecipeQA will serve as a challenging test bed and an ideal benchmark for evaluating machine comprehension systems. The data and leaderboard are available at http://hucvl.github.io/recipeqa.'

response = client.search(index='plagiarism-checker', size=1,
    knn={
        "field": "abstract_vector.predicted_value",
        "k": 9,
        "num_candidates": 974,
        "query_vector_builder": { #The 'all-mpnet-base-v2' model is also employed to generate the vector representation of the query in a 'query_vector_builder' object.
            "text_embedding": {
                "model_id": "sentence-transformers__all-mpnet-base-v2",
                "model_text": model_text
            }
        }
    }
)

for hit in response['hits']['hits']:
    score = hit['_score']
    title = hit['_source']['title']
    abstract = hit['_source']['abstract']
    openai = hit['_source']['openai-detector']['predicted_value']
    url = hit['_source']['url']

    if score &gt; 0.9:
        print(f"\nHigh similarity detected! This might be plagiarism.")
        print(f"\nMost similar document: '{title}'\n\nAbstract: {abstract}\n\nurl: {url}\n\nScore:{score}\n\n")

        if openai == 'Fake':
            print("This document may have been created by AI.\n")

    elif score &lt; 0.7:
        print(f"\nLow similarity detected. This might not be plagiarism.")

        if openai == 'Fake':
            print("This document may have been created by AI.\n")

    else:
        print(f"\nModerate similarity detected.")
        print(f"\nMost similar document: '{title}'\n\nAbstract: {abstract}\n\nurl: {url}\n\nScore:{score}\n\n")

        if openai == 'Fake':
            print("This document may have been created by AI.\n")

ml_client = MlClient(client)

model_id = 'roberta-base-openai-detector' #open ai text classification model

document = [
    {
        "text_field": model_text
    }
]

ml_response = ml_client.infer_trained_model(model_id=model_id, docs=document)

predicted_value = ml_response['inference_results'][0]['predicted_value']

if predicted_value == 'Fake':
    print("\nNote: The text query you entered may have been generated by AI.\n")
<p>Output:</p>High similarity detected! This might be plagiarism.

Most similar document: 'RecipeQA: A Challenge Dataset for Multimodal Comprehension of Cooking Recipes'

Abstract: Understanding and reasoning about cooking recipes is a fruitful research direction towards enabling machines to interpret procedural text. In this work, we introduce RecipeQA, a dataset for multimodal comprehension of cooking recipes. It comprises of approximately 20K instructional recipes with multiple modalities such as titles, descriptions and aligned set of images. With over 36K automatically generated question-answer pairs, we design a set of comprehension and reasoning tasks that require joint understanding of images and text, capturing the temporal flow of events and making sense of procedural knowledge. Our preliminary results indicate that RecipeQA will serve as a challenging test bed and an ideal benchmark for evaluating machine comprehension systems. The data and leaderboard are available at[ http://hucvl.github.io/recipeqa](http://hucvl.github.io/recipeqa).

url:[http://aclweb.org/anthology/D18-1166](http://aclweb.org/anthology/D18-1166)

Score:1.0
<p>In this example, after utilizing one of the 'abstract' values from our dataset as the text query 'model_text', plagiarism was identified. The similarity score is 1.0, indicating a high level of similarity — <strong>direct plagiarism</strong>. The vectorized query and document were not recognized as AI-generated content, which was expected.</p><p>Query:</p>#similar text - paraphrase plagiarism test 

model_text = 'Comprehending and deducing information from culinary instructions represents a promising avenue for research aimed at empowering artificial intelligence to decipher step-by-step text. In this study, we present CuisineInquiry, a database for the multifaceted understanding of cooking guidelines. It encompasses a substantial number of informative recipes featuring various elements such as headings, explanations, and a matched assortment of visuals. Utilizing an extensive set of automatically crafted question-answer pairings, we formulate a series of tasks focusing on understanding and logic that necessitate a combined interpretation of visuals and written content. This involves capturing the sequential progression of events and extracting meaning from procedural expertise. Our initial findings suggest that CuisineInquiry is poised to function as a demanding experimental platform.'
<p>Output:</p>High similarity detected! This might be plagiarism.

Most similar document: 'RecipeQA: A Challenge Dataset for Multimodal Comprehension of Cooking Recipes'

Abstract: Understanding and reasoning about cooking recipes is a fruitful research direction towards enabling machines to interpret procedural text. In this work, we introduce RecipeQA, a dataset for multimodal comprehension of cooking recipes. It comprises of approximately 20K instructional recipes with multiple modalities such as titles, descriptions and aligned set of images. With over 36K automatically generated question-answer pairs, we design a set of comprehension and reasoning tasks that require joint understanding of images and text, capturing the temporal flow of events and making sense of procedural knowledge. Our preliminary results indicate that RecipeQA will serve as a challenging test bed and an ideal benchmark for evaluating machine comprehension systems. The data and leaderboard are available at[ http://hucvl.github.io/recipeqa](http://hucvl.github.io/recipeqa).

url:[http://aclweb.org/anthology/D18-1166](http://aclweb.org/anthology/D18-1166)

Score:0.9302529

Note: The text query you entered may have been generated by AI.
<p>By updating the text query 'model_text' with an AI-generated text that conveys the same message while minimizing the repetition of similar words, the detected similarity was still high, but the score was 0.9302529 instead of 1.0 — <strong>paraphrase plagiarism</strong>. It was also expected that this query, which was generated by AI, would be detected.</p><p>Lastly, considering the text query 'model_text' as a text about Elasticsearch, which is not an abstract of one of these documents, the detected similarity was 0.68991005, indicating low similarity according to the considered threshold values.</p><p>Query:</p>#different text - not a plagiarism

model_text = 'Elasticsearch provides near real-time search and analytics for all types of data.'
<p>Output:</p>Low similarity detected. This might not be plagiarism.
<p>Although plagiarism was accurately identified in the text query generated by AI, as well as in cases of paraphrasing and direct copied content, navigating the landscape of plagiarism detection involves acknowledging various aspects.</p><p>In the context of AI-generated content detection, we explored a model that makes a valuable contribution. However, it is crucial to recognize the inherent limitations in standalone detection, necessitating the incorporation of other methods to boost the accuracy.</p><p>The variability introduced by the choice of text embedding models is another consideration. Different models, trained with distinct datasets, result in varying levels of similarity, highlighting the importance of the text embeddings generated.</p><p>Lastly, in these examples, we used the document's abstract. However, plagiarism detection often involves large documents, making it essential to address the challenge of text length. It is common for the text to exceed a model's token limit, requiring segmentation into chunks before building embeddings. A <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.11/knn-search.html#nested-knn-search">practical approach</a> to handling this involves utilizing nested structures with dense_vector.</p><h2>Conclusion</h2><p>In this blog, we discussed the challenges of detecting plagiarism, particularly in paraphrased and AI-generated content, and how semantic textual similarity and text classification can be used for this purpose.</p><p>By combining these methods, we provided an example of plagiarism detection where we successfully identified AI-generated content, direct and paraphrased plagiarism.</p><p>The primary goal was to establish a filtering system that simplifies detection but human assessment remains essential for validation.</p><p>If you are interested in learning more about semantic textual similarity and NLP, we encourage you to also check out these links:</p><ul><li><p><a href="https://www.elastic.co/what-is/semantic-search">What is semantic search?</a></p></li><li><p><a href="https://www.elastic.co/what-is/natural-language-processing">What is natural language processing (NLP)?</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/lexical-and-semantic-search-with-elasticsearch">Lexical and Semantic Search with Elasticsearch</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/chunking-via-ingest-pipelines">Chunking Large Documents via Ingest pipelines plus nested vectors equals easy passage search</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-plagiarism-checker-with-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-plagiarism-checker-with-elasticsearch</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Priscilla Parodi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt68a5bc2434a9b03b/6a1711510e2e49a09641a22a/83e05cd4f81799fbb7b7950ed87600e825ec81e9-1024x1024.png" length="0" type="image/png"/>
    <pubDate>Tue, 19 Dec 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Introducing kNN Query: An expert way to do kNN search]]></title>
    <description><![CDATA[Explore how the kNN query in Elasticsearch can be used and how it differs from top-level kNN search, including examples.]]></description>
    <content:encoded><![CDATA[<h3>kNN search as a top-level section</h3><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">kNN search</a> in Elasticsearch is organized as a top level section of a search request. We have designed it this way so that:</p><ul><li><p>It can always return global k nearest neighbors regardless of a number of shards</p></li><li><p>These global k results are combined with a results from other queries to form a hybrid search</p></li><li><p>The global k results are passed to aggregations to form facets.</p></li></ul><p>Here is a simplified diagram how kNN search is executed internally (some phases are omitted) :</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9df3a17c59cedde/6a170b2aab7f08ec09db9e96/24e227a8482aead389c0ad6298779fd8610006f7-2849x1020.gif" alt="Execution for top level kNN search" /><p>Figure 1: The steps for the top level kNN search are:</p><ol><li><p>A user submits a search request</p></li><li><p>The coordinator node sends a kNN search part of the request to data nodes in the DFS phase</p></li><li><p>Each data node runs kNN search and sends back the local top-k results to the coordinator</p></li><li><p>The coordinator merges all local results to form the global top k nearest neighbors.</p></li><li><p>The coordinator sends back the global k nearest neighbors to the data nodes with any additional queries provided</p></li><li><p>Each data node runs additional queries and sends back the local <code>size</code> results to the coordinator</p></li><li><p>The coordinator merges all local results and sends a response to the user</p></li></ol><p>We first run kNN search in the DFS phase to obtain the global top k results. These global k results are then passed to other parts of the search request, such as other queries or aggregations. Even the execution looks complex, from a user’s perspective this model of running kNN search is simple, as the user can always be sure that kNN search returns the global k results.</p><h3>Introducing kNN query in Elasticsearch</h3><p>With time we realized there is also a need to represent kNN search as a query. Query is a core component of a search request in Elasticsearch, and representing kNN search as a query allows for flexibility to combine it with other queries to address more complex requests.</p><p>kNN query, unlike the top level kNN search, doesn’t have a <code>k</code> parameter. The number of results (nearest neighbors) returned is defined by the <code>size</code> parameter, as in other queries. Similar to kNN search, the <code>num_candidates</code> parameter defines how many candidates to consider on each shard while executing a kNN search.</p>GET products/_search
{
 "size" : 3,
 "query": {
   "knn": {
     "field": "embedding",
     "query_vector": [2,2,2,0],
     "num_candidates": 10
   }
 }
}

<p>kNN query is executed differently from the top level kNN search. Here is a simplified diagram that describes how a kNN query is executed internally (some phases are omitted):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf2768a24d52acbbd/6a170b2c7d8d6762fd70e71a/fe505bf7e95ae94c0f7605df1348ef15536134b9-2849x1020.gif" alt="Execution for kNN query" /><p>Figure 2: The steps for query based kNN search are:</p><ol><li><p>A user submits a search request</p></li><li><p>The coordinator sends to the data nodes a kNN search query with additional queries provided</p></li><li><p>Each data node runs the query and sends back the local size results to the coordinator node</p></li><li><p>The coordinator node merges all local results and sends a response to the user</p></li></ol><p>We run kNN search on a shard to get <code>num_candidates</code> results; these results are passed to other queries and aggregations on a shard to get size results from the shard. As we don’t collect the global k nearest neighbors first, in this model the number of nearest neighbors collected and visible for other queries and aggregations depend on the number of shards.</p><h3>kNN query API examples</h3><p>Let’s look at API examples that demonstrate differences between the top level kNN search and kNN query.</p><p>We create an index of products and index some documents:</p>PUT products
{
 "mappings": {
   "dynamic": "strict",
   "properties": {
     "department": {
       "type": "keyword"
     },
     "brand": {
       "type": "keyword"
     },
     "description": {
       "type": "text"
     },
     "embedding": {
       "type": "dense_vector",
       "index": true,
       "similarity": "l2_norm"
     },
     "price": {
       "type": "float"
     }
   }
 }
}
POST products/_bulk?refresh=true
{"index":{"_id":1}}
{"department":"women","brand": "Levi's", "description":"high-rise red jeans","embedding":[1,1,1,1],"price":100}
{"index":{"_id":2}}
{"department":"women","brand": "Calvin Klein","description":"high-rise beautiful jeans","embedding":[1,1,1,1],"price":250}
{"index":{"_id":3}}
{"department":"women","brand": "Gap","description":"every day jeans","embedding":[1,1,1,1],"price":50}
{"index":{"_id":4}}
{"department":"women","brand": "Levi's","description":"jeans","embedding":[2,2,2,0],"price":75}
{"index":{"_id":5}}
{"department":"women","brand": "Levi's","description":"luxury jeans","embedding":[2,2,2,0],"price":150}
{"index":{"_id":6}}
{"department":"men","brand": "Levi's", "description":"jeans","embedding":[2,2,2,0],"price":50}
{"index":{"_id":7}}
{"department":"women","brand": "Levi's", "description":"jeans 2023","embedding":[2,2,2,0],"price":150}
<p>kNN query similar to the top level kNN search, has <code>num_candidates</code> and an internal <code>filter</code> parameter that acts as a pre-filter.</p>GET products/_search
{
 "size" : 3,
 "query": {
   "knn": {
     "field": "embedding",
     "query_vector": [2,2,2,0],
     "num_candidates": 10,
     "filter" : {
       "term" : {
         "department" : "women"
       }
     }
   }
 }
} 
<p>kNN query can get more diverse results than kNN search for collapsing and aggregations. For the kNN query below, on each shard we execute kNN search to obtain 10 nearest neighbors which are then passed to collapse to get 3 top results. Thus, we will get 3 diverse hits in a response.</p>GET products/_search
{
 "size" : 3,
 "query": {
   "knn": {
     "field": "embedding",
     "query_vector": [2,2,2,0],
     "num_candidates": 10,
     "filter" : {
       "term" : {
         "department" : "women"
       }
     }
   }
 },
 "collapse": {
   "field": "brand"        
 }
}
<p>The top level kNN search first gets the global top 3 results in the DFS phase, and then passes them to collapse in the query phase. We will get only 1 hit in a response, as all the global 3 nearest neighbors happened to be from the same brand.</p>GET products/_search?size=3
{
 "knn" : {
   "field": "embedding",
     "query_vector": [2,2,2,0],
     "k" : 3,
     "num_candidates": 10,
     "filter" : {
       "term" : {
         "department" : "women"
       }
     }
 },
 "collapse": {
   "field": "brand"        
 }
}
<p>Similarly for aggregations, a kNN query allows us to get 3 distinct buckets, while kNN search only allows 1.</p>GET products/_search
{
"size": 0,
"query": {
   "knn": {
     "field": "embedding",
     "query_vector": [2,2,2,0],
     "num_candidates": 10,
     "filter" : {
       "term" : {
         "department" : "women"
       }
     }
   }
 },
 "aggs": {
   "brands": {
     "terms": {
       "field": "brand"
     }
   }
 }
}
​
GET products/_search
{
"size": 0,
"knn" : {
 "field": "embedding",
   "query_vector": [2,2,2,0],
   "k" : 3,
   "num_candidates": 10,
   "filter" : {
     "term" : {
       "department" : "women"
     }
   }
 },
 "aggs": {
   "brands": {
     "terms": {
       "field": "brand"
     }
   }
 }
}
<p>Now, let’s look at other examples that show the flexibility of the kNN query. Specifically, how it can be flexibly combined with other queries.</p><p>kNN can be a part of a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html">boolean</a> query (with a caveat that all external query filters are applied as post-filters for kNN search). We can use a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html#named-queries">_name</a> parameter for kNN query to enhance results with extra information that tells if the kNN query was a match and its score contribution.</p>GET products/_search?include_named_queries_score
{
 "size": 3,
 "query": {
   "bool": {
     "should": [
       {
         "knn": {
           "field": "embedding",
           "query_vector": [2,2,2,0],
           "num_candidates": 10,
           "_name": "knn_query"
         }
       },
       {
         "match": {
           "description": {
             "query": "luxury",
             "_name": "bm25query"
           }
         }
       }
     ]
   }
 }
}
<p>kNN can also be a part of complex queries, such as a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-pinned-query.html">pinned</a> query. This is useful when we want to display the top nearest results, but also want to promote a selected number of other results.</p>GET products/_search
{
 "size": 3,
 "query": {
   "pinned": {
     "ids": [ "1", "2" ],
     "organic": {
       "knn": {
           "field": "embedding",
           "query_vector": [2,2,2,0],
           "num_candidates": 10,
           "_name": "knn_query"
         }
     }
   }
 }
}
<p>We can even make the kNN query a part of our <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html">function_score</a> query. This is useful when we need to define custom scores for results returned by kNN query: ​</p>GET products/_search
{
 "size": 3,
 "query": {
   "function_score": {
     "query": {
       "knn": {
           "field": "embedding",
           "query_vector": [2,2,2,0],
           "num_candidates": 10,
           "_name": "knn_query"
         }
     },
     "functions": [
       {
         "filter": { "match": { "department": "men" } },
         "weight": 100
       },
       {
         "filter": { "match": { "department": "women" } },
         "weight": 50
       }
     ]
   }
 }
}
<p>kNN query being a part of <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-dis-max-query.html">dis_max</a> query is useful when we want to combine results from kNN search and other queries, so that a document’s score comes from the highest ranked clause with a tie breaking increment for any additional clause. ​</p>GET products/_search
{
 "size": 5,
 "query": {
   "dis_max": {
     "queries": [
       {
         "knn": {
           "field": "embedding",
           "query_vector": [2,2, 2,0],
           "num_candidates": 3,
           "_name": "knn_query"
         }
       },
       {
         "match": {
           "description": "high-rise jeans"
         }
       }
     ],
     "tie_breaker": 0.8
   }
 }
}
<p>kNN search as a query has been introduced with the 8.12 release. Please try it out, and we would appreciate any feedback.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/knn-query-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/knn-query-elasticsearch</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Mayya Sharipova,Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd528ca7843f1946f/6a170b2e2b835ff0b7f4b21b/d2c2a3cddc393d80b11e4ed93672e345d0addd7d-1024x1024.png" length="0" type="image/png"/>
    <pubDate>Thu, 07 Dec 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Retrieval Augmented Generation (RAG) using Cohere Command model through Amazon Bedrock and domain data in Elasticsearch]]></title>
    <description><![CDATA[Learn how to implement Retrieval Augmented Generation (RAG) using Cohere Command model via Amazon Bedrock &amp; domain data in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p><strong>Generative AI</strong> is a type of Artificial Intelligence (AI) that can create new content and ideas, including conversations, stories, images, videos, and music. Like all AI, generative AI is powered by Machine Learning (ML) models—very large models that are pre-trained on vast corpora of data and commonly referred to as Foundation Models (FMs).</p><p>To the public, generative AI has seemingly appeared from nowhere. But if you dig deeper, you’ll note that the ideas underlying generative AI solutions trace their lineage back to inventions such as the Mark I perceptron in 1958 and neural networks in the late twentieth century.</p><p>Advancements in statistical techniques, the vast growth of publicly available data and advancements in Machine Learning (specifically the invention of the transformer-based neural network architecture) have led to the rise of models that contain billions of parameters or variables. To give a sense for the change in scale, the largest pre-trained model in 2019 was 330M parameters. Cohere's Command XL model, one of the leading models in Stanford’s <a href="https://crfm.stanford.edu/helm/latest/">Holistic Evaluation of Language Models (HELM) benchmark</a> is trained on 52.4 billion parameters - ~1580x increase in size in just a few years.</p><p><strong>Foundation Models (FMs)</strong> are ML models trained on massive quantities of structured and unstructured data, which can be fine-tuned or adapted for more specific tasks.</p><p><strong>Large Language Models (LLMs)</strong> are a subset of FMs focused on understanding and generating human-like text. These models are ideal for needs such as translation, answering questions, summarizing information, and creating or identifying images.</p><p><strong>LLMs</strong> can perform a wide range of tasks that span multiple domains, like writing blog posts, solving math problems, engaging in dialog, and answering questions based on a document. The size and general-purpose nature of FMs make them different from traditional ML models, which typically perform specific tasks, like analyzing text for sentiment, classifying images, and forecasting trends.</p><p>The primary goal of LLMs is to enable meaningful and engaging conversation between humans and machines and have become an immensely effective resource in countless industries, helping business to improve the customer experience.</p><h2>Limitations of LLMs</h2><p>LLMs have certain limitations. One notable constraint is that they are trained on general domain corpora, making them less effective on domain-specific tasks. There are scenarios when you want models to generate text based on specific data rather than generic data. For example, a health service provider company may want their chatbot to answer questions using the latest information stored in an enterprise document repository, so that the answers are specific to the health service provider’s business.</p><p>Also, these LLMs are trained offline until a <strong>knowledge cutoff date</strong>. It will be agnostic of any developments that have happened after the knowledge cutoff date. This may lead to inaccurate interpretations. For example, in 2021 San Francisco was the most expensive Bay Area City for renters. Today, it is Mountain view - <a href="https://www.nbcbayarea.com/news/local/south-bay/mountain-view-rent-report/3152054/#:~:text=A%20new%20report%20shows%20Mountain,almost%20always%20been%20San%20Francisco.">source</a>.</p><h2>Approaches to enhance LLMs</h2><p>There are two popular ways to reference contextual data in LLMs.</p><p>The first option is to <strong>fine-tune</strong> the base LLMs with contextual data. But, using this approach generating the correctly formatted information is time consuming. Also, it is costly to fine-tune a model. In addition to that, if the domain specific data is changing frequently, it would require frequent fine-tunings and retraining to provide accurate responses. This impacts time to market and also increases overall cost of the solution. In addition to that, not all LLMs provide an option to fine-tune.</p><p>To overcome these constraints, we can use a technique called <strong>Retrieval Augmented Generation (RAG)</strong>. RAG is a process in which the model retrieves contextual documents from an external data source like Elasticsearch as part of its execution. These contextual documents are used in conjunction with the original input to produce an output.</p><p>Below are a few examples of how RAG can be used in various applications to improve the quality and relevance of generated content.</p><ul><li><p><strong>Chatbot responses</strong>: In a chatbot system, RAG refers to combining a retrieval-based approach with a generative model. The retrieval component obtains relevant responses from a pre-defined database or knowledge base, while the generative model can add additional context or generate more fluent and diverse responses. This combination helps the chatbot provide more accurate and contextually appropriate answers to user queries.</p></li><li><p><strong>Content generation</strong>: RAG can be used in content generation tasks such as summarization or paraphrasing. The retrieval component can retrieve relevant sentences or paragraphs from existing documents or articles, and the generative model can then augment or rephrase the retrieved content to create new and original summaries or paraphrases.</p></li><li><p><strong>Recommendation systems</strong>: RAG can also be applied in recommendation systems. The retrieval component can retrieve a set of candidate items or products based on user preferences or history, and the generative model can then generate personalized recommendations or provide additional information about the recommended items to enhance the user’s decision-making process.</p></li></ul><h2>RAG using Elasticsearch and Cohere Command model through Amazon Bedrock</h2><h3>Why Cohere?</h3><p><a href="https://cohere.com/">Cohere</a> is the leading AI platform for enterprise. The company builds world-class LLMs that allow computers to search, understand meaning, and converse in text. Cohere's models are uniquely suited to the needs of business, providing ease of use and strong security and privacy controls across multiple deployment options. Companies can use the models out-of-the-box or tailor them to their particular needs using their own custom data.</p><p><a href="https://cohere.com/models/command">Command </a>is Cohere’s flagship text generation model. It is trained to follow user commands and to be instantly useful in practical business applications, such as text generation, summarization, RAG, and chat. Command ranks as one of the leading language models according to the <a href="https://crfm.stanford.edu/helm/latest/?group=core_scenarios">Stanford’s HELM website</a> an evaluation leaderboard comparing large language models on a wide number of tasks from Stanford University (March 2023 results). Customers can use Cohere's Command LLM through <a href="https://aws.amazon.com/sagemaker/jumpstart/?p=pm&amp;c=sm&amp;z=2">Amazon SageMaker Jumpstart</a> and Amazon Bedrock.</p><p><a href="https://cohere.com/embed">Embed</a> is Cohere’s representative model which translates text into numerical vectors that models can understand. Cohere provides industry-leading English and multilingual models (100+ languages) for a range of use cases, including semantic search, text classification, and semantic engine for RAG.</p><h3>Why Elasticsearch?</h3><p>To make the most of generative AI, it is essential to have a unified data platform where the organization's data is stored, making it easily (and safely) accessible and searchable in one centralized location.</p><p><a href="https://www.elastic.co/?utm_campaign=B-Stack-Trials-AMER-US-E-Exact&amp;utm_content=Stack-Core&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elasticsearch&amp;gclid=Cj0KCQjwy4KqBhD0ARIsAEbCt6gQivnVj9HtKlnH2V-vRa9cXTQ-06y4DDUe_g2Rj3sunEAqQQNZ7qMaArfnEALw_wcB"><strong>Elasticsearch</strong></a> is a distributed, open source search and analytics engine for all types of data, including textual, numerical, geospatial, structured, and unstructured. Raw data from Enterprises flows into Elasticsearch from a variety of sources, including logs, system metrics, and web applications. Elasticsearch is built on top of Lucene and it excels at full-text search. Elasticsearch is fast and excels at delivering the most relevant responses to users.</p><p>In addition to full-text search, Elasticsearch also supports vector-search.</p><p><strong>Vector search</strong> leverages <a href="https://www.elastic.co/what-is/elasticsearch-machine-learning">ML</a> to capture the meaning and context of unstructured data, including text and images, transforming it into a numeric representation. Frequently used for <strong>semantic search,</strong> vector search finds similar data using approximate nearest neighbor (ANN) algorithms. Compared to traditional keyword search, vector search yields more relevant results and executes faster. Users can enhance the search experience by combining vector search with filtering and aggregations to optimize relevance by implementing a hybrid search and combining it with traditional scoring.</p><p>Elasticsearch provides an easy-to-use and performant API to enable integration with other services. These features make Elasticsearch a preferred choice for enterprises to store business data and improve search experience.</p><p>Elasticsearch allows for the seamless integration of domain-specific context from the organization's data, thereby enhancing the performance and value of generative AI for achieving desired business objectives.</p><h3>Why Amazon Bedrock?</h3><p><a href="https://aws.amazon.com/bedrock/">Amazon Bedrock</a> is a fully managed service that offers a choice of high-performing FMs from leading AI companies like AI21 Labs, Anthropic, Cohere, Meta, Stability AI, and Amazon with a single API, along with a broad set of capabilities you need to build generative AI applications, simplifying development while maintaining privacy and security. With the comprehensive capabilities of Amazon Bedrock, you can easily experiment with a variety of top FMs, privately customize them with your data using techniques such as fine-tuning and RAG, and create managed agents that execute complex business tasks—from booking travel and processing insurance claims to creating ad campaigns and managing inventory—all without writing any code. Since Amazon Bedrock is serverless, you don't have to manage any infrastructure, and you can securely integrate and deploy generative AI capabilities into your applications using the AWS services you are already familiar with.</p><p>Amazon Bedrock offers several capabilities to support security and privacy requirements and has achieved HIPAA eligibility and GDPR compliance. With Amazon Bedrock, content is not used to improve the base models and is not shared with third-party model providers. Data in Amazon Bedrock is always encrypted in transit and at rest, and can encrypt the data using your own keys. <a href="https://aws.amazon.com/privatelink/">AWS PrivateLink</a> can be used with Amazon Bedrock to establish private connectivity between FMs and your Amazon Virtual Private Cloud (Amazon VPC) without exposing your traffic to the Internet.</p><h3>Solution overview</h3><p>Here's how to use RAG to enable Generative AI capabilities on domain-specific business data using Elasticsearch and Cohere Generate Model -Command through Amazon Bedrock.</p><p>The below architecture diagram explains how to get domain-specific responses from <strong>Cohere Command Model</strong> through Amazon Bedrock using enterprise data hosted in Elastic Enterprise Search using a technique called <strong>RAG.</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltacd3183ea4f4f1dd/6a17e783e9ea87c9eda9c5cd/1d97936d86f6e24e67d208e3cb84518a2b6eac18-1440x748.png" alt="" /><p><em>Figure 1. RAG Architecture using Elasticsearch and Amazon Bedrock</em></p><h4>Step by step explanation</h4><p><strong>Offline Data Ingestion:</strong></p><p>i. The documents are ingested using web crawler or any other ingestion mechanism.</p><p>ii. The <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html">Elastic Learned Sparse EncodeR (ELSER)</a> model will embed the text and store the resulting tokens in the Elasticsearch Index</p><p><strong>Real-time flow on user query:</strong></p><ol><li><p>The User provides a question via the RAG web application</p></li><li><p>The RAG application generates a retrieval request initialized from the vector store (Elasticsearch index). At query time, the text will be embedded using the ELSER model and the resulting tokens will be used to perform a text expansion query.</p></li><li><p>The Retriever component of RAG application fetches the relevant documents from Elasticsearch vector store.</p></li><li><p>The RAG application passes the retrieved documents (context) along with user question (prompt) to the Cohere Command Model through Amazon Bedrock</p></li><li><p>The Cohere Command Model through Amazon Bedrock generates a textual response and sends it back to RAG application</p></li><li><p>The RAG application performs any required post processing tasks. For example, it adds source to the response generated from Cohere Command Model. The User views the response in the web application.</p></li></ol><p>We used the following <strong>AWS and third-party services</strong>:</p><ol><li><p><a href="https://aws.amazon.com/bedrock/">Amazon Bedrock</a> for interacting with LLMs from Cohere.</p></li><li><p>Cohere Command Model for Text Generation.</p></li><li><p>Elasticsearch for storing embeddings of the enterprise knowledge corpus and doing similarity search with user questions.</p></li><li><p>Python, LangChain and Streamlit for building the RAG application</p></li><li><p>Amazon EC2 for hosting the Streamlit application</p></li><li><p><a href="https://aws.amazon.com/iam/">AWS Identity and Access Management</a> roles and policies for access management.</p></li></ol><p>Prerequisites:</p><ol><li><p><strong>Sign up</strong> for a free trial of Elasticsearch cluster with <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">Elastic Cloud</a></p></li><li><p><strong>Create a new deployment</strong> on AWS following the <a href="https://www.elastic.co/guide/en/cloud/current/ec-create-deployment.html">steps</a></p></li><li><p><strong>Reset and download the elastic user password</strong> following these <a href="https://www.elastic.co/guide/en/cloud/current/ec-password-reset.html">steps</a></p></li><li><p><strong>Copy the Cloud ID</strong> from the My Deployment page listed under Deployments</p></li><li><p>Deploy ELSER Model: In Kibana, navigate to Machine Learning&gt; Trained models. ELSER can be found in the list of trained models. Click the Download model button under Actions. After the download is finished, start the deployment by clicking the <strong>Start deployment</strong> button. Go to the Elastic <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html">ELSER</a> page to find more details.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3232748eebefafaa/6a17e78563baff7b63741c3c/32a80e07f0f4e904bacc835f2f7e79c31a375f09-1433x780.png" alt="" /><ol><li><p>Install packages and import modules: Firstly, we need to install modules. Make sure <a href="https://www.python.org/downloads/release/python-381/">python</a> is installed with min version 3.8.1. Then we need to import modules.</p></li></ol>pip install -qU langchain langchain-elasticsearch boto3

from getpass import getpass
from urllib.request import urlopen
from langchain_elasticsearch import ElasticsearchStore
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings.bedrock import BedrockEmbeddings
from langchain.llms.bedrock import Bedrock
from langchain.chains import RetrievalQA
import boto3
import json

<ol><li><p>Initialize Amazon Bedrock client using the following code</p></li></ol>default_region = "us-east-1"
AWS_REGION = input(f"AWS Region [default: {default_region}]: ") or default_region

def get_bedrock_client(region):
    bedrock_client = boto3.client("bedrock-runtime", region_name=region)
    return bedrock_client

<ol><li><p>Connect to Elasticsearch using Elastic Cloud Id, Elastic username and Elastic password. Use <strong>ElasticsearchStore</strong> to connect to our elastic cloud deployment. As we’re using ELSER we use “SparseVectorRetrievalStrategy”. This strategy uses Elasticsearch’s sparse vector retrieval to retrieve the top-k results.</p></li></ol>CLOUD_ID = getpass("Elastic deployment Cloud ID: ")
CLOUD_USERNAME = "elastic"
CLOUD_PASSWORD = getpass("Elastic deployment Password: ")


vector_store = ElasticsearchStore(
   es_cloud_id=CLOUD_ID,
   es_user=CLOUD_USERNAME,
   es_password=CLOUD_PASSWORD,
   index_name= "workplace_index",
   strategy=ElasticsearchStore.SparseVectorRetrievalStrategy()
)
<ol><li><p>Download the dataset, deserialize the document and split the document into passages. We’ll chunk the documents into passages in order to improve the retrieval specificity and to ensure that we can provide multiple passages within the context window of the final question answering prompt. Here we are chunking into 800 tokens with an overlap of 400 tokens. Here, we are using a simple splitter but LangChain offers more advanced splitters to reduce the chance of context being lost.</p></li></ol>url = "https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/example-apps/workplace-search/data/data

response = urlopen(url)

workplace_docs = json.loads(response.read())

metadata = []
content = []

for doc in workplace_docs:
  content.append(doc["content"])
  metadata.append({
      "name": doc["name"],
      "summary": doc["summary"],
      "rolePermissions":doc["rolePermissions"]
})

text_splitter = CharacterTextSplitter(chunk_size=800, chunk_overlap=400)
docs = text_splitter.create_documents(content, metadatas=metadata)

<ol><li><p>Index data to Elasticsearch using <a href="https://api.python.langchain.com/en/latest/vectorstores/langchain_community.vectorstores.elasticsearch.ElasticsearchStore.html#langchain_community.vectorstores.elasticsearch.ElasticsearchStore.from_documents">ElasticsearchStore.from_documents</a>.</p></li></ol>documents = vector_store.from_documents(
    docs,
    es_cloud_id=CLOUD_ID,
    es_user=CLOUD_USERNAME,
    es_password=CLOUD_PASSWORD,
    index_name="workplace_index"
    strategy=ElasticsearchStore.SparseVectorRetrievalStrategy()
)
<ol><li><p>Initialize the Amazon Bedrock LLM. In the Amazon Bedrock instance, will pass bedrock_client and specific model_id. In this case model_id = <code>cohere.command-text-v14.</code></p></li></ol>default_model_id = "cohere.command-text-v14"
AWS_MODEL_ID = input(f"AWS model [default: {default._model_id}]: ") or default_model_id

def create_bedrock_llm(bedrock_client, model_version_id):
    bedrock_lIm=Bedrock(
        model_id=model_version_id,
        client=bedrock_client,
        model_kwargs={'temperature': 0}
        )
    return bedrock_lIm
<ol><li><p>Asking a question: Now that we have the passages stored in Elasticsearch and LLM is initialized, we can now ask a question to get the relevant passages.</p></li></ol>retriever = vector_store.as_retriever()

qa = RetrievalQA.from_llm(
     llm=llm,
     retriever=retriever,
     return_source_documents=True
)

questions = [
    'What is the nasa sales team?',
    'What is our work from home policy?',
    'Does the company own my personal project?',
    'What job openings do we have?',
    'How does compensation work?'
]
question = questions [1]
print(f"Question: {question}\n")

ans = qa({"query": question})

print("\033[92m ---- Answer ---- \033 [Om')
print(ans["result"] + "\n")
print("\033[94m ---- Sources ----\033 [0m')
for doc in ans["source_documents"]:
  print("Name: " + doc.metadata ["name"])
  print("Content: " + doc.page_content)
  print ("-------\n")

<ol><li><p>(Optional) You can also add a reranking step to the search pipeline which can further improve the ranking of the results returned in the search step. See the <a href="https://docs.cohere.com/docs/deploying-with-aws-sagemaker">Deploying with Amazon SageMaker</a> guide on using <a href="https://txt.cohere.com/rerank/">Rerank.</a></p></li></ol><h2>Conclusion</h2><p>In this post, we showed how to create a RAG web application using a combination of Elasticsearch, Amazon Bedrock, Cohere Command Model and open source python packages like LangChain and Streamlit.</p><p>We encourage you to learn more by exploring <a href="https://aws.amazon.com/sagemaker/jumpstart/?p=pm&amp;c=sm&amp;z=2">Amazon SageMaker Jumpstart</a>, <a href="https://aws.amazon.com/bedrock/">Amazon Bedrock</a>, <a href="https://cohere.com/">Cohere</a>, and <a href="https://www.elastic.co/?ultron=B-Stack-Trials-AMER-US-W&amp;gambit=Stack-Core-EXT&amp;blade=adwords-s&amp;hulk=paid&amp;Device=c&amp;thor=elasticsearch&amp;gclid=Cj0KCQjw1_SkBhDwARIsANbGpFuGD05uYzL230GZmrmDxpIIUdX5GpC0e_wwdUr8OAwseTx7dx3jjywaAuhEEALw_wcB">Elasticsearch</a> and building a solution using the sample implementation provided in this post and a dataset relevant to your business. If you have questions or suggestions, leave a comment.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/gen-ai-using-cohere-llm</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/gen-ai-using-cohere-llm</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Udayasimha Theepireddy,Meor Amer,Ayan Ray,James Yi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5cd8853e995046c9/6a17e7872f4a5c03b6fa88f6/52748b9e2f082718804ed9c0d8f4272f4e4f893a-1024x1024.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 23 Nov 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Domain specific generative AI: pre-training, fine-tuning, and RAG]]></title>
    <description><![CDATA[Explore strategies for integrating domain-specific knowledge into large language models (LLMs) through pre-training, fine-tuning, and RAG.]]></description>
    <content:encoded><![CDATA[<p>There are a number of strategies to add domain specific knowledge to large language models (LLMs), and more approaches are being investigated as part of <a href="https://acl2023-retrieval-lm.github.io/">an active research field</a>. Methods such as pre-training and fine-tuning on domain specific datasets allow the LLM to reason and generate domain specific language. However, using these LLMs as knowledge bases is still prone to hallucinations. If the domain language is similar to the LLM training data, using external information retrieval systems via Retrieval Augmented Generation (RAG) to provide contextual information to the LLM can improve factual responses. Ultimately, a combination of fine-tuning and RAG may provide the best result.</p><p>The blog attempts to describe some of the basic processes for storing and retrieving knowledge from LLMs. Followup blogs will describe different RAG strategies in more detail.</p><p></p><p>Pre-training</p><p>Fine-tuning</p><p>Retrieval Augmented Generation</p><p>Training duration</p><p>Days to weeks to months</p><p>Minutes to hours</p><p>Not required</p><p>Customisation</p><p>Requires large amount of domain training data  Can customise model architecture, size. tokenizer etc. Creates new “foundation” LLM model</p><p>Add domain-specific data  Tune for specific tasks.  Updates LLM model.</p><p>No model weights.  External information retrieval system can be tuned to align with LLM.  Prompt can be optimised for task performance.</p><p>Objective</p><p>Next-token prediction</p><p>Increase task performance</p><p>Increase task performance for specific set of domain documents</p><p>Expertise</p><p>High</p><p>Medium</p><p>Low</p><h2>Introduction to domain specific generative AI</h2><p>Generative AI technologies, built on large language models (LLMs), have substantially progressed our ability to develop tools for processing, comprehending, and generating text. Furthermore, these technologies have introduced an innovative information retrieval mechanism, wherein generative AI technologies directly respond to user queries using the stored (parametric) knowledge of the model.</p><p>However, it's important to note that the parametric knowledge of the model is a condensed representation of the entire training dataset. Thus, employing these technologies for a specific knowledge base or domain beyond the original training data does come with certain limitations, such as:</p><ul><li><p>The generative AI's responses might lack context or accuracy, as they won't have access to information that wasn't present in the training data.</p></li><li><p>There is potential for generating plausible-sounding but incorrect or misleading information (<a href="https://aclanthology.org/2021.findings-emnlp.320.pdf">hallucinations</a>).</p></li></ul><p>Different strategies exist to overcome these limitations, such as extending the original training data, fine-tuning the model, and integrating with an external source of domain-specific knowledge. These various approaches yield distinct behaviours and carry differing implementation costs.</p><h2>Strategies for integrating domain-specific knowledge into LLMs</h2><h3>Domain specific pre-training for LLMs</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt928972dfb98a9f9c/6a1711fc0e2e4915dc41a26a/85cabbc57c68146c67a4bf978e1dd5549a20923c-960x540.png" alt="" /><p>LLMs are pre-trained on huge corpora of data that represent a wide range of natural language use cases:</p><p>Model</p><p>Total dataset size</p><p>Data sources</p><p>Training cost</p><p>PaLM 540B</p><p>780 billion tokens</p><p>Social media conversations (multilingual) 50%; Filtered web pages (multilingual) 27%; Books (English) 13%; GitHub (code) 5%; Wikipedia (multilingual) 4%; News (English) 1%</p><p>8.4M TPU v2 hours</p><p>GPT-3</p><p>499 billion tokens</p><p>Common Crawl (filtered) 60%; WebText2 22%; Books1 8%; Books2 8%; Wikipedia 3%</p><p>0.8M GPU hours</p><p>LLaMA 2</p><p>2 trillion tokens</p><p>“mix of data from publicly available sources”</p><p>3.3M GPU hours </p><p>The costs of this pre-training step are substantial, and there’s a significant amount of work required to curate and prepare the datasets. Both of these tasks require a high level of technical expertise.</p><p>In addition, pre-training is only one step in creating the model. Typically, the models are then fine-tuned on a narrower dataset that is carefully curated and tailored for specific tasks. This process also typically involves human reviewers that rank and review possible model outputs to improve the model’s performance and safety. This adds further complexity and cost to the process.</p><p>Examples of this approach applied to specific domains include:</p><ul><li><p><a href="https://www.biorxiv.org/content/10.1101/2022.07.20.500902v1.full.pdf">ESMFold</a>, <a href="https://arxiv.org/pdf/2206.13517.pdf">ProGen2</a> and others - LLM for protein sequences: protein sequences can be represented using language-like sequences but are not covered by natural language models</p></li><li><p><a href="https://arxiv.org/pdf/2211.09085.pdf">Galactica</a> - LLM for science: trained exclusively on a large collection of scientific datasets, and includes special processing to handle scientific notations</p></li><li><p><a href="https://arxiv.org/pdf/2303.17564.pdf">BloombergGPT</a> - LLM for finance: trained on 51% financial data, 49% public datasets</p></li><li><p><a href="https://arxiv.org/pdf/2305.06161.pdf">StarCoder</a> - LLM for code: trained on 6.4TB of permissively licensed source code in 384 programming languages, and included 54 GB of GitHub issues and repository-level metadata</p></li></ul><p>The domain-specific models generally outperform generalist models within their respective domains, with the most significant improvements observed in domains that differ significantly from natural language (such as protein sequences and code). However, for knowledge-intensive tasks, these domain-specific models suffer from the same limitations due to their reliance on parametric knowledge. Therefore, while these models can understand the relationships and structure of the domain more effectively, they are still prone to inaccuracies and hallucinations.</p><h3>Domain specific fine-tuning for LLMs</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6649b2195ef435cb/6a1711fde8fbce8a4e39fd73/f9c42e2b3fae929312f17d429fea1c11e44357f9-960x540.png" alt="" /><p>Fine-tuning for LLMs involves training a pre-trained model on a specific task or domain to enhance its performance in that area. It adapts the model's knowledge to a narrower context by updating its parameters using task-specific data, while retaining its general language understanding gained during pre-training. This approach optimises the model for specific tasks, saving significant time compared to training from scratch.</p><h4>Examples</h4><ul><li><p><a href="https://crfm.stanford.edu/2023/03/13/alpaca.html">Alpaca</a> - fine-tuned LLaMA-7B model that behaves qualitatively similarly to OpenAI’s GPT-3.5</p></li><li><p><a href="https://www.stochastic.ai/blog/xfinance-vs-bloomberg-gpt">xFinance</a> - fine-tuned LLaMA-13B model for financial-specific tasks. Reportedly outperforms BloombergGPT</p></li><li><p><a href="https://arxiv.org/pdf/2303.14070.pdf">ChatDoctor</a> - fine-tuned LLaMA-7B model for medical chat.</p></li><li><p><a href="https://huggingface.co/jinaai/falcon-40b-code-alpaca">falcon-40b-code-alpaca</a> - fine-tuned falcon-40b model for code generation from natural language</p></li></ul><h4>Costs: fine-tuning vs. pre-training LLMs</h4><p>Costs for fine-tuning are significantly smaller than for pre-training. In addition, novel methods such as parameter-efficient fine-tuning (<a href="https://github.com/huggingface/peft">PEFT</a>) methods (e.g. <a href="https://arxiv.org/pdf/2106.09685.pdf">LoRA</a>, adapters, prompt tuning, and in-context learning as described above) enable very efficient adaptation of pre-trained language models (PLMs) to various downstream applications without fine-tuning all the model's parameters. For example,</p><p>Model</p><p>Fine-tuning method</p><p>Fine-tuning dataset</p><p>Cost</p><p>Alpaca </p><p>Self-Instruct</p><p>52K unique instructions and the corresponding outputs</p><p>3 hours on 8 80GB A100s:24 GPU hours</p><p>xFinance</p><p>Unsupervised fine-tuning and instruction fine-tuning using xTuring library</p><p>493M token text dataset; 82K instruction dataset</p><p>25 hours on 8 A100 80GB GPUs:200 GPU hours</p><p>ChatDoctor</p><p>Self-Instruct</p><p>110K patient-doctor interactions</p><p>3 hours on 6 A100 GPUS: 18 GPU hours</p><p>falcon-40b-code-alpaca</p><p>Self-Instruct</p><p>52K instruction dataset; 20K instruction-input-code triplets</p><p>4 hours on 4 A100 80GB GPUs: 16 GPU hours</p><p>Similar to domain-specific pre-trained models, these models typically exhibit better performance within their respective domains, yet they still face the limitations associated with parametric knowledge.</p><h3>Enhancing LLMs with Retrieval Augmented Generation (RAG)</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltabcd8f29eadaa390/6a1711ffb0367d9da172be36/1dde84a8973bbf045dc9024ee5619ee5b7459a9f-960x540.png" alt="" /><p>LLMs store factual knowledge in their parameters, and their ability to access and precisely manipulate this knowledge is still limited. This can lead to LLMs providing non-factual but seemingly plausible predictions (hallucinations) - particularly for unpopular questions. Additionally, providing references for their decisions and updating their knowledge efficiently remain open research problems.</p><p>A general purpose recipe to address these limitations is RAG, where the LLM's parametric knowledge is grounded with external or non-parametric knowledge from an information retrieval system. This knowledge is passed as additional context in the prompt to the LLM and specific instructions are given to the LLM on how to use this contextual information. This keeps it more inline with the discussion so far about parametric knowledge. The advantages of this approach are:</p><ul><li><p>Unlike fine-tuning and pre-training, LLM parameters do not change and so there are no training costs</p></li><li><p>Expertise required to simple implementation is low (although more advanced strategies exist)</p></li><li><p>Response can be tightly constrained to context returned from the information retrieval system, limiting hallucinations</p></li><li><p>Smaller task specific LLMs can be used - as the LLM is being used for a specific task rather than a knowledge base.</p></li><li><p>Knowledge base is easily updatable as it requires no changes to the LLM</p></li><li><p>Responses can cite sources for human verification and link outs</p></li></ul><p>Strategies to combine this non-parametric knowledge (i.e. retrieved text) with an LLM’s parametric knowledge is an <a href="https://acl2023-retrieval-lm.github.io/slides/3-architecture.pdf">active area of research</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt04a78b28c5eab515/6a171201cdacbf9af27d2b12/beec836f570eaf9a6a5945ee869e1d37e89e3190-1014x463.png" alt="" /><p>Some of these approaches involve modifying the LLM in conjunction with the retrieval strategy and so can not be classified as distinctly as the definitions in this blog. We will dive into more details in further blogs.</p><h2>Example</h2><p>In a simple example, we utilised a fine-tuned LLaMA2 13B model, which was based on the information from this <a href="https://towardsdatascience.com/leveraging-qlora-for-fine-tuning-of-task-fine-tuned-models-without-catastrophic-forgetting-d9bcd594cff4">blog</a>. This model underwent fine-tuning using AWS blog posts published after the LLaMA2 pre-training and fine-tuning data cutoff date, specifically those from July 23rd, 2023. We also ingested these documents into a Elasticsearch and established a simple RAG pipeline. In this pipeline, model responses are generated based on the retrieved documents serving as context. Red highlights indicate incorrect responses, and blue highlights correct responses.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt04470fdb73579b8d/6a171203839dfac0f5dcfffd/55da35f1cce6f015d38b1e49f16c47d12da95f74-960x540.png" alt="" /><p>However, it's important to note that this is just a single example and does not constitute a comprehensive evaluation of fine-tuning versus RAG, but provides an example of fine-tuning before <a href="https://www.anyscale.com/blog/fine-tuning-is-for-form-not-facts">useful for form, not facts</a>.. We plan to conduct more thorough comparisons in upcoming blogs.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/domain-specific-generative-ai-pre-training-fine-tuning-rag</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/domain-specific-generative-ai-pre-training-fine-tuning-rag</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Steve Dodson]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltab7c62bf91cd741e/6a1712042b835f6332f4b32f/ab358b8ccb26b9fe4ee4f909fd7cd308e0e182f9-1024x1024.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 22 Nov 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elastic Support Hub starts using semantic search]]></title>
    <description><![CDATA[We transitioned our Support Hub to semantic search, a more advanced search method that understands user intent rather than relying on keywords. This transition helps provide customers with more relevant search results in Elastic's support content.]]></description>
    <content:encoded><![CDATA[<p>We’re excited to share a recent enhancement made to the Elastic Support Hub: it’s now powered by semantic search!</p><p>But before we go into more detail on the changes we made to the Elastic® Support Hub and its impact on our customers, it's important that we take a moment to explain the concept of semantic search. At its core, semantic search is a method of search that uses AI to return more relevant search results. Take a look at this quick video explaining the concept:</p><p>As shown in the video, semantic search matches the <em>intent</em> of what the user searches to the content available rather than the <em>words</em>. You can read more about the AI behind it on our blog, <a href="https://www.elastic.co/search-labs/may-2023-launch-sparse-encoder-ai-model">Introducing Elastic Learned Sparse Encoder: Elastic’s AI model for semantic search</a>. The rest of this blog tells our story about moving the Elastic <a href="https://support.elastic.co/home">Support Hub</a> to semantic search.</p><h2>Why did we make this change?</h2><p>All technology news these days seems to have something to do with <a href="https://www.elastic.co/what-is/large-language-models">large language models</a> and <a href="https://www.elastic.co/what-is/generative-ai">generative AI</a>. Elastic is leading the charge with its <a href="https://www.elastic.co/elasticsearch/vector-database">vector database capabilities</a> and built-in natural language models. It makes sense that we should build our supporting applications on the same bleeding edge that our product lives on. By making this change now, we can provide feedback to our product development teams and make the product better for everyone.</p><h2>Biggest takeaway configuring semantic search</h2><p>As with most new technology innovations, it requires tearing down, replacing older code, and potentially updating underlying architecture. Our internal app development team faced these challenges head-on, and we are now in a much better position to iterate on any of Elasticsearch®’s new features. From our teams' point of view, there were two significant features that stood out in the setup process:</p><p>1. Considering ELSER, Elastic’s proprietary transformer model for semantic search, is a relatively new feature in Elasticsearch (8.8), our development team was happy to see a guided UI experience to enable Elasticsearch ingest pipelines with ELSER.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3ad9bab63a32532d/6a17d70d7b54f92cc48b3716/6d41f12865ca5fede4ce8cb41d6851b6ed69fa47-814x586.png" alt="" /><p>This allowed our developers to quickly add the necessary text expansion configuration to the ingestion pipeline that makes semantic search possible. This made the configuration experience much easier to get started and see results quicker.</p><p>2. A machine learning model like ELSER takes dedicated machine resources to run (minimum 4GB). Since we were already running on <a href="https://cloud.elastic.co/">Elastic Cloud</a>, we were able to enable dedicated machine learning (ML) nodes with autoscaling to accommodate our resource demands and see more consistent performance.</p><h2>Early evaluation of search results</h2><p>We are enabling various systems to help us to understand user queries, search results, and relevancy at scale. However, in our user testing, we can already see significant improvements in various queries. For example, we tested the phrase “How to index data into Elasticsearch” on both our standard full-text search and our new semantic search implementations.</p><p>Here is a side-by-side comparison of the two search methods.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt467faa649e4d2c87/6a17d70f1480093ce0b485b9/4249b79ed37ca62593e3a5466aeca92554d674c5-1440x692.png" alt="" /><p>While there isn’t a single article that explains all the ways you can index data (there are a lot), you can see how fundamentally different these results are. For full-text search, we have a mix of guides, troubleshooting articles, and a blog with matched keywords, but none of them answer the question of “how.” Or to say it differently, text search didn't capture the meaning (semantically) of the query and did its best to match keywords.</p><p>For the semantic search results, you can see blogs that generally relate to the indexing of data. What is even more interesting is the fourth returned result of “How to ingest data into Elasticsearch Service” as the term ingest is actually more relevant to the process of adding data to an index. Elastic’s out-of-the-box transformer model picked up on the semantic meaning of adding data to an index and returned more relevant results regardless of the exact keywords.</p><h2>What’s next?</h2><p>While we see this as a gigantic leap forward in our ability to provide customers with relevant search results, we know our work is not done. Over time, we will evaluate the data we have on terms searched, results, and articles read. This data will allow us to add <a href="https://www.elastic.co/guide/en/app-search/current/synonyms-guide.html">synonyms</a> and configure appropriate <a href="https://www.elastic.co/guide/en/app-search/current/relevance-tuning-guide.html">weights and boosts</a> to give you, our customers, the best experience when searching for Elastic content on <a href="https://support.elastic.co/home">support.elastic.co</a>.</p><p><a href="https://www.elastic.co/blog/elastic-knowledge-center-support-hub">&gt;&gt; Learn more about all the Support Hub has to offer.</a></p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p><p><em>In this blog post, we may have used or referred to third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch, ESRE, Elasticsearch Relevance Engine and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-support-hub-uses-semantic-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-support-hub-uses-semantic-search</guid>
    <category><![CDATA[Inside Elastic]]></category>
    <dc:creator><![CDATA[Chris Blaisure]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbeb5dbab80ffb03d/6a17d7104202290f4229f449/7fce63922298d2a0fcc8a5f292db74d2e0fae0a1-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 16 Nov 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Bringing maximum-inner-product into Lucene]]></title>
    <description><![CDATA[Explore how we brought maximum-inner-product into Lucene and the investigations undertaken to ensure its support.]]></description>
    <content:encoded><![CDATA[<p>Currently Lucene restricts <code>dot_product</code> to be only used over normalized vectors. Normalization forces all <a href="https://en.wikipedia.org/wiki/Magnitude_(mathematics)#Euclidean_vector_space">vector magnitudes</a> to equal one. While for many cases this is acceptable, it can cause relevancy issues for certain data sets. A prime example are embeddings built by <a href="https://cohere.com/">Cohere</a>. Their vectors use magnitudes to provide more relevant information.</p><p>So, why not allow non-normalized vectors in dot-product and thus enable maximum-inner-product? What's the big deal?</p><h2>Negative values and Lucene optimizations</h2><p>Lucene requires non-negative scores, so that matching one more clause in a disjunctive query can only make the score greater, not lower. This is actually important for dynamic pruning optimizations such as <a href="https://www.elastic.co/blog/faster-retrieval-of-top-hits-in-elasticsearch-with-block-max-wand">block-max WAND</a>, whose efficiency is largely defeated if some clauses may produce negative scores. How does this requirement affect non-normalized vectors?</p><p>In the normalized case, all vectors are on a unit sphere. This allows handling negative scores to be simple scaling.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9a1c9f6f1c8eebd/6a170da3cdacbf64197d2a61/b6ddddc9103479474c3bdb5f3b5d0ef0491fee7f-1179x1209.png" alt="Normalized Vectors" /><p>Figure 1: Two opposite, two dimensional vectors in a 2d unit sphere (e.g. a unit circle). When calculating the dot-product here, the worst it can be is -1 = [1, 0] * [-1, 0]. Lucene accounts for this by adding 1 to the result.</p><p>With vectors retaining their magnitude, the range of possible values is unknown.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7fd8462a258df0ec/6a170da41949f744c8e7aaac/0a8549e941e3b4dec79905e00e22e04b38b469c7-1181x1209.png" alt="Normalized Vectors" /><p>Figure 2: When calculating the dot-product for these vectors <code>[2, 2] \* [-5, -5] = -20</code></p><p>To allow Lucene to utilize blockMax WAND with non-normalized vectors, we must scale the scores. This is a fairly simple solution. Lucene will scale non-normalize vectors with a simple piecewise function:</p>if (dotProduct &lt; 0) {
  return 1 / (1 + -1 * dotProduct);
}
return dotProduct + 1;
<p>Now all negative scores are between 0-1, and all positives are scaled above 1. This still ensures that higher values mean better matches and removes negative scores. Simple enough, but this is not the final hurdle.</p><h2>The triangle problem</h2><p>Maximum-inner-product doesn't follow the same rules as of <a href="https://en.wikipedia.org/wiki/Euclidean_space">simple euclidean spaces</a>. The simple assumed knowledge of the <a href="https://en.wikipedia.org/wiki/Triangle_inequality">triangle inequality</a> is abandoned. Unintuitively, a vector is no longer nearest to itself. This can be troubling. Lucene’s underlying index structure for vectors is Hierarchical Navigable Small World (HNSW). This being a graph based algorithm, it might rely on euclidean space assumptions. Or would exploring the graph be too slow in non-euclidean space?</p><p>Some research has indicated that a transformation into <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/XboxInnerProduct.pdf">euclidean space is required for fast search</a>. Others have gone through the trouble of <a href="https://blog.vespa.ai/announcing-maximum-inner-product-search/">updating their vector storage</a> enforcing transformations into euclidean space.</p><p>This caused us to pause and dig deep into some data. The key question is this: does HNSW provide good recall and latency with maximum-inner-product search? While the original <a href="https://arxiv.org/pdf/1603.09320.pdf">HNSW paper</a> and <a href="http://boytsov.info/pubs/thesis_boytsov.pdf">other published research</a> indicate that it does, we needed to do our due diligence.</p><h2>Experiments and results: Maximum-inner-product in Lucene</h2><p>The experiments we ran were simple. All of the experiments are over real data sets or slightly modified real data sets. This is vital for benchmarking as modern neural networks create vectors that adhere to specific characteristics (<a href="https://arxiv.org/pdf/1908.10396.pdf">see discussion in section 7.8 of this paper</a>). We measured latency (in milliseconds) vs. recall over non-normalized vectors. Comparing the numbers with the same measurements but with a euclidean space transformation. In each case, the vectors were indexed into Lucene’s HNSW implementation and we measured for 1000 iterations of queries. Three individual cases were considered for each dataset: data inserted ordered by magnitude (lesser to greater), data inserted in a random order, and data inserted in reverse order (greater to lesser).</p><p>Here are some results from real datasets from Cohere:</p><p>Figure 3: Here are results for the Cohere’s Multilingual model embedding wikipedia articles. <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-en-embeddings">Available on HuggingFace</a>. The first 100k documents were indexed and tested.</p><p>Figure 4: This is a mixture of Cohere’s English and Japanese embeddings over wikipedia. <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-en-embeddings">Both</a> <a href="https://huggingface.co/datasets/Cohere/wikipedia-22-12-ja-embeddings">datasets</a> are available on HuggingFace.</p><p>We also tested against some synthetic datasets to ensure our rigor. We created a data set with <a href="https://huggingface.co/intfloat/e5-small-v2">e5-small-v2</a> and scaled the vector's magnitudes by different statistical distributions. For brevity, I will only show two distributions.</p><p>Figure 5: <a href="https://en.wikipedia.org/wiki/Pareto_distribution">Pareto distribution</a> of magnitudes. A pareto distribution has a “fat tail” meaning there is a portion of the distribution with a much larger magnitude than others.</p><p>Figure 6: <a href="https://en.wikipedia.org/wiki/Gamma_distribution">Gamma distribution</a> of magnitudes. This distribution can have high variance and makes it unique in our experiments.</p><p>In all our experiments, the only time where the transformation seemed warranted was the synthetic dataset created with the gamma distribution. Even then, the vectors must be inserted in reverse order, largest magnitudes first, to justify the transformation. These are exceptional cases.</p><p>If you want to read about all the experiments, and about all the mistakes and improvements along the way, here is the <a href="https://github.com/apache/lucene/issues/12342">Lucene Github issue</a> with all the details (and mistakes along the way). Here’s one for open research and development!</p><h2>Conclusion</h2><p>This has been quite a journey requiring many investigations to make sure maximum-inner-product can be supported in Lucene. We believe the data speaks for itself. No significant transformations required or significant changes to Lucene. All this work will soon unlock maximum-inner-product support with Elasticsearch and allow models like the ones provided by Cohere to be first class citizens in the Elastic Stack.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/lucene-bringing-maximum-inner-product-to-lucene</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/lucene-bringing-maximum-inner-product-to-lucene</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt762b38f91bd71c8e/6a170db8b339d547bb76a048/368db71c500e72d20fe225fe44c2c40231e29765-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 01 Sep 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Demystifying ChatGPT & LLMs: Different methods for building AI search]]></title>
    <description><![CDATA[Explore the inner workings of ChatGPT and LLMs, and discover three effective approaches for building generative AI search experiences for specific domains.]]></description>
    <content:encoded><![CDATA[<h2>What is ChatGPT?</h2><p>First things first, <a href="https://chat.openai.com/">ChatGPT</a> is awesome! It can help you work more efficiently — from summarizing a 10,000-word document to providing a list of differentiations between competing products, as well as many other tasks.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta90b8f4b4113a42c/6a17120ca929cf7f2dae0afd/52ad8d748c52d89f59b012cb1903af1d6fbdef14-1440x698.png" alt="differences" /><p>ChatGPT is the best known <a href="https://www.elastic.co/what-is/large-language-models">large language model</a> (LLM) based on the transformer architecture. But there are other LLMs that you may have heard of, including BERT (Bidirectional Encoder Representation from Transformer), Bard (Language Model for Dialogue Applications), or LLaMA (LLM Meta AI). LLMs have multiple layers of neural networks that work together to analyze text and predict outputs. They’re trained with a left-to-right or bidirectional transformer that maximizes the probability of following and preceding words in context to figure out what might come next in a sentence. LLMs also have an attention mechanism that allows them to focus selectively on parts of text in order to identify the most relevant sections. For example, <em>Rex is adorable and he is a cat</em>. “He”, in this sentence, refers to “Cat” and “Rex.”</p><h2>Understanding Large Language Models (LLMs)</h2><p>Large Language Models are generally compared by the number of parameters — and bigger is better. The number of parameters is a measure of the size and the complexity of the model. The more parameters a model has, the more data it can process, learn from, and generate. However, having more parameters also means having more computational and memory resource demands. Parameters are learned or updated during the training process by using an optimization algorithm that tries to minimize the error or the loss between the predicted outputs and the actual outputs. By adjusting the parameters, the model can improve its performance and accuracy on a given task or domain.</p><h3>LLMs are expensive to train</h3><p>Modern LLMs have billions of parameters that are trained on trillions of tokens and cost millions of dollars. Training an LLM includes identifying a data set, making sure the data set is large enough for it to perform functions like a human, determining the network layer configurations, using supervised learning to learn the information in the data set, and finally, fine-tuning. Needless to say, retraining LLMs on domain specific data is also very expensive.</p><h2>How does a GPT model work?</h2><p>A Generative pre-trained transformer (GPT) model is a type of neural network that uses the transformer architecture to learn from large amounts of text data. The model has two main components: an encoder and a decoder. The encoder processes the input text and converts it into a sequence of vectors called embeddings that represent the meaning and context of each word/subword in numerics. However, the decoder generates the output text by predicting the next word in the sequence based on the embeddings and the previous words.</p><p>The GPT model uses a technique called “attention” to focus on the most relevant parts of the input and output texts and to capture long-range dependencies and relationships between words. The model is trained by using a large corpus of texts as both the input and the output and by minimizing the difference between the predicted words and the actual words. It can then be fine-tuned or adapted to specific tasks or domains by using smaller and more specialized data sets.</p><h3>Tokens</h3><p>Tokens are the basic units of text or code that an LLM uses to process and generate language. Tokens can be characters, words, subwords, or other segments of text or code, depending on the chosen tokenization method or scheme. They are assigned numerical values or identifiers and are arranged in sequences or vectors, then are fed into or outputted from the model. Tokenization is the process of splitting the input and output texts into smaller units that can be processed by the LLM models.</p><p>For example, the sentence “A quick brown fox jumps over a lazy dog” can be tokenized into the following tokens: “a,” “quick,” “brown,” “fox,” “jumps,” “over,” “a,” “lazy,” and “dog.”</p><h3>Embedding</h3><p><a href="https://www.elastic.co/blog/how-to-deploy-nlp-text-embeddings-and-vector-search">Embeddings</a> are vectors or arrays of numbers that represent the meaning and the context of the tokens that the model processes and generates. They are derived from the parameters of the model and are used to encode and decode the input and output texts. Embeddings help the model to understand the semantic and syntactic relationships between the tokens and to generate more relevant and coherent texts. They are essential components of the transformer architecture that GPT-based models use. They can also vary in size and dimension, depending on the model and the task.</p><p>At a minimum, pre-trained LLMs contain embedding for tens of thousands of words, tokens, and terms. For example, ChatGPT-3 has a vocabulary of 14,735,746 words and a dimension of 1,536. The following is from a small model called distilbert-based-uncased. Despite the fact that this is a small model, it is still 100s mb in size.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt88b7f78278b84915/6a17120dcf4f25b42ab2d281/55c5de07087f92b1a90de551934607458c02c318-740x778.png" alt="token embedding" /><h3>Transformer</h3><p>A transformer model is a neural network that learns context or meaning by tracking relationships in sequential data like the words in this sentence. In its simplest form, a transformer will take an input and predict an output. Within the transformer, there is an encoder stack and a decoder stack.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt58397107dde44443/6a17120f66c4f91cfaf8c14f/7d7fcacf75bb5ffa805974f6b100ba4bbc347614-840x800.png" alt="input output" /><p>Let’s dig into the encoder block and the decoder block. In the encoder block, there are two important components: the self-attention neural network and the feed-forward neural network.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4760e4d0298d2dbe/6a171210839dfa61e2dd0001/5a14e5ede835455ead80ec491b70149488bfb662-1060x500.png" alt="neural networks" /><p>The self-attention layer is crucial as it builds in the “understanding” of the current token from the previous words that are relevant to the current one. For example, “it” refers to the chicken in “the chicken crossed the road because it wants to know what the jokes are all about.”</p><p>The other important layer in an encoder is the feed-forward neural network (FFNN). FFNN predicts what word comes after the current token.</p><p>Moving on to the decoder side, the encoder-decoder attention layer stands out. The encoder-decoder layer focuses on relevant parts of the input sentence, taking into account the layer below it and the output of the encoder stack.</p><p>Putting it all together:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt714e963310a2bf78/6a171212a929cf8c05ae0b01/b8ee600d6538ba2bcd2a0ebe3f43df3d6f45e557-1440x792.png" alt="An abstract look at the transformer model" /><p>We will take an input, tokenize the input, and obtain token IDs of the tokens before converting them into embeddings for each token. From there, we will pass the embeddings into a transformer block. At the end of the process, the transformer will predict a series of output tokens. The following image provides a detailed look at the transformer model.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6ead6cabb4690bb0/6a17121367045bdda145c309/176cc78a831751e61fd809dbde671b4d1ef30f0b-789x421.png" alt="second transformer model" /><p>The decoder stack outputs a vector of floats. The linear layer projects the vector of floats produced by the stack of decoders into a larger vector called a logits vector. If the model has a 10,000-word vocabulary, then the linear layer maps the decoder output onto a 10,000 cell vector. The softmax layer turns those scores from the logit vector into probabilities — all positive — and adding up to 100%. The cell with the highest probability is chosen, and the word associated with it is produced as the output for this step.</p><h2>The challenges with ChatGPT and LLMs</h2><ul><li><p>They are trained on data that has no domain knowledge and could be out of date. For example, hallucinations are incorrect answers given as if they are correct and are common with LLMs.</p></li><li><p>The models on their own do not have a natural ability to apply or extract filters from the input. Examples include time, date, and geographical filters.</p></li><li><p>There is no access control on what document users can see.</p></li><li><p>There are serious privacy and sensitive data control concerns.</p></li><li><p>It is slow and very expensive to train on your own data and keep it up to date.</p></li><li><p>Response from ChatGPT or other LLMs can be slow. Usually, Elasticsearch would have millisecond query responses. With LLMs, it can take up to seconds to get a response. But this is expected as LLMs are performing complex tasks. Also, ChatGPT charges by the number of token processed. If you have a high velocity workload like Black Friday merchandise search for an ecommerce site, it can get very expensive very quickly. Not to mention, it probably won’t meet &lt;10ms query SLA.</p></li><li><p>It is difficult, if not impossible, to interpret how ChatGPT or other LLMs arrived at query results. Besides hallucinations, ChatGPT and otherLLMs may produce irrelevant responses that are difficult to determine how the model produced the erroneous answer.</p></li></ul><h2>Integration of Elasticsearch with LLMs</h2><p>Elasticsearch supports a bag of words and BM25 information retrieval approach, in addition to vector search through kNN and aNN natively (kNN is the exact nearest neighbor distance of all documents and aNN is the approximation). For aNN, Elasticsearch uses the HNSW (hierarchical navigable small world) algorithm for calculating approximate nearest neighbor distance. Elastic can mitigate many of the problems with LLMs while letting our users take advantage of all the good things ChatGPT and other LLMs can provide.</p><p>Elasticsearch can be used as a <a href="https://www.elastic.co/elasticsearch/vector-database">vector database</a>, and to perform hybrid retrieval across text and vector data. There are three patterns where Elasticsearch can provide clear benefits when used with LLMs:</p><ol><li><p>Provide context to your data and integrate with ChatGPT or other LLMs</p></li><li><p>Enable you to bring your own model (any 3rd party model)</p></li><li><p>Use the built-in Elastic Learned Sparse Encoder model</p></li></ol><h3>Method 1: Provide context to your data and integrate with ChatGPT or other LLMs</h3><p>The following depicts how to separate LLMs from your data while integrating with generative AI.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb5f80418923645f5/6a171215961e698110c4d027/f345f66991086bca713c737fba75e183bd6ac83e-1412x752.png" alt="Using Elasticsearch as a vector store and integrate with LLM" /><p>A customer can bring their own embedding generated by a LLM and ingest their data along with the embedding into Elasticsearch. Then, the customer can take the similarity search results from their own data stored in Elasticsearch (context to the user’s question) to ChatGPT or another LLM to construct natural-language based answers to their users.</p><p>Also, the newly released reciprocal rank fusion (RRF) allows users to perform hybrid search, which can combine and rank search results. For instance, the BM25 method can filter for the relevant documents along with vector search to provide the best documents. With RRF, customers can achieve best search results natively through Elasticsearch instead of through their own applications, which greatly reduces complexity and maintenance of their applications.</p><h3>Method 2: Bring your own model</h3><p>The recently announced <a href="https://www.elastic.co/enterprise-search/generative-ai">Elasticsearch Relevance Engine</a></p><p><a href="https://www.elastic.co/enterprise-search/generative-ai">TM</a><a href="https://www.elastic.co/enterprise-search/generative-ai"> (ESRE</a>TM<a href="https://www.elastic.co/enterprise-search/generative-ai">)</a> provides the capability to bring your own LLMs. This capability has been available for a while through machine learning. The Elasticsearch machine learning team has been scaffolding infrastructure for integrating transformer-based models. Starting with the <a href="https://www.elastic.co/blog/whats-new-elasticsearch-8-8-0">8.8 release</a>, you can ingest and query just like you would normally do in Elasticsearch through the search APIs. On top of that, you can use the hybrid search method with RRF, which provides even better relevance. As the models are managed and integrated into Elasticsearch, it reduces operation complexity while achieving the most relevant search results.</p><p></p><p>This approach would require the users to know what model would work well for their use case and a commercial relationship with Elastic.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltab30afdfbc480563/6a171216839dfa1873dd0005/78676dcf352fcb63238c82e031cb7e7c33deedad-962x473.png" alt="method 2" /><h3>Method 3: Use the built-in sparse encoder model</h3><p>Elastic Learned Sparse Encoder is the Elastic out-of-the-box language model that outperforms SPLADE (SParse Lexical AnD Expansion Model), which itself is a state-of-the-art model. Elastic Learned Sparse Encoder solves the vocabulary mismatch problem where a document may be relevant to a query but does not contain any terms that appear in the query. An example of the mismatch may be if we ask <em>“how have American corporations have assisted with Covid-19 efforts”</em>, then manufacturers of ventilators may not appear in the query results.</p><p>Elastic Learned Sparse Encoder is accessible just like other search endpoints via the text_expansion query. Elastic Learned Sparse Encoder enables our user to begin the state-of-the-art generative AI search with a click and yield immediate results. Elastic Learned Sparse Encoder is also an Elastic commercial feature.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltebfe651a5fccbddb/6a17121847d49c4a2d2d8b2e/47b63a2a851bc3b72fefd204397e99f1e102c762-1040x400.png" alt="method 3" /><p>Here are some benchmark results using the BEIR benchmark. We used several standardized data sets (horizontal axis) and applied different retrieval metaphors (vertical axis). As you can see, a combination of BM25 and our Learned Sparse Encoder using RRF, returns the best relevance scores. These scores with RRF beat the SPLADE model and our Learned Sparse Encoder model when considered by itself. We published more details on <a href="https://www.elastic.co/blog/may-2023-launch-information-retrieval-elasticsearch-ai-model">our blog here</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b33813737f12d7a/6a17121947d49c27462d8b32/152565c3ba0986e8e81ca9e473da66e37e6a3be3-1440x549.png" alt="Elastic Learned Sparse Encoder compared to other popular retrieval methods (source Elasticsearch)" /><h2>Terms and definitions</h2><h3>Neural Network (NN)</h3><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd94a58379948c9ea/6a17121b839dfac075dd0009/d78d42ffba46112457ecf72bdbedb93618c0fbd4-351x349.png" alt="neural networks chart" /><p>Each node is a neuron. Think of each individual node as its own linear regression model, composed of input data, weights, a bias (or threshold), and an output. The math representation may look like:</p><p>∑wixi + bias = w1x1 + w2x2 + w3x3 + bias</p><p>output = f(x) = 1 if ∑w1x1 + b&gt;= 0; 0 if ∑w1x1 + b &lt; 0</p><p>Once an input layer is determined, weights (w) are assigned. These weights help determine the importance of any given variable with larger ones contributing more significantly to the output compared to other inputs. All inputs are then multiplied by their respective weights and then summed. Afterward, the output is passed through an activation function, which determines the output. If that output exceeds a given threshold, it activates the node and passes data to the next layer in the network. This results in the output of one node becoming the input of the next node. This process of passing data from one layer to the next layer is a feed-forward network. This is just one type of NNs.</p><h3>LLM parameters</h3><p><em>Weights</em> are numerical values that define the strength of connections between neurons across different layers in the model. <em>Biases</em> are additional numerical values that are added to the weighted sum of inputs before being passed through an activation function.</p><h3>SPLADE</h3><p><a href="https://arxiv.org/abs/2107.05720">SPLADE</a> is a late interaction model. The idea behind SPLADE models is that using a pre-trained language model like BERT can identify connections between words and use the knowledge to enhance sparse vector embedding. You would use this when you have a document that covers a wide range of topics, such as a Wikipedia article about a WWII movie — it contains the plot, the actors, the history, and the studio that released the film.</p><p>With embedding retrieval techniques alone, the relevance of the document to queries becomes an issue because the document can be projected onto a large number of dimensions and render it close to none of the queries. SPLADE solves the problem by combining all token-level probability distributions into a single distribution that tells us the relevance of every token in the vocabulary to our input sentence, similar to the BM25 method. Elastic Learned Sparse Encoder is the Elastic version of the SPLADE model.</p><h3>RRF</h3><p>RRF is a hybrid search query that normalizes and combines multiple search result sets with different relevant indicators into a single result set. Based on our own testing, combining RRF (BM25 + Elastic Learned Sparse Encoder) produces the best search relevance.</p><h2>Wrap up</h2><p>By combining the creative capabilities of technologies, such as ChatGPT, and the business context of proprietary data, we can truly transform how customers, employees, and organizations search.</p><p>Retrieval augmented generation (RAG) bridges the gap between large language models that power generative AI and private data sources. Well-known limitations of large language models can be addressed with context-based retrieval, enabling you to build deeply engaging search.</p> <ul><li><p><a href="https://elastic.co/elasticsearch/vector-database">Use Elasticsearch as your vector database</a></p></li><li><p><a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a></p></li><li><p><a href="https://www.elastic.co/enterprise-search/generative-ai">Generative AI search tools for developers</a></p></li></ul><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p><p><em>In this blog post, we may have used or referred to third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch, ESRE, Elasticsearch Relevance Engine and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/demystifying-chatgpt-methods-building-ai-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/demystifying-chatgpt-methods-building-ai-search</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Sherry Ger]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt537d387cb7738d7f/6a17121dcdacbf5b4b7d2b1a/2356f6d43bbd976157addf019223b1a424cd6093-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 28 Jul 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Generative AI using Elastic and Amazon SageMaker JumpStart]]></title>
    <description><![CDATA[Learn how to build a generative artificial intelligence (GAI) solution with Amazon SageMaker JumpStart, Elastic, and Hugging Face open source LLMs using the sample implementation provided in this post and a data set relevant to your business.]]></description>
    <content:encoded><![CDATA[<p>In the rapidly advancing world of artificial intelligence, one of the most intriguing and transformative developments is <a href="https://www.elastic.co/what-is/generative-ai">generative artificial intelligence (GAI)</a>. GAI represents a significant leap forward in AI capabilities, enabling machines to generate original and creative content across various domains including conversations, stories, images, videos, and music. Enterprises seek not only top-performing infrastructure, but also a secure platform to harness the power of GAI without compromising their sensitive data and intellectual property. <a href="https://www.elastic.co/what-is/large-language-models">Large language models (LLMs)</a> strive to understand and produce text that resembles human language, utilizing the structure, meaning, and context of natural language.</p><p>Elastic and Amazon Web Services (AWS) understand this pressing need and have taken the lead in offering cutting-edge solutions to meet these demands. Using Amazon SageMaker JumpStart combined with Elasticsearch’s capabilities, businesses can now confidently explore and adopt the most suitable AI models for their specific use cases while maintaining cost-effectiveness, security, and <a href="https://www.elastic.co/blog/privacy-first-ai-search-langchain-elasticsearch">privacy</a>.</p><p>Elasticsearch’s integration with advanced AI models further enhances its capabilities. By leveraging Elasticsearch’s <a href="https://www.elastic.co/enterprise-search/generative-ai">retrieval prowess</a>, LLMs can access the most relevant documents to provide accurate and up-to-date responses. This synergy between Elasticsearch and LLMs ensures that users receive contextually relevant and factual answers to their queries, setting a new standard for information retrieval and AI-powered assistance.</p><p>Elasticsearch is a scalable data store and vector database that offers a range of features to ensure exceptional search performance. It supports traditional keyword and text-based search using the BM25 algorithm, as well as AI-ready <a href="https://www.elastic.co/elasticsearch/vector-database">vector search</a> with exact match and approximate kNN (k-Nearest Neighbor) search capabilities. These advanced features allow Elasticsearch to retrieve highly relevant results for queries expressed in natural language. By combining traditional, vector, or hybrid search approaches, Elasticsearch delivers precise results, making it effortless for users to find the information customers need.</p><p>The Elasticsearch platform seamlessly incorporates robust machine learning and artificial intelligence capabilities directly into its solutions, empowering you to create highly sought-after applications and accomplish tasks with remarkable efficiency. By leveraging these advanced technologies, you can harness the full potential of Elasticsearch to deliver exceptional user experiences and expedite your workflow.</p><h2>Implementing RAG using Elasticsearch and open source LLM available in Amazon SageMaker JumpStart</h2><p>The solution below explains how to use Retrieval Augmented Generation (RAG) to enable GAI capabilities on domain-specific business data using Elasticsearch, Amazon SageMaker JumpStart, and your choice of open source LLMs.</p><h3>Solution overview</h3><p>We will start by reviewing the architecture diagram below. It explains how to get domain-specific responses from an LLM hosted in Amazon SageMaker JumpStart using enterprise data hosted in Elasticsearch using RAG.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt03d8da21895b6412/6a17f5455772623f5c1bcd54/f4cd684ae24b76ee5d4395394f30e0d22ce5a20a-1440x745.png" alt="Figure 1. RAG Architecture using Elasticsearch and Amazon SageMaker" /><p>We used the following AWS and third-party services:</p><ol><li><p><a href="https://aws.amazon.com/pm/sagemaker">Amazon SageMaker</a> and <a href="https://aws.amazon.com/sagemaker/jumpstart/">Amazon SageMaker JumpStart</a> for hosting the open source LLMs from Hugging Face</p></li><li><p>Falcon 40B Instruct and Flan-T5 XL LLM from Hugging Face</p></li><li><p>Elasticsearch for storing embeddings of the enterprise knowledge corpus and doing similarity search with user questions</p></li><li><p>Python, <a href="https://python.langchain.com/v0.1/docs/get_started/introduction/">LangChain</a>, and <a href="https://streamlit.io/">Streamlit</a> for building the RAG application</p></li><li><p>Amazon EC2 for hosting the Streamlit application</p></li><li><p><a href="https://aws.amazon.com/iam/">AWS Identity and Access Management</a> roles and policies for access management</p></li></ol><h3>Step-by-step explanation</h3><p><strong>Offline data ingestion:</strong></p><p>We ingest data from an enterprise knowledge corpus – for example this could be internal web pages, documents describing a company’s process, or corporate financial data.</p><ol><li><p>The documents are ingested using a web crawler or any other ingestion mechanism.</p></li><li><p>The textual content is converted into vectors and stored in a dense_vector field by a sentence transformer type ML model.</p></li></ol><p><strong>Real-time flow on user query:</strong></p><ol><li><p>The user provides a question via the Retrieval Augmented Generation (RAG) web application.</p></li><li><p>The RAG application generates a hybrid search request for Elasticsearch based on the user's question and sends it to Elasticsearch. The hybrid search request does a BM25 match on the text field and kNN search on the dense_vector field.</p></li><li><p>Elasticsearch returns the document body and source URL (if applicable) to the RAG application. The RAG application accepts only the top scored document.</p></li><li><p>The RAG application passes the top scored document body (context) along with user question (prompt) to the LLM hosted as Amazon SageMaker endpoint.</p></li><li><p>The Amazon SageMaker endpoint generates a textual response and sends it back to the RAG application.</p></li><li><p>The RAG application performs any required post processing tasks. For example, it adds a source url to the response generated from the LLM. The user views the response in the web application.</p></li></ol><p>Let’s now look at a few setup steps and a few implementation steps to create a working search solution:</p><p><strong>Setup steps:</strong></p><ol><li><p><strong>Sign up</strong> for a free trial of an Elasticsearch cluster with <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">Elastic Cloud</a>.</p></li><li><p><strong>Create a new deployment</strong> on AWS following <a href="https://www.elastic.co/guide/en/cloud/current/ec-create-deployment.html">these steps</a>.</p></li><li><p><strong>Add a new machine learning node</strong> following the steps below. This will enable you to run machine learning models in your deployment.</p></li><li><p>Click on <strong>Edit</strong> under Deployment Name in the left navigation bar.</p></li><li><p>Scroll down to the Machine Learning instances box.</p></li><li><p>Click <strong>+Add Capacity</strong>.</p></li><li><p>Under Size per zone, click and select <strong>2GB RAM</strong>.</p></li><li><p>Click on <strong>Save</strong> and then <strong>Confirm</strong>.</p></li><li><p><strong>Reset and download the elastic user password</strong> following these <a href="https://www.elastic.co/guide/en/cloud/current/ec-password-reset.html">steps</a>.</p></li><li><p><strong>Copy the deployment ID</strong> from the Overview page under Deployment name.</p></li><li><p><strong>Load an embedding model into Elasticsearch.</strong> Here, we have used<a href="https://huggingface.co/sentence-transformers/all-distilroberta-v1"><strong>all-distilroberta-v1</strong></a> model hosted in the Hugging Face model hub. You can choose other sentence transformer types based on your case. Import this Python <strong>notebook</strong> <a href="https://github.com/Udayel/RAGElastic-LLM">here</a> in Amazon SageMaker and run it. Provide the <strong>Cloud Id</strong> , <strong>Elasticsearch username</strong> , and <strong>Elasticsearch password</strong> when prompted. This will download the model from Hugging Face, chunk it up, load it into Elasticsearch, and deploy the model onto the machine learning node of the Elasticsearch cluster.</p></li><li><p><strong>Create an Elasticsearch index</strong> by opening Kibana from the Elastic Cloud console and navigating to Enterprise Search - Overview. Click on <strong>Create an Elasticsearch Index</strong>. Choose <strong>Web Crawler</strong> as the Ingestion method. Enter a suitable Index name and click <strong>Create Index</strong>.</p></li><li><p><strong>Add an Inference Pipeline</strong> by clicking on <strong>Pipelines tab &gt; Copy</strong> and customizing it in the Ingest Pipeline Box. Click <strong>Add Inference Pipeline</strong> in the Machine Learning Inference Pipelines box. Enter the Name for the new pipeline. Select the trained Model loaded in step 6 and Select title as source field. Click <strong>Continue</strong> in two subsequent screens and click <strong>Create Pipeline</strong> at the Review stage.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt99f3fcd7de79819d/6a17f5471d1b834f2e93e59c/0aa8612be25d206f962fe81c9bff08769cad6b9f-1440x779.png" alt="Figure 2. Adding Inference Pipeline in Elasticsearch" /><ol><li><p><strong>Update the mapping for dense vector</strong> by clicking on <strong>Dev Tools</strong> and running the following code. This will enable you to run kNN search on the title field vectors. From Elasticsearch version 8.8+, this step will be handled automatically.</p></li></ol>POST search-elastic-docs/_mapping
{
 "properties": {
   "title-vector": {
     "type": "dense_vector",
     "dims": 768,
     "index": true,
     "similarity": "dot_product"
   }
 }
}
<ol><li><p><strong>Configure web crawler</strong> to crawl Elastic Docs (you can replace this with your Enterprise Domain corpus). Click on the relevant index under Available indices. Click on the <strong>Manage Domains</strong> tab. Click <strong>Add domain</strong>. Enter <a href="https://www.elastic.co/guide/en"><strong>https://www.elastic.co/guide/en</strong></a> and click <strong>Validate Domain</strong>. Click <strong>Add domain</strong> and then <strong>Add Crawl rules</strong>. Add the following rules. Click <strong>Crawl</strong> and then <strong>Crawl all domains on this index</strong>. This will start Elasticsearch’s web crawler and it will crawl the targeted documents, generate vectors for the title field, and index the document and vector.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfbdf843267a3291d/6a17f548a2929920f6d02dd7/51d6e71a3562a1f1e07d64ab40e192e9c0342a09-1440x834.png" alt="Figure 3. Adding Web Crawling rules in Elasticsearch" /><p>The <strong>implementation steps</strong> for instantiating the solution presented in this post are as follows:</p><ol><li><p><strong>Choose your LLM.</strong> Amazon SageMaker JumpStart offers a wide selection of proprietary and publicly available foundation models from various model providers. Log in to Amazon SageMaker Studio, open Amazon SageMaker JumpStart, and search for your preferred Foundation model. Please find the list of models available for each task <a href="https://aws.amazon.com/sagemaker/jumpstart/?sagemaker-data-wrangler-whats-new.sort-by=item.additionalFields.postDateTime&amp;sagemaker-data-wrangler-whats-new.sort-order=desc">here</a>.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt453df8cf0664bb04/6a17f54afbc5f8222a491c1f/86ed5cadd37078d41237502fc1cc3438446ada6c-1440x624.png" alt="Figure 4. LLMs in Amazon SageMaker JumpStart" /><ol><li><p><strong>Deploy your LLM.</strong> Amazon SageMaker JumpStart studio also provides a no-code interface to deploy the model. You can easily deploy a model with few clicks. After the deployment is successful, copy the Endpoint Name.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c2b995cb1db4810/6a17f54b6df731cb540a1073/d163b7593a40d7cff7170dd4aba3082227aea73b-1440x627.png" alt="Figure 5. Deploying LLMs using Amazon SageMaker JumpStart Console" /><ol><li><p><strong>Download and set up the RAG Application.</strong> Launch an EC2 instance and clone the code from this <a href="https://github.com/Udayel/RAGElastic-LLM">GitHub link</a>. Set up a virtual environment following <a href="https://docs.python.org/3/library/venv.html">these steps</a>. Install the required Python libraries by running the command pip install -r requirements.txt. Update the config.sh file with the following:</p></li><li><p>ES_CLOUD_ID: Elastic Cloud Deployment ID</p></li><li><p>ES_USERNAME: Elasticsearch Cluster User</p></li><li><p>ES_PASSWORD: Elasticsearch User password</p></li><li><p>FLAN_T5_ENDPOINT: Amazon SageMaker Endpoint Name pointing to Flan T5</p></li><li><p>FALCON_40B_ENDPOINT: Amazon SageMaker Endpoint Name pointing to Falcon 40B</p></li><li><p>AWS_REGION: AWS Region</p></li><li><p><strong>Run the application</strong> using the command streamlit run rag_elastic_aws.py. This will start a web browser and the url will be printed to the command line.</p></li><li><p><strong>Response of LLM without context.</strong></p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt645d15fec3945281/6a17f54daf47b60bf0cde0ff/633edd0c512231b96badd2586aafec5d9071fb3a-1440x627.png" alt="Figure 6. Sample response of LLM without context" /><ol><li><p><strong>Response of LLM with context</strong> derived from Elasticsearch.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltee94315b36674a22/6a17f54ebe6086573b0048f9/6c326977f60b70c4711429abf0a5e82f36713402-1440x630.png" alt="Figure 7. Sample response of LLM with domain-specific context" /><h2>Conclusion</h2><p>In this post, we showed you how to create a Retrieval Augmented Generation-based search application using a combination of Elasticsearch, Amazon SageMaker JumpStart, open-source LLMs from Hugging Face, and open source Python packages like LangChain and Streamlit.</p><p>Learn more by exploring <a href="https://aws.amazon.com/sagemaker/jumpstart/">JumpStart</a>, <a href="https://aws.amazon.com/bedrock/titan/">Amazon Titan</a> models, <a href="https://aws.amazon.com/bedrock/">Amazon Bedrock</a>, and <a href="https://www.elastic.co/">Elastic</a> to build a solution using the sample implementation provided in this post and a data set relevant to your business.</p><p>Or, start your own <a href="https://aws.amazon.com/marketplace/pp/prodview-voru33wi6xs7k?trk=5fbc596b-6d2a-433a-8333-0bd1f28e84da&amp;sc_channel=el">7-day free trial</a> by signing up via <a href="https://aws.amazon.com/marketplace/pp/prodview-voru33wi6xs7k?trk=d54b31eb-671c-49ba-88bb-7a1106421dfa%E2%89%BBchannel=el">AWS Marketplace</a> and quickly spin up a deployment in minutes on any of the <a href="https://www.elastic.co/guide/en/cloud/current/ec-reference-regions.html#ec_amazon_web_services_aws_regions">Elastic Cloud regions on AWS</a> around the world. Your AWS Marketplace purchase of Elastic will be included in your monthly consolidated billing statement and will draw against your committed spend with AWS.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p><p><em>In this blog post, we may have used or referred to third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch, ESRE, Elasticsearch Relevance Engine and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/generative-ai-using-elastic-amazon-sagemaker-jumpstart</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/generative-ai-using-elastic-amazon-sagemaker-jumpstart</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Udayasimha Theepireddy,Ayan Ray]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9f7119562f10d5a/6a17f5507f6f151debc09ca3/f10a706b66a83e9df451ece25326cbcd10134e3c-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 25 Jul 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Enhancing chatbot capabilities with NLP and vector search in Elasticsearch]]></title>
    <description><![CDATA[Explore how vector search and NLP work to enhance chatbot capabilities and see how Elasticsearch facilitates the process.]]></description>
    <content:encoded><![CDATA[<p>Conversational interfaces have been around for a while and are becoming increasingly popular as a means of assisting with various tasks, such as customer service, information retrieval, and task automation. Typically accessed through voice assistants or messaging apps, these interfaces simulate human conversation in order to help users resolve their queries more efficiently.</p><p>As technology advances, chatbots are used to handle more complex tasks — and quickly — while still providing a personalized experience for users. Natural language processing (NLP) enables chatbots to process the user's language, identifies the intent behind their message, and extracts relevant information from it. For example, Named Entity Recognition extracts key information in a text by classifying them into a set of categories. Sentiment Analysis identifies the emotional tone, and Question Answering the “answer” to a query. The goal of NLP is to enable algorithms to process human language and perform tasks that historically only humans were capable of, such as finding relevant passages among large amounts of text, summarizing text, and generating new, original content.</p><p>These advanced NLP capabilities are built upon a technology known as <a href="https://www.elastic.co/what-is/vector-search">vector search</a>. Elastic has native support for vector search, performing exact and approximate <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#knn-search">k-nearest neighbor (kNN) search</a>, and for NLP, enabling the use of custom or <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-model-ref.html#ml-nlp-model-ref">third-party models</a> directly in Elasticsearch.</p><p>In this blog post, we will explore how vector search and NLP work to enhance chatbot capabilities and demonstrate how Elasticsearch facilitates the process. Let's begin with a brief overview of vector search.</p><h2>Vector search</h2><p>Although humans can comprehend the meaning and context of written language, machines cannot do the same. This is where vectors come in. By converting text into vector representations (numerical representations of the meaning of the text), machines can overcome this limitation. Compared to a traditional search, instead of relying on keywords and lexical search based on frequencies, vectors enable the process of text data using operations defined for numerical values.</p><p>This allows vector search to locate data that shares similar concepts or contexts by using distances in the "embedding space" to represent similarity given a query vector. When the data is similar, the corresponding vectors will be alike.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt53a615a8ba0ac931/6a17d795fbc5f8b257491910/08542abf8108aace288745b1aca8579b476ddc1b-1440x618.png" alt="" /><p>Vector search is not only utilized in NLP applications, but it’s also used in various other domains where unstructured data is involved, including image and video processing.</p><p>In a chatbot flow, there can be several approaches to users' queries, and as a result, there are different ways to improve information retrieval for a better user experience. Since each alternative has its own set of advantages and possible disadvantages, it is essential to take into account the available data and resources, as well as the training time (when applicable) and expected accuracy. In the following section, we will cover these aspects for question-answering NLP models.</p><h2>Question-answering</h2><p>A question-answering (QA) model is a type of NLP model that is designed to answer questions asked in natural language. When users have questions that require inferring answers from multiple resources, without a pre-existing target answer available in the documents, generative QA models can be useful. However, these models can be computationally expensive and require large amounts of data for domain related training, which may make them less practical in some situations, even though this method can be particularly valuable to handle out-of-domain questions.</p><p>On the other hand, when users have questions on a specific topic, and the actual answer is present in the document, extractive QA models can be used. These models directly extract the answer from the source document, providing transparent and verifiable results, making them a more practical option for businesses or organizations that want to provide a simple and efficient way of answering questions.</p><p>The example below demonstrates the use of a pre-trained extractive QA model, <a href="https://huggingface.co/deepset/minilm-uncased-squad2">available on Hugging Face</a> and deployed into Elasticsearch, to extract answers from a given context:</p>POST _ml/trained_models/deepset__minilm-uncased-squad2/deployment/_infer
{
    "docs": [{"text_field": "Canvas is a data visualization and presentation application within Kibana. With Canvas, live data can be pulled directly from Elasticsearch and combined with colors, images, text, and other customized options to create dynamic, multi-page displays."}],
    "inference_config": {"question_answering": {"question": "What is Kibana Canvas?"}}
}


{
  "predicted_value": "a data visualization and presentation application",
  "start_offset": 10,
  "end_offset": 59,
  "prediction_probability": 0.28304219431376443
}
<p><a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-deploy-models.html">Deploy trained models.</a></p><p><a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-ner-example.html#ex-ner-ingest">Add a model to an inference ingest pipeline.</a></p><p>There are various ways to handle user queries and retrieve information, and using multiple language models and data sources can be an effective alternative when dealing with unstructured data. To illustrate this, we have an example of the data processing of a chatbot employed to respond to queries with answers considering data extracted from selected documents.</p><h2>Chatbot data processing: NLP and vector search</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta418a9c54bb16cf9/6a17d7975772624b371bca43/c2d1a2f110e937b1d3e5df0d5caac3c906c98fb0-1440x748.png" alt="" /><p>As shown above, the data processing for our chatbot can be divided into three parts:</p><ul><li><p><strong>Vector processing:</strong> This part converts documents into vector representations.</p></li><li><p><strong>User input processing:</strong> This part extracts relevant information from the user query and performs semantic search and hybrid retrieval.</p></li><li><p><strong>Optimization:</strong> This part includes monitoring and is crucial for ensuring the chatbot's reliability, optimal performance, and great user experience.</p></li></ul><h2>Vector processing</h2><p>For the <strong>processing</strong> part, the first step is to determine component parts of each document to then convert each element to a vector representation; these representations can be created for a wide range of data formats.</p><p>There are various methods that can be used to compute embeddings, including pre-trained models and libraries.</p><p>It's important to note that the effectiveness of search and retrieval on these representations depends on the existing data and the quality and relevance of the method used.</p><p>As the vectors are computed, they are stored in Elasticsearch with a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html">dense_vector</a> field type.</p>PUT &lt;target&gt;
{
  "mappings": {
    "properties": {
      "doc_part_vector": {
        "type": "dense_vector",
        "dims": 3
      },
      "doc_part" : {
        "type" : "keyword"
      }
    }
  }
}
<h2>Chatbot user input processing</h2><p>For the <strong>user</strong> part, after receiving a question, it's useful to extract all possible information from it before proceeding. This helps to understand the user's intention, and in this case, we are using a <a href="https://huggingface.co/dslim/bert-base-NER">Named Entity Recognition model (NER)</a> to assist with that. NER is the process of identifying and classifying named entities into predefined entity categories.</p>POST _ml/trained_models/dslim__bert-base-ner/deployment/_infer
{
  "docs": { "text_field": "How many people work for Elastic?"}
}


{
  "predicted_value": "How many people work for [Elastic](ORG&amp;Elastic)?",
  "entities": [
    {
      "entity": "Elastic",
      "class_name": "ORG",
      "class_probability": 0.4993975435876747,
      "start_pos": 25,
      "end_pos": 32
    }
  ]
}
<p>Although not a necessary step, by using structured data or the above or another NLP model result to categorize the user's query, we can restrict the kNN search using a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#knn-search-filter-example">filter</a>. This helps to improve performance and accuracy by reducing the amount of data that needs to be processed.</p>    "filter": {
      "term": {
        "org": "Elastic"
      }
    }
<h2>Semantic search and hybrid retrieval</h2><p>Since the prompt originates from user queries and the chatbot needs to process human language with its variability and ambiguity, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#semantic-search">semantic search</a> is a great fit. In Elasticsearch, you can perform semantic search in a single step by passing the query string and the ID of the <a href="https://huggingface.co/sentence-transformers/msmarco-MiniLM-L-12-v3">embedding model</a> into a query_vector_builder object. This will vectorize the query and perform kNN <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html">search</a> to retrieve top k matches that are closest in meaning to the query:</p>POST /&lt;target&gt;/_search
{
  "knn": {
    "field": "doc_part_vector",
    "k": 5,
    "num_candidates": 20,
    "query_vector_builder": {
      "text_embedding": {
        "model_id": "&lt;text-embedding-model-id&gt;",
        "model_text": "&lt;query_string&gt;"
      }
    }
  }
 }
<p><a href="https://www.elastic.co/guide/en/machine-learning/8.7/ml-nlp-text-emb-vector-search-example.html">End-to-end example: How to deploy a text embedding model and use it for semantic search.</a> Elasticsearch uses the Lucene implementation of the Okapi BM25, a <strong>sparse model</strong> , to rank text queries for relevance, while <strong>dense models</strong> are used for <strong>semantic search</strong>. To <strong>combine</strong> the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#_combine_approximate_knn_with_other_features"><strong>strengths of both</strong></a> <strong>,</strong> vector matches and matches obtained from the text query, you can perform a <strong>hybrid retrieval</strong> :</p>POST &lt;target&gt;/_search
{
  "query": {
          "match": {
            "content": {
              "query": "&lt;query_string&gt;"
            }
        }
  },
  "knn": {
    "field": "doc_part_vector",
    "query_vector_builder": {
      "text_embedding": {
    "model_id": "&lt;text-embedding-model-id&gt;",
     "model_text": "&lt;query_string&gt;"
      }
    },
    "filter": {
      "term": {
        "org": "Elastic"
      }
    }
  }
}
<h3>Combining both sparse and dense models often yields the best results</h3><p>Sparse models generally perform better on short queries and specific terminologies, while dense models leverage context and associations. If you want to learn more about how these methods compare and complement each other, <a href="https://www.elastic.co/blog/improving-information-retrieval-elastic-stack-benchmarking-passage-retrieval">here</a> we benchmark BM25 against two dense models that have been specifically trained for retrieval.</p><p>The most relevant result can usually be the first answer given to the user, the<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html#search-api-response-body-score">_score</a> is a number used to determine the <strong>relevance</strong> of the returned document.</p><h2>Chatbot optimization</h2><p>To help improve the user experience, performance, and reliability of your chatbot, in addition to applying hybrid scoring, you can incorporate the following approaches: <strong>Sentiment Analysis:</strong> To provide awareness of user comments and reactions as the dialog unfolds, you can incorporate a <a href="https://huggingface.co/distilbert-base-uncased-finetuned-sst-2-english">sentiment analysis model</a>:</p>POST _ml/trained_models/distilbert-base-uncased-finetuned-sst-2-english/deployment/_infer
{
  "docs": { "text_field": "That was not my question!"}
}


{
  "predicted_value": "NEGATIVE",
  "prediction_probability": 0.980080439016437
}
<p><a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data"><strong>GPT's capabilities</strong></a> <strong>:</strong> As an alternative to enhance the overall experience, you can combine Elasticsearch's search relevance with OpenAI's GPT question-answering capabilities, utilizing the <a href="https://platform.openai.com/docs/guides/chat">Chat Completion API</a> to return to the user model-generated responses considering these top k documents as a context. <em>Prompt: "answer this question &lt;user_question&gt; using only this document &lt;top_search_result&gt;"</em></p><p><strong>Observability:</strong> Ensuring the performance of any chatbot is crucial, and monitoring is an essential component in achieving this. In addition to logs that capture chatbot interactions, it's important to track response time, latency, and other relevant chatbot metrics. By doing so, you can identify patterns, trends, and even detect anomalies.<a href="https://www.elastic.co/blog/monitor-openai-api-gpt-models-opentelemetry-elastic">Elastic Observability</a> tools enable you to collect and analyze this information.</p><h2>Summary</h2><p>This blog post covers what NLP and vector search are and delves into an example of a chatbot employed to respond to user queries by considering data extracted from the vector representation of documents.</p><p>As demonstrated, using NLP and vector search, chatbots are capable of performing complex tasks that go beyond structured, targeted data. This includes making recommendations and answering specific product or business-related queries using multiple data sources and formats as context, while also providing a personalized user experience.</p><p>Use cases range from providing customer service by assisting customers with their inquiries to helping developers with their queries, by providing step-by-step guidance, suggesting recommendations, or even automating tasks. Depending on the goal and existing data, other models and methods can also be utilized to achieve even better results and improve the overall user experience.</p><p>Here are some links on the topic that may be useful:</p><ol><li><p><a href="https://www.elastic.co/blog/how-to-deploy-natural-language-processing-nlp-getting-started">How to deploy natural language processing (NLP): Getting started</a></p></li><li><p><a href="https://www.elastic.co/blog/overview-image-similarity-search-in-elastic">Overview of image similarity search in Elasticsearch</a></p></li><li><p><a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a></p></li><li><p><a href="https://www.elastic.co/blog/monitor-openai-api-gpt-models-opentelemetry-elastic">Monitor OpenAI API and GPT models with OpenTelemetry and Elastic</a></p></li><li><p><a href="https://www.elastic.co/blog/why-technology-leaders-need-vector-search">5 reasons IT leaders need vector search to improve search experiences</a></p></li></ol><p>By incorporating NLP and native vector search in Elasticsearch, you can take advantage of its speed, scalability, and search capabilities to create highly efficient and effective chatbots capable of handling large amounts of data, whether structured or unstructured.</p><p>Ready to get started? Begin a <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">free trial of Elastic Cloud</a>.</p><p><em>In this blog post, we may have used or we may refer to third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/enhancing-chatbot-capabilities-with-nlp-and-vector-search-in-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/enhancing-chatbot-capabilities-with-nlp-and-vector-search-in-elasticsearch</guid>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Priscilla Parodi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5c545fc80b6d79d6/6a170214839dfad776dcfd6f/d968e646240cd3ef7c79b5124d562a5f951d812b-1440x840.png" length="0" type="image/png"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: A plugin to use ChatGPT with your Elastic data]]></title>
    <description><![CDATA[Learn how to implement a plugin and enable ChatGPT users to extend ChatGPT with any content indexed in Elasticsearch, using the Elastic documentation.]]></description>
    <content:encoded><![CDATA[<p>Update: April 16th, 2024</p><p>OpenAI has discontinued the use of plugins in ChatGPT. You can read more about this <a href="https://help.openai.com/en/articles/8988022-winding-down-the-chatgpt-plugins-beta">here</a>. We recommend reading <a href="https://www.elastic.co/search-labs/tutorials/chatbot-tutorial/welcome">this</a> tutorial instead to learn how to build a large language model (LLM) chatbot that uses a pattern known as <a href="https://www.elastic.co/what-is/retrieval-augmented-generation">Retrieval-Augmented Generation</a>. You can also read <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-creating-custom-gpts-with-elastic-data">this</a> blog to learn how to create custom GPTs with Elastic data.</p><p>You may have read this <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">previous blog post</a> about our journey to connect Elasticsearch’s relevance capabilities with OpenAI question-answering capabilities. The key idea in that post was to illustrate how to use Elastic with OpenAI’s GPT model to build a response and return context-relevant content to users.</p><p>The application that we built can expose a search endpoint and be called by any front-end service. The good news is that now OpenAI has released a private alpha of the future <a href="https://openai.com/blog/chatgpt-plugins">ChatGPT plugin framework</a>.</p><p>In this blog, you will learn how to implement the plugin and extend the use of ChatGPT to any content indexed in Elasticsearch, using the Elastic documentation.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c29f4ce816ee08d/6a1711cb66c4f932b5f8c143/67a68ec5eee1b81462e0adeef41d5963054ec65e-1440x1239.png" alt="summarize transaction sampling" /><h2>What is a ChatGPT plugin?</h2><p><a href="https://openai.com/blog/chatgpt-plugins">ChatGPT plugins</a> are extensions that are developed to assist the model in completing its knowledge or executing actions.</p><p>For example, we know that the cutover of ChatGPT from a knowledge perspective is September 2021, so any question on recent data won’t be answered. In addition, any question that relates to something too specific beyond the boundaries of what the model has been trained on won’t be answered.</p><p>Plugins can broaden the scope of possible applications and enhance the capabilities of the models, but reciprocally, the plugin's output is augmented by the model itself.</p><p>The official list of plugins currently supported by ChatGPT are listed below. You can expect this list to expand rapidly as more organizations experiment with ChatGPT:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb5c7f4f71fefa8bb/6a1711ccb339d5202c76a0ee/34e746016e23a8a8b8fded4ecfcf34b6fcaba039-1440x583.png" alt="chatgpt plugins list" /><p>As you scan through the list, you’ll notice that the use cases are slowly revealing themselves here. In the case of Expedia, for example, its plugin is extending ChatGPT to assist in planning travel, making ChatGPT a trip-planning assistant.</p><p>This blog aims to achieve similar objectives for Elastic — to allow ChatGPT to access Elastic’s current knowledge base and assist you with your Elastic projects.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda2a330ddbd80a0d/6a1711cea6c2b981bce7980e/226eacfcaa5c0f2e3d42f7381e360e81a1d52433-656x634.png" alt="plugin store" /><h2>Architecture</h2><p>We are going to bring a slight modification that has a positive cost impact in the sample code presented in <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">part 1</a> by my colleague <a href="https://www.elastic.co/blog/author/jeff-vestal">Jeff Vestal</a>.</p><p>We will remove the call to OpenAI API, as now ChatGPT will fulfill the role of taking the content from Elasticsearch and digesting it back to the user:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt762330bba798a8b8/6a1711d0839dfa22c0dcfff5/fac71d9933fdd297308bc54ebc471108ef9a4b07-1440x900.png" alt="elastic chatgpt diagram" /><ol><li><p>ChatGPT makes a call to the <code>/search</code> endpoint of the plugin.</p></li></ol><ul><li><p>This decision is based on the plugin “rules” <code>description_for_human</code> (see plugin-manifest below).</p></li></ul><ol><li><p>The plugin code creates a search request that is sent to Elasticsearch.</p></li><li><p>Documentation body and original url are returned to Python.</p></li><li><p>The plugin returns the document body and url, in text form to ChatGPT.</p></li><li><p>ChatGPT uses the information from the plugin to craft its response.</p></li></ol><p>Again, this blog post assumes that you have set up your <a href="https://www.elastic.co/cloud">Elastic Cloud</a> account, <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data#eland">vectorized your content</a>, and have an Elasticsearch cluster filled with data ready to be used. If you haven’t set all that up, see <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">our previous post</a> for detailed steps to follow.</p><h2>Plugin code</h2><p>OpenAI built a fairly simple-to-handle plugin framework for ChatGPT. It deploys a service that exposes:</p><ul><li><p>The plugin manifest, explaining what the plugin provides to the users <em>and</em> to ChatGPT</p></li><li><p>The plugin openAPI definition, which is the functional description that enables ChatGPT to understand the available APIs The plugin code can be <a href="https://github.com/elastic/ElasticGPT_Plugin/">found here</a>.</p></li></ul><h3>Plugin file structure</h3><p>The screenshot below shows what the structure looks like:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltae1c75244b991b74/6a1711d1ab7f0895addb9fb5/b01242a370046e6bf0bab96edb2366b2fa1f22bf-728x436.png" alt="elasticgpt doc plugin" /><ul><li><p>The plugin manifest is stored in the ai-plugin.json file under the .well-known directory as per OpenAI best practices.</p></li><li><p>The main service code is in app.py.</p></li><li><p>The Dockerfile will be later used to deploy the plugin to Google Cloud Compute.</p></li><li><p>The plugin’s logo (logo.ong) as displayed in the ChatGPT plugin store, here the Elastic logo.</p></li><li><p>The OpenAI description of the plugin.</p></li></ul><h3>Python code</h3><p>For the full code, refer to the <a href="https://github.com/elastic/ElasticGPT_Plugin/">GitHub repository</a>. We are going to look only at the main part of this code:</p>…
@app.get("/search")
…
@app.get("/logo.png")
…
@app.get("/.well-known/ai-plugin.json")
…
@app.get("/openapi.yaml")
…
<p>We took out all the details and kept the main parts here. There are two categories of APIs here:</p><ol><li><p>The one required by OpenAI to build a plugin:</p></li></ol><ul><li><p>/logo.png: retrieve the plugin logo</p></li><li><p>/.well-known/ai-plugin.json: fetches the plugin manifest</p></li><li><p>/openapi.yaml: fetches the plugin OpenAPI description</p></li></ul><ol><li><p>The plugin API:</p></li></ol><ul><li><p>/search is the only one here exposed to ChatGPT that runs the search in Elasticsearch</p></li></ul><h3>Plugin manifest</h3><p>The plugin manifest is what ChatGPT will use to validate the existence (reachable) of the plugin. The definition is the below:</p>{
   "schema_version": "v1",
   "name_for_human": "ElasticGPTDoc_Plugin",
   "name_for_model": "ElasticGPTDoc_Plugin",
   "description_for_human": "Elastic Assistant, you know, for knowledge",
   "description_for_model": "Get most recent elasticsearch docs post 2021 release, anything after release 7.15",
   "auth": {
     "type": "none"
   },
   "api": {
     "type": "openapi",
     "url": "PLUGIN_HOSTNAME/openapi.yaml",
     "is_user_authenticated": false
   },
   "logo_url": "PLUGIN_HOSTNAME/logo.png",
   "contact_email": "info@elastic.co",
   "legal_info_url": "http://www.example.com/legal"
 }
<p>There are a couple of things to point out here:</p><ol><li><p>There are two descriptions:</p></li></ol><ul><li><p>description_for_human - This is what the human sees when installing the plugin in the ChatGPT web UI.</p></li><li><p>description_for_model - Instructions for the model to understand when to use the plugin.</p></li></ul><ol><li><p>There are some placeholders such as PLUGIN_HOSTNAME that are replaced in the Python code.</p></li></ol><h3>OpenAPI definition</h3><p>Our code will only expose a single API endpoint to ChatGPT allowing it to search for Elastic documentation. Here is the description:</p>openapi: 3.0.1
info:
 title: ElasticDocGPT
 description: Retrieve information front the most recent Elastic documentation
 version: 'v1'
servers:
 - url: PLUGIN_HOSTNAME
paths:
 /search:
   get:
     operationId: search
     summary: retrieves the document matching the query
     parameters:
     - in: query
       name: query
       schema:
           type: string
       description: use to filter relevant part of the elasticsearch documentations
     responses:
       "200":
         description: OK


<p>For the definition file, the key points are:</p><ul><li><p>We take the ChatGPT prompt content and pass it as a query to our Elasticsearch cluster.</p></li><li><p>Some placeholders such as PLUGIN_HOSTNAME are replaced in the Python code.</p></li></ul><h2>Deploying the Elastic plugin in Google Cloud Platform (GCP)</h2><p>You have a choice in picking a deployment method to expose your plugin, as well as using a different cloud provider. We use GCP in this blog post — more specifically Google Cloud Run and Google Cloud Build. The first is to expose and run the service, and the second is for continuous integration.</p><h2>Setup</h2><p>This setup assumes your GCP user has the right permissions to:</p><ul><li><p>Build a container image with Google Cloud Build in the Google Container Registry</p></li><li><p>Deploy a container in Google Cloud Run</p></li></ul><p>If not, you will need to update permissions on the <a href="https://console.cloud.google.com/iam-admin/iam">GCP IAM page</a>.</p><p>We are going to use the gcloud CLI to set up our environment. You can find the installation instructions <a href="https://cloud.google.com/sdk/docs/install">here</a>.</p><p>Once installed, run the following command to authenticate:</p>  gcloud auth
<p>Then set the project identifier to your GCP project:</p>
  gcloud config set project PROJECT_ID

<p>You are now ready to build and deploy.</p><h3>Build and deploy</h3><p>The first step is to build the container image using Cloud Build and push it to the Google Container Registry:</p>  gcloud builds submit --tag gcr.io/PROJECT_ID/my-python-app
<p>Replace PROJECT_ID with your GCP project ID and my-python-app with the name you want to give to your container image.</p><p>Export the environment required by the Python code to create the Elasticsearch client:</p>
  export YOUR_CLOUD_ID=VALUE
  export YOUR_CLOUD_PASS=VALUE
  export YOUR_CLOUD_USER=VALUE

<p>Finally, deploy the container image to Cloud Run:</p>
  gcloud run deploy my-python-app \
  --image gcr.io/PROJECT_ID/my-python-app \
  --platform managed \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars  cloud_id=YOUR_CLOUD_ID,cloud_pass=YOUR_CLOUD_PASS,cloud_user=YOUR_CLOUD_USER

<p>You should see your service running in Cloud Run:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e48a26348ecc676/6a1711d3e8fbcefcfc39fd6d/b8c3ab3e7208e2e8f05ed101fc6fe9ba7582c649-654x424.png" alt="cloud run services" /><p>Note that you can also activate the continuous integration so that any commit in your GitHub repository will trigger a redeploy. On the service details page, click on <strong>Set up continuous deployment</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd5f75018e65a4bbe/6a1711d50e2e4920ca41a25c/954c1c27fc8fc7d5198f18dc727ab9df1a953a9d-538x102.png" alt="" /><h2>Installing the plugin in ChatGPT</h2><p>Once the plugin is deployed and has a publicly accessible endpoint, it can be installed in ChatGPT. In our case, since this is deployed in Google Cloud Run, you can get the URL here:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8ea1f2fc9b4dba32/6a1711d6acf0880435be9c6b/db3b12bf18c8cf15435a32ffeaf731ef6082e3bf-1404x108.png" alt="elastic doc gpt" /><p>Then in <a href="https://chat.openai.com/chat">ChatGPT</a>, go in the plugin store:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec39261316cb081c/6a1711d8964cea07f808bcd9/3783c95bda4592b93f202ac5bdb498f9a3f04c6a-1440x361.png" alt="plugins alpha" /><p>Choose to do “Develop your own plugin”:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaa7f16957d702a99/6a1711d9a292997e25d01136/77d07ff08f573ac1f8c07468d135cc373a4b94a6-1440x607.png" alt="develop your own plugin" /><p>Paste the URL you copied from the Google Cloud Run page:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe72a85a4f8b3a30/6a1711db6234e09cd2db1b00/d872c6577df2463558d93a39ebe6ca6197934cf4-1072x604.png" alt="enter your website domain" /><p>Ensure the plugin is found and valid:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71c1ff30970d5310/6a1711dcd7c0227595de65ca/6ac18505045bedf44511b364aae4934fe80d33a7-1034x568.png" alt="found plugin" /><p>Follow the installation instructions until you see your plugin available in the list:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec91c3047e89994a/6a1711de4a531b2e2836aa93/7ce491c6d2b096fa917cb50ff8fe805d6d23431d-1252x398.png" alt="plugins alpha elastic" /><h2>Let’s test our plugin!</h2><p>OK, now for the best part! Do remember that ChatGPT decides to delegate when your prompt goes beyond its knowledge. To ensure that happens, just ask a question similar to this example:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt32ecc342b659ffe8/6a1711e00c48570cd001abaa/5fbff0d0197f493e341658291f8cbc154a2dfb8a-1440x1292.png" alt="highlights of latest elastic release" /><p>With the steps provided in this blog, you can create your own plugin and deploy it on a cloud provider or your own hosts. This allows you to start exploring enhancing ChatGPT's knowledge and functionality, enhancing an already amazing tool with specialized and proprietary knowledge.</p><p>You can try all of the capabilities discussed in this blog today! Get started by signing up for a <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">free Elastic Cloud trial</a>.</p><p>Here are some other blogs you may find interesting:</p><ul><li><p><a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a></p></li><li><p><a href="https://www.elastic.co/blog/monitor-openai-api-gpt-models-opentelemetry-elastic">Monitor OpenAI API and GPT models with OpenTelemetry and Elastic</a></p></li><li><p><a href="https://www.elastic.co/security-labs/exploring-applications-of-chatgpt-to-improve-detection-response-and-understanding">Exploring the Future of Security with ChatGPT</a></p></li></ul><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-plugin-elastic-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-plugin-elastic-data</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Baha Azarmi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltafa5e250e50af311/6a1711e10e2e49950841a262/b42ad0b8550fc9ee532c0d93d2587aecdaf5dd5a-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: OpenAI meets private data]]></title>
    <description><![CDATA[Integrate Elasticsearch's search relevance with ChatGPT's question-answering capability to enhance your domain-specific knowledge base.]]></description>
    <content:encoded><![CDATA[<p><strong>NOTE: This blog has been revisited with an update incorporating new features Elastic has released since this was first published. </strong><a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-rag-enhancements"><strong>Please check out the new blog here!</strong></a></p><p>Combine Elasticsearch's search relevance with OpenAI's ChatGPT's question-answering capabilities to query your data. In this blog, you'll learn how to connect ChatGPT to proprietary data stores using Elasticsearch and build question/answer capabilities for your data.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt01bcddf80e3722d2/6a1711a3cdacbf135a7d2afc/ffbdc3b88620a1f53af18480929c6978d2fcaa44-1440x1187.png" alt="elasticdocs gpt list the steps free trial" /><h2>What is ChatGPT?</h2><p>In recent months, there has been a surge of excitement around ChatGPT, a groundbreaking AI model created by OpenAI. But what exactly is ChatGPT?</p><p>Based on the powerful GPT architecture, ChatGPT is designed to understand and generate human-like responses to text inputs. GPT stands for "Generative Pre-trained Transformer.” The Transformer is a cutting-edge model architecture that has revolutionized the field of natural language processing (NLP). These models are pre-trained on vast amounts of data and are capable of understanding context, generating relevant responses, and even carrying on a conversation. To learn more about the history of transformer models and some NLP basics in the Elastic Stack, be sure to check out the great <a href="https://www.youtube.com/watch?v=SvvbMCwyOnU">talk by Elastic ML Engineer Josh Devins</a>.</p><p>The primary goal of ChatGPT is to facilitate meaningful and engaging interactions between humans and machines. By leveraging the recent advancements in NLP, ChatGPT models can provide a wide range of applications, from chatbots and virtual assistants to content generation, code completion, and much more. These AI-powered tools have rapidly become an invaluable resource in countless industries, helping businesses streamline their processes and enhance their services.</p><h2>Limitations of ChatGPT &amp; how to minimize them</h2><p>Despite the incredible potential of ChatGPT, there are certain limitations that users should be aware of. One notable constraint is the knowledge cutoff date. Currently, ChatGPT is trained on data up to September 2021, meaning it is unaware of events, developments, or changes that have occurred since then. Consequently, users should keep this limitation in mind while relying on ChatGPT for up-to-date information. This can lead to outdated or incorrect responses when discussing rapidly changing areas of knowledge such as software enhancements and capabilities or even world events.</p><p>ChatGPT, while an impressive AI language model, can occasionally hallucinate in its responses, often exacerbated when it lacks access to relevant information. This overconfidence can result in incorrect answers or misleading information being provided to users. It is important to be aware of this limitation and approach the responses generated by ChatGPT with a degree of skepticism, cross-checking and verifying the information when necessary to ensure accuracy and reliability.</p><p>Another limitation of ChatGPT is its lack of knowledge about domain-specific content. While it can generate coherent and contextually relevant responses based on the information it has been trained on, it is unable to access domain-specific data or provide personalized answers that depend on a user's unique knowledge base. For instance, it may not be able to provide insights into an organization’s proprietary software or internal documentation. Users should, therefore, exercise caution when seeking advice or answers on such topics from ChatGPT directly.</p><p>One way to minimize these limitations is by providing ChatGPT access to specific documents relevant to your domain and questions, and enabling ChatGPT’s language understanding capabilities to generate tailored responses.</p><p>This can be accomplished by connecting ChatGPT to a search engine like Elasticsearch.</p><h2>Elasticsearch — you know, for search!</h2><p>Elasticsearch is a scalable data store and vector database designed to deliver relevant document retrieval, ensuring that users can access the information they need quickly and accurately. Elasticsearch’s primary focus is on delivering the most relevant results to users, streamlining the search process, and enhancing user experience.</p><p>Elasticsearch boasts a myriad of features to ensure top-notch search performance, including support for traditional keyword and text-based search (<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-similarity.html">BM25</a>) and an AI-ready vector search with exact match and approximate kNN (<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">k-Nearest Neighbor</a>) search capabilities. These advanced features allow Elasticsearch to retrieve results that are not only relevant but also for queries that have been expressed using natural language. By leveraging traditional, vector, or hybrid search (BM25 + kNN), Elasticsearch can deliver results with unparalleled precision, helping users find the information they need with ease.</p><p>One of the key strengths of Elasticsearch is its robust API, which enables seamless integration with other services to extend and enhance its capabilities. By integrating Elasticsearch with various third-party tools and platforms, users can create powerful and customized search solutions tailored to their specific requirements. This flexibility and extensibility makes Elasticsearch an ideal choice for businesses looking to improve their search capabilities and stay ahead in the competitive digital landscape.</p><p>By working in tandem with advanced AI models like ChatGPT, Elasticsearch can provide the most relevant documents for ChatGPT to use in its response. This synergy between Elasticsearch and ChatGPT ensures that users receive factual, contextually relevant, and up-to-date answers to their queries. In essence, the combination of Elasticsearch's retrieval prowess and ChatGPT's natural language understanding capabilities offers an unparalleled user experience, setting a new standard for information retrieval and AI-powered assistance.</p><h2>How to use ChatGPT with Elasticsearch</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d63e45e8e71c526/6a1711a5961e696f34c4d013/4c3858ece4620036b838131efd4548b844a1c8ae-1440x951.png" alt="use chatgpt with elasticsearch" /><ol><li><p>Python interface accepts user questions.</p></li></ol><p>Generate a hybrid search request for Elasticsearch</p><ul><li><p>BM25 match on the title field</p></li><li><p>kNN search on the title-vector field</p></li><li><p>Boost kNN search results to align scores</p></li><li><p>Set size=1 to return only the top scored document</p></li></ul><ol><li><p>Search request is sent to Elasticsearch.</p></li><li><p>Documentation body and original url are returned to python.</p></li><li><p>API call is made to OpenAI ChatCompletion.</p></li></ol><ul><li><p>Prompt: "answer this question &lt;question&gt; using only this document &lt;body_content from top search result&gt;"</p></li></ul><ol><li><p>Generated response is returned to python.</p></li><li><p>Python adds on original documentation source url to generated response and prints it to the screen for the user.</p></li></ol><p>The ElasticDoc ChatGPT process utilizes a Python interface to accept user questions and generate a hybrid search request for Elasticsearch, combining BM25 and kNN search approaches to find the most relevant document from the Elasticsearch Docs site, now indexed in Elasticsearch. However, you do not have to use hybrid search or even vector search. Elasticsearch provides the flexibility to use whichever search pattern best fits your needs and provides the most relevant results for your specific data sets.</p><p>After retrieving the top result, the program crafts a prompt for OpenAI's ChatCompletion API, instructing it to answer the user's question using only the information from the selected document. This prompt is key to ensuring the ChatGPT model only uses information from the official documentation, lessening the chance of hallucinations.</p><p>Finally, the program presents the API-generated response and a link to the source documentation to the user, offering a seamless and user-friendly experience that integrates front-end interaction, Elasticsearch querying, and OpenAI API usage for efficient question-answering.</p><p>Note that while we are only returning the top-scored document for simplicity, the best practice would be to return multiple documents to provide more context to ChatGPT. The correct answer could be found in more than one documentation page, or if we were generating vectors for the full body text, those larger bodies of text may need to be chunked up and stored across multiple Elasticsearch documents. By leveraging Elasticsearch's ability to search across numerous vector fields in tandem with traditional search methods, you can significantly enhance your top document recall.</p><h2>Technical setup</h2><p>The technical requirements are fairly minimal, but it takes some steps to put all the pieces together. For this example, we will configure the <a href="https://www.elastic.co/web-crawler">Elasticsearch web crawler</a> to ingest the Elastic documentation and generate vectors for the title on ingest. You can follow along to replicate this setup or use your own data. To follow along we will need:</p><ul><li><p>Elasticsearch cluster</p></li><li><p>Eland Python library</p></li><li><p>OpenAI API account</p></li><li><p>Somewhere to run our python frontend and api backend</p></li></ul><h3>Elastic Cloud setup</h3><p>The steps in this section assume you don’t currently have an Elasticsearch cluster running in Elastic Cloud. If you do you, can skip to the next section.</p><p><strong>Sign up</strong> If you don’t already have an Elasticsearch cluster, you can sign up for a free trial with <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">Elastic Cloud</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3af069b6efc24a7b/6a1711a647d49c1eb52d8b0a/1e9fcc7281b87db1024bdd52d97050b680cf654d-920x1086.png" alt="start free trial" /><p><strong>Create deployment</strong> After you sign up, you will be prompted to create your first deployment.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b04d11d688b0e04/6a1711a8a292996a4cd01124/9bab0a32b62863103ac57843078b35bae6b3d939-1440x823.png" alt="create first deployment" /><ul><li><p>Create a name for your deployment.</p></li><li><p>You can accept the default cloud provider and region or click Edit Settings and choose another location.</p></li><li><p>Click Create deployment. Shortly a new deployment will be provisioned for you and you will be logged in to Kibana. <strong>Back to the Cloud</strong> We need to do a couple of things back in the Cloud Console before we move on: Click on the Navigation Icon in the upper left and select Manage this deployment.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte4d09a0d03af921d/6a1711a947d49c15562d8b0e/c4064762d0fe4858de9f92018084dd3d654f8a68-277x449.png" alt="manage this deployment" /><p>Add a machine learning node.</p><ul><li><p>Back in the Cloud Console, click on Edit under your Deployment’s name in the left navigation bar.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt570ab096155f4cd7/6a1711aa28671432b593e42e/cdbf2abe7b7f2efe95c083a5800ce5c8edbac5e0-330x252.png" alt="deployments edit monitoring" /><ul><li><p>Scroll down to the Machine Learning instances box and click +Add Capacity.</p></li><li><p>Under Size per zone, click and select 2 GB RAM.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt79a4423f5ab22324/6a1703aeb339d5901a769e85/e30e63a849b2ba1fdcc58c946a5a482db8ac88d0-1432x292.png" alt="machine learning instances" /><ul><li><p>Scroll down and click on Save.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt75bcca2c5fb12ec1/6a1711ac28671421f193e432/1d3efb1b02888e310c47948966aef3a8fd8879a2-556x176.png" alt="save equivalent api request" /><ul><li><p>In the pop-up summarizing the architecture changes, click Confirm.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3ed7ef67cc298e2a/6a1711ae14b270b607e3c6eb/04e0da7b1378609ae810fabe2ac84f9917b5a69c-384x152.png" alt="cancel confirm" /><ul><li><p>In a few moments, your deployment will now have the ability to run machine learning models!</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt438d9d1a5474c693/6a1711afb0367dae8e72be2a/c5dcac69868ce404bf986ccf7a6e6429b4ad807c-1440x156.png" alt="change summary" /><p>Reset Elasticsearch Deployment User and password:</p><ul><li><p>Click on Security on the left navigation under your deployment’s name.</p></li><li><p>Click on Reset Password and confirm with Reset. (Note: as this is a new cluster nothing should be using this Elastic password.)</p></li><li><p>Download the newly created password for the “elastic” user. (We will use this to load our model from Hugging Face and in our python program.)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt76c75e99c823c09f/6a1711b17d8d67766970e85c/da1dd29d4b3d61ce79921b0bb1a15629b553e689-912x638.png" alt="save deployment credentials" /><p>Copy the Elasticsearch Deployment Cloud ID.</p><ul><li><p>Click on your Deployment name to go to the overview page.</p></li><li><p>On the right-hand side click the copy icon to copy your Cloud ID. (Save this for use later to connect to the Deployment.)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt352796cac3fbe52b/6a1711b37d8d6725a570e860/f5dcfe766eea84e4a116caf33d8e068119863bba-1440x159.png" alt="applications hardware profile" /><h3>Eland</h3><p>We next need to load an embedding model into Elasticsearch to generate vectors for our blog titles and later for our user’s search questions. We will be using the <a href="https://huggingface.co/sentence-transformers/all-distilroberta-v1">all-distilroberta-v1</a> model trained by SentenceTransformers and hosted on the Hugging Face model hub. This particular model isn’t required for this setup to work. It is good for general use as it was trained on very large data sets covering a wide range of topics. However, with vector search use cases, using a model fine-tuned to your particular data set will usually provide the best relevancy.</p><p>To do this, we will use the <a href="https://github.com/elastic/eland#readme">Eland python library</a> created by Elastic. The library provides a wide range of data science functions, but we will be using it as a bridge to load the model into Elasticsearch from the Hugging Face model hub so it can be deployed on machine learning nodes for inference use.</p><p>Eland can either be run as part of a python script or on the command line. The repo also provides a Docker container for users looking to go that route. Today we will run Eland in a <a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/load_embedding_model.ipynb">small python notebook</a>, which can run in Google’s Colab in the web browser for free.</p><p>Open the <a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/load_embedding_model.ipynb">program link</a> and click the “Open in Colab” button at the top to launch the notebook in colab.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt277c97e73b673f82/6a1711b460084b6b6a3c4680/5a1b9de1ba50f8e48dab6341070194c70b715b61-236x40.png" alt="open in colab" /><p>Set the variable hf_model_id to the model name. This model is set already in the example code but if you want to use a different model or just for future information:</p><ul><li><p>hf_model_id='sentence-transformers/all-distilroberta-v1'</p></li><li><p>Copy model name from Hugging Face. The easiest way to do this is to click the copy icon to the right of the model name.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcbf5b198c21da493/6a1711b514b27074d6e3c6ef/d883b5342323b8bfcbf5a2a29014f48750717b25-1212x270.png" alt="hugging face" /><p>Run the cloud auth section, and you will be prompted to enter:</p><ul><li><p>Cloud ID (you can find this in the Elastic Cloud Console)</p></li><li><p>Elasticsearch Username (easiest will be to use the “Elastic” user created when the deployment was created)</p></li><li><p>Elasticsearch User Password</p></li></ul><p>Run the remaining steps.</p><ul><li><p>This will download the model from Hugging face, chunk it up, and load it into Elasticsearch.</p></li><li><p>Deploy (start) the model onto the machine learning node.</p></li></ul><h3>Elasticsearch index and web crawler</h3><p>Next up we will create a new Elasticsearch index to store our Elastic Documentation, configure the web crawler to automatically crawl and index those docs, as well as use an ingest pipeline to generate vectors for the doc titles.</p><strong>Note that you can use your proprietary data for this step, to create a question/answer experience tailored to your domain.</strong><ul><li><p>Open Kibana from the Cloud Console if you don’t already have it open.</p></li><li><p>In Kibana, Navigate to Enterprise Search -&gt; Overview. Click Create an Elasticsearch Index.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdc51e9f198d426e7/6a1711b74a531b8d0936aa8d/57ae4a7024863162265b67da3f0419bcd7bd6f62-752x180.png" alt="create an elasticsearch index" /><ul><li><p>Using the Web Crawler as the ingestion method, enter elastic-docs as the index name. Then, click Create Index.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt65d0058455a44207/6a1711b8b0367da8aa72be2e/51bd68374c03625dfa1e06cc3170e4173b5fb7b6-1440x474.png" alt="select an ingestion method" /><ul><li><p>Click on the “Pipelines” tab.</p></li><li><p>Click Copy and customize in the Ingest Pipeline Box.</p></li><li><p>Click Add Inference Pipeline in the Machine Learning Inference Pipelines box.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltabf90aa59e3e850f/6a1711ba0c4857d1a001ab9c/e3d85ab9e4b6688dc5ea8614f6b2248394177485-1186x436.png" alt="machine learning inference pipelines" /><ul><li><p>Enter the name elastic-docs_title-vector for the New pipeline.</p></li><li><p>Select the trained ML model you loaded in the Eland step above.</p></li><li><p>Select title as the Source field.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc443155a95f5187b/6a1711bb964cea4f6108bcd5/555170b190ea940418f2bf8ba7d45c41d2589a75-1440x818.png" alt="configure add a new pipeline" /><ul><li><p>Click Continue, then click Continue again at the Test stage.</p></li><li><p>Click Create Pipeline at the Review stage.</p></li></ul><p>Update mapping for dense_vector field. (Note: with Elasticsearch version 8.8+, this step should be automatic.)</p><ul><li><p>In the navigation menu, click on Dev Tools. You may have to click Dismiss on the flyout with documentation if this is your first time opening Dev Tools.</p></li><li><p>In Dev Tools in the Console tab, update the mapping for our dense vector target field with the following code. You simply paste it in the code box and click the little arrow to the right of line 1.</p></li></ul>POST search-elastic-docs/_mapping
{
  "properties": {
    "title-vector": {
      "type": "dense_vector",
      "dims": 768,
      "index": true,
      "similarity": "dot_product"
    }
  }
}
<ul><li><p>You should see the following response on the right half of the screen:</p></li></ul>{
  "acknowledged": true
}
<ul><li><p>This will allow us to run kNN search on the title field vectors later on.</p></li></ul><p>Configure web crawler to crawl Elastic Docs site:</p><ul><li><p>Click on the navigation menu one more time and click on Enterprise Search -&gt; Overview.</p></li><li><p>Under Content, click on Indices.</p></li><li><p>Click on search-elastic-docs under Available indices.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt39f78c6403e11420/6a1711bd47d49c67992d8b1a/7b05934f8775f4f602eb258c7bb8c3c1b82bd285-1440x177.png" alt="available indices" /><ul><li><p>Click on the Manage Domains tab.</p></li><li><p>Click “Add domain.”</p></li><li><p>Enter <a href="https://www.elastic.co/guide/en">https://www.elastic.co/guide/en</a>, then click Validate Domain.</p></li><li><p>After the checks run, click Add domain. Then click Crawl rules.</p></li><li><p>Add the following crawl rules one at a time. Start with the bottom and work up. Rules are evaluated according to first match.</p></li></ul><p></p><p></p><p></p><p>Disallow</p><p>Contains</p><p>release-notes</p><p>Allow</p><p>Regex</p><p>/guide/en/.*/current/.*</p><p>Disallow</p><p>Regex</p><p>.*</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt72127923b67d3b5a/6a1711bed7c022c73ade65c4/efd9052ba0038084855988b4fa6888a2a114a974-1440x410.png" alt="crawl rules" /><ul><li><p>With all the rules in place, click Crawl at the top of the page. Then, click Crawl all domains on this index.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcd9093ca85f8da56/6a1711c0a929cf9114ae0ae1/a50db802ba5cadb3863ffa314a8deee9b64c8dd7-638x380.png" alt="search engines crawl" /><p>Elasticsearch’s web crawler will now start crawling the documentation site, generating vectors for the title field, and indexing the documents and vectors.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteedc9efa600488e7/6a1711c2c1e8a5aea3f883d9/41eac123618338f127abffe9c957300f4e61fd0a-338x128.png" alt="crawling" /><p>The first crawl will take some time to complete. In the meantime, we can set up the OpenAI API credentials and the Python backend.</p><h2>Connecting with OpenAI API</h2><p>To send documents and questions to ChatGPT, we need an OpenAI API account and key. If you don’t already have an account, you can create a free account and you will be given an initial amount of free credits.</p><ul><li><p>Go to <a href="https://platform.openai.com">https://platform.openai.com</a> and click on Signup. You can go through the process to use an email address and password or login with Google or Microsoft.</p></li></ul><p>Once your account is created, you will need to create an API key:</p><ul><li><p>Click on <a href="https://platform.openai.com/account/api-keys">API Keys</a>.</p></li><li><p>Click Create new secret key.</p></li><li><p>Copy the new key and save it someplace safe as you won’t be able to view the key again.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3c4b4def8f822024/6a1711c3a292995b95d0112e/b94891d6199c0c0c9f451eec9c8ac8d250882991-1114x586.png" alt="api key generated" /><h2>Python backend setup</h2><h3>Clone or download the python program</h3><p><a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/elasticdocs_gpt.py">Github Link to code</a></p><ol><li><p>Install required python libraries. We are running the example program in Replit, which has isolated environments. If you are running this on a laptop or VM, best practice is to <a href="https://docs.python.org/3/library/venv.html">set up a virtual ENV for python</a>.</p></li></ol><ul><li><p>Run pip install -r requirements.txt</p></li></ul><ol><li><p>Set authentication and connection environment variables (e.g., if running on the command line: export openai_api=”123456abcdefg789”)</p></li></ol><ul><li><p>openai_api - OpenAI API Key</p></li><li><p>cloud_id - Elastic Cloud Deployment ID</p></li><li><p>cloud_user - Elasticsearch Cluster User</p></li><li><p>cloud_pass - Elasticsearch User Password</p></li></ul><ol><li><p>Run the streamlit program. More info about <a href="https://docs.streamlit.io/library/get-started/installation">streamlit can be found in its docs</a>.</p></li></ol><ul><li><p>Streamlit has its own command to start: streamlit run elasticdocs_gpt.py</p></li></ul><ol><li><p>This will start a web browser and the url will be printed to the command line.</p></li></ol><h2>Sample chat responses</h2><p>With everything ingested and the front end up and running, you can start asking questions about the Elastic Documentations.</p><p>Asking “Show me the API call for an inference processor” now returns an example API call and some information about the configuration settings.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltffaa60616a42dc47/6a1711c50e2e49673b41a258/d767639258b64417da444346e191a158620cf134-1440x1448.png" alt="show api call" /><p>Asking for steps to add a new integration to Elastic Agent will return:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf0a7fe68f5fe11c5/6a1711c6a929cf650bae0ae5/5065af9bf9ae5ee5fd2a1c7636d2293746fb241d-1440x1272.png" alt="how add new integration" /><p>As mentioned earlier, one of the risks of allowing ChatGPT to answer questions based purely on data it has been trained on is its tendency to hallucinate incorrect answers. One of the goals of this project is to provide ChatGPT with the data containing the correct information and let it craft an answer.</p><p>So what happens when we give ChatGPT a document that does not contain the correct information? Say, asking it to tell you how to build a boat (which isn’t currently covered by Elastic’s documentation):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba5ff5dddb7b8982/6a1711c8b339d58b8d76a0e8/43f80bfefab55261493751672669616cc6d0f54b-1440x548.png" alt="show build boat" /><p>When ChatGPT is unable to find an answer to the question in the document we provided, it falls back on our prompt instruction simply telling the user it is unable to answer the question.</p><h2>Elasticsearch’s robust retrieval + the power of ChatGPT</h2><p>In this example, we've demonstrated how integrating Elasticsearch's robust search retrieval capabilities with cutting-edge advancements in AI-generated responses from GPT models can elevate the user experience to a whole new level.</p><p>The individual components can be tailored to suit your specific requirements and adjusted to provide the best results. While we used the Elastic web crawler to ingest public data, you're not limited to this approach. Feel free to experiment with alternative embedding models, especially those fine-tuned for your domain-specific data.</p><p>You can try all of the capabilities discussed in this blog today! To build your own ElasticDocs GPT experience, sign up for an <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">Elastic trial account</a>, and then look at this <a href="https://github.com/jeffvestal/ElasticDocs_GPT">sample code repo</a> to get started.</p><p>If you would like ideas to experiment with search relevance, here are two to try out:</p><ul><li><p><a href="https://www.elastic.co/blog/how-to-deploy-nlp-text-embeddings-and-vector-search">[BLOG] Deploy NLP text embeddings and vector search using Elasticsearch</a></p></li><li><p><a href="https://www.elastic.co/blog/implement-image-similarity-search-elastic">[BLOG] Implement image similarity search with Elastic</a></p></li></ul><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Jeff Vestal]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4478d2508f563479/6a1711c9a929cf0b9fae0ae9/1d616d244f05328ed677b008941db001d79c86b7-1440x840.png" length="0" type="image/png"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: enhance user experience with faceting, filtering, and more context]]></title>
    <description><![CDATA[By providing ChatGPT more context and using Elasticsearch's facets &amp; filters, you can refine the search and lower ChatGPT costs. Here's how.]]></description>
    <content:encoded><![CDATA[<p>In a recent blog post, we discussed how ChatGPT and Elasticsearch can <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">work together</a> to help manage proprietary data more effectively. By utilizing Elasticsearch's search capabilities and ChatGPT's contextual understanding, we demonstrated how the resulting outcomes can be improved.</p><p>In this post, we discuss how users’ experience can be further enhanced with the addition of facets, filtering, and additional context. By providing tools like ChatGPT additional context, you can increase the likelihood of obtaining more accurate results. See how Elasticsearch's faceting and filtering framework can allow users to refine their search and reduce the cost of engaging with ChatGPT.</p><h2>Comparing ChatGPT and Elasticsearch results</h2><p>To improve the user experience of our sample application, we've added a feature that displays the raw results alongside the ChatGPT-created response. This will help users better understand how ChatGPT works.</p><p>Since our source data set is only crawled, the structure in the documents makes it difficult to read for a human. To show this difference and therefore the value that ChatGPT can bring, we added the raw result next to the GPT created response.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5c9d37f2456eefe3/6a17119ba929cf5105ae0ad9/0db9068dcd432aae0871c68bcdbf0227b7580e91-1440x939.png" alt="" /><p>Currently, this example application only returns a single result. And even though we have hybrid scoring with vector search and BM25, this result may not be perfect. If we take this not perfect result and pass it over to ChatGPT, there’s a good chance that the response we get won’t be great either, as the context was missing important information.</p><p>Ideally, we’d just pass more context into ChatGPT, but the current 3.5-turbo models are limited to 4,096 tokens (that’s including the response you expect to get, so the actual limit is much lower). Future models will likely have a much larger limit, but this also comes with a cost.</p><p>As of today, GPT-3.5-turbo costs $0.002 per 1K <a href="https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them">tokens</a>, while the up-and-coming GPT-4 with 32K context costs $0.06 per 1K tokens — that’s a factor of 30 more. Even with more powerful models coming in the next few years, there’s a chance that it’s not economically viable to do so for all user cases.</p><p>We will therefore not use GPT-4 right now and instead work around the max token limitation of GPT-3.5 by sending multiple concurrent requests and giving the user more flexibility in filtering the results.</p><h2>Leveraging aggregations, facets, and filtering in Elasticsearch to enhance ChatGPT</h2><p>To address this limitation, one of the biggest advantages of Elasticsearch is its robust faceting and filtering framework. When a user is searching for something, they may have additional preferences or context they can provide to dramatically increase the likelihood of obtaining the correct result. By leveraging Elasticsearch's faceting and filtering framework, we can allow users to refine their search based on various parameters such as date, location, or other relevant criteria.</p><p>It’s also important to note that many users have gotten used to having facet filtering options available when searching for something. Let us look at an example.</p><p>Searching for “How can I parse a message with Grok?” results in a document for ingest pipelines to be returned as the top result. This is not wrong, as ingest pipelines also support Grok expressions, but what if the user was interested in parsing his data using Logstash?</p><p>Using a simple terms aggregation as part of the request to fetch the hits, we can get a list of the top 10 product categories and offer these as a filtering option for a user.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt19e8ac27b6e24ca1/6a17119ccf4f25a6bcb2d267/39187b4da43ec7d9c75a4e7ff4ec6666b9f410b8-1440x816.png" alt="chatgpt options" /><p>If the user now selects “Logstash” on the left side, all results will be filtered for Logstash. It’s important to note that this all works while still using the same hybrid query model that we’ve talked about in the previous blog. We’re still using a combination of BM25 and kNN search to match our documents.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt150df5ca25922fc4/6a17119e8b73cb1bbe18a131/475f39166fd84e9728cb49d142b5f83e8af7b127-1440x814.png" alt="chatgpt grok filter plugin" /><h2>Loading multiple results in parallel</h2><p>We briefly mentioned the max token limit earlier. In short, the prompt that you send to the API and its response can’t be longer than 4,096 tokens. When searching your proprietary data, you would like to provide as much specific context as possible so the model can give you the best answer. However, the 4,096 tokens aren’t that much, especially when you include things like code snippets.</p><p>A very simple first step toward mitigating the limit is to just ask multiple times in parallel, giving a different context each time. Using our approach with Elasticsearch, instead of only fetching the top 1 result and sending that to OpenAI, we can change the application to load the top 10 hits instead and then ask the question with the respective context.</p><p>This gives us 10 unique answers to our question and greatly increases our chances of presenting a relevant answer to the end user. While we are increasing the burden of the user to look at the results, it still gives them more flexibility.</p><p>Think of it like this: if you try to debug a problem and search for an exception on Google, you quickly scan the list of the top four or five results that Google displays and click on the one that seems most fitting to your question. Showing the user multiple answers to their question is similar to this.</p><p>While having a single correct answer would be ideal, having more than one to choose from initially is a great starting point. And as mentioned before, it can be cheaper compared to using a more expensive model (such as GPT-4).</p><p>We can also get more creative with our prompt and ask ChatGPT to send us a specific response if it can’t answer the question using the provided context. This will allow us to remove the results from the UI later.</p><p>One prompt that worked well in our use case is:</p>prompt = f"Answer this question: {query}\n. Don’t give information not mentioned in the CONTEXT INFORMATION. If the CONTEXT INFORMATION contains code or API requests, your response should include code snippets. If the context does not contain relevant information, answer 'The provided page does not answer the question': \n {body}"
<h2>Working around the max token limit of ChatGPT: Answering a question from a set of answers</h2><p>Since we have more than a single answer to our question now, we can attempt to summarize them into a single response. For this, we will mostly follow the same approach as before, but instead of searching Elasticsearch for the context, we will just concatenate the individual answers we’ve received so far, excluding any where the model responded that it can’t answer it based on the provided context.</p><p>Note that the prompt for this run is a little different from the earlier prompt, so the model treats our context slightly differently. The provided prompt here is by no means perfect, and depending on the data, it should be adjusted and optimized further.</p>concatResult = ""
        for resultObject in results:
            if resultObject['choices'][0]["message"]["content"] != "The provided page does not answer the question.":
                concatResult += resultObject['choices'][0]["message"]["content"]
        if st.session_state['summarizeResults']['state']:
            results = [None] * 1
            tasks = []
            prompt = f"I will give you {numberOfResults} answers to this question.: \"{query}\"\n. They are ordered by their likelyhood to be correct. Come up with the best answer to the original question, using only the context I will provide you here. If the provided context contains code snippets or API requests, half of your response must be code snippets or API requests. \n {concatResult}"
            element = None
            with st.session_state['topResult']:
                with st.container():
                    st.markdown(f"**Summary of all results:**")
                    element = st.empty()

            with elasticapm.capture_span("top-result", "openai"):
                task = loop.create_task(achat_gpt(prompt, results, counter, element))
                tasks.append(task)
                loop.set_exception_handler(handle_exception)
                loop.run_until_complete(asyncio.wait(tasks))
	      loop.close()
<p>With this additional “reduce phase” in place, our app will now:</p><ul><li><p>Search Elasticsearch for the top 10 hits</p></li><li><p>10x in parallel ask OpenAI to answer the question, providing a different context each time</p></li><li><p>Concatenate responses from OpenAI and ask OpenAI once again to answer the question</p></li></ul><p>With this setup, we can use close to 40,000 tokens of context, while only paying for the considerably cheaper GPT-3.5 model. In another blog post, we will explore the cost in more detail and use Elastic APM for tracking our spend, alongside other metrics.</p><p>It should be noted that GPT-4 may still perform much better than the approach above, so use whatever works best for you and the amount of traffic you expect.</p><h2>Citations for your ChatGPT results</h2><p>One downside of large language models (LLMs) is their overconfidence and tendency to hallucinate. You ask a question, you get an answer. Whether the answer is actually correct is for you to decide. The model rarely admits that it does not know something. Providing the context and telling it to respond with a specific answer as we did above helps mitigates this to some extent.</p><p>But the provided context alongside getting the model to admit that it can’t answer a question also allows us to provide more accurate citations for the responses.</p><p>In the last section, we summarized our set of 10 answers into one global answer. In addition to just providing this global answer, we can also provide a list of all source documentation pages that we used to compile the result — basically any page where the model did not respond "The provided page does not answer the question."</p><p>In this screenshot, you can see the summary answer on a set of 10 results from Elasticsearch. Even though we inspected 10 results, we are only displaying the three links to the documentation that are actually relevant to answer the question. In this case, the other seven documents returned by Elasticsearch had something to do with documents or indices, but they didn’t specifically talk about how to index something.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c4bc2ffece60c7c/6a17119fe8fbce22a839fd5d/ca302c403cfe19dc86a69d9f38d400510908bf81-1440x952.png" alt="chatgpt to index a document" /><h2>Searching proprietary data</h2><p>We’ve mentioned in an <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">earlier blog post</a> that it’s great to use Elasticsearch and OpenAI to search proprietary data. However, we did use a web-crawler to crawl public documentation. That may seem a bit counterintuitive, and you’re right to think about it! OpenAI trains GPT models on web data, so we will assume it knows our documentation already. So why do we need Elasticsearch in addition to that data? Does this setup actually work on data that’s not public? It does — let’s prove it.</p><p>Using the existing setup, we will push a single super secret document about an internal project into our index.</p>PUT search-elastic-docs/_doc/1?pipeline=search-elastic-docs@ml-inference
{
  "title": "Project LfQg832p6Jx040809WZc",
  "product_name": "SuperSecret",
  "url": "https://www.example.com",
  "body_content": """What is Project LfQg832p6Jx040809WZc? Project LfQg832p6Jx040809WZc is an internal project that's not public information. This is the plan for the project: Step 1 is writing a blog post about OpenAi and Elasticsearch for private data. Step 2 is noticing that we didn't actually include any private data. Step 3 is including an example about private data

  We also have some super secret API requests as part of this project:
  PUT project/_doc/hello-world
  {
    "secret": "don't share this with anyone!"
  }

  """
}
<p>Next we’ll then head over to our app and search for “What are the steps for the internal project?”</p><p>In summary, we used faceting and filtering to, for certain use cases, reduce the number tokens of context required to engage with ChatGPT. By providing additional context at query time, we showed it is also possible to improve the accuracy of search results.</p><p><a href="https://www.elastic.co/blog/may-2023-launch-announcement"><strong>Learn more about the possibilities with Elasticsearch and AI</strong></a> <strong>.</strong></p><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Costs referred to herein are based on the current OpenAI API pricing and how often we call it when loading our sample app.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-faceting-filtering-more-context</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-faceting-filtering-more-context</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Luca Wintergerst]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9fd006de03fddc5d/6a1711a1dc55de3cf7e00ef7/981b7b0cb9b9ca0561e9c1784f5ce51240199385-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: APM instrumentation, performance, and cost analysis]]></title>
    <description><![CDATA[Learn how to instrument a Python application that uses OpenAI, analyze its performance &amp; cost and integrate large language models (LLMs).]]></description>
    <content:encoded><![CDATA[<p>In a <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">previous blog post</a>, we built a small Python application that queries Elasticsearch using a mix of vector search and BM25 to help find the most relevant results in a proprietary data set. The top hit is then passed to OpenAI, which answers the question for us.</p><p>In this blog, we will instrument a Python application that uses OpenAI and analyze its performance, as well as the cost to run the application. Using the data gathered from the application, we will also show how to integrate large language models (LLMs) into your application. As a bonus, we will try to answer the question: why does ChatGPT print its output word by word?</p><h2>Instrumenting the application with Elastic APM</h2><p>If you’ve had a chance to give our <a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/elasticdocs_gpt.py">sample application</a> a try, you might have noticed that the result does not load as quickly as you’d expect it to, from a search interface.</p><p>The now is if this is from our two-phased approach of running a query in Elasticsearch first or if the slow behavior is emerging from OpenAI, or if it’s a combination of the two.</p><p>Using Elastic APM, we can easily instrument this application to get a better look. All we need to do for the instrumentation is the following (we will show the full example at the end of the blog post and also in a GitHub repository):</p>import elasticapm
# the APM Agent is initialized
apmClient = elasticapm.Client(service_name="elasticdocs-gpt-v2-streaming")

# the default instrumentation is applied
# this will instrument the most common libraries, as well as outgoing http requests
elasticapm.instrument()
<p>Since our sample application is using Streamlit, we will also need to start at least one transaction and eventually end it again. In addition, we can also provide information about the outcome of the transaction to APM, so we can track failures properly.</p># start the APM transaction
apmClient.begin_transaction("user-query")

(...)



elasticapm.set_transaction_outcome("success")

# or "failure" for unsuccessful transactions
# elasticapm.set_transaction_outcome("success")

# end the APM transaction
apmClient.end_transaction("user-query")
<p>And that’s it — this would be enough to have full APM instrumentation for our application. That being said, we will be doing a little extra work here in order to get some more interesting data.</p><p>As a first step, we will add the user’s query to the APM metadata. This way we can inspect what the user was trying to search and can analyze some popular queries or reproduce errors.</p>elasticapm.label(query=query)
<p>In our async method, which talks to OpenAI, we will also add some more instrumentation so we can better visualize the tokens we receive, as well as to collect additional statistics.</p>async with elasticapm.async_capture_span('openaiChatCompletion', span_type='openai'):
        async for chunk in await openai.ChatCompletion.acreate(engine=engine, messages=[{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": truncated_prompt}],stream=True,):
            content = chunk["choices"][0].get("delta", {}).get("content")
            # since we have the stream=True option, we can get the output as it comes in
            # one iteration is one token
	  # we start a new span here for each token. These spans will be aggregated
            # into a compressed span automatically
            with elasticapm.capture_span("token", leaf=True, span_type="http"):
                if content is not None:
                    # concatenate the output to the previous one, so have the full response at the end
                    output += content
                    # with every token we get, we update the element
                    element.markdown(output)
<p>And finally, toward the very end of our application, we will also add the number of tokens and approximate cost to our APM transaction. This will enable us to visualize these metrics later and correlate them to the application performance.</p><p>If you do not use streaming, then the OpenAI response will contain a “total_tokens” field, which is the sum of the context you sent and the response returned. If you are using the “stream=True” option, then it’s your responsibility to calculate the number of tokens or approximate them. A common recommendation is to use “(len(prompt) + len(response)) / 4” for english text, but especially code snippets can throw off this approximation. If you need more exact numbers, you can use libraries like <a href="https://github.com/openai/tiktoken">tiktoken</a> to calculate the number of tokens.</p># add the number of tokens as a metadata label
elasticapm.label(openai_tokens = st.session_state['openai_current_tokens'])
# add the approximate cost as a metadata label
# currently the cost is $0.002 / 1000 tokens
elasticapm.label(openai_cost = st.session_state['openai_current_tokens'] / 1000 * 0.002)

<h2>Analyzing the APM data — Elasticsearch vs. OpenAI performance</h2><p>After instrumenting the application, a quick look at the “Dependencies” gives us a better understanding of what’s going on. It looks like our requests to Elasticsearch return within 125ms on average, while OpenAI takes 8,500ms to complete a request. (This screenshot was taken on a version of the application that does not use streaming. If you use streaming, the default instrumentation only considers the initial POST request in the dependency response time and not the time it takes to stream the full response.)</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdfa68cd926558eb2/6a17117f47d49c04ba2d8b02/78cb423a576c698d97b3d47a13ec896e371008a5-1440x494.png" alt="chatgpt dependencies" /><p>If you’ve used ChatGPT yourself already, you might have been wondering why the UI is printing every word individually, instead of just returning the full response immediately.</p><p>As it turns out, this is not actually to entice you to pay money if you’re using the free version! It’s more of a limitation of the inference model. In simple terms, in order to compute the next token, <a href="https://lilianweng.github.io/posts/2023-01-10-inference-optimization/">the model</a> will need to take into consideration the last token as well. So there’s not much room for parallelization. And since every token is processed individually, this token can also be sent to the client, while the computation for the next token is running.</p><p>In order to improve the UX, it can be helpful to therefore use a streaming approach when using the ChatCompletion functionality. This way a user can start to consume the first results while the full response is being generated. You can see this behavior in the GIF below. Even though all three responses are still loading, the user can scroll down and inspect what’s there already.</p><p>As mentioned previously, we added a bit more custom instrumentation than just the bare minimum. This allows us to get detailed information on where our time is spent. Let’s take a look at a full trace and see this streaming in action.</p><p>Our application is configured to fetch the top three hits from Elasticsearch, and then run one ChatCompletion request against OpenAI in parallel.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6234efc85bc5c69b/6a171180ab7f080136db9f9f/c930cf97ddc51049f839bba4aa41b3d1901f4c67-1440x679.png" alt="elastic openai in parallel" /><p>As we can see in the screenshot, loading the individual results takes about 15s. We can also see that requests to OpenAI that return a larger response take longer to return. But this is only a single request. Does this behavior happen for all requests? Is there a clear correlation between response time and number of tokens to back up our claims from earlier?</p><h2>Analyzing cost and response time</h2><p>Instead of visualizing the data using Elastic APM, we can also use custom dashboards and create visualizations from our APM data. Two interesting charts that we can build show the relationship between the number of tokens in a response and the duration of the request.</p><p>We can see that the more tokens get returned (x-axis in the first chart), the higher the duration (y-axis in the first chart). In the chart to the right, we can also see that the duration per 100 tokens returned stays almost flat at around 4s, no matter the number of tokens returned in total (x-axis).</p><p>If you want to improve the responsiveness of your application that uses OpenAI models, it might be a good idea to tell the model to keep the response short.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3ccfdcdb869cd30e/6a1711820c4857151a01ab8c/6bbadaa8995e8c947b593451732dad3135806871-1440x549.png" alt="chatgpt response time vs tokens" /><p>In addition to this, we can also track our total spend and the average cost per page load, as well as other statistics.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf567d2300b1599d2/6a171183dc55dee5e2e00ee9/d1f6a54c30109a7aea12b7cbcbd183f516f96e51-1440x820.png" alt="chatgpt total cost" /><p>With our sample application, the cost for a single search is around 1.1¢. This number does not sound high, but it’s likely not something that you will have on your public website as a search alternative anytime soon. For company internal data and a search interface that’s only used occasionally, this cost is negligible.</p><p>In our testing, we’ve also hit frequent errors when using the OpenAI API in Azure, which eventually made us add a retry loop to the sample app with an exponential backoff. We can also capture these errors using Elastic APM.</p>while tries &lt; 5:
    try:
        print("request to openai for task number: " + str(index) + " attempt: " + str(tries))
        async with elasticapm.async_capture_span('openaiChatCompletion', span_type='openai'):
            async for chunk in await openai.ChatCompletion.acreate(engine=engine, messages=[{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": truncated_prompt}],stream=True,):
                content = chunk["choices"][0].get("delta", {}).get("content")
                counter += 1
                with elasticapm.capture_span("token", leaf=True, span_type="http"):
                    if content is not None:
                        output += content
                        element.markdown(output)
        break
    except Exception as e:
        client = elasticapm.get_client()
        # capture the exception using Elastic APM and send it to the apm server
        client.capture_exception()
        tries += 1
        time.sleep(tries * tries / 2)
        if tries == 5:
            element.error("Error: " + str(e))
        else:
            print("retrying...")
<p>Any captured errors are then visible in the waterfall charts as part of the span where the failure happened.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf84f271da871a62e/6a1711854a531b456736aa6f/49939b8e2b24eabb366799917681ebd66a8e53fa-1440x871.png" alt="timeline user query" /><p>In addition, Elastic APM also provides an overview of all the errors. In the screenshot below, you can see the occasional RateLimitError and APIConnectionError that we’ve encountered. Using our crude exponential retry mechanism, we can mitigate most of these problems.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1d7f046dcd014818/6a171186a6c2b9203be797ec/203e60003a3f1da6dff6d5d21870bbe32da8c847-1440x764.png" alt="elasticdocs gpt v2 streaming" /><h2>Latency and failed transaction correlation</h2><p>With all the built-in metadata that the Elastic APM agent capture, as well as the custom labels we added, we can easily analyze if there’s any correlation between the performance and any of the metadata (like services version, user query, etc.)</p><p>As we can see below, there’s a small correlation between the query “How can I mount and index on a frozen node?” and a slower response time.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba078944b177b48f/6a171188d7c0228ae7de65a6/3839ef67669dcee18515618997d9e591d9445f63-1440x592.png" alt="latency distribution correlations" /><p>Similar analysis can be done on any transaction that resulted in an error. In this example, the two queries “How do I create an ingest pipeline” and “How can I create an ingest pipeline” fail more often than other queries, causing them to bubble up in this correlation analysis.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d4c16b1beb5ebfa/6a17118947d49cf9452d8b06/a4094ed5ba442a74e87b7fa73c18c110939452ff-1440x639.png" alt="failed transactions latency distribution" />import elasticapm
# the APM Agent is initialized
apmClient = elasticapm.Client(service_name="elasticdocs-gpt-v2-streaming")

# the default instrumentation is applied
# this will instrument the most common libraries, as well as outgoing http requests
elasticapm.instrument()

# if a user clicks the "Search" button in the UI
if submit_button:
	# start the APM transaction
apmClient.begin_transaction("user-query")
# add custom labels to the transaction, so we can see the users question in the API UI
elasticapm.label(query=query)



    async with elasticapm.async_capture_span('openaiChatCompletion', span_type='openai'):
        async for chunk in await openai.ChatCompletion.acreate(engine=engine, messages=[{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": truncated_prompt}],stream=True,):
            content = chunk["choices"][0].get("delta", {}).get("content")
            # since we have the stream=True option, we can get the output as it comes in
            # one iteration is one token
            with elasticapm.capture_span("token", leaf=True, span_type="http"):
                if content is not None:
                    # concatenate the output to the previous one, so have the full response at the end
                    output += content
                    # with every token we get, we update the element
                    element.markdown(output)
async def achat_gpt(prompt, result, index, element, model="gpt-3.5-turbo", max_tokens=1024, max_context_tokens=4000, safety_margin=1000):
    output = ""
    # we create on overall Span here to track the total process of doing the completion
    async with elasticapm.async_capture_span('openaiChatCompletion', span_type='openai'):
        async for chunk in await openai.ChatCompletion.acreate(engine=engine, messages=[{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": truncated_prompt}],stream=True,):
            content = chunk["choices"][0].get("delta", {}).get("content")
            # since we have the stream=True option, we can get the output as it comes in
            # one iteration is one token, so we create one small span for each
            with elasticapm.capture_span("token", leaf=True, span_type="http"):
                if content is not None:
                    # concatenate the output to the previous one, so have the full response at the end
                    output += content
                    # with every token we get, we update the element
                    element.markdown(output)
<p>In this blog, we instrumented an app written in Python to use OpenAI and analyze its performance. We looked at response latency and failed transactions, and we assessed the costs of running the application. We hope this how-to was useful for you!</p><p><a href="https://www.elastic.co/what-is/elasticsearch-machine-learning"><strong>Learn more about the possibilities with Elasticsearch and AI</strong></a> <strong>.</strong></p><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Costs referred to herein are based on the current OpenAI API pricing and how often we call it when loading our sample app.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-apm-instrumentation-performance-cost-analysis</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-apm-instrumentation-performance-cost-analysis</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Luca Wintergerst]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt762b38f91bd71c8e/6a170db8b339d547bb76a048/368db71c500e72d20fe225fe44c2c40231e29765-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Aggregate data faster with new the random_sampler aggregation]]></title>
    <description><![CDATA[Aggregate billions of documents in milliseconds instead of minutes with Elastic. Learn more about how the new random_sampler aggregation gives you statistically robust results at a lower cost.]]></description>
    <content:encoded><![CDATA[<p>With 8.2, the Elastic Stack gives users the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/search-aggregations-random-sampler-aggregation.html"><code>random_sampler</code></a> aggregation. It adds the capability to randomly sample documents in a statistically robust manner. Randomly sampling documents in aggregations allows you to balance speed and accuracy at query time. You can aggregate billions of documents with high accuracy at a fraction of the latency. This allows you to achieve faster results with fewer resources and comparable accuracy — all with a simple aggregation.</p><p>Let's run through some basic details, best practices, and how it works, so you can try it out in the Elasticsearch Service today.</p><h2>Delivering speed and accuracy</h2><p>Random sampling in Elasticsearch has never been easier or faster. If your query has many aggregations, you can quickly obtain results by using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/master/search-aggregations-random-sampler-aggregation.html"><code>random_sampler</code></a> aggregation.</p>POST _search?size=0&amp;track_total_hits=false
{
  "aggs": {
    "sampled": {
      "random_sampler": {
        "probability": 0.001,
        "seed": 42
      },
      "aggs": {
        ...
      }
    }
  }
}
<p>All the above aggregations nested under <code>random_sampler</code> will return sampled results. Each agg is roughly seeing only 0.1% of the documents (or 1 in every 1000th document). Where computational cost correlates with the number of documents, the aggregation speed increases. You may have also noticed the “<code>seed</code>” parameter. You can provide a <code>seed</code>to get consistent results on the same shards. Without a seed, a new random subset of documents is considered and you may get slightly different aggregated results.</p><p>How much faster is the <code>random_sampler</code>? The speed improves according to the provided probability as fewer documents are aggregated. The improvements relative to probability will eventually flatten out. Each aggregation has its own computational overhead regardless of the number of documents. An example of this overhead cost is comparing multi-bucket to single metric aggregations. Multi-bucket aggregations have a higher overhead due to their bucket handling logic. While speed is improved for multi-bucket aggregations, the rate of that speed increase will flatten out sooner than single metric.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1427bb522f9a2e33/6a17113be8fbce617c39fd55/e6a33afc9b30709dd5606bfb23726005f40bb803-800x600.png" alt="expected speedup" /><p>Figure 1. The speedup expected for aggregations of different constant overhead.</p><p>Here are some results on expected speed and error rate over an APM data set of 64 million documents.</p><p>The calculations are from: 300 query and aggregation combinations, 5 seeds, and 9 sampling probabilities. In total, 13,500 separate experiments generated the following graphs for median speedup and median relative error as a function of the downsample factor which is 1 / sample probability.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd264a08140ef2bed/6a17113dacf0883d26be9c4d/77697af30ebd43c64216df0a9e99951191dd612d-800x600.png" alt="median speedup" /><p>Figure 2. Median speedup as a function of the downsample factor (or 1 / probability provided for the sampler).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1f3876b646dd7b14/6a17113e1949f77a4ae7ab20/047bb5dd28bedaee14998168d71ac152e3ea4392-800x600.png" alt="median error" /><p>Figure 3. Median relative error as a function of the downsample factor (or 1 / probability provided for the sampler).</p><p>With a probability of 0.001, for half of the scenarios tested, there was an 80x speed improvement or better with a 4% relative error or less. These tests involved a little over 64 million documents but spread across many shards. More compact shards and larger data can expect better results.</p><p>But, you may ask, do the visualizations look the same?</p><p>Below are two visualizations showing document counts for every 5 minutes over 100+ million documents. The total set loads in seconds and is sampled in milliseconds. This is with almost no discernible visual difference.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e84bd402ad8ec58/6a171140b339d56a6976a0c2/925ab6b529edb5065ec7d0f1886f7bc3eaa7da62-800x158.png" alt="sampled vs unsampled count" /><p>Figure 4. Sampled vs unsampled document count visualizations.</p><p>Here is another example. This time the average transaction by hour is calculated and visualized. While visually these are not exactly the same, the overall trends are still evident. For a quick overview of the data to catch trends, sampling works marvelously.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt583f18fbd2c02001/6a171141acf0885acabe9c51/1998189c574de515e1bff2dca886bd923a2af584-800x250.png" alt="sampled vs unsampled average" /><p>Figure 5. Sampled vs. unsampled average transaction time by hour visualization.</p><h2>Best practices for using sampling aggregation</h2><p>Sampling shines when you have a large data set. In these cases you might ask, should I sample before the data is indexed in Elasticsearch? Sampling at query time and before ingestion are complimentary. Each has its distinct advantages.</p><p>When sampling at ingest time, it can save disk and indexing costs. However, if your data has multiple facets, you have to stratify sampling over facets when sampling before ingestion, unless you know exactly how it will be queried. This suffers from the <a href="https://en.wikipedia.org/wiki/Curse_of_dimensionality">curse of dimensionality</a> and you could end up with underrepresented sets of facets. Furthermore, you have to cater for the worst case when sampling before ingestion. For example, if you want to compute percentiles for two queries, one which matches 50% of the documents and one which matches 1% of documents in an index, you can get away with 7X more downsampling for the first query and achieve the same accuracy.</p><p>Here is a summary of what to expect from sampling with the <code>random_sampler</code> at query time.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt908ecf7a4a90ba82/6a1711436234e00ddcdb1ada/3a1cc19429ee33b95e6a0ab6eed2413665a5e0bd-640x480.png" alt="relative error" /><p>Figure 6. Relative error for different aggregations.</p><p>Sampling accuracy varies across aggregations (see Figure 5 for some examples). Here is a list of some aggregations in order of descending accuracy: <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-aggregation.html">percentiles</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-valuecount-aggregation.html">counts</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-avg-aggregation.html">means</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-sum-aggregation.html">sums</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html">variance</a>, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-min-aggregation.html">minimum</a>, and <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-max-aggregation.html">maximum</a>. Metric aggregation accuracy will also be affected by the underlying data variation: the lower the variation in the values, the fewer samples you need to get accurate aggregate values. The minimum and maximum will not be reliable with outliers, since there is always a reasonable chance that the sampled set misses the one very large (or small) value in the data set. If you are using terms aggregations (or some partitioning such as date histogram), aggregate values for terms (or buckets) with few values will be less accurate or missed altogether.</p><p>Aggregations also have fixed overheads (see Figure 1 for an example). This means as the sample size decreases, the performance improvement will eventually level out. Aggregations which have many buckets have higher overheads and so the speedup you will gain from sampling is smaller. For example, a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">terms aggregation</a> for a high cardinality field will show less performance benefit.</p><p>If in doubt, some simple experiments will often suffice to determine good settings for your data set. For example, suppose you want to speed up a dashboard; try reducing the sample probability while the visualizations look similar enough. Chances are your data characteristics will be stable and so this setting will remain reliable.</p><h2>Uncovering how sampling works</h2><p>Sampling considers the entire document set within a shard. Once it creates the sampled document set, sampling applies any provided user filter. The documents that match the filter and are within the sampled set are then aggregated (see Figure 7).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfed879f2c11f87c2/6a171144ab7f08018ddb9f8f/9145ccea9747229b2d08a8badf6120c20bd0e271-800x227.png" alt="request data flow" /><p>Figure 7. Typical request and data flow for the random_sampler aggregation.</p><p>The key to the sampling is generating this random subset of the shard efficiently and without statistical biases. Taking <a href="https://en.wikipedia.org/wiki/Geometric_distribution">geometrically distributed random</a> steps through the document set is equivalent to uniform random sampling, meaning each document in the set has an equally likely chance of being selected into the sample set. The advantage of this approach is that the sampling cost scales with p (where p is the probability configured in the aggregation). This means no matter how small p is, the relative latency of performing the sampling adds will remain fixed.</p><h2>Ensuring performance reliability and accuracy</h2><p>To achieve the highest performance, accuracy, and robustness, we evaluated a range of realistic scenarios.</p><p>In the case of <code>random_sampler,</code> the evaluation process is complicated by two factors:</p><ol><li><p>It cuts right across the aggregation framework and so it needs to be evaluated with many different combinations of query and aggregation,</p></li><li><p>The results are random numbers, so rather than running just once, you need to run multiple times and test the statistical properties of the result set.</p></li></ol><p>We began with a proof of concept that showed that the overall strategy worked and the performance characteristics were remarkable. However, there are multiple factors which can affect implementation performance and accuracy. For example, we found the off-the-shelf sampling code for the geometric distribution was not fast enough. We decided to roll our own using some tricks to extract more random samples per random bit along with a very fast quantized version of the log function. You also need to be careful that you are generating statistically independent samples for different shards. In summary, as is often the case, the devil is in the details.</p><p>Undaunted, we wrote a test harness using the <a href="https://elasticsearch-py.readthedocs.io/en/stable/">Elastic Python client</a> to programmatically generate aggregations and queries, and perform statistical tests of quality.</p><p>We wanted the approximations we produce to be unbiased. This means if you run a sampled aggregation repeatedly and averaged the results it would converge towards the true value. Standard machinery allows you to test if there is statistically significant evidence of bias. We used a <a href="https://en.wikipedia.org/wiki/Student%27s_t-test">t-test</a> for the difference between the statistic and true value for each aggregation. In over 300 different experiments, the minimum p-value was around 0.0003 which — given we ran 300 experiments — has about a 9% odds of occurring by chance. This is a little low, but not enough to worry about; furthermore the median p-value was 0.38.</p><p>We also tested whether various index properties affect the statistical properties. For example, we wanted to see if we could measure a statistically significant difference between the distribution of results with and without index sorting. A <a href="https://en.wikipedia.org/wiki/Kolmogorov%E2%80%93Smirnov_test">K-S test</a> can be used to check if samples come from the same distribution. In our 300 experiments the smallest p-value was around 0.002 which occurs with odds of about 45% by chance.</p><h2>Get started today</h2><p>We're not done with this feature yet. Once you have the ability to generate fast approximate results, a key question is: how accurate are those results? We're planning to integrate a confidence interval calculation directly into the aggregation framework to answer this efficiently in a future release. Learn more about random_sampler_aggregation in this documentation. You can explore this feature and more with a <a href="https://cloud.elastic.co/registration?elektra=whats-new-elastic-8-1-0-blog">free 14-day trial of Elastic Cloud</a>.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/aggregate-data-faster-with-new-the-random-sampler-aggregation</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/aggregate-data-faster-with-new-the-random-sampler-aggregation</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Benjamin Trent,Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte47734cb679b1cc8/6a171146a929cf44d5ae0ac5/bc75e4b6f15f183c75c931db011791301523d0cb-1217x840.png" length="0" type="image/png"/>
    <pubDate>Wed, 20 Apr 2022 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>