<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title><![CDATA[Elasticsearch Labs]]></title>
        <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
        <link>https://www.elastic.co/search-labs</link>
        <image>
            <url>https://www.elastic.co/search-labs/favicon.svg</url>
            <title>Elasticsearch Labs</title>
            <link>https://www.elastic.co/search-labs</link>
        </image>
        <generator>Elasticsearch Labs</generator>
        <lastBuildDate>Thu, 09 Apr 2026 14:26:37 GMT</lastBuildDate>
        <atom:link href="https://www.elastic.co/search-labs/rss/feed" rel="self" type="application/rss+xml"/>
        <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
        <language><![CDATA[en]]></language>
        <item>
            <title><![CDATA[How to build agentic AI applications with Mastra and Elasticsearch]]></title>
            <description><![CDATA[Learn how to build agentic AI applications using Mastra and Elasticsearch through a practical example.]]></description>
            <link>https://www.elastic.co/search-labs/blog/build-agentic-ai-applications-mastra-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/build-agentic-ai-applications-mastra-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Enrico Zimuel]]></dc:creator>
            <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ab30baf2f908534840c5d71a46705773807baf54-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we’ll walk through how to use the <a href="https://mastra.ai/">Mastra</a> TypeScript framework to build agentic applications that interact with <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a>.</p><p>We recently contributed to the <a href="https://github.com/mastra-ai/mastra">mastra-ai/mastra</a> open source project by adding support for Elasticsearch as a vector database. With this new feature, you can use Elasticsearch natively in Mastra to store embeddings. In addition to vectors, Elasticsearch provides a suite of advanced features to address all your context engineering requirements. (for example, <a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-evolution-agentic-ai">hybrid search and reranking</a>).</p><p>This article details the creation of an agent to implement a retrieval augmented generation (RAG) architecture using Elasticsearch. We’ll showcase a demo project where an agentic approach is used to interact with a corpus of sci-fi movie data stored within Elasticsearch. The project is available at <a href="https://github.com/elastic/mastra-elasticsearch-example">elastic/mastra-elasticsearch-example</a>.</p><h2>Mastra</h2><p>Mastra is a TypeScript framework to create agentic AI applications.</p><p>A project structure in Mastra looks as follows:</p><pre><code>src/
├── mastra/
│   ├── agents/
│   │   └── weather-agent.ts
│   ├── tools/
│   │   └── weather-tool.ts
│   ├── workflows/
│   │   └── weather-workflow.ts
│   ├── scorers/
│   │   └── weather-scorer.ts
│   └── index.ts
├── .env.example
├── package.json
└── tsconfig.json</code></pre><p>In Mastra, you can build <a href="https://mastra.ai/docs/agents/overview">agents</a>, <a href="https://mastra.ai/docs/agents/using-tools">tools</a>, <a href="https://mastra.ai/docs/workflows/overview">workflows</a>, and <a href="https://mastra.ai/docs/evals/overview">scores</a>.</p><p>An <strong>agent</strong> is a class that accepts a message in input and produces a response as output. An agent can use tools, large language models (LLMs), and a memory (figure 1).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f6aca2dcc7fcc45d25e06681649be1b2b7eb6781-706x721.png" style="width: 100%;" /><p>An agent&#x27;s <strong>tools</strong> allow it to interact with the &quot;external world,&quot; such as communicating with a web API or performing an internal operation, like querying Elasticsearch. The <strong>memory</strong> component is crucial for storing the history of conversations, including past inputs and outputs. This stored context enables the agent to provide more informed and relevant responses to future questions by using its past interactions.</p><p><strong>Workflows</strong> let you define complex sequences of tasks using clear, structured steps rather than relying on the reasoning of a single agent (figure 2). They give you full control over how tasks are broken down, how data moves between them, and what gets executed when. Workflows run using the built-in execution engine by default or can be deployed to <a href="https://mastra.ai/docs/deployment/workflow-runners">workflow runners</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0dce161cf7891207015dc87532b5b90df1822432-880x252.png" style="width: 100%;" /><p>In Mastra, you can also define scores, which are automated tests that evaluate agent outputs using model-graded, rule-based, and statistical methods. Scorers return <em>scores</em>: numerical values (typically between 0 and 1) that quantify how well an output meets your evaluation criteria. These scores enable you to objectively track performance, compare different approaches, and identify areas for improvement in your AI systems. Scorers can be customized with your own prompts and scoring functions.</p><h2>Elasticsearch</h2><p>For running the demo project, we need to have an Elasticsearch instance running. You can activate a free trial on <a href="https://www.elastic.co/cloud">Elastic Cloud</a> or install it locally using the <a href="https://github.com/elastic/start-local"><code>start-local</code></a> script:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>This will install Elasticsearch and Kibana on your computer and generate an API key to be used for configuring the Mastra integration.</p><p>The API key will be shown as output of the previous command and stored in a <strong>.env</strong> file in the elastic-start-local folder.</p><h2>Install and configure the demo</h2><p>We created an <a href="https://github.com/elastic/mastra-elasticsearch-example">elastic/mastra-elasticsearch-example</a> repository containing the source code of the demo project. The example reported in the repository illustrates how to create an agent in Mastra that implements a RAG architecture for retrieving documents from Elasticsearch.</p><p>We provided a dataset for the demo about sci-fi movies. We extracted 500 movies from the IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>.</p><p>The first step is to install the dependencies of the project with npm, using the following command:</p><pre><code>npm install</code></pre><p>Then we need to configure the <strong>.env</strong> file that will contain the settings. We can generate this file copying the structure from the <strong>.env.example</strong> file, using the following command:</p><pre><code>cp .env.example .env</code></pre><p>Now we can edit the .env, adding the missing information:</p><pre><code>OPENAI_API_KEY=
ELASTICSEARCH_URL=
ELASTICSEARCH_API_KEY=
ELASTICSEARCH_INDEX_NAME=scifi-movies</code></pre><p>The name of the Elasticsearch index is <strong><code>scifi-movies</code></strong>. If you want, you can change it using the env variable <code>ELASTICSEARCH_INDEX_NAME</code>.</p><p>We used OpenAI as embedding service, which means that you need to provide an API key for OpenAI in the <code>OPENAI_API_KEY</code> env variable.</p><p>The embedding model used in the example is <a href="https://developers.openai.com/api/docs/models/text-embedding-3-small">openai/text-embedding-3-small</a>, with an embedding dimension of 1536.</p><p>To generate the final answer, we used the <a href="https://developers.openai.com/api/docs/models/gpt-5-nano">openai/gpt-5-nano</a> model to reduce the costs.</p><p>The RAG architecture allows you to use a less powerful (and typically less expensive) final LLM model because the heavy lifting of grounding the answer is done by the retrieval component (Elasticsearch in this case).</p><p>The smaller LLM is only responsible for two main tasks:</p><ul><li><strong>Rephrasing/embedding the query:</strong> Converting the user&#x27;s natural language question into a vector embedding for semantic search.</li><li><strong>Synthesizing the answer:</strong> Taking the highly relevant, retrieved context chunks (documents/movies) and synthesizing them into a coherent, final, human-readable answer, following the provided prompt instructions.</li></ul><p>Since the RAG process <strong>provides the exact factual context</strong> needed for the answer, the final LLM doesn&#x27;t need to be massive or highly complex and it doesn’t need to possess all the required knowledge within its own parameters (which is where large, expensive models excel). It essentially acts as a sophisticated text summarizer and formatter for the context provided by Elasticsearch, rather than as a full-fledged knowledge base itself. This enables the use of models like <code>gpt-5-nano</code> for cost and latency optimization.</p><p>After the configuration of the .env file, you can ingest the movies to Elasticsearch using the following command:</p><pre><code>npx tsx src/utility/store.ts</code></pre><p>You should see an output as follows:</p><pre><code>🚀 Starting ingestion of 500 movies from 500_scifi_movies.jsonl...
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 1/500 (0%) | ok:1 | fail:0 | chunks:1 | eta:19m 33s | current:Capricorn One
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 2/500 (0%) | ok:2 | fail:0 | chunks:2 | eta:10m 32s | current:Doghouse
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 3/500 (1%) | ok:3 | fail:0 | chunks:3 | eta:7m 33s | current:Dinocroc
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 4/500 (1%) | ok:4 | fail:0 | chunks:7 | eta:6m 10s | current:Back to the Future           
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 5/500 (1%) | ok:5 | fail:0 | chunks:9 | eta:5m 14s | current:The Projected Man            
Ingesting ░░░░░░░░░░░░░░░░░░░░░░░░ 6/500 (1%) | ok:6 | fail:0 | chunks:11 | eta:4m 41s | current:I, Robot
...
✅ Ingestion complete in 1m 46s. Success: 500, Failed: 0, Chunks: 693.</code></pre><p>The mapping of the scifi-movies index contains the following fields:</p><ul><li><strong>embedding</strong>, dense_vector with 1536 dimension, cosine similarity.</li><li><strong>description</strong>, text containing the description of the movie.</li><li><strong>director</strong>, text containing the name of the director.</li><li><strong>title</strong>, text containing the title of the movie.</li></ul><p>We generated the embeddings using the title + description. Since the title and the description are two separate fields, the concatenation of both ensures that the resulting embedding vector captures both the specific, unique identity (title) and the rich, descriptive context (description) of the movie, leading to more accurate and comprehensive semantic search results. This combined input gives the embedding model a better single representation of the document&#x27;s content for similarity matching.</p><h2>Run the demo</h2><p>You can run the demo with the following command:</p><pre><code>npm run dev</code></pre><p>This command will start a web application at <strong>localhost:4111</strong> to access Mastra Studio (figure 3).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1567e309df21a12bcf1dfef4429f82342549956c-1705x1079.png" style="width: 100%;" /><p><a href="https://mastra.ai/docs/getting-started/studio">Mastra Studio</a> offers an interactive UI for building and testing your agents, along with a REST API that exposes your Mastra application as a local service. This lets you start building right away without worrying about integration.</p><p>We provided an <strong>Elasticsearch Agent</strong> that uses the <a href="https://mastra.ai/reference/tools/vector-query-tool">createVectorQueryTool</a> by Mastra as a tool for executing semantic search using Elasticsearch. This agent uses the RAG approach to search for relevant documents (that is, movies) to answer the user’s question.</p><p>This agent uses the following prompt:</p><pre><code>You are a helpful assistant that answers questions based on the provided context.
Follow these steps for each response:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public string Name { get; set; }

    public string Brand { get; set; }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Key Recommendations

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

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

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

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

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

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

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

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

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

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

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


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

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


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

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

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

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

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

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

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

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

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

                    try:
                        chunk_data = json.loads(data_content)

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

    full_response = ""

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

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

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

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

        parsed = json.loads(cleaned_text)

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

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

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

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

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

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

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

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

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

context, articles, recommendations = recommend_articles(search_query)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 ✓  Shell 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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


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


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


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


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

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

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

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

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

   return format_response(normalized)


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

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

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

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

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

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

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

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

   return format_response(clean_result.strip())


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

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

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

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

...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hybrid search (BM25 + kNN + RRF) top results:
1. Edge of Tomorrow (2014)
2. The Witch: Part 1 - The Subversion (2018)
3. Boss Level (2020)
4. Divinity (2023)
5. The Maze Runner (2014)</code></pre><h2><strong>Why these results? </strong></h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped/escape) can appear.</li><li>Hybrid (BM25 + kNN + RRF) rewards documents that match both keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</li></ul><p>Hybrid search doesn’t guarantee that every result is perfect. It balances lexical and semantic signals so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords.</p><h2>Full code example</h2><p>You can find our full demo code in Python and JavaScript, as well as the dataset used, hosted on <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>.</p><h2>Conclusion</h2><p>Hybrid search provides a pragmatic and powerful retrieval strategy by combining traditional BM25 keyword search with modern vector similarity into a single, unified ranking. Instead of choosing between lexical precision and semantic understanding, you get the best of both worlds, without adding significant complexity to your application.</p><p>In real-world datasets, this approach consistently yields results that feel more intuitively correct. Exact term matches help anchor results to the user’s explicit intent, while embeddings ensure robustness against paraphrasing, synonyms, and incomplete queries. This balance is especially valuable for noisy, heterogeneous, or user-generated content, where relying on only one retrieval method often falls short.</p><p>In this article, we demonstrated how to use hybrid search in LangChain through its Elasticsearch integrations, with complete examples in both Python and JavaScript. We’re also contributing to other open-source projects, such as <a href="https://github.com/langchain4j/langchain4j/pull/4069">LangChain4j</a>, to extend hybrid search support with Elasticsearch.</p><p>We believe hybrid search will be a key capability for generative AI (GenAI) and agentic AI applications, and we plan to continue collaborating with libraries, frameworks, and programming languages across the ecosystem to make high-quality retrieval more accessible and robust.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to defend your RAG system from context poisoning]]></title>
            <description><![CDATA[How context engineering techniques prevent context poisoning in LLM responses.]]></description>
            <link>https://www.elastic.co/search-labs/blog/context-poisoning-llm</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/context-poisoning-llm</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Tue, 10 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/32e26f6dc2c8fc41d4c1d981eff6487eb8423fa5-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>With the recent release of models like Claude Sonnet 4.5, Gemini 3 series and GPT 5 featuring million-token context windows, there’s a growing misconception that context management is becoming simpler. If a large language model (LLM) can process millions of tokens at once, does it really matter what information we provide?</p><p>The reality is precisely the opposite. <a href="https://www.elastic.co/search-labs/blog/context-engineering-overview"><em>Context engineering</em></a>, the practice of managing what information reaches your LLM, is more critical than ever. Large context windows don’t eliminate the need for precision; they amplify it. With more context comes exponentially more opportunities for error, hallucinations, and irrelevant information to contaminate your LLM reasoning process.</p><p>Whether you’re using retrieval-augmented generation (RAG) retrieval, tool outputs, or memory systems, effective context engineering isn’t about providing more information but about providing <em>the right</em> information. That’s where Elasticsearch comes in, serving as your context engineering platform.</p><p>In this article, we’ll explore what context poisoning is, how it manifests across different types of memory, and how Elasticsearch RAG capabilities provide defense at every stage of the retrieval pipeline, from ingestion to composition, ensuring your LLM receives clean, relevant, and reliable context.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d832932f12723226ecc98e2a9b8d6bfc008829bf-1080x571.png" style="width: 100%;" /><h2>What is context poisoning?</h2><p><em>Context poisoning</em> occurs when compromised, outdated, or irrelevant information enters an LLM’s context window, leading to degraded responses, hallucinations, or perpetuated errors. Once corrupted or incorrect information enters the context window, it propagates into answers. The LLM references it as truth, creating cascading errors across the conversation.</p><p>This poisoning can happen at multiple stages of the LLM lifecycle (like in training), but our focus is on the retrieval and composition stages. Although adversarial attacks, like prompt injection, also pose risks, this article focuses on the operational patterns that teams encounter most frequently in production environments.</p><h3><strong>Operational understanding</strong></h3><p>Context poisoning often happens for reasons like:</p><ul><li><strong>Context rot:</strong> Information becomes outdated but remains in your knowledge base without being updated or deleted.</li><li><strong>Context overflow: </strong>Too much information overwhelms the LLM&#x27;s attention to the real important and relevant context, leading to missing relevant information from answers.</li><li><strong>Conflicting information:</strong> Multiple sources provide contradictory data, confusing the model.</li><li><strong>Semantic noise: </strong>Vectorial similar but contextually irrelevant content dilutes relevance.</li><li><strong>Malicious injection: </strong>Content deliberately inserted by attackers into knowledge bases, including prompt injections or manipulated data.</li></ul><p>Understanding these patterns is the first step toward building robust defenses. Let’s examine each pattern and how Elasticsearch helps you address them. You can follow along with the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/context-poisoning">supporting notebook</a>.</p><h2>Types of context poisoning</h2><h3><strong>Temporal degradation</strong></h3><p>Over time, information in your knowledge base becomes outdated, and without proper management, stale content continues to be retrieved and presented to your LLM as current truth. This is especially problematic in industries where information changes frequently, like product documentation, pricing, regulations, or news.</p><h4><strong>Impact</strong></h4><p>Your LLM provides outdated advice, references to deprecated features, or contradictions to current reality, disengaging user trust.</p><h4><strong>Solutions: Temporal filtering in hybrid search</strong></h4><p>Elasticsearch’s date-based query capabilities ensure your RAG system prioritizes recent and relevant information through explicit temporal filters.</p><p><strong>Example: Product documentation search with time filtering</strong></p><p>A user asks your chatbot about authentication setup. Six months ago, the authentication had a significant change, so it’s important to only return documents from six months or earlier.</p><h3><strong>Without temporal filtering</strong></h3><pre><code>POST product-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "how to configure OAuth authentication"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "configure OAuth authentication",
                "fields": ["title^2", "content"]
              }
            }
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "last_updated", "version", "content_snippet"]
}</code></pre><h4><strong>Response without filtering: Contradictory results</strong></h4><p>The LLM receives three different methods for OAuth configuration: the current security API (9.x), legacy realm settings (7.x), and the deprecated shield plugin (6.x). This contradictory context leads to confused or misleading responses:</p><pre><code>{
  "hits": {
    "total": { "value": 23 },
    "max_score": 24.5,
    "hits": [
      {
        "_id": "doc-oauth-2025",
        "_score": 24.5,
        "_source": {
          "title": "OAuth 2.0 Authentication Setup",
          "last_updated": "2025-10-15",
          "version": "9.x",
          "content_snippet": "To configure OAuth 2.0 authentication in Elasticsearch 9.x, use the new security API..."
        }
      },
      {
        "_id": "doc-oauth-2023",
        "_score": 23.8,
        "_source": {
          "title": "OAuth Authentication Configuration",
          "last_updated": "2023-04-20",
          "version": "7.x",
          "content_snippet": "Configure OAuth using the legacy realm settings in elasticsearch.yml..."
        }
      },
      {
        "_id": "doc-oauth-deprecated",
        "_score": 22.9,
        "_source": {
          "title": "Setting Up OAuth (Deprecated)",
          "last_updated": "2022-11-10",
          "version": "6.x",
          "content_snippet": "Use the shield plugin to configure OAuth authentication..."
        }
      }
    ]
  }
}</code></pre><h4><strong>With temporal filtering</strong></h4><p>Add a filter to restrict results to documents updated within the last six months:</p><pre><code>POST product-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "how to configure OAuth authentication"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "configure OAuth authentication",
                "fields": ["title^2", "content"]
              }
            }
          }
        }
      ],
      "filter": [
        {"range": {"last_updated": {"gte": "now-6M"}}},
        {"term": {"status": "published"}}
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "last_updated", "version", "content_snippet"],
  "size": 5
}</code></pre><h4><strong>This hybrid search query</strong></h4><ul><li><strong>Semantic search</strong> (<code>semantic</code>) captures related concepts and context using the <code>content_semantic</code> field.</li><li><strong>Lexical search</strong> (<code>multi_match</code>) matches exact keywords like “OAuth” with field boosting <code>title^2</code>.</li><li><strong>Reciprocal rank fusion (RRF)</strong> combines both results sets with balanced reranking, retrieving the most relevant results.</li><li><strong>Temporal filter</strong> ensures only documents updated within the last six months are retrieved.</li><li><strong>Status filter</strong> restricts results to published documents, excluding drafts or deprecated content.</li></ul><h4><strong>Response with temporal filtering: Consistent results</strong></h4><p>The temporal filtering eliminated outdated documents, leaving only current documentation for version 9.x. The LLM now receives consistent context and generates confident, accurate responses:</p><pre><code>{
  "hits": {
    "hits": [
      {
        "_source": {
          "title": "OAuth 2.0 Authentication Setup",
          "last_updated": "2026-01-15",
          "version": "9.x",
          "content_snippet": "Configure OAuth 2.0 in Elasticsearch 9.x using the security API via Stack Management > Security."
        }
      },
      {
        "_source": {
          "title": "OAuth Provider Configuration",
          "last_updated": "2025-12-20",
          "version": "9.x",
          "content_snippet": "Configure Okta, Azure AD, Auth0 via security API with OIDC auto-discovery."
        }
      }
    ]
  }
}</code></pre><h4><strong>Relative versus absolute time filters</strong></h4><p><strong>Relative filtering </strong>(recommended for most use cases):</p><pre><code>"filter": [
  {
    "range": {
      "last_updated": {
        "gte": "now-1y"
      }
    }
  }
]</code></pre><p><strong>Absolute filtering </strong>(for specific time ranges):</p><pre><code>"filter": [
  {
    "range": {
      "last_updated": {
        "gte": "2025-01-01",
        "lte": "2025-12-31"
      }
    }
  }
]</code></pre><h4><strong>Impact on LLM response quality</strong></h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0c225743cc92df873bd8685b5073fe749f4d2da6-1600x414.png" style="width: 100%;" /><ul><li><strong>Without filtering: </strong>LLM receives contradictory guidance from 2023–2025, producing uncertain responses mixing deprecated and current methods.</li><li><strong>With temporal filtering: </strong>LLM receives only recent documentation, generating confident responses based on current best practices.</li></ul><h3><strong>Information conflicts</strong></h3><p>When your RAG system retrieves documentation for features that behave differently across deployment types, versions, or configurations, conflicting information can confuse the LLM about which guidance applies to the user’s specific context.</p><h4><strong>Impact</strong></h4><p>The LLM has to use more resources and tokens to understand and determine which information is correct, becoming more prone to errors and hallucinations.</p><h4><strong>Solutions: Hybrid search with metadata boosting</strong></h4><p>Elasticsearch’s <code>bool</code> query with a <code>should</code> clause allows you to boost values to prioritize documents matching specific metadata, ensuring deployment-specific or version-specific documentation appears first in the context window. For query syntax details, refer to <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">Bool query reference</a>.</p><h4><strong>Example: Deployment-specific feature documentation</strong></h4><p>A user asks, “How do I configure custom users in serverless?” Your knowledge base contains information about cloud, self-hosted, and managed deployments. With proper metadata prioritization, the LLM retrieves signals about feature availability and provides correct guidance:</p><pre><code>POST platform-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "bool": {
                "must": [
                  {
                    "multi_match": {
                      "query": "How do I configure custom users in serverless?",
                      "fields": ["title^2", "content"]
                    }
                  }
                ],
                "should": [
                  {"term": {"deployment_type": {"value": "serverless", "boost": 3.0}}},
                  {"term": {"doc_status": {"value": "current", "boost": 2.0}}}
                ]
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "How do I configure custom users in serverless?"
              }
            }
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "deployment_type", "feature_supported", "content_snippet"],
  "size": 5
}</code></pre><h4><strong>What this query does</strong></h4><ul><li><code>must</code> clause: All documents must match “How do I configure custom users in serverless?”</li><li><code>should</code> clauses with explicit boosting:<ul><li>Documents with <code>deployment_type: “serverless”</code> receive 3x boost.</li><li>Documents with <code>doc_status: “current”</code> receive 2x boost.</li></ul></li><li>Semantic search runs in parallel to capture conceptual matches.</li><li>RRF combines lexical (with metadata boosting) and semantic results to get the best of both approaches.</li></ul><h4><strong>Expected response:</strong></h4><pre><code>{
  "hits": {
    "hits": [
      {
        "_source": {
          "title": "Authentication in Serverless",
          "deployment_type": "serverless",
          "feature_supported": false,
          "content_snippet": "Custom authentication not available in Serverless. Use SSO with your identity provider."
        }
      },
      {
        "_source": {
          "title": "User Management in Serverless",
          "deployment_type": "serverless",
          "feature_supported": false,
          "content_snippet": "Direct user creation not supported in Serverless. Use your organization's IdP."
        }
      },
      {
        "_source": {
          "title": "Role-Based Access in Serverless",
          "deployment_type": "serverless",
          "feature_supported": true,
          "content_snippet": "Configure roles in Serverless console. Roles sync with SSO provider groups."
        }
      },
      {
        "_source": {
          "title": "API Keys in Serverless",
          "deployment_type": "serverless",
          "feature_supported": true,
          "content_snippet": "Create API keys for programmatic Serverless access. Keys inherit user permissions."
        }
      },
      {
        "_source": {
          "title": "SSO Configuration for Serverless",
          "deployment_type": "serverless",
          "feature_supported": true,
          "content_snippet": "Configure SSO in Serverless via Cloud console with SAML 2.0 or OIDC."
        }
      }
    ]
  }
}</code></pre><h4><strong>How metadata boosting resolves conflicts</strong></h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/43a9227fc651ebd6ed6f0058972e0dac502aa7c5-955x1600.png" style="width: 100%;" /><h4><strong>Impact on LLM response quality</strong></h4><ul><li><strong>Without metadata boosting: </strong>The<strong> </strong>context window receives equal-weight documents from all deployment types. The LLM produces vague responses that hedge between possibilities, failing to clearly state deployment-specific limitations.</li><li><strong>With metadata boosting (3x): </strong>Managed-specific documentation dominates the top results. The LLM generates direct answers about feature unavailability and provides actionable alternatives while maintaining the cross-deployment context for follow-up questions.</li></ul><h3><strong>Semantic noise</strong></h3><p>Vector similarity search can retrieve documents that are semantically related but contextually irrelevant to the user’s need. This “semantic drift” occurs when embeddings capture a similarity without understanding the query intent. So when your context window fills with irrelevant information, the LLM&#x27;s ability to generate precise answers declines.</p><h4><strong>Impact</strong></h4><p>The LLM receives correct information that doesn’t answer the question, wasting the context window and lowering the quality of the provided answer.</p><h4><strong>Solution: Hybrid search</strong></h4><p>Elasticsearch hybrid search combines lexical precision with semantic understanding, using explicit product filters to eliminate cross-product drift while maintaining conceptual recall.</p><h4><strong>Example: Technical documentation search</strong></h4><p>A developer searches for “Elastic Agent configuration,” and your knowledge base contains both the Elastic Agent (Elastic Observability) and the Elastic Agent Builder documentation. Both use the word &quot;agent&quot; prominently, making them semantically similar.</p><p>Let’s search for agent configuration documentation:</p><pre><code>POST elastic-docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "agent configuration logs metrics collection",
                "fields": ["title^3", "content", "tags^2"],
                "type": "best_fields"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content_semantic",
                "query": "configuring agents to collect logs and metrics from hosts"
              }
            }
          }
        }
      ],
      "filter": [
        {"terms": {"product": ["observability", "elastic-agent"]}},
        {"term": {"doc_type": "configuration"}}
      ],
      "rank_window_size": 50,
      "rank_constant": 20
    }
  },
  "_source": ["title", "product", "tags", "url"],
  "size": 5
}</code></pre><p>This hybrid query:</p><ul><li><strong>Lexical component</strong> (<code>multi_match</code>) ensures exact keyword matches for &quot;agent&quot;, &quot;configuration&quot;, &quot;logs&quot;, &quot;metrics&quot;, and “collection”.</li><li><strong>Field boosting</strong> (<code>title^3</code>, <code>tags^2</code>) prioritizes documents where terms appear in important fields.</li><li><strong>Semantic component</strong> captures conceptual relationships and the intent about “configuring data collection agents”.</li><li><strong>RRF </strong>merges both result sets with balanced ranking using <code>rank_constant: 20</code>.</li><li><strong>Product filter </strong>restricts results to Elastic Observability and Elastic Agent domains, eliminating Agent Builder docs entirely.</li><li><strong>Category filter</strong> restricts results to &quot;observability&quot; and &quot;elastic-agent&quot; domains, eliminating semantic drift to other domains.</li></ul><p><strong>Expected response:</strong><br/></p><pre><code>{
  "hits": {
    "hits": [
      {
        "_source": {
          "title": "Elastic Agent Input Configuration",
          "product": "elastic-agent",
          "tags": ["inputs", "logs", "metrics", "configuration"],
          "url": "/docs/elastic-agent/inputs"
        }
      },
      {
        "_source": {
          "title": "Configure Elastic Agent for Log and Metric Collection",
          "product": "elastic-agent",
          "tags": ["configuration", "logs", "metrics", "observability"],
          "url": "/docs/elastic-agent/configure"
        }
      },
      {
        "_source": {
          "title": "Agent Policies and Integrations",
          "product": "observability",
          "tags": ["policies", "integrations", "fleet"],
          "url": "/docs/fleet/policies"
        }
      },
      {
        "_source": {
          "title": "Configuring Agent Outputs",
          "product": "elastic-agent",
          "tags": ["outputs", "elasticsearch", "logstash"],
          "url": "/docs/elastic-agent/outputs"
        }
      },
      {
        "_source": {
          "title": "Manage Elastic Agents with Fleet",
          "product": "observability",
          "tags": ["fleet", "agent-management", "deployment"],
          "url": "/docs/fleet/manage-agents"
        }
      }
    ]
  }
}</code></pre><h4><strong>Why hybrid search works</strong></h4>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Search type</td><td style="border: 1px solid #ccc; padding: 4px;">Strengths</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Lexical only</td><td style="border: 1px solid #ccc; padding: 4px;">Precise keyword matching</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Semantic only</td><td style="border: 1px solid #ccc; padding: 4px;">Captures semantic meaning</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Hybrid search</td><td style="border: 1px solid #ccc; padding: 4px;">Precision and recall, intent understanding</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Before and after: LLM response comparison</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/437dbe84121502b6c9b320d29b0df5ac67bd35fa-1600x371.png" style="width: 100%;" /><h2>Elasticsearch RAG best practices</h2><p>Following these best practices optimizes your context engineering and significantly reduces the risk of context poisoning in your RAG systems. By implementing the following strategies, you ensure that every token in your context window contributes to relevant, accurate, and trustworthy LLM responses.</p><ol><li><strong>Choose the right search strategy for your data: <br/></strong>Select your search approach based on your data characteristics and query patterns. Choose between lexical, semantic, or hybrid search. For more details, refer to <a href="https://www.elastic.co/docs/solutions/search/search-approaches">Search approaches | Elastic Docs</a>.</li><li><strong>Implement temporal awareness<br/></strong>Time-sensitive information requires active management to prevent outdated content from contaminating your context window. Use range queries with relative time filters (like <code>now-6M</code> or <code>now-1y</code>) for content that changes frequently, ensuring your RAG system prioritizes recent content. For more details, refer to <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-range-query">Range query | Reference</a>. </li><li><strong>Use metadata boosting<br/></strong>When your knowledge base contains similar content across different contexts, such as multiple product versions, deployment types, or user roles, metadata boosting helps prioritize contextually relevant results. For more details, refer to <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">Boolean query | Reference</a>.</li><li><strong>Apply reranking when needed<br/></strong>For complex or high-priority queries where precision is critical, consider implementing a reranker solution where the model can significantly improve the search result quality by reordering results based on the semantic understanding of the queries and documents. For more details, refer to <a href="https://www.elastic.co/docs/solutions/search/ranking">Ranking and reranking | Elastic Docs</a>.</li><li><strong>Optimize chunking strategies<br/></strong><em>Chunking</em> is the process of breaking down large text into smaller “chunks.” Document chunking strategy affects both semantic representation and retrieval precision. Smaller chunks provide more granularity, but you may lose context; and larger chunks preserve more context, but you reduce retrieval precision. For more details, refer to <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">Understanding chunking strategies in Elasticsearch</a>.</li><li><strong>Filter the data before it reaches the LLM<br/></strong>Vector similarity search can retrieve semantically related but contextually irrelevant documents. Apply explicit filters on product, category, or domain fields to constrain results to the appropriate context before delivering the results to the LLM. For more details, refer to <a href="https://www.elastic.co/search-labs/blog/rag-in-production">RAG pipelines in production: Operationalize your GenAI project - Elasticsearch Labs</a>.</li><li><strong>Calibrate your retrieval volume (k)<br/></strong>Finding the &quot;Goldilocks zone&quot; for the number of documents retrieved is essential. Too few results lead to incomplete answers, while too many can cause the LLM to miss key facts. Balance your token budget against the depth of the model&#x27;s window. For more details, refer to <a href="https://www.elastic.co/docs/solutions/search/vector/knn">kNN search in Elasticsearch | Elastic Docs</a>.</li><li><strong>Consider summarization for large documents<br/></strong>When retrieved content exceeds your context budget, summarization techniques help retain essential information while reducing token count. For more details, refer to <a href="https://www.elastic.co/search-labs/blog/ai-generated-summaries-using-elastic">Adding AI summaries to your site with Elastic - Elasticsearch Labs</a>.</li><li><strong>Monitor and iterate<br/></strong>Over time, as knowledge bases grow and content evolves, we recommend that you implement monitoring to track relevance score distributions, temporal patterns in retrieved results, and user feedback signals. Watch for signs like outdated documents, declining user satisfaction scores, or a growing number of “no relevant results” queries. For more details, refer to <a href="https://www.elastic.co/observability-labs/blog/data-quality-and-failure-store-in-streams">Elastic Observability: Streams Data Quality and Failure Store Insights</a>.</li></ol><h2>Conclusion</h2><p>The new era of million-token context windows has not made context management obsolete; it has made context engineering more critical than ever. As context windows grow, so does the potential for poisoning from any source retrieval, tools, or memory.</p><p>The patterns shown in this article apply beyond just RAG. Temporal filtering, metadata boosting, and hybrid search are foundational techniques that improve context quality, regardless of source.</p><p>By implementing these strategies, you maintain control over what information reaches your LLM, ensuring relevance, accuracy, and trust at scale.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[jina-vlm: Seeing like an AI with vision language models]]></title>
            <description><![CDATA[Learn about vision language models (VLMs), what jina-vlm can do, how to use it, and best practices.]]></description>
            <link>https://www.elastic.co/search-labs/blog/visual-language-model-jina-vlm</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/visual-language-model-jina-vlm</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Scott Martens]]></dc:creator>
            <pubDate>Mon, 09 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/38c086d709c066acaab063deee25ad23ce488a30-1600x900.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Vision-language models (VLMs) are generative language models that take texts and images as input and generate new text as outputs. They’re designed for <em>visual question-answering</em> (VQA), an application of multimodal AI that fuses image and natural language processing (NLP) in ways that would have been impossible a few years ago. Jina’s new model, <code>jina-vlm</code>, breaks new ground for VQA with state-of-the-art semantic processing of images like scans, slides, and diagrams that combine text and visual materials to carry information instead of natural scenes like photographs.</p><p><code>jina-vlm</code> opens up new application areas:</p><ul><li><strong>Accessibility:</strong> Creating descriptions of images for visually impaired people.</li><li><strong>Indexing:</strong> Generating detailed textual information to improve search for images and documents that contain images.</li><li><strong>Querying:</strong> Matching natural language queries to images through direct analysis of image content.</li><li><strong>Visual content analysis:</strong> Automatically inspecting images for classification and analysis.</li><li><strong>Retrieval-augmented generation (RAG):</strong> Using visual information as a knowledge source in RAG systems.</li></ul><p>This article will introduce you to VLMs and show you how to use <code>jina-vlm</code> to bring multimodal AI to your own solutions.</p><h2>Vision-language models</h2><p><code>jina-vlm</code> is a decoder-only language model with additional features and training to answer questions or create general descriptions of images.</p><p>We adapted the <a href="https://huggingface.co/Qwen/Qwen3-1.7B-Base">Qwen3-1.7B-Base</a> open weight language model as the backbone of <code>jina-vlm</code>. What makes it a <em>vision</em> language model is that we’ve connected it to a custom fine-tuned image encoder model, derived from the <a href="https://huggingface.co/google/siglip2-so400m-patch14-384">SigLIP2-So400M/14-384</a> model, which converts images into a form that the language model can understand and trained it specifically for VQA and description generation.</p><p>To understand how this works, think about how language models “read” your prompts. Input texts are split into tokens (words or parts of words), which are replaced with embedding vectors from a lookup table and then inputted to a neural network. The model never sees the actual letters you typed.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b5c711b2e1e0710370fc1979e4a987a6c2a497ba-1600x235.png" style="width: 100%;" /><p>As seen in the image, each unique token is substituted with a number that, in turn, corresponds to an embedding vector stored in the model’s vocabulary. This enables the model to process whatever text you give it, even if the words aren’t in its stored vocabulary.</p><p>This is why language models struggle to count letters in obscure words, like this example with Claude Haiku 4.5:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cc242a0f9f5a8fa9aa14a1511ad47abfe6440b51-628x582.png" style="width: 100%;" /><p>This model correctly recognizes what <em>Qaqortoq</em> is; it knows that the word means a town in Greenland, and it can tell you all about it, but it has no idea which actual letters compose the name. There are ways to supplement language models to address this shortcoming, so some models (like ChatGPT 5, Gemini, and Claude Sonnet) don’t generally make this kind of mistake.</p><p>However, this architectural shortcoming makes it possible to create multimodal language models.</p><p>The actual input to generative language models is a sequence of semantic embeddings, and there’s no reason those embeddings have to come from texts. They could come from anything, as long as they’re compatible with the model’s training. We can make any language model handle images, provided we can generate vectors for those images in the same embedding space as the tokens.</p><p><code>jina-vlm</code> contains an image embedding model and adapter trained to do just that. The image (with some preprocessing into overlapping patches) is the input to the model, and it outputs a sequence of embeddings in the same semantic embedding space as the language model’s text token embeddings. These embeddings are then treated just like the text input embeddings. The full language model has access to your text and to the image at the same time, and it treats both as the same kind of input.</p><p>Figure 3 is a schematic of the <code>jina-vlm</code> model, showing the dual track processing of text and images together.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/66886ced02dc707434a1f7d19c519681f648eb13-1128x1579.png" style="width: 100%;" /><p>This architecture has some important benefits. Language models learn a lot of implicit information about the world from their training data, and the VLM architecture can use that information to help understand the content of images. Furthermore, some of the bias problems present in other multimodal architectures are absent in this one, notably the <a href="https://jina.ai/news/the-what-and-why-of-text-image-modality-gap-in-clip-models/">media “gap” that troubles CLIP-style models</a>.</p><h2>What can jina-vlm do?</h2><p><code>jina-vlm</code> has three overlapping core competencies:</p><ul><li>Describing images and generating captions.</li><li>Understanding informationally rich images.</li><li>Answering questions about the content of images.</li></ul><h2>Describing and captioning pictures</h2><p>Providing good descriptions of images can be a boon for accessibility. There are increasing legal and informal requirements to provide alternative text (alt-text) for digitized images on the internet. Search engine optimization (SEO) strategies in particular often demand high-quality image captions and alt-text. Manually composing such descriptions can be expensive and time consuming.</p><p><code>jina-vlm</code> can help. For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/816b17945624f8965705a06f4ac7e8774ae65714-1280x853.png" style="width: 100%;" /><p>You can input this image to <code>jina-vlm</code> with the text:</p><ul><li><strong>Input Instruction: </strong>Describe this image in detail.</li></ul><p>The result is the following:</p><ul><li><strong>Output: </strong>The image captures a vibrant scene of a white plate filled with a multitude of colorful jelly beans. The jelly beans, in hues of green, yellow, orange, purple, red, and blue, are scattered across the plate, creating a rainbow-like effect. The plate itself is placed on a gray surface, providing a stark contrast to the colorful jelly beans. The image does not contain any discernible text. The relative positions of the jelly beans vary, with some clustered together and others spread out, giving the image a sense of depth and texture. The overall composition of the image is simple yet striking, with the colorful jelly beans being the central focus.</li></ul><p>This description is a little wordy but quite detailed.</p><p><code>jina-vlm</code> treats the text you give it as a question or instruction, and some prompt engineering may improve the result a lot. For example, using the same picture of jelly beans:</p><ul><li><strong>Input Instruction: </strong>Describe this image. Be concise.</li><li><strong>Output: </strong>A white plate is filled with a variety of colorful jelly beans, including green, yellow, red, purple, and blue, arranged in a scattered manner.</li></ul><p>Or:</p><ul><li><strong>Input Instruction: </strong>Provide a caption for this picture.</li><li><strong>Output: </strong>A white plate full of colorful jelly beans.</li></ul><p>Asking specifically for a caption tends to produce very short results. Most captions are brief, so <code>jina-vlm</code> is trained to generate brief captions.</p><p>You can also use <code>jina-vlm</code> to identify and list items in images:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6a7a2d832e225905e3e29a6df11c3d56dbf0655d-1764x876.png" style="width: 100%;" /><p>As a relatively small model, <code>jina-vlm</code> has limits to its object recognition. For common categories, it’s very good. For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/253083f674293de2a2aea866151c1844f1561d03-1768x752.png" style="width: 100%;" /><p>It’s able to handle content like “red cars”, but ask jina-vlm a question involving detailed categories and it can become unreliable very quickly. For example, using the same image:</p><ul><li><strong>Input Instruction: </strong>What model of car is this?</li><li><strong>Output: </strong>The car in the image is a classic French car, specifically a Citroën DS. This model is known for its distinctive rounded body shape, which was a hallmark of the DS series. The DS was produced from 1955 to 1969 and was a popular choice for its combination of style, performance, and practicality. It was designed to be a luxury car that could be driven on city streets as well as on the roads, making it a versatile vehicle for its time. The DS was also notable for its innovative features, such as the rear spoiler and the front grille with the Citroën emblem.</li></ul><p>This answer is largely hallucinated. The car in the picture is one of the most recognizable cars in history: a Citroën 2CV, manufactured from 1948 to 1990. The Citroën DS was made from 1955 to 1975 and looked like this:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/79f2d0348b0bae6a9253f649e43d2a030983408a-1600x1066.png" style="width: 100%;" /><p>In short, when pressed for details beyond its capacity, jina-vlm hallucinates without warning.</p><p>It’s especially important to keep this in mind with images of people:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/499a0378f555ad24c745119f1d0110bbf82d5d2c-1772x1108.png" style="width: 100%;" /><p>Tom Cruise is famous enough, and present enough in the training data, for j<code>ina-vlm</code> to recognize him. But, using the same picture:</p><ul><li><strong>Input Instruction: </strong>Provide a caption for this picture.</li><li><strong>Output: </strong>A man with a green shirt and a big smile.</li></ul><p>This caption never mentions Tom Cruise, even though it’s the most important fact about the picture. Asking for a caption produces very little hallucination but also very little information.</p><p>In contrast, let’s do the same thing with this image:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2ccda0dd49d4caf78dffee2a797c7359e1ecca5f-1772x1076.png" style="width: 100%;" /><p>This is terribly wrong. Ronald Reagan looks nothing like Richard Nixon.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dab9812917a27d064845a5baae2e3d2bdf6b2076-250x313.png" style="width: 100%;" /><p>When you ask <code>jina-vlm</code> to identify persons, it’s likely to hallucinate because it doesn’t have very deep world knowledge. Only people incidentally included many times in the training data might be correctly identified.</p><div style="display:none">Unknown block type "infoPanel", specify a component for it in the `components.types` option</div><p><code>jina-vlm</code> hallucinates much less when asked to write a caption. For example, using the same picture of Richard Nixon:</p><ul><li><strong>Input Instruction: </strong>Provide a caption for this picture.</li><li><strong>Output: </strong>A man in a suit sits in a chair with a bookshelf behind him.</li></ul><p>The most effective way to reduce hallucination is to prompt the model to say less. Any use or deployment of <code>jina-vlm</code> should take this into account.</p><h3>Understanding informational content</h3><p><code>jina-vlm </code>truly shines at understanding informational graphics and synthetic images, like diagrams, graphs, screenshots, scans of printed pages, advertising copy, and presentation slides. The examples below show only part of <code>jina-vlm</code>’s range.</p><h4>Charts</h4><p><code>jina-vlm</code> can report on the contents of all sorts of data presentations.</p><p><strong>Bar charts:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dbe91a785ef93e1036f2a7b227e4ffbdfc3d9d07-1742x772.png" style="width: 100%;" /><p><strong>Pie charts:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/460dd1663f32744297cede944af69b46f45bbb37-1832x872.png" style="width: 100%;" /><p><strong>Area charts:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/645ee7038dd1e0b6be7485796cf6ea6f2057b841-1828x928.png" style="width: 100%;" /><p><strong>Line graphs:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/852da7806adf0aaf0071bab0a045894e4f7e8272-1828x660.png" style="width: 100%;" /><p><strong>Table:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e86d3bc65a4fc285bbc4751edc9cadf52a266a7-1818x948.png" style="width: 100%;" /><h4>Multilingual content</h4><p><code>jina-vlm</code>’s multilingual support means that it can recognize languages and describe informational content in a different language from its presentation:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0473ad1847e931a8f3b33e04c4e70ead94c2e5a8-1824x1104.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e3fc53c96bb1ae235b29a01df05c0ffcf6b68bb4-1864x774.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/75376c40ae29ea33519dc79c55a46dc9f9f42438-1870x802.png" style="width: 100%;" /><p>It can also list the languages when there’s more than one:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1acf868a00ddd221a563d33a3d73a353ed816124-1854x992.png" style="width: 100%;" /><p><code>jina-vlm</code> may answer in a different language than you asked for, if the image content is in another language or strongly associated with it:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2778d64e56cde5f438e92a07555a80ae3fe48eb4-1934x710.png" style="width: 100%;" /><p>To fix this, specify the language in the query. With the same image:</p><ul><li><strong>Input Instruction: </strong>Summarize the content of this document in English.</li><li><strong>Output: </strong>The image is a promotional poster for a KFC Thailand event. The poster is predominantly red with white and black text. At the top, there is a logo of KFC, which is a red and white square with a black silhouette of Colonel Sanders. Below the logo, there are three young men, presumably the main participants or hosts of the event. They are dressed in casual attire, with two of them wearing red jackets and one in a white shirt with a black stripe.<br/><br/>The central part of the poster features a large text in white that reads, “เปิดตัวใหม่! ร่วมกับ KFC Thailand พบกับความสนุกสนานที่ไม่เหมือนใคร!” which translates to “Launching New! Join KFC Thailand and experience something unique!” Below this, there is a smaller text that says, “ทุกคนมีส่วนร่วม! วันนี้ 8 พฤศจิกายน 64 เวลา 12:00 น.” indicating that the event is happening on November 8, 2021, at 12:00 PM.<br/><br/>The bottom section of the poster includes social media icons and a Facebook page link, suggesting that the event will be live on Facebook. There are also icons of a chicken drumstick and a bucket, which are associated with KFC, reinforcing the brand’s identity.<br/><br/>The overall design of the poster is vibrant and eye-catching, with a clear focus on the event’s theme and the brand’s presence. The use of red and white colors is typical for KFC branding, which is known for its bold and energetic visual style. The poster is designed to attract attention and inform viewers about the upcoming event.</li></ul><h4>Complex information displays</h4><p>We specifically trained <code>jina-vlm</code> to understand complex information displays and to be able to summarize them in natural language.</p><p><strong>Infographics:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b964abf7f3f1176b0546babae3f4ae1d22d99c86-1850x1396.png" style="width: 100%;" /><p><strong>Technical and educational diagrams:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8499b5793faa3c4879e1b1ec8994c76e3b10ad20-1850x838.png" style="width: 100%;" /><p><strong>Maps:</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4e6c3d47890dbaf9476963e992126c0f497eacda-1854x758.png" style="width: 100%;" /><h2>Screenshots</h2><p>jina-vlm has been trained to parse and understand screenshots:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/50d17af1a4c08e4a69c4fb625229264dbfc65ce8-1848x708.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3a9273a5be5134185005bdb5efe7666f5494abb2-1864x1104.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5b66c570624d61c4e4c9f31791cd63656258b238-1850x810.png" style="width: 100%;" /><p>It can even parse video games:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/335b9482505a7dd2dcd86851f19ae514647368a7-1698x902.png" style="width: 100%;" /><h3>Scans</h3><p><code>jina-vlm</code> excels at materials like scans and images of printed text:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bb057adc4451107d6cc2b3dde90eb11d562ef581-1494x1468.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/687fb7b5c707ade1511ee6d40a7d034cfa7cfefd-1796x1540.png" style="width: 100%;" /><h3>Pre-digital materials</h3><p>Robust support for scans also makes pre-digital materials accessible to digital search technologies:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6b560a39e1e48d0e6755ed7b5273be77f03eca3c-1788x936.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d998c8e1ece6f1fc087b6d815b41ae11e16ee0bd-1498x1094.png" style="width: 100%;" /><h3>Social media</h3><p>Since much of the internet is “<a href="https://doctorow.medium.com/my-mcluhan-lecture-on-enshittification-ea343342b9bc">five giant websites filled with screenshots of text from the other four,”</a> <code>jina-vlm</code> is especially helpful in processing the kind of content that the web is full of:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8f901fd079c7c53a6c0517e93b90b28367490710-1856x1044.png" style="width: 100%;" /><p>jina-vlm can <em>sometimes </em>even explain memes:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4b55322d02cc13c6e718721763233f26497c64d0-1862x608.png" style="width: 100%;" /><h2>Visual question-answering</h2><p>“A picture is worth a thousand words.” As cliché as that saying is, multimodal AI can prove it.</p><p>VQA is the domain where <code>jina-vlm</code> excels and the one with the most potential for new and revolutionary applications. <code>jina-vlm</code> answers questions about the content of images just like the way a language model can answer questions about texts. This means it can serve as part of a RAG system that assembles responses from mixtures of images and texts stored in a database.</p><p>The infographic below is very information rich, and with <code>jina-vlm</code> you can ask natural language questions about its content:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5f71a444daedfae06eccbd6555451ea0d53889be-1792x1388.png" style="width: 100%;" /><p>Or, using the same image:</p><ul><li><strong>Input Instruction: </strong>What is the crew module of the SLS called?</li><li><strong>Output: </strong>Orion spacecraft</li></ul><p>This capability makes it possible to query formal schematics and diagrams:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a11f29f186455831f36849695ff69e0425d78f42-1792x838.png" style="width: 100%;" /><p>With the same image:</p><ul><li><strong>Input Instruction: </strong>How many bathrooms are there?</li><li><strong>Output: </strong>4</li></ul><p>If you use photographs or natural scenes, jina-vlm is typically good with high-visibility characteristics:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/61e2eebcd677e7a38e47cfe812b57c2f43022b43-1796x844.png" style="width: 100%;" /><p>But if you’re asking about finer, less visible details, the answers might get spotty:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6377ac8e9156cc0515abd54d7090d5739a575bd7-1786x758.png" style="width: 100%;" /><p>Thank you, Captain Obvious.</p><p><code>jina-vlm</code> is also not well-suited to resolving optical illusions:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/26304a5b8b4159e67d80712006f39db3ec90d5af-1794x626.png" style="width: 100%;" /><p>You can ask questions about text in photographs:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f5dc84db1d9a25f68fa914928b1d9effdaf0e821-1630x1068.png" style="width: 100%;" /><p>And <code>jina-vlm</code> is ideally suited to understand traditional magazine and newspaper advertisements:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c83c55998e7b68e2a802572e8234d1a277e50f6e-1634x1268.png" style="width: 100%;" /><p>Visual question-answering also works cross-lingually:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fad19e8b0008c52954441e98879ee170bb278291-1628x1278.png" style="width: 100%;" /><p>Using the same image:</p><ul><li><strong>Input Instruction: </strong>How much do grapes cost per kilo?</li><li><strong>Output: </strong>The cost of grapes per kilogram is 3.98.</li></ul><p>It answers questions from manual pages:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4b3cb87ad2132671f8c079fc1922088753f58036-1788x1424.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e3ef1b1e578d200511ee8805eaedba04391d43c2-1796x1532.png" style="width: 100%;" /><p>It can also answer questions about the content of posters and artistic works:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7771639037d176121702dc6d68307e377928e284-1500x1090.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7e8eb8e43a46d5f5f6ca93c1810bc41f7baada5b-1496x684.png" style="width: 100%;" /><p>We showed in the previous section that jina-vlm is not very good at general face recognition, outside of a few very famous people. However, it can use the text in images to answer questions it can’t answer from just a picture:</p><h2>Best practices</h2><p>jina-vlm is a generative language model. It’s not a database. It’s prone to error and hallucination. It’s very important to take that into account in your use case.</p><div style="display:none">Unknown block type "infoPanel", specify a component for it in the `components.types` option</div><p>To minimize errors, we offer the following advice:</p><h3>Test your prompts</h3><p>If you depend on getting certain kinds of results consistently, you should do thorough quality testing on your queries. Getting a few good results with one prompt does not guarantee the same quality of result with different images. You can minimize this risk by manually reviewing the results of queries over a broad collection of images that are genuinely representative of what you plan to process.</p><h3>Specify the language</h3><p>Add to your query the language you expect the answer to be in. Like all language models, jina-vlm cannot easily distinguish between your instructions and the data it’s supposed to apply your instructions to. The only way for it to know how to answer is to look at the languages it recognizes in its input or to follow your explicit instructions.</p><h3>Don’t ask leading questions</h3><p>It’s very easy to get <code>jina-vlm</code> to hallucinate by asking leading questions. For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/454b1b483a0d555f37dbf575e064fdbb13a3b517-1658x676.png" style="width: 100%;" /><h3>Prompt for short answers</h3><p><code>jina-vlm</code> can be very wordy sometimes. Using words like “briefly” and “concise” reduces the size of the output.</p><p>Compare:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0805c2868fa220c3bd39d6cee6d6092d50c79d06-1830x1042.png" style="width: 100%;" /><p><br/>And with the same image:</p><ul><li><strong>Input Instruction: </strong>What kind of vehicle is this? Be very concise.</li><li><strong>Output: </strong>This is a Massey Ferguson tractor.</li></ul><h3>Only ask questions relevant to the image</h3><p><code>jina-vlm</code> will not always be able to tell that your question is irrelevant to the content and can easily hallucinate answers. For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a493d89b6a3aba7040083f4028da9cda0bc37aa5-1778x1272.png" style="width: 100%;" /><p>With some planning, you might be able to reduce this effect by using a decision tree: Ask multiple questions, each one dependent on the answer to the previous one. Using the same Picasso painting above, we would never have asked about the language of the text if we had asked this question first:</p><ul><li><strong>Input Instruction: </strong>Is there any written language in this image? Be very concise.</li><li><strong>Output: </strong>No</li></ul><h2>Think like a language model</h2><p>We trained <code>jina-vlm</code> to attend to the image given to it. This means that no matter what you ask it, its answer will be a result of the picture it sees. Anthropomorphizing language models is a major hazard for AI users, but it’s easy to see how <code>jina-vlm</code> “thinks” if we ask it questions that call for reflection. For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a49ce6aed18dcf023deaaf52c0b2e536e237b2b7-1782x910.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4711f4c8da535107dff421275af6bbbdd602aaf8-1774x916.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/805c2b4598999094b3c36c828813b471316237bf-1772x916.png" style="width: 100%;" /><p>You can ask <code>jina-vlm</code> anything, even questions like, “How are you feeling?” and it will answer in a way that is, if possible, attentive to the image.</p><p><code>jina-vlm</code> has no filter, safety training, or reliable source of facts, and it only knows that what it sees in the image you give it matters. For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c1e91515231fd88b707480f102c98576fa7707d6-1778x594.png" style="width: 100%;" /><p>But:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/65576bf161a845447f17f1f42615a6e679767b37-1768x622.png" style="width: 100%;" /><p>This is not a bug, it’s a feature. <code>jina-vlm</code> will rely on what you give it to ground what it says. It’s up to you to decide what to give it.</p><p>As they say,</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/66efa7ef107db4948b25d32843699244e3a8bb9c-1770x784.png" style="width: 100%;" /><p>And,</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a542cf0363b432662ef4bf78946c9f75d3681e0e-1782x830.png" style="width: 100%;" /><h2>Getting started with jina-vlm</h2><p><code>jina-vlm </code>is available via a public <a href="https://platform.openai.com/docs/api-reference/introduction">OpenAI-compatible API</a> at <a href="https://api-beta-vlm.jina.ai">https://api-beta-vlm.jina.ai</a>. To use this API, get a <a href="https://jina.ai/api-dashboard">free Jina API key at the Jina website</a>. It comes with 10 million free tokens to try out Jina by Elastic’s models. Additional tokens are available for purchase.</p><p>You can find <a href="https://jina.ai/news/jina-vlm-small-multilingual-vision-language-model/#getting-started">instructions for accessing the <code>jina-vlm</code> API on the Jina AI website</a>, and there’s a <a href="https://jina.ai/api-dashboard/visual-qa-test">web-based sandbox for trying out images and requests</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/de2fa4ded4913a7592665e2944d395ef87c8d768-1072x798.png" style="width: 100%;" /><p>You can also <a href="https://huggingface.co/jinaai/jina-vlm">download the model from Hugging Face</a> and run it locally for noncommercial purposes. For commercial deployment on site, contact <a href="https://www.elastic.co/contact?pg=global&amp;plcmt=nav&amp;cta=205352">Elastic sales</a>.</p><p>For more technical information about the model, including pricing information, visit <a href="https://jina.ai/models/jina-vlm/">the jina-vlm model page</a>. You can also read <a href="https://arxiv.org/abs/2512.04032">the release paper on arXiv.org</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Groq with Elasticsearch for intelligent queries]]></title>
            <description><![CDATA[Learn how to use Groq with Elasticsearch to run LLM queries and natural language searches in milliseconds.]]></description>
            <link>https://www.elastic.co/search-labs/blog/groq-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/groq-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Mark Puddick]]></dc:creator>
            <pubDate>Fri, 06 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c9661fc360c775ebfe48ccda81160057c49f7088-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>One of the challenges with using large language models (LLMs) in conjunction with Elastic is that we often need fast results. Elastic has no issues providing millisecond response time. However, when we introduce LLM calls with this, we potentially have a big issue with our performance dropping to unacceptable levels. This is where hardware inference using Groq can supercharge the speed of your results when combining Elastic with an LLM.</p><p>Groq is a hardware and software company focused on delivering ultra-low-latency, deterministic AI inference at scale. Its core innovation is the Groq Language Processing Unit (LPU) Inference Engine, a purpose-built, custom-designed chip architecture specifically engineered for running LLMs at extremely high speed with predictable performance. The links below give a more detailed overview of the Groq architecture.</p><ul><li><a href="https://groq.com/lpu-architecture">Groq: Introducing the LPU</a></li><li><a href="https://groq.com/blog/artificialanalysis-ai-llm-benchmark-doubles-axis-to-fit-new-groq-lpu-inference-engine-performance-results">Groq: The New LLM Inference Benchmark</a></li></ul><p>Unlike traditional GPU-based systems, Groq&#x27;s inference-specific architecture allows it to process tokens at unprecedented throughput with minimal variance in response time. This directly addresses the memory bandwidth bottlenecks and scheduling overhead that typically slow down traditional LLM calls, ensuring that integrating an LLM with Elastic&#x27;s search results maintains a real-time user experience. Groq provides this industry-leading speed and performance, often at the best price performance, via GroqCloud, an easy-to-use tokens-as-a-service platform.</p><p>Let’s start by looking at a common intelligence query layer request pattern and what improvements we can get from this.</p><h2>Natural language search</h2><p>Since the widespread adoption of LLMs, a common search ask is to be able to use natural language for domain-specific search. A naive way of addressing this is to do a simple semantic search within a retrieval-augmented generation (RAG) workflow; however, in most cases, this doesn’t provide the desired results. This is largely due to specific attributes in the question that need to be translated into query terms. To address this, we can ask an LLM to generate a query that we can execute. However, this leaves a lot of room for error. Ultimately, we’ve found that providing a tool with specific parameters for the domain and using that with the LLM gives the best result. There’s more info on this in <a href="https://www.elastic.co/search-labs/blog/mcp-intelligent-search">this blog</a>.</p><p>To define the agent, we’ll use the following prompt:</p><pre><code>You are a helpful banking transaction agent. You help users search and analyze their banking transactions.

Current date: {current_date}

When users ask about transactions, use the appropriate tools:
- Use trans-search for finding specific transactions

For date references:
- "last month" = past 30 days from today
- "this month" = current month from 1st to today
- "last week" = past 7 days
- "this year" = January 1st of current year to today

By default set the make the to date today and the from date 1 year ago
Common categories: groceries, dining, gas, shopping, entertainment, utilities, healthcare, transportation, travel, subscriptions, insurance, phone, internet</code></pre><p>As an example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/71a1abec1c90a5faace7dc4699b2cb1096da405b-1600x215.png" style="width: 100%;" /><p>This gives us good results, but our search time goes from under 100ms to over 1 second, due to the LLM call.</p><p>To address this, we can use Groq’s hardware inference to run this query in a fraction of the time. To run through the example, you’ll need to <a href="https://console.groq.com/home">sign up for a Groq account</a>.</p><p>Then you can generate an API key from the top-right menu:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6acd9e3155c4008aa903c8f0ad1276a7b1af8bea-970x330.png" style="width: 100%;" /><p>We’ve created the tool <a href="https://github.com/markpudd/transaction_search_mcp">here</a> to be able to execute the search.</p><p></p><p></p><p></p><p>Once you’ve cloned the above repo, you’ll need to update the .env to point to Groq:</p><pre><code>OPENAI_API_KEY=gsk-........

OPENAI_API_BASE=https://api.groq.com/openai/v1

OPENAI_MODEL=openai/gpt-oss-20b</code></pre><p>We’ve used the 20b gpt-oss model, as this will give accurate results. There’s little to no gain in using a bigger model for this type of solution.</p><p>Now, from a testing situation, we can run this from a simple UI with a prompt to use the tool:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9fe93bd101f230ff72f43b126ea88037b624bee5-1600x957.png" style="width: 100%;" /><p>To test the timings around this, we’ll run the tool 50 times and get the average of the repose from the total, the LLM, and Groq. We’ll use ChatGPT-4.1-nano and the Groq OSS-20b model. Here’s the result from this test:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d8eb1cca56914a54a19a80a3983106753b5e7367-1568x1324.png" style="width: 100%;" /><p>It’s clear that we drop around a second by using Groq’s hardware inference. We’re also using a smaller model which, for this use case, still provides good results. By dropping this from 1.5 seconds to 250ms, we’ll generally fall inside the Service Level Agreement (SLA) levels for a lot of organizations.</p><h2>Elastic Agent Builder</h2><p>We’ve shown how this cannot only be used to accelerate natural language processing (NLP) search with Elastic but also how we can use this to accelerate Elastic Agent Builder. Agent Builder was recently released into technical preview and is now capable of connecting to Groq via the Groq endpoint. Agent Builder is available on Elastic 9.1+. We can use the same API key that we used earlier.</p><p>Here’s how you set this up:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/237e1c08060d0598dc398750f636f5da15807163-1600x171.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/065e70fedf794a2d791340ed88785e07e2579dca-1536x596.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ea0979dfcbc4de7cd3db748b575dbfd718a9fbb9-1600x214.png" style="width: 100%;" /><p>If you’re using serverless, you need to create a new connector from the stack management connectors page. First, click AI Connector.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ba55fbc1e424792de3afe567ad92547e7a4165de-530x700.png" style="width: 100%;" /><p>On the next screen, select Groq as the service:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e628e81738de5ffdeaf5a5e533a7070ffad44354-1232x1246.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/43863ceb10f4c0d3f306e660a0a23f514869d5ef-670x414.png" style="width: 100%;" /><p>You can then set up the model you want to use. The supported models are listed on the <a href="https://console.groq.com/docs/models">Groq website</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fdbf14924d209b69da572e3d1aca883403444d5a-654x663.png" style="width: 100%;" /><p>If you need to add your organization ID, this can be added by expanding <strong>More options</strong> under <strong>Settings</strong>.</p><p>If you’re on a hosted version of Elastic, at the time of this writing, you can use the OpenAI compatible endpoint on Groq to connect to Elastic. To do this, select the OpenAI service and use a custom URL that points to the Groq URL, as below:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/71261d380c60e08280cce54f0dbf67a0cc3a3e74-1584x928.png" style="width: 100%;" /><p>Once you have set up Groq using either of the above methods, go to GenAI Settings and set Groq as your default GenAI.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7c367bebb80074ba1a04116d0cc66b991a0a735d-1600x842.png" style="width: 100%;" /><p>Agent Builder will now default to using the Groq connector.</p><p>Let&#x27;s look to see if we can replicate the NLP search within Agent Builder and use Groq.</p><p>In order to create agents, we generally need to have some tools for the agent to use. In Agent Builder, you have the ability to use built-in tools or create your own. A number of built-in tools are documented <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/builtin-tools-reference">here</a>.</p><p>You can use these tools for your transaction searching. The LLM will use the built-in tools, such as the `index_explorer`, `generate_esql`, and `execute_esql`, which will try and find the relevant index, inspect the structure, and execute an Elasticsearch Query Language (ES|QL) generated query. However, this presents a few challenges:</p><ul><li>The time to run the agent will increase substantially, as there will be multiple reasoning steps and tool executions. Since we’re using Groq to get faster results, this isn’t ideal.</li><li>As the number of steps and the tool usage grows, we’re going to consume substantially more tokens and therefore increase cost.</li></ul><p>To avoid the above issues, we can create a new tool, designed to specifically search transactions. At the time of this writing, there are three types of tools we can use:</p><ul><li>ES|QL tools: These allow you to use templated ES|QL to define a query.</li><li>Index search tools: These allow you to provide an index, and the LLM creates the query.</li><li>Model Context Protocol (MCP) tools: These allow you to use external tools via MCP.</li></ul><p>We could use our previously created MCP tools; however, to keep things simple, we’ll use the index search tool. You can set this up as below:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f9b85dacf677613e5421cd6b4590f4b57d18e6b6-1505x1600.png" style="width: 100%;" /><p>Once we’ve created the tools, we can create an agent in Agent Builder. To do this, we click the <strong>Create agent</strong> button and fill in the screenshot below, using the prompt we used in our original example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/74c190822b3ebd974b224d749e5229e9f3552b83-1600x1112.png" style="width: 100%;" /><p>We also want to select the tool we created as part of the agent:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/270fc98a264dacb4a48af60135f9a82bbf6cdc6e-1600x1045.png" style="width: 100%;" /><p>And test in the Agent Builder UI by asking a few different questions:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3fde2839943659e599c013cae50929913c81d092-1600x1376.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ba2bb5b219c23289dd5b1c4608dcf98379bbf030-1600x1445.png" style="width: 100%;" /><p>We actually get some more functionality via Agent Builder, since it can create extra queries due to the extra built-in tools we selected. The only real disadvantage of this is that it can take longer to answer questions overall, since the LLM has the ability to do more. Again, this is where Groq can help. Let&#x27;s take a look at the performance difference in Agent Builder with Groq.</p><h2>Performance with Groq in Agent Builder</h2><p>A great feature of Agent Builder is that it has MCP and agent-to-agent (A2A) out of the box. We can use this to do some simple benchmark testing. Using A2A, we can replace the built-in agent in the UI and test harness. This allows us to test Agent Builder with Elastic LLM and a couple of different models in Groq.</p><p>There’s an <a href="https://github.com/markpudd/a2a_chat">updated repo</a> that has the benchmark script in it.</p><p>To test this, we’ll ask the question:</p><p><em>How much did I spend on gas?</em></p><p>The results of the testing are shown below:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Groq -openai/gpt-oss-120b</td><td style="border: 1px solid #ccc; padding: 4px;">Groq llama-3.3-70b-versatile</td><td style="border: 1px solid #ccc; padding: 4px;">Elastic LLM</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Min: 6.040s</td><td style="border: 1px solid #ccc; padding: 4px;">6.04</td><td style="border: 1px solid #ccc; padding: 4px;">4.433</td><td style="border: 1px solid #ccc; padding: 4px;">15.962</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Max: 9.625s</td><td style="border: 1px solid #ccc; padding: 4px;">9.625</td><td style="border: 1px solid #ccc; padding: 4px;">7.986</td><td style="border: 1px solid #ccc; padding: 4px;">24.037</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Mean: 7.862s</td><td style="border: 1px solid #ccc; padding: 4px;">7.862</td><td style="border: 1px solid #ccc; padding: 4px;">6.216</td><td style="border: 1px solid #ccc; padding: 4px;">17.988</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Median: 7.601s</td><td style="border: 1px solid #ccc; padding: 4px;">7.601</td><td style="border: 1px solid #ccc; padding: 4px;">6.264</td><td style="border: 1px solid #ccc; padding: 4px;">17.027</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">StdDev: 1.169s</td><td style="border: 1px solid #ccc; padding: 4px;">1.169</td><td style="border: 1px solid #ccc; padding: 4px;">1.537</td><td style="border: 1px solid #ccc; padding: 4px;">2.541</td>
                    </tr>
              </tbody>
            </table>
          <p>As you can see, the built-in Elastic LLM is not bad, but Groq still outperforms this by almost 3x on average. You’ll notice that the speed overall is substantially slower than the external app. This is due to the way we’ve set up our tool in Agent Builder just to use the index. As a result, a lot of this time is taken by Agent Builder reasoning (that is, inspecting the index). We could use templated ES|QL tools instead of the index, and this would move the results closer to the external app.</p><h2>Conclusion</h2><p>It’s clear to see that by using Groq with Elastic we open up a range of new possibilities in which speed is an important factor. This article covers the basic intelligent query example, but there are many other applications, such as image understanding, summarization, and captioning, that become possible with the 10x increase in speed.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ES|QL dense vector search support]]></title>
            <description><![CDATA[Using ES|QL for vector search on your dense_vector data.]]></description>
            <link>https://www.elastic.co/search-labs/blog/dense-vector-search-elasticsearch-query-language</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dense-vector-search-elasticsearch-query-language</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Carlos Delgado]]></dc:creator>
            <pubDate>Thu, 05 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c61f7d90a3d5c79da51518f56a115bf059224cad-1024x559.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>You can now use Elasticsearch Query Language (ES|QL) for vector search! ES|QL can retrieve, filter, and score <code>dense_vector</code> fields. Use k-nearest neighbors (KNN) queries for fast, approximate nearest neighbors search at scale. Use vector similarity functions for exact search and custom scoring.</p><p>KNN is simpler to use in ES|QL than in the Query DSL. Prefilters and the number of results to retrieve per shard are automatically inferred from the ES|QL query.</p><h2><strong>What is vector search?</strong></h2><p>Modern search is no longer limited to exact keyword matching. Users expect systems to understand <em>meaning</em>, not just text. This is where <strong>vector embeddings</strong> and Elasticsearch’s <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a> field type come in.</p><p>The easiest way to use vector search in Elasticsearch is to use the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"> field type</a>. It allows you to automatically generate text embeddings, perform semantic search, and handle chunking. However, you may want to use <code>dense_vector</code> instead when:</p><ul><li>You’re already using <code>dense_vector</code> fields.</li><li>You’re using non-textual data, like images, sound, or video.</li><li>You need to generate embeddings separately to ingestion in Elasticsearch.</li><li>You need to do custom or advanced scoring.</li><li>You want to perform exact nearest neighbors search.</li></ul><p>A <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a> stores numerical embeddings produced by machine learning models. These embeddings capture semantic similarity: documents with similar meaning have vectors that are close to each other in high-dimensional space.</p><p>With vectors, you can build:</p><ul><li><a href="https://www.elastic.co/docs/solutions/search/semantic-search"><code>Semantic text</code> search</a>, for finding documents related to a question.</li><li><a href="https://www.elastic.co/what-is/retrieval-augmented-generation">Retrieval-augmented generation</a> (RAG).</li><li>Recommendation systems.</li></ul><p>ES|QL brings the power of a query-piped experience to Elasticsearch. Adding first-class support for <code>dense_vector</code> fields means you can now <strong>retrieve, filter, score, and search using vectors directly in ES|QL</strong>, alongside your text and non-text data.</p><p>In this post, we’ll walk through how to work with <code>dense_vector</code> fields in ES|QL, from basic inspection to approximate and exact similarity search, and how to use vector search as part of hybrid search strategies.</p><h2><strong>The basics: Retrieving vector data</strong></h2><p>Assume you have an index with a mapping similar to:</p><pre><code>{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "category": { "type": "keyword" },
      "content_vector": {
        "type": "dense_vector",
        "dims": 384,
        "similarity": "cosine"
      }
    }
  }
}</code></pre><p>You can retrieve vector fields just like any other column:</p><pre><code>FROM documents
| KEEP title, content_vector
| LIMIT 5</code></pre><p>Keep in mind that vectors can be large. For exploration and debugging, it may be useful to retrieve vector data, but in production you should avoid returning full vector data unless it&#x27;s really necessary.</p><p>You can use familiar ES|QL constructs to check how many rows have vector information:</p><pre><code>FROM documents
| WHERE content_vector IS NOT NULL
| STATS non_null = COUNT(*)</code></pre><h2><strong>Approximate search using KNN</strong></h2><p>Vector search means finding the most similar vectors to a given query vector.</p><p>For large datasets, the most common approach is <a href="https://www.elastic.co/blog/understanding-ann">approximate nearest neighbor</a> (ANN) search. ANN tries to find the most similar vectors by using data structures that allow for fast computation of similar vectors but doesn’t guarantee that all vectors will be considered.</p><p>ES|QL exposes approximate search via the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-knn"><code>KNN</code> function</a>:</p><pre><code>FROM documents MEDATADATA _score
| WHERE KNN(content_vector, [0.12, -0.03, 0.98, ...])
| SORT _score DESC
| KEEP title, _score
| LIMIT 10</code></pre><p>This simple example:</p><ul><li>Searches over the <code>content_vector</code> field.</li><li>Uses a dense vector query <code>[0.12, -0.03, 0.98, ...]</code> to search similar vectors to it.</li><li>Sorts the results by score, by using the <code>METADATA _score</code> attribute that will be populated by the `KNN` function.</li><li>Keeps just the title and score, as the <code>content_vector</code> field is not interesting to be returned and we can avoid loading its contents.</li><li>Retrieves the top 10 elements by using <code>LIMIT</code>. This automatically sets <code>k</code> to 10 in the <code>KNN</code> function.</li></ul><p>The <code>KNN</code> function can be further customized by using options:</p><pre><code>FROM documents MEDATADATA _score
| WHERE KNN(content_vector, [0.12, -0.03, 0.98, ...], {"k": 20, "min_candidates": 100, "rescore_oversample": 4.0})
| SORT _score DESC
| KEEP title, _score
| LIMIT 10</code></pre><p>See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-knn"><code>KNN</code> function</a> named parameters for a complete description of the available parameters.</p><h3><strong>Combining KNN with filters</strong></h3><p>You can narrow down the candidate set for the vector search:</p><pre><code>FROM documents METADATA _score
| WHERE category == "tutorial"
| WHERE KNN(content_vector, [0.12, -0.03, 0.98, ...])
| SORT _score DESC
| LIMIT 10
| KEEP title, category, _score</code></pre><p>Of course, you can use any other <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/where"><code>WHERE</code> clauses</a> that filter the results or include <code>KNN</code> as part of a filter expression:</p><pre><code>FROM documents METADATA _score
| WHERE published_date > NOW() - 1 hour AND LENGTH(category) > 10 AND KNN(content_vector, [0.12, -0.03, 0.98, ...])</code></pre><h3><strong>KNN made simple</strong></h3><p><code>KNN</code> is simpler to use in ES|QL. You won&#x27;t have to specify prefilters or k for your query explicitly.</p><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query#knn-query-filtering">Prefilters</a> are the way to ensure that a <code>KNN</code> query returns as many results as expected. Prefilters are applied on the KNN search itself, instead of being applied after the query.</p><p>Keep in mind that <code>KNN</code> returns the top k results it&#x27;s been asked for. If filters are applied after the <code>KNN</code> query, some of the results returned by the query may be filtered. If that happens, we’ll retrieve fewer results than expected.</p><p>The Query DSL <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query"><code>knn</code></a><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query"> query</a> contains a section for specifying prefilters:<br/></p><pre><code>POST my-image-index/_search
{
  "query" : {
    "knn": {
      "field": "content_vector",
      "query_vector": [0.12, -0.03, 0.98, ...],
      "filter" : {
        "term" : { "category" : "tutorial" }
      }
    }
  }
}</code></pre><p>You don&#x27;t need to care about prefilters when using <code>KNN</code> in ES|QL. All filters are applied as prefilters for the <code>KNN</code> function, so there’s no need to specify them as a specific option or command; just use `WHERE` and let ES|QL do it for you!</p><p>KNN also allows specifying the number of results to retrieve per shard; that is, the k parameter. Similar to the Query DSL, k defaults to the <code>LIMIT</code> specified in your query.</p><h2><strong>Exact search using vector similarity functions</strong></h2><p><code>KNN</code> is designed to be fast, and that makes it ideal for large datasets (hundreds of thousands or millions of vectors) and latency-sensitive applications. The trade-off is that results are <em>approximate</em>, though usually very accurate.</p><p>Sometimes you want <em>exact</em> similarity computation instead of approximate search, for example:</p><ul><li>When your dataset is small.</li><li>When the filters used in the query are very restrictive and select a small subset of your dataset.</li></ul><p>ES|QL provides the following <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions">vector similarity functions</a>:</p><ul><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-v_cosine"><code>V_COSINE</code></a></li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-v_dot_product"><code>V_DOT_PRODUCT</code></a></li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-v_hamming"><code>V_HAMMING</code></a></li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-v_l1_norm"><code>V_L1_NORM</code></a></li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-v_l2_norm"><code>V_L2_NORM</code></a></li></ul><p>Using these functions, you can calculate the similarity of your query vector with all the vectors your query retrieves.</p><p>The following query uses the same mapping as our <code>KNN</code> example, above, but does exact search using cosine similarity:</p><pre><code>FROM documents
| EVAL similarity = V_COSINE(content_vector, [0.12, -0.03, 0.98, ...])
| SORT similarity DESC
| KEEP title, similarity
| LIMIT 10</code></pre><p>This query:</p><ul><li>Computes the similarity using the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-v_cosine"><code>V_COSINE</code></a> vector similarity function.</li><li>Sorts on the computed similarity.</li><li>Keeps the top 10 similar results.</li></ul><h2>Semantic search</h2><p>When doing semantic search, you&#x27;ll be trying to match a text query to your vectors. Of course, you can retrieve the query vector by first calculating the embeddings and then supply the query vector directly to your vector search.</p><p>But it would be much simpler to allow Elasticsearch to calculate the embeddings for you by using the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-text_embedding"><code>TEXT_EMBEDDING</code></a> function:</p><pre><code>FROM documents METADATA _score
| WHERE KNN(content_vector, TEXT_EMBEDDING("my semantic query", inference_id))
| SORT _score DESC
| LIMIT 10
| KEEP title, _score</code></pre><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-text_embedding"><code>TEXT_EMBEDDING</code></a> uses an already existing <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-inference">inference endpoint</a> to automatically calculate the embeddings and use them in your query.</p><h2>Hybrid search</h2><p>Most searches don&#x27;t rely just on vector data; they need to be combined with lexical search, as well, so we have the best of both worlds:</p><ul><li>Lexical information is great for exact search of words and synonyms and provides a strong signal that users are looking for specific terms.</li><li>Vectors capture meaning and intention, using similar phrases or terms that are not lexically related.</li></ul><p>Combining vector search and lexical search is best done using <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>:</p><pre><code>FROM documents METADATA _score, _id, _index
| FORK
(WHERE KNN(content_vector, TEXT_EMBEDDING("my query")) | SORT _score DESC | LIMIT 10)
(WHERE MATCH(title, "my query") | SORT _score DESC | LIMIT 10)
| FUSE
| SORT _score DESC
| LIMIT 10</code></pre><p>The query above:</p><ul><li>Uses <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> to do two queries:<ul><li>A <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions#esql-knn"><code>KNN</code></a> query over a `dense_vector` field.</li><li>A <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions#esql-match"><code>MATCH</code></a> query over a text field.</li><li>Both queries sort by score and return the top 10 results for each.</li></ul></li><li>Uses <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> to mix together the query results by using reciprocal rank fusion (RRF) by default.</li></ul><p>This allows total control of the queries you want to perform, how many results to retrieve from each one, and how to combine the results together.</p><p>Check out our <a href="https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql#vector-search">multistage retrieval</a> blog post for more details on how modern search works and how easy it is to implement via ES|QL.</p><h2>Custom scoring</h2><p>Calculating custom scoring is easy using ES|QL! Just use the <code>_score</code> metadata field for calculating your custom score:</p><pre><code>FROM documents METADATA _score
| WHERE KNN(content_vector, TEXT_EMBEDDING("my semantic query", inference_id)
| EVAL my_custom_score = _score * 1.5 + ...
| SORT my_custom_score DESC
| LIMIT 10</code></pre><p>If you&#x27;re using exact search, you already have an evaluation for the vector similarity that you can fine-tune:</p><pre><code>FROM documents
| EVAL similarity = V_COSINE(content_vector, [0.12, -0.03, 0.98, ...])
| EVAL my_custom_score = similarity * 1.5 + ...
| SORT my_custom_score DESC
| LIMIT 10</code></pre><p>Compared to the Query DSL <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-script-score-query"><code>script_score</code></a>, this is a much simpler and more iterative approach and fits perfectly into the ES|QL execution flow.</p><h2>Using query parameters</h2><p>When using a query vector, you can specify it directly on the query as in our previous examples. But you may have noticed that we&#x27;re using ellipses (...) to signal that there&#x27;s more data to come.</p><p>Dense vectors are usually high dimensional; they can have hundreds or thousands of dimensions, so copying and pasting your query vector on the query itself can make it difficult to understand or reason about, as you&#x27;ll be seeing thousands of numeric values on your screen.</p><p>Remember that you can use ES|QL query parameters for supplying parameters to your query:</p><pre><code>POST _query
{
  "query": """
FROM documents
| WHERE KNN(content_vector, ?query_vector)
| SORT _score DESC
| KEEP title, _score
| LIMIT 10
   """,
 "params": [{"query_vector" : [0.12, -0.03, 0.98, ...]}]
}</code></pre><p>This helps to keep your query and parameters separated, so you can focus on the query logic and not on specific parameters that get in your way.</p><p>Using query parameters for vectors is also more performant, as vectors are parsed faster that way using the request parser instead of the ES|QL parser.</p><h2><strong>Conclusion</strong></h2><p>ES|QL doesn’t just support vector search; it makes it a natural part of how you query your data. It allows you to use a single, powerful syntax for text, vectors, and everything in between, including:</p><ul><li>Vector search, both approximate and exact.</li><li>Semantic search, using text for search over vector data.</li><li>Hybrid search, combining the best of text and vector search.</li><li>Custom vector scoring, using <code>EVAL</code> and ES|QL constructs.</li></ul><p>Vector search in ES|QL is easier than in the Query DSL, by inferring prefilters and parameters, and integrating with the expressive, feature-rich expressions that ES|QL allows.</p><p>Defining KNN as part of a query pipeline for <a href="https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql#vector-search">multistage retrieval</a> is just another piece in the query; you can keep using filters, combine with other text functions for hybrid search, and apply reranking or query completion on top of your vector results.</p><p>We’ll keep adding vector functions for performing vector arithmetic and aggregations over dense vectors, so you can use the full power of ES|QL to manipulate your vector data.</p><p>Happy (vector) searching!</p>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jim Ferenczi, Benjamin Trent, Ignacio Vera Sequeiros]]></dc:creator>
            <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4c1ebbd7a1071e8e1721a9871cba87f6aed140e9-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a286408afc85ff1cd3dd448b8fdf59dd3e11d599-1600x675.png" style="width: 100%;" /><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><pre><code>{
  “emb” : [1.2345678, 2.3456789, 3.4567891]
}</code></pre><p>We can now send vectors encoded as Base64 strings:</p><pre><code>{
  “emb” : ”P54GUUAWH5pAXTwI”
}</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/86e5a926e13b07bb3b0abe80bd4930464e8f6f9b-1200x742.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4277057ee59cb84d068176b56bb7fa00b66e1cb3-1200x742.png" style="width: 100%;" /><p>Starting with Elasticsearch v9.2, <a href="https://www.elastic.co/search-labs/blog/elasticsearch-exclude-vectors-from-source">vectors are excluded from <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><pre><code>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,
)</code></pre><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>
        </item>
        <item>
            <title><![CDATA[Jina Rerankers bring fast, multilingual reranking to Elastic Inference Service (EIS)]]></title>
            <description><![CDATA[Elastic now offers jina-reranker-v2-base-multilingual and jina-reranker-v3 on EIS, enabling fast multilingual reranking directly in Elasticsearch for higher-precision retrieval, RAG, and agentic workflows without added infrastructure.]]></description>
            <link>https://www.elastic.co/search-labs/blog/jina-rerankers-elastic-inference-service</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-rerankers-elastic-inference-service</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sean Handley, Brendan Jugan, Ranjana Devaji]]></dc:creator>
            <pubDate>Tue, 03 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/be373a8af506d463948b9027f1cc2f19356f5499-720x421.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Today we’re excited to introduce <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> on Elastic Inference Service (EIS), enabling fast multilingual, high-precision reranking directly in Elasticsearch.</p><p>Jina AI, recently acquired by Elastic, is a leader in open-source multilingual and multimodal models, delivering state-of-the-art <a href="https://www.elastic.co/search-labs/blog/jina-models-elasticsearch-guide">search foundation models</a> for high-quality retrieval and retrieval-augmented generation (RAG). <a href="https://www.elastic.co/blog/elastic-inference-service">EIS</a> makes it easy to run fast, high-quality inference with an expanding catalog of these ready-to-use models on managed GPUs, with no setup or hosting complexity.</p><p>Rerankers add semantic precision by refining the ordering of retrieved results, helping select the best matches for a query. They add relevance improvements without the time and effort involved in reindexing or pipeline disruption, and they’re especially valuable for hybrid and RAG workflows where better context boosts downstream accuracy.</p><p>This follows the recent introduction of <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service"><code>jina-embeddings-v3</code> on EIS</a>, expanding the model catalog for multilingual reranking. Developers can now perform hybrid search using lexical search with BM25F and vector search with multilingual embeddings from <code>jina-embeddings-v3</code>, then rerank results with Jina Rerankers v2 or v3 depending on the use case. This delivers full control over recall tuning natively in Elasticsearch.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1afc549ebe74619e750b3809bca014f87a6dfb73-1600x437.png" style="width: 100%;" /><h2>jina-reranker-v2-base-multilingual</h2><p><code>jina-reranker-v2-base-multilingual</code> is a compact general-purpose reranker with features designed to support function calling and SQL querying.</p><ul><li><strong>Low-latency inference at scale:</strong> A compact 278M parameter model with <a href="https://arxiv.org/abs/2307.08691">Flash Attention 2</a> for low-latency inference, delivering strong multilingual performance that outperforms larger rerankers based on <a href="https://huggingface.co/spaces/AIR-Bench/leaderboard">AIR metrics</a> and other widely used benchmarks.</li><li><strong>Supports agentic use cases:</strong> Accurate multilingual text reranking with additional support for selecting SQL tables and external functions that match text queries, enabling agentic workflows.</li><li><strong>Unbounded candidate support:</strong> v2 handles arbitrarily large candidate lists by scoring documents independently. Scores are compatible across batches, so developers can rerank large result sets incrementally. For example, a pipeline can score 100 candidates at a time, merge the scores, and sort the combined results. This makes v2 suitable when pipelines don’t apply strict top-k limits.</li></ul><h2>jina-reranker-v3</h2><p><code>jina-reranker-v3</code> performs multilingual listwise reranking, offering state-of-the-art performance with higher precision for RAG and agent-driven workflows.</p><ul><li><strong>Lightweight, production-friendly architecture:</strong> A ~0.6B parameter listwise reranker optimized for low-latency inference and efficient deployment in production settings.</li><li><strong>Strong multilingual performance:</strong> <a href="https://jina.ai/news/jina-reranker-v3-0-6b-listwise-reranker-for-sota-multilingual-retrieval/">Benchmarks</a> show that v3 delivers state-of-the-art multilingual performance while outperforming much larger alternatives and maintains stable top-k rankings under permutation.</li><li><strong>Cost-efficient, cross-document reranking:</strong> Unlike v2, v3 reranks up to 64 documents together in a single inference call, reasoning over relationships across the full candidate set to improve ordering when results are similar or overlapping. By batching candidates instead of scoring them individually, v3 significantly reduces inference usage, making it a strong fit for RAG and agentic workflows with defined top-k results.</li></ul><p>More models are on the way. EIS continues to expand with models optimized for candidate reranking, retrieval, and agentic reasoning. Next up is <a href="https://jina.ai/news/fair-scoring-for-multimodal-documents-with-jina-reranker-m0/"><strong><code>jina-reranker-m0</code></strong></a> for multimodal reranking, followed closely by frontier models from OpenAI, Google and Anthropic.</p><h2><strong>Get started</strong></h2><p>You can start using <code>jina-reranker-v2-base-multilinugal</code> on EIS with just a few steps.</p><h3><strong>Create embeddings with jina-embeddings-v3</strong></h3><pre><code>POST _inference/text_embedding/.jina-embeddings-v3
{
  "input": [
    "The Atlantic is a vast, deep ocean.",
    "A small puddle formed on the sidewalk."
  ]
}</code></pre><p>The response:</p><pre><code>{
  "text_embedding": [
    {
      "embedding": [
        0.0061287,
        ...
      ]
    },
    {
      "embedding": [
        -0.11765291,
        ...
      ]
    }
  ]
}</code></pre><h3><strong>Rerank with jina-reranker-v2-base-multilingual</strong></h3><p>Perform inference:</p><pre><code>POST _inference/rerank/.jina-reranker-v2-base-multilingual
{
 "input": ["puddle", "ocean", "cup of tea"],
 "query": "a large body of water"
}</code></pre><p>The response:</p><pre><code>{
  "rerank": [
    {
      "index": 1,
      "relevance_score": 0.48755136
    },
    {
      "index": 0,
      "relevance_score": 0.41489884
    },
    {
      "index": 2,
      "relevance_score": 0.07696084
    }
  ]
}</code></pre><p>The response contains the ranked list of inputs sorted by relevance score. In this example, the model identifies &quot;ocean&quot; as the most relevant match for a large body of water, assigning it the highest score while correctly ranking &quot;puddle&quot; and &quot;cup of tea&quot; lower.</p><h3><strong>Rerank with jina-reranker-v3</strong></h3><p>Perform inference:</p><pre><code>POST _inference/rerank/.jina-reranker-v3
{
  "input": ["pebble", "The Swiss Alps", "a steep hill"],
  "query": "mountain range"
}</code></pre><p>The response:</p><pre><code>{
  "rerank": [
    {
      "index": 1,
      "relevance_score": 0.06519848
    },
    {
      "index": 2,
      "relevance_score": -0.05002501
    },
    {
      "index": 0,
      "relevance_score": -0.09782915
    }
  ]
}</code></pre><p>Similar to <code>jina-reranker-v2-base-multilingual</code>, the response provides a prioritized list of the inputs sorted by relevance. In this example, the model identifies &quot;The Swiss Alps&quot; as the most relevant match for &quot;mountain range,&quot; compared to &quot;pebble&quot; and &quot;a steep hill.&quot;</p><p>However, a key difference is that <code>jina-reranker-v3</code> is a listwise reranker. Unlike <code>jina-reranker-v2-base-multilingual</code>, which scores document-query pairs individually, <code>jina-reranker-v3</code> processes all inputs simultaneously, enabling rich cross-document interactions before determining the final ranking.</p><h2><strong>What’s new in EIS</strong></h2><p><strong>EIS via <a href="https://www.elastic.co/docs/deploy-manage/cloud-connect">Cloud Connect</a></strong> brings EIS to self-managed clusters, allowing developers to access its GPU fleet to prototype and ship RAG, semantic search, and agent workloads without needing to procure GPU capacity on their self-managed clusters. Platform teams gain hybrid flexibility by keeping data and indexing on-prem while scaling GPU inference in Elastic Cloud when needed.</p><h2>What’s next</h2><p><strong><code>semantic_text</code> fields will soon default to <code>jina-embeddings-v3</code> on EIS</strong>, providing built-in inference at ingestion time, making it easier to adopt multilingual search without additional configuration.</p><h2>Try it out</h2><p>With Jina AI models on EIS, you can build multilingual, high-precision retrieval pipelines without managing models, GPUs, or infrastructure. You get fast dense retrieval, accurate reranking, and tight integration with Elasticsearch’s relevance stack, all in one platform.</p><p>Whether you’re building RAG systems, search, or agentic workflows that need reliable context, Elastic now gives you high-performance models 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 on Elastic Cloud Serverless and Elastic Cloud Hosted.</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building automation with Elastic Workflows]]></title>
            <description><![CDATA[A practical introduction to workflow automation in Elastic. Learn what workflows look like, how they work, and how to build one.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elastic-workflows-automation</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-workflows-automation</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tinsae Erkailo, Tal Borenstein, Shahar Glazner]]></dc:creator>
            <pubDate>Tue, 03 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cc239e947732501cbe0579f15093efa3d9db1cb8-1600x933.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elastic Workflows is an automation engine built into the Elasticsearch platform. You define workflows in YAML; what triggers (starts) them, what steps they take, what actions they perform, and the platform handles execution. A workflow can query Elasticsearch, transform data, branch based on conditions, call external APIs, and integrate with services like Slack, Jira, PagerDuty and more through connectors you&#x27;ve already configured.</p><p>In this blog post, we&#x27;ll cover the core concepts of Workflows and build an example workflow together.</p><h2>Workflows are declarative and defined in YAML</h2><p>Workflows are composable. You define what should happen, and the platform handles execution, error recovery, and logging. Every workflow is defined as YAML and lives inside Kibana.</p><p>A workflow is composed of a few key parts: <em>triggers</em>, <em>inputs</em>, and <em>steps</em>.</p><p><strong>Triggers</strong> determine when a workflow runs. An alert trigger runs when a Kibana alerting rule fires, with full access to the alert context. A scheduled trigger runs on an interval or cron pattern. A manual trigger runs on demand from the UI or API. A workflow can have multiple triggers.</p><p><strong>Inputs</strong> define parameters that can be passed to the workflow at runtime. These let you create reusable workflows that accept different values depending on how they&#x27;re invoked.</p><p><strong>Steps</strong> are the actions a workflow takes. They execute in sequence, and each step can reference outputs from previous steps. Step types include:</p><ul><li><strong>Internal actions</strong> for actions you perform inside Elasticsearch and Kibana, like querying indices, running Elasticsearch Query Language (ES|QL) queries, creating cases, or updating alerts.</li><li><strong>External actions</strong> for actions you perform on external systems, like sending a Slack message or creating a Jira ticket. Use any connector you&#x27;ve configured in Elastic, with the flexibility to hit any API or internal service using HTTP steps.</li><li><strong>Flow control</strong> for defining the logic of your workflow with conditionals, loops, and parallel execution.</li><li><strong>AI</strong> for everything from prompting a large language model (LLM) to enabling agents as workflow steps, unlocking agentic workflow use cases.</li></ul><h2>Getting practical: Your first workflow</h2><p>Let&#x27;s build a workflow that demonstrates core capabilities: working with Elasticsearch indices, conditional logic, and data flow between steps. We&#x27;ll create a simple demo that sets up a National Parks index, loads sample data, and searches it.</p><h3>Enable Workflows</h3><p>Workflows is available in Elastic 9.3 (Technical Preview). Go to <strong>Stack Management → Advanced Settings</strong>, and enable <strong>Elastic Workflows</strong>:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/afc1f7168f3248d1792703973010e37ff05d19af-5344x3044.png" style="width: 100%;" /><h3>Create a workflow</h3><p>Navigate to <strong>Workflows</strong> in Kibana. If this is your first time, you&#x27;ll see the Get Started screen:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ca4bf4505ca26c32d12158a33211bf943a5bf37-5344x3044.png" style="width: 100%;" /><p>Click <strong>Create a new workflow</strong> to open the editor. You can also explore <strong>Example workflows</strong> to see the <a href="https://github.com/elastic/workflows">Elastic Workflow Library</a>, a collection of ready-to-use workflows for search, observability, and security use cases.</p><h3>The workflow editor</h3><p>The editor gives you YAML editing with autocomplete and validation. Start typing a step type, and suggestions appear. Use the <strong>quick action menu</strong> (Cmd+K / Ctrl+K) to browse available triggers, steps, and actions by category:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b94d4b4496f2214867bbd1ed1eb82174df426fd6-2538x1340.gif" style="width: 100%;" /><h3>Build the workflow</h3><p>Here&#x27;s our National Parks demo workflow:</p><pre><code>name: National Parks Demo
description: Creates an Elasticsearch index, loads sample national park data, searches for parks, and displays the results.
enabled: true
consts:
  indexName: national-parks

triggers:
  - type: manual

steps:
  - name: get_index
    type: elasticsearch.indices.exists
    with:
      index: '{{ consts.indexName }}'

  - name: check_if_index_exists
    type: if
    condition: 'steps.get_index.output: true'
    steps:
      - name: index_already_exists
        type: console
        with:
          message: 'index: {{ consts.indexName }} already exists. Will proceed to delete it and re-create'
      - name: delete_index
        type: elasticsearch.indices.delete
        with:
          index: '{{ consts.indexName }}'
    else:
      - name: no_index_found
        type: console
        with:
          message: 'index: {{ consts.indexName }} not found. Will proceed to create'

  - name: create_parks_index
    type: elasticsearch.indices.create
    with:
      index: '{{ consts.indexName }}'
      mappings:
        properties:
          name:
            type: text
          category:
            type: keyword
          description:
            type: text

  - name: index_park_data
    type: elasticsearch.index
    with:
      index: '{{ consts.indexName }}'
      id: yellowstone
      document:
        name: Yellowstone National Park
        category: geothermal
        description: "America's first national park, established in 1872, famous for Old Faithful geyser and diverse wildlife including grizzly bears, wolves, and herds of bison and elk."
      refresh: wait_for

  - name: search_park_data
    type: elasticsearch.search
    with:
      index: '{{ consts.indexName }}'
      query:
        term:
          _id: yellowstone

  - name: log_results
    type: console
    with:
      message: 'Found {{ steps.search_park_data.output.hits.total.value }} park with doc id of yellowstone.'</code></pre><p>This workflow demonstrates several capabilities:</p><ul><li><strong>Constants:</strong> Define reusable values, like <code>indexName</code>, that can be referenced throughout.</li><li><strong>Elasticsearch operations:</strong> Check whether an index exists, delete it, create it with mappings, index a document, and search.</li><li><strong>Conditional logic with branches:</strong> If the index exists, log a message and delete it. If not, log that it wasn&#x27;t found. Either way, continue to create the index.</li><li><strong>Data flow:</strong> Each step references outputs from previous steps using <code>steps.&lt;name&gt;.output</code>.</li></ul><p>Notice the <code>{{ }}</code> syntax throughout. This is Liquid templating for passing data between steps. <code>consts</code> contains workflow constants. <code>steps.&lt;name&gt;.output</code> references the output of a previous step.</p><h3>Run the workflow</h3><p>Save the workflow and click the <strong>Play </strong>button next to the <strong>Save </strong>button to execute it.</p><p>The workflow begins executing, and you&#x27;ll see the execution view. Each step appears in the side panel as it runs, showing status and timing:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/db89d94db97e51f76af0db084fef55c325b1426c-2538x1318.gif" style="width: 100%;" /><p>Click on any step to see its inputs and outputs. The side panel shows exactly what data the workflow received and produced at each step. This makes debugging straightforward: You see what went in, what came out, and where things went wrong if a step fails.</p><h3>Extending the workflow</h3><p>Let&#x27;s extend this workflow with AI and external notifications. We&#x27;ll add a step that generates a poem about the park using an LLM and then sends it to Slack.</p><p>Add these steps after <code>log_results</code>:</p><pre><code>  - name: generate_poem
    type: ai.prompt
    with:
      prompt: >
        Write a short, fun poem about {{ steps.search_park_data.output.hits.hits[0]._source | json }}.
        Include something about its famous features. No other formatting.

  - name: share_poem
    type: slack
    connector-id: my-slack-connector
    with:
      message: |
        🏔️ *Poem of the Day about Yellowstone*
        
        {{ steps.generate_poem.output.content }}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a529292d102b2b2800729458ecffb411b83f6f8f-5344x3008.png" style="width: 100%;" /><p>Now the workflow creates the index, loads data, searches it, generates a poem with AI, and shares it to Slack. The same patterns apply throughout: Add steps, reference their outputs, and let the workflow handle execution.</p><p>This is a simple example, but the same approach scales to real use cases. Replace national parks with security alerts, observability metrics, or any data in Elasticsearch. Replace the poem with an AI summary or assessment. Replace Slack with Jira, PagerDuty, or any connector you&#x27;ve configured.</p><h2>Workflows and Elastic Agent Builder</h2><p>The National Parks example demonstrates the core components of Workflows: triggers, steps, conditional logic, data flow, AI prompts, and external notifications. These components can be combined to build automations that orchestrate outcomes within Elasticsearch, Kibana, external systems, and AI.</p><p></p><p>This covers processes where the steps are known. But what about processes where they aren&#x27;t, where the right action depends on what you find, and what you find depends on where you look?</p><p>This is where <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Agent Builder</a> expands what&#x27;s automatable. An agent grounded in your operational context can do the initial analysis or investigation that an analyst or developer would normally do manually. It can explore, reason across data sources, and surface findings. Then the workflow continues with the structured steps that follow.</p><p>Workflows integrate with Agent Builder, and the integration works both directions.</p><h3>Agents as workflow steps</h3><p>Use the <code>ai.agent</code> step type to invoke an agent from within a workflow:</p><pre><code>- name: analyze
  type: ai.agent
  with:
    agent_id: my-analyst-agent
    message: 'Analyze this data and recommend next steps: {{ steps.search.output | json }}'</code></pre><p>The agent uses its configured tools to query indices, correlate data, and reason across results. The workflow waits for findings and continues with the next steps.</p><h3>Workflows as agent tools</h3><p>Workflows can also be exposed to Agent Builder as tools, allowing agents to invoke them when action is required.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d73f329f5166f4c3bea4d697c451341ea3e7423e-5344x3044.png" style="width: 100%;" /><p>This pattern is useful when an agent determines what should be done, but the how should follow a known and repeatable process. Agents are well suited for reasoning, exploration, and judgment, especially when information is incomplete or evolving. Workflows are well suited for executing established procedures that involve multiple steps, external systems, and state changing actions.</p><p>By exposing a workflow as a tool, you separate decision making from execution. An agent can conclude that a specific outcome is needed, such as declaring an incident or initiating a response, and then delegate the execution to a workflow that encodes the correct sequence of actions. That workflow handles coordination across systems, applies the same logic every time, and makes the result observable and auditable.</p><p>This also enables reuse and consistency. The same workflow can be invoked by different agents or run manually, while enforcing a shared process and set of controls. Instead of teaching every agent how to carry out complex actions safely and correctly, that responsibility lives in one place.</p><p>Together, this creates a clear contract. Agents decide when an action is needed. Workflows ensure that the action follows the right process, every time.<br/></p><h2>Looking ahead</h2><p>This technical preview establishes Workflows as a core capability of the Elasticsearch platform. The foundation is here: triggers, steps, data flow, AI integration, and the bidirectional connection with Agent Builder.</p><p>What comes next builds on this foundation by expanding how workflows are authored and where they show up. In addition to new step types and broader connector support, Workflows will support multiple authoring modes. These include natural language intent, where AI helps translate what you want to achieve into a working workflow, as well as visual, drag and drop builders. As these capabilities are embedded directly into solution specific experiences across Elastic, they change not just how workflows are built, but how work actually gets done.</p><p><strong>For Developers</strong>, this means AI assistants that go beyond conversation. Agents that can actually do things: query systems, update records, trigger processes, and return results. Reasoning backed by reliable execution.</p><p><strong>In Observability</strong>, this means correlating signals from logs, metrics, and traces. Surfacing likely root causes. Orchestrating remediation steps. Closing the loop between detection and resolution without waiting for someone to intervene.</p><p><strong>In Security</strong>, this means investigation that begins the moment an alert fires. Enriching findings with context from internal and external sources. Coordinating response actions across tools. Updating cases and notifying the right people. Work that used to require manual effort, handled automatically.</p><p>The patterns you learn today apply directly to these experiences. Workflows is the automation layer that makes them possible.</p><h2>Get started with Elastic Workflows</h2><p>Elastic Workflows is available now as a technical preview. Get started with an <a href="https://cloud.elastic.co/registration">Elastic Cloud trial</a>, and check out the <a href="https://www.elastic.co/docs/explore-analyze/workflows">documentation</a> or <a href="https://github.com/elastic/workflows">library of example workflows</a> to learn more.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Skip MLOps: Managed cloud inference for self-managed Elasticsearch with EIS via Cloud Connect]]></title>
            <description><![CDATA[Introducing Elastic Inference Service (EIS) via Cloud Connect, which provides a hybrid architecture for self-managed Elasticsearch users and removes MLOps and CPU hardware barriers for semantic search and RAG.]]></description>
            <link>https://www.elastic.co/search-labs/blog/cloud-connect-elastic-inference-service</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/cloud-connect-elastic-inference-service</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jordi Mon Companys, Matt Ryan]]></dc:creator>
            <pubDate>Tue, 03 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e419160397b56d1eacc101cd69ecc9c8e0b20967-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>If you’re running Elasticsearch on-premises or in a private cloud, you’ve likely faced a familiar standoff when trying to modernize your search experience: You want to implement <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-inference">semantic search</a>. You know that state-of-the-art dense vector models, like jina-embeddings-v3, are the standard for relevance. But when you look at the infrastructure requirements to run these models at scale, the project stalls.</p><p>The problem usually isn&#x27;t the software: Elasticsearch has supported vector search for years. The problem is the hardware.</p><h2><strong>The MLOps bottleneck</strong></h2><p>The process of running model inference to generate the embeddings required for semantic search is computationally expensive. If you’re self-managing your cluster, this presents a difficult set of trade-offs regarding operational complexity and resource flexibility:</p><ul><li><strong>Burn CPU cycles:</strong> You run the models on your existing CPU-bound nodes. This works for small datasets and small models, but as ingestion rates climb, your indexing throughput plummets and your search nodes end up choking on vector generation instead of serving queries.</li><li><strong>Provision GPUs:</strong> You ask your infrastructure team for GPU-accelerated nodes. In many organizations, this triggers a procurement nightmare. GPUs are expensive and scarce, and they introduce a new layer of machine learning operations (MLOps) complexity: driver compatibility, container orchestration, and scaling logic that your team might not have time to manage.</li></ul><p>This creates a gap where self-managed deployments remain stuck on keyword search (BM25) simply because the infrastructure barrier to AI is too high.</p><h2><strong>Introducing a hybrid architecture for inference</strong></h2><p>We built the <strong><a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a> </strong>and made it available via <a href="https://www.elastic.co/docs/deploy-manage/cloud-connect"><strong>Cloud Connect</strong></a> to solve this specific hardware constraints problem. It allows self-managed clusters (running on Elastic Cloud on Kubernetes [ECK], Elastic Cloud Enterprise [ECE], or stand-alone) hosted on-premises or in private cloud environments to delegate compute-intensive model inference to Elastic Cloud. It doesn’t require a lift-and-shift migration of your cluster architecture. While the specific text fields you need to vectorize are transmitted to the cloud for processing, you don’t need to permanently relocate your terabytes of business data or re-architect your storage. It allows you to use a <strong>hybrid topology</strong>: Your data nodes, master nodes, and index storage remain in your private environment, but the heavy lifting of generating embeddings is outsourced to Elastic’s managed GPU fleets.</p><h2><strong>How it works: Data stays, intelligence travels</strong></h2><p>It’s important to understand the distinction between <em>storage </em>and <em>inference</em>. When you use EIS via Cloud Connect:</p><ol><li><strong>The handshake:</strong> You generate an API key in Elastic Cloud and paste it into your self-managed Kibana instance. This establishes a secure, authenticated bridge.</li><li><strong>The pipeline:</strong> When you index a document using the <code>semantic_text</code> field (or manually configure an inference processor), your local cluster automatically sends only the specific text content to the EIS endpoint, encrypted in transit.</li><li><strong>The inference:</strong> The text is processed in memory on Elastic’s managed GPUs. The resulting vector embedding is returned immediately to your local cluster.</li><li><strong>The storage:</strong> The vector is indexed and stored on your local disks alongside the original source document.</li></ol><p>The raw text payload is ephemeral: It’s processed for inference and discarded. It’s never indexed or permanently stored in the cloud. You get the relevance of a GPU-powered cluster without changing your data residency posture.</p><h2><strong>Scaling semantic search without hardware provisioning</strong></h2><p>Let’s look at a practical scenario. You’re a site reliability engineer (SRE) managing a cluster for a large ecommerce platform. The search team wants to deploy Jina to fix “zero results” queries, but your on-premises nodes are CPU-bound and you have no GPU infrastructure available.</p><p>Here’s how you can use EIS via Cloud Connect to solve this in minutes, not months.</p><p>Step 1: <strong>The handshake</strong></p><p>First, we establish the bridge between your self-managed cluster and Elastic Cloud:</p><ul><li>Navigate to <strong>Stack Management</strong> in your local Kibana.</li><li>Under the <strong>Cloud Connect</strong> section, click <strong>Connect to Elastic Cloud</strong>.</li><li>Authenticate with your Elastic Cloud credentials, and authorize the connection.<ul><li>Result: Your local cluster is now cloud connected, acting as a satellite that can consume Software as a Service (SaaS) services.</li></ul></li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e7f2f846515f30375340080c6f61b0321f6db5c1-1600x1162.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/329916c4ecd6bdb78d21812751014b887de6148f-1600x1158.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9e98f5b7f1aa1d245507163df285b6688745c6ff-1600x1156.png" style="width: 100%;" /><p>Step 2: <strong>Enable the service</strong></p><ul><li>On the Cloud connected services page, locate <strong>Elastic Inference Service</strong>.</li><li>Click <strong>Enable</strong> and wait for the status to switch to <strong>Enabled</strong>.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c171888a47a8e412551b701c59274fd7b3f6d33e-1600x1159.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f678694670f3686ac4b7be8a1850b39421999f48-1600x245.png" style="width: 100%;" /><p>Which makes all these inference endpoints immediately available locally:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4332fb202e9537128d03abf115f6728b925755ce-800x579.gif" style="width: 100%;" /><p>Step 3: <strong>Configure the pipeline</strong></p><p>Now that the service is available, you configure an ingest pipeline. Instead of managing a local Jina container, you simply point to the cloud-hosted model. Open the console, and try the Jina embeddings model locally:</p><pre><code>PUT /semantic-search-eis
{
  "mappings": {
    "properties": {
      "text": {
        "type": "semantic_text",
        "inference_id": ".jina-embeddings-v3"
      }
    }
  }
}

POST /semantic-search-eis/_doc
{
  "text": "Aberdeen Football Club"
}

GET /semantic-search-eis/_search
{
  "query": {
    "match": {
      "text": "soccer"
    }
  }
}</code></pre><p>The match query will return the semantically correct result for “Aberdeen Football Club” where an exact match for “soccer would not”:</p><pre><code>{
  "took": 42,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 0.89421,
    "hits": [
      {
        "_index": "semantic-search-eis",
        "_id": "1",
        "_score": 0.89421,
        "_source": {
          "text": "Aberdeen Football Club"
        }
      }
    ]
  }
}</code></pre><p>Step 4: <strong>Hybrid ingestion</strong></p><p>When you index a document through this pipeline, the flow is streamlined:</p><ul><li>Your local node accepts the document.</li><li>The text field is securely transmitted to the EIS endpoint.</li><li>Elastic Cloud generates the dense vector embedding on a managed GPU using Jina v3.</li><li>The vector is returned and indexed locally on your self-managed disk.</li></ul><h2><strong>Oh and one more thing: LLMs via Cloud Connect too!</strong></h2><p>While vector search solves the <em>retrieval</em> part of retrieval-augmented generation (RAG), Cloud Connect also solves the <em>generation</em> side. Typically, giving your self-managed teams access to high-quality large language models (LLMs), like Anthropic’s Claude, involves a separate procurement cycle, managing vendor API keys, and handling secure egress.</p><p>EIS via Cloud Connect removes that friction entirely.</p><ul><li><strong>Preconfigured access:</strong> The moment you enable the service, you get access to a preconfigured Anthropic connector (one for Claude 3.7 and one for Claude 4.5). You don’t need to bring your own API key or sign a separate contract with the model provider.</li><li><strong>Zero setup:</strong> It just works. Because the secure bridge is already established, you can immediately select these models in the Search Playground to test RAG against your local data.</li><li><strong>Integrated workflows:</strong> This also powers our internal AI tools. Your team can instantly start using the Elastic AI Assistant for observability insights or Attack Discovery for security threat analysis, all powered by cloud-hosted LLMs, grounded in your on-premises data.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/361d7f503a0401078b19b5108d40d8a59fa2e607-1600x1160.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8bfd1b8de4a5a9621b414791fe03f979ad7458ec-1600x1164.png" style="width: 100%;" /><h2><strong>The shortcut to AI</strong></h2><p>EIS via <a href="https://www.elastic.co/docs/deploy-manage/cloud-connect">Cloud Connect </a>removes the operational friction of managing GPU drivers, specialized hardware, and complex performance monitoring stacks. By offloading these requirements to Elastic Cloud, you can build RAG applications and semantic search interfaces today, regardless of whether your cluster is running on-premises or in a private cloud VPC.</p><p>The hybrid approach solves the resource utilization problem typical of self-managed AI. You no longer have to over-provision expensive hardware that sits idle during quiet periods, nor do you have to worry about performance bottlenecks when traffic bursts. You simply establish the secure connection via Cloud Connect and consume high-performance inference as a managed API, keeping your data residency intact while your search capabilities scale instantly.</p><p>EIS via Cloud Connect is available immediately for <strong>Elastic Enterprise</strong> self-managed customers on <strong>Elastic Stack 9.3</strong>.</p><ul><li><a href="https://www.elastic.co/search-labs/blog/elastic-autoops-self-managed-elasticsearch"><strong>Read the docs:</strong> Guide to Configuring Cloud Connect</a></li><li><a href="https://cloud.elastic.co/registration"><strong>Start a trial:</strong> Create an Elastic Cloud account</a></li></ul>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sean MacKirdy]]></dc:creator>
            <pubDate>Mon, 02 Feb 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e7ed91ecf91e7a1de7d9ff514a2c285f2cb3f65c-1000x628.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <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&#x27;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&#x27;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>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.</li><li>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.</li><li>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. </li><li>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.</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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c53aaa04503baf654ccee274f012d7c1ddc2f643-1380x1600.png" style="width: 100%;" /><p>Let&#x27;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><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.</li><li><strong>Image build infrastructure:</strong> This is the process and tooling used to create container images from your application&#x27;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.</li><li><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.</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><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.</li><li><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.</li><li><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.</li></ul><h4>2. Hosting and orchestration</h4><p>As we introduced in the &quot;Containerization platform&quot; 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&#x27;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&#x27;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&#x27;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&#x27;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&#x27;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&#x27;s Vertex AI, and AWS Bedrock provide access to powerful, state-of-the-art models through a managed API.</p><ul><li><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.</li><li><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&#x27;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.</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&#x27;s Llama series) on your own virtualized GPU infrastructure within a cloud provider. This is typically managed using the containerization and Kubernetes orchestration we&#x27;ve already discussed, often with high-performance inference servers like vLLM.</p><ul><li><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>].</li><li><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.</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><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.</li><li><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.</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&#x27;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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dc05dfaf1bde3c7a74cf559b8c790a6e8e36be45-1600x900.png" style="width: 100%;" /><p>First, it must master <strong>hybrid search</strong>. The best retrieval systems don&#x27;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&#x27;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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/de028ce2f5acd6f84e59ea67091472128a8a0143-1430x990.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7b1062f775ed2cf21e9386d1999933ba3f73efc4-1432x1488.png" style="width: 100%;" /><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&#x27;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&#x27;ve now walked through the entire cookbook, from selecting your fresh ingredients to following the recipe step by step. Most of the domains we&#x27;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&#x27;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>
        </item>
        <item>
            <title><![CDATA[Query rewriting strategies for LLMs and search engines to improve results]]></title>
            <description><![CDATA[Exploring query rewriting strategies and explaining how to use the LLM's output to boost the original query's results and maximize search relevance and recall.]]></description>
            <link>https://www.elastic.co/search-labs/blog/query-rewriting-llm-search-improve</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/query-rewriting-llm-search-improve</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Christina Nasika, Emilia Garcia Casademont]]></dc:creator>
            <pubDate>Fri, 30 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e56cb99dab7734d506e6b8a226c798047122c1a9-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>One of the biggest challenges in integrating large language models (LLMs) into search pipelines is the complexity of navigating the space of possibilities that they provide. This blog focuses on a small set of concrete <strong>query rewriting (QR) </strong>strategies, using LLM-generated keywords, pseudo-answers, or enriched terms. We specifically focus on how to best use the LLM&#x27;s output to strategically boost the original query&#x27;s results to maximize search relevance and recall.</p><h2>LLMs and search engines: An exploration of query rewriting strategies for search improvement</h2><p>The integration of large language models (LLMs) with search engines represents a significant advancement in the fields of search and AI. This integration can take many forms, depending on the application [1]. On one hand, search engines can enhance LLMs by grounding their generation in freshly retrieved data, a strategy that’s commonly referred to as retrieval-augmented generation (RAG). On the other hand, LLMs can improve search engines by optimizing various stages of the search pipeline.</p><p>The search pipeline typically consists of three main components: indexing, first-stage retrieval, and reranking. LLMs can contribute at each of these stages. During indexing, LLMs can assist with data preparation tasks, such as text cleaning, entity extraction, and document enrichment by adding domain knowledge, synonyms, or clarifications. In the first-stage retrieval, LLMs can optimize user queries by rewriting them in natural language to improve retrieval accuracy or by mapping them to structured queries (for example, domain‑specific language–style [DSL-style] or Elasticsearch Query Language–style [ES|QL-style] queries) [2]. This blog focuses on query optimization strategies for this stage.</p><p>While there is some research on using LLMs as rerankers [3], the literature is less extensive. Technical blogs on the topic suggest that LLMs as rerankers may not always be the optimal choice, though this area remains an active field of exploration [4,5].</p><p>The advancements in LLMs have also unlocked new possibilities beyond the traditional indexing and retrieval stages. For example, LLMs can be used to generate natural language responses grounded in retrieved data (RAG). Users increasingly anticipate coherent, natural language responses to their queries, which are also dependable and guaranteed to be based on retrieval data. This is a significant shift in user expectation, occurring quickly since LLM-integrated search engines became generally available, demonstrating a major improvement in user experience. A language model that can understand intent, retrieve data, and synthesize information is especially valuable in multi-hop scenarios where a query requires combining information from various sources.</p><p>This is even clearer when looking at the application of LLMs in the creation of interactive, conversational search interfaces. These interfaces allow users to submit queries, provide feedback on responses, or introduce clarifications, enabling iterative improvements to the results, while making use of the historical context of the conversation. Taking this a step further, integrating LLMs with autonomous capabilities, such as planning, retrieving, reasoning, and decision-making, can lead to the development of agentic search systems. These systems can refine results based on user feedback or self-evaluation, creating a dynamic and intelligent search experience.</p><p>Finally, LLMs are widely used in search tool development, from data generation to serving as backbones for embedding and reranking models [6,7,8]. Synthetic data generation has become a common step in training retrieval models, and LLMs are increasingly being used as judges to generate labels for training and evaluation.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e8ce0659b6c1b5bf59c1a7d4ab23e672f0970533-1600x656.png" style="width: 100%;" /><h3>Query rewriting and optimization strategies</h3><p>Query rewriting strategies are best understood by categorizing user queries into two main types: <em>retrieval </em>and <em>computational</em>.</p><p></p><h4>Retrieval queries</h4><p></p><p>Unlike computational queries, the user&#x27;s intent here is information retrieval, not calculation. These are the standard queries handled by retrieval algorithms, like lexical and vector search. For example, for the following query:</p><p></p><p>&quot;What is the origin of COVID-19?&quot;</p><p></p><p>texts providing answers or context relevant to the query are targeted.</p><h4>Computational queries</h4><p>These queries require calculation, aggregation, or structured filtering to produce an answer. They must be translated from natural language into a structured query language, like Elasticsearch DSL or ES|QL.</p><p>For example, a query like:</p><p>&quot;What was the average amount spent by customers who placed more than five orders in the last month?&quot;</p><p>Assuming that the information on the orders and customers can be found in some available index, this query requires more than simple text matching. It involves filtering by a date range, grouping by customer, calculating order counts, filtering customers with fewer than five orders, and computing the final average. In this case, the LLM&#x27;s task is to parse the natural language and generate the corresponding structured query to execute these calculations.</p><p>Another example would be:</p><p>&quot;Which universities in Germany have an acceptance rate below 20%, and what is their average tuition fee?&quot;</p><p>When there is no indexed document that contains that specific information, but rather there might be documents containing acceptance rate information separately from tuition fees information.</p><p>In computational queries, the model is essentially expected to decompose the query into a retrievable informational query and a calculation that can be performed when the retrieved data is available, or to build a structured query that can do both.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query type</td><td style="border: 1px solid #ccc; padding: 4px;">Primary mechanisms</td><td style="border: 1px solid #ccc; padding: 4px;">Example</td><td style="border: 1px solid #ccc; padding: 4px;">Query rewriting task</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Retrieval</td><td style="border: 1px solid #ccc; padding: 4px;">Lexical and vector search. </td><td style="border: 1px solid #ccc; padding: 4px;">"What is the origin of COVID-19?"</td><td style="border: 1px solid #ccc; padding: 4px;">Optimize the query's text to improve lexical or vector matching.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Computational</td><td style="border: 1px solid #ccc; padding: 4px;">Calculation, aggregation, or structured filtering.</td><td style="border: 1px solid #ccc; padding: 4px;">"Which universities in Germany have an acceptance rate below 20%, and what is their average tuition fee?"</td><td style="border: 1px solid #ccc; padding: 4px;">Decompose the query: Retrieve relevant data (university profiles), and then perform a calculation (average tuition fee) on the subset of retrieved documents.</td>
                    </tr>
              </tbody>
            </table>
          <h3>Design methodology: Template-based expansion</h3><p>The main focus of this blog is on retrieval queries. However, unlike standard approaches that simply ask an LLM to rewrite or completely rephrase a query, we adopted a template-based design methodology.</p><p>Improving query-text-to-query-text relevance by completely rephrasing the query is often not straightforward for vector nor lexical search. It introduces the complexity of merging search results when multiple hypotheses are considered, and the LLM can often drift from the original user intent. Instead, we explore expanding the original query through an Elasticsearch Query DSL template + prompt strategy. By using specific prompts, we guide the LLM to output specific textual elements (like a list of entities, synonyms, or a pseudo-answer) rather than giving it free rein. These elements are then plugged into a predefined Elasticsearch Query DSL template (a search &quot;recipe&quot;).</p><p>This approach reduces the scope of the LLM application, making the output more deterministic. In our experiments, the LLM is simply prompted to output some text, which then is inserted into the template.</p><p>To validate this approach, we performed a limited exploration of different Elasticsearch primitives to identify and &quot;freeze&quot; a good-enough search template. This allowed us to test how different prompting strategies affect relevance within that fixed structure, rather than changing the structure itself.</p><p>While this blog focuses on retrieval queries, and lexical extraction and semantic expansion strategies where the linguistic aspect plays the major role, this methodology is flexible. Specific templates could be designed for other specific retrieval query use cases, such as handling product codes since relevance criteria are often context dependent. However, use cases with queries dependent on complex aggregations or strict filtering should be considered computational queries, which would require query optimization strategies outside the scope of this blog.</p><h3>Query optimization strategies</h3><p>While query optimization predates LLMs, LLMs excel at this task. They can be prompted to apply several rewriting strategies [9], such as:</p><ul><li>Generic query rephrasing.</li><li>Pseudo-answer generation.</li><li>Noise reduction (removing irrelevant text, extracting important entities).</li><li>Entity enrichment (synonyms, abbreviation expansion, or other related terms).</li><li>Fixing typos.</li><li>A combination of the above.</li></ul><p>Most of these techniques depend on the model’s capacity to understand user intent and its knowledge of the corpus characteristics.</p><p></p><p>In the following sections, we’ll present our experimentation with query rewriting for informational queries and their application to Elasticsearch. We’ll present our most successful experiments and discuss our unsuccessful ones.</p><h3>Experiments and results</h3><p>All the experiments presented in this blog were run using Anthropic Claude 3.5 Sonnet. Normalized Discounted Cumulative Gain (NDCG) at position 10 and Recall at positions 10 and 50 are the metrics used to evaluate the experiments throughout the blog. All <a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">NDCG</a> and <a href="https://en.wikipedia.org/wiki/Evaluation_measures_(information_retrieval)#Recall">recall</a> scores were computed using <code>pytrec_eval</code> [10].</p><p>We experimented with several variations of the query rewriting task for informational queries and measured relevance and recall gains for three widely used information retrieval benchmarks: Benchmarking Information Retrieval - BEIR, 15 datasets, English-only [11]. Multilingual Document Ranking - MLDR, 13 languages [12] and Multilingual Information Retrieval Across a Continuum of Languages - MIRACL, 18 languages [13].</p><p>We mainly explored the following tasks:</p><ul><li>Lexical keyword enrichment.</li><li>Pseudo-answer generation.</li><li>Letting the model decide on a method or a combination of methods among keyword extraction, keyword enrichment, and pseudo-answer generation.</li></ul><p>We detail the prompts we used for each case and expand on some attempted variations below.</p><p>It’s worth noting that, out of the datasets we evaluated, only four within BEIR (NQ, Robust04, Quora, and MS MARCO) contain real user queries that can benefit from generic query rewriting strategy fixes, such as misspellings, corrections, or query cleaning. The rest of the datasets are either synthetically generated (MLDR, MIRACL) or human-constructed (most of the BEIR datasets).</p><h4>Lexical keyword enrichment</h4><p>This is the first task we tried and considered various prompts in an effort to optimize results. We started from the simplest possible version, prompting the LLM to extract relevant keywords without specifying more details.</p><p><strong>Prompt 1.</strong> </p><pre><code>You are a search engine and you want to extract the relevant keyword, codewords or proper names from a query. 
Please, extract them and list them in a comma-separated list. Reply with the list only.</code></pre><p>On a second attempt, we tried a prompt with more explicit instructions, prompting the model to provide only the most important keywords, and insisting on why that is important for our use case. We also introduce here the idea of entity enrichment, prompting the model to augment the original query only if it considers it to be too small or missing information.</p><p><strong>Prompt 2. </strong></p><pre><code>You are a search engine and you want to extract the relevant keyword, codewords or proper names from a query.
            <Instructions>
            Think about the query and select only the very central and necessary entities. 
            They will be used as boosters for the lexical search, so make sure to only extract critical ones. We don't want to boost documents that contain entities that might not be related to the specific context of the query. 
            If, and only if, the query is too short and is missing essential information, see if you can extract synonyms or enrich the query with entities that don't exist in the original query but might help the search.
            </Instructions>
            Return the thinking process inside <thinking> tags and the final entities inside <entities> tags.</code></pre><p>Finally, we tried a prompt with even more explicit instructions and details encouraging the model to apply different techniques based on the original query’s length.</p><p><strong>Prompt 3.</strong></p><pre><code>You are a search engine and you want to extract the relevant keyword, codewords or proper names from a query.
            <Instructions>
            Extraction for boosting -  query length independent:
         Think about the query and identify the main context or the information that the user is asking for. 
         Select only the very central and necessary entities. They will be used as boosters for the lexical search, so make sure to only extract critical ones. 
         We don't want to boost documents that contain entities that might not be related to the specific context of the query.

             Enrichment - query length dependent:
       	If the query is shorter than 5 words, then extract the most important entities, but also evaluate whether you can enrich with synonyms or other entities that might enhance recall.
             If the query is larger than 10 words stick to entities that exist in the query
             If you find any initials, return the initials as well as the full words represented by the initials

            Stem-proofing - query length independent:
         Regardless of the query length, return the entities in different forms, in order to overcome stemming miss-matches, provided that the versions of the word are relevant to the query context e.g.: walk, walked, walking.
            </Instructions>
Return the thinking process inside <thinking> tags and the final entities, comma separated, inside <entities> tags.
          Make sure you are only returning the most important entities! Don't return entities that are too limiting.	
</code></pre><p>We ran lexical search tests on the three prompt variations on a subset of BEIR datasets and compared performance in terms of relevance and recall. The following table lists averaged results over datasets ArguAna, FiQA-2018, Natural Questions (NQ), SciDocs, SciFact, TREC-COVID, Touché 2020, NFCorpus, Robust04:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Original query</td><td style="border: 1px solid #ccc; padding: 4px;">Prompt 1</td><td style="border: 1px solid #ccc; padding: 4px;">Prompt 2</td><td style="border: 1px solid #ccc; padding: 4px;">Prompt 3</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">NDCG@10</td><td style="border: 1px solid #ccc; padding: 4px;">0.346</td><td style="border: 1px solid #ccc; padding: 4px;">0.345</td><td style="border: 1px solid #ccc; padding: 4px;">0.356</td><td style="border: 1px solid #ccc; padding: 4px;">0.346</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Recall@10</td><td style="border: 1px solid #ccc; padding: 4px;">0.454</td><td style="border: 1px solid #ccc; padding: 4px;">0.453</td><td style="border: 1px solid #ccc; padding: 4px;">0.466</td><td style="border: 1px solid #ccc; padding: 4px;">0.455</td>
                    </tr>
              </tbody>
            </table>
          <p>We consider a baseline lexical search of the original queries and compare with experiments where the LLM-output entities are used in lexical search. The results are linearly combined with an Elasticsearch <code>bool</code> query with a <code>must</code> clause wrapping the original query and a <code>should</code> clause for each returned LLM term. (See the <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-bool-query">Elasticsearch docs</a> for more information on Boolean queries.) The linear combination can be expressed as:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b5ccd419194eab69ea5c214bfb0e109c4f8248a7-1146x306.png" style="width: 100%;" /><p>Where n is the number of keywords returned for each query. Score in this case refers to a lexical BM25 score. The query is written in native Elasticsearch Query DSL and is provided in the following section.</p><p>The rescoring, including the contribution of the extracted keywords, was applied to the top 200 retrieved documents.</p><p>Prompt 2 performs best, both in terms of relevance and recall considering the top 10 results, improving +1pt, whereas the other prompts have little effect. We kept prompt 2 for lexical keyword extraction going forward.</p><p>We visually inspect the returned keywords for some of the tested queries in these following examples, and identify several query optimization techniques that were mentioned in the previous section.</p><p>Typo corrections:</p><ul><li><strong>Query:</strong> when does season 3 of strnger things come out</li><li><strong>Keywords:</strong> Stranger Things, Season 3</li></ul><p>Noise reduction:</p><ul><li><strong>Query:</strong> Evolvability : What Is It and How Do We Get It?</li><li><strong>Keywords:</strong> evolvability</li></ul><p>Keyword expansion/augmentation:</p><p>a)</p><ul><li><strong>Query:</strong> Trust-Aware Review Spam Detection</li><li><strong>Keywords:</strong> Trust-Aware, Review Spam Detection, fake reviews, credibility&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; </li></ul><p>b)&nbsp;&nbsp; </p><ul><li><strong> Query:</strong> Dimensional inconsistencies in code and ROS messages: A study of 5.9M lines of code</li><li><strong>Keywords:</strong> dimensional inconsistencies, code, ROS, Robot Operating System, 5.9M</li></ul><p>c)</p><ul><li><strong>Query:</strong> low-carb diets</li><li><strong>Keywords:</strong> low-carb, diets, ketogenic, keto, carbohydrate</li></ul><h4>Pseudo-answer generation</h4><p>Prompting the LLM to generate pseudo-answers to the original query is another technique that was explored. It has been explored before in the context of query rewriting,9 and is expected to especially benefit factual queries and match the advantages of keyword augmentation. For this method we used the prompt:</p><p><br/><strong>Prompt 4.</strong></p><pre><code>You are a search engine assistant and you want to generate 5 possible replies for a query. 
The replies will be used to boost the search results, in a combined manner with the original query. Make sure that the generated results respect the preferences of most search (lexical or vector) engines, that is, they should be concise, relevant, and cover different aspects of the query.
Return the rewritten replies inside <replies> tags. Separate the replies by the line break symbol.
</code></pre><p>We explore some examples of LLM outputs for this prompting strategy. For comparison, the extracted Entities for Prompt 4 are also listed.<br/>Providing replies to a general knowledge question:</p><p><strong>Query: </strong>What is the best way to practice web development?</p><ul><li><strong>Replies:</strong><ul><li>Learn HTML, CSS, and JavaScript fundamentals</li><li>Build personal projects and create a portfolio</li><li>Contribute to open-source projects on GitHub</li><li>Follow online tutorials and take coding courses</li><li>Practice responsive design and cross-browser compatibility</li></ul></li><li><strong>Entities (Prompt 4): </strong>development, practice, coding projects, tools</li></ul><p>Offering imagined document titles that might contain a reply to a question that cannot be answered based on general knowledge:</p><p><strong>Query: </strong>In your opinion, what is the best classical song of all time?</p><ul><li><strong>Replies:</strong><ul><li>Best classical compositions ranked by music critics</li><li>Most popular and influential classical pieces in history</li><li>Timeless masterpieces from renowned classical composers</li><li>Iconic classical works that have stood the test of time</li><li>Top classical music recommendations for beginners and enthusiasts</li></ul></li><li><strong>Entities (Prompt 4):</strong> classical music, classical composition, symphony, concerto, opera</li></ul><p><strong>Query: </strong>A description of the history and meaning of the 1st amendment</p><ul><li><strong>Replies:</strong><ul><li>First Amendment: origins, historical context, and significance in US Constitution</li><li>Freedom of speech, religion, press, assembly: core principles of 1st Amendment</li><li>Supreme Court interpretations and landmark cases on First Amendment rights</li><li>Evolution of First Amendment protections throughout American history</li><li>Impact and controversies surrounding the First Amendment in modern society</li></ul></li><li><strong>Entities (Prompt 4):</strong> Amendment, Constitution, Bill of Rights</li></ul><p>We observe an interesting combination of LLM output modes for this strategy. Different types of queries are handled differently:</p><ul><li>When the query can be directly replied to based on general knowledge, the LLM returns possible replies.</li><li>If not, the LLM “imagines” what the type of document that the user might want could look like and returns these title-like document descriptions as replies.</li></ul><p>Both methods end up introducing useful and creative new keywords.</p><h4>Letting the model choose a method</h4><p>Finally, experiments were run using a prompt that encouraged the model to select the most appropriate method, or combination of methods and asked it to explain its rationale behind the choice. We expect the model to be inherently capable of “thinking” through how each query can be improved in a search context:</p><p><br/><strong>Prompt 5.</strong></p><pre><code>You are a search engine and you want to extract the relevant keyword, codewords or proper names from a query.
            There are 3 basic methods to do so:
            1. extract important entities and keywords
            2. create a pseudo answer to the query
            3. expand the initial query with synonyms and related terms
            Given a query, choose a method or a combination of any of them and return the rewritten query inside <rewritten query> tags.
            You can separate the different parts or terms with the line break symbol.
            Also return the rationale behind your choice of methods inside <thinking> tags. Why does this query need this type of method?</code></pre><p>The query rewriting implementation for prompts 4 and 5 closely resembles that of prompt 2. Specifically, the LLM output is incorporated into a Boolean query, where the original query is placed in a <code>must </code>clause and each LLM-generated term is included in a <code>should</code> clause. For prompt 4, an LLM-output term represents a single pseudo-answer, while for prompt 5, it represents a rewrite.</p><p>Overall, the change in performance using different prompts is significant in the context of lexical search, where prompts 4 and 5 outperform prompt 2. In the following table, LKE refers to lexical keyword extraction using prompt 2, PA refers to pseudo-answer generation using prompt 4, and MC stands for model’s choice and refers to prompt 5. The model’s output is used according to equation 1.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0bb953db787dcb03c9221e9b6e7969d3bffdb17f-2002x670.png" style="width: 100%;" /><p>In the final row of the table, the scores are averaged at the benchmark level. It’s computed as an average of the average scores of BEIR, MLDR, and MIRACL benchmarks. The pseudo-answers and model’s choice strategies perform better across metrics, with pseudo-answers being slightly better.</p><p>We further analyze these prompting techniques and obtain more results in the following section, with respect to vector search experiments.</p><p>We also present the results of lexical search when using <em>only</em> the query rewriting terms. In the table below, the original query&#x27;s contribution to the scores is entirely excluded:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4dcdfe2572217ce017c746c55f016f282c61c6e3-2006x670.png" style="width: 100%;" /><p>Excluding the original query scores from the result seems to hurt relevance performance by average. In terms of Recall@50, the pseudo-answer strategy results in improved performance compared to baseline, but the boost is smaller than when the strategy includes the original query.</p><p>Overall, we recommend combining the query rewriting terms with the original query to achieve gains across metrics in lexical search.</p><h4>Large language models versus small language models</h4><p>For the majority of the results discussed in this blog, we utilized Anthropic&#x27;s Claude 3.5 Sonnet LLM. However, we also experimented with a smaller model to assess how inference cost affects performance. We tried LKE with Anthropic’s Claude 3.5 Haiku for a subset of datasets from BEIR (ArguAna, FiQA-2018, Natural Questions [NQ], SciDocs, SciFact, TREC-COVID, Touché 2020, NFCorpus, Robust04).</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;"></td><td style="border: 1px solid #ccc; padding: 4px;">Original query </td><td style="border: 1px solid #ccc; padding: 4px;">LKE with Sonnet</td><td style="border: 1px solid #ccc; padding: 4px;">LKE with Haiku</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">NDCG@10</td><td style="border: 1px solid #ccc; padding: 4px;">0.346</td><td style="border: 1px solid #ccc; padding: 4px;">0.364</td><td style="border: 1px solid #ccc; padding: 4px;">0.368</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Recall@10</td><td style="border: 1px solid #ccc; padding: 4px;">0.454</td><td style="border: 1px solid #ccc; padding: 4px;">0.472</td><td style="border: 1px solid #ccc; padding: 4px;">0.475</td>
                    </tr>
              </tbody>
            </table>
          <p>Relevance and recall within the top 10 results remain unaffected. While this initial investigation is not exhaustive and requires further study in real-world scenarios that implement query optimization, these first results strongly suggest that small language models (SLMs) are likely a viable option for this specific use case.</p><p>A comparison between Claude 3.5 Sonnet and Claude 3.5 Haiku is provided below:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model</td><td style="border: 1px solid #ccc; padding: 4px;">Number of
parameters</td><td style="border: 1px solid #ccc; padding: 4px;">Context window</td><td style="border: 1px solid #ccc; padding: 4px;">Max output</td><td style="border: 1px solid #ccc; padding: 4px;">Input cost</td><td style="border: 1px solid #ccc; padding: 4px;">Output cost</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Claude 3.5 Sonnet</td><td style="border: 1px solid #ccc; padding: 4px;">Est. ~175B</td><td style="border: 1px solid #ccc; padding: 4px;">200K</td><td style="border: 1px solid #ccc; padding: 4px;">8192 tokens</td><td style="border: 1px solid #ccc; padding: 4px;">$15.00 / million tokens</td><td style="border: 1px solid #ccc; padding: 4px;">$4.00 / million tokens</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Claude 3.5 Haiku</td><td style="border: 1px solid #ccc; padding: 4px;">Est. ~20B</td><td style="border: 1px solid #ccc; padding: 4px;">200K</td><td style="border: 1px solid #ccc; padding: 4px;">8192 tokens</td><td style="border: 1px solid #ccc; padding: 4px;">$3.00 / million tokens</td><td style="border: 1px solid #ccc; padding: 4px;">$0.80 / million tokens</td>
                    </tr>
              </tbody>
            </table>
          <h3>Query rewriting in Elasticsearch</h3><p>In enterprise search, where precision and control are paramount, we explored methods that integrate query rewriting with existing search functionality. The focus was on strategies that build upon the original query to target relevance gains without a high implementation cost.</p><p>Elasticsearch features a wide range of search tools that tackle different search scenarios. It supports lexical and vector retrieval, as well as rerankers. We look for optimal ways to integrate query rewriting strategies in Elasticsearch, trying to explore across base retrievers and hybrid methods.</p><p>In the previous section, we presented results on lexical search and introduced equations 1 and 2. These correspond to the following Elasticsearch Query DSL code:</p><pre><code>{
  "query": {
    "bool": {
      "must":  { "match": { "text": "ORIGINAL QUERY" } },
      "should": [
        { "match": { "text": "QR TERM 1" } },
        { "match": { "text": "QR TERM 2" } },
        { "match": { "text": "QR TERM 3" } }
      ]
    }
  }
}</code></pre><p>QR TERM 1, 2, 3 stands for query rewriting term and refers to whatever the LLM output represents: keywords, pseudo-answers, or other types of replies.</p><p>The <code>bool</code> query functions like a linear combination of terms. Crucially, the <code>must</code> clause enforces <em>hard</em> requirements, meaning any document that fails to match this clause is excluded from the results. In contrast, the <code>should</code> clause operates as a score booster: Documents matching it receive a higher final score, but documents that don&#x27;t match are not discarded from the results.</p><p>Through iterative experimentation, we determined the most effective query configuration. Initial attempts included querying solely with terms generated by the LLM or various combinations of the original query and LLM terms. We observed that overreliance on LLM output reduced relevance. The optimal setup, which consistently yielded the best results, required the full inclusion of the original query, with the LLM output used only to selectively boost the ranking of certain documents.</p><h4>Dense vector search as base retriever</h4><p>When moving to vector search, the narrative changes. It’s already well-established in the industry that hybrid search (lexical + vector) improves both relevance and recall by combining the semantic understanding of dense vectors with the exact matching precision of BM25. Our goal here was to determine whether query rewriting applied to a vector retriever covers the same gap that hybrid search fixes or provides additional improvement.</p><p>For experiments where the base retriever was not BM25 but rather a K-Nearest Neighbors (KNN) search using dense vectors, we chose to explore the maximum gains we can achieve in terms of relevance and recall using an optimized, weighted linear combination. We opted to optimize the weight to ensure that the impact of query rewriting strategies on performance is isolated, preventing any potential influence from a poorly chosen linear combination on the results. We run an optimization process expressed in equation 3 using Bayesian Optimization (Optuna14) on the test set itself.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e4d8498261842d6d0942661e8bbfbe8726ddce9c-1186x176.png" style="width: 100%;" /><p>The optimization process was conducted on the same text queries used for evaluation to establish the upper bounds of potential performance improvements. Because we’re tuning only one parameter, the chance of fitting the test data is minimal. We verify this hypothesis below by running the optimization for some datasets on the train split and observe the difference in terms of performance.</p><p>For vector search evaluation, we use two optimization metrics:</p><ul><li><strong>LINEAR NDCG@10 OPTIMIZED(vector_oq, bm25_qr):</strong> The weight is optimized to achieve the maximum NDCG at the top 10 results.</li><li><strong>LINEAR RECALL@50 OPTIMIZED(vector_oq, bm25_qr):</strong> The weight is optimized to achieve the maximum recall at the top 50 results.</li></ul><p>In these metrics, <strong>oq</strong> stands for the original query, and <strong>qr</strong> stands for query rewriting. We include recall at 50 to assess query optimization&#x27;s performance as a first-stage retriever, with the assumption that the search results will subsequently be processed by a reranker.</p><p>To provide a comparison, we also conducted experiments where the BM25 scores of the original query were combined with the vector search scores. These combinations are referred to as:</p><ul><li><strong>LINEAR NDCG@10 OPTIMIZED(vector_oq, bm25_oq)</strong></li><li><strong>LINEAR RECALL@50 OPTIMIZED(vector_oq, bm25_qr)</strong></li></ul><p>For the experiments in the following tables we used the <code>multilingual-e5-large</code> [15] dense vector model for benchmarks BEIR and MIRACL, and the <code>Qwen3-0.6B-Embedding</code> [16] model to search for long-context documents in the MLDR benchmark.</p><p>First, we verify that performing the optimization on the evaluation split instead of the training split yields results that follow the same trends. The pseudo-answers prompting strategy was used to compute the qr scores.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fb329c72a7b1123428ed519f77d008c3084c064e-1736x532.png" style="width: 100%;" /><p>We compare the recall@50 scores when optimizing on the training split versus the evaluation split for MIRACL and MLDR, finding that both produced results on the same range.</p><p>In the following tables, the evaluation split is used for optimization. The final row contains results that are averaged on the benchmark level as in the previous tables.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9d10f644b474b5e114504e9515eee7457bbfa565-2014x820.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dfe8d5cbe9d9f5d7e8d2aa6f146550465ed4e73b-2014x402.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a88b3aef38fad245f629951b247a16fc8eb93574-1452x352.png" style="width: 100%;" /><p>We omit the Recall@10 results as they are very similar to NDCG@10 results. These tables show no advantage in hybrid search using QR terms instead of the original query. In terms of relevance, replacing the original query with QR seems to deteriorate results. In terms of recall, some gains are achieved in BEIR and MIRACL, but the averaged score reveals no advantage over a well-tuned hybrid search.</p><p>We further explored hybrid search using reciprocal rank fusion (RRF), relying on the built-in Elasticsearch functionality. Method <strong>RRF(vector_oq, bm25_qr) </strong>refers to DSL code:</p><pre><code>{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "knn": {
            "field": "dense_vector_field",
            "query_vector": ORIGINAL_QUERY_VECTOR,
            "k": 100,
            "num_candidates": 200
          }
        },
        {
          "standard": {
            "query": {
              "bool": {
                "should": [
                  { "match": { "text": "QR TERM" } },
                  { "match": { "text": "QR TERM" } },
                  { "match": { "text": "QR TERM" } }
                ]
              }
            }
          }
        }
      ],
      "rank_window_size": 500,
      "rank_constant": 20
    }
  },
  "size": 200,
}</code></pre><p>The corresponding baseline run is denoted <strong>RRF(vector_oq, bm25_oq)</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d3359c99afbf0649b9a610689198ff21e2cf0ac3-1398x504.png" style="width: 100%;" /><p>Replacing the original query with LLM-output terms to get lexical search scores deteriorates recall by average in all cases. In terms of relevance, we observe marginal improvement in BEIR and a notable increase by ~8 points of NDCG@10 in MIRACL. Relevance in MLDR is however so negatively affected that the average result is overall higher in baseline runs.</p><p>So far, our exploration has not revealed a definitive strategy for enhancing vector search performance through the exposed query rewriting methods. Considering the learnings from the exploration of lexical search, we try experimenting with hybrid search strategies that include both the original query and the query rewriting terms. We focus on a first-stage retrieval scenario and aim to improve Recall@50. In the following experiment, we try hybrid search with RRF, where the lexical scores are computed as an optimized linear combination of BM25 scores of the original query and BM25 scores of LLM-output terms. To establish an upper-bound for potential performance improvements, we perform Bayesian optimization on the set of queries using the Optuna library [14].</p><p>We denote this experiment <strong>RRF(vector_oq, LINEAR RECALL50 OPTIMIZED(bm25_oq, bm25_qr))</strong>. The same baseline as run in the previous table, <strong>RRF(vector_oq, bm25_oq)</strong>, still provides a useful comparison in this experiment.<br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/41e0eb5cd708c3705f5dc5ac0f7c3c01074556a2-1394x614.png" style="width: 100%;" /><p>This experiment was not designed for relevance optimization; however, the resulting NDCG@10 scores are documented for completeness. A 1–3 percentage points of recall@50 increase was achieved with this method, with the prompting strategy that generates pseudo-answers being the most prevalently beneficial among the benchmarks. This strategy is suitable only for informational queries based on general knowledge or when the LLM possesses the necessary domain expertise. This method was employed to determine the upper limits of potential performance improvements. It&#x27;s important to note that optimizing the weights using the complete test dataset, as was done, is not feasible in real-world applications.</p><p>PA turns out to be the most successful strategy for BEIR and MIRACL, while LKE gives the highest boost in recall for MLDR.</p><h4>First-stage retriever and reranking</h4><p>To maximize performance in a production setting, query rewriting could be viewed as part of a multistage pipeline. The goal of the first-stage retriever is not to be good at relevance but rather at recall, that is, to ensure the good documents make it into the candidate set for the reranker.</p><p>We implemented the following pipeline configuration:</p><ol><li><strong>Base retrieval:</strong> Retrieve top 200 documents.</li><li><strong>Entity boosting:</strong> Rescore based on LLM-extracted entities (from prompt 2).</li><li><strong>Pruning:</strong> Cut to the top 50 documents.</li><li><strong>Reranking:</strong> Apply <code>jina-reranker-v2</code> to the top 50 documents.</li></ol><p>We compared the final outcomes of this pipeline using different first-stage inputs including base retrievers and base retrievers plus QR. For QR and hybrid baseline, we used the experiments that obtained higher recall.</p><ul><li>Lexical corresponds to the original query using BM25.</li><li>Lexical + QR corresponds to the experiment using prompt MC.</li><li>Vector corresponds to the original query using vector search.</li><li>Vector + QR corresponds to the experiment LINEAR RECALL50 OPTIMIZED (vector_oq, bm25_qr) for prompt PA.</li><li>Hybrid corresponds to the RRF (vector_oq, bm25_oq).</li></ul><p>Hybrid + QR corresponds to the experiment RRF (vector_oq, LINEAR RECALL50 OPTIMIZED (bm25_oq, bm25_qr)) for prompt PA.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8e5bd75acf283e162d90a4778d6e0c2d0def4ec8-1210x434.png" style="width: 100%;" /><p>The last row shows the averaged results across BEIR, MLDR, and MIRACL.</p><p></p><p>QR in lexical and vector is applied by combining the scores as shown in equations (1, 2) and optimized for recall. RRF is widely considered a strong standard for maximizing recall in hybrid search. Our results show that an optimized linear combination of the original vector and QR actually can achieve better recall than the RRF configurations, which cannot be optimized. This suggests that, with the right weighting, a linear combination can be more effective at using LLM-generated queries for recall than rank-based fusion, since optimizing RRF is less effective.</p><p>The table below shows the averages across datasets for NDCG@10 before and after reranking and recall@50:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8a0b9b1fe65c767199d212de8084f900f00d99c5-1440x326.png" style="width: 100%;" /><p>Relevance (NDCG@10) results improve after reranking is applied. Consistently improving alongside recall@50.</p><h4>Strategy domain adaptation</h4><p>Unlike open web search, enterprise domains (such as legal, medical, or internal wikis) have specific vocabularies that generic LLMs might miss. Below we discuss several strategies that could be used to tailor the presented QR strategies for specific domains:</p><ul><li>Domain-specific tuning: Further tune the query rewriting instructions (prompts) specifically for niche domains or specific use cases.</li><li>In-context learning: Use few-shot examples retrieved from a knowledge base (such as, append the top <em>k</em> BM25 results to the prompt from a quick, cheap initial search) to ground the rewriting process.</li><li>LLMs + rules hybrid approach: Combine the flexibility of LLMs with deterministic rules for specific domain terms.</li><li>Gated query rewriting: Selectively apply QR only when necessary, employing rules, custom classifiers, or specialized prompts and models to detect whether the query requires optimization for a specific use case</li><li>Generation: Query rewriting for generation: Expanding the query or context not just for retrieval but also specifically to improve the quality of the final LLM response generation.</li></ul><h3>Conclusions</h3><p>The investigation shows how simple LLM-driven query optimization can have a positive impact within the modern search ecosystem.</p><h4>Key take-aways</h4><ul><li>LLMs are a good complement to improve lexical search: Using LLMs to enrich keywords or generate pseudo-answers provides consistent improvements in both relevance and recall for standard lexical retrieval.</li><li>Hybrid search is harder to beat<strong>:</strong> When using dense vector search or hybrid retrieval, simple query rewriting terms offer marginal gains. The best results come from using QR to <em>boost</em> existing hybrid scores rather than replacing them.</li><li>Pseudo-answers improve recall<strong>:</strong> Generating hypothetical answers (pseudo-answer generation) proved to be the most effective strategy for maximizing recall in multistage pipelines.</li><li>Structured guidance over free-form generation: Guiding the LLM is critical. Rather than allowing the LLM to freely rephrase a query, providing a strict template (like extracting specific entities to fit a DSL clause) ensures that the output adds value without introducing noise. A specific prompt + DSL template combination allows the design for a specific relevance use case (such as lexical extraction versus semantic expansion) and reduces the scope of error.</li><li>Efficiency with small models: The strategies explored here are simple strategies that could be deployed effectively using SLMs or distilled into compact models, offering a cost-effective solution.</li></ul><p>The following table contains some practical guidelines on how to incorporate the most successful query rewriting techniques into your search pipeline, depending on your particular setting:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Real-world setting

</td><td style="border: 1px solid #ccc; padding: 4px;">QR strategy </td><td style="border: 1px solid #ccc; padding: 4px;">Implementation</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Lexical search </td><td style="border: 1px solid #ccc; padding: 4px;">Lexical keyword enrichment (prompt 2)</td><td style="border: 1px solid #ccc; padding: 4px;">Boost search performance without the cost of migrating to vector search:
Use the LLM's output (keywords, entities, synonyms) in an Elasticsearch `should` clause to boost documents that match the expanded terms, while keeping the original query in a `must` clause. Avoid relying solely on QR-generated terms, and use SLMs to reduce costs without compromising QR performance.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Multistage retrieval with reranking</td><td style="border: 1px solid #ccc; padding: 4px;">Pseudo-answer generation (prompt 4)</td><td style="border: 1px solid #ccc; padding: 4px;">In a multistage search system (retriever followed by a reranker), use the pseudo-answers as boosting terms. Use the Elasticsearch `should` +` must` clauses structure as before to retrieve the widest set of potentially relevant documents before passing them to the reranker to score.</td>
                    </tr>
              </tbody>
            </table>
          <p>To reiterate our earlier comments, these solutions will benefit scenarios where most queries are retrieval queries, that is, scenarios where relevance does not depend on specific filtering, aggregations, or other types of structure. However, the same template meta strategy can potentially be adapted to such cases.</p><h4>The value of task‑focused tuning in search pipeline design</h4><p></p><p>One of the broader implications of this investigation is the importance of viewing search pipeline architectures as a set of modular, well‑defined stages where lightweight, task‑focused adjustments can meaningfully improve performance, allowing pipeline components to be tuned for specific retrieval goals. Such tuning could involve a variety of strategies, including experimenting with how LLMs are prompted to target particular gains (such as maximizing recall versus precision), parametrizing how LLM output is combined with the original query (for example, DSL Query template), or evaluating the impact of different rescoring strategies (such as MMR or <code>match_phrase–based</code> query rescoring) on an initial candidate set (such as the top 200 retrieved documents), and layering these techniques before a more computationally intensive reranking step. Overall, this perspective encourages designing pipelines with clear component boundaries and a small, controllable set of hyperparameters that can be tuned to achieve targeted retrieval outcomes. Furthermore, although our experiments demonstrated measurable gains in a general‑purpose IR setting, we expect these interventions to be even more impactful in scenarios where relevance is narrowly defined, allowing the template‑based approach to improve results in a more controlled way.</p><h4>LLM-driven query optimization in modern search pipelines</h4><p>Simple query rewriting strategies can be well-suited, easy-to-plug-in solutions for targeted performance gains. In environments where LLMs are already in use (for example, RAG, conversational interfaces, or agentic search workflows), the overhead of an extra LLM call for rewriting is absorbed, making latency less of an issue. This allows for significant and targeted improvements in relevance and recall across specific domains or challenging query types.</p><p>All the strategies discussed in this blog consist of a combination of an LLM prompt and an Elasticsearch Query DSL template, and hence they can be naturally integrated into the application layer of a search solution.</p><p>Finally, Elasticsearch has already begun integrating LLM-powered capabilities directly into its search experience, offering tools like <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> <code>COMPLETION</code>, managed LLMs through the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a>, and lately, the possibility to build a custom query rewriting tool within <a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a>.</p><p>A detailed table of the results presented can be found <a href="https://docs.google.com/spreadsheets/d/1kd7ToPZFwFjow3OIIwQv_-G34SDZzwYYOgTFJT76vgY/edit?gid=521501384#gid=521501384">here</a>.</p><h2>References</h2><ol><li>Xiong, H., Bian, J., Li, Y., Li, X., Du, M., Wang, S., Yin, D., &amp; Helal, S. (2024). When search engine services meet large language models: Visions and challenges. <a href="https://doi.org/10.48550/arXiv.2407.00128">arXiv</a>.</li><li>Remmey, M. (2024, May 14). <em>NL to SQL architecture alternatives</em>. Azure Architecture Blog. <a href="https://techcommunity.microsoft.com/blog/azurearchitectureblog/nl-to-sql-architecture-alternatives/4136387">https://techcommunity.microsoft.com/blog/azurearchitectureblog/nl-to-sql-architecture-alternatives/4136387</a></li><li>Abdallah, A., Piryani, B., Mozafari, J., Ali, M., &amp; Jatowt, A. (2025, August 22). <em>How good are LLM-based rerankers? An empirical analysis of state-of-the-art reranking models</em>. arXiv. <a href="https://arxiv.org/abs/2508.16757">arxiv</a></li><li>Joshi, A., Shi, Z., Goindani, A., &amp; Liu, H. (2025, October 22). <em>The case against LLMs as rerankers</em>. Voyage AI. <a href="https://blog.voyageai.com/2025/10/22/the-case-against-llms-as-rerankers/">https://blog.voyageai.com/2025/10/22/the-case-against-llms-as-rerankers/</a></li><li>Oosterhuis, H., Jagerman, R., Qin, Z., &amp; Wang, X. (2025, July). <em>Optimizing compound retrieval systems</em>. In <em>Proceedings of the 48th International ACM SIGIR Conference on Research and Development in Information Retrieval (SIGIR ’25)</em> (pp. 1–11). ACM. <a href="https://doi.org/10.1145/3726302.3730051">https://doi.org/10.1145/3726302.3730051</a></li><li>Zhang Y, Li M, Long D, Zhang X, Lin H, Yang B, Xie P, Yang A, Liu D, Lin J, Huang F, Zhou J. <em>Qwen3 Embedding: Advancing Text Embedding and Reranking Through Foundation Models.</em> arXiv preprint arXiv:2506.05176. 2025.<a href="https://arxiv.org/abs/2506.05176"> arXiv</a></li><li>Wang L, Yang N, Huang X, Yang L, Majumder R, Wei F. <em>Improving Text Embeddings with Large Language Models.</em> arXiv preprint arXiv:2401.00368. 2024.<a href="https://arxiv.org/pdf/2401.00368"> arXiv</a></li><li>Lee J, Dai Z, Ren X, Chen B, Cer D, Cole JR, et al. <em>Gecko: Versatile Text Embeddings Distilled from Large Language Models.</em> arXiv preprint arXiv:2403.20327. 2024.<a href="https://arxiv.org/abs/2403.20327"> arxiv</a></li><li>Li, Zhicong; Wang, Jiahao; Jiang, Zhishu; Mao, Hangyu; Chen, Zhongxia; Du, Jiazhen; Zhang, Yuanxing; Zhang, Fuzheng; Zhang, Di; Liu, Yong (2024). <em>DMQR-RAG: Diverse Multi-Query Rewriting for RAG</em>. arXiv preprint arXiv:2411.13154. DOI:10.48550/arXiv.2411.13154. (<a href="https://arxiv.org/abs/2411.13154">arxiv</a>)</li><li>Van Gysel C, de Rijke M. <em>pytrec_eval: An extremely fast Python interface to trec_eval</em>. In: Proceedings of the 2018 SIGIR Workshop on Reproducibility in Information Retrieval; 2018.</li><li>Thakur N, Reimers N, Rücklé A, Srivastava A, Gurevych I. <em>BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models</em>. arXiv [cs.IR]. 2021;arXiv:2104.08663. (<a href="https://arxiv.org/abs/2104.08663">arxiv</a>)</li><li>Chen J, Xiao S, Zhang P, Luo K, Lian D, Liu Z. <em>BGE M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation</em>. arXiv [cs.CL]. 2024;arXiv:2402.03216. (<a href="https://arxiv.org/abs/2402.03216">arxiv</a>)</li><li>Zhang X, Thakur N, Ogundepo O, Kamalloo E, Alfonso-Hermelo D, Li X, Liu Q, Rezagholizadeh M, Lin J. <em>MIRACL: A Multilingual Retrieval Dataset Covering 18 Diverse Languages</em>. Trans Assoc Comput Linguistics. 2023;11:1114-1131. (<a href="https://aclanthology.org/2023.tacl-1.63/">aclanthology.org</a>)</li><li>Akiba T, Sano S, Yanase T, Ohta T, Koyama M. Optuna: A Next-generation Hyperparameter Optimization Framework. <em>Proceedings of the 25th ACM SIGKDD International Conference on Knowledge Discovery &amp; Data Mining (KDD ’19).</em> 2019:2623-2631. doi:10.1145/3292500.3330701</li><li>Wang L, Yang N, Huang X, Yang L, Majumder R, Wei F. <em>Multilingual E5 Text Embeddings: A Technical Report.</em> arXiv preprint arXiv:2402.05672. Published 2024. Accessed November 18, 2025. <a href="https://arxiv.org/abs/2402.05672">arxiv</a></li><li>Zhang Y, Li M, Long D, Zhang X, Lin H, Yang B, Xie P, Yang A, Liu D, Lin J, Huang F, Zhou J. Qwen3 Embedding: Advancing Text Embedding and Reranking Through Foundation Models. <em>arXiv preprint</em> arXiv:2506.05176. Published 2025. Accessed November 18, 2025. https://arxiv.org/abs/2506.05176<a href="https://huggingface.co/papers/2506.05176"> Hugging Face+2GitHub+2</a></li></ol>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building human-in-the-loop (HITL) AI agents with LangGraph and Elasticsearch]]></title>
            <description><![CDATA[ Learn what human-in-the-loop (HITL) is and how to build an HITL system with LangGraph and Elasticsearch for a flight system.]]></description>
            <link>https://www.elastic.co/search-labs/blog/human-in-the-loop-hitllanggraph-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/human-in-the-loop-hitllanggraph-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Thu, 29 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/17a2863e110d7415959184a8375d70676695f35c-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we’ll explore how to combine <a href="https://langchain-ai.github.io/langgraph/">LangGraph</a> and Elasticsearch to build a human-in-the-loop (HITL) system. This approach allows AI systems to involve users directly in the decision-making process, making interactions more reliable and context-aware. We’ll implement a practical example using a context-driven scenario to demonstrate how LangGraph workflows can integrate with Elasticsearch to retrieve data, handle user input, and produce refined results.</p><h2>Requirements</h2><ul><li>NodeJS version 18 or newer</li><li>OpenAI API Key</li><li>Elasticsearch 8.x+ deployment</li></ul><h2>Why use LangGraph for production HITL systems</h2><p>In a <a href="https://www.elastic.co/search-labs/blog/ai-agent-workflow-finance-langgraph-elasticsearch">previous article</a>, we presented LangGraph and its benefits for building a retrieval-augmented generation (RAG) system using large language models (LLMs) and <a href="https://docs.langchain.com/oss/javascript/langgraph/graph-api#conditional-edges">conditional edges</a> to automatically make decisions and display results. Sometimes we don’t want the system to act autonomously end to end, but we want users to select options and make decisions within the execution loop. This concept is called <strong><em>human in the loop</em></strong>.</p><h2>Human in the loop</h2><p>HITL is an AI concept that allows a real person to interact with AI systems to provide more context, evaluate responses, edit responses, ask for more information, and perform other tasks. This is very useful in low-error-tolerance scenarios, such as compliance, decision-making, and content generation, helping improve the reliability of LLM outputs.</p><p>It&#x27;s important to note that the primary purpose of HITL in agentic systems is validation, not blind trust in the agent&#x27;s approach. HITL interventions should be reactive and triggered only when the system detects missing or ambiguous information. This ensures human involvement remains meaningful and adds value, rather than becoming a mandatory checkpoint that interrupts every workflow unnecessarily.</p><p>A common example is when your coding assistant asks you for permission to execute a certain command on the terminal or shows you the step-by-step thinking process for you to approve before starting coding.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4df9fe7383f0dc665a1b0866651f4f6a40a14aa8-499x215.png" style="width: 100%;" /><h2>Elasticsearch and LangGraph: How they interact</h2><p><a href="https://www.langchain.com/">LangChain</a> allows us to use Elasticsearch as a <a href="https://js.langchain.com/docs/integrations/vectorstores/elasticsearch/">vector store</a> and to perform queries within LangGraph applications, which is useful to execute full-text or semantic searches, while LangGraph is used to define the specific workflow, tools, and interactions. It also adds HITL as an additional interaction layer with the user.</p><h2>Practical implementation: Human in the loop</h2><p>Let’s imagine a case where a lawyer has a question about a case he recently took on. Without the right tools, he would need to manually search through legal articles and precedents, read them in full, and then interpret how they apply to his situation. With LangGraph and Elasticsearch, however, we can build a system that searches a database of legal precedents and generates a case analysis that incorporates the specific details and context provided by the lawyer. You can find the full implementation of this use case in the following <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch">repository</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e95bf4ce115cbe9954ee5033776408bed485b8d2-1600x906.png" style="width: 100%;" /><p>The workflow begins when the lawyer submits a legal question. The system performs a vector search in Elasticsearch, retrieves the most relevant precedents, and presents them for the lawyer to choose from, using natural language. After the selection, the LLM generates a draft analysis and checks whether the information is complete. At this point, the workflow can follow two paths: If everything is clear, it proceeds directly to generate a final analysis; if not, it pauses to request clarification from the lawyer. Once the missing context is provided, the system completes the analysis and returns it, taking into consideration the clarifications.</p><p>The following is a graph drawn by LangGraph showing how the app will look at the end of the development. Each node represents a tool or functionality:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/43b274d52f3644e3949bea3c8e4312c4c94384c2-307x1000.png" style="width: 100%;" /><h3>Dataset</h3><p>Here’s the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/dataset.json">dataset</a> that will be used for this example. This dataset contains a collection of legal precedents, each describing a case involving service delays, the court’s reasoning, and the final outcome.</p><pre><code>[
  {
    "pageContent": "Legal precedent: Case B - Service delay not considered breach. A consulting contract used term 'timely delivery' without specific dates. A three-week delay occurred but contract lacked explicit schedule. Court ruled no breach as parties had not defined concrete timeline and delay did not cause demonstrable harm.",
    "metadata": {
      "caseId": "CASE-B-2022",
      "contractType": "consulting agreement",
      "delayPeriod": "three weeks",
      "outcome": "no breach found",
      "reasoning": "no explicit deadline defined, no demonstrable harm",
      "keyTerms": "timely delivery, open terms, schedule definition",
      "title": "Case B: Delay Without Explicit Schedule"
    }
  },
  ...
]</code></pre><h3>Ingestion and index setup</h3><p>The index setup and data ingestion logic are defined in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/dataIngestion.ts">dataIngestion.ts</a> file, where we declare functions to handle the index creation. This setup is compatible with the LangChain <a href="https://python.langchain.com/docs/integrations/vectorstores/elasticsearch/">vector store interface for Elasticsearch</a>.</p><p><em>Note: The mapping setup is also included in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/dataIngestion.ts">dataIngestion.ts</a> file.</em></p><h3>Install packages and set up environment variables</h3><p>Let&#x27;s initialize a <a href="https://nodejs.org/en">Node.js</a> project with default settings:</p><pre><code>npm init -y</code></pre><p>Now let&#x27;s install all required dependencies:</p><ul><li><strong>@elastic/elasticsearch: </strong>Elasticsearch client for Node.js. Used to connect, create indices, and run queries.</li><li><strong>@langchain/community:</strong> Provides integrations for community-supported tools, including the ElasticVectorSearch store.</li><li><strong>@langchain/core:</strong> Core building blocks of LangChain, such as chains, prompts, and utilities.</li><li><strong>@langchain/langgraph:</strong> Adds graph-based orchestration, allowing workflows with nodes, edges, and state management.</li><li><strong>@langchain/openai: </strong>Provides access to OpenAI models (LLMs and embeddings) through LangChain.</li><li><strong>dotenv:</strong> Loads environment variables from an .env file into process.env.</li><li><strong>tsx: </strong>Is a useful tool to execute typescript code.</li></ul><p>Run the following command in the console to install all of them:</p><pre><code>npm install @elastic/elasticsearch @langchain/community @langchain/core @langchain/langgraph @langchain/openai dotenv --legacy-peer-deps && npm install --save-dev tsx</code></pre><p>Create an .env file to set up the environment variables:</p><pre><code>ELASTICSEARCH_ENDPOINT=
ELASTICSEARCH_API_KEY=
OPENAI_API_KEY=</code></pre><p>We’ll use <a href="https://www.typescriptlang.org/">TypeScript</a> to write the code because it provides a layer of type safety and a better developer experience. Create a TypeScript file named <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/main.ts">main.ts</a>, and insert the next section&#x27;s code.</p><h3>Package imports</h3><p>In the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/main.ts">main.ts</a> file, we start by importing the required modules and initializing the environment variable configuration. This includes the core LangGraph components, the OpenAI model integrations, and the Elasticsearch client.</p><p>We also import the following from the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/dataIngestion.ts">dataIngestion.ts</a> file:</p><ul><li><strong><code>ingestData</code>: </strong>A function that creates the index and ingests the data.</li><li><strong>Document and DocumentMetadata:</strong> Interfaces that define the dataset document structure.</li></ul><h3>Elasticsearch vector store client, embeddings client, and OpenAI client</h3><p>This code will initialize the vector store, the embeddings client, and one OpenAI client:</p><pre><code>const VECTOR_INDEX = "legal-precedents";

const llm = new ChatOpenAI({ model: "gpt-4o-mini" });
const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

const esClient = new Client({
  node: process.env.ELASTICSEARCH_ENDPOINT,
  auth: {
    apiKey: process.env.ELASTICSEARCH_API_KEY ?? "",
  },
});

const vectorStore = new ElasticVectorSearch(embeddings, {
  client: esClient,
  indexName: VECTOR_INDEX,
});</code></pre><p>Application workflow state schema will help in the communication between nodes:</p><pre><code>const LegalResearchState = Annotation.Root({
  query: Annotation<string>(),
  precedents: Annotation<Document[]>(),
  userChoice: Annotation<string>(),
  selectedPrecedent: Annotation<Document | null>(),
  validation: Annotation<string>(),
  draftAnalysis: Annotation<string>(),
  ambiguityDetected: Annotation<boolean>(),
  userClarification: Annotation<string>(),
  finalAnalysis: Annotation<string>(),
});</code></pre><p>In the state object, we’ll pass the following through the nodes: the user’s query, the concepts extracted from it, the legal precedents retrieved, and any ambiguity detected. The state also tracks the precedent selected by the user, the draft analysis generated along the way, and the final analysis once all clarifications are completed.</p><h3>Nodes</h3><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f16148cd439491f8e6fd929d66c38fc1066a4693-221x159.png" style="width: 100%;" /><p><strong><code>searchPrecedents</code>: </strong>This node performs a similarity search in the Elasticsearch vector store based on the user’s input. It retrieves up to five matching documents and prints them so they can be reviewed by the user:</p><pre><code>async function searchPrecedents(state: typeof LegalResearchState.State) {
  console.log(
    "📚 Searching for relevant legal precedents with query:\n",
    state.query
  );

  const results = await vectorStore.similaritySearch(state.query, 5);
  const precedents = results.map((d) => d as Document);

  console.log(`Found ${precedents.length} relevant precedents:\n`);

  for (let i = 0; i < precedents.length; i++) {
    const p = precedents[i];
    const m = p.metadata;
    console.log(
      `${i + 1}. ${m.title} (${m.caseId})\n` +
        `   Type: ${m.contractType}\n` +
        `   Outcome: ${m.outcome}\n` +
        `   Key reasoning: ${m.reasoning}\n` +
        `   Delay period: ${m.delayPeriod}\n`
    );
  }

  return { precedents };
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/72f7b09029662ae3935e17fde8f932e885c6efdd-273x203.png" style="width: 100%;" /><p><strong><code>precedentSelection</code>:</strong> This node allows the user to select, using natural language, the use case retrieved by the proximity search that best matches the question. At this point, the application<strong> interrupts the workflow</strong> and waits for user input:</p><pre><code>function precedentSelection(state: typeof LegalResearchState.State) {
  console.log("\n⚖️  HITL #1: Human input needed\n");
  const result = interrupt({
    question: "👨‍⚖️  Which precedent is most similar to your case? ",
  });

  return { userChoice: result as string };
}</code></pre><p><strong><code>selectPrecedent</code>:</strong> This node sends the user input, along with the retrieved documents, to be interpreted so that one of them can be selected. The LLM performs this task by returning a number that represents the document it infers from the user’s natural-language input:</p><pre><code>async function selectPrecedent(state: typeof LegalResearchState.State) {
  const precedents = state.precedents || [];
  const userInput = state.userChoice || "";

  const precedentsList = precedents
    .map((p, i) => {
      const m = p.metadata;
      return `${i + 1}. ${m.caseId}: ${m.title} - ${m.outcome}`;
    })
    .join("\n");

  const structuredLlm = llm.withStructuredOutput({
    name: "precedent_selection",
    schema: {
      type: "object",
      properties: {
        selected_number: {
          type: "number",
          description:
            "The precedent number selected by the lawyer (1-based index)",
          minimum: 1,
          maximum: precedents.length,
        },
      },
      required: ["selected_number"],
    },
  });

  const prompt = `
    The lawyer said: "${userInput}"

    Available precedents:
    ${precedentsList}

    Which precedent number (1-${precedents.length}) matches their selection?
  `;

  const response = await structuredLlm.invoke([
    {
      role: "system",
      content:
        "You are an assistant that interprets lawyer's selection and returns the corresponding precedent number.",
    },
    { role: "user", content: prompt },
  ]);

  const selectedIndex = response.selected_number - 1;
  const selectedPrecedent = precedents[selectedIndex] || precedents[0];

  console.log(`✅ Selected: ${selectedPrecedent.metadata.title}\n`);
  return { selectedPrecedent };
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1864ff80880ba7d394c25880e40d7c7f2b4a4765-305x402.png" style="width: 100%;" /><p><strong><code>validatePrecedentSelection</code></strong>This node introduces an explicit validation step (yes/no) to confirm that the precedent inferred by the system is indeed the one the lawyer intends to use. This step acts as a safeguard against misinterpretation of the user’s intent, ensuring that downstream legal analysis is based on the correct case:</p><pre><code>function validatePrecedentSelection(state: typeof LegalResearchState.State) {
  const precedent = state.selectedPrecedent;
  if (!precedent) return {};

  const m = precedent.metadata;
  console.log("\n⚖️  HITL #1.5: Validation needed\n");
  console.log(
    `Selected precedent: ${m.title} (${m.caseId})\n` +
      `Type: ${m.contractType}\n` +
      `Outcome: ${m.outcome}\n`
  );

  const result = interrupt({
    question: "👨‍⚖️  Is this the correct precedent? (yes/no): ",
  });
  const validation =
    typeof result === "string" ? result : (result as any)?.value || "";

  return { validation };
}</code></pre><p><strong><code>processValidation</code>: </strong>This node processes the lawyer’s confirmation response (yes/no) from the previous HITL step and determines the next path in the workflow. It interprets the user input as an explicit approval or rejection of the selected precedent.</p><p>If the precedent isn’t confirmed, the node clears the current selection and redirects the graph back to the <code>precedentSelection</code> phase, allowing the user to choose again. If the precedent is confirmed, the workflow continues without modification:</p><pre><code>function processValidation(state: typeof LegalResearchState.State) {
  const userInput = (state.validation || "").toLowerCase().trim();
  const isValid = userInput === "yes" || userInput === "y";

  if (!isValid) {
    console.log("❌ Precedent not confirmed. Returning to selection...\n");
    return { selectedPrecedent: null, userChoice: "" };
  }

  console.log("✅ Precedent confirmed.\n");
  return {};
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1c3b726d21b04cc916213da81607389a3b0304fc-305x127.png" style="width: 100%;" /><p><strong><code>createDraft</code>: </strong>This node generates the initial legal analysis based on the precedent selected by the user. It uses an LLM to evaluate how the chosen precedent applies to the lawyer’s question and determines whether the system has enough information to proceed.</p><p>If the precedent can be applied directly, the node produces a draft analysis and, taking the right path, jumps to the final node. If the LLM detects ambiguities, such as undefined contract terms, missing timeline details, or unclear conditions, it returns a flag indicating that clarification is required, along with a list of the specific pieces of information that must be provided. In that case, the ambiguity triggers the left path of the graph:</p><pre><code>async function createDraft(state: typeof LegalResearchState.State) {
  console.log("📝 Drafting initial legal analysis...\n");

  const precedent = state.selectedPrecedent;
  if (!precedent) return { draftAnalysis: "" };

  const m = precedent.metadata;

  const structuredLlm = llm.withStructuredOutput({
    name: "draft_analysis",
    schema: {
      type: "object",
      properties: {
        needs_clarification: {
          type: "boolean",
          description:
            "Whether the analysis requires clarification about contract terms or context",
        },
        analysis_text: {
          type: "string",
          description: "The draft legal analysis or the ambiguity explanation",
        },
        missing_information: {
          type: "array",
          items: { type: "string" },
          description:
            "List of specific information needed if clarification is required (empty if no clarification needed)",
        },
      },
      required: ["needs_clarification", "analysis_text", "missing_information"],
    },
  });

  const prompt = `
    Based on this precedent:
    Case: ${m.title}
    Outcome: ${m.outcome}
    Reasoning: ${m.reasoning}
    Key terms: ${m.keyTerms}

    And the lawyer's question: "${state.query}"

    Draft a legal analysis applying this precedent to the question.
    
    If you need more context about the specific contract terms, timeline details, 
    or other critical information to provide accurate analysis, set needs_clarification 
    to true and list what information is missing.
    
    Otherwise, provide the legal analysis directly.
  `;

  const response = await structuredLlm.invoke([
    {
      role: "system",
      content:
        "You are a legal research assistant that analyzes cases and identifies when additional context is needed.",
    },
    { role: "user", content: prompt },
  ]);

  let displayText: string;
  if (response.needs_clarification) {
    const missingInfoList = response.missing_information
      .map((info: string, i: number) => `${i + 1}. ${info}`)
      .join("\n");
    displayText = `AMBIGUITY DETECTED:\n${response.analysis_text}\n\nMissing information:\n${missingInfoList}`;
  } else {
    displayText = `ANALYSIS:\n${response.analysis_text}`;
  }

  console.log(displayText + "\n");

  return {
    draftAnalysis: displayText,
    ambiguityDetected: response.needs_clarification,
  };
}</code></pre><p>The two paths that the graph can take look like this:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/653083d5a2885b15c250288b353f65a993943c0d-305x379.png" style="width: 100%;" /><p>The left path includes an additional node that handles the clarification.</p><p><strong><code>requestClarification</code>:</strong> This node triggers the second HITL step when the system identifies that the draft analysis lacks essential context. The workflow is interrupted, and the user is asked to clarify the missing contract details detected by the previous node:</p><pre><code>function requestClarification(state: typeof LegalResearchState.State) {
  console.log("\n⚖️  HITL #2: Additional context needed\n");
  const userClarification = interrupt({
    question: "👨‍⚖️  Please provide clarification about your contract terms:",
  });
  return { userClarification };
}</code></pre><p>This intervention exemplifies the validation-driven nature of HITL: The system only pauses to request human input when it has identified specific gaps that could compromise the quality of the analysis. As an improvement, in production systems, this node could be enhanced with validation logic to verify that all required information points are addressed before proceeding, ensuring the analysis is built on complete and accurate context.</p><p><strong><code>generateFinalAnalysis</code>: </strong>This node produces the final legal analysis by combining the selected precedent with the additional context provided by the user, if needed. Using the clarification gathered in the previous HITL step, the LLM synthesizes the precedent’s reasoning, the contract details supplied by the user, and the conditions that determine whether a breach may have occurred.</p><p>The node outputs a complete analysis that integrates legal interpretation and practical recommendations:</p><pre><code>async function generateFinalAnalysis(state: typeof LegalResearchState.State) {
  console.log("📋 Generating final legal analysis...\n");

  const precedent = state.selectedPrecedent;
  if (!precedent) return { finalAnalysis: "" };

  const m = precedent.metadata;

  const prompt = `
    Original question: "${state.query}"
    
    Selected precedent: ${m.title}
    Outcome: ${m.outcome}
    Reasoning: ${m.reasoning}
    
    Lawyer's clarification: "${state.userClarification}"
    
    Provide a comprehensive legal analysis integrating:
    1. The selected precedent's reasoning
    2. The lawyer's specific contract context
    3. Conditions for breach vs. no breach
    4. Practical recommendations
  `;

  const response = await llm.invoke([
    {
      role: "system",
      content:
        "You are a legal research assistant providing comprehensive analysis.",
    },
    { role: "user", content: prompt },
  ]);

  const finalAnalysis = response.content as string;

  console.log(
    "\n" +
      "=".repeat(80) +
      "\n" +
      "⚖️  FINAL LEGAL ANALYSIS\n" +
      "=".repeat(80) +
      "\n\n" +
      finalAnalysis +
      "\n\n" +
      "=".repeat(80) +
      "\n"
  );

  return { finalAnalysis };
}</code></pre><p>Building graph:</p><pre><code>const workflow = new StateGraph(LegalResearchState)
  .addNode("searchPrecedents", searchPrecedents)
  .addNode("precedentSelection", precedentSelection)
  .addNode("selectPrecedent", selectPrecedent)
  .addNode("validatePrecedentSelection", validatePrecedentSelection)
  .addNode("processValidation", processValidation)
  .addNode("createDraft", createDraft)
  .addNode("requestClarification", requestClarification)
  .addNode("generateFinalAnalysis", generateFinalAnalysis)
  .addEdge("__start__", "searchPrecedents")
  .addEdge("searchPrecedents", "precedentSelection") // HITL #1
  .addEdge("precedentSelection", "selectPrecedent")
  .addEdge("selectPrecedent", "validatePrecedentSelection") // Selection validation
  .addEdge("validatePrecedentSelection", "processValidation")
  .addConditionalEdges(
    "processValidation",
    (state: typeof LegalResearchState.State) => {
      const userInput = (state.validation || "").toLowerCase().trim();
      const isValid = userInput === "yes" || userInput === "y";
      return isValid ? "validated" : "reselect";
    },
    {
      validated: "createDraft",
      reselect: "precedentSelection",
    }
  )
  .addConditionalEdges(
    "createDraft",
    (state: typeof LegalResearchState.State) => {
      // If ambiguity detected, request clarification (HITL #2)
      if (state.ambiguityDetected) return "needsClarification";
      // Otherwise, generate final analysis
      return "final";
    },
    {
      needsClarification: "requestClarification",
      final: "generateFinalAnalysis",
    }
  )
  .addEdge("requestClarification", "generateFinalAnalysis") // HITL #2
  .addEdge("generateFinalAnalysis", "__end__");</code></pre><p>In the graph, we can see the <code>createDraft</code> conditional edge, which defines the condition for choosing the “final” path. As shown, the decision now depends on whether the draft analysis detected ambiguity that requires additional clarification.</p><p>Put all together to be executed:</p><pre><code>await ingestData();

// Compile workflow
const app = workflow.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "hitl-circular-thread" } };

await saveGraphImage(app);

// Execute workflow
const legalQuestion =
  "Does a pattern of repeated delays constitute breach even if each individual delay is minor?";

console.log(`⚖️  LEGAL QUESTION: "${legalQuestion}"\n`);

let currentState = await app.invoke({ query: legalQuestion }, config);

// Handle all interruptions in a loop
while ((currentState as any).__interrupt__?.length > 0) {
  console.log("\n💭 APPLICATION PAUSED WAITING FOR USER INPUT...");

  const interruptQuestion = (currentState as any).__interrupt__[0]?.value
    ?.question;

  // Handling empty responses
  let userChoice = "";
  while (!userChoice.trim()) {
    userChoice = await getUserInput(interruptQuestion || "👤 YOUR CHOICE: ");
    if (!userChoice.trim()) {
      console.log("⚠️  Please provide a response.\n");
    }
  }

  currentState = await app.invoke(
    new Command({ resume: userChoice.trim() }),
    config
  );
}</code></pre><h3>Execute the script</h3><p>With all code allocated, let&#x27;s execute the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/human-in-the-loop-with-langgraph-and-elasticsearch/main.ts">main.ts</a> file, writing the following command on terminal:</p><pre><code>tsx main.ts</code></pre><p>Once the script is executed, the question <strong>“Does a pattern of repeated delays constitute breach even if each individual delay is minor?”</strong> will be sent to Elasticsearch to perform a proximity search, and the results retrieved from the index will be displayed. The app detects that multiple relevant precedents match the query, so it pauses execution and asks the user to help disambiguate which legal precedent is most applicable:</p><pre><code>📚 Searching for relevant legal precedents with query:
 Does a pattern of repeated delays constitute breach even if each individual delay is minor?
Found 5 relevant precedents:

1. Case H: Pattern of Repeated Delays (CASE-H-2021)
   Type: ongoing service agreement
   Outcome: breach found
   Key reasoning: pattern demonstrated failure to perform, cumulative effect
   Delay period: multiple instances

2. Case E: Minor Delay Quality Maintained (CASE-E-2022)
   Type: service agreement
   Outcome: minor breach only
   Key reasoning: delay minimal, quality maintained, termination unjustified
   Delay period: five days

3. Case A: Delay Breach with Operational Impact (CASE-A-2023)
   Type: service agreement
   Outcome: breach found
   Key reasoning: delay affected operations and caused financial harm
   Delay period: two weeks

4. Case B: Delay Without Explicit Schedule (CASE-B-2022)
   Type: consulting agreement
   Outcome: no breach found
   Key reasoning: no explicit deadline defined, no demonstrable harm
   Delay period: three weeks

5. Case C: Justified Delay External Factors (CASE-C-2023)
   Type: construction service
   Outcome: no breach found
   Key reasoning: external factors beyond control, force majeure applied
   Delay period: one month

⚖️  HITL #1: Human input needed

💭 APPLICATION PAUSED WAITING FOR USER INPUT...
👨‍⚖️  Which precedent is most similar to your case? </code></pre><p>The interesting thing about this application is that we can use natural language to choose one option, letting the LLM interpret the user’s input to determine the correct choice. Let’s see what happens if we enter the text <strong>“Case H”</strong>:</p><pre><code>💭 APPLICATION PAUSED WAITING FOR USER INPUT...
👨‍⚖️  Which precedent is most similar to your case? Case H

✅ Selected: Case H: Pattern of Repeated Delays</code></pre><p>After case selection, the application requests a small validation to confirm that the model selected the correct use case. If you answer “no”, the application returns to the case selection step:</p><pre><code>Validation needed

Selected precedent: Case H: Pattern of Repeated Delays (CASE-H-2021)
Type: ongoing service agreement
Outcome: breach found


💭 APPLICATION PAUSED WAITING FOR USER INPUT...
👨‍⚖️  Is this the correct precedent? (yes/no):  yes</code></pre><p>Once confirmed, the application workflow continues drafting the analysis:</p><pre><code>✅ Precedent confirmed.

📝 Drafting initial legal analysis...

AMBIGUITY DETECTED:
Based on Case H, a pattern of repeated delays can indeed constitute a breach of contract, even if each individual delay is minor. The outcome in Case H indicates that the cumulative effect of these minor delays led to a significant failure to perform the contractual obligations adequately. The reasoning emphasizes that consistent performance is critical in fulfilling the terms of a contract. Therefore, if the repeated delays create a situation where the overall performance is hindered, this pattern could be interpreted as a breach. However, the interpretation may depend on the specific terms of the contract at issue, as well as the expectations of performance set forth in that contract.

Missing information:
1. Specific contract terms regarding performance timelines
2. Details on the individual delays (duration, frequency)
3. Context on consequences of delays stated in the contract
4. Other parties' expectations or agreements related to performance


⚖️  HITL #2: Additional context needed


💭 APPLICATION PAUSED WAITING FOR USER INPUT...
👨‍⚖️  Please provide clarification about your contract terms:</code></pre><p>The model takes the user’s case selection and integrates it into the workflow, proceeding with the final analysis once enough context has been provided. In this step, the system also makes use of the previously detected ambiguity: The draft analysis highlighted missing contractual details that could meaningfully affect the legal interpretation. These “missing information” items guide the model in determining what clarifications are essential to resolve uncertainty before producing a reliable final opinion.</p><p>The user must include in the next input the requested clarifications. Let&#x27;s try with <strong>&quot;Contract requires ‘prompt delivery’ without timelines. 8 delays of 2-4 days over 6 months. $50K in losses from 3 missed client deadlines. Vendor notified but pattern continued.&quot;</strong>:</p><pre><code>💭 APPLICATION PAUSED WAITING FOR USER INPUT...
👨‍⚖️  Please provide clarification about your contract terms: Contract requires "prompt delivery" without timelines. 8 delays of 2-4 days over 6 months. $50K in losses from 3 missed client deadlines. Vendor notified but pattern continued.

📋 Generating final legal analysis...

================================================================================
⚖️  FINAL LEGAL ANALYSIS
================================================================================

To analyze the question of whether a pattern of repeated minor delays constitutes a breach of contract, we need to combine insights from the selected precedent, the specifics of the lawyer's contract situation, conditions that typically govern breach versus non-breach, and practical recommendations for the lawyer moving forward.

### 1. Selected Precedent's Reasoning

The precedent case, referred to as Case H, found that a pattern of repeated delays amounted to a breach of contract. The court reasoned that even minor individual delays, when considered cumulatively, demonstrated a failure to perform as stipulated in the contract. The underlying rationale was that the cumulative effect of these minor delays could significantly undermine the purpose of the contract, which typically aims for timely performance and reliable delivery.

### 2. Lawyer's Specific Contract Context

In the lawyer's situation, the contract specified "prompt delivery" but did not provide a strict timeline. The vendor experienced 8 delays ranging from 2 to 4 days over a period of 6 months. These delays culminated in $50,000 in losses due to three missed client deadlines. The vendor was notified regarding these delays; however, the pattern of delays persisted.

Key considerations include:
- **Nature of the Obligations**: While “prompt delivery” does not define a strict timeline, it does imply an expectation for timely performance.
- **Material Impact**: The missed client deadlines indicate that these delays had a material adverse effect on the lawyer's ability to fulfill contractual obligations to third parties, likely triggering damages.

### 3. Conditions for Breach vs. No Breach

**Conditions for Breach**:
- **Pattern and Cumulative Effect**: Similar to the reasoning in Case H, evidence of a habitual pattern of delays can amount to a breach. Even if individual delays are minor, when combined, they may show a lack of diligence or reliability by the vendor.
- **Materiality**: The impact of these delays is crucial. If the cumulative delays adversely affect the contract's purpose or cause significant losses, this reinforces the case for a breach.
- **Notification and Opportunity to Cure**: The fact that the vendor was notified of the delays and failed to rectify the behavior can often be interpreted as a further indication of breach.

**Conditions for No Breach**:
- **Non-Material Delays**: If the delays did not affect the overall contractual performance or client obligations, this may lessen the likelihood of establishing a breach. However, given the risks and losses involved, this seems less relevant in this scenario.
- **Force Majeure or Justifiable Delays**: If the vendor could show that these delays were due to justify circumstances not within their control, it may potentially provide a defense against breach claims.

### 4. Practical Recommendations

1. **Assess Damages**: Document the exact nature of the financial losses incurred due to the missed deadlines to substantiate claims of damages.
  
2. **Gather Evidence**: Collect all communication regarding the delays, including any notifications sent to the vendor about the issues.

3. **Consider Breach of Contract Action**: Based on the precedent and accumulated delays, consider formalized communication to the vendor regarding a breach of contract claim, highlighting both the pattern and the impact of these repeated delays.

4. **Evaluate Remedies**: Depending upon the contract specifics, the lawyer may wish to pursue several remedies, including:
   - **Compensatory Damages**: For the financial losses due to missed deadlines.
   - **Specific Performance**: If timely delivery is critical and can still be enforced.
   - **Contract Termination**: Depending on the severity, terminating the contract and seeking replacements may be warranted.

5. **Negotiate Terms**: If continuing to work with the current vendor is strategic, the lawyer should consider renegotiating terms for performance guarantees or penalties for further delays.

6. **Future Contracts**: In future contracts, consider including explicit timelines and conditions for prompt delivery, as well as specified damages for delays to better safeguard against this issue.

By integrating the legal principles from the precedent with the specific context and conditions outlined, the lawyer can formulate a solid plan to address the repeated delays by the vendor effectively.</code></pre><p>This output shows the final stage of the workflow, where the model integrates the selected precedent (Case H) and the lawyer’s clarifications to generate a complete legal analysis. The system explains why the pattern of delays likely constitutes a breach, outlines the factors that support this interpretation, and provides practical recommendations. Overall, the output demonstrates how the HITL clarifications resolve ambiguity and allow the model to produce a well-founded, context-specific legal opinion.</p><h3>Other real-world scenarios</h3><p>This kind of application, using Elasticsearch, LangGraph, and HITL, can be useful in other kinds of apps, including:</p><ul><li><strong>Reviewing tools calls before their execution</strong>; for example, in financial trading, a human approves buy/sell orders before they’re placed.</li><li><strong>Provide additional parameters when needed</strong>; for example, in customer support triage, where a human agent selects the correct issue category when the AI finds multiple possible interpretations of the customer’s problem.</li></ul><p>And there are plenty of use cases to discover, where HITL will be a game changer.</p><h2>Conclusion</h2><p>With LangGraph and Elasticsearch, we can build agents that make their own decisions and act as linear workflows or follow conditional paths based on context. With human in the loop, the agents can involve the actual user in the decision-making process to fill contextual gaps and request confirmations on systems where fault tolerance is critical.</p><p>The key advantage of this approach is that you can filter a large dataset using Elasticsearch capabilities and then use an LLM to select a single document based on the user intent; that is, HITL. The LLM complements Elasticsearch by handling the dynamics of how users express their intent..</p><p>This approach keeps the system fast and token efficient, as we’re only sending the LLM what’s needed to make the final decision and not the whole dataset. And at the same time, this keeps it very precise at detecting user intent and iterating until the desired option is picked.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Apache Lucene 2025 wrap-up]]></title>
            <description><![CDATA[2025 was a stellar year for Apache Lucene; here are our highlights.]]></description>
            <link>https://www.elastic.co/search-labs/blog/apache-lucene-wrapped-2025</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/apache-lucene-wrapped-2025</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Benjamin Trent, Chris Hegarty]]></dc:creator>
            <pubDate>Wed, 28 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bda54d0af6f391ea71aae7936bc02d53fa47fefa-1600x901.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>It’s hard to believe that Apache Lucene has been around for over a quarter of a century! Yes, more than 25 years of search enabled through Apache Lucene.</p><h2>Community by the numbers</h2><p>For contributions and the community as a whole, 2025 was another strong year, with 1,756 commits and 1,080 pull requests from 134 unique contributors. The community continued to grow this year, increasing the number of contributors by 98 from the previous year. The project management committee (PMC) and committer cohort also increased. Apache Lucene added nine new committers in 2025, one of whom was Elastic’s own Simon Cooper. (Congratulations, <a href="https://github.com/thecoop">thecoop</a>!) The PMC also welcomed two new members. Our little community continues to grow.</p><h2>Our highlights</h2><p>With almost 2,000 commits and eight releases, it’s difficult to summarize all the things that we loved from the year in Apache Lucene. But, not hiding from a challenge, here are some of our highlights.</p><h2>The year of faster queries</h2><p>In many ways, 2025 was the year Apache Lucene embraced better auto-vectorization, manual <a href="https://en.wikipedia.org/wiki/Single_instruction,_multiple_data">SIMD</a> optimizations, and overall making things go faster. Lucene community member <a href="https://jpountz.github.io/2025/08/28/compiled-vs-vectorized-search-engine-edition.html">Adrien Grand goes into great detail in this blog</a>, and below are links and summaries for the biggest jumps. As always, a huge shout-out to <a href="https://github.com/mikemccand">Mike McCandless</a> for maintaining the <a href="https://benchmarks.mikemccandless.com/AndHighHigh.html">Apache Lucene Benchmarks for years</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bff3742a2d3fd0e61a75785a4f6c79e1a3dcd94d-1600x613.png" style="width: 100%;" /><ul><li><code>[IN]</code> <a href="https://github.com/apache/lucene/pull/14701">Refactor main top-n bulk scorers to evaluate hits in a more term-at-a-time fashion </a></li><li><code>[IS]</code> Irrelevant hardware update, so it’s just noise here</li><li><code>[IY]</code> <a href="https://github.com/apache/lucene/pull/14906">Use branchless way to speedup filterCompetitiveHits</a></li><li><code>[IZ]</code> <a href="https://github.com/apache/lucene/pull/14935">Improve collecting docs stored as bitsets</a></li><li><code>[JA]</code> <a href="https://github.com/apache/lucene/pull/14896">Vectorize filterCompetitiveHits by hand with the Java Panama API</a></li><li><code>[JK]</code> <a href="https://github.com/apache/lucene/pull/15160">Increase the document block size to 256</a></li></ul><p>Discounting the hardware change <code>[IS]</code>, this is almost a 60% increase in query speed in 2025, going from &lt;100 queries per second (qps) to &gt;170 qps.</p><h2>Vector search</h2><p>There were a number of vector search improvements in 2025. Three to highlight are improving filtered <a href="https://github.com/apache/lucene/pull/14160">vector search with ACORN</a>, <a href="https://github.com/apache/lucene/pull/14226">adding optimistic multisegment search</a>, and <a href="https://github.com/apache/lucene/pull/14978">bulk scoring of vectors</a>.</p><p><a href="https://arxiv.org/pdf/2403.04871">ACORN-1</a> is an interesting algorithm for graph-based vector indices. It has the significant benefit of being filter and algorithm-agnostic. Since Apache Lucene uses hierarchical navigable small world (HNSW) for its indexing and users generally want to just filter on anything and everything without additional configuration, it’s a perfect fit. A community <a href="https://github.com/apache/lucene/pull/14085">member originally investigated</a> adding this algorithm. (He has since been hired by Elastic. Hi, <a href="https://github.com/benchaplin">Ben!</a> 👋👋👋) A nice balance was found for Lucene, providing faster filtered vector search without requiring significant work by the user in configuration and no additional indexing of information.</p><p>The addition of <a href="https://github.com/apache/lucene/pull/14226">optimistic multisegment vector search</a> to Apache Lucene really highlights how the community comes together to make it all happen. Three different individuals combined powers to debug, benchmark, design, and iterate on this solution. Originally proposed by <a href="https://github.com/msokolov">Michael Sokolov</a> (a vector search superstar in the Lucene community), it immediately caught my attention, too, because it claimed to fix our <a href="https://github.com/apache/lucene/issues/14180">weird concurrency consistency bug</a> without sacrificing performance. With some iteration and benchmarking by fellow community member <a href="https://github.com/dungba88">Dzung Bui</a>, we were able to strike the right balance in speed and recall, improving performance, making multithreaded searches consistent, and implementing a pretty neat algorithm.</p><p>Bulk scoring was born out of a collaboration between community member <a href="https://github.com/mccullocht">Trevor McCulloch</a> and our very own <a href="https://github.com/ChrisHegarty">Chris Hegarty</a> and was introduced as a new scoring interface in <a href="https://github.com/apache/lucene/pull/14978">PR #14978</a>, with an initial float32 implementation following in <a href="https://github.com/apache/lucene/pull/14980">PR #14980</a>. Modern vector search involves thousands or even millions of comparisons as a query vector is evaluated against vectors in the index, often by traversing a graph of nearest neighbors. Traditionally, these comparisons are performed one vector at a time. Bulk scoring inverts that model by passing a batch of vectors, represented as ordinals in the index, to the scorer in a single call. This allows the scorer to prefetch and pipeline work across vectors, amortizing cache misses and reducing per-vector overhead. An exciting aspect of the original design discussion considered bulk scorers implemented in Rust and C. While Lucene itself remains a Java library, this opens the door to highly optimized, SIMD-friendly, native implementations.</p><p>We didn’t even get to talk about several other improvements that landed in this area, including HNSW optimizations, like more compact <a href="https://github.com/apache/lucene/pull/14932">GroupVarInt graph</a> encoding, bypassing graph construction for <a href="https://github.com/apache/lucene/pull/14963">tiny segments</a>, and continued reductions in memory footprint. On the operations side, Lucene now <a href="https://github.com/apache/lucene/pull/14426">exposes off-heap memory requirements</a>, making it easier to understand and debug native memory usage. While these changes are small(ish) individually, together they help make Lucene’s vector search faster, leaner, and easier to operate in production.</p><h2>Bonus</h2><p>The last highlight is a little out of place. It&#x27;s a particularly <a href="https://github.com/apache/lucene/pull/15380">frustrating but satisfying bug fix</a>. I won’t go into deep details here, as it touches on how Lucene does max scoring and bulk scoring, applies filters, and handles all its internal iterator state. This deserves its own blog post. In short, we ran into this bug in production in late September 2025. It was surfacing as an <code>EndOfFileException</code> during a specific query execution. And then, like all fun bugs, it took a week or two of work to reproduce and fully debug. Finally, when we knew what exactly caused the exception to throw, we then had to dig into why to fix it. All in all, a month’s worth of work summarized in one line of code. Bask in its glory:</p><pre><code>- top.doc = top.approximation.advance(filter.doc);
+ // Must use the iterator as `top` might be a two-phase iterator
+ top.doc = top.iterator.advance(filter.doc);</code></pre><h2>Goodbye 2025, hello 2026</h2><p>A big thank you to everyone in the Apache Lucene community who worked tirelessly to improve this venerable search library. We ❤️ you.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Discord and Elastic Agent Builder A2A to build a gaming community support bot]]></title>
            <description><![CDATA[Learn how to connect Discord to Elastic Agent Builder's Agent-to-Agent (A2A) server to create a gaming community support bot.]]></description>
            <link>https://www.elastic.co/search-labs/blog/discord-elastic-agent-builder-a2a</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/discord-elastic-agent-builder-a2a</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Tue, 27 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4e90b56e9bcf0ab0b7b906fb6ec2413fdc4e6a00-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we&#x27;ll build a gaming community support bot that connects Discord to Elastic Agent Builder&#x27;s <a href="https://www.elastic.co/docs/solutions/search/agent-builder/a2a-server">Agent-to-Agent (A2A) server</a>. Players will ask questions like, &quot;Who&#x27;s the best Mage?&quot; or &quot;What&#x27;s the current meta?&quot; and get real-time answers powered by Elasticsearch Query Language (ES|QL) analytics and semantic search.</p><p>Many gaming companies already use Elasticsearch for player data and game analytics. In this tutorial, we&#x27;ll access that data through Discord, where many users spend their time.</p><h2>Prerequisites</h2><ul><li>Elasticsearch cluster (9.2 or higher)</li><li>Python 3.9+</li><li>Discord <a href="https://discord.com/developers/docs/quick-start/getting-started#step-1-creating-an-app">bot token</a> and <a href="https://support.discord.com/hc/en-us/articles/204849977-How-do-I-create-a-server">server created</a></li></ul><h2>What we&#x27;re building</h2><p>We&#x27;ll create a Discord bot that uses Elastic Agent Builder&#x27;s A2A server to answer player questions. The bot will have two types of tools:</p><ul><li><strong>ES|QL tools</strong> for analytics: leaderboards, hero stats, meta reports.</li><li><strong>Index search tools</strong> for knowledge: game mechanics, FAQs.</li></ul><p>The architecture looks like this:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4e90b56e9bcf0ab0b7b906fb6ec2413fdc4e6a00-1280x720.png" style="width: 100%;" /><h2>Architecture overview</h2><p><a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic Agent Builder</a> provides an <a href="https://www.elastic.co/docs/solutions/search/agent-builder/a2a-server">A2A server</a> to connect with clients. This server exposes the agent with tools that can query Elasticsearch using <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools/esql-tools">ES|QL</a> or <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools/index-search-tools">index search</a> tools. Any A2A-compatible client can connect to it.</p><p>The A2A client instantiates a bot that can connect to the Discord server, communicate with Agent Builder, and receive/send messages to the users.</p><h2>Why A2A instead of MCP?</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d723675b2b0bfebfae69548157420115b215572e-1600x1138.png" style="width: 100%;" /><p>Agent Builder also offers the option to expose the agent&#x27;s tools via a Model Context Protocol (MCP) server. The key difference is that with MCP, the client can only access the tools and their descriptions, not the agent itself. Additionally, tool selection logic must be implemented on the MCP client side, since all tools from all agents are exposed together.</p><p>With A2A, the entire agent, including instructions and tools, is available to the client. This gives you more control from the Agent Builder side, letting you manage behavior centrally rather than in each client.</p><p>The right choice depends on where you want that control to live. For this Discord bot, we want to manage the agent&#x27;s behavior from Elastic, so A2A is the better fit.</p><h2>Setting up sample game data</h2><p>Let&#x27;s create gaming data that our bot can query. We&#x27;ll set up three indices:</p><ul><li><code>player_stats</code>: Player profiles with wins, kills, rank.</li><li><code>hero_meta</code>: Hero pick rates and win rates by tier.</li><li><code>game_knowledge</code>: FAQs and game mechanics. Uses <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> for meaning-based versus keyword matching. Both <strong>title</strong> and <strong>content</strong> are <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to">copied into</a> <code>semantic_field</code> for a hybrid search solution.</li></ul><h2>Create the indices</h2><pre><code>from elasticsearch import Elasticsearch
import os

es = Elasticsearch(
    hosts=[os.getenv("ELASTICSEARCH_URL")],
    api_key=os.environ["ELASTIC_API_KEY"]
)

# Player stats index
es.indices.create(
    index="player_stats",
    mappings={
        "properties": {
            "player_id": {"type": "keyword"},
            "username": {"type": "keyword"},
            "hero": {"type": "keyword"},
            "wins": {"type": "integer"},
            "losses": {"type": "integer"},
            "kills": {"type": "integer"},
            "deaths": {"type": "integer"},
            "rank": {"type": "keyword"},
            "last_played": {"type": "date"}
        }
    }
)

# Hero meta index
es.indices.create(
    index="hero_meta",
    mappings={
        "properties": {
            "hero_name": {"type": "keyword"},
            "pick_rate": {"type": "float"},
            "win_rate": {"type": "float"},
            "tier": {"type": "keyword"},
            "patch_version": {"type": "keyword"}
        }
    }
)

# Game knowledge index (for semantic search)
es.indices.create(
    index="game_knowledge",
    mappings={
        "properties": {
            "title": {"type": "text", "copy_to": "semantic_field"},
            "content": {"type": "text", "copy_to": "semantic_field"},
            "category": {"type": "keyword"},
            "semantic_field": {"type": "semantic_text"} # Semantic search queries this combined field
        }
    }
)</code></pre><h3>Index sample data</h3><pre><code>from datetime import datetime, timedelta
from elasticsearch.helpers import bulk
import random

# Sample player data
players = [
    {"player_id": "p001", "username": "DragonSlayer99", "hero": "Warrior", "wins": 342, "losses": 198, "kills": 4521, "deaths": 2103, "rank": "Diamond"},
    {"player_id": "p002", "username": "ShadowMage", "hero": "Mage", "wins": 567, "losses": 234, "kills": 8932, "deaths": 3421, "rank": "Master"},
    {"player_id": "p003", "username": "HealBot3000", "hero": "Healer", "wins": 423, "losses": 187, "kills": 1234, "deaths": 1876, "rank": "Diamond"},
    {"player_id": "p004", "username": "TankMaster", "hero": "Tank", "wins": 298, "losses": 302, "kills": 2341, "deaths": 1543, "rank": "Platinum"},
    {"player_id": "p005", "username": "AssassinX", "hero": "Assassin", "wins": 789, "losses": 156, "kills": 12453, "deaths": 2987, "rank": "Grandmaster"},
]

for player in players:
    player["last_played"] = datetime.now() - timedelta(hours=random.randint(1, 72))

# Hero meta data
heroes = [
    {"hero_name": "Warrior", "pick_rate": 15.2, "win_rate": 51.3, "tier": "A", "patch_version": "2.4.1"},
    {"hero_name": "Mage", "pick_rate": 22.8, "win_rate": 54.7, "tier": "S", "patch_version": "2.4.1"},
    {"hero_name": "Healer", "pick_rate": 18.5, "win_rate": 52.1, "tier": "A", "patch_version": "2.4.1"},
    {"hero_name": "Tank", "pick_rate": 12.3, "win_rate": 48.9, "tier": "B", "patch_version": "2.4.1"},
    {"hero_name": "Assassin", "pick_rate": 31.2, "win_rate": 49.2, "tier": "A", "patch_version": "2.4.1"},
]

# Game knowledge for semantic search
knowledge = [
    {"title": "How to unlock the Dragon Mount", "content": "Complete the Dragon's Lair dungeon on Nightmare difficulty with all party members alive. The mount has a 15% drop rate.", "category": "mounts"},
    {"title": "Best Mage build for Season 4", "content": "Focus on Intelligence and Critical Chance. Use the Arcane Staff with Frost Runes. Prioritize cooldown reduction for burst damage.", "category": "builds"},
    {"title": "Understanding the ranking system", "content": "Ranks go from Bronze to Grandmaster. You need 100 points to advance. Wins give 25 points, losses subtract 20.", "category": "ranked"},
]

# Bulk index all data
actions = []
for player in players:
    actions.append({"_index": "player_stats", "_source": player})
for hero in heroes:
    actions.append({"_index": "hero_meta", "_source": hero})
for doc in knowledge:
    actions.append({"_index": "game_knowledge", "_source": doc})

success, errors = bulk(es, actions)
print(f"Indexed {success} documents")

es.indices.refresh(index="player_stats,hero_meta,game_knowledge")</code></pre><p>Done! Now we have data to query.</p><h2>Creating ES|QL tools via API</h2><p>Instead of using the UI, we&#x27;ll create tools programmatically using the <a href="https://www.elastic.co/docs/api/doc/kibana/group/endpoint-agent-builder">Agent Builder API</a>. This makes it easier to version control and deploy.</p><p>First, let&#x27;s set up our <a href="https://www.elastic.co/docs/api/doc/kibana/">Kibana API</a> connection:</p><pre><code>import requests

KIBANA_URL = os.environ["KIBANA_URL"]  # e.g., https://your-deployment.kb.us-central1.gcp.cloud.es.io
KIBANA_API_KEY = os.environ["KIBANA_API_KEY"]

headers = {
    "kbn-xsrf": "true",
    "Authorization": f"ApiKey {KIBANA_API_KEY}",
    "Content-Type": "application/json"
}</code></pre><h3>Tool 1: Leaderboard</h3><p>This tool shows top players by kills. We use ES|QL for aggregations:</p><pre><code>leaderboard_tool = {
    "id": "leaderboard",
    "type": "esql",
    "description": "Shows top players ranked by kills. Use when someone asks Who is the best? or Show me top players.",
    "configuration": {
        "query": """FROM player_stats
| STATS total_kills = SUM(kills), total_wins = SUM(wins) BY username, hero, rank
| SORT total_kills DESC
| LIMIT 10""",
        "params": {}
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/tools",
    headers=headers,
    json=leaderboard_tool
)
print(f"Leaderboard tool: {response.status_code}")</code></pre><h3>Tool 2: Hero stats</h3><p>The hero stats tool returns performance stats for a specific hero using a dynamic parameter:</p><pre><code>hero_stats_tool = {
    "id": "hero_stats",
    "type": "esql",
    "description": "Gets win rate, pick rate, and tier for a specific hero. Use when someone asks How good is Mage? or What is the win rate for Warrior?",
    "configuration": {
        "query": """FROM hero_meta
| WHERE hero_name == ?hero
| KEEP hero_name, win_rate, pick_rate, tier, patch_version""",
        "params": {
            "hero": {
                "type": "keyword",
                "description": "The hero name to look up"
            }
        }
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/tools",
    headers=headers,
    json=hero_stats_tool
)
print(f"Hero stats tool: {response.status_code}")</code></pre><p><code>?hero</code> is a dynamic parameter. When the agent calls this tool, it fills in the hero name from the user&#x27;s question, bridging natural language, “Is Mage strong?” to structured queries, like <code>| WHERE hero_name == “Mage”</code>.</p><h3>Tool 3: Meta report</h3><p>This tool shows which heroes are dominating the current patch:</p><pre><code>meta_report_tool = {
    "id": "meta_report",
    "type": "esql",
    "description": "Shows all heroes sorted by tier and win rate. Use when someone asks What is the current meta? or Which heroes are S-tier?",
    "configuration": {
        "query": """FROM hero_meta
| SORT tier ASC, win_rate DESC
| KEEP hero_name, tier, win_rate, pick_rate""",
        "params": {}
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/tools",
    headers=headers,
    json=meta_report_tool
)
print(f"Meta report tool: {response.status_code}")</code></pre><h2>Adding index search tools</h2><p>For unstructured content, like FAQs and game mechanics, we use <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools/index-search-tools">index search</a> tools. The <code>pattern</code> parameter specifies which Elasticsearch index to search:</p><pre><code>game_knowledge_tool = {
    "id": "game_knowledge",
    "type": "index_search",
    "description": "Searches game guides, FAQs, and mechanics. Use when someone asks How do I...? or What is...? questions about game content.",
    "configuration": {
        "pattern": "game_knowledge"
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/tools",
    headers=headers,
    json=game_knowledge_tool
)
print(f"Game knowledge tool: {response.status_code}")</code></pre><p>Keep in mind that ES|QL tools are for structured analytics (&quot;Who has the most kills?&quot;), while index search tools are for unstructured knowledge (&quot;How do I unlock the dragon mount?&quot;).</p><h2>Creating the agent</h2><p>Let’s create an agent that uses these tools:</p><pre><code>agent = {
    "id": "gaming_support_bot",
    "name": "Gaming Support Bot",
    "description": "A gaming community support bot that answers player questions about stats, heroes, and game mechanics.",
    "configuration": {
        "tools": [{"tool_ids": ["leaderboard", "hero_stats", "meta_report", "game_knowledge"]}],
        "instructions": """You are a helpful gaming community bot. Answer player questions about:
- Player stats and leaderboards (use leaderboard tool)
- Hero performance and meta (use hero_stats and meta_report tools)
- Game mechanics and guides (use game_knowledge tool)

Be concise and friendly. Format leaderboards clearly with rankings."""
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/agents",
    headers=headers,
    json=agent
)
print(f"Agent created: {response.status_code}")</code></pre><p>The agent now has access to all our tools and can choose one on the user&#x27;s question.</p><p>You can test the connectivity by visiting the Kibana <a href="https://www.elastic.co/docs/solutions/search/agent-builder/chat#agent-chat-gui">Agent Chat GUI</a> or by sending an API call. By default, Agent Builder uses the <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/elastic-managed-llm">Elastic Managed LLM</a>, so no connector configuration is required:</p><pre><code>test_message = "Show me all heroes sorted by tier"

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/converse",
    headers=headers,
    json={
        "agent_id": "gaming_support_bot",
        "input": test_message
    },
    timeout=60
)

print(f"Status: {response.status_code}")
if response.status_code == 200:
    result = response.json()
    print(f"\nAgent used tools: {[step.get('tool_id') for step in result.get('steps', []) if step.get('type') == 'tool_call']}")
    print(f"\nResponse:\n{result.get('response', {}).get('message', 'No message')}")
else:
    print(f"Error: {response.text}")</code></pre><h3>Setting up a Discord bot</h3><p>If you don&#x27;t have a Discord bot yet, you&#x27;ll need to create one following these steps:</p><p>1. Go to the <a href="https://discord.com/developers/applications">Discord Developer Portal</a>.</p><p>2. Click <strong>New Application</strong>, and give it a name.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9a7cf03a67865f62744e0b2f971531726b5b53d9-1600x1039.jpg" style="width: 100%;" /><p>3. Go to the <strong>Bot</strong> section, and click <strong>Add Bot</strong>.</p><p>4. Copy the bot token. (You&#x27;ll need this later.)</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9a7cf03a67865f62744e0b2f971531726b5b53d9-1600x1039.jpg" style="width: 100%;" /><p>5. Under <strong>Privileged Gateway Intents</strong>, enable <strong>Message Content Intent</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9c8edbd72d02d993efef8cf7bceb0a0e20a05da4-1502x1538.png" style="width: 100%;" /><p>6. Go to <strong>OAuth2 &gt; URL Generator</strong>, select <strong>bot</strong> and <strong>application.commands</strong> scopes and <strong>Send Messages</strong> + <strong>Read Message History</strong> permissions.</p><p>7. Use the generated URL to invite the bot to your server.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/572077eef6ee7cde811c55795c245c6b42a95369-1600x1039.jpg" style="width: 100%;" /><h3><strong>Security considerations</strong></h3><p>On the <strong>Discord</strong> side, request only the minimum permissions:</p><ul><li>Send Messages.</li><li>Send Messages in Threads.</li><li>Read Message History.</li><li>Create Polls.</li></ul><p>This limits what the bot can do if compromised.</p><p>On the <strong>Elastic </strong>side, create an API key with restricted privileges. For this bot, you only need read access to the game indices:</p><pre><code>POST /_security/api_key
{
  "name": "gaming-bot-key",
  "role_descriptors": {
    "gaming_bot_role": {
      "cluster": ["monitor"],
      "indices": [
        {
          "names": ["player_stats", "hero_meta", "game_knowledge"],
          "privileges": ["read"]
        }
      ]
    }
  }
}</code></pre><h2>Configuring the Discord A2A client</h2><p>The next step is to connect Discord to Agent Builder. We&#x27;ll use a <a href="https://github.com/llermaly/agentbuilder-a2a-discord">modified version</a> of the <a href="https://github.com/lekkerelou/kagent-a2a-discord"><code>kagent-a2a-discord</code></a>.</p><h3>Clone and setup</h3><pre><code>git clone https://github.com/llermaly/agentbuilder-a2a-discord
cd agentbuilder-a2a-discord</code></pre><h3>Environment variables</h3><p>Create an <code>.env</code> file in the root directory:</p><pre><code>DISCORD_BOT_TOKEN=your_bot_token
AGENT_BUILDER_URL=https://<kibana_url>/api/agent_builder/a2a/gaming_support_bot
A2A_API_KEY=your_api_key</code></pre><h3>Run with Python</h3><pre><code># Create virtual environment
uv venv

# Install dependencies
uv sync

# Run the bot 
uv run main.py</code></pre><p>The bot should come online in your Discord server within a few seconds.<br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/91b6644120bd6750322de433271b4cb09282ebb8-921x163.png" style="width: 100%;" /><h2>Testing the bot</h2><p>Let&#x27;s test with different query types.</p><h3>Analytics queries (ES|QL)</h3><p><strong>User:</strong> &quot;Who is the best Mage player?&quot;</p><p><strong>Bot:</strong> Based on the leaderboard data, ShadowMage is currently the best Mage player in the game. They&#x27;ve reached Master rank with an impressive 8,932 kills and 567 wins…</p><p><strong>User:</strong> &quot;What&#x27;s the current meta?&quot;</p><p><strong>Bot:</strong> The current meta is dominated by Mage, which stands alone in S-tier with an impressive 54.7% win rate and 22.8% pick rate in patch 2.4.1.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a014f6baa8a0fb39de91c3cf09dc49a9481db833-1600x677.jpg" style="width: 100%;" /><h3>Semantic queries (index search)</h3><p><strong>User:</strong> &quot;How do I unlock the dragon mount?&quot;</p><p><strong>Bot:</strong> To unlock the Dragon Mount, you need to:</p><ol><li>Complete the Dragon&#x27;s Lair dungeon on Nightmare difficulty.</li><li>Keep all party members alive throughout the entire run.</li><li>Cross your fingers for luck - the mount has a 15% drop rate…</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/296f781c20a724c813d8ca70319b12769bdea130-1600x416.jpg" style="width: 100%;" /><p>The bot automatically picks the right tool based on the question.</p><h2>Bidirectional: Giving the agent actions</h2><p>Beyond answering questions, we can give Agent Builder the ability to trigger Discord actions. With a <a href="https://github.com/llermaly/agentbuilder-a2a-discord/commit/da66c4642dababc06318f3a62e464c200ce3aec4">small modification</a> to the Discord client, we can parse special tags in the agent&#x27;s response and execute Discord commands.</p><p>For example, we added support for a <code>&lt;poll&gt;</code> tag:</p><pre><code><poll>Should Mage be nerfed?|Yes, too strong|No, it's balanced|Need more data</poll></code></pre><p>When the agent includes this in its response, the bot creates a native Discord poll. The agent just needs instructions on when to use it. Run the following to add to the instructions:</p><pre><code>agent = {
    "id": "gaming_support_bot",
    "name": "Gaming Support Bot",
    "description": "A gaming community support bot that answers player questions about stats, heroes, and game mechanics.",
    "configuration": {
        "tools": [{"tool_ids": ["leaderboard", "hero_stats", "meta_report", "game_knowledge"]}],
        "instructions": """You are a helpful gaming community bot. Answer player questions about:
- Player stats and leaderboards (use leaderboard tool)
- Hero performance and meta (use hero_stats and meta_report tools)
- Game mechanics and guides (use game_knowledge tool)

When discussing balance topics, create a poll for community input.
Use: <poll>Question|Option1|Option2|Option3</poll>

Be concise and friendly. Format leaderboards clearly with rankings."""
    }
}

response = requests.put(
    f"{KIBANA_URL}/api/agent_builder/agents",
    headers=headers,
    json=agent
)
print(f"Agent created: {response.status_code}")</code></pre><p>Now when a user asks &quot;Is Mage overpowered?&quot; the agent can share the stats and create a poll, turning data insights into community engagement.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c892045115be7484677f534098ef75df30bdc808-1600x1081.jpg" style="width: 100%;" /><p>The same pattern works for other Discord features, like reactions, role mentions, or scheduled announcements. The agent&#x27;s response becomes a control channel for Discord actions.</p><h2>Conclusion</h2><p>In this article, we successfully built a Discord bot using Elastic Agent Builder&#x27;s A2A server. The process covered creating ES|QL tools via API for analytics, such as leaderboards, hero stats, and meta reports, and developing index search tools for semantic search against a knowledge base. Furthermore, we demonstrated the use of dynamic parameters, like <code>?hero</code> for flexible queries, established the connection between Discord and the A2A client, and tested both analytics and semantic query types.</p><p>The Agent Builder provides the A2A server, so you only need to focus on creating tools and connecting clients, while ES|QL handles structured analytics and index search handles unstructured knowledge.</p><h2>Resources</h2><ul><li><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/discord-elastic-agent-builder-gaming-bot">Try the notebook</a></li><li><a href="https://www.elastic.co/docs/api/doc/kibana/operation/operation-post-agent-builder-tools">Agent Builder API docs</a></li><li><a href="https://github.com/llermaly/agentbuilder-a2a-discord">Discord Client</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[All about those chunks, ’bout those chunks, and snippets!]]></title>
            <description><![CDATA[Exploring chunking and snippet extraction for LLMs, highlighting enhancements for identifying the most relevant chunks and snippets to send to models such as rerankers and LLMs.]]></description>
            <link>https://www.elastic.co/search-labs/blog/llm-chunking-snippet-extraction</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/llm-chunking-snippet-extraction</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Kathleen DeRusso]]></dc:creator>
            <pubDate>Mon, 26 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/daa2741ce2fd31137fc0b0eb4320f4ee9db0c0e7-1536x1024.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>These days, if you’ve performed semantic searches or context engineering, you’ve probably worked a lot with chunks. If you’re not familiar with chunks, a <em>chunk </em>is a small, meaningful piece of content extracted from a larger document. This <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">blog</a> provides a great foundational overview of chunking, why it’s important, and various chunking strategies.</p><p>For this blog, we want to focus on one specific problem in the topic of chunking, and that is defining the best context to send to a large language model (LLM) or other model. Models have a limited number of tokens they can take in as context, but even within that limit, sending in large amounts of content can lead to relevance degradation caused by factors such as <a href="https://research.trychroma.com/context-rot">context rot</a> or <a href="https://arxiv.org/abs/2307.03172">“lost in the middle”</a> problems, where important information hidden in large blocks of text is overlooked.</p><p>This led to the question: How can we make this better?</p><h2>Reranking in retrievers</h2><p>We started by looking at retrievers, specifically the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/text-similarity-reranker-retriever"><code>text_similarity_reranker</code> retriever</a>. We know that many <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking#semantic-reranking-models">cross-encoder</a> rerankers do not perform well on long documents. This is because these rerankers will truncate long content to the model’s token window, discarding the remaining content. This can actually lead to degraded search relevance, if the most relevant part of the document is cut off before being sent to the reranker!</p><p>We decided to address this by introducing a <code>chunk_rescorer</code> to the <code>text_similarity_reranker</code> retriever. When specified, rather than sending in the entire document to the reranker, we will chunk the document first and evaluate each chunk based on the reranking inference text. We do this by indexing each chunk into a temporary in-memory Lucene index and performing a BM25 <a href="https://www.elastic.co/docs/solutions/search/full-text">text search</a> over these chunks. We return the best chunks for consideration into the reranker.</p><p>The chunk rescorer is simple to use with a small update to the API call:</p><pre><code>GET books/_search
{
  "retriever": {
    "text_similarity_reranker": {
      "retriever": {
        "standard": {
          "query": {
            "match": {
              "author": "Tolkien"
            }
          }
        }
      },
      "rank_window_size": 10,
      "field": "semantic_text",
      "inference_text": "are all who wander lost?",
      "chunk_rescorer": {
        "size": 1
      }
    }
  }
}</code></pre><p>When we evaluated the chunk rescorer, we found a significant improvement for many truncating models, including the <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-rerank">Elastic Reranker</a> and <a href="https://docs.cohere.com/docs/rerank">Cohere&#x27;s</a> <code>rerank-english-v3.0</code> model. However, when we evaluated against <a href="https://huggingface.co/jinaai/jina-reranker-v2-base-multilingual"><code>jina-reranker-v2-base-multilingual</code></a>, the results were not as impressive due to the fact that Jina already addresses this long document problem internally.</p><p>We performed evaluations using the <a href="https://huggingface.co/datasets/Shitao/MLDR">Multilingual Long-Document Retrieval</a> (MLDR) English dataset. This is a document containing very long article content that would trigger this document truncation issue in many reranking models. The following table shows our evaluation results with BM25 text search and a <code>rank_window_size</code> of 32:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Reranker model</td><td style="border: 1px solid #ccc; padding: 4px;">NDCG@10</td><td style="border: 1px solid #ccc; padding: 4px;">NDCG@10
NDCG@10 with chunk rescoring</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">jina-reranker-v2-base-multilingual</td><td style="border: 1px solid #ccc; padding: 4px;">0.771145</td><td style="border: 1px solid #ccc; padding: 4px;">0.764488</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Cohere rerank-english-v3.0</td><td style="border: 1px solid #ccc; padding: 4px;">0.592588</td><td style="border: 1px solid #ccc; padding: 4px;">0.707842</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">.rerank-v1-elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">0.478121</td><td style="border: 1px solid #ccc; padding: 4px;">0.751994</td>
                    </tr>
              </tbody>
            </table>
          <p>It’s worth noting that the raw BM25 results without reranking had a <a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">Normalized Discounted Cumulative Gain</a> (NDCG) score, or relevance score, close to 0.64. (Find additional background in this <a href="https://arxiv.org/pdf/2402.03216">paper</a>.) This means that for rerankers that perform truncation, reranked results for long documents were actually <em>worse</em> than without reranking. Note that this only applies for long documents; shorter documents that fit into the token window would not be affected by this long document problem.</p><p>Of the rerankers we evaluated, Jina was the only reranker to perform well against long documents out of the box, thanks to its sliding window approach.</p><p>We saw better baseline performance but similar overall difference in results when using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> fields with <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-elser">Elastic Learned Sparse EncodeR (ELSER)</a>.</p><p>We felt the results for truncating models were promising enough to release the chunk rescorer as an opt-in feature for models that will benefit from the additional relevance, but we recommend evaluating against specific rerankers before implementing this in production.</p><h2>ES|QL</h2><p>The real power of chunk extraction, however, lies in the <a href="https://www.elastic.co/docs/reference/query-languages/esql">Elasticsearch Query Language</a> (ES|QL). We wanted chunks and snippets to be first class citizens in ES|QL so they could be easily extracted and repurposed for reranking, sending into LLM context, or other purposes.</p><p>We started by introducing the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions#esql-chunk"><code>CHUNK</code></a> function in Elasticsearch version 9.2:</p><pre><code>FROM books
| EVAL chunks = CHUNK(description) </code></pre><p><code>CHUNK</code> is an extreme primitive that takes some string content (a text field, a semantic text field, or any other row content that is a string) and chunks it. You can view and interact with these chunks, and you can also explore using different chunking settings:</p><pre><code>FROM books
| EVAL chunks = CHUNK(description, {"strategy": "sentence", "max_chunk_size": 25, "sentence_overlap": 0}) </code></pre><p>You can then combine chunk with existing primitives, like <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/mv-functions#esql-mv_slice"><code>MV_SLICE</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/mv_expand"><code>MV_EXPAND</code></a>, to format the way chunks are represented in your row output:</p><pre><code>FROM books
| WHERE MATCH(author, "Tolkien")
| EVAL chunks = CHUNK(semantic_description, {"strategy": "sentence", "max_chunk_size": 25, "sentence_overlap": 0})
| EVAL truncated = MV_SLICE(chunks, 0, 2)
| MV_EXPAND truncated
| KEEP title, truncated</code></pre><p>This is great, but what we really wanted was to get the top matching snippets for a query, so we also introduced <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions#esql-top_snippets"><code>TOP_SNIPPETS</code></a> in Elasticsearch version 9.3:</p><pre><code>FROM books
| EVAL snippets = TOP_SNIPPETS(description, "ring")</code></pre><p>We added support to control the number of snippets you want to return and the word size using a sentence-based <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">chunking strategy</a>:</p><pre><code>FROM books
| EVAL snippets = TOP_SNIPPETS(description, "ring", { "num_snippets": 3, "num_words": 25 })
| MV_EXPAND snippets
| KEEP title, snippets</code></pre><p>This fits into the broader story of LLMs when you add in <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/completion"><code>COMPLETION</code></a>. Here is an example of how we envision <code>TOP_SNIPPETS</code> integrating with LLMs:</p><pre><code>FROM books METADATA _score
| WHERE semantic_description:"what are hobbits?"
| SORT _score DESC
| LIMIT 10
| FORK
   ( EVAL snippets = TOP_SNIPPETS(semantic_description, "what are hobbits?", {"num_snippets": 3})
    | COMPLETION CONCAT("Answer what are hobbits", snippets) WITH {"inference_id": "my-completion-endpoint"})</code></pre><p>In this example, we’re performing a semantic search, but for each document we’re identifying the top snippets from that document. We’re sending in the highly relevant snippets into the completion command, rather than the entire document. This is a simple document, but you could also use reranking here, and in the future, when multiple forks are available, hybrid search will be supported in the same format.</p><p>We can also utilize snippets in the newest version of <code>RERANK</code>:</p><pre><code>FROM books
| WHERE MATCH(title, "ring")
| EVAL snippets = TOP_SNIPPETS(semantic_description, "what are hobbits?", { "num_snippets": 3, "num_words": 25 })
| RERANK "what are hobbits?" ON  snippets WITH { "inference_id" : "my-reranker" }</code></pre><h2>What we’re thinking about next</h2><p>The story isn’t over for chunking and snippet extraction; in fact, it’s only getting started.</p><p>We’re looking at how to best integrate existing <code>semantic_text</code> chunks out of the box into strategies using chunking and snippet extraction. We’re also exploring what other features we need to make snippet extraction a compelling feature to use in products such as Elastic Agent Builder.</p><p>Overall, we’re excited to share these tools and look forward to your feedback as we evolve our strategies for getting the best context for LLMs!</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Improve search performance with `best_compression`]]></title>
            <description><![CDATA[While `best_compression` is typically seen as a storage-saving feature for Elastic Observability and Elastic Security use cases, this blog demonstrates its effectiveness as a performance-tuning lever for search.]]></description>
            <link>https://www.elastic.co/search-labs/blog/improve-elasticsearch-performance-best-compression</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/improve-elasticsearch-performance-best-compression</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sherry Ger, Ryan Eno]]></dc:creator>
            <pubDate>Fri, 23 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5141a8c2618337207d848ce16b258a86885955b2-1600x1034.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p></p><p>When tuning Elasticsearch for high-concurrency workloads, the standard approach is to maximize RAM to keep the working set of documents in memory to achieve low search latency. Consequently, <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules"><code>best_compression</code></a> is rarely considered for search workloads, as it is primarily viewed as a storage saving measure for Elastic Observability and Elastic Security use cases where storage efficiency takes priority.</p><p>In this blog, we demonstrate that when the dataset size significantly exceeds the OS page cache, <code>best_compression</code> improves search performance and resource efficiency by reducing the I/O bottleneck.</p><h2><strong>The setup</strong></h2><p>Our use case is a high-concurrency search application running on <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/ec-change-hardware-profile#ec-profiles-compute-optimized-arm">Elastic Cloud CPU optimized instances</a>.</p><ul><li>Data volume: ~500 million documents</li><li>Infrastructure: 6 Elastic Cloud (Elasticsearch service) instances (each instance: 1.76 TB storage | 60 GB RAM | 31.9 vCPU)</li><li>Memory-to-storage ratio: ~5% of the total dataset fits into RAM</li></ul><h2><strong>The symptoms: high latency</strong></h2><p>We observed that when the number of current requests spiked around 19:00, the search latency deteriorated significantly. As shown in Figure 1 and Figure 2, while traffic peaked around 400 requests per minute per Elasticsearch instance, the average query service time degraded to over 60ms.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f9c6cc1882e7db24336c65c54bbc1d38dcdb7fa3-697x311.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bbb705ae2fcd14c81d335bf322346caf3bf33765-996x618.png" style="width: 100%;" /><p>The CPU usage remained relatively low after the initial connections handling, indicating that compute was not the bottleneck.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cec15a28d2d22e9adedd2951bb2334b3717890a1-1494x730.png" style="width: 100%;" /><p>A strong correlation emerged between query volume and page faults. As requests increased, we observed a proportional rise in page faults, peaking around 400k/minute. This indicated that the active dataset could not fit in the page cache.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/511f2f10300a9d10ba3d7a82b9a8c8d567ac5636-1492x678.png" style="width: 100%;" /><p>Simultaneously, the JVM heap usage appeared to be normal and healthy. This ruled out garbage collection issues and confirmed the bottleneck was I/O.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/336bbad638f866304358dba1d06ee987de0f23cf-1490x568.png" style="width: 100%;" /><h2><strong>The diagnosis: I/O bound</strong></h2><p>The system was I/O bound. <a href="https://www.elastic.co/blog/elasticsearch-caching-deep-dive-boosting-query-speed-one-cache-at-a-time">Elasticsearch relies on the OS page cache to serve index data from memory</a>. When the index is too large for the cache, queries trigger expensive disk reads. While the typical solution is to scale horizontally (add nodes/RAM), we wanted to exhaust efficiency improvements on our existing resources first.</p><h2><strong>The fix</strong></h2><p>By default, Elasticsearch uses <a href="https://en.wikipedia.org/wiki/LZ4_(compression_algorithm)">LZ4</a> compression for its index segments, striking a balance between speed and size. We hypothesized that switching to <code>best_compression</code> (which uses <a href="https://en.wikipedia.org/wiki/Zstd">zstd</a>) would reduce the size of indices. A smaller footprint allows a larger percentage of the index to fit in the page cache, trading a negligible increase in CPU (for decompression) for a reduction in disk I/O.</p><p>To enable <code>best_compression</code>, we reindexed the data with the index setting <code>index.codec: best_compression</code>. Alternatively, the same result could be achieved by closing the index, resetting the index codec to <code>best_compression</code>, and then performing a segment merge.</p><pre><code>POST my-index/_close
PUT my-index/_settings
{
    "codec": "best_compression"
}
  
POST my-index/_open  
POST my-index/_forcemerge?max_num_segments=1</code></pre><h2><strong>The results</strong></h2><p>The results confirmed our hypothesis: improved storage efficiency directly translated into a substantial boost in search performance with no accompanying increase in CPU utilization.</p><p>Applying <code>best_compression</code> reduced the index size by approximately 25%. While less than the reduction seen in repetitive log data, this 25% reduction effectively increased our page cache capacity by the same margin.</p><p>During the next load test (starting at 17:00), the traffic was even higher, peaking at 500 requests per minute per Elasticsearch node.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fc1902f396cb2115c0013155ad07f6eb87389c60-660x309.png" style="width: 100%;" /><p>Despite the higher load, the CPU utilization was lower than in the previous run. The elevated usage in the earlier test was likely due to the overhead of excessive page fault handling and disk I/O management.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/15699ef4c65b3f0a9f8a3e1bae8bb18f7b647025-819x352.png" style="width: 100%;" /><p>Crucially, page faults dropped significantly. Even at higher throughput, faults hovered around &lt;200k per minute, compared to &gt;300k in the baseline test.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f76ca967976d740af88a9359b66041701abb46fc-764x340.png" style="width: 100%;" /><p>Although the page fault results were still less than optimal, query service time was cut by about 50%, hovering below 30ms even under heavier load.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/750ec1c59b8eb5069aed4c066d856ecea82d5bca-620x311.png" style="width: 100%;" /><p></p><h2><strong>The conclusion: best_compression for search</strong></h2><p>For search use cases where data volume exceeds available physical memory, <code>best_compression</code> is a potent performance-tuning lever.</p><p>The conventional solution to cache misses is to scale out to increase RAM. However, by reducing the index footprint, we achieved the same goal: maximizing the document count in the page cache. Our next step is to explore <a href="https://www.elastic.co/blog/space-savings-a-lesser-known-benefit-of-index-sorting-in-elasticsearch"><strong>index sorting</strong></a> to further optimize storage and squeeze even more performance out of our existing resources.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Agent Builder now GA: Ship context-driven agents in minutes]]></title>
            <description><![CDATA[Agent Builder is now GA. Learn how it allows you to quickly develop context-driven AI agents.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Anish Mathur, Evan Castle]]></dc:creator>
            <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6840eb7dbb884055ab0e965dcfd614fec54936af-2210x1440.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We are excited to announce the general availability of Agent Builder in Elastic Cloud Serverless and in the upcoming 9.3 release. Agent Builder brings the power of Elasticsearch as a context engineering platform to quickly develop contextual, data-focused AI agents.</p><p>Agents are gaining traction driven by their potential to deliver efficiency gains and better customer experiences. But in practice, providing agents with the right context is difficult, especially when operating over messy, unstructured enterprise data. Developers must manage tools, prompts, state, reasoning logic, models, and crucially retrieve relevant context from business sources to deliver accurate results and actions. Elastic Agent Builder delivers these core components to develop secure, reliable, context-driven agents.</p><h2>Agent Builder core capabilities</h2><p>Agent Builder leverages Elastic’s long-term investments in search relevance and retrieval-augmented generation, and work to make Elasticsearch the best vector database to simplify the development of contextual, data-focused AI agents.</p><p>Agent Builder allows you to:</p><ul><li>Immediately start with a built-in conversational agent that can answer questions, perform analytics and drive investigations over any data in Elasticsearch.</li><li>Quickly go from complex unstructured data to a custom agent with configuration-based development experience.</li><li>Leverage best-in-class, hybrid search relevance through built-in ES|QL or custom tools to improve context quality and agent reliability.</li><li>Execute complex workflows (preview) as reusable tools to enrich data, update records, send messages, and more for rules-based automation.</li><li>Connect to data sources outside of Elasticsearch using workflows and MCP to correlate and combine context for agents.</li><li>Integrate with any agentic or application framework using built-in and custom tools exposed over MCP, and the ability to connect to external MCP (preview), support for A2A, and full API support.</li><li>Extend the capabilities of Agent Builder with integration to third-party solutions like LlamaIndex for complex document processing or Arcade.dev for secure, structured tool access.</li></ul><p>To further extend Agent Builder functionality, we are introducing Elastic Workflows, our new rules-based automation capabilities, now in technical preview. For organizational tasks, agents at times need certainty and reliability of rules-based actions, which are often necessary to implement specific business logic. Elastic Workflows provides agents with a simple, declarative way to orchestrate internal and external systems to take actions, gather and transform data and context. Workflows are fully composable, event-driven and flexible, and can be exposed as tools to an agent via MCP.</p><h2>Go from data to agent in minutes</h2><p>Developing agents can take weeks of upfront work to consolidate separate data stores, build manual pipelines, tune queries, and manage complex orchestration. Agent Builder reduces the time to develop agents by removing the need for separate data stores, vector databases, RAG pipelines, search layers, query translators, and tool orchestrators, allowing you to focus on agent logic and application delivery.</p><p>Agent Builder natively integrates Elasticsearch platform primitives to make agent development fast.</p><ul><li>Start with a built-in conversational agent that can immediately chat with and reason with your indexed data.</li><li>Integrate agents into applications, dashboards, or CI/CD systems with interactive access via Kibana, APIs, or MCP and A2A.</li><li>Build with default tooling to understand your data structure, select the appropriate index, generate optimized hybrid, semantic, and structured queries, and create configurable visualizations using ES|QL based on natural language prompts.</li></ul><p>To go deeper, try a complete <a href="https://www.elastic.co/search-labs/blog/ai-agent-builder-elasticsearch">hands-on walkthrough</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b55b63eae40f72952967cc8f3ea4df4cd62d7d70-1080x608.gif" style="width: 100%;" /><h2>Build on Elasticsearch, a complete data platform for context engineering</h2><p>For AI agents, context quality is essential to provide effective reasoning and reduce the risks of hallucination. For many enterprise AI agents, the business data required to perform a task is the most crucial piece of context. As a massively scalable data store, vector database, and leader in relevance, Elasticsearch already offers many strong context-engineering primitives. Context engineering goes beyond simple retrieval-augmented generation by allowing you to tailor and scale how data is fetched, ranked, filtered, and presented to agents, helping reduce noise and ambiguity.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/419b9b6f13739e0a8983249d8ac31478e73dac89-1600x901.png" style="width: 100%;" /><p>Elasticsearch delivers a context engine that combines lexical search, vector search, and structured filtering for retrieval that materially <a href="https://www.elastic.co/search-labs/blog/context-engineering-relevance-ai-agents-elasticsearch">improves LLM performance</a> by ensuring the model operates on relevant and precise context. This capability is supported by agentic retrieval, along with built-in tools and search logic that automatically select the right indexes and transform natural language into optimized queries for context.</p><p>With Agent Builder, you can ensure agents receive the most useful context first with controls for relevance and ranking, allowing you to fine-tune scoring, ranking, and filtering logic. Elasticsearch lets you control what matters, why it matters, and how it is prioritized, instead of relying on opaque retrieval behavior. This is all underpinned by Elasticsearch as a scalable data platform to store and scale all your data from text, vectors, metadata, logs, and more on one platform, making it easier to manage context for agents.</p><h2>Execute complex workflows as reusable tools</h2><p>While AI agents enable reasoning for complex tasks, much automation depends on reliably executing rules-based actions that enforce specific business logic. Elastic Workflows provides a simple, declarative way to orchestrate internal and external systems to take actions, gather context or data, and integrate them as part of agents. Defined in YAML, workflows are fully composable, allowing them to be as simple or as complex as the job requires. This gives agents an efficient way to take action across the Elasticsearch platform and solutions, as well as with third-party applications.</p><p>Integrating a workflow with Agent Builder can be done in three steps (prerequisite: enable workflows with details provided <a href="https://github.com/elastic/workflows">here</a>)</p><p>1. Create and save a new workflow using the simple YAML-based editor with built-in autocomplete and testing.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/308888bf3d2fa013f9391a55be6a6fbd458b6dac-1600x998.png" style="width: 100%;" /><p>2. Create a new tool in Agent Builder with type “Workflow” and provide a description to help the agent determine when to use the workflow tool.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c04810d30d226112c3610bd58e208607b213fc3d-1600x945.png" style="width: 100%;" /><p>3. Add the workflow tool to your custom agent.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/724cd4ac93c46efb0d339fd140e5caf138f8150f-1600x948.png" style="width: 100%;" /><p>4. That’s it! Now the agent can call the workflow from within a conversation.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8dfdd726ab89e31c48b79372650ce33946713dca-1600x929.png" style="width: 100%;" /><h2>Your agent, your rules</h2><p>Agent Builder doesn’t lock you into a single development paradigm. Instead, it’s designed to enable open, flexible development approaches for agents with full control of data, relevance, models, interoperability, security and agent design.</p><p>Custom agent definitions let you choose exactly which tools an agent can access, embed custom system prompts, tailor the agent’s instructions, and define security boundaries. Agents remain model-agnostic, allowing you to flexibly configure a preferred LLM, both native and across the broader ecosystem, without being locked into a single provider.</p><p>Build extensible tools that encapsulate domain-specific logic (e.g., specific index filters, ES|QL joins, analytical pipelines), and constrain them for safe use in production. Full API support enables interoperability with other agentic frameworks, with native support for Model Context Protocol (MCP). A2A integration means you can expose your Elastic agents to other frameworks, services, and client apps, reusing the same data and context engineering logic across integrations.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e903ba24ffb3f40231e901f63bd494c89cb7757-1600x1004.png" style="width: 100%;" /><p>Agent Builder supports flexible, open development and is designed to integrate easily with popular agent frameworks and platforms. These integrations can be essential to delivering effective agents. As <strong>Sam Partee, Co-Founder at Arcade.dev</strong> describes,</p><p><em>“Agentic systems fail today because connecting AI to tools and data is complex. Elastic Agent Builder with Arcade.dev gives developers a structured, secure way to handle how agents retrieve context, reason, and act, taking agents from demo to production grade.&quot;</em></p><p>Agent Builder also leverages the extensibility of Elasticsearch for handling complex data. As <strong>Jerry Liu, CEO at LlamaIndex </strong>describes,</p><p><em>“Unlocking enterprise context from unstructured data sources is key to building effective agents. Elastic Agent Builder combined LlamaIndex complex document processing strengthens the critical context layer, helping teams retrieve, process, and prepare data so agents can reason more accurately and deliver better outcomes.”</em></p><h2>What can you build?</h2><p>Agent Builder is already being used for a variety of use cases. Below are a few examples and reference architectures to get started with agents:</p><ul><li><strong>Automate infrastructure: </strong>In support scenarios, agents have been used to read, think, and chat, but to date, they cannot reach out and touch the infrastructure they may need to manage. Elastic’s engineering team built an agent for <a href="https://www.elastic.co/search-labs/blog/agent-builder-augmented-infrastructure">automated infrastructure management</a> as part of a hackathon. The agent actively investigates issues with application infrastructure and takes automated actions. It uses workflows to optimize configurations, respond to issues and scale resources, all based on an intelligent understanding of infrastructure logs.</li><li><strong>Security threat analysis: </strong>A security vulnerability agent was developed with Elastic Agent Builder, MCP, and Elasticsearch. It automates threat analysis by correlating internal security data with external threat intelligence. The agent performs semantic search over historical incidents and configurations, augments results with live internet data, and applies LLM reasoning to assess environmental relevance, prioritize risks, and produce actionable remediation. See the <a href="https://www.elastic.co/search-labs/blog/agent-builder-mcp-reference-architecture-elasticsearch">reference architecture</a><strong>.</strong></li><li><strong>Technical customer support: </strong>Agents can perform multiple support tasks, including case summarization, issue deduplication and creation, and deep technical investigation. Agent Builder enables this with multi-step, hybrid search to find only the most relevant related issues, solutions, and procedures, and formulate root cause hypotheses and remediation plans. Agent Builder can simplify the architecture of complex <a href="https://www.elastic.co/blog/generative-ai-customer-support-elastic-support-assistant">support systems</a> and accelerate time to delivery.</li><li><strong>Product and content discovery:</strong> Agent Builder simplifies the process of <a href="https://www.elastic.co/search-labs/blog/build-voice-agents-elastic-agent-builder">exposing complex product catalogs for conversational experiences</a>, while allowing organizations to maintain flexibility to include their own business logic and requirements.</li><li><strong>Build your own:</strong> Join the <a href="https://elasticsearch.devpost.com/">Agent Builder Hackathon,</a> running from January 22 to February 27, 2026. Work with the community to build context-driven, multi-step AI agents that combine search, workflows, tools, and reasoning to automate real-world tasks*</li></ul><h2>Start building custom agents now</h2><p>Get started with an <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;pg=en-enterprise-search-page">Elastic Cloud Trial</a>, and check out the documentation <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">here</a>. For existing customers, Agent Builder is available in Cloud Serverless and on the Enterprise Tier in Elastic Cloud Hosted and self-managed.</p><p>* <a href="https://elasticsearch.devpost.com/rules">Click here</a> for full terms, conditions, and eligibility requirements for the hackathon</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building voice agents with Elastic Agent Builder]]></title>
            <description><![CDATA[Exploring how voice agents work and how to build one using Elastic Agent Builder and LiveKit.]]></description>
            <link>https://www.elastic.co/search-labs/blog/build-voice-agents-elastic-agent-builder</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/build-voice-agents-elastic-agent-builder</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Kenneth Kreindler]]></dc:creator>
            <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/43ceabb9e2c0966261c188bd40e03178d5a91e5c-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>AI has been trapped in a glass box. You type commands, it responds with text, and that’s it. It’s useful but distant, like watching someone move behind a screen. This year, 2026, will be the year when business will shatter this glass and bring AI agents into products, where they really deliver value.</p><p>One of the ways the glass will be shattered is by the adoption of <em>voice agents</em>, which are AI agents that recognize human speech and synthesize computer-generated audio. With the rise of low-latency transcriptions, fast large language models (LLMs), and text-to-speech models that sound human, this has become possible.</p><p>Voice agents also need access to business data to become really valuable. In this blog, we’ll learn how voice agents work and build one for ElasticSport, a fictitious outdoor sports equipment shop, using <a href="https://livekit.io/">LiveKit</a> and <a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a>. Our voice agent will be context-aware and will work with our data.</p><h2>How it works</h2><p>There are two paradigms in the world of voice agents: The first uses speech-to-speech models, and the second uses a voice pipeline consisting of speech-to-text, LLM, and text-to-speech. Speech-to-speech models have their own benefits, but voice pipelines offer much more customization over the technologies used and how context is managed, along with control over the agent’s behavior. We’ll focus on the voice pipeline model.</p><h3>Key components</h3><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b237501903f9c3a71fe1b7755c3990e40c5495c8-1600x653.png" style="width: 100%;" /><h4>Transcription (speech-to-text)</h4><p>Transcription is the voice pipeline entrypoint. The transcription component takes as input raw audio frames, transcribes speech into text, and outputs that text. The transcribed text is buffered until the system detects that the user&#x27;s speech has ended, at which point the LLM generation is kicked off. Various third-party providers offer low-latency transcriptions. When selecting one, consider latency and transcription accuracy, and ensure that they support streamed transcripts.</p><p></p><p>Examples of third-party APIs: <a href="https://www.assemblyai.com/">AssemblyAI</a>, <a href="https://deepgram.com/product/speech-to-text">Deepgram</a>, <a href="https://platform.openai.com/docs/guides/realtime-transcription">OpenAI</a>, <a href="https://elevenlabs.io/speech-to-text">ElevenLabs</a></p><h4>Turn detection</h4><p>Turn detection is the component of the pipeline that detects when the speaker has finished speaking and the generation should begin. One common way to do this is through a voice activity detection (VAD) model, such as <a href="https://github.com/snakers4/silero-vad">Silero VAD</a>. VAD uses audio energy levels to detect when audio contains speech and when speech has ended. However, VAD by itself cannot identify the difference between a pause and the end of speech. This is why it’s often combined with an end-of-utterance model that predicts whether the speaker has finished speaking, based on the interim transcript or raw audio.</p><p>Examples (Hugging Face): <a href="https://huggingface.co/livekit/turn-detector">livekit/turn-detector</a>, <a href="https://huggingface.co/pipecat-ai/smart-turn-v3">pipecat-ai/smart-turn-v3</a></p><h4>Agent</h4><p>The agent is the core of a voice pipeline. It’s responsible for understanding intent, gathering the right context, and formulating a reply in text format. <a href="https://www.elastic.co/elasticsearch/agent-builder">Elastic Agent Builder</a>, with its built-in reasoning capabilities, tool library, and workflow integration, make for an agent that can work on top of your data and interact with external services.</p><h4>LLM (text-to-text)</h4><p>When selecting an LLM for Elastic Agent Builder, there are two main characteristics to consider: LLM reasoning benchmarks, and time to first token (TTFT).</p><p>Reasoning benchmarks indicate how well the LLM is able to generate correct responses. Benchmarks to consider are ones that evaluate multiturn conversation adherence and intelligence benchmarks, such as MT-Bench and the Humanity&#x27;s Last Exam dataset, respectively.</p><p>TTFT benchmarks evaluate how fast the model produces its first output token. There are other types of latency benchmarks, but TTFT is particularly important for voice agents, as audio synthesis can begin as soon as the first token is received, resulting in lower latency between turns, a natural-feeling conversation.</p><p>Usually, one needs to make a trade-off between these two characteristics because faster models often perform worse on reasoning benchmarks.</p><p>Examples (Hugging Face): <a href="https://huggingface.co/openai/gpt-oss-20b">openai/gpt-oss-20b</a>, <a href="https://huggingface.co/openai/gpt-oss-120b">openai/gpt-oss-120b</a></p><h4>Synthesis (text-to-speech)</h4><p>The final part of the pipeline is the text-to-speech model. This component is responsible for converting the text output from the LLM into audible speech. Similar to the LLM, latency is a characteristic to look out for when selecting a text-to-speech provider. Text-to-speech latency is measured by time to first byte (TTFB). That’s the time it takes for the first audio byte to be received. Lower TTFB also reduces turn latency.</p><p>Examples: <a href="https://elevenlabs.io/text-to-speech-api">ElevenLabs</a>, <a href="https://cartesia.ai/sonic">Cartesia</a>, <a href="https://www.rime.ai/">Rime</a></p><h4>Constructing the voice pipeline</h4><p>Elastic Agent Builder can be integrated into a voice pipeline at several different levels:</p><ol><li>Agent Builder tools only: speech-to-text → LLM (with Agent Builder tools) → text-to-speech</li><li>Agent Builder as an MCP: speech-to-text → LLM (with Agent Builder access via MCP) → text-to-speech</li><li>Agent Builder as the core: speech-to-text → Agent Builder → text-to-speech</li></ol><p>For this project, I chose the Agent Builder as the core approach. With this approach, the full functionality of Agent Builder and workflows can be used. The project uses LiveKit to orchestrate speech-to-text, turn detection, and text-to-speech, and it implements a custom LLM node that integrates directly with Agent Builder.</p><h2>Elastic support voice agent</h2><p>We’ll be building a custom support voice agent for a fictitious sports shop called ElasticSport. Customers will be able to call the help line, ask for product recommendations, find product details, check order statuses, and have order information sent to them via text. To achieve this, we first need to configure a custom agent and create tools for executing Elasticsearch Query Language (ES|QL) queries and workflows.</p><a href="https://videos.elastic.co/watch/jpX6D21URZuh3PtXVV7Ks6">Watch video on Vidyard</a><h3>Configuring the agent</h3><h4>Prompt</h4><p>The prompt instructs the agent what personality it should take and how to respond. Importantly, there are a few voice-specific prompts that ensure the responses are synthesized into audio properly and misunderstandings are recovered from gracefully.</p><pre><code>You are a Sales Assistant at ElasticSport, an outdoor sport shop specialized in hiking and winter equipment. 

[Profile]
- name: Iva
- company: ElasticSport
- role: Sales Assistant
- language: en-GB
- description: ElasticSport virtual sales assistant

[Context]
- Ask clarifying questions to understand the context.
- Use available tools to answer the user's question.
- Use the knowledge base to retrieve general information

[Style]
- Be informative and comprehensive.
- Maintain a professional, friendly and polite tone.
- Mimic human behavior and speech patterns.
- Be concise. Do not over explain initially

[Response Guideline]
- Present dates in spelled-out month date format (e.g., January fifteenth, two thousand and twenty-four).
- Avoid the use of unpronounceable punctuation such as bullet points, tables, emojis.
- Respond in plain text, avoid any formatting.
- Spell out numbers as words for more natural-sounding speech.
- Respond in short and concise sentences. Responses should be 1 or 2 sentences long.

[ERROR RECOVERY]
### Misunderstanding Protocol
1. Acknowledge potential misunderstanding
2. Request specific clarification</code></pre><h4>Workflows</h4><p>We’ll add a small workflow to send an SMS through Twilio’s messaging API. The workflow will be exposed to the custom agent as a tool, resulting in a user experience where the agent can send the caller an SMS while on the call. This allows the caller to, for example, ask, “Can you send more details about <em>X</em> over text?”</p><pre><code>name: send sms
enabled: true
triggers:
  - type: manual
inputs:
  - name: message
    type: string
    description: The message to send to the phone number.

  - name: phone_number
    type: string
    description: The phone number to send the message to.

consts:
  TWILIO_ACCOUNT: "****"
  BASIC_AUTH: "****"
  FROM_PHONE_NNUMBER: "****"
steps:
  - name: http_step
    type: http
    with:
      url: https://api.twilio.com/2010-04-01/Accounts/{{consts.TWILIO_ACCOUNT}}/Messages.json
      method: POST
      headers:
        Content-Type: application/x-www-form-urlencoded
        Authorization: Basic {{consts.BASIC_AUTH | base64_encode}}
      body: From={{consts.FROM_PHONE_NNUMBER}}&To={{inputs.phone_number}}&Body={{inputs.message}}
      timeout: 30s</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b057e71b0a7c50eb3da47cd4f95e77ec7b4c6126-1600x1245.png" style="width: 100%;" /><h4>ES|QL tools</h4><p>The following tools allow the agent to provide relevant responses that are grounded in real data. The example repo contains a setup script to initialize Kibana with product, order, and knowledge base datasets.</p><ul><li><strong>Product.search</strong></li></ul><p>The product dataset contains 65 fictitious products. This is an example document:</p><pre><code>{
      "sku": "ort3M7k",
      "name": "Ortovox Free Rider 26 Backpack",
      "price": 189,
      "currency": "USD",
      "image": "https://via.placeholder.com/150",
      "description": "The Ortovox Free Rider 26 is a technical freeride backpack with a dedicated safety compartment and diagonal ski carry system. Perfect for backcountry missions.\n\nKey Features:\n- 26L capacity\n- Diagonal ski carry system\n- Safety equipment compartment\n- Helmet holder\n- Hydration system compatible",
      "category": "Accessories",
      "subCategory": "Backpacks",
      "brand": "Ortovox",
      "sizes": ["One Size"],
      "colors": ["Black", "Blue", "Orange"],
      "materials": ["Nylon", "Polyester"]
    }</code></pre><p>The name and description fields are mapped as <code>semantic_text</code>, enabling the LLM to use semantic search via ES|QL to retrieve relevant products. The hybrid search query performs semantic matching across both fields, with a slightly higher weight applied to matches on the name field using a boost.</p><p>The query first retrieves the top 20 results ranked by their initial relevance score. These results are then reranked based on their description field using the <code>.rerank-v1-elasticsearch</code> inference model, and finally trimmed down to the top five most relevant products.</p><pre><code>type: ES|QL
toolId: products.search
description: Use this tool to search through the product catalogue by keywords.
query: |
    FROM products
        METADATA _score
      | WHERE
          MATCH(name, ?query, {"boost": 0.6}) OR
            MATCH(description, ?query, {"boost": 0.4})
      | SORT _score DESC
      | LIMIT 20
      | RERANK ?query
            ON description
            WITH {"inference_id": ".rerank-v1-elasticsearch"}
      | LIMIT 5

parameters:
    query: space separated keywords to search for in catalogue</code></pre><ul><li><strong>Knowledgebase.search</strong></li></ul><p>The knowledgebase datasets contain documents of the following shape, where the title and content fields are stored as semantic text:</p><pre><code>{
        id: "8273645",
        createdAt: "2025-11-14",
        title: "International Orders",
        content: `International orders are processed through our international shipping partner. Below are the countries we ship to and average delivery times.
        Germany: 3-5 working days
        France: 3-5 working days
        Italy: 3-5 working days
        Spain: 3-5 working days
        United Kingdom: 3-5 working days
        United States: 3-5 working days
        Canada: 3-5 working days
        Australia: 3-5 working days
        New Zealand: 3-5 working days
        `
}</code></pre><p>And the tool uses a similar query as the <code>product.search</code> tool:</p><pre><code>type: "ES|QL"
toolId: knowledgebase.search
description: Use this tool to search the knowledgebase.
query: |
  FROM knowledge_base
    METADATA _score
  | WHERE
      MATCH(title, ?query, {"boost": 0.6}) OR
      MATCH(content, ?query, {"boost": 0.4})
  | SORT _score DESC
  | LIMIT 20
  | RERANK ?query
      ON content
      WITH {"inference_id": ".rerank-v1-elasticsearch"}
  | LIMIT 5

parameters:
  query: space separated keywords or natural language phrase to semantically search for in the knowledge base</code></pre><ul><li><strong>Orders.search</strong></li></ul><p>The final tool that we’ll add is the one used to retrieve orders by <code>order_id</code>:</p><pre><code>type: "ES|QL"
toolId: order.search
description: Use this tool to retrieve an order by its ID.
query: |
  FROM orders
    METADATA _score
  | WHERE order_id == ?order_id
  | SORT _score DESC
  | LIMIT 1

parameters:
  order_id: "the ID of the order"</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d22bdd540a95b5a9c2bd5f308620835e8e6f7ecb-1600x1361.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d91709a50fb5391876b714885242d998b2b21027-1600x1443.png" style="width: 100%;" /><p>After configuring the agent and attaching these workflows and ES|QL tools to the agent, the agent can be tested inside of Kibana.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8691f41624247a6b1352d158c970031e1426ce5e-1600x1056.png" style="width: 100%;" /><p>Aside from building an ElasticSport support agent, the agent, workflows, and tools can be tailored to other use cases, such as a sales agent that qualifies leads, a servicing agent for home repairs, reservations for a restaurant, or an appointment-scheduling agent.</p><p></p><p>The final part is linking up the agent we just made with LiveKit, text-to-speech, and speech-to-text models. The repo linked at the end of this blog contains a custom Elastic Agent Builder LLM node that can be used with LiveKit. Just replace the <code>AGENT_ID</code> with your own, and link it with your Kibana instance.</p><h2>Getting started</h2><p>Check out the code, and try it for yourself <a href="https://github.com/KDKHD/elastic_agent_builder_livekit">here</a>. </p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Agent Builder, beyond the chatbox: Introducing Augmented Infrastructure]]></title>
            <description><![CDATA[Learn about Elastic Agent Builder with Augmented Infrastructure, an AI agent that enables augmented operations, augmented development, and augmented synthetics.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-builder-augmented-infrastructure</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-augmented-infrastructure</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Wert, Bill Easton, Gil Raphaelli, Graham Hudgins, Norrie Taylor]]></dc:creator>
            <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e08daf78f328e826f39d06329f6a5487f75d178d-1272x700.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><strong>This is not talk. We&#x27;re doing it.</strong></p><p>We’ve all seen the rise of AI agents. They’re fantastic at summarizing text, writing code snippets, and answering questions based on documentation. But for those of us in DevOps and site reliability engineering (SRE), there has been a frustrating limitation. Most agents are trapped in the Call Center paradigm, meaning that they can read, think, and chat, but they cannot reach out and touch the infrastructure they’re supposed to be managing.</p><p>For our latest hackathon project, we decided to blow that limitation up.</p><p>We built <strong>Augmented Infrastructure</strong>: an infrastructure copilot that not only gives you advice but also creates, deploys, monitors, and fixes your live environment.</p><h2><strong>The problem: Copy, reformat, paste</strong></h2><p>Standard agents operate in a vacuum. If your app goes down and costs the company $5 million, a standard agent can read you the runbook on how to fix it. But <em>you</em> still have to do the work. You’re left to copy the code, reformat it for your environment, and paste it into your terminal.</p><p>We wanted an agent that understands the difference between <em>talking</em> about Kubernetes and <em>configuring</em> Kubernetes.</p><h2><strong>The engine: What is Elastic Agent Builder?</strong></h2><p>To build this, we didn&#x27;t start from scratch. We built it on top of <a href="https://www.elastic.co/elasticsearch/agent-builder"><strong>Elastic Agent Builder</strong></a>. For those unfamiliar, Elastic Agent Builder is a framework designed to rapidly develop agents, and it acts as the bridge between a large language model (LLM) (in our demo, we used Google Gemini) and private data stored in Elasticsearch.</p><p>Agent Builder can be used for conversational AI by grounding it in internal data, like documents or logs. But its most powerful feature is the ability to assign <strong>tools</strong>. These tools allow the LLM to step outside of the chat interface to perform specific tasks. We realized that if we pushed this feature to its limit, we could transform Agent Builder into an automation powerhouse.</p><h2><strong>Making it work: Building the first version</strong></h2><p>When we started on the project, we knew we wanted to make the agents be able to change the outside world. We had an idea: What if we built some “runner” software (to run any command the agent could think of on the host)? And then: What if the runners, Elastic Agent Builder, and the user were in a three-way call?</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8dc8317c1301b8eb7b89438529e8d8d17411c95a-1024x559.png" style="width: 100%;" /><p>We started by building out a Python project, Augmented Infrastructure Runners, which was essentially a <code>while(true)</code> loop that queried the Elastic Agent Builder conversations API every second and checked for a special syntax we had created:</p><pre><code>{
	"tool_name": "my_tool",
       "tool_arguments": "\{stringified json arguments\}"
}</code></pre><p>We then updated the prompt to teach it about our new tool calling syntax. Bill is a maintainer of <a href="https://gofastmcp.com/getting-started/welcome">FastMCP</a>, the most popular framework for building Model Context Protocol (MCP) servers in Python. He set out to work using FastMCP client with this new runner software to mount MCP servers and make their tools available to the runner. When the agent saw this, it would run the tool call and it would <code>POST</code> the results back to the conversation as if the user had sent the results. This triggered the LLM to respond to the result, and off we went!</p><p>This was great but it had two main problems:</p><ol><li>The agent would spew all of this JSON right into the conversation with the user.</li><li>The earliest point in time when messages were visible through the conversations API was when a conversation round was completed (that is, when the LLM replied).</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7b3755aeae17722ff1bb9677712293e9195f96a0-1058x1034.png" style="width: 100%;" /><p>So we set out to figure out how to move this into the background.</p><p>We then switched to giving the agent a tool called <code>call_external_tool</code> with two arguments: the <code>tool_name</code> and the stringified JSON tool arguments. This external tool call would return nothing, but importantly, would be visible in the <code>GET</code> request to the conversations API. We then gave the runners permission to write documents directly to Elasticsearch, which the Elastic Agent Builder agent could retrieve as needed. The agent is always operating in response to a user message, so we need to kick-start the agent with a user message so that it will go look for results and continue processing. So we had the agents insert a small message into the chat to resume the conversation:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/61ff59a57c68ed5fad492d19c0580644113a507d-1600x1321.png" style="width: 100%;" /><p>So now we had external tool calls. However, because of the second problem mentioned above, we had to get rid of that final kick-start part. Otherwise, every external tool call required a full conversation round to retrieve the results!</p><h2><strong>Making it great: Introducing workflows</strong></h2><p>In addition to Elasticsearch Query Language (ES|QL) and index search tool calls, Agent Builder agents can call Elastic workflow-based tools. Elastic workflows provide a flexible and easy to manage way to execute an arbitrary sequence and logic of actions. For our purposes, all we need the workflow to do is store an external tool request to Elasticsearch and return an ID to poll the results for. This results in the following, simple workflow definition:</p><pre><code>name: ai-tool-call
enabled: true
triggers:
  - type: manual
inputs:
  - name: runner_id
    type: string
  - name: tool_calls
    type: string

steps:
  - name: store_request
    type: elasticsearch.create
    with:
      index: distributed-tool-requests
      id: "{{inputs.runner_id}}_{{ execution.id }}"
      document:
        request_id: "{{ execution.id }}"
        runner_id: "{{inputs.runner_id}}"
        tool_call: "{{inputs.tool_calls}}"
        status: "unhandled"

  - name: output_result
    type: console
    with:
      message: "Called tool, with execution id: {{ execution.id }}. Use this ID to poll the results."</code></pre><p>With that, instead of relying on the tool call request being written into the conversation, the runners can just poll the Elasticsearch <code>distributed-tool-requests</code> index for new external tool requests and report the results back into another Elasticsearch index with the provided <code>execution.id</code>.</p><p>This eliminates the two main issues mentioned above:</p><ol><li>The conversation history isn’t cluttered with the payload for the external tool calls anymore.</li><li>As the runners are polling the Elasticsearch index instead of the conversation history, they aren’t blocked by the conversation round to be completed for the external tool requests to become visible.</li></ol><p>The second point has the great advantage that processing of the external tool calls starts within the agent’s thinking phase (rather than when the conversation round has been completed). This allows us to instruct the LLM in the system prompt to poll for the external tool results until the results are available and eliminates the need for the kick-start message. Overall, this has the nice effect that the conversation feels more natural: The LLM can process multiple external tool requests within a single conversation round (instead of requiring one conversation round per tool request) and, thus, can accomplish more complex user requests in one go.</p><h2><strong>Putting it all together</strong></h2><p>To bridge the gap between the LLM and the server rack, we developed a specific architecture using Agent Builder’s tool capabilities:</p><ol><li><strong>Augmented Infrastructure runners:</strong> We deployed lightweight runners inside the target environments (servers, Kubernetes clusters, cloud accounts). These runners are connected directly to Elastic, using secured endpoints and secrets only available to each of the runners.</li><li><strong>ES|QL retrieval:</strong> The copilot uses Elastic’s <strong>ES|QL</strong> to perform hybrid searches. It doesn&#x27;t just search for knowledge; it searches for <em>capabilities</em>. It queries the connected runners to see which tools are available (for example, <code>list_ec2_instances</code>, <code>install_helm_chart</code>).</li><li><strong>Workflow execution:</strong> Once the agent decides on a course of action, it creates a structured workflow.</li><li><strong>Feedback loop:</strong> The runners execute the command locally and report the results back into Elasticsearch. The copilot reads the result from the index and decides the next step.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/76be256da722c1965971fc506502768bd890f0c4-1290x1076.png" style="width: 100%;" /><h2><strong>The demo: From outage to observability</strong></h2><a href="https://videos.elastic.co/watch/bRgPJfJLeABjPMMhvy1sbB">Watch video on Vidyard</a><p>In the video, we showcased two distinct scenarios demonstrating the power of this architecture.</p><h3><strong>Scenario 1: DevOps rescue</strong></h3><p>We started with a user panicking about a $5-million outage caused by a blind spot in their Kubernetes cluster.</p><ul><li><strong>The request:</strong> &quot;How do I make sure this doesn&#x27;t happen again?&quot;</li><li><strong>The action:</strong> The agent didn&#x27;t just provide a tutorial. It identified the cluster, created the necessary namespaces, generated Kubernetes secrets, installed the OpenTelemetry Operator, and instantly provided a link to a live APM dashboard.</li><li><strong>The result:</strong> Full Kubernetes observability and application insights without the user writing a single line of YAML.</li></ul><h3><strong>Scenario 2: Security handoff</strong></h3><p>A fundamental rule of infrastructure security is that you cannot protect what you cannot see. While performing our DevOps rescue, the agent sees an opportunity to improve the security of the environment.</p><p>With an alert kicked off from a previous Elastic Observability–related investigation, we demonstrate how a security practitioner can chat directly with their infrastructure: first, to enumerate the assets and resources in their cloud environment; and second, to deploy the tools necessary to ensure that the environment is secured.</p><ul><li><strong>Discovery:</strong> The copilot enumerated AWS resources for the security practitioner and identified a critical gap: an Amazon Elastic Compute Cloud (EC2) instance and an Amazon Elastic Kubernetes Service (EKS) cluster with public endpoints missing endpoint protection.</li><li><strong>Remediation:</strong> With a simple approval, the copilot deployed <strong>Elastic Security</strong> <strong>extended detection and response (XDR) and cloud detection and response (CDR)</strong> to the vulnerable assets, securing the environment in real time.</li><li><strong>The Result:</strong> Protection of deployed AWS assets and resources with complete runtime security.</li></ul><h2><strong>The future: Augmented everything</strong></h2><p>This project proves that Elastic Agent Builder can be the central brain for distributed operations. We aren&#x27;t limited to just infrastructure. Our runner technology can power:</p><ul><li><strong>Augmented synthetics:</strong> Diagnosing TLS errors across global runners.</li><li><strong>Augmented development:</strong> Creating pull requests and implementing CAPTCHAs on frontend services.</li><li><strong>Augmented operations:</strong> Automatically reconfiguring DNS resolvers during an outage.</li></ul><h2><strong>Try it yourself</strong></h2><p>We believe the future of AI isn&#x27;t just about chat support; it&#x27;s about <strong>Augmented Infrastructure</strong>. It’s about having a partner that can deploy, fix, observe, and protect alongside you.</p><p>Check out the code and try it for yourself with distributed runners (<a href="https://github.com/strawgate/augmented-infrastructure">GitHub</a>) plus Elastic Agent Builder on <a href="https://cloud.elastic.co/">Elastic Cloud Serverless</a> today!</p><ul><li>Create a serverless project on Elastic Cloud.</li><li>Deploy the code to a runner.</li><li>Set up the runner.</li><li>Configure your mcp.json.</li><li>Start the runner, which will create your agent and its tools automatically.</li><li>Chat with an agent that can reason, plan, and execute actions on your distributed runners!</li></ul><p><strong>The team: </strong><em>Alex, Bill, Gil, Graham, &amp; Norrie</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Monitoring LLM inference and Agent Builder with OpenRouter]]></title>
            <description><![CDATA[Learn how to monitor LLM usage, costs, and performance across Agent Builder and inference pipelines using OpenRouter's OpenTelemetry broadcast and Elastic APM.]]></description>
            <link>https://www.elastic.co/search-labs/blog/llm-monitoring-openrouter-agent-builder</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/llm-monitoring-openrouter-agent-builder</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d9744a1f78863313de34472dff4696ea3cf82930-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p></p><p>New models are released every week that surpass previous ones in intelligence, speed, or cost. This makes vendor lock-in risky and managing multiple connectors, billing accounts, and APIs unnecessarily complex. Each model behaves differently, in terms of token consumption, response latency, and compatibility with specific tool sets.</p><p></p><p>In this article, we&#x27;ll build an AI-enriched audio products catalog, connect it to Elastic Agent Builder, and use OpenRouter to access different models while monitoring their performance across the entire workflow, from data ingestion to agent interactions.</p><h2>Prerequisites</h2><ul><li>Elastic Cloud 9.2 or Elastic Cloud Serverless</li><li>Integration server with APM enabled</li><li>OpenRouter account and <a href="https://openrouter.ai/docs/api/reference/authentication">API Key</a></li><li>Python 3.9+</li></ul><h2>What is OpenRouter?</h2><p><a href="https://openrouter.ai/">OpenRouter</a> is a platform that unifies access to over 500 models from multiple providers through a single account and API. Instead of managing separate accounts for OpenAI, Anthropic, Google, and others, you access all of them through OpenRouter.</p><p>OpenRouter handles load balancing across providers, automatically routing requests to the provider with the best latency and fewest errors. You can also manually select providers or configure fallback chains. OpenRouter is compatible with standard APIs, code assistants, integrated development environments (IDEs), and more.</p><p>One key feature is <a href="https://openrouter.ai/docs/guides/features/broadcast/overview">Broadcast</a>, which sends traces of your model usage to external observability systems. Since OpenRouter supports OpenTelemetry, we can monitor our complete pipeline plus any other OpenRouter usage costs in the Elastic Stack.</p><h2>Architecture overview</h2><p>We&#x27;ll use an audio products catalog for which we generate new fields with AI using an inference ingest pipeline and then create an agent that can answer questions based on the indexed product data.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/03f9811dcd883b3c3302079369e690fc4d09350f-1156x491.png" style="width: 100%;" /><p>When ingesting data, the ingest pipeline uses an OpenRouter <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put">inference endpoint</a> to generate new attribute fields based on the unstructured descriptions of the products, which triggers OpenRouter to send logs about that inference to Elasticsearch.</p><p>Similarly, when chatting with the <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Agent Builder</a> that uses this data, logs are also sent to Elasticsearch for visualization.</p><p>We’ll use separate OpenRouter API keys for Agent Builder and ingestion:</p><ul><li><code>OPENROUTER_API_KEY</code> for Agent Builder Interactions</li><li><code>OPENROUTER_INGESTION_KEY</code> for the inference pipeline</li></ul><p>This allows us to differentiate traffic in monitoring dashboards and attribute costs to specific workflows.</p><h2>Setup</h2><p>First, we need to create an <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/gen-ai-connectors">AI connector</a> for the agent to interact with the large language model (LLM) and an inference endpoint for the ingest pipeline to extract fields from descriptions. Both connect to OpenRouter using the same API (but can use different keys for monitoring separation).</p><h3><strong>Create the AI connector</strong></h3><p>The AI connector allows Agent Builder to communicate with LLMs. We configure it to use OpenRouter as the provider:</p><pre><code>import requests
import os

ELASTIC_URL = os.getenv("ELASTIC_URL")
KIBANA_URL = os.environ["KIBANA_URL"]
ELASTIC_API_KEY = os.environ["ELASTIC_API_KEY"]
OPENROUTER_API_KEY = os.environ["OPENROUTER_AGENT_KEY"]
OPENROUTER_INGESTION_KEY = os.environ.get("OPENROUTER_INGESTION_KEY", OPENROUTER_API_KEY)

# Create AI Connector for Agent Builder
connector_payload = {
    "name": "OpenRouter Agent Connector",
    "connector_type_id": ".gen-ai",
    "config": {
        "apiProvider": "Other",
        "apiUrl": "https://openrouter.ai/api/v1/chat/completions",
        "defaultModel": "openai/gpt-5.2",
        "enableNativeFunctionCalling": True
    },
    "secrets": {
        "apiKey": OPENROUTER_API_KEY
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/actions/connector",
    headers={
        "kbn-xsrf": "true",
        "Authorization": f"ApiKey {ELASTIC_API_KEY}",
        "Content-Type": "application/json"
    },
    json=connector_payload
)

connector = response.json()
print(f"Connector created: {connector['id']}")</code></pre><p>We use a reasoning-capable model, like <a href="https://openrouter.ai/openai/gpt-5.2">GPT-5.2</a>, for the agent since it needs to handle complex queries and tool orchestration.</p><h3><strong>Create the inference endpoint</strong></h3><p>The inference endpoint allows Elasticsearch to call LLMs during data processing:</p><pre><code>from elasticsearch import Elasticsearch

es = Elasticsearch(
    hosts=[ELASTIC_URL],
    api_key=ELASTIC_API_KEY,
    request_timeout=60  # Higher timeout for inference operations
)

# Create inference endpoint for ingestion
inference_config = {
    "service": "openai",
    "service_settings": {
        "model_id": "openai/gpt-4.1-mini",
        "api_key": OPENROUTER_INGESTION_KEY,
        "url": "https://openrouter.ai/api/v1/chat/completions"
    }
}

response = es.inference.put(
    inference_id="openrouter-inference-endpoint",
    task_type="completion",
    body=inference_config
)

print(f"Inference endpoint created: {response['inference_id']}")</code></pre><p>We use a fast, cheaper model, like <a href="https://openrouter.ai/openai/gpt-4.1-mini">GPT-4.1 Mini</a>, for bulk ingestion tasks that don&#x27;t require advanced reasoning capabilities.</p><h2>Data pipeline</h2><p>Let&#x27;s configure the <a href="https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines">ingest pipeline</a>. It will read from the product description field and extract structured categories that Agent Builder can use for filtering and aggregations.</p><p>For example, given this product description:</p><p>&quot;<em>Premium wireless Bluetooth headphones with active noise cancellation, 30-hour battery life, and premium leather ear cushions. Perfect for travel and office use.</em>&quot;</p><p>We can extract:</p><ul><li><strong>Category:</strong> Headphones</li><li><strong>Features:</strong> [&quot;wireless&quot;, &quot;noise_cancellation&quot;, &quot;long_battery&quot;]</li><li><strong>Use case:</strong> Travel</li></ul><p>The key is providing possible values to the LLM as an enum so it groups consistently. Otherwise, we might get variations like &quot;noise cancellation&quot;, &quot;ANC&quot;, and &quot;noise-canceling&quot; that are harder to aggregate.</p><pre><code># Define the extraction prompt
EXTRACTION_PROMPT = (
    "Extract audio product information from this description. "
    "Return raw JSON only, no markdown, no explanation. Fields: "
    "category (string, one of: Headphones/Earbuds/Speakers/Microphones/Accessories), "
    "features (array of strings from: wireless/noise_cancellation/long_battery/waterproof/voice_assistant/fast_charging/portable/surround_sound), "
    "use_case (string, one of: Travel/Office/Home/Fitness/Gaming/Studio). "
    "Description: "
)

# Create the enrichment pipeline
pipeline_config = {
    "processors": [
        {
            "script": {
                "source": f"ctx.prompt = '{EXTRACTION_PROMPT}' + ctx.description"
            }
        },
        {
            "inference": {
                "model_id": "openrouter-inference-endpoint",
                "input_output": {
                    "input_field": "prompt",
                    "output_field": "ai_response"
                }
            }
        },
        {
            "json": {
                "field": "ai_response",
                "add_to_root": True  # Parses JSON and adds fields to document root
            }
        },
        {
            "remove": {
                "field": ["prompt", "ai_response"]
            }
        }
    ]
}

es.ingest.put_pipeline(
    id="product-enrichment-pipeline",
    body=pipeline_config
)

print("Pipeline created: product-enrichment-pipeline")</code></pre><p>After using OpenAI to extract a JSON with the new attributes, we use the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-parse-json-field-ingest-pipeline">json processor</a> to spread them into new fields.</p><p>Now let&#x27;s index some sample audio products:</p><pre><code># Sample audio product data
products = [
    {
        "name": "Wireless Noise-Canceling Headphones",
        "description": "Premium wireless Bluetooth headphones with active noise cancellation, 30-hour battery life, and premium leather ear cushions. Perfect for travel and office use.",
        "price": 299.99
    },
    {
        "name": "Portable Bluetooth Speaker",
        "description": "Compact waterproof speaker with 360-degree surround sound. 20-hour battery life, perfect for outdoor adventures and pool parties.",
        "price": 149.99
    },
    {
        "name": "Studio Condenser Microphone",
        "description": "Professional USB microphone with noise cancellation and voice assistant compatibility. Ideal for podcasting, streaming, and home studio recording.",
        "price": 199.99
    }
]

# Create index with mapping
es.indices.create(
    index="products-enriched",
    body={
        "mappings": {
            "properties": {
                "name": {"type": "text"},
                "description": {"type": "text"},
                "price": {"type": "float"},
                "category": {"type": "keyword"},
                "features": {"type": "keyword"},
                "use_case": {"type": "keyword"}
            }
        }
    },
    ignore=400  # Ignore if already exists
)

# Index products using the enrichment pipeline
for i, product in enumerate(products):
    es.index(
        index="products-enriched",
        id=i,
        body=product,
        pipeline="product-enrichment-pipeline"
    )
    print(f"Indexed: {product['name']}")

# Refresh to make documents searchable
es.indices.refresh(index="products-enriched")</code></pre><h2>Agent Builder</h2><p>Now we can create an Agent Builder agent to use this index and answer both text questions and analytical queries using the new fields we&#x27;ve created:</p><pre><code># Create Agent Builder agent
agent_payload = {
    "id": "audio-product-assistant",
    "name": "Audio Product Assistant",
    "description": "Answers questions about audio product catalog using semantic search and analytics",
    "labels": ["audio"],
    "avatar_color": "#BFDBFF",
    "avatar_symbol": "AU",
    "configuration": {
        "tools": [
            {
                "tool_ids": [
                    "platform.core.search",
                    "platform.core.list_indices",
                    "platform.core.get_index_mapping",
                    "platform.core.execute_esql"
                ]
            }
        ],
        "instructions": """You are an audio product assistant that helps users find and analyze audio equipment.

Use the products-enriched index for all queries. The extracted fields are:
- category: Headphones, Earbuds, Speakers, Microphones, or Accessories
- features: array of product features like wireless, noise_cancellation, long_battery
- use_case: Travel, Office, Home, Fitness, Gaming, or Studio

For analytical questions, use ESQL to aggregate data.
For product searches, use semantic search on the description field."""
    }
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/agents",
    headers={
        "kbn-xsrf": "true",
        "Authorization": f"ApiKey {ELASTIC_API_KEY}",
        "Content-Type": "application/json"
    },
    json=agent_payload
)

agent = response.json()
print(f"Agent created: {agent['id']}")</code></pre><p>For tools, we use search for semantic queries and Elasticsearch Query Language (ES|QL) for analytical queries:</p><p>Now you can chat with your agent and ask questions like:</p><ul><li>&quot;What headphones do we have for travel?&quot;</li><li>&quot;Show me products with noise cancellation under $200&quot;</li><li>&quot;What&#x27;s the average price by category?&quot;</li></ul><p>The agent uses the AI-enriched fields to provide better filtering and aggregations.</p><h2>Implementing OpenRouter Broadcast</h2><p>Now let&#x27;s set up inference monitoring. First, we need our OpenTelemetry endpoint URL. Navigate to the APM tutorial in Kibana:</p><pre><code>https://<your_kibana_url>/app/observabilityOnboarding/otel-apm/?category=application</code></pre><p>Collect the URL and authentication token from the OpenTelemetry tab:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0aa268bbee53da9059bbdd12b4ccb5ace0764b1d-1348x1292.jpg" style="width: 100%;" /><p><strong>Important:</strong> Your Kibana server needs to be reachable via the public internet so that it can receive data from OpenRouter.</p><p>In OpenRouter, go to <a href="https://openrouter.ai/settings/broadcast">Broadcast settings</a> and add a new destination for the &quot;OpenTelemetry Collector&quot;:<br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/20fc11b904ced2f7c9b26da3a5f809ec2ad3e8c9-752x447.jpg" style="width: 100%;" /><p><strong>Important: </strong>Configure the endpoint with the <code>/v1/traces</code> path and authentication headers:</p><pre><code>Endpoint: https://xxxxx.ingest.us-east-2.aws.elastic-cloud.com:443/v1/traces

Headers: {"Authorization": "Bearer YOUR_APM_SECRET_TOKEN"}</code></pre><p>Press <strong>Test connection</strong>, and you should see a success message.</p><h2>Monitoring in Elastic</h2><p>After using OpenRouter models, you should start seeing documents in Kibana. The indexed documents are in the data stream <code>traces-generic.otel-default</code> with <code>service.name: &quot;openrouter&quot;</code> and include information about:</p><ul><li>Request and response details.</li><li>Token usage (prompt, completion, total).</li><li>Cost (in USD).</li><li>Latency (time to first token, total).</li><li>Model information.</li></ul><p>From now on, the activity of the inference pipeline and Agent Builder in relation to LLM usage will be recorded in OpenRouter and sent to Elastic.</p><h3><strong>Default APM dashboards</strong></h3><p>You can view the default dashboards in Kibana under Observability &gt; Applications &gt; Service Inventory &gt; openrouter:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5d79419b51de2c9b21c383e8f8326b81d47f2f0-1600x1039.png" style="width: 100%;" /><p>The service view shows:</p><ul><li><strong>Latency:</strong> Average response times across all calls.</li><li><strong>Throughput:</strong> Requests per minute.</li><li><strong>Failed transactions:</strong> Error rates.</li><li><strong>Transactions:</strong> Breakdown by operation type.</li></ul><h3><strong>Custom LLM monitoring dashboard</strong></h3><p>For more control over the information displayed, you can create a custom dashboard. We created one that differentiates ingestion from agent chat and measures relevant parameters, like token usage and cost, plus usage outside Elastic, like coding assistants via API key filters:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/753d942ea7721e2e24074e87d3e535a637f9e707-1600x1089.jpg" style="width: 100%;" /><p>The dashboard shows:</p><ul><li>Success rate by workflow type.</li><li>Token usage by model.</li><li>Cost breakdown by API key.</li><li>Latency trends over time.</li><li>Model comparison metrics.</li></ul><p>You can download the dashboard <a href="https://gist.github.com/llermaly/7ca1ae96613c3449291b4e5d16810ba1">here</a> and import it to your Kibana instance using <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/saved-objects#saved-objects-import-and-export">Saved Objects import</a>.</p><h2>Conclusion</h2><p>OpenRouter lets you move quickly and test multiple models and providers using the same API and billing account, making it convenient to compare models of different types—large parameter, small parameter, commercial, open source, and more.</p><p>Using OpenRouter Broadcast, we can effortlessly monitor the performance of these models during ingestion via ingest pipelines or chat via Agent Builder, plus combine it with OpenRouter usage for other purposes, like coding agents and apps.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Context engineering vs. prompt engineering]]></title>
            <description><![CDATA[Learn how context engineering and prompt engineering differ and why mastering both is essential for building production AI agents and RAG systems.]]></description>
            <link>https://www.elastic.co/search-labs/blog/context-engineering-vs-prompt-engineering</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/context-engineering-vs-prompt-engineering</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Tue, 20 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3c91bde403096645057988e83cfe460d82fd0a6f-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In the early days of web development, web design was a single discipline encompassing everything from visual aesthetics to user workflows. As the industry matured, it split into two different fields: user interface (UI) and user experience (UX). They remain interconnected, but each requires distinct expertise and tools.</p><p>We&#x27;re seeing a similar split in AI. Since ChatGPT&#x27;s launch in November 2022, the industry has focused on improving prompts to optimize large language model (LLM) interactions. As we build more sophisticated AI systems, particularly agents and Model Context Protocol (MCP) tools that need access to external knowledge, two distinct disciplines are emerging: prompt engineering and context engineering. While they work together, they address fundamentally different challenges.</p><p>Prompt engineering focuses on how you communicate with the model. Context engineering focuses on what information the model has access to when it generates responses.</p><h2>What is prompt engineering?</h2><p>A <em>prompt </em>is an input to a generative AI model used to guide its output. Prompts can consist of text, images, sound, or other media.</p><p><em>Prompt engineering</em> is the iterative process of refining how you communicate with the model to achieve better results. It focuses on the words, structure, and techniques you use within a single interaction.</p><p>Here are some examples of prompt engineering techniques:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f38222691de873b792629949afd2a6e61323d573-1600x1192.png" style="width: 100%;" /><p>Common techniques include <em>few-shot prompting</em> (providing examples), <em>chain-of-thought reasoning</em> (asking the model to show its work), and <em>role assignment</em> (giving the model a persona). These techniques address challenges like ambiguity, where a question can be interpreted multiple ways and the model must guess which interpretation the user intended.</p><p>A key challenge in prompt engineering is finding what Anthropic calls &quot;the right altitude&quot; for instructions. At one extreme, engineers hardcode complex, brittle logic in their prompts to anticipate every scenario. This creates fragility and maintenance overhead. At the other extreme, engineers provide vague guidance that fails to give the model concrete signals or that falsely assumes shared context. The optimal altitude strikes a balance: specific enough to guide behavior, yet flexible enough to let the model apply good judgment.</p><p>Prompt engineering typically operates at the single-turn level, optimizing how you phrase one query to get the best response. This works well for straightforward interactions but reaches its limits when tasks require external knowledge, persistent state, or multistep reasoning.</p><p>For a deeper exploration of prompt engineering techniques, refer to <a href="https://arxiv.org/pdf/2406.06608">The Prompt Report: A Systematic Survey of Prompting Techniques</a>.</p><h2>What is context engineering?</h2><p><em>Context engineering</em> is the broader discipline of curating and maintaining the optimal set of tokens during LLM inference. While prompt engineering asks, &quot;How should I phrase this?,&quot; context engineering asks, &quot;What information does the model need access to right now?&quot;</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f42d4980623088cfcf0c26c9acce8577bb5660ed-1600x1326.png" style="width: 100%;" /><p>As the <a href="https://github.com/humanlayer/12-factor-agents">12-Factor Agents framework</a> explains, LLMs are stateless functions that turn inputs into outputs. At any given point, your input to an LLM is essentially, &quot;Here&#x27;s what&#x27;s happened so far. What&#x27;s the next step?&quot; Every interaction becomes context:</p><ul><li>The prompt and instructions you give to the model.</li><li>Documents or external data you retrieve through retrieval-augmented generation (RAG).</li><li>Past state, tool calls, or other history.</li><li>Instructions about structured data output formats.</li></ul><p>The distinction matters because modern AI agents don&#x27;t operate in single turns. An agent running in a loop generates an ever-expanding universe of information: tool outputs, retrieved documents, conversation history, intermediate reasoning. Context engineering is the practice of deciding what from that universe makes it into the model&#x27;s limited working memory at any given moment.</p><p>For a comprehensive exploration of context engineering components and best practices, refer to <a href="https://www.elastic.co/search-labs/blog/context-engineering-overview#what-is-context-engineering?">What is context engineering?</a> and <a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-evolution-agentic-ai">You Know, for Context</a>.</p><h2>Key differences: Prompt engineering vs. context engineering</h2>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Dimension</td><td style="border: 1px solid #ccc; padding: 4px;">Prompt engineering</td><td style="border: 1px solid #ccc; padding: 4px;">Context engineering</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Core question</td><td style="border: 1px solid #ccc; padding: 4px;">"How should I phrase this?"</td><td style="border: 1px solid #ccc; padding: 4px;">"What does the model need to know?"</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Scope</td><td style="border: 1px solid #ccc; padding: 4px;">Single query</td><td style="border: 1px solid #ccc; padding: 4px;">System-wide information flow</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Failure mode</td><td style="border: 1px solid #ccc; padding: 4px;">Ambiguity: poorly phrased instructions lead to misinterpretation</td><td style="border: 1px solid #ccc; padding: 4px;">Retrieval problems: wrong documents, stale information, or context overflow</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Tools</td><td style="border: 1px solid #ccc; padding: 4px;">Describes desired output</td><td style="border: 1px solid #ccc; padding: 4px;">Selects and sequences tools</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Debugging approach</td><td style="border: 1px solid #ccc; padding: 4px;">Linguistic precision: refine wording, add examples</td><td style="border: 1px solid #ccc; padding: 4px;">Data architecture: tune retrieval, prune context, sequence tools</td>
                    </tr>
              </tbody>
            </table>
          <h3><strong>Single-turn vs. multi-turn</strong></h3><p>Prompt engineering optimizes one interaction. Context engineering thinks in sequences: What did previous turns establish? What tool outputs carry forward? What should persist three steps from now? As tasks move from simple question-answering to multistep agent workflows, context engineering becomes the dominant challenge.</p><h3><strong>Context window management</strong></h3><p>Context engineering manages a finite resource with <a href="https://www.elastic.co/search-labs/blog/context-engineering-llm-evolution-agentic-ai">three failure modes</a>:</p><ul><li><strong>Too little information</strong> leads to hallucination or bad responses. When the LLM lacks enough context, it cannot determine the semantic context to generate accurate responses.</li><li><strong>Too much information </strong>causes context overflow. This overwhelms the LLM&#x27;s attention span and lowers relevance across the whole context window, causing the model to struggle identifying which parts matter most.</li><li><strong>Distracting or conflicting information</strong> confuses the model. Larger context windows increase the chances of conflicting or irrelevant information that distracts the LLM answer.</li></ul><p><strong>Key distinction: </strong>Prompt engineering takes the context window as given. Context engineering actively curates it.</p><h3><strong>Tool orchestration</strong></h3><p>Prompt engineering can request tool use and describe what tools should do. Context engineering decides which tools to make available, what information to pass them, and how their outputs flow back into context.</p><p>One of the most common failure modes is bloated tool sets with overlapping functionality. If a human engineer can&#x27;t definitively say which tool should be used in a given situation, an AI agent can&#x27;t be expected to do better. Context engineering applies a clear principle: Curate the minimal viable set of tools. Each tool should be self-contained, robust to error, and unambiguous in its purpose. Tools should also be token-efficient, returning only the information necessary rather than everything available.</p><h3><strong>Just-in-time context vs. pre-retrieval</strong></h3><p>Traditional RAG systems preprocess and retrieve all potentially relevant data up front, loading it into the prompt before inference. Context engineering increasingly favors just-in-time strategies, like <a href="https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills">Anthropic’s Agent Skills</a>, which agents discover and dynamically load into the context.</p><p>Rather than loading everything up front, agents maintain lightweight references (file paths, stored queries, document IDs) and dynamically load data at runtime using tools. This mirrors human cognition: We don&#x27;t memorize entire books but maintain systems like file folders and bookmarks to retrieve information on demand.</p><p>The trade-off is speed versus precision. Pre-retrieval is faster but risks context overflow. Just-in-time retrieval is slower but keeps the context window focused. The most effective agents often use a hybrid approach: retrieving essential baseline context up front while enabling further exploration as needed.</p><h2>Practical example: Book recommendation agent</h2><p>To demonstrate how prompt engineering and context engineering work together, we built a book recommendation agent using <a href="https://www.elastic.co/search-labs/blog/elastic-ai-agent-builder-context-engineering-introduction">Elastic Agent Builder</a> with a <a href="https://www.kaggle.com/datasets/elvinrustam/books-dataset">dataset</a> of 103,063 books indexed in Elasticsearch.</p><p><strong>Setup:</strong></p><ul><li><strong>Index: </strong><code>books-dataset</code> with 103,063 documents</li><li><strong>Fields: </strong>Title, Authors, Description, Category, Publisher, Price, Published Date</li><li><strong>Tools: </strong>Agent Builder predetermined tools</li><li><strong>Model: </strong><a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/elastic-managed-llm">Elastic Managed LLM</a></li></ul><p><strong>Mappings:</strong></p><pre><code>{
  "mappings": {
    "properties": {
      "@timestamp": { "type": "date" },
      "Authors": { "type": "text" },
      "Category": { "type": "text" },
      "Description": { "type": "text" },
      "Price Starting With ($)": { "type": "double" },
      "Published Date": { "type": "date", "format": "iso8601" },
      "Publisher": { "type": "text" },
      "Title": { "type": "text" }
    }
 }</code></pre><p>We tested three scenarios to show different outcomes based on prompt quality and context management.</p><h3><strong>Scenario 1: Prompt engineering failure (ambiguity)</strong></h3><ul><li><strong>User prompt: </strong>“Recommend a good book”</li></ul><p>The agent searched for &quot;highly rated popular books&quot; and returned results about Labrador retrievers and a Paul Reiser comedy book, neither matching typical &quot;good book&quot; expectations.</p><ul><li><strong>Problem: </strong>The agent had to guess what “good” means without any filtering criteria. The LLM interpreted the request based on its assumption of what a “good” book is rather than on the user preferences.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/11166e428139d0bd552c145980c893ba65f3c882-879x730.png" style="width: 100%;" /><h3><strong>Scenario 2: Context engineering failure (too much information)</strong></h3><ul><li><strong>User prompt: </strong>“Retrieve all books from the database&quot;</li></ul><p>Elasticsearch Query Language (ES|QL) query generated:</p><pre><code>FROM books-dataset 
| LIMIT 100</code></pre><ul><li><strong>Context retrieved:</strong> 100 random books across all categories (cooking, history, and fiction all mixed together)</li><li><strong>Problem: </strong>Too much unfiltered information. The agent brought excessive context that makes finding a relevant book difficult, and the answer is incomplete.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0b5fc4e5dd2fca32d6601190661b2383cd6ebb4d-937x1118.png" style="width: 100%;" /><h3><strong>Scenario 3: Both disciplines working together</strong></h3><ul><li><strong>User prompt: </strong>“I enjoy science and fantasy fiction like The Lord of the Rings or Foundation. Find books that match these preferences.”</li></ul><p>The agent executed targeted searches, retrieving relevant titles: <em>The Return of the King</em>, <em>Dune: House Corrino</em>, <em>Far Horizons</em> (a collection featuring <em>Foundation</em> and <em>Dune</em> universe stories).</p><ul><li><strong>Search query: </strong>&quot;science fiction and fantasy books similar to The Lord of the Rings or Foundation&quot;<br/></li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/19d2415c3f00374317f1116049d03656fcbc0024-944x1118.png" style="width: 100%;" /><h3><strong>Agent reasoning</strong></h3><p>The agent demonstrated context engineering through a smart tool usage and focused retrieval:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b74faabcfcfef4b995d056b029637f1851db5132-845x532.jpg" style="width: 100%;" /><p>The agent used <code>platform.core.search</code> to query the <code>books-dataset</code> index with a targeted search: “science fiction fantasy books similar to The Lord of the Rings Foundation”. From 103,063 documents, it retrieved only the most relevant matches.</p><h3><strong>Why it worked</strong></h3><ul><li><strong>Prompt engineering: </strong>Clear genre specification and concrete examples (<em>Lord of the Rings</em>, <em>Foundation</em>) eliminated ambiguity.</li><li><strong>Context engineering: </strong>Focused retrieval bringing only relevant books, maintaining a manageable context window despite the dataset having 103,063 entries.</li></ul><p>The agent used the same tools in all three cases, but input quality determined how effectively those tools retrieved relevant context.</p><h2>Conclusion</h2><p>Prompt engineering and context engineering are distinct but complementary disciplines. What started as a general practice of prompting is splitting into specialized fields requiring different expertise, much like the UI/UX split in web development.</p><p>For straightforward question-answering, prompt engineering skills may be sufficient. But as systems grow more complex, adding retrieval, tools, and multistep reasoning, context engineering becomes the dominant challenge. Teams building production AI systems need both skill sets, and increasingly, they need practitioners who understand how the two disciplines interact.</p><p>To dive deeper into context engineering strategies for AI agents, including hybrid retrieval, semantic chunking, and agentic search patterns, see <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>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Chris Hegarty, Matthew Alp, Nik Everett]]></dc:creator>
            <pubDate>Mon, 19 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/21036e031070f14faccb2b53b22723de2750c391-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bd418778f0c6144f8f5f18419f6220ac0c935c7a-903x407.png" style="width: 100%;" /><p>In practice, this means:</p><ul><li>Fewer branches.</li><li>Shorter probe chains.</li><li>Fewer loads from key and value memory.</li><li>Much better utilization of the CPU’s execution units.</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><pre><code>; 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 <handle_match></code></pre><p>Each instruction has a clear role in the insertion process:</p><ul><li><code>vmovdqu</code>: Loads 16 consecutive control bytes into the 128-bit <code>xmm0</code> register.</li><li><code>vpbroadcastb</code>: Replicates the 7-bit fingerprint of the new key across all lanes of the <code>xmm1</code> register.</li><li><code>vpcmpeqb</code>: Compares each control byte against the broadcasted fingerprint, producing a mask of potential matches.</li><li><code>kmovq</code> + <code>test</code>: Moves the mask to a general-purposes register and quickly checks whether a match exists.</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&#x27;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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/24900afc47ab07b0e9933f6117b99d0f4613f794-962x599.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6e49b7609fba83e33692cb9834552b6ca7e42a83-998x499.png" style="width: 100%;" /><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>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Someshwaran Mohankumar]]></dc:creator>
            <pubDate>Fri, 16 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/80907fd072e72d6ec902470b449c9f337957a0d7-1280x720.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In 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>The difference between short- and long-term memory.</li><li>How they relate to retrieval-augmented generation (RAG) techniques with vector databases, like Elasticsearch, and why careful memory management is necessary.</li><li>The risks of neglecting memory, including context overflow and context poisoning.</li><li>Best practices, like context pruning, summarizing, and retrieving only what’s relevant, to keep an agent’s memory both useful and safe.</li><li>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.</li></ul><h2>Short-term versus long-term memory in AI agents</h2><p><strong><em>Short-term memory</em></strong> 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4fbcc6f68055b2bccefc4176297a4ca50056dc0d-764x498.png" style="width: 100%;" /><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 <strong><em>session-scoped</em></strong>; 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 <strong><a href="https://help.openai.com/en/articles/8914046-temporary-chat-faq">temporary chat</a> </strong>available in ChatGPT.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/150bdf209cda5ed20b59cddf34e624ad1a8016aa-1100x577.png" style="width: 100%;" /><p><strong><em>Long-term memory</em></strong>, 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/46635aa11ceff89b8d6a26ac3e22da52407d82f3-1600x900.png" style="width: 100%;" /><p><strong><em>How do we give an AI agent a useful long-term memory in practice?</em></strong></p><p>One prominent approach for long-term memory is <strong><em>semantic memory</em></strong>, 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/41ccbc7b5171e8170ac300139a963c0708816ba6-1600x900.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e6897356c9f0940e35a63d005e9cd20bc33e5dd7-1600x931.png" style="width: 100%;" /><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&#x27;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><pre><code># 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)</code></pre><p>Here’s how it stores <strong>checkpoints</strong>:</p><pre><code>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</code></pre><p>For long-term memory, here&#x27;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><pre><code>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) -> 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']) > 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)}"</code></pre><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><strong>Context poisoning</strong></li><li><strong>Context distraction</strong></li><li><strong>Context confusion</strong></li><li><strong>Context clash</strong></li><li><strong>Context leakage and knowledge conflicts</strong></li><li><strong>Hallucinations and misinformation</strong></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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3f2facf5aad67613ad557422e09ec23a66adc0ed-1600x1388.png" style="width: 100%;" /><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>Check the context constantly: Monitor the conversation or retrieved text for anything suspicious or harmful, not just the starting prompt.</li><li>Use trusted sources: Score or label documents based on credibility so the system prefers reliable information and ignores low scored data.</li><li>Spot unusual data: Use tools that detect odd, out-of-place, or manipulated content, and remove it before the model uses it.</li><li>Filter inputs and outputs: Add guardrails so harmful or misleading text can’t easily enter the system or be repeated by the model.</li><li>Keep the model updated with clean data: Regularly refresh the system with verified information to counter any bad data that slipped through.</li><li>Human-in-the-loop: Have people review important outputs or compare them against known, trustworthy sources.</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 guidelines<em> </em>or 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e08e5b6d2e8ec7e3511d455985eed3d7fa6241e0-1352x636.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/500d7a80dc8db1923f9b5ca84728eed64fa296f7-1316x580.png" style="width: 100%;" /><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>,<strong> </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 <strong><em>cross-context leak</em></strong> 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8f0ccf0b2f7bd6715c14aceee2deffb213d50bd9-1600x936.png" style="width: 100%;" /><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><blockquote><em>In multi-tenant environments where multiple classes of users or applications share the same vector database, there&#x27;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></blockquote><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>Use targeted retrieval, not bulk loading: Retrieve only the most relevant chunks instead of pushing entire documents or full conversation histories into the prompt.</li><li>Treat RAG as just-in-time memory recall: Fetch context only when it’s needed, rather than carrying everything forward across turns.</li><li>Prefer relevance-aware retrieval strategies: Approaches like top-k semantic search, Reciprocal Rank Fusion, or tool loadout filtering help reduce noise and improve grounding.</li><li>Larger context windows don’t remove the need for RAG: Two highly relevant paragraphs are almost always more effective than 20 loosely related pages.</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><pre><code># Context pruning with Provence
def prune_with_provence(query: str, context: str, threshold: Optional[float] = None) -> 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 > 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</code></pre><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.<br/></p><pre><code># Context summarization
def summarize_context(query: str, context: str) -> 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 > 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</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f2de68c3339883d7658e633af3948f29f427e6cf-1600x900.png" style="width: 100%;" /><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>
        </item>
        <item>
            <title><![CDATA[Higher throughput and lower latency: Elastic Cloud Serverless on AWS gets a significant performance boost]]></title>
            <description><![CDATA[We've upgraded the AWS infrastructure for Elasticsearch Serverless to newer, faster hardware. Learn how this massive performance boost delivers faster queries, better scaling, and lower costs.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-serverless-aws-performance-boost</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-serverless-aws-performance-boost</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Pete Galeotti, Yuvraj Gupta, Rachel Forshee]]></dc:creator>
            <pubDate>Wed, 14 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3aa0b481994d2445ba979d3c79fff64c5ee6676a-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elastic Cloud Serverless is already the definitive solution for developers who want to build efficient search and AI applications without the operational burden of managing infrastructure. Now, we&#x27;re taking the performance of your serverless projects to a whole new level.</p><p>We&#x27;ve completed a major infrastructure upgrade for all <a href="https://www.elastic.co/cloud/serverless">Elastic Cloud Serverless</a> projects running on AWS, migrating to newer, faster hardware. This change has been rolled out to every serverless project automatically. It delivers <strong>higher throughput and lower latency</strong> for Elasticsearch, Elastic Observability, and Elastic Security serverless projects on AWS.</p><h2><strong>Key performance benefits for developers</strong></h2><p>The new AWS hardware infrastructure underpins everything you do with Elastic Cloud Serverless, translating to tangible benefits for your applications&#x27; speed and responsiveness.</p><h3><strong>Reduced query latency… increased throughput</strong></h3><p>The improved hardware dramatically enhances the speed of compute resources, which means your search queries are processed faster than ever.</p><ul><li><strong>Search and vector search:</strong> Whether you&#x27;re running traditional full-text queries or using cutting-edge vector search for your <a href="https://www.elastic.co/generative-ai">generative AI and retrieval-augmented generation (RAG) applications</a>, you&#x27;ll see a marked decrease in latency. Internal benchmarking showed a 35% average decrease in search latency.</li><li><strong>Faster indexing:</strong> Data ingestion rates are optimized, allowing you to index massive data volumes and complex documents with increased throughput. This is crucial for applications that require near–real-time data visibility. Internal benchmarking showed a 26% average increase in indexing throughput.</li></ul><h3><strong>Consistent performance under load</strong></h3><p>Elastic Cloud Serverless is designed to autoscale dynamically in real time to meet demand, minimizing latency, regardless of your workload. With this hardware upgrade, that scaling is now more performant and responsive.</p><ul><li><strong>Handling spikes with ease:</strong> Whether you&#x27;re facing a sudden surge in user traffic or a massive batch data ingest, the new infrastructure ensures that your search and indexing resources scale up more efficiently to maintain consistently low latency.</li><li><strong>Optimized compute-storage decoupling:</strong> The serverless architecture separates compute and storage, which allows workloads to scale independently for optimal performance and cost efficiency. The faster hardware enhances the compute layer, maximizing the efficiency of this decoupled design.</li></ul><h2><strong>Under the hood: Internal benchmarking results</strong></h2><p>To quantify the impact of our AWS infrastructure upgrade, the Elastic engineering team conducted comprehensive internal benchmarking against a range of serverless workloads. These workloads provided empirical evidence of performance improvements that you can expect across your applications, regardless of your use case.</p><h3><strong>The benchmarking approach</strong></h3><p>We focused our testing on the key metrics that directly affect the developer experience and application responsiveness: response time (that is, latency) and throughput on search and on indexing operations.</p><ul><li><strong>Workloads tested:</strong> The tests included high-concurrency search operations typical of user-facing applications, complex vector search queries, and high-volume data ingestion/indexing for observability and security use cases. In particular, our testing methodology used <a href="https://github.com/elastic/rally-tracks/tree/master">publicly</a> <a href="https://github.com/elastic/rally-tracks/tree/master">available datasets for Rally</a>, Elastic’s benchmarking tool.<ul><li><a href="https://github.com/elastic/rally-tracks/tree/3bedd51/wikipedia"><code>wikipedia</code></a>: A dataset derived from a snapshot of Wikipedia’s text contents, to measure general-purpose text search performance.</li><li><a href="https://github.com/elastic/rally-tracks/tree/3bedd51/msmarco-passage-ranking"><code>MSMARCO-Passage-Ranking</code></a>: A dataset derived from Microsoft’s Machine Reading Comprehension (MS MARCO), to measure search performance on sparse vector fields.</li><li><a href="https://github.com/elastic/rally-tracks/tree/3bedd51/openai_vector"><code>OpenAI_Vector</code></a>: A dataset derived from BEIR’s NQ and enriched with embeddings generated by OpenAI’s <code>text-embedding-ada-002</code> model, to measure search performance on dense vector fields.</li></ul></li><li><strong>Measurement:</strong> We compared performance on the old and new infrastructure, measuring latency at the 99th percentile (P99) to capture the worst-case, tail-latency performance and operations per second. Each track was run five times for each hardware profile to ensure consistency in the results.</li><li><strong>The goal:</strong> Our aim was to validate the infrastructure&#x27;s ability to deliver consistently <strong>faster and more predictable performance</strong> across the board, even during periods of rapid autoscaling.</li></ul><h3><strong>Performance data summary</strong></h3><p>The results confirm significant gains in efficiency and speed. These gains translate directly into lower response times for your users and lower operational costs as a result of the ability to complete the same amount of work with fewer compute resources.</p><p>The following tables detail the quantitative improvements. Higher values are better for throughput; lower values are better for latency.</p><p><strong>Searching benchmark results:</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Benchmark</td><td style="border: 1px solid #ccc; padding: 4px;">Comparison</td><td style="border: 1px solid #ccc; padding: 4px;">Old infra</td><td style="border: 1px solid #ccc; padding: 4px;">New infra</td><td style="border: 1px solid #ccc; padding: 4px;">Differential</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`wikipedia` (plain text)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation throughput (ops/s)</td><td style="border: 1px solid #ccc; padding: 4px;">729</td><td style="border: 1px solid #ccc; padding: 4px;">1107</td><td style="border: 1px solid #ccc; padding: 4px;">+52%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`wikipedia` (plain text)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation latency (p99, ms)</td><td style="border: 1px solid #ccc; padding: 4px;">56</td><td style="border: 1px solid #ccc; padding: 4px;">35</td><td style="border: 1px solid #ccc; padding: 4px;">-37%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`MSMARCO-Passage-Ranking` (sparse vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation throughput (ops/s)</td><td style="border: 1px solid #ccc; padding: 4px;">22</td><td style="border: 1px solid #ccc; padding: 4px;">31</td><td style="border: 1px solid #ccc; padding: 4px;">+40%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`MSMARCO-Passage-Ranking` (sparse vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation latency (p99, ms)</td><td style="border: 1px solid #ccc; padding: 4px;">108</td><td style="border: 1px solid #ccc; padding: 4px;">67</td><td style="border: 1px solid #ccc; padding: 4px;">-38%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`OpenAI_Vector` (dense vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation throughput (ops/s)</td><td style="border: 1px solid #ccc; padding: 4px;">475</td><td style="border: 1px solid #ccc; padding: 4px;">624</td><td style="border: 1px solid #ccc; padding: 4px;">+31%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`OpenAI_Vector` (dense vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation latency (p99, ms)</td><td style="border: 1px solid #ccc; padding: 4px;">35</td><td style="border: 1px solid #ccc; padding: 4px;">22</td><td style="border: 1px solid #ccc; padding: 4px;">-37%</td>
                    </tr>
              </tbody>
            </table>
          <p><strong>Indexing benchmark results:</strong></p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Benchmark</td><td style="border: 1px solid #ccc; padding: 4px;">Comparison</td><td style="border: 1px solid #ccc; padding: 4px;">Old infra</td><td style="border: 1px solid #ccc; padding: 4px;">New infra</td><td style="border: 1px solid #ccc; padding: 4px;">Differential</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`wikipedia` (plain text)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation throughput (ops/s)</td><td style="border: 1px solid #ccc; padding: 4px;">2845</td><td style="border: 1px solid #ccc; padding: 4px;">3220</td><td style="border: 1px solid #ccc; padding: 4px;">+13%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`wikipedia` (plain text)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation latency (p99, ms)</td><td style="border: 1px solid #ccc; padding: 4px;">1769</td><td style="border: 1px solid #ccc; padding: 4px;">1120</td><td style="border: 1px solid #ccc; padding: 4px;">-37%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`MSMARCO-Passage-Ranking` (sparse vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation throughput (ops/s)</td><td style="border: 1px solid #ccc; padding: 4px;">7087</td><td style="border: 1px solid #ccc; padding: 4px;">8900</td><td style="border: 1px solid #ccc; padding: 4px;">+26%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`MSMARCO-Passage-Ranking` (sparse vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation latency (p99, ms)</td><td style="border: 1px solid #ccc; padding: 4px;">824</td><td style="border: 1px solid #ccc; padding: 4px;">677</td><td style="border: 1px solid #ccc; padding: 4px;">-18%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`OpenAI_Vector` (dense vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation throughput (ops/s)</td><td style="border: 1px solid #ccc; padding: 4px;">2972</td><td style="border: 1px solid #ccc; padding: 4px;">3187</td><td style="border: 1px solid #ccc; padding: 4px;">+7%</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">`OpenAI_Vector` (dense vectors)</td><td style="border: 1px solid #ccc; padding: 4px;">Search operation latency (p99, ms)</td><td style="border: 1px solid #ccc; padding: 4px;">2946</td><td style="border: 1px solid #ccc; padding: 4px;">2944</td><td style="border: 1px solid #ccc; padding: 4px;">0%</td>
                    </tr>
              </tbody>
            </table>
          <h2><strong>The added bonus: Cost reduction</strong></h2><p>While our focus is on delivering low-latency performance, the efficiency of the new hardware also has a direct, positive impact on costs for Elasticsearch projects.</p><p><a href="https://www.elastic.co/pricing/serverless-search">Elasticsearch Serverless pricing</a> is usage-based, meaning that you only pay for the ingest and search resources you consume. Because the newer, faster hardware is more efficient, your workloads will often complete tasks using fewer resources, leading to an inherent cost reduction for most projects. You get a premium performance boost without the premium price tag—the definition of optimized efficiency.</p><h2><strong>What does this mean for you, the developer?</strong></h2><p>This infrastructure upgrade is entirely managed by Elastic, so you don&#x27;t need to lift a finger—no migrations and no configuration changes. The improvement is immediate and automatic across all your AWS-based serverless projects.</p><p>This upgrade empowers you to:</p><ul><li><strong>Build faster applications:</strong> Focus on feature velocity, knowing that your underlying search platform is delivering the speed your users demand.</li><li><strong>Innovate with confidence:</strong> Deploy new search, observability, and security features—including complex AI capabilities, like vector search and relevance ranking—with the assurance that the platform can handle the load at peak performance.</li><li><strong>Simplify your stack:</strong> Use a fully managed service that handles infrastructure management, capacity planning, and scaling, so you can focus on your code and data.<br/></li></ul>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sean Handley, Ranjana Devaji, Brendan Jugan]]></dc:creator>
            <pubDate>Mon, 12 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9bb1c96c697d8d48b764bee487a73a6cae130d0d-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Today, we 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><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.</li><li><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.</li><li><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.</li><li><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.</li></ul><h2><strong>Get started</strong></h2><pre><code>POST _inference/text_embedding/.jina-embeddings-v3
{
  "input": ["Rocky Mountain National Park"],
  "input_type": "ingest"
}</code></pre><p>The response:</p><pre><code>{
  "text_embedding": [
    {
      "embedding": [
        -0.06678891,
        -0.0073341704,
        0.011903269,
        -0.041797,
      ...
      ]
    }
  ]
}</code></pre><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 <code><a href="https://jina.ai/models/jina-reranker-v2-base-multilingual/">jina-reranker-v2-base-multilingual</a> </code>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>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Ioana Tagirta, Aurélien Foucret, Carlos Delgado]]></dc:creator>
            <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In 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&#x27;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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" style="width: 100%;" /><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><pre><code>FROM books METADATA _score
| WHERE KNN(description_vector, ?query_vector)
| SORT _score DESC
| LIMIT 100</code></pre><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><pre><code>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</code></pre><p>Let&#x27;s decompose the query to better understand the execution model and first look at the output of the <code>FORK</code> command:</p><pre><code>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)</code></pre><p>The<code> FORK</code> commands outputs the results from both branches and adds a <code>_fork</code> discriminator column:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">_id</td><td style="border: 1px solid #ccc; padding: 4px;">title</td><td style="border: 1px solid #ccc; padding: 4px;">_score</td><td style="border: 1px solid #ccc; padding: 4px;">_fork</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">0.88</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">0.88</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">0.86</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">0.84</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">0.78</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4144</td><td style="border: 1px solid #ccc; padding: 4px;">The Children of Húrin</td><td style="border: 1px solid #ccc; padding: 4px;">0.79</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">4.55</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">4.25</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">4.11</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">3.8</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">4.1</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
              </tbody>
            </table>
          <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>For each row, <code>FUSE</code> assigns a new relevance score, depending on the hybrid search algorithm that is being used.</li><li>Rows that represent the same document are merged together, and a new score is computed.</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><pre><code>score(doc) = 1 / (rank_constant + rank(doc))</code></pre><p>Where the <code>rank_constant</code> takes a default value of 60 and <code>rank(doc)</code><strong> </strong>represents the position of the document in the result set.</p><p>In the first phase, our results become:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">_id</td><td style="border: 1px solid #ccc; padding: 4px;">title</td><td style="border: 1px solid #ccc; padding: 4px;">_score</td><td style="border: 1px solid #ccc; padding: 4px;">_fork</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 1) = 0.01639</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 2) = 0.01613</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 3) = 0.01587</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 4) = 0.01563</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;"> The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 5) = 0.01538</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4144</td><td style="border: 1px solid #ccc; padding: 4px;">The Children of Húrin</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 6) = 0.01515</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 1) = 0.01639</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 2) = 0.01613</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 3) = 0.01587</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 4) = 0.01563</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">1 / (60 + 5) = 0.01538</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
              </tbody>
            </table>
          <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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">_id</td><td style="border: 1px solid #ccc; padding: 4px;">title</td><td style="border: 1px solid #ccc; padding: 4px;">_score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">0.01639 + 0.01639 = 0.03279</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">0.01613 + 0.01613 = 0.03226</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">0.01587 + 0.01563 = 0.0315</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">0.01538 + 0.01587 = 0.03125</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">0.01563 + 0.01538 = 0.03101</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4144</td><td style="border: 1px solid #ccc; padding: 4px;">The Children of Húrin</td><td style="border: 1px solid #ccc; padding: 4px;">0.01515</td>
                    </tr>
              </tbody>
            </table>
          <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><pre><code>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</code></pre><p>Let&#x27;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><pre><code>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)</code></pre><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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">_id</td><td style="border: 1px solid #ccc; padding: 4px;">title</td><td style="border: 1px solid #ccc; padding: 4px;">_score</td><td style="border: 1px solid #ccc; padding: 4px;">_fork</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">0.88</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">0.88</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">0.86</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">0.84</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">0.78</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4144</td><td style="border: 1px solid #ccc; padding: 4px;">The Children of Húrin</td><td style="border: 1px solid #ccc; padding: 4px;">0.79</td><td style="border: 1px solid #ccc; padding: 4px;">fork1</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">4.55</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">4.25</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">4.11</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">3.8</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">4.1</td><td style="border: 1px solid #ccc; padding: 4px;">fork2</td>
                    </tr>
              </tbody>
            </table>
          <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><pre><code>_score = 0.7 *_score1 + 0.3 * _score2</code></pre><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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">_id</td><td style="border: 1px solid #ccc; padding: 4px;">title</td><td style="border: 1px solid #ccc; padding: 4px;">_score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">0.7 * 0.88 + 0.3 * 4.55 = 1.981</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">0.7 * 0.88 + 0.3 * 4.25 = 1.891</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">0.7 * 0.84 + 0.3 * 4.1 = 1.818</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">0.7 * 0.78 + 0.3 * 4.11 = 1.779</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">0.7 * 0.86 + 0.3 * 3.8 = 1.742</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4144</td><td style="border: 1px solid #ccc; padding: 4px;">The Children of Húrin</td><td style="border: 1px solid #ccc; padding: 4px;">0.7 * 0.79 + 0.3 * 0 = 0.553</td>
                    </tr>
              </tbody>
            </table>
          <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><pre><code>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</code></pre><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><pre><code>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</code></pre><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><pre><code>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</code></pre><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.<br/>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><pre><code>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" } </code></pre><p>Each row now contains a summary:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">_id</td><td style="border: 1px solid #ccc; padding: 4px;">title</td><td style="border: 1px solid #ccc; padding: 4px;">_score</td><td style="border: 1px solid #ccc; padding: 4px;">summary</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4001</td><td style="border: 1px solid #ccc; padding: 4px;">The Hobbit</td><td style="border: 1px solid #ccc; padding: 4px;">0.03279</td><td style="border: 1px solid #ccc; padding: 4px;">Bilbo helps dwarves reclaim Erebor from the dragon Smaug.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3999</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship of the Ring</td><td style="border: 1px solid #ccc; padding: 4px;">0.03226</td><td style="border: 1px solid #ccc; padding: 4px;">Frodo begins the quest to destroy the One Ring.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4005</td><td style="border: 1px solid #ccc; padding: 4px;">The Two Towers</td><td style="border: 1px solid #ccc; padding: 4px;">0.0315</td><td style="border: 1px solid #ccc; padding: 4px;">The Fellowship splits; war comes to Rohan; Frodo nears Mordor.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4123</td><td style="border: 1px solid #ccc; padding: 4px;">The Silmarillion</td><td style="border: 1px solid #ccc; padding: 4px;">0.03125</td><td style="border: 1px solid #ccc; padding: 4px;">Ancient myths and history of Middle-earth's First Age.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4006</td><td style="border: 1px solid #ccc; padding: 4px;">The Return of the King</td><td style="border: 1px solid #ccc; padding: 4px;">0.3101</td><td style="border: 1px solid #ccc; padding: 4px;">Sauron is defeated and Aragorn is crowned King.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4144</td><td style="border: 1px solid #ccc; padding: 4px;">The Children of Húrin</td><td style="border: 1px solid #ccc; padding: 4px;">0.01515</td><td style="border: 1px solid #ccc; padding: 4px;">The tragic tale of Túrin Turambar's cursed life.</td>
                    </tr>
              </tbody>
            </table>
          <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><pre><code>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" }</code></pre><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><a href="https://www.elastic.co/docs/solutions/search/esql-for-search">ES|QL for search</a></li><li><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-search-tutorial">ES|QL for search tutorial</a></li><li><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">Semantic_text field type</a></li><li><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</li><li>ES|QL search functions</li></ul>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Wed, 07 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/33a57e3f61f9095c99b6d1499175a6edb0d5dfc5-4720x2560.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we 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>Elasticsearch 9.2</li><li>Python 3.1x</li><li><a href="https://platform.openai.com/api-keys">OpenAI API Key</a></li><li><a href="https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys">Elasticsearch API Key</a></li><li><a href="https://serpapi.com/users/sign_up?plan=free">Serper API Key</a></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><pre><code>{KIBANA_URL}/api/agent_builder/mcp
# Or if you are using a custom Kibana space:
{KIBANA_URL}/s/{SPACE_NAME}/api/agent_builder/mcp</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c5bbba345340bfe5571b17d53b5896d4a3235eac-4720x2560.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/97a0075ae637d64140ec7ff0d167297723675632-3000x1176.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9bb396bbd4c3baa3be26f9d9e386f4d5405132ab-2180x2560.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1b46b29a8cde4645ebaec1f747be4f6888dd8d39-1600x906.png" style="width: 100%;" /><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><pre><code># 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</code></pre><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 <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><pre><code>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}")</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e5b3282dea07987613d8e8d35c372ca68820e44-1600x381.png" style="width: 100%;" /><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><pre><code>{
    "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"
}</code></pre><p>For this type of document, we will use the following index mappings:</p><pre><code>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")</code></pre><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><pre><code>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)}")</code></pre><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><pre><code>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']</code></pre><p>Next, we create an agent that selects the appropriate tool based on the user input:</p><pre><code>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.""",
)</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/729295115427ec981a594e873245fa541dd977aa-332x531.png" style="width: 100%;" /><p>We need to define the application state:</p><pre><code>class AgentState(TypedDict):
    query: str
    agent_builder_response: dict
    internet_results: list
    final_response: str
    needs_internet_search: bool</code></pre><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><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.</li><li><strong>decide_internet_search:</strong> Analyzes the internal results and determines whether an external search is required.</li><li><strong>perform_internet_search: </strong>Runs an external search using the <a href="https://serper.dev/">Serper</a> API when needed.</li><li><strong>generate_response:</strong> Correlates internal and external findings and produces a final, actionable cybersecurity analysis for the user.</li></ul><p>With the workflow defined, we can now send a query:</p><pre><code>query = "We are using Node.js with Express 4.17 for our API gateway. Are there known prototype pollution or remote code execution vulnerabilities?"</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b18a31e42bcd8f4d86bb605f85d4ff77135b0855-1084x517.png" style="width: 100%;" /><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>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Nastia Havriushenko]]></dc:creator>
            <pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/965c58f19742361160593c38fcaa8b2f4b0d6cc5-3838x2159.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9108ec303339dd091faa3c363c7cf5c228155f49-3840x2160.png" style="width: 100%;" /><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"><span style="text-decoration:underline">Streams</span></a>!</p><h2>Dataset description</h2><p>We chose a <strong><a href="https://github.com/logpai/loghub">Loghub</a> </strong>collection of logs for PoC purposes. For our investigation, we selected representative samples from the following key areas:</p><ul><li>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.</li><li>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.</li><li>Operating systems: We included logs from Linux and Windows. These datasets represent the common, semi-structured system-level events that operations teams encounter daily.</li><li>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.</li><li>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.</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><pre><code>[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</code></pre><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 &quot;Goldilocks&quot; 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&#x27;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>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.</li><li>Too heterogeneous (confusion): Conversely, if the sample contains significant formatting variance—or worse, &quot;trash logs&quot; 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.</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&#x27;s output often deteriorated, becoming &quot;messy&quot; 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>Service naming variations: The model proposes different names for the same entity (e.g., labeling the source as &quot;Spark,&quot; &quot;Apache Spark,&quot; and &quot;Spark Log Analytics&quot; in different runs).</li><li>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>.</li><li>Resolution variance: The resolution of the field extraction varied depending on how similar the input logs were to one another.</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 &quot;fingerprint&quot; that allows us to group related logs.</p><p>The mapping logic is simple to ensure speed and consistency:</p><ol><li>Digit abstraction: Any sequence of digits (0-9) is replaced by a single ‘0’.</li><li>Text abstraction: Any sequence of alphabetical characters with whitespace is replaced by a single ‘a’.</li><li>Whitespace normalization: All sequences of whitespace (spaces, tabs, newlines) are collapsed into a single space.</li><li>Symbol preservation: Punctuation and special characters (e.g., :, [, ], /) are preserved, as they are often the strongest indicators of log structure.</li></ol><p>We introduce the log mapping approach. The basic mapping patterns include the following:</p><ul><li>Digits 0-9 of any length -&gt; to ‘0.’</li><li>Text (alphabetical characters with spaces) of any length -&gt; to ‘a’.</li><li>White spaces, tabs, and new lines -&gt; to a single space.</li></ul><p>Let&#x27;s look at an example of how this mapping allows us to transform the logs.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/78fa2887486eb9417804354ee3bf2a4fdb0f6383-846x252.png" style="width: 100%;" /><p>As a result, we obtain the following log masks:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ec439a3d3a25002498b97defcff733ea5ebc6b55-826x94.png" style="width: 100%;" /><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><pre><code>FROM loghub |
EVAL pattern = REPLACE(REPLACE(REPLACE(REPLACE(raw_message, "[ \t\n]+", " "), "[A-Za-z]+", "a"), "[0-9]+", "0"), "a( a)+", "a") |
STATS total_count = COUNT(), ratio = COUNT() / 2000.0, datasources=VALUES(filename), example=TOP(raw_message, 3, "desc") BY SUBSTRING(pattern, 0, 15) |
SORT total_count DESC |
LIMIT 100</code></pre><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 &#x27;0&#x27;, text to &#x27;a&#x27;, 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b119498f124376c41d242a099bf9081fd6536be8-1600x394.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4dcfc0a5b7fa753497cc5def5ea3cd54449c0481-1600x719.png" style="width: 100%;" /><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>Versatility as a primitive </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>Flexibility</li></ul><p>LFF is easy to customize and extend to capture specific patterns, i.e. hexadecimal numbers and IP addresses.</p><ul><li>Deterministic stability</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>Performance and mMemory</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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b3f46ea471760b406a32fc7d4bc74cc03faaced2-3840x1660.png" style="width: 100%;" /><p>1. Two-stage hierarchical clustering</p><ul><li>Subclasses (exact match): Logs are aggregated by identical fingerprints. Every log in one subclass shares the exact same format structure.</li><li>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.</li><li>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.</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>We select representative logs from <em>each</em> valid subclass within the broader metaclass.</li><li>To manage an edge case of too numerous subclasses, we apply random down-sampling to fit the target window size.</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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/49b2b6a1401dd1f33951da68e5a3fac37d0b5aaa-1600x1506.png" style="width: 100%;" /><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&#x27;s high fidelity in source attribution, with minimal scattering.</p><h2>Our performance benchmarks insights:</h2><ul><li><strong>Optimal baseline:</strong> a context window of <strong>30–40 log samples</strong> per category proved to be the &quot;sweet spot,&quot; consistently producing robust parsing with both Regex and Grok patterns.</li><li><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.</li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[An introduction to Jina models, their functionality, and uses in Elasticsearch]]></title>
            <description><![CDATA[Explore Jina multimodal embeddings, Reranker v3, and semantic embedding models, and how to use them natively in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/jina-models-elasticsearch-guide</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/jina-models-elasticsearch-guide</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Scott Martens]]></dc:creator>
            <pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/407b4c862b51ebdfc7f26db4e25950a65caf1673-656x442.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Jina by Elastic provides search foundation models for applications and business process automation. These models provide core functionality for bringing AI to Elasticsearch applications and innovative AI projects.</p><p>Jina models fall into three broad categories designed to support information processing, organization, and retrieval:</p><ul><li>Semantic embedding models</li><li>Reranking models</li><li>Small generative language models</li></ul><h2>Semantic embedding models</h2><p>The idea behind semantic embeddings is that an AI model can learn to represent aspects of the meaning of its inputs in terms of the geometry of high-dimensional spaces.</p><p>You can think of a semantic embedding as a point (technically a <em>vector</em>) in a high-dimensional space. An embedding model is a neural network that takes some digital data as input (potentially anything, but most often a text or an image) and outputs the location of a corresponding high-dimensional point as a set of numerical coordinates. If the model is good at its job, the distance between two semantic embeddings is proportionate to how much their corresponding digital objects mean the same things.</p><p>To understand how this is important for search applications, imagine one embedding for the word “dog” and one for the word “cat” as points in space:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/802cf9bbcb82180d3fc91009f9f62027eee8f031-615x615.png" style="width: 100%;" /><p>A good embedding model should generate an embedding for the word “feline” that’s much closer to “cat” than to “dog,” and “canine” should have an embedding much closer to “dog” than to “cat,” because those words mean almost the same thing:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bce49daf9a31b8fb7ce1c6ef7ae4e8117a4e8b33-615x615.png" style="width: 100%;" /><p>If a model is multilingual, we would expect the same thing for translations of “cat” and “dog”:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ce4d030385324526cbd7539140e0e634d939371c-615x615.png" style="width: 100%;" /><p>Embedding models translate similarity or dissimilarity in meaning between things into spatial relationships between embeddings. The pictures above have just two dimensions so you can see them on a screen, but embedding models produce vectors with dozens to thousands of dimensions. This makes it possible for them to encode subtleties of meaning for whole texts, assigning a point in a space that has hundreds or thousands of dimensions for documents of thousands of words or more.</p><h2>Multimodal embeddings</h2><p>Multimodal models extend the concept of semantic embeddings to things other than texts, especially to images. We would expect an embedding for a picture to be near to an embedding of a faithful description of the picture:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1ac6aef5b1423e5fe4853e8a547a74e66b0885c2-615x615.png" style="width: 100%;" /><p>Semantic embeddings have a lot of uses. Among other things, you can use them to build efficient classifiers, to do data clustering, and to accomplish a variety of tasks, like data deduplication and investigating data diversity, both of which are important for big data applications that involve working with too much data to manage by hand.</p><p>The biggest direct use of embeddings is in information retrieval. Elasticsearch can store retrieval objects with embeddings as keys. Queries are converted into embedding vectors, and a search returns the stored objects whose keys are the nearest to the query embedding.</p><p>Where traditional <em>vector-based retrieval</em> (sometimes called <em>sparse vector retrieval</em>) uses vectors based on words or metadata in documents and queries, <em>embedding-based retrieval</em> (also known as <em>dense vector retrieval</em>) uses AI-assessed meanings rather than words. This makes them generally much more flexible and more accurate than traditional search methods.</p><h2>Matryoshka representation learning</h2><p>The number of dimensions an embedding has, and the precision of the numbers in it have significant performance impacts. Very high-dimensional spaces and extremely high-precision numbers can represent highly detailed and complex information, but demand larger AI models that are more expensive to train and to run. The vectors they generate require more storage space, and it takes more computing cycles to calculate the distances between them. Using semantic embedding models involves making important trade-offs between precision and resource consumption.</p><p>To maximize flexibility for users, Jina models are trained with a technique called <a href="https://arxiv.org/abs/2205.13147">Matryoshka Representation Learning</a>. This causes models to front-load the most important semantic distinctions into the first dimensions of the embedding vector so you can just cut off the higher dimensions and still get good performance.</p><p>In practice, this means that users of Jina models can choose how many dimensions they want their embeddings to have. Choosing fewer dimensions reduces precision, but the degradation in performance is minor. On most tasks, performance metrics for Jina models decline 1–2% every time you reduce the embedding size by 50%, down to about a 95% reduction in size.</p><h2>Asymmetric retrieval</h2><p>Semantic similarity is usually measured symmetrically. The value you get when comparing “cat” to “dog” is the same as the value you’d get comparing “dog” to “cat.” But when you use embeddings for information retrieval, they work better if you break the symmetry and encode queries differently from the way you encode retrieval objects.</p><p>This is because of the way we train embedding models. Training data contains instances of the same elements, like words, in many different contexts, and models learn semantics by comparing the contextual similarities and differences between elements.</p><p>So, for example, we might find that the word “animal” doesn’t appear in very many of the same contexts as “cat” or “dog,” and therefore the embedding for “animal” might not be particularly close to “cat” or “dog”:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9a33163405af6c71ee7f4ba8ebc86af39e295a69-615x615.png" style="width: 100%;" /><p>This makes it less likely that a query for “animal” will retrieve documents about cats and dogs — the opposite of our goal. So instead, we encode “animal” differently when it’s a query than when it’s a target for retrieval:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/363992d4f0affba7937c0c8a9f82c9a531fcd3ba-615x615.png" style="width: 100%;" /><p><em>Asymmetric retrieval</em> means using a different model for queries or specially training an embedding model to encode things one way when they’re stored for retrieval and to encode queries another way.</p><h2>Multivector embeddings</h2><p>Single embeddings are good for information retrieval because they fit the basic framework of an indexed database: We store objects for retrieval with a single embedding vector as their retrieval key. When users query the document store, their queries are translated into embedding vectors and the documents whose keys are closest to the query embedding (in the high-dimensional embedding space) are retrieved as candidate matches.</p><p>Multivector embeddings work a little differently. Instead of generating a fixed-length vector to represent a query and a whole stored object, they produce a sequence of embeddings representing smaller parts of them. The parts are typically tokens or words for texts and are image tiles for visual data. These embeddings reflect the meaning of the part in its context.</p><p>For example, consider these sentences:</p><ul><li>She had a heart of gold.</li><li>She had a change of heart.</li><li>She had a heart attack.</li></ul><p>Superficially, they look very similar, but a multivector model would likely generate very different embeddings for each instance of “heart,” representing how each means something else in the context of the whole sentence:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a33e60c8d8ee3d312bca8375ca2a8b0a0cd40ba9-615x615.png" style="width: 100%;" /><p>Comparing two objects via their multivector embeddings often involves measuring their chamfer distance: comparing each part of one multivector embedding to each part of another one and summing the minimum distances between them. Other systems, including the Jina Rerankers described below, input them to an AI model trained specifically to evaluate their similarity. Both approaches typically have higher precision than just comparing single-vector embeddings because multivector embeddings contain much more detailed information than single-vector ones.</p><p>However, multivector embeddings aren’t well-suited to indexing. They’re often used in reranking tasks, as described for the <code>jina-colbert-v2</code> model in the next section.</p><h2>Jina embedding models</h2><h3>Jina embeddings v4</h3><p><a href="https://jina.ai/news/jina-embeddings-v4-universal-embeddings-for-multimodal-multilingual-retrieval/"><strong>jina-embeddings-v4</strong></a> is a 3.8 billion (3.8x10⁹) parameter multilingual and multimodal embedding model that supports images and texts in a variety of widely used languages. It uses a novel architecture to take advantage of visual knowledge and language knowledge to improve performance on both tasks, enabling it to excel at image retrieval and especially at <a href="https://huggingface.co/tasks/visual-document-retrieval">visual document retrieval</a>. This means it handles images like charts, slides, maps, screenshots, page scans, and diagrams — common kinds of images, often with important embedded text, which fall outside the scope of computer vision models trained on pictures of real-world scenes.</p><p>We’ve optimized this model for several different tasks using compact <a href="https://huggingface.co/docs/peft/en/package_reference/lora">Low-Rank Adaptation (LoRA) adapters</a>. This allows us to train a single model to specialize at multiple tasks, without compromising performance on any of them, at a minimal added cost in memory or processing.</p><p>Main features include:</p><ul><li>State-of-the-art performance in visual document retrieval, along with multilingual text and regular image performance that surpasses significantly larger models.</li><li>Support for large input context size: 32,768 tokens is roughly equivalent to 80 pages of double-spaced English text, and 20 megapixels is equivalent to an image of 4,500 x 4,500 pixels.</li><li>User-selected embedding sizes, from a maximum of 2048 dimensions down to 128 dimensions. We found empirically that performance degrades dramatically below that threshold.</li><li>Support for both single embeddings and multivector embeddings. For texts, multivector output consists of one 128-dimensional embedding for each input token. For images, it produces one 128-dimensional embedding for each 28x28 pixel tile needed to cover the image.</li><li>Optimization for asymmetric retrieval via a pair of LoRA adapters trained specifically for the purpose.</li><li>A LoRA adapter optimized for semantic similarity calculation.</li><li>Special support for computer programming languages and IT frameworks, also via a LoRA adapter.</li></ul><p>We developed <code>jina-embeddings-v4</code> to serve as a general, multipurpose tool for a broad array of common search, natural language understanding, and AI analysis tasks. It’s a relatively small model given its capabilities but still takes significant resources to deploy and is best suited to use via a cloud API or in a high-volume environment.</p><h3>Jina embeddings v3</h3><p><a href="https://jina.ai/news/jina-embeddings-v3-a-frontier-multilingual-embedding-model/"><strong>jina-embeddings-v3</strong></a> is a compact, high-performance, multilingual, text-only embedding model with under 600 million parameters. It supports up to 8192 tokens of text input and outputs single-vector embeddings with user-chosen sizes from a default of 1024 dimensions down to 64.</p><p>We’ve trained <code>jina-embeddings-v3</code> for a variety of text tasks — not just information retrieval and semantic similarity but also classification tasks, like sentiment analysis and content moderation, as well as clustering tasks, like news aggregation and recommendation. Like <code>jina-embeddings-v4</code>, this model provides LoRA adapters specialized for the following categories of usage:</p><ul><li>Asymmetric retrieval</li><li>Semantic similarity</li><li>Classification</li><li>Clustering</li></ul><p><code>jina-embeddings-v3</code> is a much smaller model than <code>jina-embeddings-v4</code> with a significantly reduced input context size, but it costs less to operate. Nonetheless, it has very competitive performance, albeit only for texts, and is a better choice for many use cases.</p><h3>Jina code embeddings</h3><p>Jina’s specialized code embedding models — <a href="https://jina.ai/models/jina-code-embeddings-1.5b"><strong>jina-code-embeddings&nbsp; (0.5b and 1.5b)</strong></a> — support 15 programming schemes and frameworks, as well as English language texts relating to computing and information technology. They’re compact models with a half-billion (0.5x10⁹) and one-and-a-half-billion (1.5x10⁹) parameters, respectively. Both models support input context sizes of up to 32,768 tokens and let users select their output embedding sizes, from 896 down to 64 dimensions for the smaller model and 1536 down to 128 for the larger.</p><p>These models support asymmetric retrieval for five task-specific specializations, using <a href="https://arxiv.org/abs/2101.00190">prefix tuning</a> rather than LoRA adapters:</p><ul><li><strong>Code to code.</strong> Retrieve similar code across programming languages. This is used for code alignment, code deduplication, and support for porting and refactoring.</li><li><strong>Natural language to code.</strong> Retrieve code to match natural language queries, comments, descriptions, and documentation.</li><li><strong>Code to natural language. </strong>Match code to documentation or other natural language texts.</li><li><strong>Code-to-code completion.</strong> Suggest relevant code to complete or enhance existing code.</li><li><strong>Technical Q&amp;A.</strong> Identify natural language answers to questions about information technologies, ideally suited for technical support use cases.</li></ul><p>These models provide superior performance for tasks involving computer documentation and programming materials at a relatively small computational cost. They’re well suited to integration into development environments and code assistants.</p><h3>Jina ColBERT v2</h3><p><a href="https://jina.ai/models/jina-colbert-v2"><strong>jina-colbert-v2</strong></a> is a 560 million parameter multivector text-embedding model. It’s multilingual, trained using materials in 89 languages, and supports variable embedding sizes and asymmetric retrieval.</p><p>As previously noted, multivector embeddings are poorly suited to indexing but are very useful for increasing the precision of results of other search strategies. Using <code>jina-colbert-v2</code><strong>,</strong> you can calculate multivector embeddings in advance and then use them to rerank retrieval candidates at query time. This approach is less precise than using one of the reranking models in the next section but is much more efficient because it just involves comparing stored multivector embeddings instead of invoking the whole AI model for every query and candidate match. It’s ideally suited for use cases where the latency and computational overhead of using reranking models is too great or where the number of candidates to compare is too large for reranking models.</p><p>This model outputs a sequence of embeddings, one per input token, and users can select token embeddings of 128-, 96-, or 64-dimension embeddings. Candidate text matches are limited to 8,192 tokens. Queries are encoded asymmetrically, so users must specify whether a text is a query or candidate match and must limit queries to 32 tokens.</p><h3>Jina CLIP v2</h3><p><a href="https://jina.ai/news/jina-clip-v2-multilingual-multimodal-embeddings-for-text-and-images/"><strong>jina-clip-v2</strong></a> is a 900 million parameter multimodal embedding model, trained so that texts and images produce embeddings that are close together if the text describes the content of the image. Its primary use is for retrieving images based on textural queries, but it’s also a high-performance text-only model, reducing user costs because you don’t need separate models for text-to-text and text-to-image retrieval.</p><p>This model supports a text input context of 8,192 tokens, and images are scaled to 512x512 pixels before generating embeddings.</p><p>Contrastive language–image pretraining (CLIP) architectures are easy to train and operate and can produce very compact models, but they have some fundamental limitations. They can’t use knowledge from one medium to improve their performance in another. They can’t use from one medium to improve their performance in another. So, although it might know that the words “dog” and “cat” are closer to each other in meaning than either one is to “car,” it won’t necessarily know that a picture of a dog and a picture of a cat are more related than either one is to a picture of a car.</p><p>They also suffer from what is called the <em>modality gap</em>: An embedding of a text about dogs is likely to be closer to an embedding of a text about cats than to an embedding of a picture of dogs. Because of this limitation, we advise using CLIP as a text-to-image retrieval model or as a text-only model, but not mixing the two in a single query.</p><h2>Reranking models</h2><p>Reranking models take one or more candidate matches, along with a query as input to the model, and compare them directly, producing much higher precision matches.</p><p>In principle, you could use a reranker directly for information retrieval by comparing each query to each stored document, but this would be very computationally expensive and is impractical for any but the smallest collections. As a result, rerankers tend to be used to evaluate relatively short lists of candidate matches found by some other means, like embeddings-based search or other retrieval algorithms. Reranking models are ideally suited to hybrid and federated search schemes, where performing a search might mean that queries get sent to separate search systems with distinct data sets, each one returning different results. They work very well at merging diverse results into a single high-quality result.</p><p>Embeddings-based search can be a large commitment, involving reindexing all your stored data and changing user expectations about the results. Adding a reranker to an existing search scheme can add many of the benefits of AI without re-engineering your entire search solution.</p><h2>Jina reranker models</h2><h3>Jina Reranker m0</h3><p><a href="https://jina.ai/models/jina-reranker-m0/"><strong>jina-reranker-m0</strong></a> is a 2.4 billion (2.4x10⁹) parameter multimodal reranker that supports textual queries and candidate matches consisting of texts and/or images. It’s the leading model for visual document retrieval, making it an ideal solution for stores of PDF, scans of text, screenshots, and other computer-generated or modified imagery containing text or other semistructured information, as well as mixed data consisting of text documents and images.</p><p>This model takes a single query and a candidate match and returns a score. When the same query is used with different candidates, the scores are comparable and can be used to rank them. It supports a total input size of up to 10,240 tokens, including the query text and the candidate text or image. Every 28x28 pixel tile needed to cover an image counts as a token for calculating input size.</p><h3>Jina Reranker v3</h3><p><a href="https://jina.ai/models/jina-reranker-v3/"><strong>jina-reranker-v3</strong></a> is a 600 million parameter text reranker with state-of-the-art performance for models of comparable size. Unlike <code>jina-reranker-m0</code>, it takes a single query and a list of up to 64 candidate matches and returns the ranking order. It has an input context of 131,000 tokens, including the query and all text candidates.</p><h3>Jina Reranker v2</h3><p><a href="https://jina.ai/models/jina-reranker-v2"><strong>jina-reranker-v2-base-multilingual</strong></a> is a very compact general-purpose reranker with additional features designed to support function-calling and SQL querying. Weighing in at under 300 million parameters, it provides fast, efficient, and accurate multilingual text reranking with additional support for selecting SQL tables and external functions that match text queries, making it suitable for agentic use cases.</p><h2>Small generative language models</h2><p>Generative language models are models like OpenAI’s ChatGPT, Google Gemini, and Claude from Anthropic that take text or multimedia inputs and respond with text outputs. There’s no well-defined line that separates <em>large</em> language models (LLMs) from <em>small</em> language models (SLMs), but the practical problems of developing, operating, and using top-of-the-line LLMs are well-known. The best-known ones are not publicly distributed, so we can only estimate their size, but ChatGPT, Gemini, and Claude are expected to be in the 1–3 trillion (1–3x10¹²) parameter range.</p><p>Running these models, even if they’re openly available, is far beyond the scope of conventional hardware, requiring the most advanced chips arranged in vast parallel arrays. You can access LLMs via paid APIs, but this incurs significant costs, has a large latency, and is difficult to align with demands for data protection, digital sovereignty, and cloud repatriation. Additionally, costs related to training and customizing models of that size can be considerable.</p><p>Consequently, a great deal of research has gone into developing smaller models that might lack all the capabilities of the largest LLMs but can perform specific kinds of tasks just as well at a reduced cost. Enterprises generally deploy software to address specific problems, and AI software is no different, so SLM-based solutions are often preferable to LLM ones. They can typically run on commodity hardware, are faster and consume less energy to run, and are much easier to customize.</p><p>Jina’s SLM offerings are growing as we focus on how we can best bring AI into practical search solutions.</p><h2>Jina SLMs</h2><h3>ReaderLM v2</h3><p><a href="https://jina.ai/models/ReaderLM-v2"><strong>ReaderLM-v2</strong></a> is a generative language model that converts HTML into Markdown or into JSON, according to user-provided JSON schemas and natural language instructions.</p><p>Data preprocessing and normalization is an essential part of developing good search solutions for digital data, but real-world data, especially web-derived information, is often chaotic, and simple conversion strategies frequently prove to be very brittle. Instead, <code>ReaderLM-v2</code> offers an intelligent AI model solution that can understand the chaos of a DOM-tree dump of a web page and robustly identify useful elements.</p><p>At 1.5 billion (1.5x10⁹) parameters, it’s three orders of magnitude more compact than cutting-edge LLMs but performs on par with them at this one narrow task.</p><h3>Jina VLM</h3><p><a href="https://jina.ai/models/jina-vlm"><strong>jina-vlm</strong></a> is a 2.4 billion (2.4x10⁹) parameter generative language model trained to answer natural language questions about images. It has very strong support for visual document analysis, that is, answering questions about scans, screenshots, slides, diagrams, and similar non-natural image data.</p><p>For example:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/adfa1420d079ca4fd5582eef4349b1265b378e76-950x500.png" style="width: 100%;" /><p>It’s also very good at reading text in images:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ea3956e7ad86f8e171841cab2c28c8b3498da1d4-1002x500.png" style="width: 100%;" /><p>But where <code>jina-vlm</code> really excels is understanding the content of informational and man-made images:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f68606f9d2d99e2cd616d4ff81db3574dc4e26a5-1020x700.png" style="width: 100%;" /><p>Or:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/297e85e7e78f296388a02301e1e08fed70827423-1000x500.png" style="width: 100%;" /><p><code>jina-vlm</code> is well suited for automatic caption generation, product descriptions, image alt text, and accessibility applications for vision-impaired people. It also creates possibilities for retrieval‑augmented generation (RAG) systems to use visual information and for AI agents to process images without human assistance.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to build an agent knowledge base with LangChain and Elasticsearch]]></title>
            <description><![CDATA[Learn how to build an agent knowledge base and test its ability to query sources of information based on context, use WebSearch for out-of-scope queries, and refine recommendations based on user intention.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-knowledge-base-langchain-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-knowledge-base-langchain-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Han Xiang Choong]]></dc:creator>
            <pubDate>Wed, 31 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/525531c53146c9ce308b8f39273354fa54b2d31d-3840x2161.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/77d1d1c77310edcff019e3cef66ab04d96017116-2000x1257.png" style="width: 100%;" /><p>In industry use cases, there are two primary modes of interacting with large language models (LLMs). Direct querying, ie., conversing with an LLM on an ad-hoc basis, is useful for getting assistance on tasks like summarization, proofreading, information extraction, and non-domain-specific querying.</p><p>For specific business applications, such as in customer relationship management, maintenance of IT systems, and investigative work, to name only a few examples, direct LLM usage is insufficient. Private, enterprise-specific information, or information about niche interests and topics, or even from specific documents and written sources, tends to be lacking from LLM training datasets.<br/>In addition, real-world data is constantly changing, and enterprise contexts are constantly evolving. LLMs also tend to require reinforcement of factual accuracy. All these factors limit the utility value of using LLMs directly for enterprise use-cases, especially those requiring up-to-date factual information about specific technical or business topics.</p><p><a href="https://www.elastic.co/what-is/retrieval-augmented-generation">R</a><a href="https://www.elastic.co/what-is/retrieval-augmented-generation">etrieval Augmented Generation (RAG)</a>, the use of <a href="https://www.elastic.co/what-is/vector-database">searchable data stores</a> to retrieve information sources relevant to the context and intention of a user query, was popularized as a way to address this deficiency. A large amount of work has been done to <a href="https://www.elastic.co/search-labs/blog/build-rag-workflow-langgraph-elasticsearch">implement</a>, <a href="https://www.elastic.co/search-labs/blog/rag-retrieval-elasticsearch-deepeval">assess</a>, and <a href="https://www.elastic.co/search-labs/blog/building-multimodal-rag-system">improve</a> the quality of RAG applications, and RAG has enjoyed widespread adoption in enterprise use cases for productivity enhancement and workflow automation. However, RAG does not leverage the decision-making capacity of large language models.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a01c35927bf359190acb06368fa5f0135d4d3bc5-3596x2004.png" style="width: 100%;" /><p>The agentic model revolves around the LLM being able to take specific actions in response to a user input. These actions may involve the use of tools to augment the LLM&#x27;s existing capabilities. In this sense, RAG functions as a long-term memory store that the LLM agent may choose to use to augment and reinforce answers to user queries. Where the traditional RAG model involves the LLM querying one or more knowledge bases, an agentic implementation allows an LLM to choose from a set of knowledge bases. This allows for more flexible question-answering behavior, and can improve accuracy, as information from irrelevant knowledge bases is omitted, reducing potential sources of noise. We might call such a system an &quot;agent knowledge base.&quot; Let&#x27;s take a look at how to implement such a system using Elasticsearch.</p><h2>Designing an agent knowledge base</h2><p>All code may be found<a href="https://github.com/pioorg/agentic_search_example_UI"> in the GitHub repo</a>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/525531c53146c9ce308b8f39273354fa54b2d31d-3840x2161.png" style="width: 100%;" /><p>I recently became interested in scuba diving after trying it and realizing it could cure my persistent <a href="https://en.wikipedia.org/wiki/Thalassophobia">thalassophobia</a>, so I decided to set up an agentic knowledge base for diving specifically.</p><ul><li>The US Navy Dive Manual - Containing a wealth of technical detail about diving operations and equipment.</li><li>Diving Safety Manual - Containing general guidelines and procedures aimed at recreational divers. </li><li>The Google Custom Search API - Capable of searching the web for any information not contained within the two manuals.</li></ul><p>The intention was that this <code>Diving Assistant</code> would be a one-stop shop for diving-related knowledge, which would be capable of responding to any query, even those out of scope of the knowledge bases ingested. The LLM would recognize the motivation behind a user query, and select the source of information most likely to be relevant. I decided to use <a href="https://www.langchain.com/">LangChain</a> as the agentic wrapper, and built a <a href="https://streamlit.io/">streamlit UI</a> around it.</p><h2>Setting up the endpoints</h2><p>I start by creating a <code>.env</code> file and populating it with the following variables:</p><pre><code>ELASTIC_ENDPOINT=<ELASTIC CLOUD ENDPOINT>
ELASTIC_API_KEY=<ELASTIC CLOUD API KEY>

# Enable custom search API
# https://developers.google.com/custom-search/v1/introduction/?apix=true
GCP_API_KEY=<GCP API KEY>
GCP_PSE_ID=<GCP PSE ID>

AZURE_OPENAI_SYSTEM_PROMPT="You are a helpful assistant. Be as concise and efficient as possible. Convey maximum meaning in fewest words possible."

AZURE_OPENAI_ENDPOINT=<AZURE ENDPOINT>
AZURE_OPENAI_API_VERSION=<AZURE API VERDSION>
AZURE_OPENAI_API_KEY=<AZURE API KEY>
AZURE_OPENAI_MODEL="gpt-4o-mini"</code></pre><p>This project makes use of a GPT-4o-Mini deployed on Azure OpenAI, as well as the Google Custom Search API, and an Elastic Cloud deployment to hold my data. I also add a custom system prompt encouraging the LLM to avoid wordiness as much as possible.</p><h2>Ingestion and processing</h2><p>The US Navy Dive Manual and Diving Safety Manual are in PDF format, so the next step was to ingest them into an Elastic Cloud deployment. I set-up this python script using Elastic&#x27;s <code>bulk</code> API to upload documents to Elastic Cloud:</p><pre><code>import os
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from elasticsearch import Elasticsearch, helpers # elasticsearch==8.14.0
from tqdm import tqdm # tqdm==4.66.4
from llama_index.core import SimpleDirectoryReader

def bulk_upload_to_elasticsearch(data, index_name, es, batch_size=500, max_workers=10):
    ''' 
    data: [ {document} ]
        document: {
                    "_id": str
                    ...
                  }
    index_name: str 
    es: Elasticsearch 
    batch_size: int 
    max_workers: int
    '''
    total_documents = len(data)
    success_bar = tqdm(total=total_documents, desc="Successful uploads", colour="green")
    failed_bar = tqdm(total=total_documents, desc="Failed uploads", colour="red")

    def create_action(doc):
        '''
        Define upload action from source documents
        '''
        return {
            "_index": index_name,
            "_id": doc["id_"],
            "body": doc["text"]
        }

    def read_and_create_batches(data):
        ''' 
        Yield document batches
        '''
        batch = []
        for doc in data:
            batch.append(create_action(doc))
            if len(batch) == batch_size:
                yield batch
                batch = []
        if batch:
            yield batch

    def upload_batch(batch):
        ''' 
        Make bulk call for batch
        '''
        try:
            success, failed = helpers.bulk(es, batch, raise_on_error=False, request_timeout=45)
            if isinstance(failed, list):
                failed = len(failed)
            return success, failed
        except Exception as e:
            print(f"Error during bulk upload: {str(e)}")
            return 0, len(batch)
''' 
    Parallel execution of batch upload
    '''
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_batch = {executor.submit(upload_batch, batch): batch for batch in read_and_create_batches(data)}
        for future in as_completed(future_to_batch):
            success, failed = future.result()
            success_bar.update(success)
            failed_bar.update(failed)

    ''' 
    Update progress bars
    '''
    total_uploaded = success_bar.n
    total_failed = failed_bar.n
    success_bar.close()
    failed_bar.close()

    return total_uploaded, total_failed

# This is connecting to ES Cloud via credentials stored in .env 
# May have to change this to suit your env. 
try:
    es_endpoint = os.environ.get("ELASTIC_ENDPOINT")
    es_client = Elasticsearch(
        es_endpoint,
        api_key=os.environ.get("ELASTIC_API_KEY")
    )
except Exception as e:
    es_client = None

print(es_client.ping())</code></pre><p>After downloading the US Navy Dive Manual PDF and storing it in its own folder, I use <a href="https://www.llamaindex.ai">LlamaIndex&#x27;s</a> <code>SimpleDirectoryReader</code> to load the PDF data, then trigger a bulk upload:</p><pre><code>reader = SimpleDirectoryReader(input_dir="./data")
documents = reader.load_data()
bulk_upload_to_elasticsearch([i.to_dict() for i in list(documents)], 
                            "us_navy_dive_manual_raw", 
                            es_client, batch_size=16, max_workers=10)</code></pre><p>This sends all the text content to Elastic Cloud, with each page of the PDF as a separate document, to an index called <code>us_navy_dive_manual_raw</code>. No further processing is done, so the process of uploading all 991 pages takes less than a second. The next step is to do semantic embedding within Elastic Cloud.</p><h2>Semantic data embedding and chunking</h2><p>In my Elastic Cloud DevTools console, I first deploy the <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-elser">ELSER v2</a> model using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">Elastic inference API</a></p><pre><code>PUT _inference/sparse_embedding/elser_v2
{
  "service": "elasticsearch",
  "service_settings": {
    "num_allocations": 1,
    "num_threads": 8,
    "model_id": ".elser_model_2_linux-x86_64"
  },
  "chunking_settings": {
    "strategy": "sentence",
    "max_chunk_size": 250,
    "sentence_overlap": 1
  }
}</code></pre><p>I then define a simple pipeline. Each document stores the text of a page from the dive manual in the <code>body</code> field, so I copy the contents of <code>body</code> to a field called <code>semantic_content</code>.</p><pre><code>PUT _ingest/pipeline/diving_pipeline
{
  "processors": [
    {
      "set": {
        "field": "semantic_content",

        "copy_from": "body",
        "if": "ctx.body != null"
      }
    }
  ]
}</code></pre><p>I then create a new index called <code>us_navy_dive_manual</code>, and set <code>semantic_content</code> as a <code>semantic_text</code> field:</p><pre><code>PUT us_navy_dive_manual
{
  "mappings": {
    "properties": {
      "semantic_content": {
        "type": "semantic_text",
        "inference_id": "elser_v2"
      }
    }
  }
}</code></pre><p>I then trigger a reindex job. Now the data will flow from <code>us_navy_dive_manual_raw</code>, to be chunked and embedded using ELSER, and be reindexed into <code>us_navy_dive_manual</code> ready for use.</p><pre><code>POST _reindex?slices=auto&wait_for_completion=false
{
  "source": {
    "index": "us_navy_dive_manual_raw",
    "size": 4
  },
  "dest": {
    "index": "us_navy_dive_manual",
    "pipeline": "diving_pipeline"
  },
  "conflicts": "proceed"
}</code></pre><p>I repeat this process for the Diving Safety Manual, and with this simple process, data ingestion is completed.</p><h2>Tooling for agentic search</h2><p>This agent is relatively simple, so I make use of <a href="https://python.langchain.com/docs/how_to/agent_executor/">LangChain&#x27;s AgentExecutor</a> which creates an agent and bundles it with a set of tools. Complex decisionmaking flows can be achieved using the <a href="https://python.langchain.com/docs/concepts/architecture/#langgraph">LangGaph implementation</a> , which we will use in a future blog. We will focus on the parts related to the agents, so for details on the actual streamlit UI, please check out <a href="https://github.com/pioorg/agentic_search_example_UI">the github repo</a>.</p><p>I create two tools for my agent to use. The first is an <code>ElasticSearcher</code> class, which performs a semantic search over an Elastic index, then returns the top 10 articles as text.</p><pre><code>class ElasticSearcher:
    def __init__(self):
        self.client = Elasticsearch(
            os.environ.get("ELASTIC_ENDPOINT"),
            api_key=os.environ.get("ELASTIC_API_KEY")
        )

    def search(self, query, index="us_navy_dive_manual", size=10):
        response = self.client.search(
            index=index,
            body={
                "query": {
                    "semantic": {
                        "field": "semantic_content",
                        "query": query
                    }
                }     
            },
            size=size
        )
        return "\n".join([hit["_source"].get("body", "No Body") 
                            for hit in response["hits"]["hits"]])</code></pre><p>The second tools is the <code>Googler</code> class, which calls the Google Custom Search API to perform a general web search.</p><pre><code>class Googler:
    def __init__(self):
        self.service = build('customsearch', 'v1', developerKey=os.getenv("GCP_API_KEY"))

    def scrape(self, url):
        try:
            response = requests.get(url, timeout=10)
            if response.status_code == 200:
                soup = BeautifulSoup(response.text, 'html.parser')
                for script in soup(["script", "style"]):
                    script.decompose()
                text = soup.get_text()
                lines = (line.strip() for line in text.splitlines())
                chunks = (phrase.strip() for line in lines for phrase in line.split("  "))
                return '\n'.join(chunk for chunk in chunks if chunk)[:5000]
            return None
        except:
            return None

    def search(self, query, n=5):
        results = self.service.cse().list(q=query, cx=os.getenv("GCP_PSE_ID"), num=n).execute()
        scraped_data = []
        for item in results.get('items', []):
            url = item['link']
            title = item['title']
            content = self.scrape(url) or item['snippet']
            scraped_data.append(f"Page: {title}\nURL: {url}\n\n{content}\n")
        return "\n".join(scraped_data)</code></pre><p>I then create a set of tools for the agent to use. The description of each tool is an important part of the prompt engineering, as the agent will refer to it primarily when choosing which tool to use for its response to a user query.</p><pre><code>tools = [
    Tool(
        name="WebSearch",
        func=lambda q: googler.search(q, n=3),
        description="Search the web for information. Use for current events or general knowledge or to complement with additional information."
    ),
    Tool(
        name="NavyDiveManual",
        func=lambda q: elastic.search(q, index="us_navy_dive_manual"),
        description="Search the Operations Dive Manual. Use for diving procedures, advanced or technical operational planning, resourcing, and technical information."
    ),
    Tool(
        name="DivingSafetyManual",
        func=lambda q: elastic.search(q, index="diving_safety_manual"),
        description="Search the Diving Safety Manual. Use for generic diving safety protocols and best practices."
    )
]</code></pre><p>Next, I define an LLM using the <code>AzureChatOpenAI</code> abstraction:</p><pre><code>llm = AzureChatOpenAI(
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
    deployment_name=os.getenv("AZURE_OPENAI_MODEL"),
    streaming=False
)</code></pre><p>And also create a custom prompt for the LLM, telling it how to make use of the tools and their outputs.</p><pre><code>prompt = PromptTemplate.from_template("""Answer the following questions as best you can. You have access to the following tools:
{tools}
You should use multiple tools in conjunction to promote completeness of information.
Be comprehensive in your answer.
Use the following format:
Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [{tool_names}]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question
Question: {input}
{agent_scratchpad}""")</code></pre><p>Finally, I define the agent, passing it the LLM, prompt, and toolset, and integrate it into the rest of the UI.</p><pre><code>agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=False)</code></pre><p>And with that, we are ready to test out our agent knowledge base.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8adfb985eb98a3aed652bd2376b0525b228f3b3b-1432x806.png" style="width: 100%;" /><h3>Test 1: Retrieve knowledge from a specific knowledge base</h3><p>First, we should test that the knowledge base component is working as intended. Let&#x27;s start by asking a detailed technical question. The agent should choose the right knowledge base, retrieve some information, and use it to craft a response.</p><pre><code>List of recommended equipment for ice/cold water diving operations</code></pre><p>The UI displays the Agent&#x27;s thought process, and the first thought it has is to consult the diving safety manual:</p><pre><code>Thought: To provide a comprehensive list of recommended equipment for ice 
or cold water diving operations, I should consult specialized diving 
manuals that cover operational procedures and safety protocols. 
This will ensure that I include all necessary items for safety and 
efficiency in such challenging environments. 

Action: NavyDiveManual

Action Input: "recommended equipment for ice cold water 
diving operations"</code></pre><p>The agent uses the NavyDiveManual tool to perform a semantic search of the <code>us_navy_dive_manual</code> index. The result is information like this:</p><pre><code>CHAPTER 11 — Ice and Cold Water Diving Operations   11-9
A detailed reconnaissance of the dive site will provide the 
planner with information that is helpful in deciding what ancillary equipment is 
required. Diving under ice will require special accessory equipment such as a line 
with lights/strobes for under  water navigation, ice-cutting tools, platforms, engine 
protection kits, and stainless steel ice screws, quick draw, and carabineers.
The method of cutting the hole through the ice depends on ice thickness and availability 
of equipment. Normally, two or more of the following tools are used: hand 
ice chipper, ice handsaw, ice auger, chain saw, thermal ice cutter or blasting equipment. 
In addition, equipment to lift the ice block, remove the slush, and mark ...</code></pre><p>Which the LLM then uses to craft a response:</p><pre><code>The recommended equipment for ice and cold water diving operations includes:

Thermal Protection:

* Wet suit or Variable Volume Dry Suit (VVDS)
* Heavy insulating socks (if using a wet suit)
* Diving Equipment:
* Approved cold water open-circuit SCUBA or closed-circuit UBA</code></pre><p>The response is exactly what we wanted. Great!</p><h3>Test 2: Iteratively refine knowledge collection</h3><p>The next test is to see whether the agent can select multiple sources of knowledge to refine its responses.<br/>Let&#x27;s ask a more open ended question:</p><pre><code>What are the risk factors when snorkeling?</code></pre><p>The first thought the agent has involves searching Google for information specific to risk factors and dangers:</p><pre><code>Thought: To provide a comprehensive answer to the risk factors associated with snorkeling, 
I should gather information on general safety protocols and specific risks from both diving 
and snorkeling perspectives. I will start by searching for general snorkeling risks and 
then look into specific safety guidelines related to diving and snorkeling. 

Action: WebSearch  
Action Input: "risk factors when snorkeling"</code></pre><p>This returns web results such as:<br/></p><pre><code>Factors Contributing to Snorkel Drowning in Hawai'i - PMC
URL: https://pmc.ncbi.nlm.nih.gov/articles/PMC8899085/
Causes of the extraordinarily high and increasing incidence of snorkeler drownings in Hawai'i 
have remained unexplained for years. Defining the mechanisms and factors predisposing to 
drowning while snorkeling is needed to provide recommendations to substantially mitigate 
the incidence of this form of preventable death. The mechanisms of drowning are described 
and insight into the predisposing factors are explored in this study...</code></pre><p>The agent then chooses to consult the <code>diving_safety_manual</code>, and takes the appropriate action to retrieve safety protocols and guidelines.</p><pre><code>Action: DivingSafetyManual  
Action Input: "snorkeling safety protocols"</code></pre><p>Which returns relevant information from the <code>DivingSafetyManual</code> tool:</p><pre><code>Pre-Snorkel briefing       
Immediately before snorkelling commences, and using site diagrams or 
photos as appropriate, the snorkeller supervisor  should emphasise 
the following points: 
• On-site environmental conditions  
• Boundaries of the snorkelling area 
• Times when snorkelli ng is being conducted 
• Specific site hazards 
• Location of lookout 
• Emergency and recall signals 
• Buddy and guided group  procedures</code></pre><p>Finally, the LLM provides a comprehensive answer, containing both risk factors and safety guidelines:</p><pre><code>The risk factors when snorkeling include:
Environmental Conditions: Strong currents, waves, and marine life can pose 
immediate threats. Snorkelers should be aware of local weather and water 
conditions before entering.
Physical Fitness: Snorkeling is a strenuous activity that can be 
challenging for individuals with pre-existing medical conditions 
such as heart disease, respiratory issues (e.g., asthma), or those 
who are not strong swimmers.
...
To mitigate these risks, snorkelers should engage in pre-snorkel briefings, 
wear personal flotation devices, ensure proper equipment checks, and 
maintain awareness of their environment and physical limits.</code></pre><h3>Test 3: Searching out of scope</h3><p>The final test is to see what happens when the user asks a query that is certainly out of scope of the prepared knowledge bases. Let&#x27;s ask about a specific diving product:</p><pre><code>Tell me about the Apeks XTX50 regulator</code></pre><p>The agent&#x27;s first thought is to check the external web for relevant information:</p><pre><code>Thought: I need to gather information about the Apeks XTX50 regulator, 
including its features, specifications, and performance in diving conditions. 
I will begin with a web search for comprehensive details.  
Action: WebSearch  
Action Input: "Apeks XTX50 regulator review features specifications"</code></pre><p>It discovers a wealth of information from commercial and diving hobbyist sites, and returns both technical specifications and user feedback:</p><pre><code>The Apeks XTX50 is a high-performance scuba diving regulator designed for 
divers who seek reliability and advanced features at a moderate price point. 
Here are the key features and specifications:
Features:
First Stage:
Utilizes the DST (Diver Adjustable) first stage with an environmentally 
sealed design to prevent water ingress. Over-balanced diaphragm technology 
provides superior performance as divers descend, with medium pressure gas 
increasing faster than ambient pressure.
...
Divers appreciate the XTX50's reliability and comfort, making it a popular 
choice for both recreational and technical diving.
The regulator is lauded for its performance in colder conditions, with 
many users noting its effectiveness during deep dives.
Some divers have raised concerns about the availability of parts for maintenance, 
as Apeks does not sell parts directly to consumers.
... </code></pre><p>The agent did not choose to search the US Navy Dive Manual, which is concerned with operational planning, or with the DivingSafetyManual.</p><h2>Conclusion</h2><p>In a traditional RAG implementation, we might have chosen to force the LLM to search and use information from all three data sources simultaneously, but this would have negatively impacted accuracy by introducing noise from irrelevant information. With the agentic knowledge base, we see the LLM making targeted searches to specific sources of knowledge based on user intention and context. The agent is able to refine the knowledge collected by building upon initial searches, and combining them with information collected from other sources.</p><p>The agent is also able to handle questions out of the scope of its prepared data, and is also able to exclude knowledge bases not pertinent to the query - Significant enhancements on the traditional RAG model.</p><p>This agent knowledge base concept provides a graceful way to combine many different sources into a coherent and comprehensive system, and the next steps would be to expand the range of actions and the diversity of information that can be referred to. Introducing workflows for fact-checking and cross referencing would be a boon to overall reliability, and tools for specialized capabilities like calculation would be a very interesting direction to explore.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[NeurIPS 2025 highlights: From model merging to deep learning for code]]></title>
            <description><![CDATA[Explore our NeurIPS 2025 highlights on model merging, task vectors, and VLM dynamics, plus our DL4C workshop presentation on Jina code embeddings.]]></description>
            <link>https://www.elastic.co/search-labs/blog/neurips-2025-model-merging-task-vectors-code-embeddings</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/neurips-2025-model-merging-task-vectors-code-embeddings</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Scott Martens, Michael Günther]]></dc:creator>
            <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/757b108276c1fc09dc23bd1b1ea594f0c1ac7eab-1216x628.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Over the last decade, <a href="https://neurips.cc/">NeurIPS</a> has become one of the premier academic conferences for AI and machine learning, where the most important papers are presented and where researchers in this community meet and network.</p><p>Jina by Elastic engineers Michael Günther and Florian Hönicke attended <a href="https://neurips.cc/Conferences/2025">this year’s conference in San Diego</a> with Daria Kryvosheieva. They presented her internship project, the <a href="https://jina.ai/news/jina-code-embeddings-sota-code-retrieval-at-0-5b-and-1-5b/">jina-code-embeddings</a> models, at the <a href="https://dl4c.github.io/">Deep Learning for Code</a> (DL4C) workshop.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ef2cd707fe6136590e44c3269724129bd888ff9e-1131x1600.jpg" style="width: 100%;" /><p>Coding agents and automated coding are very popular research areas and were prominent topics at this year’s NeurIPS, with more than 60 papers and hundreds of participants at the DL4C workshop. AI models that can generate code are not just important to software developers. They also they enable AI agents to execute code to solve problems and interact with databases and other applications, such as by writing their own SQL queries,creating SVG and HTML on the fly for display, and more.</p><p>There’s a lot of interest in AI applications for the IT industry, including <a href="https://www.elastic.co/elasticsearch/streams">Streams</a>, <a href="https://www.elastic.co/observability-labs/blog/elastic-observability-streams-ai-logs-investigations">which is using AI to interpret system logs</a>.</p><p>Jina’s contribution to the field is a very compact, high-performance embedding model dedicated to retrieving code and computer documentation from knowledge bases and repositories, with applications to integrated development environments (IDEs) code assistants, and IT-centric retrieval-augmented generation (RAG) applications.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/43f9cbd30d857127da50a25687530f1505aa45ec-1200x1600.jpg" style="width: 100%;" /><p>Overall, the conference found a good balance between theoretical work and applied research.</p><p>San Diego in early December was mild and pleasant, and the city has an easygoing atmosphere. People lingered outside between sessions and, in the evening, the cafés and bars were full of people with conference badges.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/757b108276c1fc09dc23bd1b1ea594f0c1ac7eab-1216x628.png" style="width: 100%;" /><p>We learned a lot at NeurIPS 2025 and enjoyed the trip to a city much warmer than Berlin at this time of year. In this post, we briefly share what we found most valuable at the conference.</p><h2>Model merging: Theory, practice, and applications</h2><p><a href="https://neurips.cc/virtual/2025/loc/san-diego/109593">This tutorial</a>, from Marco Ciccone, Malikeh Ehghaghi, and Colin Raffel, was particularly interesting. Over the last few years, model merging has become a widely used technique for making AI models more robust when fine-tuned for specialized applications. In the simplest case, it involves combining two or more fine-tuned models, derived from the same base model, by averaging their weights, as in the image below:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/bd908570745066704480c4e790d2b90d63b865b8-374x513.png" style="width: 100%;" /><p>As simplistic as this sounds, it usually works and leads to models that perform better (or at least not much worse) on both fine-tuned tasks,, as well as retaining the performance of the base model on nonspecialized tasks.</p><p>The tutorial provided an overview of recent advances in this very active research area, especially developments in more sophisticated merging methods beyond simple weight averaging. Notably:</p><ul><li><a href="https://arxiv.org/abs/2306.01708">TIES-Merging</a>, which tries to mitigate merging conflicts between weights by, among other things, selecting subsets of the weights.</li><li><a href="https://arxiv.org/abs/2111.09832">Fisher Merging</a> and <a href="https://arxiv.org/abs/2212.09849">RegMean</a>, which involve using activation information to improve outcomes from model mergers.</li></ul><p>There was also a summary of model development techniques deployed at the largest AI labs, like Google DeepMind and Cohere, which both appear to rely on model merging, ensuring continuing interest and development in this area.</p><h2>Interesting research</h2><p>We also attended oral presentations and poster sessions, and several struck us as particularly valuable.</p><h3>Large language diffusion models</h3><p><a href="https://arxiv.org/abs/2502.09992">Diffusion models</a> work very differently from most language models. Language models are generally trained using autoregressive methods: Given some length of text, they receive training to produce the next token. Diffusion language models, in contrast, are trained with texts with some tokens masked out, and they learn to fill them in. They generate text nonlinearly, passing over it multiple times and adding tokens in no particular order, instead of generating one word after the other. Diffusion was originally applied very successfully to image generation but has only recently been widely applied to text.</p><p>This research applies the diffusion approach to a relatively large transformer-based language model (8 billion parameters) as pretraining and supervised fine-tuning. During pretraining, the model learns to fill in text with random (up to 100%) masked text. During supervised fine-tuning, the prompt is never masked, so it can learn to generate text from instructions.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b9e62c2ac3fcf3e1fae8ca4271a785eb01795e86-1600x448.png" style="width: 100%;" /><p>The resulting model shows comparable performance to autoregressively trained models across many tasks, while excelling in some domains, particularly math-related tasks. This is a very promising direction for language modeling research, and we’re curious to see whether diffusion models will become more prominent for training language models and whether they’re applied to embedding models, as well.</p><h3>Activation-informed merging of large language models</h3><p><a href="https://arxiv.org/abs/2502.02421">This paper</a> proposes another technique for improving model merging. The intuition behind this work is identifying and preserving the most important weights of the base model when merging one or more fine-tuned models.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9e9ee9082ad00fe49ef99153fb3f970ea96c106d-1600x1200.jpg" style="width: 100%;" /><p>It uses a calibration dataset to obtain the average activations of all layers in the model and identifies the most critical weights by calculating the influence of each weight on model activation levels. It then uses this to determine which weights should not be dramatically changed during merging.</p><p>This approach is compatible with using other model-merging techniques. The authors show significant improvements when using this method in combination with various other merging methods.</p><h3>SuperCLIP: CLIP with simple classification supervision</h3><p>A well-known problem with image-text models trained using Contrastive Language Image Pretraining (CLIP) is that they aren’t good at capturing fine-grained textual information, due to architectural limitations and to the nature of the web-scraped data typically used to train vision models. During the development of the <a href="https://huggingface.co/jinaai/jina-clip-v2">Jina-CLIP models</a>, we also identified that CLIP models are generally bad at understanding more complex texts because they’re trained on short texts. We compensated by adding longer texts to our training data.</p><p><a href="https://openreview.net/pdf?id=EeIEvZlmVg">This paper</a> proposes an alternative solution: adding a novel classification loss component to the ordinary CLIP loss.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4de5edda8386571d57a1404d561159569663ee2e-1600x1179.jpg" style="width: 100%;" /><p>It relies on added layers during training that use output image tile embeddings to predict the text tokens in its description. Training optimizes for both this objective and the CLIP loss at the same time.</p><h3>Datasets, documents, and repetitions: The practicalities of unequal data quality</h3><p><a href="https://arxiv.org/abs/2503.07879">This paper</a> addresses issues in training data quality for large language models (LLMs). Typically, such models are trained with large datasets that routinely contain duplicated items. However, paradoxically, deduplication often produces worse results.</p><p>The authors propose an explanation for this confusing finding and offer some elements of a solution.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/74ffd21afb1a9cfdd69ba6bedca145ab5c0e3e23-1600x1200.jpg" style="width: 100%;" /><p>Their principal findings are:</p><ul><li>Large models suffer more when training data is duplicated than small models do.</li><li>Duplicating high-quality documents improves training outcomes or at least does less to reduce them than low-quality ones do.</li><li>High-quality documents are more likely to appear multiple times in real-world training datasets.</li></ul><p>These last two points in particular explain the paradox of deduplication.</p><h3>Does reinforcement learning really incentivize reasoning capacity in LLMs beyond the base model?</h3><p><a href="https://arxiv.org/abs/2504.13837">Reinforcement Learning</a> with Verifiable Rewards (RLVR)is a method of fine-tuning LLMs for reasoning using reinforcement learning in a way that doesn’t require human labeling because the solutions to training problems are automatically verifiable. This contrasts with Reinforcement Learning from Human Feedback (RLHF), where direct human supervision is required during training. For example, this can mean training models to solve math problems or perform coding tasks where the output can be independently tested by machines, that is, checking the solution to a math problem automatically or running unit tests to show that a block of code works correctly.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9aec18d80e9e30eaa2cd50c66c2611f1cdfe713a-694x708.png" style="width: 100%;" /><p>The authors’ test methodology is to assess the number of correct answers for a set of problems, given a varying number of attempts to answer. Each attempt is done by sampling an answer during the generation process. They compare the model after different numbers of RLVR training epochs the model has had. They show that their training substantially improves the accuracy of the model’s answer, if only given one or a few chances, but not if given many. This suggests that they’ve increased the probability of the right answer, but they haven’t really improved the reasoning capabilities of the model.</p><h2>Conclusion</h2><p>Measured by conference activity, research in AI looks like it’s still undergoing explosive growth, with no end in sight. Academic work remains very relevant and is especially important for AI developers who don’t have billions of dollars to rent data centers for research.</p><p>However, this explosive growth also makes it more and more difficult to follow everything that’s going on.</p><p>Here at Elastic, and especially on the Jina team, we’re always excited about what comes next for AI, and we do our best to stay on top of new developments and emergent directions for research. We hope this article gives you a taste of that excitement and a glimpse into the kind of work going on in search AI today.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating reliable agents with structured outputs in Elasticsearch]]></title>
            <description><![CDATA[Explore what structured outputs are and how to leverage them in Elasticsearch to ground agents in the most relevant context for data contracts.]]></description>
            <link>https://www.elastic.co/search-labs/blog/structured-outputs-elasticsearch-guide</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/structured-outputs-elasticsearch-guide</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[JD Armada]]></dc:creator>
            <pubDate>Mon, 29 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ae1416f77bafde7687466a1912a9f6514c7501e9-1308x736.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We’re quickly moving from simple chatbots to agents that can take real, consequential actions on your systems. To make sure these agents are dependable, we can’t rely purely on the free-form text anymore. The ability to generate predictable, machine-readable outputs has become an important layer in building reliable AI agents. Structured outputs are also a key layer in context engineering, which is a set of strategies that ensure LLMs are grounded in the most relevant information for their task. Together, these patterns help turn LLMs from simple conversational tools into reliable components that you can safely integrate into larger systems. In this piece, we’ll walk through what structured outputs are and how they can be leveraged to provide reliable output that meets key contracts. If you’re new to context engineering, check out our article <a href="https://search-labs-redesign.vercel.app/search-labs/blog/context-engineering-overview">here</a>.</p><h2>Structured outputs</h2><p>Structured outputs are LLM responses that conform to predefined schemas or data structures, instead of free-form text. Rather than receiving unpredictable responses, developers can specify exactly how a response should be formatted.</p><p>In the example below, if you give an LLM access to your indices within Elasticsearch and ask it to, “analyze this Elasticsearch index”, it’ll respond with a narrative explanation that’s likely to change each time you ask it the same prompt. With structured outputs, you can request a response with specific fields like <code>indexName</code>, <code>documentCount</code>, <code>healthStatus,</code> etc., each with defined types and validation rules.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ae1416f77bafde7687466a1912a9f6514c7501e9-1308x736.png" style="width: 100%;" /><p>The structured format on the right can be immediately validated against a schema, without adding the extra step of parsing the text.</p><p>The largest model providers are quickly taking note of how important structured outputs are becoming, with <a href="https://blog.google/technology/developers/gemini-api-structured-outputs/">Google</a>, <a href="https://openai.com/index/introducing-structured-outputs-in-the-api/">OpenAI</a>, and <a href="https://platform.claude.com/docs/en/build-with-claude/structured-outputs">Anthropic</a> releasing support for structured outputs in each of their respective APIs. OpenAI goes beyond by releasing new models that are trained to better understand and adhere to complex schemas. Below is OpenAI’s evaluation of how well their models follow complex JSON schemas.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/96ead1dba23dddddd2ff213252372cf27e2fc3cc-1592x1060.png" style="width: 100%;" /><p>In this visual, their model <code>gpt-40-2024-08-06</code> with structured outputs has a perfect score, while their previous model <code>gpt-4-0613</code> without structured outputs scores below 40%.</p><h2>How this affects multi-agent systems</h2><p>Imagine a scenario where a system of agents passed around unstructured, free-form data. Each agent would need custom parsing logic to understand the responses from other agents, which not only bloats your token usage but will surely break in practice. This is compounded by the fact that LLM outputs are probabilistic and therefore unpredictable. How can we trust this type of system to take real actions on our systems? Structured outputs define a contract between agents, replacing ambiguity with reliable, predictable behavior.</p><h2>The importance of standardization for AI agents and MCP</h2><p><a href="https://www.openapis.org/">OpenAPI</a> revolutionized REST API development by giving developers a shared, predictable way to describe endpoints, parameters, and responses. Structured outputs bring that same idea to AI agents by providing contracts that standardize how data is exchanged between agents and systems.</p><p>These contracts ensure that:</p><ul><li><strong>Downstream systems can parse responses reliably: </strong>When an agent needs to perform an action like updating a database, calling an API, or triggering a workflow, the receiving system must be able to trust the shape and integrity of the data.</li><li><strong>Type safety is maintained</strong>: Structured outputs enable compile-time or runtime validation, catching errors before they propagate throughout the system and turn into bigger problems.</li><li><strong>Integration is predictable:</strong> With defined schemas, integrating agents into existing infrastructure follows patterns developers already know from traditional API development.</li><li><strong>Multi-agent systems can understand each other:</strong> When multiple agents need to collaborate, structured outputs provide a common language for exchanging information.</li></ul><p>The <a href="https://modelcontextprotocol.io/docs/getting-started/intro">Model Context Protocol</a> (MCP) extends this by standardizing how agents exchange context between models, tools, and applications. When agents communicate via MCP, structured outputs ensure that the context being shared maintains its structure across systems.</p><p>MCP is responsible for the transport and the lifecycle of context, while structured outputs define the shape and constraints of the data within the context.</p><p>Together, they enable:</p><ul><li>Composable agents can be reused or replaced</li><li>Clear contracts between models, tools, and applications</li><li>More reliable automation, especially when we need agents to trigger real-world actions</li><li>Scalable multi-agent architectures</li></ul><p>Other emerging protocols like the <a href="https://github.com/a2aproject/A2A">agent-to-agent</a> (A2A) protocol also emphasize schemas and contracts to enable reliable communication directly between agents.</p><p>MCP and other protocols like A2A alongside structured outputs bring what OpenAPI brought to microservices, which is a shared contract that turns ad-hoc integrations into reliable systems.</p><h2>Technologies for creating schemas</h2><p>Now, how do we actually implement structured outputs? Luckily, popular ecosystems like Python and JavaScript already have mature schema and validation libraries that make implementing structured outputs easier. You can use these tools today to control the shape of data your LLM returns, validate it at runtime, or reject it if the model hallucinates. In this section, we’ll look at the most common tools developers reach for and what happens under the hood.</p><h3>Zod and the JavaScript ecosystem</h3><p>In the JavaScript and TypeScript space, <a href="https://zod.dev/">Zod</a> has become the go-to library for schema definition and validation, due to its efficiency, ease of use, and integration with popular AI orchestration frameworks like Vercel’s <a href="https://ai-sdk.dev/">AI SDK</a> and <a href="https://mastra.ai/">Mastra</a>. To see this in action, we’ll look at my colleague <a href="https://search-labs-redesign.vercel.app/search-labs/blog/context-engineering-overview#:~:text=LLM%20structured%20outputs">Carly’s example</a>. Carly used Zod alongside the AI SDK to create a schema that forces the LLM to return itinerary data in a validated, type-safe format.</p><p>The schema below does 3 things:</p><ol><li>Ensures that the LLM returns valid JSON.</li><li>Ensures that the data has the correct types, constraints, and nesting.</li><li>Generates application-ready data that doesn’t need extra processing.</li></ol><pre><code>import { generateObject } from 'ai';
import { z } from 'zod';
const { object } = await generateObject({
 model: 'openai/gpt-4.1',
 schemaName: 'Travel Itinerary',
 schemaDescription: 'Sample travel itinerary for a trip',
 schema: z.object({
   title: z.string(),
   location: z.string(),
   hotel: z.object({name: z.string(), roomType: z.string(), amount: z.number(), checkin: z.iso.date(), checkout: z.iso.date()}),
   flights: z.array(z.object({carrier: z.string(), flightNo: z.string().max(8), origin: z.string(), destination: z.string(), date: z.iso.datetime()})),
   excursions: z.array(z.object({ name: z.string(), amount: z.number(), date: z.iso.datetime()}))
 }),
 prompt: 'Generate a travel itinerary based on the specified location',
});</code></pre><p>Let’s take a closer look at the important parts of this schema.</p><p><strong>Trip information</strong></p><pre><code>schema: z.object({
  title: z.string(),
  location: z.string(),</code></pre><p>These fields above are simple strings but what we can conclude is that the LLM is not free to make up the structure. The fields <code>title</code> and <code>location</code> have to be included and they must be strings or the response will be rejected.</p><p><strong>Hotel details</strong></p><pre><code>hotel: z.object({ 
  name: z.string(), 
  roomType: z.string(), 
  amount: z.number(), 
  checkin: z.iso,date(), 
  checkout: z.iso.date() 
}),</code></pre><p>Take note that <code>amount</code> is defined as a <code>number</code> type and dates use ISO formats, which means the output can be used right away for calculations, sorting, or storage without any extra parsing.</p><p><strong>Flight information</strong></p><pre><code>flights: z.array(z.object({ 
  carrier: z.string(), 
  flightNo: z.string().max(8), 
  origin: z.string(), 
  destination: z.string(), 
  date: z.iso.datetime() })),</code></pre><p>Flights are an array of objects because trips usually involve multiple legs. We cap <code>flightNo</code> to 8 characters and use <code>datetime()</code> instead of <code>date()</code> to include departure times.</p><p>When we run this, the model should generate a JSON object that looks like:</p><pre><code>{ 
  "title": "Desert Adventure", 
  "location": "Palm Springs, California", 
  "hotel": { 
    "name": "The Madison", 
    "roomType": "Suite", 
    "amount": 250, 
    "checkin": "2025-12-15", 
    "checkout": "2025-12-20" 
 }, 
  "flights": [ 
    { 
      "carrier": "SouthWest Airlines", 
      "flightNo": "AF123", 
      "origin": "SFO", 
      "destination": "PSP", 
      "date": "2025-12-15T18:00:00Z" 
    } 
 ], 
  "excursions": [ 
    { 
      "name": "ATV Desert Tour", 
      "amount": 50, 
      "date": "2025-12-16T10:00:00Z" 
    } 
 ] 
}</code></pre><p>If the model spits out invalid JSON, breaks a specified constraint, or doesn’t include a required field, the request fails immediately instead of silently pushing through bad data.</p><h3>Pydantic and the Python ecosystem</h3><p>For Python developers, <a href="https://docs.pydantic.dev/latest/">Pydantic</a> plays a similar role to Zod in JavaScript/TypeScript, giving you runtime validation and strongly typed structured outputs.</p><p>Let’s use the same travel itinerary example, but this time we’ll use Pydantic models and LangChain’s support for structured outputs.</p><pre><code>from datetime import date, datetime
from decimal import Decimal
from typing import List

from pydantic import BaseModel, Field, ConfigDict, condecimal, constr
from langchain_openai import ChatOpenAI


class Hotel(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    name: str
    room_type: str = Field(..., alias="roomType")
    amount: condecimal(max_digits=10, decimal_places=2, ge=0) 
    checkin: date
    checkout: date


class Flight(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    carrier: str
    flight_no: constr(max_length=8) = Field(..., alias="flightNo")
    origin: str
    destination: str
    date: datetime


class Excursion(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    name: str
    amount: condecimal(max_digits=10, decimal_places=2, ge=0)
    date: datetime


class TravelItinerary(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    title: str
    location: str
    hotel: Hotel
    flights: List[Flight]
    excursions: List[Excursion]


llm = ChatOpenAI(model="gpt-4.1", temperature=0)
structured_llm = llm.with_structured_output(TravelItinerary)

itinerary: TravelItinerary = structured_llm.invoke(
    "Generate a travel itinerary based on the specified location."
)</code></pre><p>This approach feels pretty similar to the Zod example, where you define a schema once and then rely on the framework to handle validation for you at runtime. The main difference is that Pydantic gives you back actual Python objects instead of plain validated JSON. What you get back is a <code>TravelItinerary</code> instance with nested models and properly typed fields, which tends to fit cleaner with Python-based agent pipelines.</p><p>When we run this, the model generates structured data maps onto the Pydantic models, and we should get back an object like:</p><pre><code>{
  "title": "Palm Springs Getaway",
  "location": "Palm Springs, California",
  "hotel": {
    "name": "The Madison",
    "roomType": "Suite",
    "amount": 250.00,
    "checkin": "2025-12-15",
    "checkout": "2025-12-20"
  },
  "flights": [
    {
      "carrier": "SouthWest Airlines",
      "flightNo": "SW123",
      "origin": "SFO",
      "destination": "PSP",
      "date": "2025-12-15T18:00:00Z"
    }
  ],
  "excursions": [
    {
      "name": "ATV Desert Tour",
      "amount": 50.00,
      "date": "2025-12-16T10:00:00Z"
    }
  ]
}</code></pre><p>This JSON should be identical to what we generated using ZOD. Under the hood, this JSON is automatically converted to a <code>TravelItinerary</code> object with nested <code>Hotel</code>, <code>Flight</code>, and <code>Excursion</code> instances. Again, if the model spits out invalid data, breaks a constraint, or doesn’t include a required field, validation fails right away.</p><h3>Under the hood: JSON schemas</h3><p>At the API level, all of these approaches essentially convert to JSON schema. Libraries like Zod and Pydantic exist to make defining these schemas intuitive and developer-friendly.</p><p>Working directly with raw JSON schemas can still be useful when you need language-agnostic contracts shared across teams or services, but the tradeoff is that you lose native types, composability, and much of the developer experience that the libraries provide.</p><h2>Combining Elasticsearch with structured outputs</h2><p>Controlling what the LLM outputs is still only half the battle. Next, we need to know how to make these outputs useful in real systems. Elasticsearch is a natural fit here because it’s designed to work equally well with both structured and unstructured data. This mirrors modern agent architectures where unstructured data provides rich context to power reasoning and retrieval, and structured outputs act as contracts that applications can rely on. Elasticsearch is central to this loop.</p><p>Here’s an example of how Elasticsearch fits into this approach:</p><p><strong> 1. Unstructured inputs</strong></p><p>User queries, documents, chat history, logs, or tool traces, for example, are ingested into an Elasticsearch index. To capture both exact text matching and semantic meaning, we’ll use a mix of text fields and vector embeddings as we index this data.</p><p><strong>2. Elasticsearch as the context engine</strong></p><p>In the moment that an AI agent needs relevant context, it can query Elasticsearch using these different types of search:</p><ul><li><strong>Semantic/vector search: </strong>To search by the underlying meaning of a word.</li><li><strong>Keyword/text search: </strong>For exact matches and filters.</li><li><strong>Geospatial search: </strong>To search by location.</li><li><strong>Hybrid search: </strong>To search using a mix of the above.</li></ul><p><strong>3. LLM reasoning</strong></p><p>The retrieved context is passed back to the LLM to ground its response in the most relevant data instead of relying on its trained data.</p><p><strong>4. Structured output generation</strong></p><p>The model is restricted by the schema we created using either Zod or Pydantic, and produces a validated JSON object instead of free-form text.</p><p><strong>5. Structured indexing</strong></p><p>The validated output is indexed back into Elasticsearch using explicit mappings, making it easier to query, aggregate, and analyze.</p><p><strong>6. Reuse and automate</strong></p><p>Now that we’ve added structure to the data, it becomes easy to query, filter, aggregate, or use it as input for downstream systems and workflows.</p><p>This loop lets agents use Elasticsearch both as the retrieval layer and as a memory store that enables context-driven reasoning, automation, and long-term learning.</p><h2>Limitations</h2><p>Marius Schroder’s<a href="https://medium.com/medialesson/structured-prompting-in-real-projects-checklist-best-practices-c39fa789856b"> structured prompting article</a> mentions some limitations with structured prompting that can also be applied to structured outputs.</p><p>He mentions that:</p><ul><li><strong>A schema can guarantee format but not correctness:</strong> The model can still output garbage where the JSON is structurally valid but filled with the wrong data. For example, an itinerary schema might require a valid ISO date, a price with a numeric type, and a flight number under 8 characters. The model could still return a flight that’s on February 30th (impossible date), or assign a 10 dollar price to a five-star hotel. In this instance, the structure is valid but the facts are wrong, illustrating that schemas validate the shape of data but not the truth.</li><li><strong>Complex or deeply nested schemas can still be a point of failure:</strong> You can still run into parsing failures or token limits, if the output is large enough, the model might cut off different parts.</li><li><strong>Not great for creative scenarios: </strong>In this case, free-form text might be the better choice especially if you don’t want to handcuff the LLM too much in creative tasks.</li></ul><h2>Conclusion</h2><p>This article dives into the importance of providing structured outputs in multi-agent systems, the most common tools developers reach for, and how Elasticsearch can be a natural extension. If you want to learn more, be sure to check out these resources below.</p><h2>Resources</h2><ol><li><a href="https://search-labs-redesign.vercel.app/search-labs/blog/context-engineering-overview">What is context engineering? | Carly Richmond</a></li><li><a href="https://docs.langchain.com/oss/python/langchain/structured-output">LangChain: Structured output</a></li><li><a href="https://towardsdatascience.com/hands-on-with-anthropics-new-structured-output-capabilities/">A Hands-On Guide to Anthropic’s New Structured Output Capabilities | Thomas Reed</a></li><li><a href="https://openai.com/index/introducing-structured-outputs-in-the-api/">OpenAI: Introducing structured outputs in the API</a></li><li><a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI: Structured outputs</a></li><li><a href="https://medium.com/medialesson/structured-prompting-in-real-projects-checklist-best-practices-c39fa789856b">Structured Prompting in real projects — checklist &amp; best practices | Marius Schroder</a></li><li><a href="https://blog.google/technology/developers/gemini-api-structured-outputs/">Improving Structured Outputs in the Gemini API</a></li></ol>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Comparing dense vector search performance with the Profile API in Elasticsearch]]></title>
            <description><![CDATA[Learn how to use the Profile API in Elasticsearch to compare dense vector configurations and tune kNN performance with visual data from Kibana.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-profile-api-dense-vector-search-comparison</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-profile-api-dense-vector-search-comparison</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Dávila]]></dc:creator>
            <pubDate>Tue, 23 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/599a25998d0211836e2962ff3422552d77312b2e-956x556.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-profile">Profile API </a>is a debugging tool that Elasticsearch provides to measure query execution performance. This API breaks down query execution, showing how much time was spent on each phase of the search process. This visibility comes in handy when identifying bottlenecks and comparing different query and index configurations.</p><p>In this blog, we will explore how the Profile API can help us compare different approaches to vector search in Elasticsearch, understanding execution times and how the total response time is used across different actions. This showcases how search profiling can drive the settings selection, giving us an example of how each one behaves with a particular use case.</p><h2>Profile API implementation</h2><h3>Profiler API</h3><p>To enable search profiling in Elasticsearch, we add a<strong> “profile” : ”true”</strong> parameter to a search request. This instructs Elasticsearch to collect timing information on the query execution without affecting the actual search results.</p><p>For example, a simple text query using profiling:</p><pre><code>GET wikipedia-brute-force-1shard/_search
{
  "size": 0,
  "profile": true,
  "query": {
    "match": {
      "text": "semantic search"
    }
  }
}</code></pre><p>The main parts of the response are:</p><pre><code>"profile": {
    "shards": [
      {
        "id": "[OGMMYXQqRseu_8fR0yD4Qg][wikipedia-brute-force-1shard][0]",
        "node_id": "OGMMYXQqRseu_8fR0yD4Qg",
        "shard_id": 0,
        "index": "wikipedia-brute-force-1shard",
        "cluster": "(local)",
        "searches": [
          {
            "query": [
              {...
            //detailed timing of the query tree executed by Lucene on a particular shard.
               ...
              }
            ],
            "rewrite_time": //All queries in Lucene undergo a "rewriting" process that allows Lucene to perform optimizations, such as removing redundant clauses
            ,
            "collector": [
              {...
             // shows high-level execution details about coordinating the traversal, scoring, and collection of matching documents
               ...
              }
            ]
          }
        ],
        "aggregations": [
             //detailed timing of the aggregation tree executed by a particular shard
        ]
      }
    ]
  }</code></pre><h3>Kibana profiler</h3><p>In the <a href="https://www.elastic.co/blog/dev-tools-console-kibana">DevTools app</a> in Kibana we can find a <a href="https://www.elastic.co/docs/explore-analyze/query-filter/tools/search-profiler">search profiler</a> feature that makes reading the metrics a lot easier. The search profiler in Kibana uses the same profile API seen above but providing a friendlier visual representation of the profiler output.</p><p>You can see how the total query time is being spent:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4ee5c39ca890d5f6ce31f5cdb609004ef568b492-817x279.png" style="width: 100%;" /><p>And you can see details on each part of the query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8adf87395648fe8ffa9afe844d22c8b73bea5d3b-530x592.png" style="width: 100%;" /><p>The profiler feature can help in comparing different queries and index configurations quickly.</p><p><strong>When to use the Profile API directly</strong></p><ul><li><strong>Automation</strong>: Scripts, monitoring tools, CI/CD pipelines</li><li><strong>Programmatic analysis</strong>: Custom parsing and processing of results</li><li><strong>Application integration</strong>: Profile directly from your code</li><li><strong>No Kibana access</strong>: Environments without a Kibana instance or remote servers</li><li><strong>Batch processing</strong>: Profile multiple queries systematically</li></ul><p><strong>When to use the search Profiler in Kibana</strong></p><ul><li><strong>Interactive debugging</strong>: Quick iteration and experimentation</li><li><strong>Visual analysis</strong>: Spot bottlenecks through color coding and hierarchy views</li><li><strong>Collaboration</strong>: Share visual results with other people</li><li><strong>Ad-hoc investigation</strong>: One-off performance checks without coding</li></ul><h3>Basic profiling KNN example</h3><p>For a simple KNN search, we can use:</p><pre><code>GET wikipedia-brute-force-1shard/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "knn": {
            "field": "embedding",
            "query_vector": [...],
            "k": 10,
            "num_candidates": 1500
          }
        },
        {
          "match": {
            "text": "country"
          }
        }
      ],
      "filter": {
        "term": {
          "category": "medium"
        }
      }
    }
  },
  "size": 10,
  "_source": [],
  "profile": true
}</code></pre><h3>Main KNN search metrics in Elasticsearch</h3><p>We can find <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-profile#profiling-knn-search">KNN metrics</a> in the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-profile#profiling-dfs-statistics">dfs section</a> of the profile. It shows the execution time for query, rewrite, and collector phases; it also shows the number of vector operations executed in the query.</p><h4>Vector search time (rewrite_time)</h4><p>This is the core metric for vector similarity computation time. In the profile object, it&#x27;s found at:</p><pre><code>"dfs": {
  "knn": [{
    "rewrite_time": 198703  // nanoseconds
  }]
}</code></pre><p>Unlike traditional Elasticsearch queries, <strong>kNN search performs the bulk of its computational work during the query rewrite phase</strong>. This is a fundamental architectural difference.</p><p>The <strong>rewrite_time</strong> value represents the cumulative time spent on <strong>Vector similarity calculations</strong>,<strong> HNSW graph traversal</strong> and <strong>Candidate evaluation</strong></p><h4>Vector operations count</h4><p>Found in the same KNN section:</p><pre><code>"vector_operations_count": 15000</code></pre><p>This metric tells you <strong>how many actual vector similarity calculations were performed</strong> during the kNN search.</p><h3>Understanding the count</h3><p>In our query with <code>num_candidates: 1500</code>, the vector operations count represents:</p><ul><li><strong>Approximate search efficiency</strong>: The number of vectors actually compared during HNSW (Hierarchical Navigable Small World) graph traversal</li><li><strong>Search accuracy trade-off</strong>: Higher counts mean a more thorough search, but longer execution time</li></ul><h4>Query processing time (time_in_nanos)</h4><p>After finding vector candidates, Elasticsearch processes the actual query on this reduced set:</p><pre><code>"query": [{ 
"type": "BooleanQuery", 
"description": "+DenseVectorQuery.Floats +text:country #category:medium", 
"time_in_nanos": 5064686, 
"children": [ 
{ 
"type": "Floats", 
"description": "DenseVectorQuery.Floats",
"time_in_nanos": 566195 
},
{ 
"type": "TermQuery", 
"description": 
"text:country", 
"time_in_nanos": 667083 
}, 
{ 
"type": "TermQuery", 
"description": "category:medium", 
"time_in_nanos": 2725249 
} 
]
}]</code></pre><p>The <code>time_in_nanos</code> metric <strong>covers the query phase</strong>: the computational work of finding and scoring relevant documents. This total time is broken down into children, and each child query represents a clause in our Boolean query:</p><p><strong>DenseVectorQuery</strong></p><ul><li>Processing kNN results: Scoring the candidate documents identified by kNN</li><li>Not computing vectors: Vector similarities were already computed in DFS phase</li><li>Fast because: Operating only on the pre-filtered candidate set (10-1500 docs, not millions)</li></ul><p><strong>TermQuery: text:country</strong></p><ul><li>Inverted index lookup: Finding documents containing &quot;country&quot;</li><li>Posting list traversal: Iterating through matching documents</li><li>Term frequency scoring: Computing BM25 scores for matched terms</li></ul><p><strong>TermQuery: category:medium</strong></p><ul><li>Filter application: Identifying documents with category=&quot;medium&quot;</li><li>No scoring needed: Filters don&#x27;t contribute to score (notice <code>score_count: 0</code>)</li></ul><h4>Collection time</h4><p>The time spent collecting and ranking results:</p><pre><code>"collector": [{
  "name": "QueryPhaseCollector",
  "reason": "search_query_phase", 
  "time_in_nanos": 270704,  // ~271 microseconds
  "children": [
    {
      "name": "TopScoreDocCollector",
      "reason": "search_top_hits",
      "time_in_nanos": 215204  // ~215 microseconds
    }
  ]
}]</code></pre><p>The<code> time_in_nanos</code> for collectors breaks down into:</p><p>TopScoreDocCollector</p><ul><li>Collects top hits from the query results.</li></ul><h4>Understanding collection in Elasticsearch&#x27;s architecture</h4><p>In Elasticsearch, a query is distributed among all relevant shards, where it is executed individually. The collection phase operates across Elasticsearch&#x27;s distributed shard architecture like this:</p><p><strong>Per-Shard Collection</strong>: Each shard collects its top-scoring documents using the <strong>TopScoreDocCollector</strong>. This happens in parallel across all shards that hold relevant data.</p><p><strong>Result Ranking and Merging</strong>: The coordinating node (the node that receives your query) then receives the top results from each shard and merges these partial results together by score to find the global top N results</p><p>So for our example:</p><p><strong>QueryPhaseCollector</strong> (270μs): The time spent on the query phase collection within a single shard.</p><p><strong>TopScoreDocCollector</strong> (215μs): The actual time spent collecting and ranking top hits from that shard</p><p>Note<strong> that </strong>these times represent the collection phase on a <em>single shard</em> in the profile output. For multi-shard indices, this process happens in parallel on each shard, and the coordinating node adds additional overhead for merging and global ranking, but this merge time is not included in the per-shard collector times shown in the Profiler API.</p><h2>Experiment set up</h2><p>The script consists of running 50 queries per experiment using the Profiler under four experiment setups. The experiments measure query processing, fetch, collection, and vector search execution times across multiple index configurations with different vector indexing strategies, quantization techniques, and infrastructure setups:</p><ul><li><strong><em>Experiment 1</em></strong>: Comparing query performance on a flat dense vector vs a HNSW quantized dense vector.</li><li><strong><em>Experiment 2</em></strong>: Understanding the effect of oversharding in vector search.</li><li><strong><em>Experiment 3: </em></strong>Understanding how Elastic boosts the performance of a vector query with filters by applying them before the more expensive KNN algorithm.</li><li><strong><em>Experiment 4:</em></strong> Comparing the performance of a cold query vs a cached query.</li></ul><h3>Getting started</h3><p><strong>Prerequisites</strong></p><ul><li>Python 3.x</li><li>An Elasticsearch deployment</li><li>Libraries<ul><li>Elasticsearch</li><li>Pandas</li><li>Numpy</li><li>Matplotlib</li><li>Datasets (HuggingFace library)</li></ul></li></ul><p>To reproduce this experiment, you can follow these steps:</p><p>1. Clone the repository </p><pre><code>git clone https://github.com/Alex1795/profiler_experiments_blog.git</code></pre><p>2. Install required libraries:</p><pre><code>pip install -r requirements.txt</code></pre><p>3. Run the upload script. Make sure to have the following environment variables set beforehand</p><ol><li>ES_HOST</li><li>API_KEY</li></ol><p>Example configuration:</p><pre><code>ES_HOST="<your_deployment_url>"
API_KEY="<your_api_key>"</code></pre><p>To run the upload script, use:</p><pre><code>python data_upload.py</code></pre><p>This might take several minutes; it is streaming the data from Hugging Face.</p><p>4. Once the data is indexed in Elastic, you can run the experiments using:</p><pre><code>python profiler_experiments.py</code></pre><h3>Dataset selection</h3><p>For this analysis, we will be using pre-generated embeddings generated from the <a href="https://huggingface.co/datasets/wikimedia/wikipedia">wikimedia/wikipedia dataset</a>, created using the <a href="https://huggingface.co/Qwen/Qwen3-Embedding-4B">Qwen/Qwen3-Embedding-4B model</a>. We can find these embeddings already generated in H<a href="https://huggingface.co/datasets/maknee/wikipedia_qwen_4b">ugging Face</a>.</p><p>The model produces 2560-dimensional embeddings that capture the semantic relationships in the Wikipedia articles. This makes this dataset an adequate candidate for testing vector search performance with different index configurations. We will take 50.000 datapoints (documents) from the dataset.</p><p>All the documents will be used in 4 indices with 4 different configurations for the <code>dense_vector</code> field.</p><h3>Profiler data extraction</h3><p>The heart of the experiments is the <a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/profiler_experiments.py#L116">extract_profile_data</a> method. This function gets these metrics from the response:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Original field in the Search Profile</td><td style="border: 1px solid #ccc; padding: 4px;">Extracted metric</td><td style="border: 1px solid #ccc; padding: 4px;">comment</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">response['took']</td><td style="border: 1px solid #ccc; padding: 4px;">total_time_ms</td><td style="border: 1px solid #ccc; padding: 4px;">The total time the query took to execute, populated directly from the top-level 'took' key.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">shard['dfs']['knn'][0]['rewrite_time']</td><td style="border: 1px solid #ccc; padding: 4px;">vector_search_time_ms</td><td style="border: 1px solid #ccc; padding: 4px;">The total time spent on vector search operations across all shards, aggregated and converted from nanoseconds to milliseconds.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">shard['dfs']['knn'][0]['vector_operations_count']</td><td style="border: 1px solid #ccc; padding: 4px;">vector_ops_count</td><td style="border: 1px solid #ccc; padding: 4px;">The total number of vector operations performed during the search, aggregated across all shards.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">shard['searches'][0]['query'][0]['time_in_nanos']</td><td style="border: 1px solid #ccc; padding: 4px;">query_time_ms</td><td style="border: 1px solid #ccc; padding: 4px;">The total time spent on query execution across all shards, aggregated and converted from nanoseconds to milliseconds.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">shard['searches'][0]['collector'][0]['time_in_nanos']</td><td style="border: 1px solid #ccc; padding: 4px;">collect_time_ms</td><td style="border: 1px solid #ccc; padding: 4px;">The total time spent on collecting and ranking results across all shards, aggregated and converted from nanoseconds to milliseconds.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">shard['fetch']['time_in_nanos']</td><td style="border: 1px solid #ccc; padding: 4px;">fetch_time_ms</td><td style="border: 1px solid #ccc; padding: 4px;">The total time spent on retrieving documents across all shards, aggregated and converted from nanoseconds to milliseconds.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">len(response['profile']['shards'])</td><td style="border: 1px solid #ccc; padding: 4px;">shard_count</td><td style="border: 1px solid #ccc; padding: 4px;">The total number of shards the query was executed on.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">(Calculated)</td><td style="border: 1px solid #ccc; padding: 4px;">other_time_ms</td><td style="border: 1px solid #ccc; padding: 4px;">The remaining time after accounting for vector search, query, collect, and fetch times, representing overhead such as network latency.</td>
                    </tr>
              </tbody>
            </table>
          <h3>Indices configuration</h3><p>Each index will have 4 fields:</p><ul><li>text <strong>(text type)</strong>: The original text used to generate the embedding</li><li>embedding <strong>(dense_vector type)</strong>: 2560-dimensional embedding with a different configuration for each index</li><li>category <strong>(keyword type)</strong>: A classification of the length of the text short, medium or long</li><li>text_length<strong> (integer type)</strong>: Words count of the text</li></ul><p><a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/data_upload.py#L9-L36">wikipedia-brute-force-1shard</a></p><p>Relevant settings:</p><ul><li>Embedding type: float</li><li>Number of shards: 1</li></ul><p><a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/data_upload.py#L39-L66">Wikipedia-brute-force-3shards</a></p><p>Relevant settings:</p><ul><li>Embedding type: float</li><li>Number of shards: 3</li></ul><p><a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/data_upload.py#L69-L98">wikipedia-float32-hnsw</a></p><p>Relevant settings:</p><ul><li>Embedding type: HNSW</li><li>m=16 (The number of neighbors each node will be connected to in the HNSW graph)</li><li>ef_construction=200 (The number of candidates to track while assembling the list of nearest neighbors for each new node)</li></ul><p>To learn more about parameters for the dense vector field, see: <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-params">Parameters for dense vector fields</a></p><h2>Experiment execution</h2><h3>Experiment 1: Flat vs int 8 HNSW dense vector</h3><p><strong>Objective:</strong> Compare the performance of a flat dense vector against a vector using HNSW.</p><p><strong>Indices to use:</strong></p><ul><li>wikipedia-brute-force-1shard</li><li>wikipedia-int8-hnsw</li></ul><p><strong>Hypothesis: </strong>The HNSW index will have significantly lower query latency, especially on larger datasets, as it reduces memory usage by 75% and it avoids comparing the query vector with each vector in the dataset.</p><p><strong>Kibana Search Profiler results:</strong></p><ul><li>wikipedia-brute-force-1shard</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/af55b18fd9fdd06744e4ebcb31df5d05ce593868-878x223.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ea589d6b609ddc2bb288b9dde88de08dde115b0b-444x499.png" style="width: 100%;" /><ul><li>wikipedia-int8-hnsw</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a9ba30bf7a6233a7c772da71852999639a8af17a-882x226.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b2a635575ae3e1df3ee9958f05d8da90b60eea1f-524x500.png" style="width: 100%;" /><p><strong>Experiment results:</strong></p><pre><code>=== Experiment 1: Flat vs. HNSW dense vector ===

Testing Flat (float32) (wikipedia-brute-force-1shard)...
  Average total time (ES): 528.67ms
  Average vector search time: 517.52ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 7.37ms
  Average wall clock time: 853.63ms
  Vector operations: 50000

Testing HNSW (int8) (wikipedia-int8-hnsw)...
  Average total time (ES): 12.67ms
  Average vector search time: 3.66ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 7.47ms
  Average wall clock time: 140.74ms
  Vector operations: 2352</code></pre><p>We can see from the metrics that the float approach did <strong>50000</strong> vector operations, which means it compared the query vector with each vector in the dataset, which resulted in <strong>~140 times increase </strong>in<strong> the vector search time</strong> when compared with the HNSW vector. </p><p>From the graph below, we can visualize that even if other metrics are similar, the Vector search takes much longer with a float-type dense vector. That being said, it is worth noting that BBQ quantization reduces the recall when compared with a non-quantized vector.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f14eaf8be209ff27d78f0e0409e9ee423aba8519-1600x1091.png" style="width: 100%;" /><h3>Experiment 2: Impact of over-sharding on brute force search</h3><p><strong>Objective</strong>: Understand how excessive sharding on a single-node Elasticsearch deployment negatively impacts vector search query performance</p><p><strong>Indices to use:</strong></p><ul><li>wikipedia-brute-force-1shard: The single-shard baseline.</li><li>wikipedia-brute-force-3shards: The multi-shard version.</li></ul><p><strong>Hypothesis</strong>: On a single-node deployment, increasing the number of shards will degrade query performance rather than improve it. The 3-shard index will exhibit higher total query latency compared to the 1-shard index. This can be extrapolated to having an inadequate number of shards for our infrastructure.</p><p><strong>Kibana Search Profiler results:</strong></p><ul><li>wikipedia-brute-force-1shard</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f08308e6249074cc6eb53bfb411ce0ea960ee850-876x247.png" style="width: 100%;" /><ul><li>wikipedia-brute-force-3shards</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/10caeb04aef97979f5bb730ca14942fd87e8c1ef-877x439.png" style="width: 100%;" /><p>Notice time is more than 3 times here because it runs in 3 separate shards.</p><p><strong>Experiment results:</strong></p><pre><code>=== Experiment 2: Impact of Sharding on Brute Force Search ===

Testing 1 Shard (wikipedia-brute-force-1shard)...
  Shards: 1
  Average total time (ES): 40.00ms
  Average vector search time: 27.15ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 8.50ms
  Average wall clock time: 204.40ms
  Vector operations: 50000

Testing 3 Shards (wikipedia-brute-force-3shards)...
  Shards: 3
  Average total time (ES): 67.77ms
  Average vector search time: 55.36ms
  Average query time: 0.02ms
  Average collect time: 0.03ms
  Average fetch time: 8.70ms
  Average wall clock time: 338.77ms
  Vector operations: 50000</code></pre><p>We can see that even when executing the exact same number of vector operations, having <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards#create-a-sharding-strategy">too many shards</a> for this specific dataset added more vector search time, overall making the query slower. This demonstrates how our sharding strategy must go hand in hand with our cluster architecture.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6f84faecbcc4c55acc9b8026d5a7cbdc787e4541-1600x903.png" style="width: 100%;" /><h3>Experiment 3: Combined filter and vector search</h3><p><strong>Objective</strong>: Demonstrate how Elasticsearch efficiently handles pre-filtering before a vector search.</p><p><strong>Indices to use:</strong></p><ul><li>wikipedia-brute-force-1shard</li></ul><p><strong>Note:</strong> This experiment is only applicable to hosted deployments, since we can&#x27;t control the number of shards on serverless. <strong>It will be automatically skipped in a serverless project.</strong></p><p><strong>Setup</strong>: Construct a query that combines a KNN query for a vector search with a filter.</p><p><strong>Hypothesis</strong>: When a filter is applied, Elasticsearch first prunes the documents that don&#x27;t match the filter before performing the expensive vector search on the matching documents. The Profile API will show that the number of documents searched by the vector search operation is significantly lower than the total number of documents in the index, leading to a faster query.We will run the query with 4 configurations:</p><ul><li><a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/profiler_experiments.py#L362">No filter</a></li></ul><pre><code>"knn": {
        "field": "embedding",
        "query_vector":[...],
        "k": k,
        "num_candidates": num_candidates,
        "filter":[] // no filters
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9f87da872084856af951838cd8956cf1f1967a72-885x243.png" style="width: 100%;" /><ul><li>Term filter on the<a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/profiler_experiments.py#L363"> category field</a></li></ul><pre><code>"knn": {
        "field": "embedding",
        "query_vector":[...],
        "k": k,
        "num_candidates": num_candidates,
        "filter":[
          {
            "term":{
              "category": "short" // term filter on category
               }
          }
         ]
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d1a86e141843cf17128df11c28f4a7c74212f204-883x232.png" style="width: 100%;" /><ul><li>Range filter on the<a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/profiler_experiments.py#L364"> text_length field</a></li></ul><pre><code>"knn": {
        "field": "embedding",
        "query_vector":[...],
        "k": k,
        "num_candidates": num_candidates,
        "filter":[
          {
            "range":{
              "text_length": { // range filter on text_length
 			"gte": 1000, 
"lte": 2000
}
               }
          }
         ]
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a1f30c326816467154cc6e4471738329c3157dfb-887x235.png" style="width: 100%;" /><ul><li>A <a href="https://github.com/Alex1795/profiler_experiments_blog/blob/main/profiler_experiments.py#L365-L368">combined filter</a>: term filter on the category field + range filter on the text_length field</li></ul><pre><code>"knn": {
        "field": "embedding",
        "query_vector":[...],
        "k": k,
        "num_candidates": num_candidates,
        "filter":[ // the two previous filters combined in the same query
          {
            "range":{
              "text_length": {
 			"gte": 1000, 
"lte": 2000
}
               }
          },
	   {
            "term":{
              "category": "short"
               }
          }
         ]
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2b8fa7d928376c47552dc0ef32cda0c5716e95b0-891x243.png" style="width: 100%;" /><p><strong>Results:</strong></p><pre><code>=== Experiment 3: Combined Filter and Vector Search ===

Testing No Filter...
  Total hits: 10.0
  Average total time (ES): 50.80ms
  Average vector search time: 42.37ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 7.07ms
  Average wall clock time: 287.01ms
  Vector operations: 50000

Testing Category Filter...
  Total hits: 10.0
  Average total time (ES): 8.00ms
  Average vector search time: 0.78ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 6.11ms
  Average wall clock time: 134.40ms
  Vector operations: 198

Testing Text Length Filter...
  Total hits: 10.0
  Average total time (ES): 18.40ms
  Average vector search time: 9.93ms
  Average query time: 0.01ms
  Average collect time: 0.02ms
  Average fetch time: 7.15ms
  Average wall clock time: 144.74ms
  Vector operations: 10387

Testing Combined Filters...
  Total hits: 1.0
  Average total time (ES): 2.20ms
  Average vector search time: 0.68ms
  Average query time: 0.00ms
  Average collect time: 0.01ms
  Average fetch time: 0.59ms
  Average wall clock time: 127.28ms
  Vector operations: 1</code></pre><p>We can see that applying filters adds fetch time to our search, but in exchange, it reduces the vector search time dramatically because it executes less vector operations. This shows how Elastic handles filtering before vector search to improve performance and avoid wasting resources by running the vector search before filtering out irrelevant documents.</p><p>Even if the results are constrained to a maximum (k=10), underneath, more vector operations are being executed if we don&#x27;t filter out some documents before. This effect is more notorious with a flat dense vector, of course, but even in quantized vectors, we can still reduce execution time by applying filters before the vector search.</p><p>In the graph, we can see how the query time increased with the filters, but the vector search time is much lower, resulting in lower times overall. We can also see that having more filters impacted the time positively (meaning it lowered the total time), so actually applying the filters is worth it, as the overall time decreases.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c1b1a6a095112946397bee88c82ab721c8834ffb-1500x800.png" style="width: 100%;" /><p>The results highlight how filtering improves efficiency and is a key benefit of using a hybrid search engine like Elasticsearch.</p><h3>Experiment 4: Compare cold vs cached query performance</h3><p><strong>Objective:</strong> Demonstrate how Elasticsearch&#x27;s caching mechanisms significantly improve query performance when the same vector search is executed multiple times.</p><p><strong>Indices to use:</strong></p><ul><li>wikipedia-float32-hnsw</li></ul><p><strong>Setup</strong>:</p><p>First, clear the Elasticsearch cache</p><p>Execute the same vector search query twice:</p><ul><li>Cold query: First execution after cache clearing</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/56357cca942a419da41bbcc69341dae23d076c1d-883x199.png" style="width: 100%;" /><ul><li>Cached Query: Second execution with caches populated</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ed272973eac31aa2d12d913bd2a1b5474509deee-889x248.png" style="width: 100%;" /><p><strong>Hypothesis:</strong>The cached (warm) query will execute significantly faster than the cold query. The Profile API will show reduced times across all query phases, with the most dramatic improvements in vector search operations and data retrieval phases.</p><p><strong>Results:</strong></p><pre><code>=== Experiment 4: Cache Performance (Cold vs Warm Queries) ===

Testing Cold Query (First Run)...
  Clearing caches...
  Runs executed: 1
  Average total time (ES): 490.00ms
  Average vector search time: 474.77ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 13.48ms
  Average wall clock time: 728.77ms
  ↳ This represents cold start performance

Testing Warm Query (Cached)...
  Runs executed: 5
  Average total time (ES): 14.60ms
  Average vector search time: 6.99ms
  Average query time: 0.01ms
  Average collect time: 0.01ms
  Average fetch time: 3.96ms
  Average wall clock time: 144.35ms</code></pre><p>This experiment shows the impact of Elasticsearch&#x27;s cache on vector search performance. Elastic keeps the embedding data in memory, so it executes faster. On the other hand, if the data isn’t in memory and Elastic has to read from disk often, searches become slower.</p><p>In this case, the cold query, executed after clearing all caches, took <strong>490ms total time</strong> with vector search operations consuming 474.77ms. This shows the &quot;first-time&quot; cost of loading index segments and vector data structures into memory. In contrast, the warm queries averaged just <strong>14.6ms total time</strong> with vector search dropping to 6.99ms, demonstrating a remarkable <strong>33x overall speedup and 68x improvement in vector search operations.</strong></p><p>In the graph, we can see the huge difference between the cached and cold queries. This result highlights why vector search systems benefit from an initial warm-up period.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0ee321e4b23bfae87575e50c65f8ec094dd27fbc-1600x862.png" style="width: 100%;" /><h2>Conclusion</h2><p>Search profiling can let us look into the execution of our queries and, by extension, compare them. This opens the door to comprehensive analysis that can drive design decisions. In our particular experiment, we could see the difference between dense vector configurations and derive complex insights.</p><p>Particularly, in our experiments, we have been able to use the profiler to confirm in practice that:</p><ol><li>A quantized dense vector performs queries much faster than a non-quantized one</li><li>Having an appropriate sharding strategy can lead to better performance</li><li>Combining vector search + filters is a powerful tool to improve performance in our queries</li></ol><p>Cache can impact performance meaningfully, so for production systems, it might be a good idea to start with a warm-up process using common queries.</p>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
            <pubDate>Mon, 22 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6150bb846170d9be926a19260846a161ed377a5f-1098x542.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://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&#x27;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><pre><code>GET products/_search
{
  "query": {
    "bool": {
      "must": [ { "match": { "description": "running shoes" }}],
      "should": [ { "term": { "brand": { "value": "nike", "boost": 1 }}}]
    }
  }
}</code></pre><p>This kind of query results in the following behavior:</p><pre><code>final_score = base_BM25 + should_BM25</code></pre><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>When base_BM25 is small (0.12), +2 dominates the score — roughly an 18× increase.</li><li>When base_BM25 is large (12), the same +2 barely shifts the document —&nbsp; only about a 17% increase.</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><pre><code>final_score = base_BM25 + feature_score</code></pre><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>The feature score can dominate BM25 in one query and be negligible in another.</li><li>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.</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>Promote higher-margin items modestly</li><li>Reduce exposure for low-stock products, but don’t hide them</li><li>Give this user segment a slight uplift for matching products</li><li>Boost for popularity, but not so much that textual relevance is lost</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><pre><code>final_score = BM25 × boost_factor</code></pre><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>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.</li><li>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.</li><li>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.</li></ol><p>Elasticsearch’s <code>function_score</code> query provides an elegant mechanism for expressing this pattern. By using:</p><ul><li><strong>score_mode: “sum”</strong> to assemble a boost factor (building the multiplier), and</li><li><strong>boost_mode: “multiply”</strong> to apply the boost (multiplier) to BM25</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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Brand</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">nike</td><td style="border: 1px solid #ccc; padding: 4px;">“Nike basketball shoes”</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">adidas</td><td style="border: 1px solid #ccc; padding: 4px;">“New Adidas basketball shoes”</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">reebok</td><td style="border: 1px solid #ccc; padding: 4px;">“Reebok basketball shoes”</td>
                    </tr>
              </tbody>
            </table>
          <p>We can create an index with the above products with the following commands from Kibana Dev Tools:</p><pre><code>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" }</code></pre><p>With this dataset, we now evaluate three queries:</p><ul><li>A baseline “basketball shoes” search</li><li>The same query with a 50% promotion for Adidas and a 25% promotion for Nike</li><li>A specific “Reebok basketball shoes” query while the Adidas and Nike promotions are still active</li></ul><p>Each scenario highlights a different property of multiplicative boosting.</p><h3>1. Baseline ranking: No promotion</h3><pre><code>GET products/_search
{
  "size": 3,
  "_source": ["brand", "description"],
  "query": {
    "match": { "description": "basketball shoes" }
  }
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/050f44956c112ea9916d34946e9296480354f7d3-2322x1324.png" style="width: 100%;" /><p>This query returns the following results where Nike and Reebok are ranked above adidas:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Rank</td><td style="border: 1px solid #ccc; padding: 4px;">Brand</td><td style="border: 1px solid #ccc; padding: 4px;">Score (BM25)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1/2 (tie)</td><td style="border: 1px solid #ccc; padding: 4px;">nike</td><td style="border: 1px solid #ccc; padding: 4px;">0.27845407</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1/2 (tie)</td><td style="border: 1px solid #ccc; padding: 4px;">reebok</td><td style="border: 1px solid #ccc; padding: 4px;">0.27845407</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">adidas</td><td style="border: 1px solid #ccc; padding: 4px;">0.24686474</td>
                    </tr>
              </tbody>
            </table>
          <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><pre><code>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"
    }
  }
}</code></pre><h3>How the multiplier is constructed</h3><ul><li>Base weight = 1.0</li><li>Adidas gets an additional +0.5</li><li>So Adidas’s multiplier = 1.5</li><li>Nike gets an additional +0.25</li><li>So Nike’s multiplier = 1.25</li><li>All other brands (including Reebok) get the base weight multiplier = 1.0</li></ul><h3>Apply multiplier:</h3><p>Final score = BM25 × multiplier</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Product</td><td style="border: 1px solid #ccc; padding: 4px;">BM25</td><td style="border: 1px solid #ccc; padding: 4px;">Multiplier</td><td style="border: 1px solid #ccc; padding: 4px;">Final score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Adidas</td><td style="border: 1px solid #ccc; padding: 4px;">0.24686474</td><td style="border: 1px solid #ccc; padding: 4px;">1.5</td><td style="border: 1px solid #ccc; padding: 4px;">0.37029710</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Nike</td><td style="border: 1px solid #ccc; padding: 4px;">0.27845407</td><td style="border: 1px solid #ccc; padding: 4px;">1.25</td><td style="border: 1px solid #ccc; padding: 4px;">0.34806758</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Reebok</td><td style="border: 1px solid #ccc; padding: 4px;">0.27845407</td><td style="border: 1px solid #ccc; padding: 4px;">1.0</td><td style="border: 1px solid #ccc; padding: 4px;">0.27845407</td>
                    </tr>
              </tbody>
            </table>
          <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>Adidas and Nike both gain visibility, but in proportion to their configured uplifts.</li><li>The relative differences in BM25 still matter; we are reshaping the ranking, not replacing it.</li><li>The ordering changes primarily where BM25 scores are close.</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><pre><code>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"
    }
  }
}</code></pre><p>The response shows the following results:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Rank</td><td style="border: 1px solid #ccc; padding: 4px;">Brand</td><td style="border: 1px solid #ccc; padding: 4px;">Final score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1</td><td style="border: 1px solid #ccc; padding: 4px;">reebok</td><td style="border: 1px solid #ccc; padding: 4px;">1.3011196</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td><td style="border: 1px solid #ccc; padding: 4px;">adidas</td><td style="border: 1px solid #ccc; padding: 4px;">0.3702971</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">nike</td><td style="border: 1px solid #ccc; padding: 4px;">0.34806758</td>
                    </tr>
              </tbody>
            </table>
          <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>When BM25 scores are close, boosts can shift the relative ordering.</li><li>When BM25 scores differ significantly (as they do here, due to strong text matching), the same boosts have little practical effect.</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>The influence is proportional, not arbitrary. A percentage-based uplift has the same proportional effect regardless of the underlying BM25 scale.</li><li>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.</li><li>The math is stable across queries. The same promotion works correctly whether the match is broad or highly specific.</li><li>Application logic stays clean. The business layer decides the uplift; Elasticsearch applies it predictably.</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>Which margin thresholds matter for your business</li><li>Whether popularity should rise or fall based on seasonality</li><li>How to interpret customer behavior or cohort membership</li><li>How to encode campaign rules</li><li>When to surface or suppress certain product groups</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>Application logic: decides <em>what</em> should be influenced.</li><li>BM25 provides the core text relevance.</li><li><code>function_score</code> applies influence in a mathematically stable way.</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>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Sander Philipse, Pete Galeotti]]></dc:creator>
            <pubDate>Fri, 19 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/46f1e3c09e17cb8aa2a1cca64624bf533e55fe1d-1746x1096.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <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&#x27;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&#x27;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>Search VCU</li><li>Ingest VCU</li><li>Machine Learning (ML) VCU</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&#x27;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&#x27;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&#x27;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 &quot;warm&quot; 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&#x27;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&#x27;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>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.</li><li>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.</li><li>You do it yourself, outside of our systems, and just bring your vectors or other inference results to store and search in Elasticsearch.</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&#x27;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>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.</li><li>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.</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>Limited support</li><li>Base support</li><li>Enhanced support</li><li>Premium support</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&#x27;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>
        </item>
        <item>
            <title><![CDATA[Building a local RAG personal knowledge assistant with LocalAI and Elasticsearch]]></title>
            <description><![CDATA[Learn how to create a private, offline local RAG personal knowledge assistant that can summarize meetings and internal reports using e5-small for embeddings and dolphin3.0-qwen2.5-0.5b for completions in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/local-rag-personal-knowlege-assistant-localai-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/local-rag-personal-knowlege-assistant-localai-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Thu, 18 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9441c88cb30945142184d9cfadc2a2ea4b9b709b-1600x906.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Building a Retrieval Augmented Generation (RAG) system locally is possible with components that run entirely on a mid-range laptop. Elasticsearch provides vector database infrastructure, while <a href="https://localai.io/">LocalAI</a> makes it simple to run small, efficient language models without requiring a powerful GPU or external services. By combining these tools, we can enable private, fast, and offline access to company or personal data.</p><p>The goal is to build a full RAG system: Embeddings for retrieval and LLM for answers generated locally, while using as few resources as possible without affecting latency and the quality of the answers.</p><h2>Prerequisites</h2><ul><li>Docker</li><li>Python 3.10+</li></ul><h2>Use case: Personal knowledge assistant</h2><p>The goal is to unlock insights from local files through a simple assistant. In this example, we&#x27;ll focus on internal documentation from a CRM migration project, which includes meeting transcripts, progress reports, and planning notes. Everything will run on the same machine; Elasticsearch will handle storage and semantic search, while a local LLM will produce answers and summaries based on the retrieved documents.</p><h2>Why do this locally?</h2><p>Deciding to go local and use this stack of tools in particular presents multiple advantages, such as:</p><ul><li><strong>Privacy:</strong> Since you are using a local LLM, you have full discretion over the information you pass to it. While some cloud-based LLMs offer enterprise tiers that disable tracking or data retention, this is not guaranteed across all providers or plans.</li><li><strong>Flexibility: </strong>Tools like LocalAI offer a <a href="https://localai.io/gallery.html">wide range of models</a> and make it easy to replace them as needed, whether for evaluating new models, running tests, handling security-related updates, or switching between models to support different types of tasks. On the other hand, using Elasticsearch as the vector database offers integration with many <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-model-ref#ml-nlp-model-ref-text-embedding">third-party embedding models</a>.</li><li><strong>Cost: </strong>With this approach you don’t need to pay for any cloud-based service for embeddings or LLM usage, which makes it more affordable.</li><li><strong>Independence from the internet: </strong>Another advantage of a local solution is that you can work completely offline, which also makes it suitable for isolated or air-gaped environments where network access is intentionally limited due to strict security or compliance requirements.</li><li><strong>Speed:</strong> Depending on the chosen model and your hardware, this can potentially be faster than a web service.</li></ul><h2>Setting up the core: Minimal Elasticsearch instance</h2><p>To install Elasticsearch locally, we will use <a href="https://github.com/elastic/start-local">start-local</a>, which allows you to install Elasticsearch with just one command using Docker under the hood.</p><p>Since we will not be using Kibana, we will <a href="https://github.com/elastic/start-local?tab=readme-ov-file#install-only-elasticsearch">install only Elasticsearch</a> with the <code>--esonly</code> flag:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh -s -- --esonly</code></pre><p>If everything goes well, you’ll see a message like this:</p><pre><code>🎉 Congrats, Elasticsearch is installed and running in Docker!
🔌 Elasticsearch API endpoint: http://localhost:9200
🔑 API key: MDk0NVRwa0IxRGRjVmVKSGl2ZFc6dUFaNkZTUHVXRzEzVjdrejQzSUNxZw==</code></pre><p><em><strong>NOTE:</strong> If you forgot your credentials, you can find them at <code>…/elastic-start-local/.env</code></em></p><p>You can check if the Elasticsearch instance is running using the command <code>docker ps</code></p><pre><code>docker ps</code></pre><p>Response:</p><pre><code>CONTAINER ID   IMAGE                                                       COMMAND                  CREATED          STATUS                    PORTS                                NAMES
061fbfbb67bb   docker.elastic.co/elasticsearch/elasticsearch:9.1.3-arm64   "/bin/tini -- /usr/l…"   11 minutes ago   Up 11 minutes (healthy)   127.0.0.1:9200->9200/tcp, 9300/tcp   es-local-dev</code></pre><p>To this Elasticsearch instance, we can send requests, for example:</p><pre><code>curl -X GET "http://localhost:9200/" \
  -H "Authorization: ApiKey MDk0NVRwa0IxRGRjVmVKSGl2ZFc6dUFaNkZTUHVXRzEzVjdrejQzSUNxZw=="</code></pre><p>Response:</p><pre><code>{
  "name" : "061fbfbb67bb",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "IC_VY7cyQw6F_wJbH5Ik3A",
  "version" : {
    "number" : "9.1.3",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "0c781091a2f57de895a73a1391ff8426c0153c8d",
    "build_date" : "2025-08-24T22:05:04.526302670Z",
    "build_snapshot" : false,
    "lucene_version" : "10.2.2",
    "minimum_wire_compatibility_version" : "8.19.0",
    "minimum_index_compatibility_version" : "8.0.0"
  },
  "tagline" : "You Know, for Search"
}</code></pre><p>This local instance will store our CRM migration notes and reports so they can later be searched semantically.</p><h2>Adding AI: Choosing the right local models</h2><p>So, now we will choose two models to make it work:</p><ul><li><strong>Embeddings model:</strong> For embeddings, we will use the multilingual model <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-e5">multilingual-e5-small</a>. It is available pre-configured in Elasticsearch but needs to be deployed before use.</li><li><strong>Completion model:</strong> For chatting, generating responses, and interacting with the data, we need to choose a model with the best size-to-performance ratio. For that, I prepared the following table comparing some small-sized models:</li></ul>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model</td><td style="border: 1px solid #ccc; padding: 4px;">Parameters</td><td style="border: 1px solid #ccc; padding: 4px;">Size in memory (Approx)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">llama-smoltalk-3.2-1b-instruct</td><td style="border: 1px solid #ccc; padding: 4px;">1B</td><td style="border: 1px solid #ccc; padding: 4px;">500 MB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">dolphin3.0-qwen2.5-0.5b</td><td style="border: 1px solid #ccc; padding: 4px;">0.5B</td><td style="border: 1px solid #ccc; padding: 4px;">200 MB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">fastllama-3.2-1b-instruct</td><td style="border: 1px solid #ccc; padding: 4px;">1B</td><td style="border: 1px solid #ccc; padding: 4px;">550 MB</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">smollm2-1.7b-instruct</td><td style="border: 1px solid #ccc; padding: 4px;">1.7B</td><td style="border: 1px solid #ccc; padding: 4px;">1.0 GB</td>
                    </tr>
              </tbody>
            </table>
          <p>The final decision depends on your needs and your machine, and for this example we will use the <a href="https://huggingface.co/dphn/Dolphin3.0-Qwen2.5-0.5B"><strong>dolphin3.0-qwen2.5-0.5b</strong></a> model because it is a model but with powerful capabilities in a RAG system. It provides the best size-to-parameters ratio in the table. All the other options work well for this use case but by its size, the <strong>dolphin3.0-qwen2.5-0.5b</strong> is our choice.</p><p>The balance of CPU, and memory usage is important because our assistant needs to summarize meetings and reports in a reasonable time using mid range laptop resources.</p><p>To download the <strong>dolphin3.0-qwen2.5-0.5b</strong>, we will use <a href="https://localai.io/">LocalAI</a>, which is an easy-to-use solution to run models locally. You can <a href="https://localai.io/docs/getting-started/models/">install LocalAI on your machine</a>, but we will use Docker to isolate the LocalAI service and models. Follow <a href="https://localai.io/basics/container/">these instructions</a> to install the official LocalAI Docker image.</p><h3>LocalAI REST API</h3><p>One of the main features of LocalAI is its ability to serve models through HTTP requests in an <a href="https://platform.openai.com/docs/api-reference/chat/create">OpenAI API-compatible format</a>. This feature will be useful in later steps.</p><p>The LocalAI service will be accessible at port 8080, which is where we will send the HTTP requests. Let’s send a request to download the <strong>dolphin3.0-qwen2.5-0.5b</strong> model:</p><pre><code>curl -X POST http://localhost:8080/models/apply \
  -H "Content-Type: application/json" \
  -d '{"id": "dolphin3.0-qwen2.5-0.5b"}'

# Response: 
{"uuid":"d5212e97-bf1d-11f0-ba2a-22b2311545e6","status":"http://localhost:8080/models/jobs/d5212e97-bf1d-11f0-ba2a-22b2311545e6"}% </code></pre><p>We can check the download status using the ID generated in the previous step:</p><pre><code>curl -s http://localhost:8080/models/jobs/d5212e97-bf1d-11f0-ba2a-22b2311545e6

# Response: 

{
  "deletion": false,
  "file_name": "/models/dolphin-3.0-qwen2.5-0.5b-instruct.Q4_K_M.gguf.partial",
  "error": null,
  "processed": false,
  "message": "processing",
  "progress": 9.860515383462856,
  "file_size": "391.2 MiB",
  "downloaded_size": "37.2 MiB",
  "gallery_element_name": ""
}</code></pre><p>The <code>progress</code> field represents the percentage of the current download; we need to wait for it to complete. Once it’s completed, we can create a test to make sure that everything is working well:</p><pre><code>curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "dolphin3.0-qwen2.5-0.5b",
    "messages": [
      {
        "role": "user",
        "content": "Why is the sky blue?"
      }
    ],
    "stream": false
  }</code></pre><p>See the LLM result <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/local-rag-with-lightweight-elasticsearch/app-logs/why-is-the-sky-blue.md">here</a>.</p><h2>Showing the workflow: Project data to answers</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9441c88cb30945142184d9cfadc2a2ea4b9b709b-1600x906.png" style="width: 100%;" /><p>Now that we have an embeddings model and a general-purpose LLM model, it’s time to combine them with Elasticsearch and build a tool that can help us explore our data. For this walkthrough, we prepared a folder with relevant data for our mock CRM-migration project, including reports and meeting transcripts. In a real application, this ingestion step would typically be automated through a deployment pipeline or a background process, but here we will trigger it manually for simplicity.</p><h3>Data overview</h3><p>All the dataset is accessible in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/local-rag-with-lightweight-elasticsearch/Dataset">this GitHub repository</a>.</p><p>/CRM migration data</p><p>|__</p><p>|-- meeting_QA-team_wednesday.txt</p><p>|-- meeting_development-team_monday.txt</p><p>|-- meeting_management-sync_friday.txt</p><p>|-- report_QA-team.txt</p><p>|-- report_development-team.txt</p><p>To illustrate how it looks, let&#x27;s test a couple of examples:</p><p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/local-rag-with-lightweight-elasticsearch/Dataset/meeting_development-team_monday.txt">meeting_development-team_monday.txt</a>:</p><pre><code>MEETING TRANSCRIPT - DEVELOPMENT TEAM

Date: Monday, September 16, 2025

Time: 09:00 AM - 10:15 AM

Participants: Alice (Tech Lead), John (Senior Developer), Sarah (Backend Developer), Mike (DevOps Engineer)

[09:02] Alice:Let's review the search API deployed last week. Any issues?

[09:03] Sarah:API works but performance degrades with 1,000+ queries per minute. Response times jump from 200ms to 3 seconds.

...</code></pre><p><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/local-rag-with-lightweight-elasticsearch/Dataset/report_development-team.txt">report_development-team.txt</a>:</p><pre><code>WEEKLY REPORT - DEVELOPMENT TEAM
Week of September 16-20, 2025
Prepared by: Alice Thompson, Tech Lead

=== EXECUTIVE SUMMARY ===
Development team completed critical infrastructure components but identified performance bottlenecks requiring attention before production deployment.

=== KEY ACCOMPLISHMENTS ===
- Database schema and indexes completed for CRM
...</code></pre><h3>Elasticsearch setup</h3><p>Now we need a data structure and an inference endpoint in Elasticsearch to store and embed the data.</p><p>First, let’s create an inference endpoint using the <code>.multilingual-e5-small</code> model:</p><pre><code>def setup_inference_endpoint():
    inference_id = "e5-small-model"
    try:
        es_client.inference.put(
            inference_id=inference_id,
            task_type="text_embedding",
            body={
                "service": "elasticsearch",
                "service_settings": {
                    "num_allocations": 1,
                    "num_threads": 1,
                    "model_id": ".multilingual-e5-small",
                },
            },
        )

        print(f"✅ Inference endpoint '{inference_id}' created successfully")
    except Exception as e:
        print(f"❌ Error creating inference endpoint: {str(e)}")</code></pre><p>The response should be this:</p><pre><code>{"inference_id":"e5-small-model","task_type":"text_embedding","service":"elasticsearch","service_settings":{"num_allocations":1,"num_threads":1,"model_id":".multilingual-e5-small"},"chunking_settings":{"strategy":"sentence","max_chunk_size":250,"sentence_overlap":1}}</code></pre><p>This will automatically download the model and create the inference endpoint for our embeddings during ingestion and query time. If you need to install the embeddings model in an air-gapped environment, you can follow <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-e5#air-gapped-install-e5">these instructions</a>.</p><p>Now, let’s create the mappings for the data. We will create 3 fields: <code>file_title</code> to store the file name, <code>file_content</code> to store the file content of each document, and <code>semantic</code> to store the embeddings and plain text content of both fields (<code>file_title</code> and <code>file_content</code>):</p><pre><code>def setup_index():
    try:
        if es_client.indices.exists(index=INDEX_NAME):
            print(f"✅ Index '{INDEX_NAME}' already exists")
            return False

        print(f"Creating index '{INDEX_NAME}'...")
        es_client.indices.create(
            index=INDEX_NAME,
            body={
                "mappings": {
                    "properties": {
                        "file_title": {"type": "text", "copy_to": "semantic_field"},
                        "file_content": {"type": "text", "copy_to": "semantic_field"},
                        "semantic_field": {
                            "type": "semantic_text",
                            "inference_id": "e5-small-model",
                        },
                    }
                }
            },
        )
        print(f"Index '{INDEX_NAME}' created successfully")
 return True
    except Exception as e:
        print(f"Error creating index: {str(e)}")
	 exit(1)</code></pre><p>Elasticsearch response:</p><pre><code>{"acknowledged":true,"shards_acknowledged":true,"index":"team-data"}</code></pre><p>With this setup, each file from the CRM migration project gets indexed and becomes searchable.</p><h3>Python script</h3><p>To centralize Elasticsearch, data, and LLMs, we will create a simple Python script to ingest the data, make search requests to Elasticsearch, and send prompts to the LLM. This approach allows us to customize the workflow, change prompts and models, and automate processes:</p><p>Let’s create a <a href="https://docs.python.org/3/library/venv.html">venv</a> environment to handle the dependencies required to execute the script:</p><pre><code>python -m venv venv && source venv/bin/activate</code></pre><p>Now we need to install the <code>elasticsearch</code> dependencies to interact with our locally running Elasticsearch instance and <code>requests</code> will be used to handle HTTP requests:</p><pre><code>pip install elasticsearch requests openai</code></pre><p>After installation, create a Python file named `script.py` and let&#x27;s start scripting:</p><pre><code>import os
import time

from elasticsearch import Elasticsearch, helpers
from openai import OpenAI

ES_URL = "http://localhost:9200"
ES_API_KEY = "NDdDQWM1b0JPSDBFTV9JQzA0WVo6eHFXcWFJQmFYNzBwS1RjUllpRUNHZw=="
INDEX_NAME = "team-data"
LOCAL_AI_URL = "http://localhost:8080/v1"
DATASET_FOLDER = "./Dataset"


es_client = Elasticsearch(ES_URL, api_key=ES_API_KEY)
ai_client = OpenAI(base_url=LOCAL_AI_URL, api_key="sk-x") # The API key needs to have a value to work </code></pre><p>In the code above, we import the necessary packages, set up some relevant variables, and instantiate the Elasticsearch Python client and the OpenAI client to handle AI requests. There’s no need for a real OpenAI API key to make it work;you can use any value there.</p><p>Using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk">bulk API</a>, we created two methods to ingest the data directly from the folder to Elasticsearch <code>index_documents</code> and <code>load_documents.</code> To execute semantic queries, we&#x27;ll use the <code>semantic_search</code> method:</p><pre><code>def load_documents(dataset_folder, index_name):
    for filename in os.listdir(dataset_folder):
        if filename.endswith(".txt"):
            filepath = os.path.join(dataset_folder, filename)

            with open(filepath, "r", encoding="utf-8") as file: # UTF-8 encoding ensures proper handling of special characters and international text
                content = file.read()

            yield {
                "_index": index_name,
                "_source": {"file_title": filename, "file_content": content},
            }


def index_documents():
    try:
        if es_client.indices.exists(index=INDEX_NAME) is False:
            print(f"❌ Error: Index '{INDEX_NAME}' does not exist. ")
            exit(1)

        success, _ = helpers.bulk(es_client, load_documents(DATASET_FOLDER, INDEX_NAME))

        print(f"✅ Indexed {success} documents successfully")
        return success
    except Exception as e:
        print(f"❌ Error indexing documents: {str(e)}")
        exit(1)


def semantic_search(query, size=3):
    start_time = time.time()
    search_body = {
        "query": {"semantic": {"field": "semantic_field", "query": query}},
        "size": size,
    }

    response = es_client.search(index=INDEX_NAME, body=search_body)
    search_latency = (time.time() - start_time) * 1000  # ms

    return response["hits"]["hits"], search_latency</code></pre><p>The <code>query_local_ai</code> function handles the request to LocalAI models.</p><pre><code>def query_local_ai(prompt, model):
    start_time = time.time()

    try:
        response = ai_client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": prompt}],
        )

        ai_latency = (time.time() - start_time) * 1000  # ms

        # Extract response text
        response_text = response.choices[0].message.content

        # Calculate tokens per second if usage info is available
        tokens_per_second = 0
        if hasattr(response, "usage") and response.usage:
            total_tokens = response.usage.completion_tokens
            if ai_latency > 0:
                tokens_per_second = (total_tokens / ai_latency) * 1000  # tokens/second

        return response_text, ai_latency, tokens_per_second
    except Exception as e:
        ai_latency = (time.time() - start_time) * 1000
        
        return f"Error: {str(e)}", ai_latency, 0</code></pre><p>We will pass the Elasticsearch-retrieved data with a prompt to the <code>query_local_ai</code> function:</p><pre><code>if __name__ == "__main__":
    print("🚀 Setting up infrastructure...")

    # Setup inference endpoint and index
    setup_inference_endpoint()
    is_created = setup_index()

    if is_created:  # Index was just created, need to index documents
        print("\n📥 Indexing documents...")
        success = index_documents()

        if success == 0:  # if indexing failed, exit
            print("❌ Documents indexing failed. Exiting.")
            exit(1)

        time.sleep(1)  # Wait for indexing to complete

    query = "Can you summarize the performance issues in the API?"

    print(f"🔍 Search: '{query}'")
    search_results, search_latency = semantic_search(query)

    context = ""
    citations = []
    for idx, hit in enumerate(search_results, 1):
        source = hit["_source"]
        context += f"[{idx}] File: {source['file_title']}\n"
        context += f"Content: {source['file_content']}\n\n"
        citations.append(f"[{idx}] {source['file_title']}")

    prompt = f"""Based on the following documents, answer the user's question. 
        You MUST cite your sources using the format [1], [2], etc. when referencing information from the documents.

        Documents:
        {context}

        User Question: {query}
    """

    ai_model = "dolphin3.0-qwen2.5-0.5b"

    print(f"🤖 Asking to model: {ai_model}")
    response, ai_latency, tokens_per_second = query_local_ai(prompt, ai_model)

    print(f"\n💡 Question: {query}\n📝 Answer: {response}")
    for citation in citations:
        print(f"  {citation}")

    print(f"\n🔍 Search Latency: {search_latency:.0f}ms")
    print(f"🤖 AI Latency: {ai_latency:.0f}ms | {tokens_per_second:.1f} tokens/s")</code></pre><p>Finally, we can see the complete script workflow: first, we ingest the documents using <code>index_documents</code>; then we retrieve Elasticsearch data using <code>semantic_search</code>, and with those results, we send a request to the <strong>dolphin3.0-qwen2.5-0.5b</strong> model to generate the LLM response with our requirements (including citation generation) by calling the <code>query_local_ai</code> function. The latency and tokens per second will be measured and printed at the end of the script.In this workflow, the query “<strong><em>Can you summarize the performance issues in the API?</em></strong>” serves as the user’s natural language request that guides both the search and the final LLM response.</p><p>Run the following command to execute the script:</p><pre><code>python script.py</code></pre><p>Response:</p><pre><code>📝 Answer: 
The performance issues in the API can be summarized as follows:

1. **Search API degrades with 1,000+ queries per minute**: The search API has degraded, with performance degrades to a time of 3 seconds compared to the target of 200ms.

2. **Complex queries are slow and no caching layer**: Complex queries take longer to execute as expected.

3. **CPU limits during spikes**: The CPU usage is at 100% at the peak hour and limits to 70% at the peak hour.

📚 Citations:
  [1] report_development-team.txt
  [2] meeting_development-team_monday.txt
  [3] meeting_management-sync_friday.txt</code></pre><p>See the complete answer <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/local-rag-with-lightweight-elasticsearch/app-logs/results.md">here</a>.</p><p>The model’s answer is satisfactory: it highlights the key performance issues in a concise way and correctly points out underlying causes, offering a solid basis for further diagnosis.</p><h3>Latency</h3><p>As shown in the application results above, we have the following latency:</p><pre><code>📥 Indexing documents...
🔍 Search: 'Can you summarize the  performance issues in the API?'
🤖 Asking to model...

...

🔍 Search Latency: 14ms
🤖 AI Latency: 16044ms | 9.5 tokens/s</code></pre><p>Adding all times, we can see that the entire flow takes 17 seconds to get a response, producing <strong><em>9.5 tokens per second.</em></strong></p><h2>Hardware usage</h2><p>The last step is to analyze the resource consumption of the entire environment. We describe it based on the Docker environment configuration shown in the following screenshot:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0816451544f69a8cd9eaf22884eb9d2802e75e10-1600x1196.png" style="width: 100%;" /><p>With 8GB of RAM, we have enough memory to run both the LocalAI container and the Elasticsearch container. This configuration is representative of a mid-range laptop setup, which helps us better approximate realistic inference performance.</p><h3>Resources consumption</h3><p>Using the Docker <a href="https://hub.docker.com/extensions/artifision/live-charts-docker-extension">Live Charts</a> extension, we can see the resource consumption of both containers working together while generating responses:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1b1070e9b733024f27b9375d2b0469e64913b781-1600x727.png" style="width: 100%;" /><p>Consumption per container is as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/84ffc25a741d92a3ff35a4f2fa1686986e6ec4e1-1600x1124.png" style="width: 100%;" /><p>When it starts running, Elasticsearch uses about 0.5 cores for indexing data. On the LocalAI side, <strong>dolphin3.0-qwen2.5-0.5b</strong> consumes 100% of the 6 available cores when producing the answer. When analyzing memory consumption, it uses approximately 2.2GB in total: 1.9 GB for Elasticsearch and 200 MB for LocalAI (client and model).</p><h3>Alternative model with higher resource requirements: smollm2-1.7b-instruct</h3><p>To see the flexibility of this approach, let&#x27;s change the model by just switching the variable <code>ai_model</code> to <code>ai_model = &quot;smollm2-1.7b-instruct&quot;</code> in code. This model requires significantly more memory due to its larger parameter count, which impacts the tokens-per-second rate and increases the overall latency when generating a response.</p><pre><code>🤖 Asking to model: smollm2-1.7b-instruct

💡 Question: Can you summarize the performance issues in the API?
📝 Answer: The development team identified two key technical challenges for the API:

1.  The search API degrades at 1,000+ queries per minute, causing average execution times to jump from 200ms to 3 seconds.
2.  The root cause is complex database queries without a caching layer, leading to poor query performance.

📚 Citations:
  [1] report_development-team.txt
  [2] meeting_development-team_monday.txt
  [3] meeting_management-sync_friday.txt

🔍 Search Latency: 16ms
🤖 AI Latency: 47561ms | 4.8 tokens/s</code></pre><p>As expected, being a heavier model, <strong>smollm2-1.7b-instruct</strong> produces fewer tokens per second (4.8) for the same question and takes significantly more time (around 30 seconds longer).</p><p>The response looks good and detailed. It’s similar to the one generated by the <strong>dolphin3.0-qwen2.5-0.5b</strong> model but takes longer to generate and consumes more resources, as this model uses approximately 1 GB of memory.</p><h3>Alternative balance model: llama-smoltalk-3.2-1b-instruct</h3><p>Now let’s try again by changing <code>ai_model</code> to <strong>llama-smoltalk-3.2-1b-instruct</strong>.</p><pre><code>🤖 Asking to model: llama-smoltalk-3.2-1b-instruct
💡 Question: Can you summarize the performance issues in the API?

📝 Answer: Based on the documents, it appears that the main performance issue with the API is related to the search query optimization. The API degrades to around 1,000+ queries per minute (QP/min) when there are 12 of 18 API endpoints integrated with authentication. This issue is caused by complex queries without a caching layer, leading to performance degrades and slow response times.

However, there is also a smaller issue with the "Search" API, where it degrades to around 3+ seconds after 1.2 seconds execution time. This is likely due to multi-filter searches and the need for a caching layer to improve performance.

To address these issues, the team is working on implementing a caching layer (Sarah) and optimizing bool queries and adding calculated index fields (John) to improve query efficiency. They are also working on setting up auto-scaling for the database (Mike) to ensure that it can handle increased traffic.

A meeting was held to discuss these issues and a plan for improvement was agreed upon. The team will work together to implement a caching layer and optimize the queries, and the team will work with product team to ensure that the migration is completed on time and does not impact the October migration date.

📚 Citations:
  [1] report_development-team.txt
  [2] meeting_development-team_monday.txt
  [3] meeting_management-sync_friday.txt

🔍 Search Latency: 12ms
🤖 AI Latency: 21019ms | 5.8 tokens/s</code></pre><p>Analyzing the results,<strong> llama-smoltalk-3.2-1b-instruct</strong> delivers responses similar to the other models, varying slightly in format and extension. However, this comes at a higher cost compared to the lighter model (about 5 seconds slower and 4 tokens fewer per second.) It also consumes more memory than the <strong>dolphin3.0-qwen2.5-0.5b</strong> model (around 500 MB more in total). This makes it reliable for accurate summarization tasks but less efficient for fast or interactive scenarios.</p><h3>Table comparison</h3><p>To get a better view of the model’s consumption, let’s include a table comparing the results:</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Model</td><td style="border: 1px solid #ccc; padding: 4px;">Memory Usage</td><td style="border: 1px solid #ccc; padding: 4px;">Latency</td><td style="border: 1px solid #ccc; padding: 4px;">Tokens/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">dolphin3.0-qwen2.5-0.5b</td><td style="border: 1px solid #ccc; padding: 4px;">~200 MB</td><td style="border: 1px solid #ccc; padding: 4px;">16,044 ms</td><td style="border: 1px solid #ccc; padding: 4px;">9.5 tokens/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">smollm2-1.7b-instruct</td><td style="border: 1px solid #ccc; padding: 4px;">~1 GB</td><td style="border: 1px solid #ccc; padding: 4px;">47,561 ms</td><td style="border: 1px solid #ccc; padding: 4px;">4.8 tokens/s</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">llama-smoltalk-3.2-1b-instruct</td><td style="border: 1px solid #ccc; padding: 4px;">~700 MB</td><td style="border: 1px solid #ccc; padding: 4px;">21,019 ms</td><td style="border: 1px solid #ccc; padding: 4px;">5.8 tokens/s</td>
                    </tr>
              </tbody>
            </table>
          <h2>Conclusion</h2><p>Combining e5-small for embeddings and <strong>dolphin3.0-qwen2.5-0.5b</strong> for completions, we could set up an efficient and fully functional RAG application on a mid-end laptop, with all data kept private. As we saw in the <a href="https://docs.google.com/document/d/1bZtpoM8PNwwKYj1XHPIsCPKiNP2oDhuBkw9DDi2Xwak/edit?tab=t.0#heading=h.y2lmcstwlsgi">latency</a> section from the first test we ran using the dolphin model, the part of the flow that takes the longest is the LLM inference step (16 s), while Elasticsearch vector retrieval was fast (81 ms).</p><p><strong>dolphin3.0-qwen2.5-0.5b</strong> was the best candidate as a LLM to generate answers. Other models like <strong>llama-smoltalk-3.2-1b-instruct</strong> are indeed fast and reliable, but they tend to be heavier models. They require more resources, producing fewer tokens per second in exchange for slightly better quality in the responses.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Boosting e-commerce search by profit and popularity with the function score query in Elasticsearch]]></title>
            <description><![CDATA[Discover how to optimize e-commerce search by blending BM25 relevance with profit margin and popularity signals in Elasticsearch using the function_score query.]]></description>
            <link>https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
            <pubDate>Wed, 17 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/eb3d344298e81f4da59005ea41dd779edd424412-1000x628.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<h2>Overview</h2><p>In this article, you will learn how to combine BM25 relevance with real business metrics like profit margin and popularity using Elasticsearch’s function_score query. This step-by-step guide shows how to control scaling with logarithmic boosts and allows full explainability for each ranking calculation.</p><h2>Introduction</h2><p>In many use cases, search results focus on lexical (keyword) and semantic (meaning-based) analysis to find the content that most accurately and authoritatively answers a user’s query. However, e-commerce search is a bit more complex.</p><p>Results must reflect the shopper’s intent and incorporate business objectives such as profit margin, product popularity, or other factors that don’t always directly align with purely lexical or semantic matching.</p><p>While text relevance ensures customer satisfaction, ranking by profitability and popularity turns search into a business optimization engine.</p><p>In order to demonstrate how business signals can be incorporated into search results, in this post we’ll explore:</p><ol><li>How to boost product rankings by margin (profitability 0% to 200% in the demo data below).</li><li>How to extend that same logic to include popularity (number of sales).</li></ol><p>Once you understand how to boost by margin and popularity, extending search to incorporate other signals is straightforward.</p><h2>Setup</h2><p>Below is a small dataset you can paste directly into Dev Tools to follow along.</p><pre><code>POST _bulk
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "MCC-HOME-500", "description": "McCain Home Chips 500g - High Margin", "margin": 200, "popularity": 100 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "MCC-HOME-1000", "description": "McCain Home Chips 1kg", "margin": 100, "popularity": 640 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "MCC-HOME-1500", "description": "McCain Home Chips 1.5kg", "margin": 50, "popularity": 10000 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "BIR-CHIPS-450", "description": "BirdsEye Crispy Chips 450g", "margin": 9, "popularity": 880 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "BIR-CHIPS-900", "description": "BirdsEye Crispy Chips 900g", "margin": 12, "popularity": 720 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "TRE-MINT-33", "description": "Trebor Peppermint 33g", "margin": 5, "popularity": 1100 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "TRE-MINT-4X38", "description": "Trebor Peppermint 4x38g", "margin": 8, "popularity": 680 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "TIC-MINT-16", "description": "TicTac Mint 16g", "margin": 3.5, "popularity": 980 }
{ "index": { "_index": "blog_food_products" } }
{ "product_id": "TIC-MINT-6X16", "description": "TicTac Mint 6x16g", "margin": 7, "popularity": 640 }</code></pre><p>Each document represents a product with:</p><ul><li>margin: profit margin (percent)</li><li>popularity: relative sales volume (e.g. weekly average, or last week’s sum)</li></ul><h2>Ranking without margin</h2><p>We can see how baseline results look by executing a simple query for “McCain chips” that does not take into consideration margin, as follows:</p><pre><code>POST blog_food_products/_search
{
  "size": 5,
  "_source": ["description", "margin"],
  "query": {
    "match": {
      "description" : "McCain Chips"    }
  }
}</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/050f44956c112ea9916d34946e9296480354f7d3-2322x1324.png" style="width: 100%;" /><p>Which returns the following results:</p><pre><code>{
   "_index": "blog_food_products",
   "_id": "GKO3MJoBBtzDfCS5JfQM",
   "_score": 1.6089411,
   "_source": {
     "description": "McCain Home Chips 1kg",
     "margin": 100
   }
 },
 {
   "_index": "blog_food_products",
   "_id": "GaO3MJoBBtzDfCS5JfQM",
   "_score": 1.6089411,
   "_source": {
     "description": "McCain Home Chips 1.5kg",
     "margin": 50
   }
 },
 {
   "_index": "blog_food_products",
   "_id": "F6O3MJoBBtzDfCS5JfQM",
   "_score": 1.3280699,
   "_source": {
     "description": "McCain Home Chips 500g - High Margin",
     "margin": 200
   }
 },
 {
   "_index": "blog_food_products",
   "_id": "GqO3MJoBBtzDfCS5JfQM",
   "_score": 0.5837885,
   "_source": {
     "description": "BirdsEye Crispy Chips 450g",
     "margin": 9
   }
 },
 {
   "_index": "blog_food_products",
   "_id": "G6O3MJoBBtzDfCS5JfQM",
   "_score": 0.5837885,
   "_source": {
     "description": "BirdsEye Crispy Chips 900g",
     "margin": 12
   }
 }</code></pre><p>As you can see from the above results, the high margin version of the chips is 3rd in the results because the ordering does not consider margin.</p><h2>Ranking by margin</h2><p>Without any additional context, all sizes of “McCain chips” look equally relevant — but from a business perspective, it is possible that the higher-margin items should rank higher.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Product</td><td style="border: 1px solid #ccc; padding: 4px;">Margin (%)</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">McCain Home Chips 500g – High Margin</td><td style="border: 1px solid #ccc; padding: 4px;">200%</td><td style="border: 1px solid #ccc; padding: 4px;">small pack</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">McCain Home Chips 1kg</td><td style="border: 1px solid #ccc; padding: 4px;">100%</td><td style="border: 1px solid #ccc; padding: 4px;">mid pack</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">McCain Home Chips 1.5kg</td><td style="border: 1px solid #ccc; padding: 4px;">50%</td><td style="border: 1px solid #ccc; padding: 4px;">family pack</td>
                    </tr>
              </tbody>
            </table>
          <p>We’ll use Elasticsearch’s<a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query"> function_score</a> query to apply a margin-based boost.</p><pre><code>POST blog_food_products/_search
{
 "size": 5,
 "explain": false,                       // keep only for tuning
 "_source": ["description", "margin"],
 "query": {
   "function_score": {
 
 
     /* ───────────────────────────────────────────────
      * Base query
      * Replace with your actual BM25 or semantic query.
      * ─────────────────────────────────────────────── */
     "query": {
       "match": {
         "description" : "McCain Chips"       }
     },
 
 
     /* ───────────────────────────────────────────────
      * Margin-driven boost
      * ------------------------------------------------
      * Elasticsearch computes (for the ln1p modifier):
      *
      *   log_margin  = ln(1 + margin * factor)  
      *   boost       = 1 + log_margin            // +1 baseline via explicit { "weight": 1 }
      *   final_score = BM25 * boost
      *
      * Picking `factor` to cap around 2× at max_margin ≈ 200:
      *
      *   1 + ln(1 + factor * 200)  ≈  2
      *   ln(1 + 200*factor)        =  1
      *   1 + 200*factor            =  e
      *   factor                    =  (e - 1) / 200  ≈ 0.00859
      *
      * You can keep a little headroom, e.g. use 0.0085.
      * ─────────────────────────────────────────────── */
     "functions": [
       {
         "filter": { "range": { "margin": { "gt": 0 } } },
         "field_value_factor": {
           "field"   : "margin",
           "modifier": "ln1p",     // natural log of (1 + margin * factor)
           "factor"  : 0.0085,     // ≈ (e - 1) / 200
           "missing" : 0
         }
       },
       { "weight": 1 }              // explicit neutral baseline (keeps zero/small margins neutral)
     ],
 
     "score_mode": "sum",           // boost = 1 + ln(1 + margin*factor)  (sum of the two functions)
     "boost_mode": "multiply"       // final_score = BM25 × boost
     // "max_boost": 2.0                // optional: clamp hard ceiling
   }
 }
}</code></pre><p>The above query results in the following, which reflect the impact of the margin boosting on the score. Notice that, as we intended, the high-margin McCain Chips have been boosted to the 1st position in the results.</p><pre><code>{
  "_index": "blog_food_products",
  "_id": "F6O3MJoBBtzDfCS5JfQM",
  "_score": 2.6471777,
  "_source": {
    "description": "McCain Home Chips 500g - High Margin",
    "margin": 200
  }
},
{
  "_index": "blog_food_products",
  "_id": "GKO3MJoBBtzDfCS5JfQM",
  "_score": 2.5987387,
  "_source": {
    "description": "McCain Home Chips 1kg",
    "margin": 100
  }
},
{
  "_index": "blog_food_products",
  "_id": "GaO3MJoBBtzDfCS5JfQM",
  "_score": 2.1787827,
  "_source": {
    "description": "McCain Home Chips 1.5kg",
    "margin": 50
  }
},
{
  "_index": "blog_food_products",
  "_id": "G6O3MJoBBtzDfCS5JfQM",
  "_score": 0.64049,
  "_source": {
    "description": "BirdsEye Crispy Chips 900g",
    "margin": 12
  }
},
{
  "_index": "blog_food_products",
  "_id": "GqO3MJoBBtzDfCS5JfQM",
  "_score": 0.62682253,
  "_source": {
    "description": "BirdsEye Crispy Chips 450g",
    "margin": 9
  }
}</code></pre><h2>Understanding the formula</h2><p>The <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-function-score-query">function_score query</a> allows us to apply a smooth, interpretable boost based on margin without overwhelming BM25’s lexical relevance.</p><p>Here’s how it works:</p><ul><li><strong>margin_boost</strong> = ln(1 + margin × factor)</li><li><strong>boost</strong> = 1 + margin_boost</li><li><strong>final_score</strong> = BM25 × boost</li></ul><p>Where the query is specified with the following fields:</p><ul><li><strong>field_value_factor</strong> – uses a document field to influence scoring without scripting overhead.</li><li><strong>modifier: “ln1p” </strong>– computes ln(1 + margin × factor)<ul><li>Note: ln1p(x) is shorthand for ln(1 + x).</li></ul></li><li><strong>factor</strong> – controls scale; 0.0085 caps boosts near 2× at margin=200.</li><li><strong>weight: 1 </strong>– ensures a minimum boost of 1 for neutral items.</li><li><strong>score_mode: “sum” </strong>– adds constant 1 (from that standalone “weight” : 1) and the margin_boost together.</li><li><strong>boost_mode: “multiply”</strong> – multiplies BM25 by the computed boost.</li></ul><h2>Why was that formula chosen?</h2><p>The logarithmic (ln1p) scaling behaves well across real-world data:</p><ul><li>It grows fast at small margins (rewarding incremental gains).</li><li>It flattens at high margins (preventing runaway scores).</li><li>It’s continuous and interpretable — no thresholds or discontinuities.</li></ul>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Margin</td><td style="border: 1px solid #ccc; padding: 4px;">ln(1 + margin × 0.0085)</td><td style="border: 1px solid #ccc; padding: 4px;">Boost (≈1+ln1p)</td><td style="border: 1px solid #ccc; padding: 4px;">Boost Multiplier</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5</td><td style="border: 1px solid #ccc; padding: 4px;">0.042</td><td style="border: 1px solid #ccc; padding: 4px;">1.04</td><td style="border: 1px solid #ccc; padding: 4px;">×1.04</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">50</td><td style="border: 1px solid #ccc; padding: 4px;">0.35</td><td style="border: 1px solid #ccc; padding: 4px;">1.35</td><td style="border: 1px solid #ccc; padding: 4px;">×1.35</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">100</td><td style="border: 1px solid #ccc; padding: 4px;">0.63</td><td style="border: 1px solid #ccc; padding: 4px;">1.63</td><td style="border: 1px solid #ccc; padding: 4px;">×1.63</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">200</td><td style="border: 1px solid #ccc; padding: 4px;">0.99</td><td style="border: 1px solid #ccc; padding: 4px;">1.99</td><td style="border: 1px solid #ccc; padding: 4px;">×1.99</td>
                    </tr>
              </tbody>
            </table>
          <h2>Ranking by margin and popularity</h2><p>We can extend the same logic to add a popularity boost. Here, we tune the popularity factor so that the boost increases by roughly +1.0, at a popularity of 10,000. (These thresholds depend on your dataset’s scale.)</p><pre><code>POST blog_food_products/_search
{
 "size": 5,
 "_source": ["product_id", "description", "margin", "popularity"],
 "query": {
   "function_score": {
     "query": {
       "match": {
         "description": "McCain Chips"       }
     },
     "functions": [
       {
         // calculate margin_boost
         "filter": { "range": { "margin": { "gt": 0 } } },
         "field_value_factor": {
           "field":   "margin",
           "modifier":"ln1p",           // ln(1 + margin * margin_f)
           "factor":  0.008591,         // ≈ (e - 1) / 200
           "missing": 0
         },
         "weight": 1                 // full impact from margin
       },
       {
         // calculate popularity_boost
         "filter": { "range": { "popularity": { "gt": 0 } } },
         "field_value_factor": {
           "field":   "popularity",
           "modifier":"ln1p",           // ln(1 + popularity * popularity_f)
           "factor":  0.0001718,        // ≈ (e - 1) / 10,000
           "missing": 0
         },
         "weight": 0.5                 // popularity counts for half the impact of margin
       },
       {
         "weight": 1                   // ensures minimum boost of 1
       }                 
     ],
     "score_mode": "sum",               // boost = 1 + margin_boost + 0.5×popularity_boost
     "boost_mode": "multiply"           // final_score = BM25 * boost
     // "max_boost": 4.0                // optional: clamp hard ceiling
   }
 }
}</code></pre><p>Which returns results with the most popular product in 1st place, even though it is not the highest margin, as follows — in this case, the impact of the popularity boost has pushed up McCain Home Chips 1.5kg to 1st place.</p><pre><code>{
  "_index": "blog_food_products",
  "_id": "IqPBMJoBBtzDfCS5CvRg",
  "_score": 2.988299,
  "_source": {
    "product_id": "MCC-HOME-1500",
    "description": "McCain Home Chips 1.5kg",
    "margin": 50,
    "popularity": 10000
  }
},
{
  "_index": "blog_food_products",
  "_id": "IaPBMJoBBtzDfCS5CvRg",
  "_score": 2.6905532,
  "_source": {
    "product_id": "MCC-HOME-1000",
    "description": "McCain Home Chips 1kg",
    "margin": 100,
    "popularity": 640
  }
},
{
  "_index": "blog_food_products",
  "_id": "IKPBMJoBBtzDfCS5CvRg",
  "_score": 2.667411,
  "_source": {
    "product_id": "MCC-HOME-500",
    "description": "McCain Home Chips 500g - High Margin",
    "margin": 200,
    "popularity": 100
  }
},
{
  "_index": "blog_food_products",
  "_id": "JKPBMJoBBtzDfCS5CvRg",
  "_score": 0.67510986,
  "_source": {
    "product_id": "BIR-CHIPS-900",
    "description": "BirdsEye Crispy Chips 900g",
    "margin": 12,
    "popularity": 720
  }
},
{
  "_index": "blog_food_products",
  "_id": "I6PBMJoBBtzDfCS5CvRg",
  "_score": 0.66836256,
  "_source": {
    "product_id": "BIR-CHIPS-450",
    "description": "BirdsEye Crispy Chips 450g",
    "margin": 9,
    "popularity": 880
  }
}</code></pre><h2>What the resulting boosts look like</h2><p>The “factors” are tuned to add +1.0 to the boost at the assumed maximums. These are calculated to satisfy the following formulas:</p><pre><code>ln(1 + 200 × margin_f)   = 1.0 
i.e. margin_f  = 0.008592
ln(1 + 10 000 × popularity_f)  = 1.0 
i.e. popularity_f  = 0.0001718</code></pre><p>Then:</p><pre><code>margin_boost = ln(1 + margin × margin_f)
popularity_boost = ln(1 + popularity × popularity_f)
boost = 1 + margin_boost + 0.5 × popularity_boost
final_score = BM25 × boost</code></pre><p>Each cell in the table below represents the total BM25 multiplier for various margin and popularity values.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/adaa1c58de96c1e319e1605e1d51253dd6b5af7f-1024x572.png" style="width: 100%;" /><p>How to read the table:</p><ul><li>The first column (popularity = 0) isolates the margin effect.</li><li>Moving right, popularity increases the boost — but since its weight is 0.5, its contribution to the summed boost is halved.</li><li>Even at extreme values (popularity = 100,000), the boost flattens due to logarithmic scaling.</li></ul><h2>Tuning</h2><p>If you find popularity can spike very high (e.g., 100k+) and you don’t want boosts above some ceiling, you can:</p><ul><li>Lower the popularity factor further, or</li><li>Add “max_boost”: &lt;cap&gt; to function_score, or</li><li>Split weights, e.g. “weight”: 0.25 on popularity and “weight”: 1 on margin (still with score_mode: “sum”), if you want one to dominate less.</li></ul><h2>Using rank_feature for similar use cases</h2><p>At first glance, <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-rank-feature-query">rank_feature</a> and <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/rank-features">rank_features</a> look like a natural choice for incorporating numeric signals such as popularity, recency, or even profit margin. They are fast, compressed, and easy to operationalize — which is why many teams reach for them first.</p><p>However, they are not a good fit for this type of scoring model, for the following reasons:</p><h3>1. Rank-feature contributions are strictly additive</h3><p>The score takes the form:</p><pre><code>final_score = BM25 + feature_boost</code></pre><p>This means the effect of the boost changes dramatically depending on the scale of the BM25 score.</p><ul><li>When BM25 is small, the boost dominates the ranking.</li><li>When BM25 is large, the identical boost becomes negligible.</li></ul><p>We need consistent, proportional behavior instead.</p><h3>2. Impossible to express “percentage-based” or multiplicative logic</h3><p>This article’s model requires expressing things like:</p><ul><li>“Popularity increases relevance by ~20%.”</li><li>“Margin strengthens relevance but never overrides it.”</li></ul><p><code>rank_feature</code> cannot do this. It does not support multiplicative shaping of the BM25 score.</p><h3>3. Combining multiple signals becomes unstable and hard to tune</h3><p>If you try to combine margin, popularity, availability, or other business metrics via rank_features, each feature adds another independent additive term. These interact in opaque ways, making tuning brittle and unpredictable.</p><h3>4. Bottom line</h3><p><code>rank_feature</code> is great for simple additive numeric boosts. It is not suitable when you need:</p><ul><li>stable behavior across queries</li><li>proportional / multiplicative effects</li><li>explainable blending of multiple signals</li></ul><p>For this reason, the article uses <code>function_score</code> instead, because it provides explicit, controlled scoring that behaves consistently regardless of BM25 scale.</p><h2>Wrapping up</h2><p>Elastic’s <code>function_score</code> query makes it simple to transform search ranking from content relevance into business-aware optimization.</p><p>By combining BM25 relevance with economic signals like margin and popularity, you can:</p><ul><li>Align search with real business outcomes.</li><li>Tune scaling via a single parameter (factor).</li><li>Maintain full explainability through _<code>explain</code>.</li></ul><p>Once this foundation is in place, you can extend it to Stock levels (reduce the ranking of low-stock products), Recency (prioritize new products), or other business-critical signals that you want to take into consideration. Each new signal simply adds to the boost which is then multiplied by the base BM25 relevance score.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Reducing Elasticsearch frozen tier costs with Deepfreeze S3 Glacier archival]]></title>
            <description><![CDATA[Learn how to leverage Deepfreeze in Elasticsearch to automate searchable snapshot repository rotation, retaining historical data and aging it into lower cost S3 Glacier tiers after index deletion.]]></description>
            <link>https://www.elastic.co/search-labs/blog/s3-glacier-archiving-elasticsearch-deepfreeze</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/s3-glacier-archiving-elasticsearch-deepfreeze</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Bret Wortman]]></dc:creator>
            <pubDate>Tue, 16 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/abe4a7118c7964c73e8a8e38339ab4c0355d27d3-854x480.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<h2>The problem: Your old data is expensive (and getting more so)</h2><p>As legal requirements change and data retention periods grow longer, many Elasticsearch customers have started to wonder: <strong>How can I retain my historical data without storage costs breaking the bank?</strong></p><p>You&#x27;ve got your ILM policies humming along nicely. Fresh data comes in hot, ages into warm or cold, gets frozen as a searchable snapshot, and eventually—after 180 days, 10 years, or longer, depending on your compliance requirements—gets deleted.</p><p>When the delete action runs, it typically deletes the searchable snapshot with it. Sure, you might have regular snapshots elsewhere, but those aren&#x27;t searchable without a full restore. If someone comes to you six months later asking, &quot;Hey, can we check what happened in Q2 2023?&quot; you&#x27;re looking at a lengthy restore operation, a manual and time-consuming re-ingestion, or an awkward conversation about data retention policies.</p><p>The obvious solution is to just... not delete things, right? Keep those frozen indices around forever! But that creates its own problems:</p><ol><li><strong>Cost creep</strong>: Frozen tier storage isn&#x27;t free, and it adds up</li><li><strong>Cluster clutter</strong>: Managing hundreds or thousands of ancient frozen indices gets messy</li><li><strong>Wasted resources</strong>: You&#x27;re paying for data to be &quot;searchable&quot; when you haven&#x27;t searched it in months</li></ol><p>What you really want is something in between: a way to keep the data <em>accessible</em> without drowning in S3 storage costs.</p><h2>The solution: Deepfreeze makes old data cheap (but not gone)</h2><p>Deepfreeze is a new repository management solution from Elastic. It allows you to retain searchable snapshots for deleted indices, move them to a cheaper S3 storage tier, and easily restore them if the saved data is needed again. All because <strong>Elasticsearch lets you delete an index while keeping its searchable snapshot intact.</strong></p><p>When you configure your ILM delete action, there&#x27;s an often-overlooked option available in the delete phase:</p><pre><code>{
  "delete": {
    "delete_searchable_snapshot": false
  }
}</code></pre><p>Set that to <code>false</code>, and when the index gets deleted, the snapshot repository keeps the snapshot files in S3. The index is gone from your cluster (no more rent!), but the underlying data is still sitting in your S3 bucket, waiting patiently.</p><p>But here&#x27;s where it gets interesting: if you just leave those snapshots in the same repository, they&#x27;re still &quot;managed&quot; by Elasticsearch, which means they must remain in a standard access tier. AWS S3 Intelligent-Tiering won&#x27;t move them to cheaper tiers because, from S3&#x27;s perspective, Elasticsearch still actively manages that bucket.<br/><strong>Deepfreeze solves this by rotating snapshot repositories.</strong></p><p>Think of it like this: instead of one big filing cabinet where you keep adding folders forever, you start a new filing cabinet every month. Old cabinets get closed, labeled, and moved to cheaper storage. If you need something from an old cabinet, you can always bring it back out and open it up.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/abe4a7118c7964c73e8a8e38339ab4c0355d27d3-854x480.png" style="width: 100%;" /><p>Note: Deepfreeze currently supports AWS. Azure and GCP are on the roadmap for future development.</p><h2>How Deepfreeze works: Four steps to data freedom</h2><p>Deepfreeze is a Python automation tool that runs periodically (typically via cron) to manage this rotation process. Here&#x27;s what happens when you execute its monthly <code>rotate</code> action:</p><h3>Step 1: Create a new S3 bucket</h3><p>Deepfreeze creates a fresh S3 bucket with a one-up name (like <code>Repository-000002</code>). This bucket is configured with your preferred storage class—by default, this is Standard, but Intelligent Tiering is an option.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/052ded4b9f437b627fad432b66a35fd6898c621c-854x480.png" style="width: 100%;" /><p><em>Please note that I use the term “bucket” here for ease of discussion. In reality, Deepfreeze creates new base paths inside a bucket to avoid any potential issues with AWS bucket creation limits. For simplicity, I refer to this combination of a bucket and a path as a bucket.</em></p><h3>Step 2: Mount the bucket as an Elasticsearch repository</h3><p>The new S3 bucket gets registered as a snapshot repository in your Elasticsearch cluster. Your ILM policies will now start using this repository for new frozen indices.</p><h3>Step 3: Update ILM policies to use the new repository</h3><p>Here&#x27;s where the magic happens. Deepfreeze automatically:</p><ul><li>Scans all your ILM policies</li><li>Finds any reference to the old repository in their <code>searchable_snapshot</code> actions</li><li>Copies the ILM policies to new ones referencing the new repository</li><li>Updates index templates to use the new policies</li></ul><p>You don&#x27;t have to edit dozens of policies manually. Deepfreeze handles it all.</p><h3>Step 4: Unmount old repositories (but keep recent ones)</h3><p>Deepfreeze maintains a sliding window of mounted repositories—by default, it keeps the last 6 months accessible. Older repositories get unmounted from Elasticsearch, but <strong>the S3 buckets remain intact</strong>.</p><p>Once unmounted, those buckets are no longer under active Elasticsearch management, and Deepfreeze can now move them to Glacier.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/95ed8ed1a8322a2d14ac6b7513ac488ab875817e-854x480.png" style="width: 100%;" /><h2>The benefits: Why you should care</h2><h3> Cost optimization</h3><p>Elasticsearch&#x27;s frozen tier storage is cheaper than hot or warm storage, but it still incurs standard S3 storage costs. The S3 Glacier tier can be <strong>95% cheaper</strong> than standard S3 storage. Over months and years, this adds up to real money.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/27c7439107c34b6b2772d06714aeb5fa89c265e3-1316x866.png" style="width: 100%;" /><h3>Data retention without the guilt</h3><p>Compliance requirements often mandate keeping data for years. Deepfreeze lets you meet those requirements without constantly explaining to finance why your storage bill keeps growing.</p><h3>Quick access when needed</h3><p>Because the searchable snapshots are still in their native format, you can remount them using Elasticsearch&#x27;s standard <code>_mount</code> API. No lengthy restore process, no reformatting—just point to the old repository and mount the index.</p><p>Deepfreeze makes this easier still, by keeping track of the datetime range of every repo it processes, making something like this possible, where the system does all the work of thawing data from the specified range by requesting the buckets from S3, mounting the repos when the data is available, and even mounting the indices that cover the specified dates:</p><pre><code>$ deepfreeze thaw --start-date 2024-10-12 --end-date 2024-12-01</code></pre><p>After the duration has passed (30 days, by default), AWS automatically re-freezes the data to Glacier. Deepfreeze checks for this and updates its metadata, repositories, and indices during normal operations.</p><p>(AWS doesn’t actually move the data from Glacier to Standard. Instead, it copies the desired data back to Standard and then, when the duration has expired, it deletes it.)</p><h3>Full automation</h3><p>Run Deepfreeze on a schedule (the first of every month works for a monthly rotation) and forget about it. It handles all the tedious policy updates, bucket creation, and repository management automatically. Run it from cron or a .service file. Examples are available <a href="https://github.com/elastic/deepfreeze/tree/main/examples">on GitHub</a>.</p><h3>Reduced cluster clutter</h3><p>Your Elasticsearch cluster only needs to track the repositories that are actively mounted. Ancient indices don&#x27;t show up in your cluster state, monitoring dashboards, or backup routines. Everything stays cleaner.</p><h2>Configuration: Making Deepfreeze work for you</h2><p>Deepfreeze is flexible. You can configure it via environment variables, command-line arguments, or both. For example, you can change the default number of mounted repositories by setting the DEEPFREEZE_KEEP environment variable. The default value is 6. Increasing this value gives you access to more data.</p><pre><code>DEEPFREEZE_KEEP=10</code></pre><p>The ideal setting for the <code>DEEPFREZE_KEEP</code> value depends on the likelihood that older data is needed. If you rarely look at data older than a year, set the keep value to 12 to ensure your cluster has a full year of data; Deepfreeze will push anything older to glacier storage.</p><p>You can also change the AWS Storage Class and use <code>intelligent_tiering</code> (Auto-tiering based on access) instead of the default standard (Full-price, always accessible):</p><pre><code>DEEPFREEZE_STORAGE_CLASS=intelligent_tiering</code></pre><p><strong>Options for each action are in the <a href="https://github.com/elastic/deepfreeze/blob/main/README.md">GitHub README</a>.</strong></p><h2>Real-world use case</h2><p>Whether you’re looking to ingest application logs, security events, IoT sensor data, or something entirely different, cost savings will depend on your ingest rate and retention periods.</p><p>For example, if you&#x27;re ingesting 175GB of application logs per day, but typically only need the last 6 months searchable for troubleshooting. Assume compliance requires 7 years of retention:</p><p><strong>Without Deepfreeze:</strong></p><ul><li>Keep 7 years × 60TB = 420TB in frozen tier</li><li>Cost: ~$9,660/month</li></ul><p><strong>With Deepfreeze:</strong></p><ul><li>Keep 6 months × 30TB = 30TB in frozen tier: $690/month</li><li>Keep 6.5 years × 390TB in S3 Archive: $1,560/month</li><li><strong>Total: $2,250/month (77% savings)</strong></li></ul><h2>Technical deep dive: under the hood</h2><p>For those who want to understand what&#x27;s really happening, here&#x27;s a peek at the implementation.</p><h3>The Deepfreeze process flow</h3><pre><code>START
  │
  ├─► Initialize Elasticsearch client (SSL/TLS, auth)
  │
  ├─► Calculate next repository suffix
  │
  ├─► Validate: At least one existing repo with prefix exists
  │
  ├─► Validate: New repo name doesn't already exist
  │
  ├─► Create new S3 bucket path via boto3
  │   └─► Configure: storage class, ACL, region
  │
  ├─► Register new repository in Elasticsearch
  │   └─► Type: s3
  │   └─► Settings: bucket, base_path, storage_class
  │
  ├─► Update all ILM policies
  │   ├─► Fetch all policies from cluster
  │   ├─► For each policy using the last repo:
  │   │   └─► Find searchable_snapshot actions
  │   │   └─► Replace old repo name with new
  │   │   └─► Submit updated policy
  │   └─► Log policy update count
  │
  ├─► Unmount old repositories
  │   ├─► List all repos matching prefix
  │   ├─► Exclude any thawed repos from consideration
  │   ├─► Sort by suffix (oldest first)
  │   ├─► While (total count > KEEP):
  │   │   └─► DELETE oldest repository (S3 bucket stays!)
  │   └─► Log unmounted repo count
  │
END</code></pre><h3>Key implementation details</h3><p><strong>Smart repository discovery:</strong> The tool discovers repositories by prefix matching, ensuring it only manages repositories it created:</p><pre><code>def get_repos(self) -> list:
    """Get all repositories matching our prefix"""
    all_repos = self.client.snapshot.get_repository(name='*')
    return [r for r in all_repos if r.startswith(self.repo_name_prefix)]</code></pre><p><strong>Automatic ILM policy updates:</strong> No manual policy editing required. Deepfreeze walks the policy structure and updates repository references:</p><pre><code># Simplified conceptual example
for policy_name, policy in ilm_policies.items():
    for phase in policy['phases']:
        if 'searchable_snapshot' in phase['actions']:
            phase['actions']['searchable_snapshot']['snapshot_repository'] = new_repo
    es.ilm.put_policy(name=policy_name, policy=policy)</code></pre><h2>Important considerations</h2><h3>The S3 Intelligent-Tiering question</h3><p>There&#x27;s an interesting architectural question worth discussing: How does AWS S3 Intelligent-Tiering classify the temperature of unmounted searchable snapshots?</p><p>The idea is that once unmounted from Elasticsearch, the S3 bucket is no longer actively managed, so access patterns drop to zero, and Intelligent-Tiering <em>should</em> move the data through Archive Access tiers down to Deep Archive Access (or even Glacier Instant Retrieval).</p><p>Because this relies on everything playing nice and leaving the data untouched and unaccessed for at least 90 days, Deepfreeze defaults to putting repositories in Standard and moving them to Glacier as soon as they’ve been unmounted, rather than waiting for IT to decide it’s time. We ensure that the data moves to Glacier as quickly as possible, but not before.</p><h3>Initial setup requirements</h3><p>Before running Deepfreeze, you need:</p><p>1. <strong>ILM policies configured to retain searchable snapshots:</strong></p><pre><code>"delete": {
  "delete_searchable_snapshot": false
}</code></pre><p>2.<strong> At least one existing repository</strong> with your chosen prefix (deepfreeze validates this)</p><p>3. <strong>AWS credentials</strong> configured for S3 access (via environment, IAM role, or credentials file)</p><p>4.<strong> Elasticsearch authentication</strong> with privileges to manage repositories and ILM policies</p><p>The setup command runs a comprehensive set of pre-checks to ensure the conditions are right before it starts.</p><h3>Post-setup tasks</h3><p>Deepfreeze just creates the environment for managing repositories; it doesn’t help your data get into those repos. We don’t know your business and what data you want preserved for posterity. After the Deepfreeze setup runs, ensure you have at least one ILM policy that uses this repo with snapshot deletion disabled. You will also need to ensure that you have an index template that associates this ILM policy with an index or data stream you want to preserve.</p><h2>Getting started</h2><h3>Installation</h3><pre><code># Clone the repository
git clone https://github.com/elastic/deepfreeze.git
cd deepfreeze

# Install in development mode
pip install -e .

# Or install dependencies directly
pip install -r requirements.txt</code></pre><h3>Basic usage</h3><pre><code># Set up environment variables (or use .env file)
export DEEPFREEZE_ELASTICSEARCH=https://es.example.com:9200
export DEEPFREEZE_CA=/path/to/http_ca.crt  # For self-signed certificates
export DEEPFREEZE_USERNAME=elastic
export DEEPFREEZE_KEEP=6

# Use a config.yml file instead
cp /path/to/deepfreeze/packages/deepfreeze-cli/config.yml.example ~/.deepfreeze/config.yml
# ...and then edit to set your values

# Run deepfreeze setup
deepfreeze setup

# With command-line options
deepfreeze setup --repo_name_prefix dftest \
                 --bucket_name_prefix myorg_dftest \
                 --base_path_prefix df_snapshots</code></pre><h3>Recommended workflow</h3><ol><li><strong>Start small:</strong> Test with a single index or low-priority data</li><li><strong>Monitor costs:</strong> Watch your AWS bill for the first few months to verify savings</li><li><strong>Verify tiering:</strong> Check S3 metrics to ensure Intelligent-Tiering is working as expected if chosen</li><li><strong>Automate gradually:</strong> Once confident, add to cron and expand to more indices</li></ol><h2>The bottom line</h2><p>Deepfreeze solves a real problem that many Elasticsearch operators face: <strong>how do you keep historical data accessible without going broke?</strong></p><p>By automating the rotation of snapshot repositories and letting AWS S3&#x27;s native tiering capabilities do the heavy lifting, you get:</p><ul><li>✅ Massive cost savings on long-term data retention</li><li>✅ Full compliance with data retention policies</li><li>✅ Quick access to recent historical data (6 months by default)</li><li>✅ On-demand remounting of older data when needed</li><li>✅ Clean, automated workflow that runs itself</li></ul><p>Deepfreeze works with your existing ILM policies and requires minimal configuration. It&#x27;s not a redesign of your entire data management strategy—it&#x27;s an optimization that pays for itself almost immediately.</p><p>There <em>are</em> costs associated with retrieving data from Glacier, and it does take time (6 hours for Standard retrieval as of this writing). Still, if you anticipate frequently needing your historical data, Deepfreeze may not be a great fit, and keeping your data in frozen tiers might be a better solution.</p><p>If you&#x27;re running Elasticsearch at scale and storing more than a few months of historical data, Deepfreeze is worth a serious look. Your CFO will thank you, and you&#x27;ll sleep better knowing that you can still fulfill a six-month-old audit request.</p><h2>Resources &amp; next steps</h2><ul><li><strong>Repository:</strong> <a href="http://github.com/elastic/deepfreeze">github.com/elastic/deepfreeze</a></li><li><strong>Documentation:</strong> See /<code>README.md</code> and the <code>docs/</code> directory in the repository</li><li><strong>Dependencies:</strong> Python 3.8+, others as described in <code>pyproject.toml</code></li></ul><p>Have questions or want to share your deepfreeze success story? Open an issue on GitHub or reach out!</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Getting started with Elastic Agent Builder and Strands Agents SDK]]></title>
            <description><![CDATA[Learn how to create an agent with Elastic Agent Builder and then explore how to use the agent via the A2A protocol orchestrated with the Strands Agents SDK.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-builder-a2a-strands-agents-guide</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-a2a-strands-agents-guide</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jonathan Simon]]></dc:creator>
            <pubDate>Mon, 15 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/31b6a5c1c30dacbb4d5e58d1c566071e7143a0c8-1600x879.gif?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Have an idea for an AI agent? It probably involves doing something with data because if an agent is going to initiate a useful action, it needs to make a decision, and it needs the right data to make the right decision.</p><p>Elastic Agent Builder makes it easy to build data-connected AI agents. We’ll show you how in this blog post. Let’s walk through all the steps required to create an Agent with an MCP tool that accesses data stored in Elastic. Then we&#x27;ll use the Strands Agents SDK and its Agent2Agent (A2A) capabilities to operate the agent. The <a href="https://strandsagents.com/">Strands Agents SDK</a> is a multi-agent AI development platform that you can use to build agentic apps with just enough code to ensure you get the outcome that you want.</p><p>Let&#x27;s build an AI agent that plays the game of RPS+, which is a take on the classic game of Rock Paper Scissors with an added twist; it gives the game players a couple of extra choices.</p><h2>Prerequisites</h2><p>Here’s what’s required to follow along with the steps in this blog post:</p><ul><li>A text editor running on your local computer<ul><li><a href="https://code.visualstudio.com/download">Visual Studio Code</a> is what we’ll be using for the example instructions in this blog post</li></ul></li><li><a href="https://www.python.org/downloads/">Python 3.10 or greater</a> running on your local computer</li></ul><h2>Create a Serverless Project</h2><p>The first thing we need is an Elasticsearch Serverless project, which includes Elastic Agent Builder.</p><p>Go to <a href="http://cloud.elastic.co/">cloud.elastic.co</a> and create a new Elasticsearch Serverless project.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/31b6a5c1c30dacbb4d5e58d1c566071e7143a0c8-1600x879.gif" style="width: 100%;" /><h2>Create an index and add data</h2><p>Next, we’re going to add some data to our Elasticsearch project. Open Developer Tools, where we can run commands to create a new index and insert some data into it. Select Developer Tools from the top-level navigation menu.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f97d5af077504463155655a9e27c171a7f974f71-1600x879.jpg" style="width: 100%;" /><p>Copy and paste the following PUT command into the request input area of the Developer Tools console. This statement creates an Elasticsearch index named “game-docs”.</p><pre><code>PUT /game-docs
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "content": { 
        "type": "text"
      },
      "filename": { "type": "keyword" },
      "last_modified": { "type": "date" }
    }
  }
}</code></pre><p>Click the <strong>Send request</strong> button that appears on the right-hand side of the statement in Developer Tools. You should see a notification confirming that the <em>game-docs</em> index was created in the response area of Developer Tools.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/be0555a1930e4d4f58b7ed8b669c9b702532ed17-1600x880.jpg" style="width: 100%;" /><p>An index named <em>game-docs</em> is a great place to store the data for the game we’re making. Let’s put a document named <em>rps+-md</em> into this index that contains all the data our game requires. Copy and paste the following PUT command into the Developer Tools console.</p><pre><code>PUT /game-docs/_doc/rps+-md
{
  "title": "Rock Paper Scissors +",
  "content": "
# Game Name
RPS+

# Starting Prompt
Let's play RPS+ !
---
What do you choose?

# Game Objects
1. Rock 🪨 👊
2. Paper 📜 🖐
3. Scissors ✄ ✌️
4. Light ☼ 👍
5. Dark Energy ☄ 🫱

# Judgement of Victory
* Rock beats Scissors
  * because rocks break scissors
* Paper beats Rock
  * because paper covers rock
* Scissors beat Paper
  * because scissors cut paper
* Rock beats Light
  * because you can build a rock structure to block out light
* Paper beats Light
  * because knowledge stored in files and paper books helps us understand light
* Light beats Dark Energy
  * because light enables humans to lighten up and laugh in the face of dark energy as it causes the eventual heat death of the universe
* Light beats Scissors
  * because light is needed to use scissors safely
* Dark Energy beats Rock
  * because dark energy rocks more than rocks. It rocks rocks and everything else in its expansion of the universe
* Dark Energy beats Paper
  * because humans, with their knowledge stored in files and paper books, can't explain dark energy 
* Scissors beat Dark Energy
  * because a human running with scissors is darker than dark energy

# Invalid Input
I was hoping for an worthy opponent
  - but alas it appears that time has past
  - but alas there's little time for your todo list when [todo:fix this] is so vast

# Cancel Game
The future belongs to the bold. Goodbye..
",
  "filename": "RPS+.md",
  "last_modified": "2025-11-25T12:00:00Z"
}</code></pre><p>Click the <strong>Send request</strong> button next to the statement to run it and add the <em>rps+-md</em> document to the game-docs index.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3c01d8a4602de5c33337457591a388a4a4e3fad3-1600x879.jpg" style="width: 100%;" /><p>We should now have some data to query, and with Agent Builder, that’s simpler than ever.</p><p>Select <strong>Agents</strong> from the top-level navigation menu.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/82dbd2e9a439cabd5a5eea3d0ce005b87df0c3ea-1600x879.jpg" style="width: 100%;" /><p>Then all you have to do is ask the default Elastic AI Agent, “What data do I have?”</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f7a2f39c9d1486bdf02d9e88a732b540ac2e2cd1-1600x872.gif" style="width: 100%;" /><p>The Elastic AI Agent evaluates the data and returns a concise explanation of the data we have.</p><h2>Create a tool</h2><p>Okay we’ve now got some data in Elastic, let’s put it to use. Agent Builder includes built-in support for creating <a href="https://modelcontextprotocol.io/">MCP</a> tools that help agents access the data they need to have the right context for their task. Let’s create a simple tool that will retrieve our game data.</p><p>Click the Agent Builder actions menu.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0e327c202674dda33bcc0e494d2b588fa8b32e4f-1600x879.png" style="width: 100%;" /><p>Select <strong>View all tools </strong>from the menu options.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1ebf58650e9fb56750d3f0b1700fab50b44f9bdf-1600x879.png" style="width: 100%;" /><p>Click <strong>+ New Tool.</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6c03a7f28b99ac2d805f34f39948979893316a00-1600x879.png" style="width: 100%;" /><p>In the <strong>Create Tool</strong> form, select the <strong><a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql">ES|QL</a> </strong>as the tool <strong>Type</strong> and enter the following values.</p><p>For <strong>Tool ID</strong>:</p><pre><code>example.get_game_docs</code></pre><p>For <strong>Description</strong>:</p><pre><code>Get RPS+ doc from Elasticsearch game-docs index.</code></pre><p>For <strong>Configuration, </strong>enter the following query into the <strong>ES|QL Query </strong>text area:</p><pre><code>FROM game-docs | WHERE filename == "RPS+.md"</code></pre><p>Your completed <strong>Create tool</strong> form should look like this. Click <strong>Save</strong> to create the tool.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b6c93e344600f319b9d2c3030020cf2d171ac1c4-1600x1312.png" style="width: 100%;" /><p>We’ve got a new tool hanging on the tool rack. Tools shouldn’t just be perpetually hanging on a rack; they should be put to a worthy use. Let’s create an agent that can use our new custom tool.</p><h2>Create an Agent and assign it a tool</h2><p>Creating an agent is refreshingly simple with Agent Builder. You just have to enter the agent instructions with a few details, and that’s all you need. Let’s create an agent now.</p><p>Click <strong>Manage agents.</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/53934b93db07187e251d4b321cb9ca647e2fd51b-1600x858.png" style="width: 100%;" /><p>Click<strong> + New agent.</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fae3ad8f31e71a6dfd044e1daa025a4e280b4e68-1600x490.png" style="width: 100%;" /><p>Enter the following information into the <strong>New Agent</strong> form.</p><p>For <strong>Agent ID, </strong>enter the text below:</p><pre><code>rps_plus_agent</code></pre><p>In the <strong>Custom Instructions </strong>text area, enter the following instructions:</p><pre><code>When prompted, if the prompt contains an integer, then select the corresponding numbered item in the list of "Game Objects" from your documents. Otherwise select a random game object. This is your chosen game object for a single round of the game.

# General Game Rules
* 2 players
    - the user: the person playing the game
    - you: the agent playing the game and serving as the game master
* Each player chooses a game object which will be compared and cause them to tie, win or lose.

# Start the game
1. This is the way each new game always starts. You make the first line of your response only the name of your chosen game object. 

2. The remainder of your response should be the "Starting Prompt" text from your documents and generate a list of "Game Objects" for the person playing the game to choose a game object from.  

# End of Game: The game ends in one of the following three outcomes:
1. Invalid Input: If the player responds with an invalid game object choice, respond with variations of the "Invalid Input" text from your documents and then end the game.

2. Tie: The game ends in a tie if the user chooses the same game object as your game object choice.

3. Win or Lose: The game winner is decided based on the "Judgement of Victory" conditions from your documents. Compare the user's game object choice and your game object choice and determine who chose the winning game object.

# Game conclusion
Respond with a declaration of the winner of the game by outputting the corresponding text in the "Judgement of Victory" section of your documents.</code></pre><p>For the <strong>Display name, </strong>enter the text below:</p><pre><code>RPS+ Agent</code></pre><p>For the <strong>Display description, </strong>enter the text below:</p><pre><code>An agent that plays the game RPS+</code></pre><p>Give the agent the custom tool we created previously by clicking the <strong>Tools</strong> tab.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1778f64bc3a1b4004998dc3668ef7f666788e193-1600x1390.png" style="width: 100%;" /><p>Select only the <em>example.get_game_docs</em> tool that we created previously.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7d734cd80161bcc058817482eb330ffcf1cb567b-1600x1363.png" style="width: 100%;" /><p>Click <strong>Save</strong> to create the new agent.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c0014faf605ce50c03679ed0d073bd9f3ae7234d-1600x468.png" style="width: 100%;" /><p>Let’s test out our new agent. There’s a handy link to start a chat with any agent from the list of agents.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b7d6943ad90a4f68691207caf66b81742e712145-1600x560.png" style="width: 100%;" /><p>Just enter “start game” and the game will start. It works!</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/984d008e4cc3f08cc1f101720673b0f7347c066c-1600x874.gif" style="width: 100%;" /><p>You can see that the agent displays its game object choice at the top of its response. This is useful in that we can see the agent’s choice and confirm the game is functioning as expected. However, knowing your opponent&#x27;s choice before you choose doesn’t make for a very fun game of Rock, Paper, Scissors. To polish and hone the game to its final form, we can use an agent orchestration platform that can control agents with code.</p><p>The Strands Agents SDK enters the chat.</p><h2>Strands Agents SDK</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c195bba6ff2754f5d8fda174a0c1d247bc283710-456x156.png" style="width: 100%;" /><p>If you’re curious about trying out new agent development frameworks, then the <a href="https://strandsagents.com/latest/">Strands Agents SDK</a> is worth your time to give it a try. The <a href="https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/">Strands Agents SDK was released by AWS (May 2025)</a> as an open source <a href="https://github.com/strands-agents/sdk-python">Python</a> implementation, and there’s now a <a href="https://dev.to/aws/strands-agents-now-speaks-typescript-a-side-by-side-guide-12b3">Typescript</a> version as well.</p><h2>Getting started with the Strands Agents SDK in Python</h2><p>Fire up your coding engines, we’re now going to race through the process of cloning and running an example app that uses Strands Agents to control the <em>RPS+ Agent</em> via the A2A protocol. Let’s create a fine-tuned version of the RPS+ game so that the agent’s choice is revealed after you make your choice, because after all, it’s the guessing and surprise outcome that makes games like Rock Paper Scissors fun.</p><p>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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2339cc37c89a3524f2b2a21684bc61dae958e1cf-915x460.jpg" style="width: 100%;" /><p>In the newly opened terminal, run the following command to clone the Elasticsearch Labs repository:</p><pre><code>git clone https://github.com/elastic/elasticsearch-labs</code></pre><p>Run the following <em>cd </em>command to change the directory into the elasticsearch-labs directory:</p><pre><code>cd elasticsearch-labs</code></pre><p>Next, run the following command to open the repository in Visual Studio Code:</p><pre><code>code .</code></pre><p>In the Visual Studio File Explorer, expand the <em>supporting-blog-content</em> and <em>agent-builder-a2a-strands-agents</em> folders and then open the file <em>elastic_agent_builder_a2a_rps+.py.</em> Here’s what the file looks like opened in Visual Studio Code:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d153b19e0e016c701576edb99ccab5af7c554f34-1484x1530.jpg" style="width: 100%;" /><p>Here’s the contents of <em>elastic_agent_builder_a2a_rps+.py </em>that you should see in your text editor:</p><pre><code>import asyncio
from dotenv import load_dotenv
from uuid import uuid4
import httpx
import os
import random
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
from a2a.types import Message, Part, Role, TextPart

DEFAULT_TIMEOUT = 60  # set request timeout to 1 minute


def create_message(*, role: Role = Role.user, text: str, context_id=None) -> Message:
    return Message(
        kind="message",
        role="user",
        parts=[Part(TextPart(kind="text", text=text))],
        message_id=uuid4().hex,
        context_id=context_id,
    )


async def main():
    load_dotenv()
    a2a_agent_host = os.getenv("ES_AGENT_URL")
    a2a_agent_key = os.getenv("ES_API_KEY")
    custom_headers = {"Authorization": f"ApiKey {a2a_agent_key}"}

    async with httpx.AsyncClient(
        timeout=DEFAULT_TIMEOUT, headers=custom_headers
    ) as httpx_client:
        # Get agent card
        resolver = A2ACardResolver(httpx_client=httpx_client, base_url=a2a_agent_host)
        agent_card = await resolver.get_agent_card(
            relative_card_path="/rps_plus_agent.json"
        )
        # Create client using factory
        config = ClientConfig(
            httpx_client=httpx_client,
            streaming=True,
        )
        factory = ClientFactory(config)
        client = factory.create(agent_card)
        # Use the client to communicate with the agent
        print("\nSending 'start game' message to Elastic A2A agent...")
        random_game_object = random.randint(1, 5)
        msg = create_message(text=f"start with game object {random_game_object}")
        async for event in client.send_message(msg):
            if isinstance(event, Message):
                context_id = event.context_id
                response_complete = event.parts[0].root.text
                # Get agent choice from the first line of the response
                parsed_response = response_complete.split("\n", 1)
                agent_choice = parsed_response[0]
                print(parsed_response[1])
        # User choice sent for game results from the agent
        prompt = input("Your Choice  : ")
        msg = create_message(text=prompt, context_id=context_id)
        async for event in client.send_message(msg):
            if isinstance(event, Message):
                print(f"Agent Choice : {agent_choice}")
                print(event.parts[0].root.text)


if __name__ == "__main__":
    asyncio.run(main())</code></pre><p>Let&#x27;s review what&#x27;s going on in this code. Starting with the <em><code>main()</code></em> method, the code begins by accessing the environment variables for the agent URL and the API Key. Then we use those values to create an <code><em>httpx</em> client</code> that we can use to get the agent card for the agent. The client then uses the agent card details to send a &quot;start game&quot; request to the agent. One interesting thing to note here is that we include a <code>random_game_object</code> value as part of the <code>&quot;start game&quot;</code> request. This value is a random number generated with the <em>random</em> module of Python&#x27;s standard library. The reason for doing this is that it turns out that the powerful LLMs (that make AI agents possible) are not great at randomness. No problem, Python to the rescue.</p><p>Continuing with the code, once the agent responds to the &quot;start game&quot; request, the code strips out the agent&#x27;s game object selection and saves it in the <em>agent_choice</em> variable. The remainder of the response is displayed as text to the end user. The user is then prompted for input of their game object choice, which is sent to the agent. The code then displays the agent’s game object choice along with the agent’s final determination of the game outcome.</p><h2>Setting your agent URL and API Key as environment variables</h2><p>Since the example app will be running on your local computer, in order to communicate with our Agent Builder agent, we need to provide the Strands Agents SDK with an A2A URL and API Key for the agent. The example app uses a file named <em>.env</em> to store these values.</p><p>Make a copy of the <em>env.example</em> file and name the new file <em>.env</em></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/25ead5f15a17dedb777132a082097cffb06cae4d-1600x843.jpg" style="width: 100%;" /><p>Jump back to Elastic Agent Builder, where we can get both of the values we need.</p><p>Select <strong>View all tools</strong> from the Agent Builder action menu at the top right of the page.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9c4f4e4a3bd76e11e0a182fa007a2f6aec7777b4-1600x880.jpg" style="width: 100%;" /><p>Click the <strong>MCP Server</strong> dropdown at the top of the Tools page and select <strong>Copy MCP Server URL.</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6cde0de678bb6f81bef8a59deffb110ad6c6ce26-1600x882.jpg" style="width: 100%;" /><p>Paste the <strong>MCP Server URL</strong> into the <em>.env</em> file as a replacement for the <strong>&lt;YOUR-ELASTIC-AGENT-BUILDER-URL&gt; </strong>placeholder value. Now we need to make one update to the URL, that is, replacing the ending text “mcp” with “a2a” because the <a href="https://a2a-protocol.org/">A2A protocol</a> is what the Agent Strands SDK will be using to communicate with the agent running in Elastic Agent Builder.</p><p>The edited URL should look something like this:</p><pre><code>https://rps-game-project-12345a.kb.us-east-1.aws.elastic.cloud/api/agent_builder/a2a</code></pre><p>The other value we need to get while we’re here in Elastic Cloud is an API Key. Click <strong>Elasticsearch </strong>in the top level navigation.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/651676b9be65178cdad50b5d24f26441c0bf3f97-1600x549.jpg" style="width: 100%;" /><p>Click the <strong>Copy API Key button </strong>to copy the API KEY.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/17f1e2ed5c7682630c71e75b0b09ffb1d9036210-1600x879.jpg" style="width: 100%;" /><p>Now, back in Visual Studio Code, paste the API Key in the <em>.env</em> file to replace the <strong>&lt;YOUR-ELASTIC-API-KEY&gt; </strong>placeholder text. Your <em>.env</em> file should look something like this:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a357947e07f29c8c03382e00c7baedf04a399297-1600x286.jpg" style="width: 100%;" /><h2>Run the example app</h2><p>Open a new Terminal in Visual Studio Code.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/33e1174c68ea1ed47c7fe62ab6a6da657c606f56-1413x711.jpg" style="width: 100%;" /><p>Start by running the following <em>cd</em> command in the terminal:</p><pre><code>cd elasticsearch-labs/supporting-blog-content/agent-builder-a2a-strands-agents</code></pre><p>Run the following command to create a Python virtual environment.</p><pre><code>python -m venv .venv</code></pre><p>Depending on your local computer’s operating system, run the following command to activate the virtual environment.</p><ul><li>MacOS/Linux</li></ul><pre><code>source .venv/bin/activate</code></pre><ul><li>Windows</li></ul><pre><code>.venv\Scripts\activate</code></pre><p>The example app uses Strands Agents SDK, and we are now at the point in this walk-through where we need to install it. Run the following command to install the Strands Agents SDK along with all of its required Python library dependencies.</p><pre><code>pip install -r requirements.txt</code></pre><p>Time to clear the launch pad and begin the countdown. We’re ready to launch this app. Stand back. Let’s run it using the following command:</p><pre><code>python elastic_agent_builder_a2a_rps+.py</code></pre><p>You should be challenged with a game of RPS+. Well done and best of luck!</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/041df81fbf1776f09e1243af0a435c4c0af6aca1-1600x948.gif" style="width: 100%;" /><h2>Build your AI apps with relevant context</h2><p>Building an AI Agent is now a skill in your toolbox. And you’ve seen how easy it is to use Elastic Agent Builder agents via A2A in agent development frameworks like Strands Agents SDK. <a href="https://cloud.elastic.co/registration?utm_source=agentic-ai-category&amp;utm_medium=search-labs&amp;utm_campaign=agent-builder">Try Elastic</a> for building AI agents connected to the relevant context in your custom data.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing Elasticsearch support in the Google MCP Toolbox for Databases]]></title>
            <description><![CDATA[Explore how Elasticsearch support is now available in the Google MCP Toolbox for Databases and leverage ES|QL tools to securely integrate your index with any MCP client.]]></description>
            <link>https://www.elastic.co/search-labs/blog/google-mcp-toolbox-elasticsearch-support</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/google-mcp-toolbox-elasticsearch-support</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Enrico Zimuel, Laurent Saint-Félix]]></dc:creator>
            <pubDate>Fri, 12 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/425a48691f436ed47c9bdfaf5d561ac122b2c472-1062x668.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>In this article, we’ll walk through how to use the Google MCP Toolbox with <a href="https://github.com/elastic/elasticsearch">Elasticsearch</a> to build a simple tool for extracting information from an Elasticsearch index.</p><p>We recently contributed to the <a href="https://github.com/googleapis/genai-toolbox">Google MCP Toolbox for Databases</a> open-source project by adding support for Elasticsearch as a database.</p><p>With this new feature, you can now use the Google MCP Toolbox to connect to Elasticsearch and directly “converse” with your data.</p><h2>Elasticsearch</h2><p>We need to have an Elasticsearch instance running. You can activate a free trial on <a href="https://www.elastic.co/cloud">Elastic Cloud</a> or install it locally using the <a href="https://github.com/elastic/start-local">start-local</a> script:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>This will install Elasticsearch and Kibana on your computer and generate an API key to be used for configuring Google MCP Toolbox.</p><p>The API key will be shown as output of the previous command and stored in a .env file in the elastic-start-local folder.</p><h2>Install the example dataset</h2><p>After the installation, you can log in to Kibana using the username <em>elastic</em> and the password generated by the start-local script (stored in a .env file).</p><p>You can install the <strong>eCommerce orders </strong>data set available from Kibana. It includes a single index named <strong>kibana_sample_data_ecommerce</strong> containing information about 4,675 orders from an ecommerce website. For each order, we have the following information:</p><ul><li>Customer information (name, ID, birth date, email, etc.)</li><li>Order date</li><li>Order ID</li><li>Products (list of all the products with price, quantity, ID, category, discount, etc.)</li><li>SKU</li><li>Total price (taxless, taxed)</li><li>Total quantity</li><li>Geo information (city, country, continent, location, region)</li></ul><p>To install the sample data, open the <strong>Integrations</strong> page in Kibana (search for “Integration” in the search top bar) and install the “Sample Data”. For more details, refer to the documentation here: <a href="https://www.elastic.co/docs/explore-analyze/#gs-get-data-into-kibana">https://www.elastic.co/docs/explore-analyze/#gs-get-data-into-kibana</a>.</p><p>The goal of this article is to show how easy it is to configure Google MCP Toolbox to connect to Elasticsearch and interact with the <strong>kibana_sample_data_ecommerce</strong> index using natural language.</p><h2>Google MCP Toolbox</h2><p>The Google MCP Toolbox is an open-source MCP server designed to make it easy for applications and AI agents to interact with databases securely and efficiently. Previously known as the “GenAI Toolbox for Databases,” the project was renamed after adopting full compatibility with the <a href="https://www.anthropic.com/news/model-context-protocol">Model Context Protocol</a> (MCP). Its purpose is to remove the heavy lifting traditionally required when connecting agents to databases by handling connection pooling, authentication, observability, and other operational concerns behind the scenes.</p><p>At its core, the Toolbox allows developers to define reusable, high-level tools that encapsulate database interactions. These tools can then be invoked by any MCP-compatible client—such as an AI agent—without requiring the client to implement low-level SQL queries or manage database connections. This approach dramatically reduces the amount of boilerplate code needed to build database-aware agents, making it possible to integrate advanced data operations in only a few lines of application logic. Once a tool is defined, it can be shared across multiple agents, frameworks, or languages (Figure 1).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/137cea290bb70ad5da21853f9a6358cef4cf7451-1248x1056.png" style="width: 100%;" /><p>A major advantage of using the Toolbox is the built-in security model. Authentication flows such as OAuth2 and OIDC are supported natively, allowing developers to avoid handling or storing sensitive database credentials in agents. The platform also provides observability features—including metrics and tracing—via OpenTelemetry, which is essential for debugging, monitoring, and production deployments. Altogether, MCP Toolbox serves as a unified, secure, and extensible interface for interacting with your data from any MCP-enabled system.</p><h2>How to install MCP Toolbox</h2><p>You can install the MCP Toolbox server on Linux using the following command:</p><pre><code>export VERSION=0.21.0
curl -L -o toolbox https://storage.googleapis.com/genai-toolbox/v$VERSION/linux/amd64/toolbox
chmod +x toolbox</code></pre><p>If you want to install it on macOS or Windows, you can follow the instructions detailed <a href="https://googleapis.github.io/genai-toolbox/getting-started/introduction/#installing-the-server">here</a>.</p><h2>Configure Toolbox for Elasticsearch</h2><p>To configure the MCP Toolbox for Elasticsearch, we need to create a <strong>tools.yaml</strong> file, as follows:</p><pre><code>sources:
  my-cluster:
    kind: elasticsearch
    addresses:
      - http://localhost:9200
    apikey: <insert-here-api-key>

tools:
  customer-orders:
    kind: elasticsearch-esql
    source: my-cluster
    description: Get the orders made by a customer identified by name.
    query: |
    	FROM kibana_sample_data_ecommerce | WHERE MATCH(customer_full_name, ?name, {"operator": "AND"})
    parameters:
      - name: name
        type: string
        description: The customer name.

toolsets:
  elasticsearch-tools:
    - customer-orders</code></pre><p>You need to replace the <strong>&lt;insert-here-api-key&gt;</strong> value with a valid Elasticsearch API key. If you are running Elasticsearch locally using start-local, you can find the API key in the .env file generated by start-local, under the <strong>ES_LOCAL_API_KEY</strong> variable. If you are using Elastic Cloud, you can generate an API key by following the procedure described <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elastic-cloud-api-keys">here</a>.</p><p>The previous tools contain the following ES|QL query for Elasticsearch:</p><pre><code>FROM kibana_sample_data_ecommerce | WHERE MATCH(customer_full_name, ?name)</code></pre><p>If you are not familiar with ES|QL, it is a query language developed by Elastic, similar to SQL, that can be used to search across one or more indices. You can read more about ES|QL in the official documentation <a href="https://www.elastic.co/docs/reference/query-languages/esql">here</a>.</p><p>The query above searches for all orders stored in the <strong>kibana_sample_data_ecommerce</strong> index that contain the specified customer’s name, using the <strong>?name</strong> parameter (the question mark denotes a parameter).</p><p>The customer’s name is defined in the earlier YAML configuration using the type string and the description “The customer name.”</p><p>This tool can be used to answer questions about a customer’s orders—for example: <em>How many orders did customer Foo place in October 2025?</em></p><p>The descriptions of the tools and their parameters are essential for extracting the relevant information from the user’s natural-language request. This extraction is performed using the <strong>function-calling</strong> capability of a Large Language Model (LLM). In practice, an LLM can determine which function (tool) needs to be executed to obtain the necessary information, along with the appropriate parameters for that function.</p><p>For more information about function calls, we suggest reading the <a href="https://www.elastic.co/search-labs/blog/function-calling-with-elastic">OpenAI function calling with Elasticsearch</a> article by Ashish Tiwari.</p><h2>Run the Toolbox server</h2><p>You can run the MCP Toolbox using the previous tools.yaml file with the following command:</p><pre><code>./toolbox --tools-file tools.yaml --ui</code></pre><p>The<strong> –ui</strong> parameter runs a web application at <a href="http://127.0.0.1:5000/ui">http://127.0.0.1:5000/ui</a> (Figure 2).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3caf2339b632bafd5847af1ed8b33b518a25b8a2-1600x314.png" style="width: 100%;" /><p>You can select the <strong>Tools</strong> &gt; <strong>customer-orders</strong> and insert a customer name in the parameter <strong>name</strong> (e.g. Gwen Sanders) and click on the <strong>Run Tool</strong> button. You should see a JSON response as reported in Figure 3.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b167e0142afb8919d9cedf6d0fa431d33d0e55f8-1600x933.png" style="width: 100%;" /><p>The setup is completed, and the MCP Toolbox can execute the <strong>customer-orders</strong> tool to communicate with Elasticsearch, running the ES|QL query.</p><h2>Using the MCP Toolbox with Gemini CLI</h2><p>We can use any MCP client to communicate with the MCP Toolbox for Databases. For instance, we can use <a href="https://github.com/google-gemini/gemini-cli">Gemini CLI</a>, a command-line tool to use Gemini. You can install Gemini CLI following the instructions reported <a href="https://geminicli.com/docs/get-started/installation/">here</a>.</p><p>Gemini CLI offers a pre-configured extension for MCP Toolbox, available at <a href="https://github.com/gemini-cli-extensions/mcp-toolbox">gemini-cli-extensions/mcp-toolbox</a>. You can install this extension by running the following command:</p><pre><code>gemini extensions install https://github.com/gemini-cli-extensions/mcp-toolbox</code></pre><p>After the installation, you need to go into the directory where you stored the tools.yaml configuration file for MCP Toolbox and execute Gemini CLI as follows (this step is required for the Gemini CLI to be automatically configured with MCP Toolbox):</p><pre><code>gemini</code></pre><p>You should see an output ad reported in Figure 4.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0f22df6d3da13c1dc50dcb560414fa7c630eb9a7-1434x341.png" style="width: 100%;" /><p>You can check if the MCP Toolbox is connected using the following command:</p><pre><code>/mcp list</code></pre><p>You should see the <strong>mcp_toolbox</strong> with the<strong> customer-orders</strong> tools listed (Figure 5).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/97edbc40de9e44f469f6f3a09427532be167de0e-493x155.png" style="width: 100%;" /><p>If the MCP Toolbox is connected to the Gemini CLI, we can now try asking some questions, such as: “<em>Give me the orders for the customer Gwen Sanders</em>.” The Gemini CLI will then request permission to execute the customer-orders tool from the mcp_toolbox server (see Figure 6).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c052f3b5e49436903b804280c0065f67ee02444b-1432x284.png" style="width: 100%;" /><p>After the confirmation, Gemini CLI will execute the request to the MCP Toolbox, getting a JSON response as a result and using it to format the response (Figure 7).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ea5128f1705951c2757af6da4b456d394d4a080-1432x734.png" style="width: 100%;" /><p>The response from Gemini CLI will report that Gewn Sanders did just one order of 2 products, for a total price of 132 euros.</p><h2>MCP Toolbox SDKs</h2><p>Google MCP Toolbox also offers an SDK to access all the functionalities from a program written in Go, Python and Javascript.</p><p>For instance, the Python SDK is available on Github at the following page: <a href="https://github.com/googleapis/mcp-toolbox-sdk-python">https://github.com/googleapis/mcp-toolbox-sdk-python</a>.</p><p>We need to create a simple agent to connect to the MCP Toolbox. We need to install the following packages:</p><pre><code>pip install toolbox-core
pip install google-adk</code></pre><p>And create a new agent project using the following command:</p><pre><code>adk create my_agent</code></pre><p>This will create a new directory named <strong>my_agent</strong> with a file <strong>agent.py</strong>.</p><p>Update <strong>my_agent/agent.py</strong> with the following content to connect to Toolbox:</p><pre><code>from google.adk import Agent
from google.adk.apps import App
from toolbox_core import ToolboxSyncClient

client = ToolboxSyncClient("http://127.0.0.1:5000")

root_agent = Agent(
    name='root_agent',
    model='gemini-2.5-flash',
    instruction="You are a helpful AI assistant designed to search information about a dataset of ecommerce orders.",
    tools=client.load_toolset(),
)

app = App(root_agent=root_agent, name="my_agent")</code></pre><p>Create a <strong>.env</strong> file with your Google API key:</p><pre><code>echo 'GOOGLE_API_KEY="YOUR_API_KEY"' > my_agent/.env</code></pre><p>Finally, we can run the agent and observe the results. To execute the agent, you can run the following command:</p><pre><code>adk run my_agent</code></pre><p>Or, you can serve it via a web interface:</p><pre><code>adk web --port 8000</code></pre><p>In both cases, you can interact with the MCP Toolbox using a Q&amp;A interface. For instance, you can ask the previous question: <em>Give me the orders of the customer Gwen Sanders</em>.</p><p>For more information about the different SDKs, you can refer to <a href="https://googleapis.github.io/genai-toolbox/sdks/">this documentation page</a>.</p><h2>Conclusion</h2><p>In this article, we demonstrated the Elasticsearch integration for the Google MCP Toolbox for Databases. Using a simple YAML configuration file, we can define a set of tools that translate natural-language questions into Elasticsearch queries using the ES|QL language.</p><p>We showed how to interact with the kibana_sample_data_ecommerce dataset, which contains orders from an e-commerce website. With this configuration file, we can simply run the MCP Toolbox server and connect to it from any MCP client.</p><p>Finally, we demonstrated how to use the Gemini CLI as a client to connect to the MCP Toolbox for Databases and query the e-commerce data stored in Elasticsearch. We executed a natural-language query to retrieve information about orders for a specific customer identified by name.</p><p>As the MCP ecosystem continues to grow, this pattern—lightweight tool definitions backed by secure, production-ready infrastructure—creates new opportunities for building increasingly capable, data-aware agents with minimal effort. Whether you&#x27;re experimenting locally with Elastic’s sample datasets or integrating search capabilities into a larger application, MCP Toolbox provides a reliable, extensible foundation for interacting with your Elasticsearch data using natural language.</p><p>For more information about the development of agentic AI applications, you can read the <a href="https://search-labs-redesign.vercel.app/search-labs/blog/ai-agentic-workflows-elastic-ai-agent-builder">Building AI Agentic workflows with Elasticsearch</a> article by Anish Mathur and Dana Juratoni.</p><p>For more information about the Google MCP Toolbox, you can visit <a href="https://googleapis.github.io/genai-toolbox/getting-started/introduction/">https://googleapis.github.io/genai-toolbox/getting-started/introduction/</a>.</p>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jhon Guzmán]]></dc:creator>
            <pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/25478d0ffb228afd5d65d82312998ec1c299c565-700x490.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <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><strong>Removes uncertainty</strong>: you no longer need to wonder if your changes impact other queries; the data will tell you.</li><li><strong>Stops manual testing</strong>: once the judgment sets are recorded, the test is automatic.</li><li><strong>Supports changes</strong>: You can show clear metrics that support the benefits of a change.</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><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”).</li><li><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.</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><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.</li><li><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.</li><li><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.</li></ul><p>Below are some recommendations to choose the best method for creating your judgment set:</p><ul><li>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>.</li><li>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)</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><strong>Results shuffling</strong>: Change the order of search results for a subset of users to estimate how position affects clicks.</li><li><strong>Click models </strong>include<strong> </strong><a href="https://wiki.math.uwaterloo.ca/statwiki/index.php?title=a_Dynamic_Bayesian_Network_Click_Model_for_web_search_ranking">Dynamic Bayesian Network <strong>DBN</strong></a>, <a href="https://rsrikant.com/papers/kdd10.pdf">User Browsing Model <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.</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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Query</td><td style="border: 1px solid #ccc; padding: 4px;">DocID</td><td style="border: 1px solid #ccc; padding: 4px;">Text</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">DiCaprio performance</td><td style="border: 1px solid #ccc; padding: 4px;">doc1</td><td style="border: 1px solid #ccc; padding: 4px;">DiCaprio's performance in The Revenant was breathtaking.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">DiCaprio performance</td><td style="border: 1px solid #ccc; padding: 4px;">doc2</td><td style="border: 1px solid #ccc; padding: 4px;">Inception shows Leonardo DiCaprio in one of his most iconic roles.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">DiCaprio performance</td><td style="border: 1px solid #ccc; padding: 4px;">doc3</td><td style="border: 1px solid #ccc; padding: 4px;">Brad Pitt delivers a solid performance in this crime thriller.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">DiCaprio performance</td><td style="border: 1px solid #ccc; padding: 4px;">doc4</td><td style="border: 1px solid #ccc; padding: 4px;">An action-packed adventure with stunning visual effects.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">sad movies that make you cry</td><td style="border: 1px solid #ccc; padding: 4px;">doc5</td><td style="border: 1px solid #ccc; padding: 4px;">A heartbreaking story of love and loss that made me cry for hours.</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">sad movies that make you cry</td><td style="border: 1px solid #ccc; padding: 4px;">doc6</td><td style="border: 1px solid #ccc; padding: 4px;">One of the saddest movies ever made — bring tissues!</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">sad movies that make you cry</td><td style="border: 1px solid #ccc; padding: 4px;">doc7</td><td style="border: 1px solid #ccc; padding: 4px;">A lighthearted comedy that will make you laugh</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">sad movies that make you cry</td><td style="border: 1px solid #ccc; padding: 4px;">doc8</td><td style="border: 1px solid #ccc; padding: 4px;">A science-fiction epic full of action and excitement.</td>
                    </tr>
              </tbody>
            </table>
          <p>Creating the index:</p><pre><code>PUT movies
{
  "mappings": {
    "properties": {
      "text": {
        "type": "text"
      }
    }
  }
}</code></pre><p>BULK request:</p><pre><code>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." }</code></pre><p>Below is the Elasticsearch query the app is using:</p><pre><code>GET movies/_search
{
 "query": {
   "match": {
     "text": {
       "query": "DiCaprio performance",
       "minimum_should_match": "100%"
     }
   }
 }
}</code></pre><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><strong><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-precision">Precision</a>: </strong>Measures the proportion of results that are truly relevant within all search results.</li><li><strong><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall">Recall</a>: </strong>Measures the proportion of relevant results the search engine found among x results.</li><li><strong><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#_discounted_cumulative_gain_dcg">Discounted Cumulative Gain (DCG)</a>: </strong>Measures the quality of the result’s ranking, considering the most relevant results should be at the top.</li><li><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.</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><pre><code>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
     }
 }
}</code></pre><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><pre><code>{
 "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": {}
}</code></pre><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><pre><code>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
     }
 }
}</code></pre><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><pre><code>{
  "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": {}
}</code></pre><p>In summary, removing the minimum_should_match: 100% clause, allows us to got a perfect recall for both queries.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/24d055da4348c63827ba7046fe8cafb6f47cadd8-546x628.png" style="width: 100%;" /><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><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.</li><li><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.</li><li><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.</li><li><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.</li><li><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.</li></ol>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
            <pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e88ddfa1b453327fe75211500b850b49ad3776f5-1172x844.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <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>A luxury shopper searching for “red lipstick” expects prestige brands near the top.</li><li>A budget shopper wants affordable options promoted.</li><li>A gift buyer may prefer popular bundles.</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>overwhelms BM25 on some queries, or</li><li>has almost no effect on others.</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><pre><code>final_score = BM25 × (1 + cohort_overlap × weight_per_cohort)</code></pre><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>Lipstick: [&quot;female&quot;, &quot;beauty&quot;, &quot;luxury&quot;]</li><li>Men’s deodorant: [&quot;male&quot;, &quot;personal_care&quot;, &quot;sport&quot;]</li><li>Glitter gloss: [&quot;female&quot;, &quot;beauty&quot;, &quot;youth&quot;, &quot;party&quot;].</li></ul><p>A user or session carries a set of tags inferred from behavior and profile:</p><ul><li>High-income female luxury shopper: [&quot;female&quot;, &quot;beauty&quot;, &quot;luxury&quot;]</li><li>Budget-oriented female shopper: [&quot;female&quot;, &quot;beauty&quot;, &quot;budget&quot;]</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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e88ddfa1b453327fe75211500b850b49ad3776f5-1172x844.png" style="width: 100%;" /><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&#x27;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><pre><code>{
  "product_id": "LIP-001",
  "description": "Premium cherry red lipstick with velvet finish",
  "cohorts": ["female", "beauty", "luxury"]
}</code></pre><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><pre><code>final_score = BM25 × boost
boost = 1 + overlap × weight_per_cohort</code></pre><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 &quot;multiply&quot; 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: &quot;sum&quot; to produce a boost factor that represents (1 + overlap × weight). Finally, we combine this boost factor with the BM25 score using boost_mode: &quot;multiply&quot;, 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><pre><code>sum_score = baseline + n × w
final_score = BM25 × sum_score</code></pre><p>So, with 2 overlapping cohorts and w = 0.1:</p><pre><code>sum_score = 1.0 + 2 × 0.1 = 1.2
final_score = BM25 × 1.2</code></pre><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><pre><code>PUT product_catalog
{
  "mappings": {
    "properties": {
      "product_id": {
        "type": "keyword"
      },
      "description": {
        "type": "text"
      },
      "cohorts": {
        "type": "keyword"
      }
    }
  }
}</code></pre><p>Index a few products:</p><pre><code>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"] }</code></pre><p>A baseline query for “red lipstick” might look like:<br/></p><pre><code>POST product_catalog/_search
{
  "size": 5,
  "_source": ["product_id", "description"],
  "query": {
    "multi_match": {
      "query": "red lipstick",
      "fields": ["description"]
    }
  }
}</code></pre><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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Product ID</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td><td style="border: 1px solid #ccc; padding: 4px;">BM25 score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-001</td><td style="border: 1px solid #ccc; padding: 4px;">Premium cherry red lipstick with velvet finish</td><td style="border: 1px solid #ccc; padding: 4px;">0.603535</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-002</td><td style="border: 1px solid #ccc; padding: 4px;">Affordable matte red lipstick for everyday wear</td><td style="border: 1px solid #ccc; padding: 4px;">0.603535</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-003</td><td style="border: 1px solid #ccc; padding: 4px;">Glitter red gloss for parties and festivals</td><td style="border: 1px solid #ccc; padding: 4px;">0.13353139</td>
                    </tr>
              </tbody>
            </table>
          <img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/513bfba3467fb9966ed00b6e798889eeb690fe84-1788x1306.png" style="width: 100%;" /><h3>Persona A: high-income luxury shopper</h3><p>Suppose we know that Persona A fits into the following cohorts:</p><pre><code>["female", "beauty", "luxury"]</code></pre><p>We translate that into a set of cohort filters, each with a small weight, plus a baseline factor:</p><pre><code>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"
    }
  }
}</code></pre><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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Product ID</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td><td style="border: 1px solid #ccc; padding: 4px;">Base BM25 score</td><td style="border: 1px solid #ccc; padding: 4px;">Boost factor</td><td style="border: 1px solid #ccc; padding: 4px;">New score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-001</td><td style="border: 1px solid #ccc; padding: 4px;">Premium cherry red lipstick with velvet finish</td><td style="border: 1px solid #ccc; padding: 4px;">0.603535</td><td style="border: 1px solid #ccc; padding: 4px;">1.3x (30%)</td><td style="border: 1px solid #ccc; padding: 4px;">0.7845955</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-002</td><td style="border: 1px solid #ccc; padding: 4px;">Affordable matte red lipstick for everyday wear</td><td style="border: 1px solid #ccc; padding: 4px;">0.603535</td><td style="border: 1px solid #ccc; padding: 4px;">1.2x (20%)</td><td style="border: 1px solid #ccc; padding: 4px;">0.724242</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-003</td><td style="border: 1px solid #ccc; padding: 4px;">Glitter red gloss for parties and festivals</td><td style="border: 1px solid #ccc; padding: 4px;">0.13353139</td><td style="border: 1px solid #ccc; padding: 4px;">1.2x (20%)</td><td style="border: 1px solid #ccc; padding: 4px;">0.16023767</td>
                    </tr>
              </tbody>
            </table>
          <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>[&quot;female&quot;, &quot;beauty&quot;, &quot;budget&quot;]</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><pre><code>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"
    }
  }
}</code></pre><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>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">Product ID</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td><td style="border: 1px solid #ccc; padding: 4px;">Base BM25 score</td><td style="border: 1px solid #ccc; padding: 4px;">Boost factor</td><td style="border: 1px solid #ccc; padding: 4px;">New score</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-002</td><td style="border: 1px solid #ccc; padding: 4px;">Affordable matte red lipstick for everyday wear</td><td style="border: 1px solid #ccc; padding: 4px;">0.603535</td><td style="border: 1px solid #ccc; padding: 4px;">1.3x (30%)</td><td style="border: 1px solid #ccc; padding: 4px;">0.7845955</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-001</td><td style="border: 1px solid #ccc; padding: 4px;">Premium cherry red lipstick with velvet finish</td><td style="border: 1px solid #ccc; padding: 4px;">0.603535</td><td style="border: 1px solid #ccc; padding: 4px;">1.2x (20%)</td><td style="border: 1px solid #ccc; padding: 4px;">0.724242</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">LIP-003</td><td style="border: 1px solid #ccc; padding: 4px;">Glitter red gloss for parties and festivals</td><td style="border: 1px solid #ccc; padding: 4px;">0.13353139</td><td style="border: 1px solid #ccc; padding: 4px;">1.2x (20%)</td><td style="border: 1px solid #ccc; padding: 4px;">0.16023767</td>
                    </tr>
              </tbody>
            </table>
          <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><pre><code>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 })</code></pre><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>browsing events (“has viewed lipstick” → beauty)</li><li>gender inference (from preferences or marketing profile)</li><li>device characteristics (mobile shopper)</li><li>location (“urban buyer”)</li><li>historical purchases</li><li>marketing segments</li><li>personalization cookies</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>catalog diversity</li><li>number of cohort tags per product</li><li>variability in BM25</li><li>business goals (revenue vs. discovery vs. personalization)</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>Noise in the signals</li><li>Gaming by merchandisers (“tag everything as luxury”)</li><li>Loss of explainability</li><li>Over-boosting</li></ul><p>As a starting point (to be confirmed by your own testing), we recommend:</p><ul><li>Approximately 5 cohorts per product.</li><li>Optionally, an offline validation step (ingest pipeline, CI script, or index-time check) that warns or blocks when more than 5 tags are assigned.</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>A user who almost always buys luxury brands.</li><li>A user who consistently picks budget options.</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><pre><code>"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 }
]</code></pre><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><pre><code>boost = 1.0 + 0.1 + 0.1 + 0.2 = 1.4</code></pre><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>Add a single keyword field (cohorts) to each product containing 3–5 cohort tags.</li><li>Compute user/session cohorts in your application logic (from browsing, purchase history, CRM, etc.) and pass them with the query.</li><li>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).</li><li>Wrap your existing BM25 query in function_score with score_mode: &quot;sum&quot; and boost_mode: &quot;multiply&quot; to apply multiplicative boosting.</li><li>Tune per-cohort weights (typically 0.05–0.20) based on A/B experiments, ensuring BM25 remains the dominant signal.</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><strong>Implement custom personalization faster:</strong> If you&#x27;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.</li><li><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.</li></ol>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AutoOps in action: Investigating Elasticsearch cluster performance on ECK]]></title>
            <description><![CDATA[Explore how Elastic's InfoSec team implemented AutoOps in a multi-cluster ECK environment, cutting cluster performance investigation time from 30+ minutes to five minutes.]]></description>
            <link>https://www.elastic.co/search-labs/blog/autoops-eck-cluster-performance-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/autoops-eck-cluster-performance-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Aram Favela]]></dc:creator>
            <pubDate>Tue, 09 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d267a73cce0714da8a42a2acc9726b996c72a786-1404x664.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<div style="display:none">Unknown block type "infoPanel", specify a component for it in the `components.types` option</div><p>At Elastic, the InfoSec Security Engineering team is responsible for deploying and managing InfoSec&#x27;s ECK clusters. In the Elastic on Elastic series, we highlight InfoSec’s role as Customer Zero. By running the latest stack versions and features, we aim to provide practical advice and an inside look at how we operate. In this segment, we cover installing AutoOps in our multi-cluster <a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s">Elastic Cloud on Kubernetes</a> (ECK) environment and highlight how it delivered value immediately.</p><p><a href="https://www.elastic.co/platform/autoops">AutoOps for Elasticsearch</a> streamlines cluster operations by providing performance recommendations, resource utilization insights, real-time issue detection, and guided remediation. With the recent <a href="https://www.elastic.co/blog/elasticsearch-autoops-on-prem">announcement</a> of AutoOps for self-managed (on‑premises) clusters, we were eager to deploy it and see how it could help us monitor and maintain the health of our <a href="https://www.elastic.co/blog/elastic-on-elastic-data-collected-to-the-infosec-siem">large</a> multi-cluster ECK environment.</p><h2>Installing AutoOps for ECK</h2><p>To ensure scalability and consistency, the Security Engineering team aims to manage all infrastructure as code (IaC). Staying in line with this approach, we created a Helm chart to deploy the AutoOps agent across our ECK environment. For additional information on our base ECK and Helm approach, please refer to a previous <a href="https://www.elastic.co/blog/elastic-on-elastic-how-infosec-deploys-infrastructure-and-stays-up-to-date-with-eck">blog</a> post in this series.</p><h3>AutoOps chart</h3><pre><code>cloud-connected-autoops/
├─ chart.yaml
├─ values.yaml
└─ templates/
   └─ deployment.yaml</code></pre><p>chart.yaml</p><pre><code>apiVersion: v1
description: Autoops cloud connected Agent chart
name: cloud-connected-autoops
version: 0.1.0</code></pre><p>values.yaml</p><pre><code>version: 9.2.0</code></pre><p>deployment.yaml<br/>This is derived from the official AutoOps <a href="https://github.com/elastic/autoops-install/blob/main/k8s/autoops_agent_deployment.yaml">repository</a>, however we templatized the image version to simplify future upgrades and enable compatibility with our existing automation:</p><pre><code> image: >-
   docker.elastic.co/elastic-agent/elastic-otel-collector-wolfi:{{ .Values.version }}</code></pre><p>With the AutoOps Helm chart defined, we can now install AutoOps in our environment using <a href="https://www.elastic.co/docs/deploy-manage/cloud-connect">Cloud Connect</a>. This feature allows us to use Elastic Cloud services within our self-managed ECK clusters without the overhead of installing and maintaining additional infrastructure.</p><p>The <a href="https://www.elastic.co/docs/deploy-manage/monitor/autoops/cc-connect-self-managed-to-autoops">documentation</a> outlines the required steps for setting up an Elastic Cloud Connect account and generating the necessary connection details for each cluster. Given our ECK environment, we utilized the Kubernetes installation method.</p><p>With the connection configured, we only needed to enable the Elastic Agent by adding a simple boolean flag to the <code>values.yml</code> for each ECK cluster:</p><pre><code>cloud-connected-autoops:
  enabled: true</code></pre><p>Once the installation is complete, each connected cluster should be listed on the AutoOps overview page:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f1ad7bb4247230dacff1c9c52d4d0f7d32764b4b-5110x1326.png" style="width: 100%;" /><h2>AutoOps in action</h2><p>We currently utilize Stack Monitoring to monitor cluster health and use <a href="https://www.elastic.co/blog/elastic-on-elastic-how-infosec-deploys-infrastructure-and-stays-up-to-date-with-eck">default</a> rules to send us alerts. While we plan to migrate to AutoOps alerting in the near future, our existing alerting still relies on Stack Monitoring.</p><p>The opportunity to test AutoOps presented itself when we received a thread pool write rejections alert in our alerting Slack channel:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ca35fc6061e946a5d18385fa7c5b8bf0a995f160-1380x120.png" style="width: 100%;" /><p>After receiving the standard alert, we checked AutoOps for additional context. AutoOps flagged the same issue but provided extra crucial detail, including the exact index causing the write rejections:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/935ee55eb8d685660023d7f9b1bf5e0ffbdf9a43-1600x902.png" style="width: 100%;" /><p>Next, we navigated to the shard Analyzer page, which visualizes sharding hotspots. The visualization made it clear that this particular node had the highest ingestion rate and was managing two write indices. The graph also indicated other nodes that were underutilized:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/24f074624e926e8245e333fa3c1b929b3a60502c-1600x1101.png" style="width: 100%;" /><p>To alleviate the load on <code>es-data-3</code>, we moved the smaller write index to <code>es-data-5</code> using a cluster reroute command:</p><pre><code>POST /_cluster/reroute
{
  "commands": [
    {
      "move": {
        "index": ".ds-logs-osquery_manager.result-workstation-2025.09.15-000632",
        "shard": 0, 
        "from_node": "es-data-3",  
        "to_node": "es-data-5"
      }
    }
  ]
}</code></pre><p>After executing the reroute, the write thread pool for <code>es-data-3</code> decreased immediately:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d2b1432bc99138081fb5b5cbe37852c0c680a4be-1176x1116.png" style="width: 100%;" /><p>Without AutoOps, investigating Elasticsearch performance would have required manually querying individual node metrics, shard allocations, and thread pools, often through multiple point in time API calls. AutoOps centralizes this data, continuously collects it in real time, and visualizes trends over time. <strong>As a result, we were able to investigate and resolve an issue that would have previously taken thirty minutes or more in just five minutes.</strong></p><p>While this example only scratches the surface of AutoOps’ capabilities, our hands-on experience shows that its detailed metrics and visualizations make investigating and resolving Elasticsearch performance issues easier and more efficient than previously possible.</p><p>To learn more about deploying AutoOps for Self Managed clusters, check out the <a href="https://www.elastic.co/blog/elasticsearch-autoops-on-prem">announcement</a>.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How excessive replica counts can degrade performance, and what to do about it]]></title>
            <description><![CDATA[Learn about the impact of high replica counts in Elasticsearch, and how to ensure cluster stability by right-sizing your replicas.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-replica-counts-right-sizing</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-replica-counts-right-sizing</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Alexander Marquardt]]></dc:creator>
            <pubDate>Mon, 08 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/44f0dae40fe8a30287a6aa4fd23b7448a4282abe-998x562.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Replicas are essential to Elasticsearch: they provide high availability and help scale out search workloads. But like any distributed system, too much redundancy can become counterproductive. Excessive replica counts magnify write load, increase shard overhead, exhaust filesystem cache, elevate heap pressure, and can destabilize a cluster.</p><p>This article explains why excessive replica counts can cause severe performance degradation, how to diagnose the symptoms, and how right-sizing replica counts restored stability in a real large-scale customer deployment.</p><h2>The role of replicas in Elasticsearch</h2><p>Replicas in Elasticsearch serve two primary purposes:</p><ul><li>High availability: If a node fails, replicas ensure data remains available.</li><li>Search scalability: Replicas allow Elasticsearch to distribute search load across multiple nodes.</li></ul><p>However, each replica is a full physical copy of its primary shard, and every write must be applied to every replica. Replicas provide resilience, but they can also consume CPU, heap, filesystem cache, disk I/O, cluster state bandwidth, and recovery bandwidth. Replicas are powerful, but they are not free.</p><h2>When high replica counts can make sense</h2><p>There is a narrow set of scenarios where high replica counts genuinely improve performance:</p><ul><li>The cluster contains a small amount of extremely hot data where the <a href="https://en.wikipedia.org/wiki/Working_set">working set</a> fits into RAM on every node.</li><li>The cluster is intentionally overprovisioned.</li><li>The data is infrequently written or updated.</li></ul><p>In this scenario, replicas help utilize all nodes effectively, maximizing CPU utilization and cache efficiency.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/530f99871fc9f0df34855886764877b792a6fe4d-2512x794.png" style="width: 100%;" /><p><strong>Diagram 1: High replica counts benefit a single, small, hot data set (one or more indices) that fits into RAM on all nodes. All queries hit cached data and throughput scales efficiently.</strong></p><h2>The reality in large, multi-index clusters</h2><p>Most production environments contain many indices, diverse workloads, variable shard sizes, and mixed read/write patterns. In these settings, high replica counts introduce compounding issues that can severely degrade performance.</p><h3>Cache thrashing and memory pressure</h3><p>Every shard copy competes for limited filesystem cache. With excessive replicas:</p><ul><li>The working set grows beyond RAM capacity</li><li>Nodes are forced to read from disk for routine queries</li><li>Useful cached pages are constantly evicted, which causes “cache churn”. Cache hit rates collapse</li><li>Latency becomes unpredictable</li></ul><p>When multiple indices compete for the same finite memory, the cost of serving a single query increases dramatically because the shard data needed for that query is not in RAM.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7ff888f4f3da9001543e250e16c3b357f3a25ca4-2556x794.png" style="width: 100%;" /><p><strong>Diagram 2: In clusters with many indices and excessive replicas, shards compete for limited RAM and heap, causing frequent cache evictions and memory pressure.</strong></p><p>Note: This diagram is conceptual. In practice, nodes hold interleaved fragments of many shards in filesystem cache, but the underlying principle remains the same as what is illustrated in the diagram.</p><h3>Write amplification</h3><p>If an index has 5 replicas, a single document write becomes 6 independent writes, each with its own merge cycles, segment management, and I/O cost. This directly increases:</p><ul><li>Disk utilization</li><li>Indexing latency</li><li>Merge pressure</li><li>Threadpool saturation</li><li>Backpressure and retry load</li></ul><p>Indexing throughput may become unsustainable with high replica counts. The diagram illustrates how an update to a single index with 5 replicas results in a write operation on every node hosting a shard copy, in this example all six nodes in the cluster.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5d4f2ab7ca7a0506dcfbbbe65db155acd2b6188e-2610x746.png" style="width: 100%;" /><p><strong>Diagram 3: Every write operation is multiplied by the number of replicas, dramatically increasing disk I/O and risking saturation in write-heavy environments.</strong></p><h3>Increased shard overhead</h3><p>More replicas mean more:</p><ul><li>Shards</li><li>Segment files</li><li>File descriptors</li><li>Cluster state updates</li><li>Memory reserved for per-shard data structures</li></ul><p>This expands JVM heap usage and increases GC frequency.</p><h2>Diagnosing excessive replication: key symptoms</h2><p>Clusters suffering from excessive replicas often exhibit the following operational symptoms:</p><ul><li>Frequent page faults and swapping: Working set cannot fit in RAM, leading to constant cache misses.</li><li>Excessive garbage collection (GC): High heap usage and long GC pauses due to too many shards.</li><li>Elevated disk I/O: Write amplification and cache churn drive up disk operations.</li><li>Unassigned shards and node instability: Resource exhaustion can cause nodes to leave and shards to be reallocated.</li><li>Search latency spikes: Queries frequently miss cache and hit disk, causing unpredictable response times.</li></ul><p>If you observe these symptoms, review your replica counts and sharding strategy.</p><h2>The solution: right-size your replicas</h2><h3>Best practices</h3><ul><li>Set replicas based on failure tolerance, not guesswork. For most clusters, 1 replica is sufficient (2 if spanning 3 <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/availability-and-resilience">availability zones</a>).</li><li>Monitor cache hit rates and heap usage. Ensure your hot working set fits in RAM; otherwise, reduce replica count or re-architect your sharding strategy.</li></ul><p>Using the earlier six-node example, reducing replicas from 5 to 1 dramatically reduces cache contention, improves cache locality, and lowers write amplification as shown in the following diagram.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/22af96ce232cccac3be687643bee4d88b3aa2ac7-2556x802.png" style="width: 100%;" /><p><strong>Diagram 4: Reducing replicas from 5 to 1 dramatically reduces the data hosted on each node and the overall memory contention.</strong></p><h3>Impact of reducing replicas</h3><p>A large enterprise customer experienced severe and persistent cluster instability. Symptoms included:</p><ul><li>High latency</li><li>Nodes repeatedly leaving the cluster</li><li>Excessive disk I/O</li><li>Frequent GC interruptions</li><li>Search throughput collapse</li></ul><p>Upon escalation, the root cause was quickly identified: The 20-node cluster had 12 replicas configured across numerous indices. After reducing replica counts to a sane baseline (typically 1) and rebalancing shards:</p><ul><li>Search latency normalized almost immediately</li><li>Disk I/O dropped dramatically</li><li>GC returned to normal levels</li><li>Nodes stabilized with no further drop-outs</li></ul><p>Right-sizing replicas was the key intervention.</p><h2>Common misconception: Will fewer replicas overload my nodes?</h2><p>A common concern is that reducing the number of replicas will concentrate search traffic on fewer nodes, creating hotspots or bottlenecks. In reality, Elasticsearch distributes queries across all available shard copies (primaries and replicas) for each index. Reducing replicas does not change the total query volume handled by the cluster; it changes the memory dynamics on each node.</p><p>With fewer replicas, each node holds fewer shards, making it far more likely that the data required for a query is already resident in RAM. The overall QPS per node remains comparable, but the cost per query drops dramatically because far fewer lookups result in (expensive) disk I/O.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4f4bcec203ee59cc90ad1204ccf0baf56e953c4f-2576x1358.png" style="width: 100%;" /><p><strong>Diagram 5: Same query load, improved cache hits: Before and after reducing replicas</strong></p><h2>Recommendations</h2><ul><li>Audit your cluster: Review replica counts across all indices to ensure that you are really benefitting from the number of replicas you have assigned.</li><li>Avoid “one-size-fits-all” settings: Tune replicas and primaries per index based on workload.</li><li>Educate your team: Replicas are a tool, not a universal solution. Understand the trade-offs.</li><li>Modifying the number of replicas that can be done at any time. Test changes in a controlled environment and monitor performance before and after adjustments.</li></ul><h2>Conclusion</h2><p>Replicas are essential for resilience and search scalability, but in many use cases high replica counts can silently undermine Elasticsearch cluster performance.</p><p>Excessive replicas amplify writes, increase shard overhead, fragment system memory and cache behavior, and destabilize large, multi-index workloads.</p><p>If your cluster exhibits unexplained latency, GC pressure, or instability, start by auditing replica settings. In Elasticsearch performance engineering, more is not always better—often, less is faster and more reliable.</p><h2>Further reading</h2><ul><li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.html">Elasticsearch: Tune for Search Speed</a></li><li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/scalability.html">Elasticsearch: Sizing and Scaling</a></li><li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-shard-allocation.html">Elasticsearch: Shard and Replica Allocation</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build a financial AI search workflow using LangGraph.js and Elasticsearch]]></title>
            <description><![CDATA[Learn how to use LangGraph.js with Elasticsearch to build an AI-powered financial search workflow that turns natural language queries into dynamic, conditional filters for investment and market analysis.]]></description>
            <link>https://www.elastic.co/search-labs/blog/ai-agent-workflow-finance-langgraph-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-agent-workflow-finance-langgraph-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
            <pubDate>Fri, 05 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/12b6057d84c6356267cd178a3c6c1a5c61123ece-2000x1256.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Building AI search applications often involves coordinating multiple tasks, data retrieval, and data extraction into a seamless workflow. LangGraph simplifies this process by letting developers orchestrate AI agents using a node-based structure. In this article, we are going to build a financial solution using <a href="https://langchain-ai.github.io/langgraphjs/">LangGraph.js</a></p><h2>What is LangGraph</h2><p><a href="https://langchain-ai.github.io/langgraphjs/">LangGraph</a> is a framework for building AI agents and orchestrating them in a workflow to create AI-assisted applications. LangGraph has a node architecture where we can declare functions that represent tasks and assign them as nodes of the workflow. The result of multiple nodes interacting together will be a graph. LangGraph is part of the broader <a href="https://js.langchain.com/docs/introduction/">LangChain</a> ecosystem, which provides tools for building modular and composable AI systems.</p><p>For a better understanding of why LangGraph is useful, let&#x27;s solve a problematic situation using it.</p><h2>Overview of the solution</h2><p>In a venture capital firm, investors have access to a large database with many filtering options, but when one wants to combine criteria, it becomes hard and slow. This may cause some relevant startups not to be found for investment. It results in spending a lot of hours trying to identify the best candidates, or even losing opportunities.</p><p>With LangGraph and Elasticsearch, we can perform filtered searches using natural language, eliminating the need for users to manually build complex requests with dozens of filters. To make it more flexible, the workflow automatically decides based on the user&#x27;s input between two query types:</p><ul><li><strong>Investment-focused queries</strong>: These target financial and funding aspects of startups, such as <a href="https://www.investopedia.com/articles/personal-finance/102015/series-b-c-funding-what-it-all-means-and-how-it-works.asp">funding rounds</a>, valuation, or <a href="https://www.investopedia.com/terms/r/revenue.asp">revenue</a>. <em>Example:</em> “Find startups with Series A or Series B funding between $8M–$25M and monthly revenue above $500K.”</li><li><strong>Market-focused queries</strong>: These concentrate on <a href="https://en.wikipedia.org/wiki/Vertical_market">industry verticals</a>, <a href="https://en.wikipedia.org/wiki/Target_market">geographic markets</a>, or <a href="https://www.investopedia.com/terms/b/businessmodel.asp">business models</a>, helping identify opportunities in specific sectors or regions. <em>Example:</em> “Find fintech and healthcare startups in San Francisco, New York, or Boston.”</li></ul><p>To keep the queries robust, we will make the LLM build <a href="https://www.elastic.co/docs/solutions/search/search-templates">search templates</a> instead of full <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/querydsl">DSL queries</a>. This way, you always get the query you want, and the LLM just has to fill in the blanks and not carry the responsibility of building the query you need every time.</p><h2>What you need to get started</h2><ul><li>Elasticsearch APIKey</li><li>OpenAPI APIKey</li><li>Node 18 or newer</li></ul><h2>Step-by-step instructions</h2><p>In this section, let’s see how the app will look. For that, we will use <a href="https://www.typescriptlang.org/">TypeScript</a>, a superset of JavaScript that adds static types to make the code more reliable, easier to maintain, and safer by catching errors early while remaining fully compatible with existing JavaScript.</p><p>The nodes&#x27; flow will look as follows:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b47d7f238c4964a6febc0de7fe5e68b186f539c3-363x555.png" style="width: 100%;" /><p>The image above is generated by LangGraph and represents the workflow that defines the execution order and conditional logic between nodes:</p><ul><li><strong>decideStrategy: </strong>Uses an LLM to analyze the user’s query and decide between two specialized search strategies, investment-focused or market-focused.</li><li><strong>prepareInvestmentSearch: </strong>Extracts filter values from the query and builds a predefined template emphasizing financial and funding-related parameters.</li><li><strong>prepareMarketSearch</strong>: Extracts filter values as well, but dynamically builds parameters emphasizing market, industry, and geographic context.</li><li><strong>executeSearch: </strong>Sends the constructed query to Elasticsearch using a search template and retrieves the matching startup documents.</li><li><strong>visualizeResults: </strong>Formats the final results into a clear, readable summary showing key startup attributes such as funding, industry, and revenue.</li></ul><p>This flow includes a <a href="https://langchain-ai.github.io/langgraphjs/how-tos/branching/?h=conditional#how-to-create-branches-for-parallel-node-execution">conditional branching</a>, working as an “if” statement, that determines whether to use the investment or market search path based on the user’s input. This decision logic, driven by the LLM, makes the workflow adaptive and context-aware, a mechanism we’ll explore in more detail in the next sections.</p><h3>LangGraph State</h3><p>Before seeing each node individually, we need to understand how the nodes communicate and share data. For that, LangGraph enables us to define the workflow state. This defines the shared state that will be passed between nodes.</p><p>The state acts as a shared container that stores intermediate data throughout the workflow: it begins with the user’s natural language query, then keeps the selected search strategy, the prepared parameters for Elasticsearch, the retrieved search results, and finally the formatted output.</p><p>This structure allows every node to read and update the state, ensuring a consistent flow of information from the user input to the final visualization.</p><pre><code>const VCState = Annotation.Root({
  input: Annotation<string>(), // User's natural language query
  searchStrategy: Annotation<string>(), // Search strategy chosen by LLM
  searchParams: Annotation<any>(), // Prepared search parameters
  results: Annotation<any[]>(), // Search results
  final: Annotation<string>(), // Final formatted response
});</code></pre><h3>Set up the application</h3><p>All the code on this section can be found in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch">elasticsearch-labs repository</a>.</p><p>Open a terminal in the folder where the app will be located and Initialize a Node.js application with the command:</p><pre><code>npm init -y</code></pre><p>Now we can install the necessary dependencies for this project:</p><pre><code>npm install @elastic/elasticsearch @langchain/langgraph @langchain/openai @langchain/core dotenv zod && npm install --save-dev @types/node tsx typescript</code></pre><ul><li><strong><code>@elastic/elasticsearch</code></strong>: Helps us handle Elasticsearch requests such as data ingestion and retrieval.</li><li><strong><code>@langchain/langgraph</code></strong>: JS dependency to provide all LangGraph tools.</li><li><strong><code>@langchain/openai</code></strong>: OpenAI LLM client for LangChain.</li><li>@langchain/core: Provides the fundamental building blocks for LangChain apps, including prompt templates.</li><li><strong><code>dotenv</code></strong>: Necessary dependency to use environment variables in JavaScript.</li><li><strong><code>zod</code></strong>: Dependency to type data.</li></ul><p><code>@types/node</code> <code>tsx</code> <code>typescript</code> allows us to write and run TypeScript code.</p><p>Now create the following files:</p><ul><li><code>elasticsearchSetup<a href="http://ingest.ts/">.ts</a></code>: Will create the index mappings, load the data set from a JSON file, and ingest the data to Elasticsearch.</li><li><a href="http://main.ts/"><code>main.ts</code></a>: will include the LangGraph application.</li><li><code>.env</code>: file to store the environment variables</li></ul><p>In the <code>.env</code> file, let’s add the following environment variables:</p><pre><code>ELASTICSEARCH_ENDPOINT="your-endpoint-here"
ELASTICSEARCH_API_KEY="your-key-here"
OPENAI_API_KEY="your-key-here"</code></pre><p>The OpenAPI APIKey will not be used directly on the code; instead, it will be used internally by the library <code>@langchain/openai</code>.</p><p>All the logic regarding mappings creation, search templates creation, and dataset ingestion can be found in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts"><code>elasticsearchSetup.ts</code></a> file. In the next steps, we will be focusing on the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/main.ts"><code>main.ts</code></a> file. Also, you can check the dataset to better understand how the data looks in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/dataset.json"><code>dataset.json</code></a>.</p><h3>LangGraph app</h3><p>In the <code>main.ts</code> file, let’s import some necessary dependencies to consolidate the LangGraph application. In this file, you must also include the node functions and the state declaration. The graph declaration will be done in a <code>main</code> method in the next steps. The <code>elasticsearchSetup.ts</code> file will contain Elasticsearch helpers we are going to use within the nodes in further steps.</p><pre><code>import { writeFileSync } from "node:fs";
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
import {
  esClient,
  ingestDocuments,
  createSearchTemplates,
  INDEX_NAME,
  INVESTMENT_FOCUSED_TEMPLATE,
  MARKET_FOCUSED_TEMPLATE,
  createIndex,
} from "./elasticsearchSetup.js";

const llm = new ChatOpenAI({ model: "gpt-4o-mini" });</code></pre><p>As mentioned before, the LLM client will be used to generate the Elasticsearch search template parameters based on the user&#x27;s question.</p><pre><code>async function saveGraphImage(app: any): Promise<void> {
  try {
    const drawableGraph = app.getGraph();
    const image = await drawableGraph.drawMermaidPng();
    const arrayBuffer = await image.arrayBuffer();

    const filePath = "./workflow_graph.png";
    writeFileSync(filePath, new Uint8Array(arrayBuffer));
    console.log(`📊 Workflow graph saved as: ${filePath}`);
  } catch (error: any) {
    console.log("⚠️  Could not save graph image:", error.message);
  }
}</code></pre><p>The method above generates the graph image in png format and uses the <a href="https://mermaid.ink/">Mermaid.INK API</a> behind the scenes. This is useful if you want to see how the app nodes interact together with a styled visualization.</p><h3>LangGraph nodes</h3><p>Now lets see each node detailed:</p><h3>decideSearchStrategy node</h3><p>The <code>decideSearchStrategy</code> node analyzes the user input and determines whether to perform an investment focused or market focused search. It uses an LLM with a structured output schema (defined with Zod) to classify the query type. Before making the decision, it retrieves the available filters from the index using an aggregation, ensuring the model has up to date context about industries, locations, and funding data.</p><p>To extract the filters possible values and send them to the LLM, let’s use an <a href="https://www.elastic.co/docs/explore-analyze/query-filter/aggregations">aggregation</a> query to retrieve them directly from the Elasticsearch index. This logic is allocated in a method called <code>getAvailableFilters</code>:</p><pre><code>async function getAvailableFilters() {
  try {
    const response = await esClient.search({
      index: INDEX_NAME,
      size: 0,
      aggs: {
        industries: {
          terms: { field: "industry", size: 100 },
        },
        locations: {
          terms: { field: "location", size: 100 },
        },
        funding_stages: {
          terms: { field: "funding_stage", size: 20 },
        },
        business_models: {
          terms: { field: "business_model", size: 10 },
        },
        lead_investors: {
          terms: { field: "lead_investor", size: 100 },
        },
        funding_amount_stats: {
          stats: { field: "funding_amount" },
        },
      },
    });

    return response.aggregations;
  } catch (error) {
    console.error("❌ Error getting available filters:", error);
    return {};
  }
}</code></pre><p>With the aggregation query above, we have the following results:</p><pre><code>{
  "industries": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "logistics",
        "doc_count": 5
      },
      ...
    ]
  },
  "locations": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "San Francisco, CA",
        "doc_count": 4
      },
      {
        "key": "New York, NY",
        "doc_count": 3
      },
      ...
    ]
  },
  "funding_stages": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "Series A",
        "doc_count": 8
      },
      ...
    ]
  },
  "business_models": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "B2B",
        "doc_count": 13
      },
      ...
    ]
  },
  "lead_investors": {
    "doc_count_error_upper_bound": 0,
    "sum_other_doc_count": 0,
    "buckets": [
      {
        "key": "Battery Ventures",
        "doc_count": 1
      },
      {
        "key": "Benchmark Capital",
        "doc_count": 1
      },
      ...
    ]
  },
  "funding_amount_stats": {
    "count": 20,
    "min": 4500000,
    "max": 35000000,
    "avg": 14075000,
    "sum": 281500000
  }
}</code></pre><p>See all the results <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/responses/aggregationsResponse.json">here</a>.</p><p>For both strategies, we are going to use hybrid search to detect both the structured part of the question (filters) and the more subjective parts (semantics). Here is an example of both queries using <a href="https://www.elastic.co/docs/solutions/search/search-templates">search templates</a>:</p><pre><code>await esClient.putScript({
      id: INVESTMENT_FOCUSED_TEMPLATE,
      script: {
        lang: "mustache",
        source: `{
          "size": 5,
          "retriever": {
            "rrf": {
              "retrievers": [
                {
                  "standard": {
                    "query": {
                      "semantic": {
                        "field": "semantic_field",
                        "query": "{{query_text}}"
                      }
                    }
                  }
                },
                {
                  "standard": {
                    "query": {
                      "bool": {
                        "filter": [
                          {"terms": {"funding_stage": {{#join}}{{#toJson}}funding_stage{{/toJson}}{{/join}}}},
                          {"range": {"funding_amount": {"gte": {{funding_amount_gte}}{{#funding_amount_lte}},"lte": {{funding_amount_lte}}{{/funding_amount_lte}}}}},
                          {"terms": {"lead_investor": {{#join}}{{#toJson}}lead_investor{{/toJson}}{{/join}}}},
                          {"range": {"monthly_revenue": {"gte": {{monthly_revenue_gte}}{{#monthly_revenue_lte}},"lte": {{monthly_revenue_lte}}{{/monthly_revenue_lte}}}}}
                        ]
                      }
                    }
                  }
                }
              ],
              "rank_window_size": 100,
              "rank_constant": 20
            }
          }
        }`,
      },
    });</code></pre><p>Look at queries detailed in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts#L119"><code>elasticsearchSetup.ts</code></a> file. In the following node, it will be decided which of the two queries will be used:</p><pre><code>// Node 1: Decide search strategy using LLM
async function decideSearchStrategy(state: typeof VCState.State) {
  // Zod schema for specialized search strategy decision
  const SearchDecisionSchema = z.object({
    search_type: z
      .enum(["investment_focused", "market_focused"])
      .describe("Type of specialized search strategy to use"),
    reasoning: z
      .string()
      .describe("Brief explanation of why this search strategy was chosen"),
  });

  const decisionLLM = llm.withStructuredOutput(SearchDecisionSchema);

  // Get dynamic filters from Elasticsearch
  const availableFilters = await getAvailableFilters();

  const prompt = `Query: "${state.input}"
    Available filters: ${JSON.stringify(availableFilters, null, 2)}

    Choose between two specialized search strategies:
    
    - investment_focused: For queries about funding stages, funding amounts, monthly revenue, lead investors, financial performance
    
    - market_focused: For queries about industries, locations, business models, market segments, geographic markets
    
    Analyze the query intent and choose the most appropriate strategy.
  `;

  try {
    const result = await decisionLLM.invoke(prompt);
    console.log(
      `🤔 Search strategy: ${result.search_type} - ${result.reasoning}`
    );

    return {
      searchStrategy: result.search_type,
    };
  } catch (error: any) {
    console.error("❌ Error in decideSearchStrategy:", error.message);
    return {
      searchStrategy: "investment_focused",
    };
  }
}</code></pre><h3>prepareInvestmentSearch and prepareMarketSearch nodes</h3><p>Both nodes use a shared helper function, <code>extractFilterValues</code>, which leverages the LLM to identify relevant filters mentioned in the user’s input, such as industry, location, funding stage, business model, etc. We are using this schema to build our <a href="https://www.elastic.co/docs/solutions/search/search-templates">search template</a>.</p><pre><code>// Extract all possible filter values from user input
async function extractFilterValues(input: string) {
  const FilterValuesSchema = z.object({
    // Investment-focused filters
    funding_stage: z
      .array(z.string())
      .default([])
      .describe("Funding stage values mentioned in query"),
    funding_amount_gte: z
      .number()
      .default(0)
      .describe("Minimum funding amount in USD"),
    funding_amount_lte: z
      .number()
      .default(100000000)
      .describe("Maximum funding amount in USD"),
    lead_investor: z
      .array(z.string())
      .default([])
      .describe("Lead investor values mentioned in query"),
    monthly_revenue_gte: z
      .number()
      .default(0)
      .describe("Minimum monthly revenue in USD"),
    monthly_revenue_lte: z
      .number()
      .default(10000000)
      .describe("Maximum monthly revenue in USD"),
    industry: z
      .array(z.string())
      .default([])
      .describe("Industry values mentioned in query"),
    location: z
      .array(z.string())
      .default([])
      .describe("Location values mentioned in query"),
    business_model: z
      .array(z.string())
      .default([])
      .describe("Business model values mentioned in query"),
  });

  const extractorLLM = llm.withStructuredOutput(FilterValuesSchema);
  const availableFilters = await getAvailableFilters();

  const extractPrompt = `Extract ALL relevant filter values from: "${input}"
    Available options: ${JSON.stringify(availableFilters, null, 2)}
    Extract only values explicitly mentioned in the query. Leave fields empty if not mentioned.`;

  return await extractorLLM.invoke(extractPrompt);
}</code></pre><p>Depending on the detected intent, the workflow selects one of two paths:</p><p><strong>prepareInvestmentSearch:</strong> builds financially oriented search parameters, including funding stage, funding amount, investor, and renew information. You can find the entire query template in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts"><code>elasticsearchSetup.ts</code></a> file:</p><pre><code>// Node 2A: Prepare Investment-Focused Search Parameters 
async function prepareInvestmentSearch(state: typeof VCState.State) {
  console.log(
    "💰 Preparing INVESTMENT-FOCUSED search parameters with financial emphasis..."
  );

  try {
    // Extract all filter values from input
    const values = await extractFilterValues(state.input);

    let searchParams: any = {
      template_id: INVESTMENT_FOCUSED_TEMPLATE,
      query_text: state.input,
      ...values,
    };

    return { searchParams };
  } catch (error) {
    console.error("❌ Error preparing investment-focused params:", error);
    return {
      searchParams: {},
    };
  }
}</code></pre><p><strong>prepareMarketSearch:</strong> creates market-driven parameters focused on industries, geographies, and business models. See the entire query in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/langgraph-js-elasticsearch/elasticsearchSetup.ts"><code>elasticsearchSetup.ts</code></a> file:</p><pre><code>// Node 2B: Prepare Market-Focused Search Parameters
async function prepareMarketSearch(state: typeof VCState.State) {
  console.log(
    "🔍 Preparing MARKET-FOCUSED search parameters with market emphasis..."
  );

  try {
    // Extract all filter values from input
    const values = await extractFilterValues(state.input);

    let searchParams: any = {
      template_id: MARKET_FOCUSED_TEMPLATE,
      query_text: state.input,
      ...values,
    };

    return { searchParams };
  } catch (error) {
    console.error("❌ Error preparing market-focused params:", error);
    return {};
  }
}</code></pre><h3>executeSearch node</h3><p>This node takes the generated search parameters from the state and sends them to Elasticsearch first, using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-render-search-template">_render API</a> to visualize the query for debugging purposes, and then sends a request to retrieve the results.</p><pre><code>// Node 3: Execute Search
async function executeSearch(state: typeof VCState.State) {
  const { searchParams } = state;

  try {
    // getting formed query from template for debugging
    const renderedTemplate = await esClient.renderSearchTemplate({
      id: searchParams.template_id,
      params: searchParams,
    });

    console.log(
      "📋 Complete query:",
      JSON.stringify(renderedTemplate.template_output, null, 2)
    );

    const results = await esClient.searchTemplate({
      index: INDEX_NAME,
      id: searchParams.template_id,
      params: searchParams,
    });

    return {
      results: results.hits.hits.map((hit: any) => hit._source),
    };
  } catch (error: any) {
    console.error(`❌ ${state.searchParams.search_type} search error:`, error);
    return { results: [] };
  }
}</code></pre><h3>visualizeResults node</h3><p>Finally, this node displays the Elasticsearch results.</p><pre><code>// Node 4: Visualize results
async function visualizeResults(state: typeof VCState.State) {
  const results = state.results || [];

  let formattedResults = `🎯 Found ${results.length} startups matching your criteria:\n\n`;

  results.forEach((startup: any, index: number) => {
    formattedResults += `${index + 1}. **${startup.company_name}**\n`;
    formattedResults += `   📍 ${startup.location} | 🏢 ${startup.industry} | 💼 ${startup.business_model}\n`;
    formattedResults += `   💰 ${startup.funding_stage} - $${(
      startup.funding_amount / 1000000
    ).toFixed(1)}M\n`;
    formattedResults += `   👥 ${startup.employee_count} employees | 📈 $${(
      startup.monthly_revenue / 1000
    ).toFixed(0)}K MRR\n`;
    formattedResults += `   🏦 Lead: ${startup.lead_investor}\n`;
    formattedResults += `   📝 ${startup.description}\n\n`;
  });

  return {
    final: formattedResults,
  };
}</code></pre><p>Programmatically, the entire graph looks like this:</p><pre><code>  const workflow = new StateGraph(VCState)
    // Register nodes - these are the processing functions
    .addNode("decideStrategy", decideSearchStrategy)
    .addNode("prepareInvestment", prepareInvestmentSearch)
    .addNode("prepareMarket", prepareMarketSearch)
    .addNode("executeSearch", executeSearch)
    .addNode("visualizeResults", visualizeResults)
    // Define execution flow with conditional branching
    .addEdge(START, "decideStrategy") // Start with strategy decision
    .addConditionalEdges(
      "decideStrategy",
      (state: typeof VCState.State) => state.searchStrategy, // Conditional function
      {
        investment_focused: "prepareInvestment", // If investment focused -> RRF template preparation
        market_focused: "prepareMarket", // If market focused -> dynamic query preparation
      }
    )
    .addEdge("prepareInvestment", "executeSearch") // Investment prep -> execute
    .addEdge("prepareMarket", "executeSearch") // Market prep -> execute
    .addEdge("executeSearch", "visualizeResults") // Execute -> visualize
    .addEdge("visualizeResults", END); // End workflow</code></pre><p>As you can see, we have a conditional edge where the app decides which “path” or node will run next. This feature is useful when workflows need branching logic, such as choosing between multiple tools or including a human-in-the-loop step.</p><p>With the core LangGraph features understood, we can set up the application where the code will be running:</p><p>Put everything together in a <code>main</code> method, here we declare the graph with all the elements under the variable workflow:</p><pre><code>async function main() {
  await createIndex();
  await createSearchTemplates();
  await ingestDocuments();

  // Create the workflow graph with shared state
  const workflow = new StateGraph(VCState)
    // Register nodes - these are the processing functions
    .addNode("decideStrategy", decideSearchStrategy)
    .addNode("prepareInvestment", prepareInvestmentSearch)
    .addNode("prepareMarket", prepareMarketSearch)
    .addNode("executeSearch", executeSearch)
    .addNode("visualizeResults", visualizeResults)
    // Define execution flow with conditional branching
    .addEdge(START, "decideStrategy") // Start with strategy decision
    .addConditionalEdges(
      "decideStrategy",
      (state: typeof VCState.State) => state.searchStrategy, // Conditional function
      {
        investment_focused: "prepareInvestment", // If investment focused -> RRF template preparation
        market_focused: "prepareMarket", // If market focused -> dynamic query preparation
      }
    )
    .addEdge("prepareInvestment", "executeSearch") // Investment prep -> execute
    .addEdge("prepareMarket", "executeSearch") // Market prep -> execute
    .addEdge("executeSearch", "visualizeResults") // Execute -> visualize
    .addEdge("visualizeResults", END); // End workflow


  const app = workflow.compile();

  await saveGraphImage(app);

  const query =
    "Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K";

  const marketResult = await app.invoke({ input: query });
  console.log(marketResult.final);
}</code></pre><p>The query variable simulates the user input entered in a hypothetical search bar:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e8d76909eb2bc1bb62f3ca9a8b3e4b85fcec2893-1600x164.png" style="width: 100%;" /><p>From the natural language phrase “Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K”, all the filters will be extracted.</p><p>Finally, invoke the main method:</p><pre><code>main().catch(console.error);</code></pre><h3>Results</h3><pre><code>🔍 Checking if index exists...
🏗️ Creating index...
✅ Index created successfully!
Ingesting documents...
✅ Documents ingested successfully!
✅ Investment-focused template created successfully!
✅ Market-focused template created successfully!

📊 Workflow graph saved as: ./workflow_graph.png

🔍 Query: "Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K"

🤔 Search strategy: investment_focused - The query specifically seeks profitable fintech startups with defined funding amounts and high monthly revenue, which aligns closely with financial performance metrics and investment-related criteria.

💰 Preparing INVESTMENT-FOCUSED search parameters with financial emphasis...

📋 Complete query: {
  "size": 5,
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "semantic_field",
                "query": "Find startups with Series A or Series B funding between $8M-$25M and monthly revenue above $500K"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "bool": {
                "filter": [
                  {
                    "terms": {
                      "funding_stage": [
                        "Series A",
                        "Series B"
                      ]
                    }
                  },
                  {
                    "range": {
                      "funding_amount": {
                        "gte": 8000000,
                        "lte": 25000000
                      }
                    }
                  },
                  {
                    "terms": {
                      "lead_investor": []
                    }
                  },
                  {
                    "range": {
                      "monthly_revenue": {
                        "gte": 500000,
                        "lte": 0
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ],
      "rank_window_size": 100,
      "rank_constant": 20
    }
  }
}
🎯 Found 5 startups matching your criteria:

1. **TechFlow**
   📍 San Francisco, CA | 🏢 logistics | 💼 B2B
   💰 Series A - $8.0M
   👥 45 employees | 📈 $500K MRR
   🏦 Lead: Sequoia Capital
   📝 TechFlow optimizes supply chain operations using AI-powered route optimization and real-time tracking. Founded in 2023, shows remarkable growth with $500K monthly revenue.

2. **DataViz**
   📍 New York, NY | 🏢 enterprise software | 💼 B2B
   💰 Series A - $10.0M
   👥 42 employees | 📈 $450K MRR
   🏦 Lead: Battery Ventures
   📝 DataViz creates intuitive data visualization tools for enterprise customers. No-code platform allows business users to create dashboards without technical expertise.

3. **FinanceAI**
   📍 San Francisco, CA | 🏢 fintech | 💼 B2C
   💰 Series C - $25.0M
   👥 120 employees | 📈 $1200K MRR
   🏦 Lead: Tiger Global Management
   📝 FinanceAI provides AI-powered investment advisory services to retail investors. Uses machine learning to analyze market trends with over 100,000 active users.

4. **UrbanMobility**
   📍 New York, NY | 🏢 logistics | 💼 B2B2C
   💰 Series B - $15.0M
   👥 78 employees | 📈 $750K MRR
   🏦 Lead: Kleiner Perkins
   📝 UrbanMobility revolutionizes urban transportation through autonomous delivery drones and smart logistics hubs. Partners with major retailers for same-day delivery across Manhattan and Brooklyn.

5. **HealthTech Solutions**
   📍 Boston, MA | 🏢 healthcare | 💼 B2B
   💰 Series B - $18.0M
   👥 95 employees | 📈 $900K MRR
   🏦 Lead: General Catalyst
   📝 HealthTech Solutions develops medical devices and software for remote patient monitoring. Comprehensive telehealth platform reducing hospital readmissions by 30%.

✨  Done in 18.80s.</code></pre><p>For the input sent, the application chooses the <strong>investment-focused</strong> path, and as a result, we can see the Elasticsearch query generated by the LangGraph workflow, which extracts the values and ranges from the user input. We can also see the query sent to Elasticsearch with the extracted values applied, and finally, the results formatted by the <code>visualizeResults</code> node with the results.</p><p>Now let&#x27;s test the <strong>market-focused</strong> node using the query “Find fintech and healthcare startups in San Francisco, New York, or Boston”:</p><pre><code>...

🔍 Query: Find fintech and healthcare startups in San Francisco, New York, or Boston

🤔 Search strategy: market_focused - The query is focused on finding fintech startups in San Francisco that are disrupting traditional banking and payment systems, which pertains to specific industries (fintech) and locations (San Francisco). Thus, a market-focused strategy is more appropriate.

🔍 Preparing MARKET-FOCUSED search parameters with market emphasis...

📋 Complete query: {
  "size": 5,
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "semantic_field",
                "query": "Find fintech and healthcare startups in San Francisco, New York, or Boston"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "bool": {
                "filter": [
                  {
                    "terms": {
                      "industry": [
                        "fintech",
                        "healthcare"
                      ]
                    }
                  },
                  {
                    "terms": {
                      "location": [
                        "San Francisco, CA",
                        "New York, NY",
                        "Boston, MA"
                      ]
                    }
                  },
                  {
                    "terms": {
                      "business_model": []
                    }
                  }
                ]
              }
            }
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 10
    }
  }
}
🎯 Found 5 startups matching your criteria:

1. **FinanceAI**
   📍 San Francisco, CA | 🏢 fintech | 💼 B2C
   💰 Series C - $25.0M
   👥 120 employees | 📈 $1200K MRR
   🏦 Lead: Tiger Global Management
   📝 FinanceAI provides AI-powered investment advisory services to retail investors. Uses machine learning to analyze market trends with over 100,000 active users.

2. **CryptoWallet**
   📍 Miami, FL | 🏢 fintech | 💼 B2C
   💰 Series B - $16.0M
   👥 73 employees | 📈 $820K MRR
   🏦 Lead: Coinbase Ventures
   📝 CryptoWallet provides secure digital wallet solutions for cryptocurrency trading and storage. Multi-chain support with enterprise-grade security features.

...

✨  Done in 7.41s.</code></pre><h2>Learnings</h2><p>During the writing process I learned:</p><ul><li>We must show the LLM the exact values of filters, otherwise we rely on the user typing the exact values of things. For low cardinality this approach is fine, but when the cardinality is high we need some mechanism to filter results out</li><li>Using search templates makes the results much more consistent than letting the LLMwrite the Elasticsearch query, and it&#x27;s also faster</li><li>Conditional edges are a powerful mechanism to build applications with multiple variants and branching paths.</li><li>Structured output is extremely useful when generating information with LLMs because it enforces predictable, type-safe responses. This improves reliability and reduces prompt misinterpretations.</li></ul><p>Combining semantic and structured search through hybrid retrieval produces better and more relevant results, balancing precision and context understanding.</p><h2>Conclusion</h2><p>In this example, we combine LangGraph.js with Elasticsearch to create a dynamic workflow capable of interpreting natural language queries and deciding between financial or market-focused search strategies. This approach reduces the complexity of crafting manual queries while improving flexibility and accuracy for venture capital analysts.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Improving Kibana dashboard interactivity with variable controls]]></title>
            <description><![CDATA[Discover how to use variable controls in Kibana 8.18+ to filter individual visualizations, adjust time intervals, and group by different fields in Kibana dashboards.]]></description>
            <link>https://www.elastic.co/search-labs/blog/kibana-dashboard-interactivity-variable-controls-overview</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dashboard-interactivity-variable-controls-overview</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Teresa Alvarez Soler]]></dc:creator>
            <pubDate>Thu, 04 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/59aa3adffc8c759e42b961ef7d63719ce232893a-1348x830.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We’re excited to share that <strong>variable controls are now available in Kibana dashboards</strong> starting from version 8.18 and all the 9.x series! This feature has been one of the most consistently requested additions from dashboard users—and it’s finally here 🎉 Over the past few months, we’ve continued expanding and refining <a href="https://www.elastic.co/docs/explore-analyze/dashboards/add-controls#add-variable-control">variable controls</a>, making it the perfect moment to give them their own dedicated blog post.</p><h2>What are variable controls?</h2><p>If you’ve worked with Kibana dashboards before, you probably know our classic dashboard controls—those handy dropdowns that show values from your data so you can filter things with a couple of clicks.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b82c1b25a0b38661e5ce4552f763be487d5074aa-1600x701.png" style="width: 100%;" /><p>Variable controls look similar on the surface, but they come with a clever twist: instead of automatically filtering every panel on your dashboard, they can be plugged directly into <a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL queries inside individual visualizations</a>.</p><p>That means <em>you</em> get to decide where each control applies. Even better, you can use them for all sorts of creative tricks—like tweaking time intervals, switching breakdown fields, or changing visualization parameters on the fly. Basically, they give your dashboards a truly interactive experience, allowing you to get your insights faster and more easily.</p><h2>Use cases for variable controls</h2><p>Alright, variable controls sound useful —but what can you actually do with them? Here are some examples of how they level up your dashboards:</p><h3>Filter selected visualizations</h3><p>Want to filter <em>some</em> visualizations, but leave others untouched? Variable controls let you do exactly that. Pick the panels you want to respond to and wire them up in the ES|QL queries behind your visualizations.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/efa367363830b03bc67028aceafe78c4b44e578f-1440x562.gif" style="width: 100%;" /><h3>Select different time intervals</h3><p>Give your users the power to switch between “5 minutes”, “1 hour”, “1 day”, or whatever time buckets make sense. Build a variable control with predefined intervals and connect it to your time series query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/62aa9f4e728036f8c70213b76b1cf131f36f5b4d-1440x606.gif" style="width: 100%;" /><h3>Change functions</h3><p>Instead of creating multiple charts for each operation, let dashboard users choose if they want to see max, avg, different percentiles, or any other aggregator.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f6a2b4c73dc35efe462c2924a153d7b3fa3a7922-1436x606.gif" style="width: 100%;" /><h3>Group by different fields</h3><p>Sometimes you need to break down the data by different dimensions during an investigation. With variable controls, you can define multiple “group by” fields and let dashboard users pick the one that helps them uncover their insights.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fe8745a6fddccadba0666686b8ebc67fdaf64158-1438x606.gif" style="width: 100%;" /><h2>How can you create them?</h2><p>The easiest (and probably most delightful) way to create a variable control is directly from the <strong>ES|QL query editor</strong> in your visualization. Just start typing your query, use the autocomplete menu, and Kibana will helpfully scaffold the control for you.</p><p>But if you prefer to start from the variable itself, you can also go to: <strong>Add panel → Controls → Variable control</strong> and add the variable to your visualizations after creating the control.</p><h3>Example 1: Filtering control with multi-value selection</h3><p>1. Pick a visualization that is powered by a ES|QL query and click “Create control” within the WHERE clause</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/46cb6f2a6775aee152d42eb5ee85170f1bdf26cb-1600x668.png" style="width: 100%;" /><p>2. You will automatically get redirected to the variable creation flyout, where the “Values from a query” type will be selected for you, and the name of the variable is already pre-populated. Remember that the name of a control always needs to start with “?...” to work in the visualization query.</p><p>You will usually need a query like this one to get the values from a field and update them according to the time range selected in the dashboard:</p><pre><code>FROM <datasource_name>
| WHERE @timestamp <=?_tend and @timestamp >?_tstart
| STATS BY <field_name></code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a2a72d4e3159923c6207908da9b4172e27cd5f81-1600x716.png" style="width: 100%;" /><p>3. When saving the control, you will see it appearing at the top of the dashboard, and your visualization query will be updated with the variable control name.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5fc434c8951889e9769652b675191711d126a685-1600x653.png" style="width: 100%;" /><p>4. If you want to add <a href="https://www.elastic.co/docs/explore-analyze/dashboards/add-controls#esql-multi-values-controls">multi-value selection</a> to the control, you need to use the <code>MV_CONTAINS</code> function in the query and select “Allow multiple selections” during the control creation in step 2 (available from 9.3).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/1f237cea0a37cb25a7917a2a683707a269adae8e-1600x670.png" style="width: 100%;" /><h3>Example 2: Time interval control</h3><p>If you’re building a time series, you can easily add a variable control for your date histogram interval:</p><p>1. When writing an ES|QL query for your time series, click “Create control”. When building a variable for intervals, it’s better to use <code>TBUCKET</code> instead of <code>BUCKET</code> so it accepts more readable intervals like “1 hour”, “1 day”, etc. There will also be an auto option for <code>TBUCKET</code> soon so it can automatically adapt to time ranges.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b0ad53d790ff9bdd42db5e77477318319f423534-1600x664.png" style="width: 100%;" /><p>2. Define the intervals to populate the options in the dropdown menu.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f3bd83f530cfa4698c1a3b1ae60d08d0414043b5-1600x757.png" style="width: 100%;" /><p>3. Select different intervals in the dropdown menu and see how your visualization changes.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0f928d9c70929f64926e065059188d140cd48943-1600x671.png" style="width: 100%;" /><h3>Example 3: variables for functions</h3><ol><li>Build a variable using the “Static values” type of control and add function names to your dropdown values. It’s important to use a name for your variable that starts with “??...” to replace functions.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/531444237b7e152d3c8a6f3ca7e464f954f9e856-1600x663.png" style="width: 100%;" /><p>2. Include the variable name in your ES|QL query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9858442abb26d8d266d464852871b139fde63b89-1600x665.png" style="width: 100%;" /><h3>Example 4: variables for fields</h3><ol><li>You can use the “Static values” type of control and write down the names of the fields you want. It’s important to use a variable name that starts with “??...” to make it work for fields.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/33534c3df2fae024b25c28b4aed5d742e54202a2-1600x710.png" style="width: 100%;" /><p>2. Reference the variable where you want in the visualization query.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/71cdf3e9df72c59d957628a3aa6e4aa9bd60d6d5-1600x676.png" style="width: 100%;" /><h2>Variable controls in Discover</h2><p>Variable controls aren’t just a dashboard feature—they’re also available directly in the ES|QL editor in Discover. You can build controls for a faster data exploration experience in Discover, bring them to the dashboard and vice versa.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/fdddeec902d0bc746caed9276d01d7d48793dd85-1600x709.png" style="width: 100%;" /><h2>Technical details</h2><p>By now, you’ve probably noticed that variable controls come with a few rules—like which parts of a query they can reference and the naming prefixes you need to use (“?...” for values and “??...” for fields or functions). That’s because variables aren’t just simple string replacements happening on the client. They’re actually first-class citizens in the query language itself (known as <a href="https://www.elastic.co/docs/solutions/search/agent-builder/tools/esql-tools#parameter-types">parameters in ES|QL</a>).</p><p></p><p>This design brings some big advantages. For one, Kibana can understand the context of each variable, which allows us to automatically generate and pre-fill its configuration for you. It’s also much more secure: because the language strictly validates variable inputs, it prevents malicious injections and gracefully errors out if something looks off. Plus, it improves performance and stability by shifting complex validation and error handling to the server instead of the client. A note on performance, a best practice is to build variables that include fast queries since they load before the dashboard so, slow queries can affect the whole dashboard performance.</p><p>Of course, this architecture also comes with a few <a href="https://www.elastic.co/docs/solutions/search/agent-builder/limitations-known-issues#esql-limitations">limitations</a>—for now. Variables don’t yet support an “Any” option for filtering, and they can’t currently be used with certain operators like <code>LIKE</code><strong> </strong>or <code>FROM</code> (for switching data sources). The good news? We’re actively working on adding these capabilities.</p><h2>What the future holds for controls</h2><p>We’re not stopping here! Some of the improvements on our radar include:</p><p>✨ The ability to place controls anywhere on the dashboard</p><p>✨ Chaining your controls—meaning the output of one control becomes the input for the next</p><p>✨ Better selection options like “Any” selection for variables</p><p>✨ New control types (search-type control and variables for your datasources)</p><p>✨ And more quality-of-life improvements you’ve been asking for like pre-filtering normal controls</p><p>If you have ideas or feedback, we’d love to hear from you.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Up to 12x Faster Vector Indexing in Elasticsearch with NVIDIA cuVS: GPU-acceleration Chapter 2]]></title>
            <description><![CDATA[Discover how Elasticsearch achieves nearly 12x higher indexing throughput with GPU-accelerated vector indexing and NVIDIA cuVS.]]></description>
            <link>https://www.elastic.co/search-labs/blog/elasticsearch-gpu-accelerated-vector-indexing-nvidia</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-gpu-accelerated-vector-indexing-nvidia</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Chris Hegarty, Hemant Malik, Corey Nolet, Manas Singh, Mithun Radhakrishnan, Mayya Sharipova, Lorenzo Dematte, Ben Frederickson]]></dc:creator>
            <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/08f7469a4daaf67b7c5999585aae179b6680c78d-896x746.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Earlier this year, Elastic announced the <a href="https://ir.elastic.co/news/news-details/2025/Elastic-Brings-Enterprise-Data-to-NVIDIA-AI-Factories/default.aspx">collaboration</a> with NVIDIA to bring GPU acceleration to Elasticsearch, integrating with <a href="https://developer.nvidia.com/cuvs">NVIDIA cuVS</a>—as detailed in a <a href="https://www.nvidia.com/en-us/on-demand/session/gtc25-S71286/">session at NVIDIA GTC</a> and various <a href="https://www.elastic.co/search-labs/blog/gpu-accelerated-vector-search-elasticsearch-nvidia">blogs</a>. This post is an update on the co-engineering effort with the NVIDIA vector search team.</p><h2>Recap</h2><p>First, let’s bring you up to speed. Elasticsearch has established itself as a powerful vector database, offering a rich set of features and strong performance for large-scale similarity search. With capabilities such as scalar quantization, Better Binary Quantization (<a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch">BBQ</a>), <a href="https://www.elastic.co/blog/accelerating-vector-search-simd-instructions">SIMD</a> vector operations, and more disk-efficient algorithms like <a href="https://www.elastic.co/search-labs/blog/diskbbq-elasticsearch-introduction">DiskBBQ</a>, it already provides efficient and flexible options for managing vector workloads.</p><p>By integrating NVIDIA cuVS as a callable module for vector search tasks, we aim to deliver significant gains in vector indexing performance and efficiency to better support large-scale vector workloads.</p><h2>The challenge</h2><p>One of the toughest challenges in building a high-performance vector database is constructing the vector index - the <a href="https://arxiv.org/abs/1603.09320">HNSW</a> graph. Index building quickly becomes dominated by millions or even billions of arithmetic operations as every vector is compared against many others. In addition, index lifecycle operations, such as compaction and merges, can further increase the overall compute overhead of indexing. As data volumes and associated vector embeddings grow exponentially, accelerated computing GPUs, built for massive parallelism and high-throughput math, are ideally positioned to handle these workloads.</p><h2>Enter the Elasticsearch-GPU Plugin</h2><p><a href="https://developer.nvidia.com/cuvs">NVIDIA cuVS</a> is an open-source CUDA-X library for GPU-accelerated vector search and data clustering that enables fast index building and embedding retrieval for AI and recommendation workloads.</p><p>Elasticsearch uses cuVS through <a href="https://mvnrepository.com/artifact/com.nvidia.cuvs/cuvs-java">cuvs-java</a>, an open-source library developed by the community and maintained by NVIDIA. The cuvs-java library is lightweight and builds on the <a href="https://docs.rapids.ai/api/cuvs/nightly/c_api/">cuVS C API</a> using <a href="https://openjdk.org/projects/panama/">Panama</a> Foreign Function to expose cuVS features in an idiomatic Java way, while remaining modern and performant.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5f6daa1eb07f704a6707d9e6b7ccb81d0abaa8c9-566x419.png" style="width: 100%;" /><p>The cuvs-java library is integrated into a <a href="https://github.com/elastic/elasticsearch/pull/135545">new Elasticsearch plugin</a>; therefore, vector indexing on the GPU can occur on the same Elasticsearch node and process, without the need to provision any external code or hardware. During index building, if the cuVS library is installed and a GPU is present and configured, Elasticsearch will use the GPU to accelerate the vector indexing process. The vectors are given to the GPU, which constructs a <a href="https://arxiv.org/abs/2308.15136">CAGRA</a> graph. This graph is then converted to the HNSW format, making it immediately available for vector search on the CPU. The final format of the built graph is the same as what would be built on the CPU; this allows Elasticsearch to leverage GPUs for high-throughput vector indexing when the underlying hardware supports it, while freeing CPU power for other tasks (concurrent search, data processing, etc.).</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3ea255bd9bfd7983f78143c5eba999d2149d72be-671x356.png" style="width: 100%;" /><h2>Index build acceleration</h2><p>As part of integrating GPU acceleration into Elasticsearch, several enhancements were made to cuvs-java, focusing on efficient data input/output and function invocation. A key enhancement is the use of <a href="https://github.com/rapidsai/cuvs/blob/2cf5fa7666d703dccbe655f8214656b0952bb69b/java/cuvs-java/src/main/java/com/nvidia/cuvs/CuVSMatrix.java">cuVSMatrix</a> to transparently model vectors, whether they reside on the Java heap, off-heap, or in GPU memory. This enables data to move efficiently between memory and the GPU, avoiding unnecessary copies of potentially billions of vectors.</p><p>Thanks to this underlying zero-copy abstraction, both transferring to GPU memory and retrieving the graph can occur directly. During indexing, vectors are first buffered in memory on the Java heap, then sent to the GPU to construct the CAGRA graph. The graph is subsequently retrieved from the GPU, converted into HNSW format, and persisted to disk.</p><p>At merge time, the vectors are already stored on disk, bypassing the Java heap entirely. Index files are memory-mapped, and data is transferred directly into GPU memory. The design also easily accommodates different bit-widths, such as float32 or int8, and naturally extends to other quantization schemes.</p><h2>Drumroll…so, how does it perform?</h2><p>Before we get into the numbers, a bit of context is helpful. Segment merging in Elasticsearch typically runs automatically in the background during indexing, which makes it difficult to benchmark in isolation. To obtain reproducible results, we used force-merge to explicitly trigger segment merging in a controlled experiment. Since force-merge performs the same underlying merge operations as background merging, its performance serves as a useful indicator of expected improvements, even though the exact gains may differ in real-world indexing workloads.</p><p>Now, let’s see the numbers.</p><p>Our initial benchmark results are very promising. We ran the benchmark on an AWS <code>g6.4xlarge</code> instance with locally attached NVMe storage. A single node of Elasticsearch was configured to use the default, optimal number of indexing threads (8 - one for each physical core), and to disable <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/merge">merge throttling</a> (which is less applicable with fast NVMe disks).</p><p>For the dataset, we used 2.6 million vectors with 1,536 dimensions from the <a href="https://github.com/elastic/rally-tracks/blob/master/openai_vector/README.md">OpenAI Rally vector track</a>, encoded as <a href="https://github.com/elastic/elasticsearch/pull/137072">base64 strings</a>, and indexed as float32 <em>hnsw</em>. In all scenarios, the constructed graphs achieve recall levels of up to 95%. Here’s what we found:</p><ul><li><strong>Indexing Throughput:</strong> By moving graph construction to the GPU during in-memory buffer flushes, we increase throughput by ~12x.</li><li><strong>Force-merge:</strong> After indexing completes, the GPU continues to accelerate segment merging, speeding up the force-merge phase by ~7x.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f60ea9ee5996e456f393ffd195ee7eada6e5a7c2-948x387.png" style="width: 100%;" /><ul><li><strong>CPU usage:</strong> Offloading graph construction to the GPU significantly reduces both average and peak CPU utilization. The graphs below illustrate CPU usage during indexing and merging, highlighting how much lower it is when these operations run on the GPU. Lower CPU utilization during GPU indexing frees up CPU cycles that can be redirected to improve search performance.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e680a5fc41700a877f3d8b2e5ce18ebd3f37a0b-1600x562.png" style="width: 100%;" /><ul><li><strong>Recall:</strong> Accuracy remains effectively the same between CPU and GPU runs, with the GPU-built graph reaching marginally higher recall.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/48a2b7758606bd321712b7d8378cd2640e652a4e-1384x544.png" style="width: 100%;" /><h2>Comparing along another dimension: Price</h2><p>The earlier comparison intentionally used identical hardware, with the only difference being whether the GPU was used during indexing. That setup is useful for isolating raw compute effects, but we can also look at the comparison from a cost perspective.</p><p>At roughly the same hourly price as the GPU-accelerated configuration, one can provision a CPU-only setup with approximately twice the comparable CPU and memory resources: 32 vCPUs (AMD EPYC) and 64 GB of RAM, allowing to double the number of indexing threads to 16.</p><p>To keep the comparison fair and consistent, we ran this CPU-only experiment on an AWS g6.8xlarge instance, with the GPU explicitly disabled. This allowed us to hold all other hardware characteristics constant while evaluating the cost–performance trade-off of GPU acceleration versus CPU-only indexing.</p><p>The more powerful CPU instance does show improved performance compared to the benchmarks in the above section, as you would expect. However, when we compare this more powerful CPU instance against the original GPU-accelerated results, the GPU still delivers substantial performance gains: <strong>~5x</strong> improvement in indexing throughput, and <strong>~6x </strong>in force merge, all while building graphs that achieve recall levels of up to <strong>95%.</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8ffa58cae3ad175ef2932a351aeef4c34a1407b9-948x394.png" style="width: 100%;" /><h2>Conclusion</h2><p>In end-to-end scenarios, GPU acceleration with NVIDIA cuVS delivers nearly a 12x improvement in indexing throughput and a 7x decrease in force-merge latency, with significantly lower CPU utilization. This shows that vector indexing and merge workloads benefit significantly from GPU acceleration. On a cost-adjusted comparison, GPU acceleration continues to yield substantial performance gains, with approximately 5x higher indexing throughput and 6x faster force-merge operations.</p><p>GPU-accelerated vector indexing is currently planned for Tech Preview in Elasticsearch 9.3, which is scheduled to be released early in 2026.</p><p>Stay tuned for more.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ES|QL in 9.2: Smart Lookup Joins and time-series support]]></title>
            <description><![CDATA[Explore three separate updates to ES|QL in Elasticsearch 9.2: an enhanced LOOKUP JOIN for more expressive data correlation, the new TS command for time-series analysis, and the flexible INLINE STATS command for aggregation.]]></description>
            <link>https://www.elastic.co/search-labs/blog/esql-elasticsearch-9-2-multi-field-joins-ts-command</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-elasticsearch-9-2-multi-field-joins-ts-command</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tyler Perkins, Kostas Krikellas, Julian Kiryakov]]></dc:creator>
            <pubDate>Tue, 02 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/415894e21e7758c907d6e60d4efc94230349beef-2012x1164.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Elasticsearch 9.2, released in October, is packed with significant advancements that make analyzing your data faster, more flexible, and more accessible than ever before. At the heart of this release are important enhancements to ES|QL, our piped query language, designed to bring even more value directly to end users.</p><p>Here&#x27;s a look at the features in Elasticsearch 9.2 that will transform your data analysis workflows with ES|QL.</p><h2>Revolutionizing data correlation: A smarter, faster, and more flexible Lookup Join</h2><p>The <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join">LOOKUP JOIN</a> command in ES|QL has undergone a significant transformation in Elasticsearch 9.2, becoming dramatically more efficient and versatile. LOOKUP JOIN combines data from your ES|QL query results table with matching records from a specified lookup mode index. It adds fields from the lookup index as new columns to your results table based on matching values in the join field. Previously, joining data was limited to a single field and simple equality. Not anymore! These enhancements empower you to tackle complex data correlation scenarios with ease.</p><p><strong>Key enhancements to Lookup Join include:</strong></p><ul><li><strong>Multi-Field Joins:</strong> Easily join on multiple fields. For example, to join <code>application_logs</code> with <code>service_registry</code> on <code>service_name</code>, <code>environment</code> and <code>version:</code></li></ul><pre><code>FROM application_logs
| LOOKUP JOIN service_registry ON service_name, environment, version</code></pre><ul><li><strong>Unleashing complex join predicates with expressions (tech preview):</strong></li></ul><p>No longer are you confined to simple equality. LOOKUP JOIN now allows you to specify <strong>multiple criteria</strong> for correlation and incorporate a range of <strong>binary operators,</strong> including ==, !=, &lt;, &gt;, &lt;=, and &gt;=. This means you can create highly nuanced join conditions, enabling you to ask much more sophisticated questions of your data.</p><p>Example 1: Finding application metrics with per-service SLA threshold</p><pre><code>FROM application_metrics
| LOOKUP JOIN sla_thresholds
      ON service_name == sla_service AND response_time > sla_response_time</code></pre><p>Example 2: This query calculates the amount due, based on regional pricing policies that change over time. It joins three datasets based on complex date-range and equality conditions to calculate a final <code>due_amount</code>. The second lookup join uses the <code>measurement_date</code> field from the <code>meter_readings</code> index and <code>region_id</code> field from the <code>customers</code> index to join to the <code>pricing_policies</code> index and find the correct pricing policy for the particular <code>region</code> and <code>measurement_date</code>.</p><pre><code>FROM meter_readings
| LOOKUP JOIN customers
      ON meter_id
| LOOKUP JOIN pricing_policies
      ON
        region_id == region AND
          measurement_date >= policy_begin_date AND
          measurement_date < policy_end_date
| EVAL due_amount = (kwh_consumed * rate_per_kwh + base_charge) * (1 + tax_rate)
| EVAL period = policy_name
| KEEP customer_name, period, due_amount, measurement_date, kwh_consumed,
    rate_per_kwh, base_charge, tax_rate
| SORT measurement_date</code></pre><ul><li><strong>Massive performance gains for filtered joins: </strong></li></ul><p>We&#x27;ve improved performance for &quot;expanding joins&quot; that are filtered using lookup table conditions. Expanding joins produce multiple matches per input row, which can create large intermediate result sets. This gets worse when many of those rows are discarded by a subsequent filter. In 9.2, we optimize these joins by filtering out unnecessary rows when a filter is applied to lookup data, avoiding processing rows that would be discarded. In some scenarios, these joins can be up to <strong>1000 times faster</strong>!</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/002c014ee29b1aaf9ddeb8c554bb76efe3ed180c-1572x954.png" style="width: 100%;" /><p>This optimization is crucial when dealing with &quot;expanding joins,&quot; where a lookup might initially generate many potential matches. By intelligently pushing down filters, only the relevant data is processed, drastically reducing query execution time and enabling real-time analysis on massive datasets. This means you get your insights much faster, even with very large or complex join operations.</p><p><strong>Lookup Join Cross-Cluster Search (CCS) compatibility:</strong></p><p>When Lookup Join went GA in 8.19 and 9.1, it lacked Cross-Cluster Search (CCS) support. For organizations operating across multiple clusters, LOOKUP JOIN now seamlessly integrates with CCS in 9.2. Simply place your lookup index on all the remote clusters where you want to perform a join, and ES|QL will automatically leverage these remote lookup indices to join with your remote data. This simplifies distributed data analysis and ensures consistent enrichment across your entire Elasticsearch deployment.</p><p>These improvements mean you can correlate diverse datasets with unprecedented precision, speed, and ease, uncovering deeper, more actionable insights without complex workarounds or pre-processing steps.</p><h2>Enrich your data with ease: Kibana Discover UX for Lookup Indices</h2><p>Data enrichment should be simple, not a hurdle. We&#x27;ve introduced a fantastic new user experience in Kibana&#x27;s Discover for creating and managing lookup indices.</p><p><strong>Intuitive Workflow:</strong> Discover’s comprehensive autocomplete will guide you through the process, suggesting lookup indices and join fields in the ES|QL editor, making it incredibly easy to connect your uploaded data with existing indices. Type the name of a lookup index that doesn’t exist, and get direct access to the Lookup editor with one click to create the index. Type the name of an existing lookup index, and we’ll suggest an option to edit it:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d36fd6ffd6bc0bf8d31067f6266445c68d15c71c-1400x184.png" style="width: 100%;" /><p><strong>In-line management (CRUD):</strong> Keep your reference datasets current with in-line editing capabilities (Create, Read, Update, Delete) directly in Discover.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a0e4aa66540b1f725c24ccb0519d978415073bb6-1453x842.png" style="width: 100%;" /><p><strong>Effortless file upload: </strong>You can now directly upload files, such as CSVs, within Discover and instantly use them in your <code>LOOKUP JOIN</code>&#x27;s. No more context switching from jumping around to different areas of Kibana!</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/0f5dc2c712af4c4cada50292a7c8b836eb02aa67-1600x748.png" style="width: 100%;" /><p>Whether you&#x27;re mapping user IDs to names, adding business metadata, or joining static reference files, this feature democratizes data enrichment, putting the power of joins directly into the hands of every user – fast, simple, and all in one place.</p><h2>Preserve your context: Introducing INLINE STATS (tech preview)</h2><p>Aggregating data is crucial, but sometimes you need to see the aggregates <em>alongside</em> your original data. We&#x27;re excited to introduce <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/inlinestats-by">INLINE STATS</a> as a <strong>Tech Preview</strong> feature.</p><p>Unlike the <code>STATS</code> command, which replaces your input fields with aggregated output, <code>INLINE STATS</code> preserves all your original input fields and simply adds the new aggregated fields. This empowers you to perform further operations on your original input fields <em>after</em> aggregation, providing a more continuous and flexible analysis workflow.</p><p>For example, to calculate the average flight distance while keeping individual flight rows:</p><pre><code>FROM kibana_sample_data_flights
 | KEEP Carrier, Dest, DistanceMiles
 | INLINE STATS avgDist = ROUND(AVG(DistanceMiles))
       BY Dest
 | WHERE DistanceMiles > avgDist</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6eea943035e0ab371270084c504a06bb89f8b82b-1496x290.png" style="width: 100%;" /><p>In this query, <code>avgDist</code> is added to each row with the corresponding <code>Dest</code>(ination) that we grouped by, and then, because we still have the flight info columns, we’re able to filter the results to the flights with a distance greater than the average.</p><h2>Time-series support in ES|QL (tech preview)</h2><p>Elasticsearch uses <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds">time-series data streams</a> for storing metrics. We’re adding support for time series aggregations in ES|QL, through the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> source command. This is available in Elastic Cloud serverless and 9.2 basic as a tech preview.</p><p>Time series analysis is largely based on aggregation queries that summarize metric values over time buckets, sliced by one or more filtering dimensions. Most aggregation queries rely on two-step processing, with (a) an inner aggregation function summarizing values per time series and (b) an outer aggregation function, combining the results from (a) across time series.</p><p>The <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> source command, combined with <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS</code></a>, provides a concise yet effective way for expressing such queries over time series. More concretely, consider the following example for calculating the total rate of requests per host and hour:</p><pre><code>TS my_metrics
| WHERE @timestamp > NOW() - 1 day
| STATS SUM(RATE(requests))
      BY host, TBUCKET(1h)</code></pre><p>In this case, the time series aggregation function <code>RATE</code> is first evaluated per time series and hour. The produced partial aggregates are then combined using <code>SUM</code> to calculate the final aggregate values per host and hour.</p><p>You can check out the list of available time series aggregation functions <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions">here</a>. <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/time-series-data-stream-tsds#time-series-metric">counter</a> rate is now supported, arguably the most important aggregation function for processing counters.</p><p>The <code>TS</code> source command is designed to be combined with <code>STATS</code>, with execution tuned to efficiently support time series aggregations. For example, the data is sorted before going into the <code>STATS</code>. Processing commands that may enrich or alter the time series data or its order, such as <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> or <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/inlinestats-by"><code>INLINE STATS</code></a>, are currently not allowed between <code>TS</code> and <code>STATS</code>. This limitation might be lifted in the future.</p><p>The <code>STATS</code> tabular output can be further processed with any applicable command. For example, the following query calculates the ratio of the average <code>cpu_usage</code> per host and hour to the maximum value per host:</p><pre><code>TS my_metrics
| STATS avg_usage = AVG(AVG_OVER_TIME(cpu_usage))
      BY host, time_bucket = TBUCKET(1h)
| INLINE STATS max_avg_usage = MAX(avg_usage)
      BY host
| EVAL ratio = avg_usage / max_avg_usage
| KEEP host, time_bucket, ratio
| SORT host, time_bucket DESC</code></pre><p>Time series data is stored on our underlying columnar storage engine that is powered by Lucene doc values. The TS command adds vectorized query execution through the ES|QL compute engine. Query performance is often improved by more than an order of magnitude, compared to equivalent <a href="https://www.elastic.co/docs/reference/query-languages/querydsl">DSL</a> queries, and is on par with established, metrics-specific systems. We’ll be providing a detailed architectural and performance analysis in the future, so stay tuned.</p><h2>Expanding your toolkit: New ES|QL functions</h2><p>To further enhance ES|QL&#x27;s utility and versatility, we&#x27;ve added a suite of new <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-functions-operators">functions</a>:</p><p><strong>String manipulation: </strong><a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions#esql-contains">CONTAINS</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/mv-functions#esql-mv_contains">MV_CONTAINS</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions#esql-url_encode">URL_ENCODE</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions#esql-url_encode_component">URL_ENCODE_COMPONENT</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/string-functions#esql-url_decode">URL_DECODE</a> for more robust text and URL processing.</p><p><strong>Time series &amp; geospatial:</strong> <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/grouping-functions#esql-tbucket">TBUCKET</a> for flexible time bucketing, TO_DENSE_VECTOR for vector operations, and a comprehensive set of <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/spatial-functions">geospatial functions</a> like <code>ST_GEOHASH</code>, <code>ST_GEOTILE</code>, <code>ST_GEOHEX</code>, <code>TO_GEOHASH</code>, <code>TO_GEOTILE</code>, <code>TO_GEOHEX</code> for advanced location-based analysis.</p><p><strong>Date formatting:</strong> <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/date-time-functions#esql-day_name">DAY_NAME</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/date-time-functions#esql-month_name">MONTH_NAME</a> for more readable date representations.</p><p>These functions provide you with a richer set of tools to manipulate and analyze your data directly within ES|QL.</p><h2>Under the hood: More performance and efficiency</h2><p>Beyond the highlighted features, Elasticsearch 9.2 includes numerous performance optimizations across ES|QL. We sped up <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/where#like-and-rlike">RLIKE (LIST</a>) with pushdown in cases where the function replaces multiple similar RLIKE queries. With <code>RLIKE</code> (LIST), we can merge those queries into a single Automaton and apply one automaton instead of multiple. We also have faster loading of keyword fields with index sorts, and general query optimizations - these improvements ensure that your ES|QL queries run more efficiently than ever before.</p><h2>Get started today!</h2><p>Elasticsearch 9.2 represents a significant leap forward for ES|QL, bringing unprecedented power and flexibility to your data analysis workflows. We encourage you to explore these new features and experience the difference they make.</p><p>For a comprehensive list of all changes and enhancements in Elasticsearch 9.2, please consult the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/9.2/release-notes-9.2.0.html">official release notes</a>. Happy querying!</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a ChatGPT connector with Elasticsearch to query GitHub issues]]></title>
            <description><![CDATA[Learn how to build a custom ChatGPT connector and deploy an Elasticsearch MCP server that uses hybrid search to query internal GitHub issues.]]></description>
            <link>https://www.elastic.co/search-labs/blog/chatgpt-connector-mcp-server-github-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-connector-mcp-server-github-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Mon, 01 Dec 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/77a6fc1eb263a0eb16aac64f2ecaca5f4ac12ec2-966x568.gif?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Recently, OpenAI announced the <a href="https://help.openai.com/en/articles/11487775-connectors-in-chatgpt">custom connectors</a> feature for ChatGPT on Pro/Business/Enterprise and Edu plans. In addition to out-of-the-box connectors to tap data on Gmail, GitHub, Dropbox, etc. It is possible to create custom connectors using MCP servers.</p><p>Custom connectors give you the ability to combine your existing ChatGPT connectors with additional sources of data like Elasticsearch to get comprehensive answers.</p><p>In this article, we’ll build an <a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCP</a> server that connects ChatGPT to an Elasticsearch index containing information on internal GitHub issues and pull requests. This enables natural language queries to be answered using your Elasticsearch data.</p><p>We’ll deploy the MCP server using the <a href="https://gofastmcp.com/getting-started/welcome">FastMCP</a> on Google Colab with ngrok to get a public URL that ChatGPT can connect to, eliminating the need for complex infrastructure setup.</p><p>For a comprehensive overview of MCP and its ecosystem, refer to <a href="https://www.elastic.co/search-labs/blog/mcp-current-state">The Current State of MCP</a>.</p><h2>Prerequisites</h2><p>Before starting, you will need:</p><ul><li>Elasticsearch cluster (8.X or higher)</li><li>Elasticsearch API key with read access to your index</li><li>Google account (for Google Colab)</li><li>Ngrok account (free tier works)</li><li>ChatGPT account with Pro/Enterprise/Business or Edu plan</li></ul><h2>Understanding ChatGPT MCP connector requirements</h2><p>ChatGPT MCP connectors require implementing two tools: <code>search</code> and <code>fetch</code>. For more details, refer to <a href="https://platform.openai.com/docs/mcp#create-an-mcp-server">OpenAI Docs</a>.</p><h3><a href="https://platform.openai.com/docs/mcp#search-tool">Search tool</a></h3><p>Returns a list of relevant results from your Elasticsearch index based on a user query.</p><h4>What it receives:</h4><ul><li>A single string with the user’s natural language query.</li><li>Example: “Find issues related to Elasticsearch migration.”</li></ul><h4>What it returns: </h4><ul><li>An object with a <code>result</code> key containing an array of result objects. Each result includes:<ul><li><code>id</code> - Unique Document identifier</li><li><code>title</code> - Issue or PR title</li><li><code>url</code> - Link to the issue/PR</li></ul></li></ul><h4>In our implementation:</h4><pre><code>return {
    "results": [
        {
            "id": "PR-612",
            "title": "Fix memory leak in WebSocket notification service",
            "url": "https://internal-git.techcorp.com/pulls/612"
        },
        # ... more results
    ]
}</code></pre><h3><a href="https://platform.openai.com/docs/mcp#fetch-tool">Fetch tool</a></h3><p>Retrieves the complete content of a specific document.</p><h4>What it receives:</h4><ul><li>A single string with the Elasticsearch document ID from the search result</li><li>Example: “Get me the details of PR-578.”</li></ul><h4>What it returns:</h4><ul><li>A complete document object with:<ul><li><code>id</code> - Unique Document identifier</li><li><code>title</code> - Issue or PR title</li><li><code>text</code> - Complete issue/PR description and details</li><li><code>url</code> - Link to the issue/PR</li><li><code>type</code> - Document type (issue, pull_request)</li><li><code>status</code> - Current status (open, in_progress, resolved)</li><li><code>priority</code> - Priority level (low, medium, high, critical)</li><li><code>assignee</code> - Person assigned to the issue/PR</li><li><code>created_date</code> - When it was created</li><li><code>resolved_date</code> - When it was resolved (if applicable)</li><li><code>labels</code> - Tags associated with the document</li><li><code>related_pr</code> - Related pull request ID</li></ul></li></ul><pre><code>return {
    "id": "PR-578",
    "title": "Security hotfix: Patch SQL injection vulnerabilities",
    "text": "Description: CRITICAL SECURITY FIX for ISSUE-1889. Patches SQL...",
    "url": "https://internal-git.techcorp.com/pulls/578",
    "type": "pull_request",
    "status": "closed",
    "priority": "critical",
    "assignee": "sarah_dev",
    "created_date": "2025-09-19",
    "resolved_date": "2025-09-19",
    "labels": "security, hotfix, sql",
    "related_pr": null
}</code></pre><p><strong>Note</strong>: This example uses a flat structure where all fields are at the root level. OpenAI requirements are flexible and also support nested metadata objects.</p><h2>GitHub issues and PRs dataset</h2><p>For this tutorial, we are going to use an internal GitHub dataset containing issues and pull requests. This represents a scenario where you want to query private, internal data through ChatGPT.</p><p>The dataset can be found <a href="https://gist.github.com/TomasMurua/4e7bbdf7a7ebbdffaa663c43578d934a">here</a>. And we will update the index of the data using the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk">bulk API</a>.</p><p>This dataset includes:</p><ul><li>Issues with descriptions, status, priority, and assignees</li><li>Pull requests with code changes, reviews, and deployment info</li><li>Relationships between issues and PRs (e.g., PR-578 fixes ISSUE-1889)</li><li>Labels, dates, and other metadata</li></ul><h3>Index mappings</h3><p>The index uses the following <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">mappings</a> to support hybrid search with <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-elser">ELSER</a>. The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">text_semantic</a> is used for semantic search, while other fields enable keyword search.</p><pre><code>{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "title": {
        "type": "text"
      },
      "text": {
        "type": "text"
      },
      "text_semantic": {
        "type": "semantic_text",
        "inference_id": ".elser-2-elasticsearch"
      },
      "url": {
        "type": "keyword"
      },
      "type": {
        "type": "keyword"
      },
      "status": {
        "type": "keyword"
      },
      "priority": {
        "type": "keyword"
      },
      "assignee": {
        "type": "keyword"
      },
      "created_date": {
        "type": "date",
        "format": "iso8601"
      },
      "resolved_date": {
        "type": "date",
        "format": "iso8601"
      },
      "labels": {
        "type": "keyword"
      },
      "related_pr": {
        "type": "keyword"
      }
    }
  }
}</code></pre><h2>Build the MCP server</h2><p>Our MCP server implements two tools following OpenAI specifications, using hybrid search to combine semantic and text matching for better results.</p><h3>Search tool</h3><p>Uses hybrid search with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">RRF</a> (Reciprocal Rank Fusion), combining semantic search with text matching:</p><pre><code>@mcp.tool()
    async def search(query: str) -> Dict[str, List[Dict[str, Any]]]:
        """
        Search for internal issues and PRs using hybrid search (semantic + text with RRF).
        Returns list with id, title, and url per OpenAI spec.
        """
        if not query or not query.strip():
            return {"results": []}

        logger.info(f"Searching for: '{query}'")

        try:
            # Hybrid search with RRF (Reciprocal Rank Fusion)
            response = es_client.search(
                index=ELASTICSEARCH_INDEX,
                size=10,
                source=["id", "title", "url", "type", "priority"],
                retriever={
                    "rrf": {
                        "retrievers": [
                            {
                                # Semantic search with ELSER
                                "standard": {
                                    "query": {
                                        "semantic": {
                                            "field": "text_semantic",
                                            "query": query
                                        }
                                    }
                                }
                            },
                            {
                                # Text search (BM25) for keyword matching
                                "standard": {
                                    "query": {
                                        "multi_match": {
                                            "query": query,
                                            "fields": [
                                                "title^3",
                                                "text^2",
                                                "assignee^2",
                                                "type",
                                                "labels",
                                                "priority"
                                            ],
                                            "type": "best_fields",
                                            "fuzziness": "AUTO"
                                        }
                                    }
                                }
                            }
                        ],
                        "rank_window_size": 50,
                        "rank_constant": 60
                    }
                }
            )

            results = []
            if response and 'hits' in response:
                for hit in response['hits']['hits']:
                    source = hit['_source']
                    results.append({
                        "id": source.get('id', hit['_id']),
                        "title": source.get('title', 'Unknown'),
                        "url": source.get('url', '')
                    })

            logger.info(f"Found {len(results)} results")
            return {"results": results}

        except Exception as e:
            logger.error(f"Search error: {e}")
            raise ValueError(f"Search failed: {str(e)}")</code></pre><h3>Key points:</h3><ul><li><strong>Hybrid search with RRF:</strong> Combines semantic search (ELSER) and text search (BM25) for better results.</li><li><strong>Multi-match query:</strong> <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-multi-match-query">Searches across multiple fields</a> with boosting (title^3, text^2, assignee^2). The caret symbol (^) multiplies relevance scores, prioritizing matches in titles over content.</li><li><strong>Fuzzy matching:</strong> <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#fuzziness"><code>fuzziness: AUTO</code></a> handles typos and misspellings by allowing approximate matches.</li><li><strong>RRF parameter tuning:</strong><ul><li><code>rank_window_size: 50</code> - Specifies how many top results from each retriever (semantic and text) are considered before merging.</li><li><code>rank_constant: 60</code> - This value determines how much influence documents in individual result sets have over the final ranked result.</li></ul></li><li><strong>Returns only the required fields:</strong> <code>id</code>, <code>title</code>, <code>url</code> per OpenAI specification, and avoids exposing additional fields unnecessarily.</li></ul><h3>Fetch tool</h3><p>Retrieves document details by document ID, where it exists:</p><pre><code>@mcp.tool()
    async def fetch(id: str) -> Dict[str, Any]:
        """
        Retrieve complete issue/PR details by ID.
        Returns id, title, text, url.
        """
        if not id:
            raise ValueError("ID is required")

        logger.info(f"Fetching: {id}")

        try:
            # Search by the 'id' field (not _id) since IDs are stored as a field
            response = es_client.search(
                index=ELASTICSEARCH_INDEX,
                body={
                    "query": {
                        "term": {
                            "id": id  # Search by your custom 'id' field
                        }
                    },
                    "size": 1
                }
            )

            if not response or not response['hits']['hits']:
                raise ValueError(f"Document with id '{id}' not found")

            hit = response['hits']['hits'][0]
            source = hit['_source']

            result = {
                "id": source.get('id', id),
                "title": source.get('title', 'Unknown'),
                "text": source.get('text', ''),
                "url": source.get('url', ''),
                "type": source.get('type', ''),
                "status": source.get('status', ''),
                "priority": source.get('priority', ''),
                "assignee": source.get('assignee', ''),
                "created_date": source.get('created_date', ''),
                "resolved_date": source.get('resolved_date', ''),
                "labels": source.get('labels', ''),
                "related_pr": source.get('related_pr', '')
            }

            logger.info(f"Fetched: {result['title']}")
            return result

        except Exception as e:
            logger.error(f"Fetch error: {e}")
            raise ValueError(f"Failed to fetch '{id}': {str(e)}")</code></pre><h3>Key points:</h3><ul><li><strong>Search by document ID field:</strong> Uses term query on the custom <code>id</code> field</li><li><strong>Returns complete document:</strong> Includes full <code>text</code> field with all content</li><li><strong>Flat structure:</strong> All fields at the root level, matching Elasticsearch&#x27;s document structure.</li></ul><h2>Deploy on Google Colab</h2><p>We&#x27;ll use Google Colab to run our MCP server and ngrok to expose it publicly so ChatGPT can connect to it.</p><h3>Step 1: Open the Google Colab notebook</h3><p>Access our pre-configured notebook <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/elasticsearch-chatgpt-connector">Elasticsearch MCP for ChatGPT</a>.</p><h3>Step 2: Configure your credentials</h3><p>You’ll need three pieces of information:</p><ul><li><strong>Elasticsearch URL:</strong> Your <a href="https://www.elastic.co/docs/deploy-manage/deploy/cloud-enterprise/connect-elasticsearch">Elasticsearch cluster URL</a>.</li><li><strong>Elasticsearch API Key:</strong> <a href="https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys">API key</a> with read access to your index.</li><li><strong>Ngrok Auth Token:</strong> Free token from <a href="https://ngrok.com/">ngrok</a>. We will use ngrok to expose the MCP URL to the internet so ChatGPT can connect to it.</li></ul><h4>Getting your ngrok token</h4><ol><li>Sign up for a free account at <a href="https://ngrok.com/">ngrok</a></li><li>Go to your <a href="https://dashboard.ngrok.com/">ngrok dashboard</a></li><li>Copy your auth token</li></ol><h4>Adding secrets to Google Colab</h4><p>In the Google Colab notebook:</p><ol><li>Click the <strong>Key icon </strong>in the left sidebar to open <strong>Secrets</strong>.</li><li>Add these three secrets:</li></ol><pre><code>ELASTICSEARCH_URL=https://your-cluster.elastic.com:443
ELASTICSEARCH_API_KEY=your-api-key
NGROK_TOKEN=your-ngrok-token</code></pre><p>3.&nbsp;&nbsp; Enable notebook access for each secret</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/d5dd6ac19fe816a562c6351fdb0f11369da0e877-609x321.jpg" style="width: 100%;" /><h3>Step 3: Run the notebook</h3><ol><li>Click <strong>Runtime</strong> and then <strong>Run all</strong> to execute all cells</li><li>Wait for the server to start (about 30 seconds)</li><li>Look for the output showing your public ngrok URL</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f185100e8869624bc9e1c7b2b4eb32785e2d89e7-1189x283.png" style="width: 100%;" /><p>4.&nbsp; The output will show something like:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e02e625e91ed9136454e4401b184575fb03a336e-1052x465.jpg" style="width: 100%;" /><h2>Connect to ChatGPT</h2><p>Now we’ll connect the MCP server to your ChatGPT account.</p><ol><li>Open ChatGPT and go to <strong>Settings</strong>.</li><li>Navigate to <strong>Connectors. </strong>If you&#x27;re using a Pro account, you need to turn on <a href="https://platform.openai.com/docs/guides/developer-mode">developer mode</a> in connectors.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/32c02192912fc0e7e5a52e9399077ba7ae3b4901-739x715.png" style="width: 100%;" /><p><em>If you&#x27;re using ChatGPT enterprise or business, you need to publish the connector to your workplace.</em></p><p>3.&nbsp; Click on <strong>Create.</strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/15c53e5ccc381108a9dc0052cca05bf0fc97679a-755x683.png" style="width: 100%;" /><p><em><strong>Note</strong>: In Business, Enterprise, and Edu workspaces, only workspace owners, admins, and users with the respective setting enabled (for Enterprise/Edu) can add custom connectors. Users with a regular member role do not have the ability to add custom connectors themselves.</em></p><p><em>Once a connector is added and enabled by an owner or admin user, it becomes available for all members of the workspace to use.</em></p><p>4.&nbsp; Enter the required information and your ngrok URL ending in <code>/sse/</code>. Note the “/” after “sse.” It won’t work without it:</p><ul><li><strong>Name:</strong> Elasticsearch MCP</li><li><strong>Description: </strong>Custom MCP for searching and fetching GitHub internal information.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/162a85705cc8ac48a3f2f665551d513e0719f93d-479x684.png" style="width: 100%;" /><p>5. Press <strong>Create</strong> to save the custom MCP.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/97eb5fb0a32b86bfadfb35561f698616f217c049-913x629.png" style="width: 100%;" /><p>The connection is instant if your server is running. No additional authentication is needed, as the Elasticsearch API key is configured in your server.</p><h2>Test the MCP server</h2><p>Before asking questions, you need to select which connector ChatGPT should use.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/77a6fc1eb263a0eb16aac64f2ecaca5f4ac12ec2-966x568.gif" style="width: 100%;" /><h3>Prompt 1: Search for issues</h3><p>Ask: “<strong>Find issues related to Elasticsearch migration” </strong>and confirm the actions tool calling.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/cfd781acbff8cd7c8095bbe29224f8b26d581f77-650x375.png" style="width: 100%;" /><p>ChatGPT will call the <code>search</code> tool with your query. You can see it’s looking for available tools and preparing to call the Elasticsearch tool and confirms with the user before taking any actions against the tool.</p><h4>Tool call request:</h4><pre><code>{
  "query": "Elasticsearch migration issues"
}</code></pre><h4>Tool response:</h4><pre><code>{
  "results": [
    {
      "id": "PR-598",
      "title": "Elasticsearch 8.x migration - Application code changes",
      "url": "https://internal-git.techcorp.com/pulls/598"
    },
    {
      "id": "ISSUE-1712",
      "title": "Migrate from Elasticsearch 7.x to 8.x",
      "url": "https://internal-git.techcorp.com/issues/1712"
    },
    {
      "id": "RFC-045",
      "title": "Design Proposal: Microservices Migration Architecture",
      "url": "https://internal-git.techcorp.com/rfcs/045"
    }
    // ... 7 more results
  ]
}</code></pre><p>ChatGPT processes the results and presents them in a natural, conversational format.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/9d5b6cff85c7e54ccc2584b8ae96d45495fae8c1-923x1352.png" style="width: 100%;" /><h3>Behind the scenes</h3><h4>Prompt: “Find issues related to Elasticsearch migration”</h4><p>1. ChatGPT calls <code>search(“Elasticsearch migration”)</code></p><p>2. Elasticsearch performs a hybrid search</p><ul><li><strong>Semantic search</strong> understands concepts like “upgrade” and “<em>version compatibility.”</em></li><li><strong>Text search</strong> finds exact matches for “<em>Elasticsearch</em>” and “migration.”</li><li><strong>RRF</strong> combines and ranks results from both approaches</li></ul><p>3. Returns top 10 matching events with <code>id</code>, <code>title</code>, <code>url</code></p><p>4. ChatGPT identifies “<em>ISSUE-1712: migrate from Elasticsearch 7.x to 8.x</em>” as most relevant result</p><h3>Prompt 2: Get complete details</h3><p>Ask: <strong><em>“Get me details of ISSUE-1889”</em></strong></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5c0db5245535ce67a36056e61e135bddc97ce496-934x629.png" style="width: 100%;" /><p>ChatGPT recognizes you want detailed information about a specific issue and calls the <code>fetch</code> tool and confirms with the user before taking any actions against the tool.</p><h4>Tool call request:</h4><pre><code>{
  "id": "ISSUE-1889"
}</code></pre><h4>Tool response:</h4><pre><code>{
  "id": "ISSUE-1889",
  "title": "SQL injection vulnerability in search endpoint",
  "text": "Description: Security audit identified SQL injection vulnerability in /api/v1/search endpoint. User input from query parameter is not properly sanitized before being used in raw SQL query. Severity: HIGH - Immediate action required Affected Code: - File: services/search/query_builder.py - Line: 145-152 - Issue: String concatenation used instead of parameterized queries Investigation: - @security_team_alice: Confirmed exploitable with UNION-based injection - @sarah_dev: Checking all other endpoints for similar patterns - @john_backend: Found 3 more instances in legacy codebase Remediation: - Rewrite using SQLAlchemy ORM or parameterized queries - Add input validation and sanitization - Implement WAF rules as additional layer - Security regression tests Comments: - @tech_lead_mike: Stop all other work, this is P0 - @sarah_dev: PR-578 ready with fixes for all 4 vulnerable endpoints - @alex_devops: Deployed hotfix to production 2025-09-19 at 14:30 UTC - @security_team_alice: Verified fix, conducting full pentest next week Resolution: All vulnerable endpoints patched. Added pre-commit hooks to catch raw SQL queries. Security training scheduled for team.",
  "url": "https://internal-git.techcorp.com/issues/1889",
  "type": "issue",
  "status": "closed",
  "priority": "critical",
  "assignee": "sarah_dev",
  "created_date": "2025-09-18",
  "resolved_date": "2025-09-19",
  "labels": "security, vulnerability, bug, sql",
  "related_pr": "PR-578"
}</code></pre><p>ChatGPT synthesizes the information and presents it clearly.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/410f19f213e94fc4e3c47eeef6e04b69e0c86159-602x462.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/54d8ffa117628a1e3afc317c3ab75d4f7731d7ab-767x1600.png" style="width: 100%;" /><h3>Behind the scenes</h3><h4>Prompt: “Get me the details of ISSUE-1889”</h4><ol><li>ChatGPT calls <code>fetch(“ISSUE-1889”)</code></li><li>Elasticsearch retrieves the complete document</li><li>Returns a full document with all fields at the root level</li><li>ChatGPT synthesizes the information and responds with proper citations.</li></ol><h2>Conclusion</h2><p>In this article, we built a custom MCP server that connects ChatGPT to Elasticsearch using dedicated <strong>search</strong> and <strong>fetch</strong> MCP tools, enabling natural language queries over private data.</p><p>This MCP pattern works for any Elasticsearch index, documentation, products, logs, or any other data you want to query through natural language.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Developing an agentic RAG assistant using LangChain and Elasticsearch]]></title>
            <description><![CDATA[Learn how to build an agentic rag news assistant using LangChain and Elasticsearch that answers queries about articles with adaptive routing.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agentic-rag-news-assistant-langchain-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agentic-rag-news-assistant-langchain-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Kirti Sodhi]]></dc:creator>
            <pubDate>Fri, 28 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/59dc0077f5dab00561d9f1b1e7dbf8ec3456259e-1600x1047.heif?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>This blog post delves into agentic RAG workflows, explaining their key features and common design patterns. It further demonstrates how to implement these workflows through a hands-on example that uses Elasticsearch as the vector store and LangChain to construct the agentic RAG framework. Finally, the article briefly discusses best practices and challenges associated with designing and implementing such architectures. You can follow along to create a simple agentic RAG pipeline with this <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/agentic-rag/agent_rag_news_assistant.ipynb">Jupyter notebook</a>.</p><h2>Introduction to agentic RAG</h2><p>Retrieval Augmented Generation (<a href="https://www.elastic.co/docs/solutions/search/rag">RAG</a>) has become a cornerstone in LLM-based applications, enabling models to provide optimal answers by retrieving relevant context based on user queries. RAG systems enhance the accuracy and context of LLM responses by drawing on external information from APIs or data stores, instead of being limited to pre-trained LLM knowledge. On the other hand, the AI agents operate autonomously, making decisions and taking actions to achieve their designated objectives.</p><p>Agentic RAG is a framework that unifies the strengths of both retrieval-augmented generation and agentic reasoning. It integrates RAG into the agent’s decision-making process, enabling the system to dynamically choose data sources, refine queries for better context retrieval, generate more accurate responses, and apply a feedback loop to continuously improve output quality.</p><h2>Key features of agentic RAG</h2><p>The agentic RAG framework marks a major advancement over traditional RAG systems. Instead of following a fixed retrieval process, it leverages dynamic agents capable of planning, executing, and optimizing results in real time.</p><p>Let’s look at some of the key features that distinguish agentic RAG pipelines:</p><ul><li><strong>Dynamic decision making</strong>: Agentic RAG uses a reasoning mechanism to understand the user’s intent and route each query to the most relevant data source, producing accurate and context-aware responses.</li><li><strong>Comprehensive query analysis:</strong> Agentic RAG deeply analyzes user queries, including sub-questions and their overall intent. It assesses query complexity and dynamically selects the most relevant data sources to retrieve information, ensuring accurate and complete responses.</li><li><strong>Multi-stage collaboration</strong>: This framework enables multi-stage collaboration through a network of specialized agents. Each agent handles a specific part of a larger objective, working sequentially or simultaneously to achieve a cohesive outcome.</li><li><strong>Self-evaluation mechanisms</strong>: The agentic RAG pipeline uses self-reflection to evaluate retrieved documents and generated responses. It can check if the retrieved information fully addresses the query and then review the output for accuracy, completeness, and factual consistency.</li><li><strong>Integration with external tools</strong>: This workflow can interact with external APIs, databases, and real-time information sources, incorporating up-to-date information and adapting dynamically to evolving data.</li></ul><h2>Workflow patterns of agentic RAG</h2><p>The workflow patterns define how agentic AI structures, manages, and orchestrates LLM-based applications in a reliable and efficient manner. Several frameworks and platforms, such as <a href="https://www.langchain.com/">LangChain</a>, <a href="https://www.langchain.com/langgraph">LangGraph</a>, <a href="https://www.crewai.com/">CrewAI</a>, and <a href="https://www.llamaindex.ai/">LlamaIndex</a>, can be used to implement these agentic workflows.</p><ol><li><strong>Sequential retrieval chain</strong>: Sequential workflows divide complex tasks into simple, ordered steps. Each step improves the input for the next one, leading to better results. For example, when creating a customer profile, one agent might pull basic details from a CRM, another retrieves purchase history from a transaction database, and a final agent combines this information to generate a complete profile for recommendations or reports.</li><li><strong>Routing retrieval chain</strong>: In this workflow pattern, a router agent analyzes the input and directs it to the most appropriate process or data source. This approach is particularly effective when multiple distinct data sources exist with minimal overlap. For instance, in a customer service system, the router agent categorizes incoming requests, such as technical issues, refunds, or complaints, and routes them to the appropriate department for efficient handling.</li><li><strong>Parallel retrieval chain</strong>: In this workflow pattern, multiple independent subtasks are executed concurrently, and their outputs are later aggregated to generate a final response. This approach significantly reduces processing time and increases workflow efficiency. For example, in a customer service parallel workflow, one agent retrieves similar past requests, and another consults relevant knowledge base articles. An aggregator then combines these outputs to generate a comprehensive resolution.</li><li><strong>Orchestrator worker chain</strong>: This workflow shares similarities with parallelization due to its utilization of independent subtasks. However, a key distinction lies in the integration of an orchestrator agent. This agent is responsible for analyzing user queries, dynamically segmenting them into subtasks during runtime, and identifying the appropriate processes or tools required to formulate an accurate response.</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ece6fc2403f211556c93e99d5227bfb7053b0c31-1600x1047.png" style="width: 100%;" /><h2>Building an agentic RAG pipeline from scratch</h2><p>To illustrate the principles of agentic RAG, let&#x27;s design a workflow using LangChain and Elasticsearch. This workflow adopts a routing-based architecture, where multiple agents collaborate to analyze queries, retrieve relevant information, evaluate results, and generate coherent responses. You could refer to this <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/agentic-rag/agent_rag_news_assistant.ipynb">Jupyter notebook</a> to follow along with this example.</p><p>The workflow starts with the router agent, which analyzes the user&#x27;s query to select the optimal retrieval method, i.e., either a <code>vectorstore</code>, <code>websearch</code>, or <code>composite</code> approach. The vectorstore handles traditional RAG-based document retrieval, the websearch fetches the most recent information not stored in the vectorstore, and the composite approach combines both when information from multiple sources is needed.</p><p>If the documents are deemed suitable, the summarization agent generates a clear and contextually appropriate response. However, if the documents are insufficient or irrelevant, the query rewriting agent reformulates the query to improve the search. This revised query then reinitiates the routing process, allowing the system to refine its search and enhance the final output.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ed8701a7f15558fbf2e967a884b3e770eccb826b-1256x1092.png" style="width: 100%;" /><h3>Prerequisites</h3><p>This workflow relies on the following core components to execute the example effectively:</p><ul><li>Python 3.10</li><li><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/agentic-rag/agent_rag_news_assistant.ipynb">Jupyter notebook</a></li><li>Azure OpenAI</li><li>Elasticsearch</li><li>LangChain</li></ul><p>Before proceeding, you will be prompted to configure the following set of required environment variables for this example.</p><pre><code>AZURE_OPENAI_ENDPOINT="Add your azure openai endpoint"
AZURE_OPENAI_KEY="Add your azure openai key"
AZURE_OPENAI_DEPLOYMENT="gpt-4.1"
AZURE_OPENAI_API_VERSION="Add your azure openai api version"

ES_ENDPOINT = "Add your Elasticsearch ENDPOINT"
ES_API_KEY = "Add your Elasticsearch API KEY"</code></pre><h3>Data sources</h3><p>This workflow is illustrated using a subset of the AG News dataset. The dataset comprises news articles across diverse categories, such as International, Sports, Business, and Science/Technology.</p><pre><code>dataset = load_dataset("ag_news", split="train[:1000]")
docs = [
    Document(
        page_content=sample["text"],
        metadata={"category": sample["label"]}
    )
    for sample in dataset
]</code></pre><p>The <a href="https://python.langchain.com/docs/integrations/vectorstores/elasticsearch/">ElasticsearchStore module</a> is utilized from the <code>langchain_elasticsearch</code> as our vector store. For retrieval, we implement the SparseVectorStrategy, employing <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-elser">ELSER</a>, Elastic&#x27;s proprietary embedding model. It is essential to confirm that the ELSER model is correctly installed and deployed in your Elasticsearch environment before initiating the vector store.</p><pre><code>elastic_vectorstore = ElasticsearchStore.from_documents(
    docs,
    es_url=ES_ENDPOINT,
    es_api_key=ES_API_KEY,
    index_name=index_name,
    strategy=SparseVectorStrategy(model_id=".elser_model_2"),
)

elastic_vectorstore.client.indices.refresh(index=index_name)</code></pre><p>The web search functionality is implemented using <a href="https://python.langchain.com/api_reference/community/tools/langchain_community.tools.ddg_search.tool.DuckDuckGoSearchRun.html">DuckDuckGoSearchRun</a> from the LangChain community tools, which allows the system to retrieve live information from the web efficiently. You can also consider using other search APIs that may provide more relevant results. This tool was chosen as it allows searches without requiring an API key.</p><pre><code>duckduckgo = DuckDuckGoSearchRun(description= "A custom DuckDuckGo search tool for finding latest news stories.", verbose=True)
def websearch_retriever(query):
    results = duckduckgo.run(f"{query}")
    return results</code></pre><p>Composite retriever is designed for queries that require a combination of sources. It is used to provide a comprehensive and contextually accurate response by simultaneously retrieving real-time data from the web and consulting historical news from the vector store.</p><pre><code>def composite_retriever(query):
    related_docs = vectorstore_retriever(query)
    related_docs += websearch_retriever(query)
    return related_docs</code></pre><h3>Setting up the agents</h3><p>In the next step, the LLM agents are defined to provide reasoning and decision-making capabilities within this workflow. The LLM chains we will create include: <code>router_chain</code>, <code>grade_docs_chain</code>, <code>rewrite_query_chain</code>, and <code>summary_chain</code>.</p><p>The router agent uses an LLM assistant to determine the most appropriate data source for a given query at run-time. The grading agent evaluates the retrieved documents for relevance. If the documents are deemed relevant, they are passed to the summary agent to generate a summary. Otherwise, the rewrite query agent reformulates the query and sends it back to the routing process for another retrieval attempt. You can find the instructions for all the agents under the LLM chains section of the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/agentic-rag/agent_rag_news_assistant.ipynb">notebook</a>.</p><pre><code>class RouteQuery(BaseModel):
    datasource: Literal["vectorstore", "websearch", "composite"] = Field(
        ...,
        description="Choose to route the query to web search, vectorstore or composite."
    )

router_prompt = ChatPromptTemplate.from_template("""You are an assistant that decides the best data source for questions based on news articles.
Choose one of the following options:
- 'vectorstore': for general, background, or historical news articles.
- 'websearch': for recent discoveries, 'latest', 'current', or '2025' type queries.
- 'composite': when the question needs both historical and current knowledge on news articles.

Question: {query}

Return one word: 'vectorstore', 'websearch', or 'composite'.
""")
router_structured = llm.with_structured_output(RouteQuery)
router_chain: RunnableSequence = router_prompt | router_structured</code></pre><p>The <code>llm.with_structured_output</code> constrains the output of the model to follow a predefined schema defined by the BaseModel under the <code>RouteQuery</code> class, ensuring consistency of the results. The second line composes a <code>RunnableSequence</code> by connecting <code>router_prompt</code> with <code>router_structured</code>, forming a pipeline in which the input prompt is processed by the language model to produce structured, schema-compliant results.</p><h3>Define graph nodes</h3><p>This part involves defining the states of the graph, which represent the data that flows between different components of the system. A clear specification of these states ensures that each node in the workflow knows what information it can access and update.</p><pre><code>class RAGState(TypedDict):
    query: str
    docs: List[Document]
    router: str
    summary: str
    self_reflection: bool
    retry_count: int = 0</code></pre><p>Once the states are defined, the next step is to define the nodes of the graph. Nodes are like the functional units of the graph that perform specific operations on the data. There are 7 different nodes in our pipeline.</p><pre><code>def router(state: RAGState):
   router = router_chain.invoke({'query': state["query"]})
   logger.info(f"Router selected the datasource: {router.datasource}")
   logger.info(f"User query: {state['query']}")
   return {"router": router.datasource}

def vectorstore(state: RAGState):
   return {"docs": vectorstore_retriever(state["query"])}

def websearch(state: RAGState):
   return {"docs": websearch_retriever(state["query"])}

def composite(state: RAGState):
   return {"docs": composite_retriever(state["query"])}

def self_reflection(state: RAGState):
   evaluation = grade_docs_chain.invoke(
       {"query": state["query"], "docs": state["docs"]}
   )
   if evaluation.binary_score:
       logger.info(f"Self-reflection passed -- binary_score={evaluation.binary_score}")
   else:
       logger.info(f"Self-reflection failed -- binary_score={evaluation.binary_score}")

   return {
       "self_reflection": evaluation.binary_score,
   }

def query_rewriter(state: RAGState):
   retry_count = state.get("retry_count", 0) + 1
   new_query = rewrite_query_chain.invoke({"query": state["query"]})
   logger.info(f"Query rewritten: {new_query}, retry_count: {retry_count}")
   return {
       "query": new_query,
       "retry_count": retry_count,
   }

def summarize(state: RAGState):
   summary = summarize_chain.run(
       query=state["query"],
       docs=state["docs"],
   )
   return {"summary": summary}</code></pre><p>The <code>query_rewriter</code> node serves two purposes in the workflow. First, it rewrites the user query using the <code>rewrite_query_chain</code> to improve retrieval when the documents evaluated by the self-reflection agent are deemed insufficient or irrelevant. Second, it acts as a counter that tracks how many times the query has been rewritten.</p><p>Each time the node is invoked, it increments the <code>retry_count</code> stored in the workflow state. This mechanism prevents the workflow from entering an infinite loop. If the <code>retry_count</code> exceeds a predefined threshold, the system can fallback to an error state, a default response, or any other predefined condition you choose.</p><h3>Compiling the graph</h3><p>The last step is to define the edges of the graph and add any necessary conditions before compiling it. Every graph must start from a designated starting node, which serves as the entry point for the workflow. Edges in the graph represent the flow of data between nodes and can be of two types:</p><ul><li>Straight edges: These define a direct, unconditional flow from one node to another. Whenever the first node completes its task, the workflow automatically proceeds to the next node along the straight edge.</li><li>Conditional edges: These allow the workflow to branch based on the current state or the results of a node’s computation. The next node is selected dynamically depending on conditions such as evaluation results, routing decisions, or retry counts.</li></ul><pre><code>graph.add_edge(START, "router")

def after_router(state: RAGState):
   route = state.get("router", None)
   if route == "vectorstore":
       return "vectorstore"
   elif route == "websearch":
       return "websearch"
   else:
       return "composite"

def after_self_reflection(state: RAGState):
   if state["self_reflection"]:
           return "summarize"
   return "query_rewriter"

def after_query_rewriter(state: RAGState):
   while state['retry_count'] <= 3:
           return "router"
   raise RuntimeError("Maximum retries (3) reached -- evaluation failed.")

graph.add_conditional_edges(
   "router",
   after_router,
   {
       "vectorstore": "vectorstore",
       "websearch": "websearch",
       "composite": "composite"
   }
)

graph.add_edge("vectorstore", "self_reflection")
graph.add_edge("websearch", "self_reflection")
graph.add_edge("composite", "self_reflection")
graph.add_conditional_edges(
   "self_reflection",
   after_self_reflection,
   {
       "summarize": "summarize",
       "query_rewriter": "query_rewriter"
   }
)
graph.add_conditional_edges("query_rewriter", after_query_rewriter, {"router": "router"})
graph.add_edge("summarize", END)
agent=graph.compile()</code></pre><p>With that, your first agentic RAG pipeline is ready and can be tested using the compiled agent.</p><pre><code>result = agent.invoke({"query": query1})
logger.info(f"\nFinal Summary:\n: {result['summary']}")</code></pre><h3>Testing the agentic RAG pipeline</h3><p>We will now test this pipeline using three distinct types of queries as below. Note that results can differ, and the examples shown below illustrate just one potential outcome.</p><pre><code>query1="What are the latest AI models released this month?"
query2="What technological innovations are discussed in Sci/Tech news?"
query3="Compare a Sci/Tech article from the dataset with a current web article about AI trends."</code></pre><p>For the first query, the router selects <code>websearch</code> as the data source. The query fails the self-reflection evaluation and is subsequently redirected to the query rewriting stage, as shown in the output.</p><pre><code>INFO     | __main__:router:11 - Router selected the datasource: websearch
INFO     | __main__:router:12 - User query: What are the latest AI models released this month?
Latest Singapore news, including the city state's relationships with Malaysia and Mahathir, China and Xi Jinping, and the rest of Southeast Asia. 3 days ago · The latest military news, insights and analysis from China. All the latest news, opinions and analysis on Hong Kong, China, Asia and around the world Latest news, in-depth features and opinion on Malaysia, covering politics, economy, society and the Asean member-nation's relationships with China, Singapore, and other Southeast Asian ... Oct 12, 2025 · Brics (an acronym for Brazil, Russia, India, China and South Africa) refers to an association of 10 leading emerging markets. The other member states are Egypt, Ethiopia, ...
INFO     | __main__:self_reflection:31 - Self-reflection failed -- binary_score=False
INFO     | __main__:query_rewriter:40 - Query rewritten: query='Which AI models have been officially released in June 2024?', retry_count: 1
INFO     | __main__:router:11 - Router selected the datasource: websearch
INFO     | __main__:router:12 - User query: query='Which AI models have been officially released in June 2024?'
Dream Machine is a text-to-video model created by Luma Labs and launched in June 2024 . It generates video output based on user prompts or still images. Dream Machine has been noted for its ability to realistically capture motion... Released in June 2023. In June 2024 , Baidu announced Ernie 4.0 Turbo. In April 2025, Ernie 4.5 Turbo and X1 Turbo were released . These models are optimized for faster response times and lower operational costs.[28][29]. The meaning of QUERY is question, inquiry. How to use query in a sentence. Synonym Discussion of Query. QUERY definition: 1. a question, often expressing doubt about something or looking for an answer from an authority.... Learn more. Query definition: a question; an inquiry.. See examples of QUERY used in a sentence.
INFO     | __main__:self_reflection:29 - Self-reflection passed -- binary_score=True
INFO     | __main__:<module>:2 - 
Final Summary:
: In June 2024, two AI models were officially released: Dream Machine, a text-to-video model launched by Luma Labs, and Ernie 4.0 Turbo, announced by Baidu, which is optimized for faster response times and lower operational costs.</code></pre><p>Next, we examine an example where <code>vectorstore</code> retrieval is used, demonstrated with the second query.</p><pre><code>INFO     | __main__:router:11 - Router selected the datasource: vectorstore
INFO     | __main__:router:12 - User query: What technological innovations are discussed in Sci/Tech news?
INFO     | __main__:self_reflection:29 - Self-reflection passed -- binary_score=True
INFO     | __main__:<module>:2 - 
Final Summary:
: Recent Sci/Tech news highlights several technological innovations: NASA is collaborating with Silicon Valley firms to build a powerful Linux-based supercomputer to support theoretical research and shuttle engineering; new chromatin transfer techniques have enabled the cloning of cats; cybersecurity advancements are being discussed in relation to protecting personal technology; Princeton University scientists assert that existing technologies can be used immediately to stabilize global warming; and a set of GameBoy micro-games has been recognized for innovation in game design.</code></pre><p>The final query is directed to composite retrieval, which utilizes both the vectorstore and web search.</p><pre><code>INFO     | __main__:router:11 - Router selected the datasource: composite
INFO     | __main__:router:12 - User query: Compare a Sci/Tech article from the dataset with a current web article about AI trends.
Atlas currently only available on macOS, built on Chromium with planned features like ad-blocking still in development. OpenAI's Atlas browser launched with bold promises of AI -powered web browsing, but early real-world testing reveals a different story. Career-long data are updated to end-of-2024 and single recent year data pertain to citations received during calendar year 2024. The selection is based on the top 100,000 scientists by c-score (with and without self-citations) or a percentile rank of 2% or above in the sub-field. In this article I list 45 AI tools across 21 different categories. After exploring all the available options in each category, I've carefully selected the best tools based on my personal experience. Reading a complex technical article ? Simply highlight confusing terminology and ask "what's this?" to receive instant explanations. compare browsers. Comparison showing traditional browser navigation versus OpenAI Atlas AI -powered workflows. After putting Gemini, ChatGPT, Grok, and DeepSeek through rigorous testing in October 2025, it's clear that there isn't one AI that reigns supreme across all categories.
INFO     | __main__:self_reflection:29 - Self-reflection passed -- binary_score=True
INFO     | __main__:<module>:2 - 
Final Summary:
: A Sci/Tech article from the dataset highlights NASA's development of robust artificial intelligence software for planetary rovers, aiming to make them more self-reliant and capable of decision-making during missions. In contrast, a current web article about AI trends focuses on the proliferation of AI-powered tools across various categories, including browsers like OpenAI Atlas, and compares leading models such as Gemini, ChatGPT, Grok, and DeepSeek, noting that no single AI currently excels in all areas. While the NASA article emphasizes specialized AI applications for autonomous robotics in space exploration, the current trends article showcases the broadening impact of AI across consumer and professional technologies, with ongoing competition and rapid innovation among major AI platforms.</code></pre><p>In the above workflow, agentic RAG intelligently determines which data source to use when retrieving information for a user query, thereby improving the accuracy and relevance of the response. You can create additional examples to test the agent and review the outputs to see if they yield any interesting results.</p><h2>Best practices for building agentic RAG workflows</h2><p>Now that we understand how agentic RAG works, let’s look at some best practices for building these workflows. Following these guidelines will help keep the system efficient and easy to maintain.</p><ul><li><strong>Prepare for fallbacks</strong>: Plan fallback strategies in advance for scenarios where any step of the workflow fails. These may include returning default answers, triggering error states, or using alternative tools. This ensures that the system handles failures gracefully without breaking the overall workflow.</li><li><strong>Implement comprehensive logging</strong>: Try implementing logging at each stage of the workflow, such as retries, generated outputs, routing choices, and query rewrites. These logs help to improve transparency, make debugging easier, and help refine prompts, agent behavior, and retrieval strategies over time.</li><li><strong>Select the appropriate workflow pattern</strong>: Examine your use case and select the workflow pattern that best suits your needs. Use sequential workflows for step-by-step reasoning, parallel workflows for independent data sources, and orchestrator-worker patterns for multi-tool or complex queries.</li><li><strong>Incorporate evaluation strategies</strong>: Integrate evaluation mechanisms at different stages of the workflow. These can include self-reflection agents, grading retrieved documents, or automated quality checks. Evaluation helps verify that retrieved documents are relevant, responses are accurate, and all parts of a complex query are addressed.</li></ul><h2>Challenges</h2><p>While agentic RAG systems offer significant advantages in terms of adaptability, precision, and dynamic reasoning, they also come with certain challenges that must be addressed during their design and implementation stages. Some of the key challenges include:</p><ul><li><strong>Complex workflows</strong>: As more agents and decision points are added, the overall workflow becomes increasingly complex. This can lead to higher chances of errors or failures at runtime. Whenever possible, prioritize streamlined workflows by eliminating redundant agents and unnecessary decision points.</li><li><strong>Scalability</strong>: It can be challenging to scale agentic RAG systems to handle large datasets and high query volumes. Incorporate efficient indexing, caching, and distributed processing strategies to maintain performance at scale.</li><li><strong>Orchestration and computational overhead</strong>: The execution of workflows with multiple agents requires advanced orchestration. This includes careful scheduling, dependency management, and agent coordination to prevent bottlenecks and conflicts, all of which add to the overall system complexity.</li><li><strong>Evaluation complexity</strong>: The evaluation of these workflows presents inherent challenges, as each stage requires a distinct assessment strategy. For instance, the RAG stage must be evaluated for the relevance and completeness of retrieved documents, while generated summaries need to be checked for quality and accuracy. Likewise, the effectiveness of query reformulation requires a separate evaluation logic to determine whether the rewritten query improves retrieval outcomes.</li></ul><h2>Conclusion</h2><p>In this blog post, we introduced the concept of agentic RAG and highlighted how it enhances the traditional RAG framework by incorporating autonomous capabilities from agentic AI. We explored the core features of agentic RAG and demonstrated these features through a hands-on example, building a news assistant using Elasticsearch as the vector store and LangChain to create the agentic framework.</p><p>Additionally, we discussed best practices and key challenges to consider when designing and implementing an agentic RAG pipeline. These insights are intended to guide developers in creating robust, scalable, and efficient agentic systems that effectively combine retrieval, reasoning, and decision-making.</p><h2>What’s next</h2><p>The workflow we built is simple, leaving ample room for improvements and experimentation. We can enhance this by experimenting with various embedding models and refining retrieval strategies. Additionally, integrating a re-ranking agent to prioritize retrieved documents could be beneficial. Another area for exploration involves developing evaluation strategies for agentic frameworks, specifically identifying common and reusable approaches applicable across different types of frameworks. Finally, experimenting with these frameworks on large and more complex datasets.</p><p>In the meantime, if you have similar experiments to share, we’d love to hear about them! Feel free to provide feedback or connect with us through our <a href="https://ela.st/slack">community Slack channel</a> or <a href="https://discuss.elastic.co/c/security">discussion forums</a>.</p><h2>Resources</h2><ul><li><a href="https://arxiv.org/abs/2310.11511">Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection</a></li><li><a href="https://arxiv.org/abs/2501.09136">Agentic Retrieval-Augmented Generation: A Survey on Agentic RAG</a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hybrid search without the headache: simplifying hybrid search with retrievers]]></title>
            <description><![CDATA[Explore how to simplify hybrid search in Elasticsearch with a multi-field query format for linear and RRF retrievers, and create queries with no previous knowledge about your Elasticsearch index.]]></description>
            <link>https://www.elastic.co/search-labs/blog/hybrid-search-multi-field-query-retrievers-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hybrid-search-multi-field-query-retrievers-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Mike Pellegrini]]></dc:creator>
            <pubDate>Thu, 27 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4494f98ae3958bbdbc6171df9677fc4d65ec5640-1536x1024.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p><a href="https://www.elastic.co/what-is/hybrid-search">Hybrid search</a> is widely recognized as a powerful search approach, combining the precision and speed of <a href="https://www.elastic.co/search-labs/blog/lexical-and-semantic-search-with-elasticsearch#lexical-search---sparse-retrieval">lexical search</a> with the natural language capabilities of <a href="https://www.elastic.co/what-is/semantic-search">semantic search</a>. However, applying it in practice can be tricky, often requiring deep knowledge about your index and the construction of verbose queries with non-trivial configurations. In this blog, we will explore how the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers#multi-field-query-format">multi-field query format for linear and RRF retrievers</a> makes hybrid search simpler and more approachable, removing common headaches and enabling you to leverage its full power with greater ease. We will also review how the multi-field query format enables you to perform hybrid search queries with no previous knowledge about your index.</p><h2>The score range problem</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c8b49153c47d2cdc233c0d2e440db04711d48ca5-1600x1600.jpg" style="width: 100%;" /><p>To set the stage, let’s review one of the primary reasons hybrid search can be hard: varying score ranges. Our old friend <a href="https://www.elastic.co/elasticon/conf/2016/sf/improved-text-scoring-with-bm25">BM25</a> produces unbounded scores. In other words, BM25 can generate scores ranging from close to 0 to (theoretically) infinity. In contrast, queries against <code>dense_vector</code> fields will produce scores bounded between 0 and 1. Exacerbating this problem, <code>semantic_text</code> obfuscates the field type used to index embeddings, so unless you have detailed knowledge about your index and inference endpoint configuration, it can be hard to tell what the score range of your query will be. This presents a problem when trying to interleave lexical and semantic search results, as the lexical results may take precedence over the semantic ones even if the semantic results are more relevant. The generally accepted solution for this problem is to normalize the scores prior to interleaving results. Elasticsearch has two tools for this, the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/linear-retriever">linear</a> and <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever">RRF</a> retrievers.<br/></p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/264481268c8b6ac259e3c257b85431b513f16672-1077x586.png" style="width: 100%;" /><p>The <strong>RRF</strong> retriever applies the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">RRF algorithm</a>, using document rank as a measure of relevance and discarding the score. Since the score is not considered, score range mismatches are not an issue.</p><p>The <strong>linear</strong> retriever uses a linear combination to determine a document’s final score. This involves taking each component query’s score for the document, normalizing it, and summing them to generate the total score. Mathematically, the operation can be expressed as:</p><pre><code>Total Score = 𝚺(N(Sx))</code></pre><p>Where <code>N</code> is the normalization function, and SX is the score for query X. The normalization function is key here, as it transforms each query’s score to use the same range. You can learn more about the linear retriever <a href="https://www.elastic.co/search-labs/blog/linear-retriever-hybrid-search">here</a>.</p><h2>Breaking it down</h2><p>Users can implement effective hybrid search with these tools, but it requires some knowledge about your index. Let’s take a look at an example with the linear retriever, where we will query an index with two fields:</p><pre><code>PUT linear_retriever_example
{
  "mappings": {
    "properties": {
      "semantic_text_field": { <1>
        "type": "semantic_text",
        "inference_id": ".multilingual-e5-small-elasticsearch"
      },
      "text_field": { <2>
        "type": "text"
      }
    }
  }
}</code></pre><p>1. <code>semantic_text_field</code> is a <code>semantic_text</code> field that uses <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-e5">E5</a>, a text embedding model</p><p>2. <code>text_field</code> is a standard <code>text</code> field</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "retrievers": [
        {
          "retriever": {
            "standard": {
              "query": {
                "match": { <1>
                  "semantic_text_field": "foo"
                }
              }
            }
          },
          "normalizer": "minmax"
        },
        {
          "retriever": {
            "standard": {
              "query": {
                "match": {
                  "text_field": "foo"
                }
              }
            }
          },
          "normalizer": "minmax"
        }
      ]
    }
  }
}</code></pre><p>1. We use a <code>match</code> query on our <code>semantic_text</code> field, which we <a href="https://www.elastic.co/search-labs/blog/semantic-search-match-knn-sparse-vector#we-made-match-happen-in-semantic-search!">added support for in Elasticsearch 8.18/9.0</a></p><p><br/>When constructing the query, we need to keep in mind that <code>semantic_text_field</code> uses a text embedding model, so any queries on it will generate a score between 0 and 1. We also need to know that <code>text_field</code> is a standard <code>text</code> field, and therefore queries on it will generate an unbounded score. To create a result set with proper relevance, we need to use a retriever that will normalize the query scores before combining them. In this example, we use the linear retriever with <code>minmax</code> normalization, which normalizes each query’s score to a value between 0 and 1.</p><p>The query construction in this example is fairly straightforward because only two fields are involved. However, it can get complicated very quickly as more fields, and of varying types, are added. This demonstrates how writing an effective hybrid search query often requires deeper knowledge of the index being queried, so that component query scores are properly normalized prior to combination. This poses a barrier to the wider adoption of hybrid search.</p><h3>Query grouping</h3><p>Let’s extend the example: What if we wanted to query one <code>text</code> field and two <code>semantic_text</code> fields? We could construct a query like this:</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "retrievers": [
        {
          "retriever": {
            "standard": {
              "query": {
                "semantic": {
                  "field": "semantic_text_field_1",
                  "query": "foo"
                }
              }
            }
          },
          "normalizer": "minmax"
        },
        {
          "retriever": {
            "standard": {
              "query": {
                "semantic": {
                  "field": "semantic_text_field_2",
                  "query": "foo"
                }
              }
            }
          },
          "normalizer": "minmax"
        },
        {
          "retriever": {
            "standard": {
              "query": {
                "match": {
                  "text_field": "foo"
                }
              }
            }
          },
          "normalizer": "minmax"
        }
      ]
    }
  }
}</code></pre><p>That seems good on its face, but there’s a potential problem. Now the <code>semantic_text</code> field matches make up ⅔ of the total score:</p><pre><code>Total Score = N(semantic_text_field_1 score) + N(semantic_text_field_2 score) + N(text_field score)</code></pre><p>This probably isn’t what you want because it creates an unbalanced score. The effects may not be that noticeable in an example like this one with only 3 fields, but it becomes problematic when more fields are queried. For instance, most indices contain far more lexical fields than semantic (i.e. <code>dense_vector</code>, <code>sparse_vector</code>, or <code>semantic_text</code>). What if we were querying an index with 9 lexical fields and 1 semantic field using the pattern above? The lexical matches would make up 90% of the score, blunting the effectiveness of semantic search.</p><p>A common way to address this is to group queries into lexical and semantic categories and weight the two evenly. This prevents either category from dominating the total score.</p><p>Let’s put that into practice. What would this grouped queries approach look like for this example when using the linear retriever?</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "retrievers": [
        {
          "retriever": {
            "linear": {
              "retrievers": [
                {
                  "retriever": {
                    "standard": {
                      "query": {
                        "semantic": {
                          "field": "semantic_text_field_1",
                          "query": "foo"
                        }
                      }
                    }
                  },
                  "normalizer": "minmax"
                },
                {
                  "retriever": {
                    "standard": {
                      "query": {
                        "semantic": {
                          "field": "semantic_text_field_2",
                          "query": "foo"
                        }
                      }
                    }
                  },
                  "normalizer": "minmax"
                }
              ]
            }
          },
          "normalizer": "minmax"
        },
        {
          "retriever": {
            "standard": {
              "query": {
                "match": {
                  "text_field": "foo"
                }
              }
            }
          },
          "normalizer": "minmax"
        }
      ]
    }
  }
}</code></pre><p>Wow, this is getting verbose! You may have even needed to scroll up and down multiple times to examine the whole query! Here, we use two levels of normalization to create the query groups. Mathematically, it can be expressed as:</p><pre><code>Total Score = N(N(semantic_text_field_1 score) + N(semantic_text_field_2 score)) + N(text_field score)</code></pre><p>This second level of normalization ensures that the queries against the <code>semantic_text</code> fields and <code>text</code> field are weighted evenly. Note that we omit the second-level normalization for <code>text_field</code> in this example since there is only one lexical field, sparing you from <em>even more</em> verbosity.</p><p>This query structure is already unwieldy, and we’re only querying three fields. It becomes increasingly unmanageable, even for seasoned search practitioners, as you query more fields.</p><h2>The multi-field query format</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/dd04e1686076a574b717c1460acfe4eb79299208-1600x1600.jpg" style="width: 100%;" /><p>We added the <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers#multi-field-query-format">multi-field query format</a> for the linear and RRF retrievers in Elasticsearch 8.19, 9.1, and <a href="https://www.elastic.co/cloud/serverless">serverless</a> to simplify all of this. You can now perform the same query as above with just:</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "fields": [ "semantic_text_field_1", "semantic_text_field_2", "text_field" ],
      "query": "foo",
      "normalizer": "minmax"
    }
  }
}</code></pre><p>Which shrinks the query from 55 lines to just 9! Elasticsearch automatically uses the index mappings to:</p><ul><li>Determine the type of each field queried</li><li>Group each field into a lexical or semantic category</li><li>Weight each category evenly in the final score</li></ul><p>This allows anyone to execute an effective hybrid search query without needing to know details about the index or the inference endpoints used.</p><p>When using RRF, you can omit the <code>normalizer</code>, since rank is used as a proxy for relevance:</p><pre><code>GET rrf_retriever_example/_search
{
  "retriever": {
    "rrf": {
      "fields": [ "semantic_text_field_1", "semantic_text_field_2", "text_field" ],
      "query": "foo"
    }
  }
}</code></pre><h2>Per-field boosting</h2><p>When using the linear retriever, you can apply a per-field boost to adjust the importance of matches in certain fields. For example, let’s say you’re querying four fields: two <code>semantic_text</code> fields and two <code>text</code> fields:</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "fields": [ "semantic_text_field_1", "semantic_text_field_2", "text_field_1", "text_field_2" ],
      "query": "foo",
      "normalizer": "minmax"
    }
  }
}</code></pre><p>By default, each field is weighted equally in its group (lexical or semantic). The score breakdown looks like:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ba31c76189e3a1e5b1638437ccf0528aafec2598-1600x549.png" style="width: 100%;" /><p>In other words, each field is 25% of the total score.</p><p>We can use the <code>field^boost</code> syntax to add a per-field boost to any field. Let’s apply a boost of 2 to <code>semantic_text_field_1</code> and <code>text_field_1</code>:</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "fields": [ "semantic_text_field_1^2", "semantic_text_field_2", "text_field_1^2", "text_field_2" ]
      "query": "foo",
      "normalizer": "minmax"
    }
  }
}</code></pre><p>Now the score breakdown looks like:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/de20e555d52f914bf483a048d056f54f4fece757-1600x549.png" style="width: 100%;" /><p>Each query group is still weighted equally, but now the field weight within the groups has changed:</p><ul><li><code>semantic_text_field_1</code> is 66% of the semantic query group score, 33% of the total score</li><li><code>text_field_1</code> is 66% of the lexical query group score, 33% of the total score</li></ul>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ℹ️ Note that the total score range will not change when a per-field boost is applied. 
This is an intended side-effect of score normalization, which ensures that lexical and semantic query scores remain directly comparable with each other.
</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">ℹ️ Per-field boosting can also be used with the RRF retriever in Elasticsearch 9.2+</td>
                    </tr>
              </tbody>
            </table>
          <h3>Wildcard resolution</h3><p>You can use the <code>*</code> wildcard in the <code>fields</code> parameter to match multiple fields. Continuing the example above, this query is functionally equivalent to querying s<code>emantic_text_field_1</code>, <code>semantic_text_field_2</code>, and <code>text_field_1</code> explicitly:</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "fields": [ "semantic_text_field_*", "*_field_1" ],
      "query": "foo",
      "normalizer": "minmax"
    }
  }
}</code></pre><p>It’s interesting to note that the <code>*_field_1</code> pattern matches both <code>text_field_1</code> and <code>semantic_text_field_1</code>. This is handled automatically; the query will execute as if each of the fields were explicitly queried. It’s also fine that the <code>semantic_text_field_1</code> matches both patterns; all field name matches are de-duplicated before query execution.</p><p>You can use the wildcard in a variety of ways:</p><ul><li>Prefix matching (ex: <code>*_text_field</code>)</li><li>Inline matching (ex: <code>semantic_*_field</code>)</li><li>Suffix matching (ex: <code>semantic_text_field_*</code>)</li></ul><p>You can also use multiple wildcards to apply a combination of the above, such as <code>*_text_field_*</code>.</p><h3>Default query fields</h3><p>The multi-field query format also allows you to query an index you know nothing about. If you omit the <code>fields</code> parameter, it will query all fields specified by the <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules">index.query.default_field index setting</a>:</p><pre><code>GET linear_retriever_example/_search
{
  "retriever": {
    "linear": {
      "query": "foo",
      "normalizer": "minmax"
    }
  }
}</code></pre><p>By default, <code>index.query.default_field</code> is set to <code>*</code>. This wildcard will resolve to every field type in the index that supports term queries, which is most. The exceptions are:</p><ul><li><code>dense_vector</code> fields</li><li><code>rank_vector</code> fields</li><li>Geometry fields: <code>geo_point</code>, <code>shape</code></li></ul><p>This functionality is especially useful when you want to perform a hybrid search query on an index provided by a third party. The multi-field query format allows you to execute an appropriate query in a simple way. Just exclude the <code>fields</code> parameter, and all applicable fields will be queried.</p><h2>Conclusion</h2><p>The score range problem can make effective hybrid search a headache to implement, particularly when there’s limited insight into the index being queried or the inference endpoints in use. The multi-field query format for the linear and RRF retrievers alleviates this pain by packaging an automated, query-grouping-based hybrid search approach into a simple and approachable API. Additional functionality, such as per-field boosting, wildcard resolution, and default query fields, extends the functionality to cover many use cases.</p><h2>Try out the multi-field query format today</h2><p>You can check out the linear and RRF retrievers with the multi-field query format in fully managed Elasticsearch <a href="https://www.elastic.co/cloud/serverless">Serverless</a> projects with a <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/create-serverless-project">free trial</a>. It’s also available in stack versions starting from 8.19 &amp; 9.1.</p><p>Get started in minutes on your local environment with a single command:</p><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p></p>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Tomás Murúa]]></dc:creator>
            <pubDate>Wed, 26 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/05d2d0513fff67d975f9223d75108aa9f50646bc-1600x914.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <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><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</li><li>Machine with 32GB RAM recommended (minimum 16GB for GPT-OSS 20B)</li><li><a href="https://lmstudio.ai/">LM Studio</a> installed</li><li><a href="https://www.docker.com/products/docker-desktop/">Docker Desktop</a> Installed</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><strong>Tool use capabilities</strong></li><li><strong>Efficient inference</strong></li><li><strong>OpenAI SDK compatible</strong></li><li><strong>Compatible with agentic workflows</strong></li></ul><p>Benchmark comparison:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/29160e3345352088e8213297630882f252b00c47-1600x680.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a4a886750ff25fa8bb7aefc7448161e52cf73ed3-1600x896.png" style="width: 100%;" /><h2>Building an AI agent for HR: Steps</h2><p>We’ll divide the implementation into 5 steps:</p><ol><li>Configure LM studio with a local model</li><li>Deploy Local Elastic with Docker</li><li>Create the OpenAI connector in Elastic</li><li>Upload employee data to Elasticsearch</li><li>Build and test your AI Agent</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>Go to the search tab and search for “GPT-OSS”</li><li>Select the <code>openai/gpt-oss-20b</code> from OpenAI</li><li>Click download</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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5d01bc4dcb377b5259fc6b521fe2425a31b90ca4-1312x872.png" style="width: 100%;" /><h4>Once the model is downloaded:</h4><ol><li>Go to the local server tab</li><li>Select the openai/gpt-oss-20b</li><li>Use the default port 1234</li><li>On the right panel, go to <strong>Load </strong>and set the Context Length to <strong>40K</strong> or higher</li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e546033f916381647b876815b2c1f1ae2a08365f-326x337.png" style="width: 100%;" /><p>5. Click start server</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/28ee78a3caa84d14e04db3d42f30acbe4d4d005a-1312x872.png" style="width: 100%;" /><p>You should see this if the server is running.</p><pre><code>[LM STUDIO SERVER] Success! HTTP server listening on port 1234
[LM STUDIO SERVER] Supported endpoints:
[LM STUDIO SERVER] ->	GET  http://localhost:1234/v1/models
[LM STUDIO SERVER] ->	POST http://localhost:1234/v1/responses
[LM STUDIO SERVER] ->	POST http://localhost:1234/v1/chat/completions
[LM STUDIO SERVER] ->	POST http://localhost:1234/v1/completions
[LM STUDIO SERVER] ->	POST http://localhost:1234/v1/embeddings
Server started.</code></pre><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><pre><code>curl -fsSL https://elastic.co/start-local | sh</code></pre><p>This script will:</p><ul><li>Download and configure Elasticsearch and Kibana</li><li>Start both services using Docker Compose</li><li>Automatically activate a 30-day Platinum trial license</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><pre><code>🎉 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</code></pre><h3>Access Kibana</h3><p>Open your browser and navigate to:</p><pre><code>http://localhost:5601</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/92e104cb846c20d875865ded8a3d37f5c7daae9b-1491x1528.png" style="width: 100%;" /><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>In Kibana</li><li>Go to <strong>Project Settings</strong> &gt; <strong>Management</strong></li><li>Under <strong>Alerts and Insights</strong>, select <strong>Connectors</strong></li><li>Click Create Connector</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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5ac87042e086c7a2bd47a8039e646ec831f0dcc6-923x974.png" style="width: 100%;" /><p>Fill in the fields with these values:</p><ul><li><strong>Connector name: </strong>LM Studio - GPT-OSS 20B</li><li><strong>Select an OpenAI provider: </strong>Other (OpenAI Compatible Service)</li><li><strong>URL: </strong><code>http://host.docker.internal:1234/v1/chat/completions</code></li><li><strong>Default model: </strong>openai/gpt-oss-20b</li><li><strong>API Key:</strong> testkey-123 (any text works, because LM Studio Server doesn&#x27;t require authentication.)</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2084ac32fcf1fb810c8b54ecab1c85a1e3e8905b-672x1302.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f567d767f1969c4730c1daa92f651789dc3742ac-1042x812.png" style="width: 100%;" /><p>Response:</p><pre><code>{
  "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"
}</code></pre><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><pre><code>{
  "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"
}</code></pre><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><pre><code>​​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"
      }
    }
  }
}</code></pre><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><pre><code>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": "...", ...}</code></pre><h3>Verify the data</h3><p>Run a query to verify:</p><pre><code>GET hr-employees/_search</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/11e830c3e2fb4c298b020c928fa5422f3397ba08-1600x1152.png" style="width: 100%;" /><h3>Access Agent Builder</h3><ol><li>Go to <strong>Agents</strong></li><li>Click on <strong>Create a new agent</strong></li></ol><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c1e60541563650163f972ac9088dc1ed1de759a7-1600x1054.png" style="width: 100%;" /><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:<br/><br/><strong>Agent ID:</strong> <code>hr_assistant</code></p><p><strong>Custom instructions:</strong></p><pre><code>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</code></pre><p><br/>Labels: <code>Human Resources</code> and <code>GPT-OSS</code></p><p>Display name: <code>HR Analytics Assistant</code></p><p>Display description:</p><pre><code>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.</code></pre><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f94bb2bf08497e5e756ca76b30a3a51f42927756-1424x1217.png" style="width: 100%;" /><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><pre><code>Which employee is the one with the highest salary in the hr-employees index?</code></pre><h4>Answer:</h4><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/94ad19f80b96304028a59f60beca51dfc9aecc8a-899x631.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6f2961d0d1b97475f6dda300acee84da540938e6-844x466.png" style="width: 100%;" /><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&#x27;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&#x27;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>
        </item>
        <item>
            <title><![CDATA[Top Elastic Agent Builder projects and learnings from Cal Hacks 12.0]]></title>
            <description><![CDATA[Explore the top Elastic Agent Builder projects from Cal Hacks 12.0 and dive into our technical takeaways on Serverless, ES|QL, and agent architectures.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agent-builder-projects-learnings-cal-hacks-12-0</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agent-builder-projects-learnings-cal-hacks-12-0</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[JD Armada]]></dc:creator>
            <pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8ba034a6f19b50521f541b8131756a8acdb52975-1280x960.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<a href="https://www.youtube.com/watch/GUI0WXNZduo">Watch video on YouTube</a><p>A few weeks ago, we had the incredible opportunity to sponsor <a href="https://cal-hacks-12-0.devpost.com/">Cal Hacks 12.0</a>, one of the largest in-person hackathons with over 2000 participants coming from all over the world. We offered a dedicated prize track for the best use of Elastic Agent Builder on Serverless, and the response was phenomenal. In just 36 hours, we received 29 submissions that used Agent Builder in creative ways, from building wildfire intelligence tools to StackOverflow validators.</p><p>Beyond the impressive projects, the experience at Cal Hacks 12.0 also gave us something equally valuable: quick, unfiltered feedback from developers encountering our Stack for the first time. Hackathons are unique pressure tests with tight timelines, zero prior familiarity, and unpredictable obstacles (like the infamous WiFi outages). They expose exactly where the developer experience shines and where it still needs work. This matters even more now, as developers interact with the Elastic Stack in new ways, increasingly through LLM-driven workflows. In this blog post, we’ll dive deeper into what the participants built with Agent Builder and what we learned in the process.</p><h2>The winning projects</h2><h3>First Place: AgentOverflow</h3><p>Stack Overflow rebuilt for the LLM and agent era.</p><p>Read more about AgentOverflow <a href="https://devpost.com/software/agentoverflow">here</a>.</p><p>AgentOverflow addresses a problem most AI developers encounter: LLMs hallucinate, chat histories disappear, and developers waste time re-solving the same issues.</p><p>AgentOverflow captures, validates, and resurfaces real problem-solution pairs, so developers can break the hallucination spiral and ship faster.</p><h4>How it works:</h4><p><strong>1. Share JSON - the “Solution Schema.”</strong></p><p>One click from a Claude share will scrape, extract, and assemble a Share Solution JSON, which is a structured format containing:</p><ul><li>Problem</li><li>Context</li><li>Code</li><li>Tags</li><li>Verified solution steps.</li></ul><p>A validator (LAVA) checks and enforces structure, the user adds a line of extra context, then it’s stored and indexed within Elasticsearch.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/45a3e96f4474050a855419628c2a7338bb12c706-1600x877.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2d65cb154d8ee32fc96ff17dfa5b0bf2636e3777-1600x1002.png" style="width: 100%;" /><p><strong>2. Find Solution</strong></p><p>When you get stuck, click on <code>Find Solution</code> and AgentOverflow will scrape your current conversation, use it to build a query, and run a hybrid Elasticsearch search to surface:</p><ul><li>Ranked, community-validated fixes</li><li>The exact prompts that originally solved the problem</li></ul><p>This allows developers to copy, paste, and unblock their current session quickly.</p><p><strong>3. MCP - context injection for LLMs</strong></p><p>By connecting to the stored structured solutions within Elasticsearch through MCP (Model Context Protocol), LLMs are fed high signal context (code, logs, configs, prior fixes) at runtime without extra noise.</p><p>AgentOverflow uses Agent Builder with Elasticsearch as a structured memory layer that injects relevant context into LLMs. This transforms them from passive chatbots into context-aware problem solvers.</p><h3>Runner-up: MarketMind</h3><p>A real-time interpretable view of market energy, powered by six Elastic Agents.</p><p>Read more about MarketMind <a href="https://devpost.com/software/marketmind-b6cy2q">here</a>.</p><p>MarketMind earned their spot by giving novice traders a platform that converts fragmented market data into clear, real-time signals. Instead of juggling price action, fundamentals, sentiment, and volatility across different tools, MarketMind consolidates all this information into one platform, helping traders gain actionable insights. This project also used some complex ES|QL queries when building their agents.</p><h4>How it works:</h4><p><strong>1. Collect real-time market data</strong></p><p>MarketMind pulls price-action, fundamentals, sentiment, volatility and risk metrics from Yahoo Finance. This data is ingested and organized into multiple Elasticsearch indices.</p><p><strong>2. Six specialized agents analyze the market</strong></p><p>Each agent, built with Agent Builder, focuses on a different layer of the market. They read from an Elasticsearch index, compute their own domain-specific metrics, and generate a standardized JSON output with scores and reasoning.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7d9716cca06a047a2b3584378b5c7e592a785ba1-1284x878.png" style="width: 100%;" /><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5aac6a833347c0d2e596c02049ec4b4d3aae5cd7-794x764.png" style="width: 100%;" /><p><strong>3. Aggregate signals into a unified “market energy” model</strong></p><p>The combined outputs appear as glowing pulses around each stock, illustrating whether momentum is building, risk is rising, or sentiment is shifting.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/46b3da8e3d528c5dd4e2829416c5446098acb3aa-744x718.png" style="width: 100%;" /><p><strong>4. Visualize insights</strong></p><p>The frontend was built with React and <a href="https://github.com/vercel/next.js">Next.js</a>, using TypeScript, SVG physics-based visuals, and <a href="https://github.com/chartjs">Chart.js</a> for live candlestick charts. This turns raw analysis into real-time actionable feedback.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3f000c043117b77ed4127202be5a49c12e3682ba-1600x930.png" style="width: 100%;" /><h2>Other interesting projects:</h2><p>Here are some other strong contenders that used Elastic in different parts of their stack:</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/76c49a853426844f475cd6b2a74999e60af20e8c-926x1080.png" style="width: 100%;" /><p>Find the full list of projects that were submitted to our track <a href="https://cal-hacks-12-0.devpost.com/submissions/search?utf8=%E2%9C%93&amp;prize_filter%5Bprizes%5D%5B%5D=91882">here</a>.</p><h2>What we learned from developers</h2><ul><li><strong>Agent Builder is user-friendly:</strong></li></ul><p>Most teams had never used Elastic before and were still able to build agents quickly with little support. We held a workshop for those who needed more guidance, but most were able to ingest their data and build an agent to perform actions on that data.</p><ul><li><strong>LLMs excel at <code>kNN</code> queries, but still need guidance in generating ES|QL:</strong></li></ul><p>Asking ChatGPT-5 to generate ES|QL queries returned incorrect information, often mixing ES|QL and SQL. Feeding the LLM the docs in a markdown file seemed to be a workable fix.</p><ul><li><strong>Snapshot-only ES|QL functions leaked into docs:</strong></li></ul><p>The upcoming <code>FIRST</code> and <code>LAST</code> aggregation functions had unintentionally slipped into our ES|QL docs. Because we fed those docs to ChatGPT, the model dutifully used these functions, even though they aren&#x27;t available in Serverless yet. Thanks to the feedback from the group, engineering quickly opened and merged a fix to remove the functions from the published docs (<a href="https://github.com/elastic/elasticsearch/pull/137341">PR #137341</a>).</p><ul><li><strong>Missing Serverless-specific guidance:</strong></li></ul><p>A team tried enabling <code>LOOKUP JOIN</code> on an index that wasn’t created in lookup mode. The error message sent them chasing commands that don’t exist on Serverless. We relayed this to the product team, who immediately opened a fix for a Serverless-specific, actionable message. Longer term, the vision is to hide the reindexing complexity entirely (<a href="https://github.com/elastic/elasticsearch-serverless/issues/4838">Issue #4838</a>).</p><ul><li><strong>Value of in-person events:</strong></li></ul><p>Online hackathons are great, but nothing matches the rapid feedback loop you get when you&#x27;re debugging shoulder-to-shoulder with builders. We watched teams integrate Agent Builder across different use cases, spotted where the developer experience with ES|QL could be improved, and fixed issues much quicker than trying to do so over asynchronous channels.</p><h2>Conclusion</h2><p>Cal Hacks 12.0 gave us more than a weekend of cool demos; it also gave us insight into how new developers are interacting with the Elastic Stack. In just 36 hours, we saw teams pick up Agent Builder, ingest data into Elasticsearch, design multi-agent systems, and test our features in a variety of ways. The event also reminded us why in-person events matter. The rapid feedback loops, real conversations, and hands-on debugging helped us understand current developer needs. We’re excited to bring back what we learned to the engineering team. We’ll see you at the next hackathon.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating an LLM agent newsroom with A2A protocol and MCP in Elasticsearch: Part II]]></title>
            <description><![CDATA[Discover how to build a specialized hybrid LLM agent newsroom using A2A Protocol for agent collaboration and MCP for tool access in Elasticsearch.]]></description>
            <link>https://www.elastic.co/search-labs/blog/a2a-protocol-mcp-llm-agent-workflow-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/a2a-protocol-mcp-llm-agent-workflow-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Justin Castilla]]></dc:creator>
            <pubDate>Mon, 24 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f87330e5d4ca961593b3cfb861ca850a4cc34186-1519x1173.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<h2>A2A and MCP: the code in action</h2><p>This is the companion piece to the article “Creating an LLM Agent newsroom with A2A protocol and MCP in Elasticsearch!”, which explained the benefits of implementing both A2A and MCP architectures within the same agent to truly reap the unique benefits of both frameworks. A <a href="https://github.com/justincastilla/elastic-newsroom">repository</a> is available should you wish to run the demo on your own.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/7196f004089127f83547b2e5dc3f663205cfcdce-1162x1600.png" style="width: 100%;" /><p>Let&#x27;s walk through how our newsroom agents collaborate using both A2A and MCP to produce a news article. The accompanying repository to see the agents in action may be found <a href="https://github.com/justincastilla/elastic-newsroom">here</a>.</p><h3>Step 1: Story assignment</h3><p>The <strong>News Chief</strong> (acting as the client) assigns a story:</p><pre><code>{
  "message_type": "task_request",
  "sender": "news_chief",
  "receiver": "reporter_agent",
  "payload": {
    "task_id": "story_renewable_energy_2024",
    "assignment": {
      "topic": "Renewable Energy Adoption in Europe",
      "angle": "Policy changes driving solar and wind expansion",
      "target_length": 1200,
      "deadline": "2025-09-30T18:00:00Z"
    }
  }
}</code></pre><h3>Step 2: Reporter requests research</h3><p>The <strong>Reporter Agent</strong> recognizes it needs background information and delegates to the <strong>Researcher Agent</strong> via A2A:</p><pre><code>{
  "message_type": "task_request",
  "sender": "reporter_agent",
  "receiver": "researcher_agent",
  "payload": {
    "task_id": "research_eu_renewable_2024",
    "parent_task_id": "story_renewable_energy_2024",
    "capability": "fact_gathering",
    "parameters": {
      "queries": [
        "EU renewable energy capacity 2024",
        "Solar installations growth Europe",
        "Wind energy policy changes 2024"
      ],
      "depth": "comprehensive"
    }
  }
}</code></pre><h3>Step 3: Reporter requests historical context from Archive Agent</h3><p>The <strong>Reporter Agent</strong> recognizes that historical context would strengthen the story. It delegates to the <strong>Archive Agent</strong> (powered by <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic&#x27;s A2A Agent</a>) via A2A to search the newsroom&#x27;s Elasticsearch-powered article archive:</p><pre><code>{
  "message_type": "task_request",
  "sender": "reporter_agent",
  "receiver": "archive_agent",
  "payload": {
    "task_id": "archive_search_renewable_2024",
    "parent_task_id": "story_renewable_energy_2024",
    "capability": "search_archive",
    "parameters": {
      "query": "European renewable energy policy changes and adoption trends over past 5 years",
      "focus_areas": ["solar", "wind", "policy", "Germany", "France"],
      "time_range": "2019-2024",
      "result_count": 10
    }
  }
}</code></pre><h3>Step 4: Archive Agent uses Elastic A2A Agent with MCP</h3><p>The <strong>Archive Agent</strong> uses Elastic&#x27;s A2A Agent, which in turn uses MCP to access Elasticsearch tools. This demonstrates the hybrid architecture where A2A enables agent collaboration while MCP provides tool access:</p><pre><code># Archive Agent using Elastic A2A Agent
async def search_historical_articles(self, query_params):
    # The Archive Agent sends a request to Elastic's A2A Agent
    elastic_response = await self.a2a_client.send_request(
        agent="elastic_agent",
        capability="search_and_analyze",
        parameters={
            "natural_language_query": query_params["query"],
            "index_pattern": "newsroom-articles-*",
            "filters": {
                "topics": query_params["focus_areas"],
                "date_range": query_params["time_range"]
            },
            "analysis_type": "trend_analysis"
        }
    )
    
    # Elastic's A2A Agent internally uses MCP tools:
    # - platform.core.search (to find relevant articles)
    # - platform.core.generate_esql (to analyze trends)
    # - platform.core.index_explorer (to identify relevant indices)
    
    return elastic_response</code></pre><p>The <strong>Archive Agent</strong> receives comprehensive historical data from Elastic&#x27;s A2A Agent and returns it to the Reporter:</p><pre><code>{
  "message_type": "task_response",
  "sender": "archive_agent",
  "receiver": "reporter_agent",
  "payload": {
    "task_id": "archive_search_renewable_2024",
    "status": "completed",
    "archive_data": {
      "historical_articles": [
        {
          "title": "Germany's Energiewende: Five Years of Solar Growth",
          "published": "2022-06-15",
          "key_points": [
            "Germany added 7 GW annually 2020-2022",
            "Policy subsidies drove 60% of growth"
          ],
          "relevance_score": 0.94
        },
        {
          "title": "France Balances Nuclear and Renewables",
          "published": "2023-03-20",
          "key_points": [
            "France increased renewable target to 40% by 2030",
            "Solar capacity doubled 2021-2023"
          ],
          "relevance_score": 0.89
        }
      ],
      "trend_analysis": {
        "coverage_frequency": "EU renewable stories increased 150% since 2019",
        "emerging_themes": ["policy incentives", "grid modernization", "battery storage"],
        "coverage_gaps": ["Small member states", "offshore wind permitting"]
      },
      "total_articles_found": 47,
      "search_confidence": 0.91
    }
  }
}</code></pre><p>This step demonstrates how Elastic&#x27;s A2A Agent integrates into the newsroom workflow. The Archive Agent (a newsroom-specific agent) coordinates with Elastic&#x27;s A2A Agent (a third-party specialist) to leverage Elasticsearch&#x27;s powerful search and analytics capabilities. Elastic&#x27;s agent uses MCP internally to access Elasticsearch tools, showing the clean separation between agent coordination (A2A) and tool access (MCP).</p><h3>Step 5: Researcher uses MCP servers</h3><p>The <strong>Researcher Agent</strong> accesses multiple MCP servers to gather information:</p><pre><code># Researcher Agent using MCP to access tools
async def gather_facts(self, queries):
    results = []
    
    # Use News API MCP Server
    news_data = await self.mcp_client.invoke_tool(
        server="news_api_mcp",
        tool="search_articles",
        parameters={
            "query": queries[0],
            "date_range": "2024-01-01 to 2024-09-30",
            "sources": ["reuters", "bloomberg", "euractiv"]
        }
    )
    
    # Use Statistics MCP Server
    stats_data = await self.mcp_client.invoke_tool(
        server="eurostat_mcp",
        tool="get_energy_statistics",
        parameters={
            "metrics": ["solar_capacity_mw", "wind_capacity_mw"],
            "countries": ["EU27"],
            "year": 2024
        }
    )
    
    # Use Fact-Checking MCP Server
    verified_facts = await self.mcp_client.invoke_tool(
        server="factcheck_mcp",
        tool="verify_claims",
        parameters={
            "claims": [
                "EU solar capacity increased 40% in 2024",
                "Germany leads in wind installations"
            ]
        }
    )
    
    return self._synthesize_research(news_data, stats_data, verified_facts)</code></pre><h3>Step 6: Researcher returns data to Reporter</h3><p>The <strong>Researcher Agent</strong> sends comprehensive research back via A2A:</p><pre><code>{
  "message_type": "task_response",
  "sender": "researcher_agent",
  "receiver": "reporter_agent",
  "payload": {
    "task_id": "research_eu_renewable_2024",
    "status": "completed",
    "research_data": {
      "key_facts": [
        {
          "claim": "EU solar capacity reached 260 GW in 2024",
          "source": "Eurostat Q3 2024 Report",
          "confidence": 0.95,
          "verified": true
        },
        {
          "claim": "Germany installed 12 GW of wind capacity in 2024",
          "source": "German Federal Network Agency",
          "confidence": 0.92,
          "verified": true
        }
      ],
      "statistics": {
        "solar_growth_rate": "35%",
        "wind_growth_rate": "28%"
      },
      "sources_count": 15
    }
  }
}</code></pre><h3>Step 7: Reporter writes article</h3><p>The <strong>Reporter Agent</strong> uses the research data and its own LLM capabilities to write the article. During writing, the Reporter uses the MCP servers for style and templates:</p><pre><code># Reporter Agent writing with MCP assistance
async def write_article(self, research_data, assignment):
    # Get style guidelines via MCP
    style_guide = await self.mcp_client.get_resource(
        server="newsroom_mcp",
        resource="style://editorial/ap_style_guide"
    )
    
    # Get article template via MCP
    template = await self.mcp_client.get_resource(
        server="newsroom_mcp",
        resource="template://articles/news_story"
    )
    
    # Generate article using LLM + research + style
    draft = await self.llm.generate(
        prompt=f"""
        Write a news article following these guidelines:
        {style_guide}
        
        Using this template:
        {template}
        
        Based on this research:
        {research_data}
        
        Assignment: {assignment}
        """
    )
    
    # Self-evaluate confidence in claims
    confidence_check = await self._evaluate_confidence(draft)
    
    return draft, confidence_check</code></pre><h3>Step 8: low confidence triggers Re-Research</h3><p>The <strong>Reporter Agent</strong> evaluates its draft and finds that one claim has low confidence. It sends another request to the <strong>Researcher Agent</strong>:</p><pre><code>{
  "message_type": "collaboration_request",
  "sender": "reporter_agent",
  "receiver": "researcher_agent",
  "payload": {
    "request_type": "fact_verification",
    "claims": [
      {
        "text": "France's nuclear phase-down contributed to 15% increase in renewable capacity",
        "context": "Discussing policy drivers for renewable growth",
        "current_confidence": 0.45,
        "required_confidence": 0.80
      }
    ],
    "urgency": "high"
  }
}</code></pre><p>The <strong>Researcher</strong> verifies the claim using fact-checking MCP servers and returns updated information:</p><pre><code>{
  "message_type": "collaboration_response",
  "sender": "researcher_agent",
  "receiver": "reporter_agent",
  "payload": {
    "verified_claims": [
      {
        "original_claim": "France's nuclear phase-down contributed to 15% increase...",
        "verified_claim": "France's renewable capacity increased 18% in 2024, partially offsetting reduced nuclear output",
        "confidence": 0.88,
        "corrections": "Percentage was 18%, not 15%; nuclear phase-down is gradual, not primary driver",
        "sources": ["RTE France", "French Energy Ministry Report 2024"]
      }
    ]
  }
}</code></pre><h3>Step 9: Reporter revises and submits to Editor</h3><p>The <strong>Reporter</strong> incorporates the verified facts and sends the completed draft to the <strong>Editor Agent</strong> via A2A:</p><pre><code>{
  "message_type": "task_request",
  "sender": "reporter_agent",
  "receiver": "editor_agent",
  "payload": {
    "task_id": "edit_renewable_story",
    "parent_task_id": "story_renewable_energy_2024",
    "content": {
      "headline": "Europe's Renewable Revolution: Solar and Wind Surge 30% in 2024",
      "body": "[Full article text...]",
      "word_count": 1185,
      "sources": [/* array of sources */]
    },
    "editing_requirements": {
      "check_style": true,
      "check_facts": true,
      "check_seo": true
    }
  }
}</code></pre><h3>Step 10: Editor reviews using MCP tools</h3><p>The <strong>Editor Agent</strong> uses multiple MCP servers to review the article:</p><pre><code># Editor Agent using MCP for quality checks
async def review_article(self, content):
    # Grammar and style check
    grammar_issues = await self.mcp_client.invoke_tool(
        server="grammarly_mcp",
        tool="check_document",
        parameters={"text": content["body"]}
    )
    
    # SEO optimization check
    seo_analysis = await self.mcp_client.invoke_tool(
        server="seo_mcp",
        tool="analyze_content",
        parameters={
            "headline": content["headline"],
            "body": content["body"],
            "target_keywords": ["renewable energy", "Europe", "solar", "wind"]
        }
    )
    
    # Plagiarism check
    originality = await self.mcp_client.invoke_tool(
        server="plagiarism_mcp",
        tool="check_originality",
        parameters={"text": content["body"]}
    )
    
    # Generate editorial feedback
    feedback = await self._generate_feedback(
        grammar_issues, 
        seo_analysis, 
        originality
    )
    
    return feedback</code></pre><p>The <strong>Editor</strong> approves the article and sends it forward:</p><pre><code>{
  "message_type": "task_response",
  "sender": "editor_agent",
  "receiver": "reporter_agent",
  "payload": {
    "status": "approved",
    "quality_score": 9.2,
    "minor_edits": [
      "Changed 'surge' to 'increased' in paragraph 3 for AP style consistency",
      "Added Oxford comma in list of countries"
    ],
    "approved_content": "[Final edited article]"
  }
}</code></pre><h3>Step 11: Publisher publishes via CI/CD</h3><p>Finally, the <strong>Printer Agent</strong> publishes the approved article using the MCP servers for the CMS and CI/CD pipeline:</p><pre><code># Publisher Agent publishing via MCP
async def publish_article(self, content, metadata):
    # Upload to CMS via MCP
    cms_result = await self.mcp_client.invoke_tool(
        server="wordpress_mcp",
        tool="create_post",
        parameters={
            "title": content["headline"],
            "body": content["body"],
            "status": "draft",
            "categories": metadata["categories"],
            "tags": metadata["tags"],
            "featured_image_url": metadata["image_url"]
        }
    )
    
    post_id = cms_result["post_id"]
    
    # Trigger CI/CD deployment via MCP
    deploy_result = await self.mcp_client.invoke_tool(
        server="cicd_mcp",
        tool="trigger_deployment",
        parameters={
            "pipeline": "publish_article",
            "environment": "production",
            "post_id": post_id,
            "schedule": "immediate"
        }
    )
    
    # Track analytics
    await self.mcp_client.invoke_tool(
        server="analytics_mcp",
        tool="register_publication",
        parameters={
            "post_id": post_id,
            "publish_time": datetime.now().isoformat(),
            "story_id": metadata["story_id"]
        }
    )
    
    return {
        "status": "published",
        "post_id": post_id,
        "url": f"https://newsroom.example.com/articles/{post_id}",
        "deployment_id": deploy_result["deployment_id"]
    }</code></pre><p>The <strong>Publisher</strong> confirms publication via A2A:</p><pre><code>{
  "message_type": "task_complete",
  "sender": "printer_agent",
  "receiver": "news_chief",
  "payload": {
    "task_id": "story_renewable_energy_2024",
    "status": "published",
    "publication": {
      "url": "https://newsroom.example.com/articles/renewable-europe-2024",
      "published_at": "2025-09-30T17:45:00Z",
      "post_id": "12345"
    },
    "workflow_metrics": {
      "total_time_minutes": 45,
      "agents_involved": ["reporter", "researcher", "archive", "editor", "printer"],
      "iterations": 2,
      "mcp_calls": 12
    }
  }
}</code></pre><p>Here is the complete sequence of the A2A workflow in the accompanying repository using the same Agents described above.</p>
            <table style="border-collapse: collapse; margin: 20px 0;">
              <tbody>
                
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">#</td><td style="border: 1px solid #ccc; padding: 4px;">From</td><td style="border: 1px solid #ccc; padding: 4px;">To</td><td style="border: 1px solid #ccc; padding: 4px;">Action</td><td style="border: 1px solid #ccc; padding: 4px;">Protocol</td><td style="border: 1px solid #ccc; padding: 4px;">Description</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">1</td><td style="border: 1px solid #ccc; padding: 4px;">User</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Assign Story</td><td style="border: 1px solid #ccc; padding: 4px;">HTTP POST</td><td style="border: 1px solid #ccc; padding: 4px;">User submits story topic and angle</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">2</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Create Story</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Creates story record with unique ID</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">3</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Delegate Assignment</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Sends story assignment via A2A protocol</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">4</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Accept Assignment</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Stores assignment internally</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">5</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">MCP Server</td><td style="border: 1px solid #ccc; padding: 4px;">Generate Outline</td><td style="border: 1px solid #ccc; padding: 4px;">MCP/HTTP</td><td style="border: 1px solid #ccc; padding: 4px;">Creates article outline and research questions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">6a</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Researcher</td><td style="border: 1px solid #ccc; padding: 4px;">Request Research</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Sends questions (parallel with 6b)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">6b</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Archivist</td><td style="border: 1px solid #ccc; padding: 4px;">Search Archive</td><td style="border: 1px solid #ccc; padding: 4px;">A2A JSONRPC</td><td style="border: 1px solid #ccc; padding: 4px;">Searches historical articles (parallel with 6a)</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">7</td><td style="border: 1px solid #ccc; padding: 4px;">Researcher</td><td style="border: 1px solid #ccc; padding: 4px;">MCP Server</td><td style="border: 1px solid #ccc; padding: 4px;">Research Questions</td><td style="border: 1px solid #ccc; padding: 4px;">MCP/HTTP</td><td style="border: 1px solid #ccc; padding: 4px;">Uses Anthropic via MCP to answer questions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">8</td><td style="border: 1px solid #ccc; padding: 4px;">Researcher</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Return Research</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Returns research answers</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">9</td><td style="border: 1px solid #ccc; padding: 4px;">Archivist</td><td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">Search Index</td><td style="border: 1px solid #ccc; padding: 4px;">ES REST API</td><td style="border: 1px solid #ccc; padding: 4px;">Queries news_archive index</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">10</td><td style="border: 1px solid #ccc; padding: 4px;">Archivist</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Return Archive</td><td style="border: 1px solid #ccc; padding: 4px;">A2A JSONRPC</td><td style="border: 1px solid #ccc; padding: 4px;">Returns historical search results</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">11</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">MCP Server</td><td style="border: 1px solid #ccc; padding: 4px;">Generate Article</td><td style="border: 1px solid #ccc; padding: 4px;">MCP/HTTP</td><td style="border: 1px solid #ccc; padding: 4px;">Creates article with research/archive context</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">12</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Store Draft</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Saves draft internally</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">13</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Submit Draft</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Submits completed draft</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">14</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Update Story</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Stores draft, updates status to "draft_submitted"</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">15</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Editor</td><td style="border: 1px solid #ccc; padding: 4px;">Review Draft</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Auto-routes to Editor for review</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">16</td><td style="border: 1px solid #ccc; padding: 4px;">Editor</td><td style="border: 1px solid #ccc; padding: 4px;">MCP Server</td><td style="border: 1px solid #ccc; padding: 4px;">Review Article</td><td style="border: 1px solid #ccc; padding: 4px;">MCP/HTTP</td><td style="border: 1px solid #ccc; padding: 4px;">Analyzes content using Anthropic via MCP</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">17</td><td style="border: 1px solid #ccc; padding: 4px;">Editor</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Return Review</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Sends editorial feedback and suggestions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">18</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Store Review</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Stores editor feedback</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">19</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Apply Edits</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Routes review feedback to Reporter</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">20</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">MCP Server</td><td style="border: 1px solid #ccc; padding: 4px;">Apply Edits</td><td style="border: 1px solid #ccc; padding: 4px;">MCP/HTTP</td><td style="border: 1px solid #ccc; padding: 4px;">Revises article based on feedback</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">21</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Update Draft</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Updates draft with revisions</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">22</td><td style="border: 1px solid #ccc; padding: 4px;">Reporter</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Return Revised</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Returns revised article</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">23</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Update Story</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Stores revised draft, status to "revised"</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">24</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Publisher</td><td style="border: 1px solid #ccc; padding: 4px;">Publish Article</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Auto-routes to Publisher</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">25</td><td style="border: 1px solid #ccc; padding: 4px;">Publisher</td><td style="border: 1px solid #ccc; padding: 4px;">MCP Server</td><td style="border: 1px solid #ccc; padding: 4px;">Generate Tags</td><td style="border: 1px solid #ccc; padding: 4px;">MCP/HTTP</td><td style="border: 1px solid #ccc; padding: 4px;">Creates tags and categories</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">26</td><td style="border: 1px solid #ccc; padding: 4px;">Publisher</td><td style="border: 1px solid #ccc; padding: 4px;">Elasticsearch</td><td style="border: 1px solid #ccc; padding: 4px;">Index Article</td><td style="border: 1px solid #ccc; padding: 4px;">ES REST API</td><td style="border: 1px solid #ccc; padding: 4px;">Indexes article to news_archive index</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">27</td><td style="border: 1px solid #ccc; padding: 4px;">Publisher</td><td style="border: 1px solid #ccc; padding: 4px;">Filesystem</td><td style="border: 1px solid #ccc; padding: 4px;">Save Markdown</td><td style="border: 1px solid #ccc; padding: 4px;">File I/O</td><td style="border: 1px solid #ccc; padding: 4px;">Saves article as .md file in /articles</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">28</td><td style="border: 1px solid #ccc; padding: 4px;">Publisher</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Confirm Publication</td><td style="border: 1px solid #ccc; padding: 4px;">A2A</td><td style="border: 1px solid #ccc; padding: 4px;">Returns success status</td>
                    </tr>
                    <tr>
                      <td style="border: 1px solid #ccc; padding: 4px;">29</td><td style="border: 1px solid #ccc; padding: 4px;">News Chief</td><td style="border: 1px solid #ccc; padding: 4px;">Internal</td><td style="border: 1px solid #ccc; padding: 4px;">Update Story</td><td style="border: 1px solid #ccc; padding: 4px;">-</td><td style="border: 1px solid #ccc; padding: 4px;">Updates story status to "published"</td>
                    </tr>
              </tbody>
            </table>
          <h2>Conclusion</h2><p>Both A2A and MCP have important roles to play in the modern augmented-LLM infrastructure paradigm. A2A offers flexibility for complex multi-agent systems but potentially less portability and higher operational complexity. MCP offers a standardized approach for tool integration that&#x27;s simpler to implement and maintain, though it&#x27;s not designed to handle multi-agent orchestration.</p><p>The choice isn&#x27;t binary. As demonstrated through our newsroom example, the most sophisticated and effective LLM-backed systems often combine both approaches: agents coordinate and specialize through A2A protocols while accessing their tools and resources through MCP servers. This hybrid architecture provides the organizational benefits of multi-agent systems alongside the standardization and ecosystem advantages of MCP. This suggests that there may not need to be a choice at all: simply use both as a standard approach</p><p>It&#x27;s up to you as a developer or architect to test and determine the best mixture of both solutions to create the right outcome for your specific use case. Understanding the strengths, limitations, and appropriate applications of each approach will enable you to build more effective, maintainable, and scalable AI systems.</p><p>Whether you&#x27;re building a digital newsroom, a customer service platform, a research assistant, or any other LLM-powered application, carefully considering your coordination needs (A2A) and tool access requirements (MCP) will set you on the path to success.</p><h2>Additional resources</h2><ul><li><strong>Elasticsearch Agent Builder: </strong><a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">https://www.elastic.co/docs/solutions/search/elastic-agent-builder</a></li><li><strong>A2A Specification</strong>: <a href="https://a2a-protocol.org/latest/specification/">https://a2a-protocol.org/latest/specification/</a></li><li><strong>A2A and MCP Integration</strong>: <a href="https://a2a-protocol.org/latest/topics/a2a-and-mcp/">https://a2a-protocol.org/latest/topics/a2a-and-mcp/</a></li><li><strong>Model Context Protocol</strong>: <a href="https://modelcontextprotocol.io/">https://modelcontextprotocol.io</a></li></ul>]]></content:encoded>
        </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>
            <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[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Jonathan Simon]]></dc:creator>
            <pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f77903e5bcaa0f52bed80d5c8ea23e7c538561d6-1703x1027.gif?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/05da6b45ca88b70181028f394bdcc2c289ca68da-1677x952.gif" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ac31098e0d557c7758f180d497b86c90ff50cf66-1976x1099.png" style="width: 100%;" /><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><pre><code>PUT /my-docs
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "content": { 
        "type": "semantic_text"
      },
      "filename": { "type": "keyword" },
      "last_modified": { "type": "date" }
    }
  }
}</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e7f426fd9a5ad6909f81af1246fe84726b7d6596-1980x1103.png" style="width: 100%;" /><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><pre><code>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"
}</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a8224c4379c88cfb720cb110d13b1c3c27291fc3-1999x1247.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5f2ab858f9cb73c40b6ca70c8da60f6d7417db74-1970x1266.png" style="width: 100%;" /><p>Then simply ask, “What data do I have?”</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4311fb41a114e932ceacb4d6bb535263cad57479-1708x938.gif" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f5af8fdb8738b07eceba130e49fdf97478d65646-1636x414.png" style="width: 100%;" /><p>Then click on <strong>Manage tools</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b8a29be0d6c8fa07deb2c523585c3a6bc67153b0-1999x992.png" style="width: 100%;" /><p>Click the <strong>+ New tool</strong> button.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/92a91e94cfa071b66761aaa81e48f7b3962cdaea-1970x1128.png" style="width: 100%;" /><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><pre><code>example.get_greetings</code></pre><p>For <strong>Description</strong>:</p><pre><code>Get greetings doc from Elasticsearch my_docs index.</code></pre><p>For <strong>Configuration </strong>enter the following query into the <strong>ES|QL Query </strong>text area:</p><pre><code>FROM my-docs | WHERE filename == "greetings.md"</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/51698c74174fd6963101eebb7ebe720902209eb5-1406x1271.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f6770cbf2047fed5a5827bfa9f24a3489a1f7deb-1400x500.png" style="width: 100%;" /><p>Click <strong>Create a new agent</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/8fe9bbb5296118c3c044aa943a08f9ec78c82173-1400x763.png" style="width: 100%;" /><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><pre><code>helloworld_agent</code></pre><p>In the <strong>Custom Instructions </strong>text area enter the following instructions:</p><pre><code>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.</code></pre><p>For <strong>Display name </strong>enter the text:</p><pre><code>HelloWorld Agent</code></pre><p>For the <strong>Display description </strong>enter the text:</p><pre><code>An agent that responds to greetings.</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e189613957fa86016710764e665a2cd11e98d401-1400x1303.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e1f95fa27afe30c402da5ffde385774e1bc8b5a-1999x1550.png" style="width: 100%;" /><p>Click <strong>Save</strong> to create the agent.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/20e7e03a8e749597f612e0f0087ff2541b3cb930-1999x545.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/e16a5ab26003c2e5b5fc8516427bd2f9b9737a12-1999x704.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/2cc98878450a7d3b26ebbfbcc5f9b841b8971c41-1191x566.gif" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6428e470f3323c2a88c20e126969939a7b616a83-1844x414.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/11f2ea916bf277e39c98701c8d31e251fcdf6a8b-956x571.png" style="width: 100%;" /><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><pre><code>git clone https://github.com/elastic/elasticsearch-labs</code></pre><p>In the terminal, cd to change directory to elasticsearch-labs.</p><pre><code>cd elasticsearch-labs</code></pre><p>In the terminal, enter the following command to open the current folder in the Visual Studio Code editor.</p><pre><code>code .</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/ae1f173f953e6d805cdc2b5ab756c229b3b31793-1428x1044.png" style="width: 100%;" /><p>Here’s the contents of <em>elastic_agent_builder_a2a.py </em>that you should see in your text editor:</p><pre><code>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 >>> ")
        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())</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/6420978cf4e3edd5ff148dca47554995da3e3f22-1428x603.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/530ebafbc6327f24cb1a94b7c94d205339db6d28-1191x321.png" style="width: 100%;" /><p>Select <strong>Manage tools</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b8a29be0d6c8fa07deb2c523585c3a6bc67153b0-1999x992.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3a9d5bf730b9541013db2b72601202d6a76e15f1-1977x1002.png" style="width: 100%;" /><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><pre><code>https://example-project-a123.kb.westus2.azure.elastic.cloud/api/agent_builder/a2a</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/11696609b354e75aa8110987b0d476634ac6b322-1965x663.png" style="width: 100%;" /><p>Click <strong>Create API key</strong> to create a new API key.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/67b15b2db4e43d6abe3ec42f3e8692f953aa4731-1995x1038.png" style="width: 100%;" /><p>Enter a <strong>Name</strong> for the API key and click <strong>Create API key</strong>.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5e49445a77dc9730dd967f2aa8f8f11f4911eb41-1999x1076.png" style="width: 100%;" /><p>Click the <strong>copy</strong> button to copy the API key.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f5b6993bea36493cf5d80867df607a71c2c24bcf-1971x1038.png" style="width: 100%;" /><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 Key<strong> </strong>value<strong> </strong>to 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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/91636964285003a3dda01ce43214b74c33492393-1428x601.png" style="width: 100%;" /><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/11f2ea916bf277e39c98701c8d31e251fcdf6a8b-956x571.png" style="width: 100%;" /><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><pre><code>cd elasticsearch-labs/supporting-blog-content/agent-builder-a2a-agent-framework</code></pre><p>In the terminal, create a Python virtual environment by running the following code.</p><pre><code>python -m venv .venv</code></pre><p>Activate the virtual environment by running the following command (based on your operating system) in the terminal window:</p><ul><li>If you’re running MacOS or Linux, the command to activate the virtual environment is:</li></ul><pre><code>source .venv/bin/activate</code></pre><ul><li>If you’re on Windows, the command to activate the virtual environment is:</li></ul><pre><code>.venv\Scripts\activate</code></pre><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&#x27;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><pre><code>pip install -r requirements.txt</code></pre><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><pre><code>python elastic_agent_builder_a2a.py</code></pre><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://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f77903e5bcaa0f52bed80d5c8ea23e7c538561d6-1703x1027.gif" style="width: 100%;" /><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><br/></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[You Know, for Context - Part III: The power of hybrid search in context engineering]]></title>
            <description><![CDATA[Discover how to use context engineering and hybrid search to improve AI output accuracy with aggregations, RBAC, and non-content signals.]]></description>
            <link>https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-agentic-ai-accuracy</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-agentic-ai-accuracy</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Woody Walton]]></dc:creator>
            <pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/b82ff25242e4229cc20b218d9cc91c60cfd680bc-720x420.jpg?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>We’ve discussed both hybrid search (<a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-evolution-agentic-ai">Part I</a>) and context engineering, (<a href="https://www.elastic.co/search-labs/blog/context-engineering-llm-evolution-agentic-ai">Part II</a>); now, let’s dive into how they work together for the greatest effect in supplying targeted context to RAG and agentic AI operations.</p><h2>Search isn’t dead, it’s just moved</h2><p>So we’ve had this shift from primarily searching for context through a text box and using the information (the context) returned to construct the answers ourselves, to now using natural language to tell an agent what we want and letting it automatically research and compile the answer for us. Many in the tech world are pointing to this shift and proclaiming “search is dead” (well, the SEO and ad-words world is <a href="https://www.pewresearch.org/short-reads/2025/07/22/google-users-are-less-likely-to-click-on-links-when-an-ai-summary-appears-in-the-results/">definitely changing</a>: <a href="https://www.wired.com/story/goodbye-seo-hello-geo-brandlight-openai/">GEO</a> anyone?), but search is still absolutely critical to agentic operations — it’s just largely performed out of sight via tools now.</p><p>Previously, humans were the main arbiters of subjective relevance: each user has their own reasons for running the search, and their personal experience colors the relative accuracy of the results. If we are to trust that agents can come to the same conclusion (or better) that we would have, we need to ensure the contextual information they have access to is as close to our subjective intent as possible. We have to engineer the context we provide LLMs towards that goal!</p><h2>Generating context with hybrid search retrieval</h2><p>Just a reminder from way back in Part I that Elastic’s hybrid search combines the strengths of traditional keyword-based search (syntax flexibility, keyword precision, and relevance scoring) with the semantic understanding of vector similarity search, and offers multiple reranking techniques. This synergy (a truer usage of that word has never been found!) allows for highly relevant results, with queries that can be much more nuanced in how they target content. It’s not just that you can apply subjective relevance as <em>one</em> of your retrieval stages; it’s really that the first-stage retrieval can include relevance scoring along with all of those other modes at once.</p><h3>Superior accuracy &amp; efficiency</h3><p>Using a data platform that can provide distributed search, retrieval, and reranking as your primary context retrieval engine makes a lot of sense. You’re able to use advanced query syntax to add the missing component of subjective intent, and filter out content that might distract from or muddy the value of the contextual information returned. You can select from any of the individual syntax options available, or combine modalities into a single search that targets each type of data in the manner it understands best, and then combine/re-order them with reranking. You can have the response filtered to only include the fields/values you want, keeping extraneous data at bay. In service to agents, that targeting flexibility lets you build tools that are extremely accurate in how they retrieve context.</p><h3>Context refinement (aggregations and non-content signals)</h3><p>Aggregations can be especially useful in shaping the content a tool delivers to the context window. Aggregations naturally provide numerical-based facts about the shape of the contextual data returned, which makes it easier and more accurate for LLMs to reason over. Because aggregations can be hierarchically nested, it’s an easy way to add multi-level detail for the LLM to generate a more nuanced understanding. Aggregations can also help with managing the context window size — you can easily reduce a query result of 100k documents to a few hundred tokens of aggregated insights.</p><p>Non-content signals are the inherent indicators in your data that tell you the bigger picture of what you’re looking at; they’re the additional characteristics of the results, things like popularity, freshness, geo-location, categories, host diversity, or price bands. These bits of information can be useful for informing the agent in how it weighs the importance of the context it has received. Some simple examples might help illustrate this best:</p><ul><li><strong>Boosting recently published and popular content</strong> - Imagine you have a knowledge base of articles. You want to find articles relevant to a user&#x27;s query, but you also want to boost articles that are both recent and have been found helpful by other users (e.g., have a high &quot;likes&quot; count). In this scenario, we can use a hybrid search to find relevant articles and then rerank them based on a combination of their publication date and popularity.</li><li><strong>E-commerce search with sales and stock adjustment</strong> - In an e-commerce setting, you want to show customers products that match their search term, but you also want to promote products that are selling well and are in stock. You might also want to down-rank products with low stock to avoid customer frustration.</li><li><strong>Prioritizing high-severity issues in a bug tracker</strong> - For a software development team, when searching for issues, it&#x27;s crucial to surface high-severity, high-priority, and recently updated issues first. You can use non-signals like ‘criticality’ and ‘most-discussed’ to weigh different factors independently, ensuring that the most critical and actively discussed issues rise to the top</li></ul><p>These example queries and more can be found in the accompanying Elasticsearch Labs <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/you-know-for-context/">content page</a>.</p><h3>Security enforcement</h3><p>A critical advantage of leveraging a search-powered speed layer like Elastic for context engineering is its built-in security framework. Elastic&#x27;s platform ensures that context delivered to agentic and generative AI operations respects and protects sensitive privately held information through granular role-based access control (RBAC) and attribute-based access control (ABAC). This means that not only are queries handled with efficiency, but also that the results are filtered according to the specific permissions of the agent or the user initiating the request.</p><p>Agents run as the authenticated user, so security is implicitly applied through the security features built-into the platform:</p><ul><li><strong>Fine-grained permissions:</strong> Define access at the document, field, or even term level, ensuring that AI agents only receive data they are authorized to see.</li><li><strong>Role-based access control (RBAC):</strong> Assign roles to agents or users, granting access to specific datasets or functionalities based on their defined responsibilities.</li><li><strong>Attribute-based access control (ABAC):</strong> Implement dynamic access policies based on attributes of the data, the user, or the environment, allowing for highly adaptable and context-aware security.</li><li><strong>Document-level security (DLS) and field-level security (FLS):</strong> These capabilities ensure that even within a retrieved document, only authorized portions are visible, preventing sensitive information from being exposed.</li><li><strong>Integration with enterprise security:</strong> Seamlessly integrate with existing identity management systems (like LDAP, SAML, OIDC) to enforce consistent security policies across the entire organization.</li></ul><p>By integrating these security measures directly into the context retrieval mechanism, Elastic acts as a secure gatekeeper, ensuring that AI agents operate within defined data boundaries, preventing unauthorized data exposure, and maintaining compliance with data privacy regulations. This is paramount for building trust in agentic AI systems that handle confidential or proprietary information.</p><p>As an added bonus, by using a unified data speed layer over your enterprise data sources, you alleviate the unexpected ad hoc query loads on those repositories that agentic tools would create. You get a single location to search everything in near real-time, and one place to apply security and governance controls.</p><h2>Hybrid search-based tools</h2><p>There are some core features (with <a href="https://www.elastic.co/blog/whats-new-elastic-9-2-0">more coming all the time</a>) of the Elastic platform that turbo boost the pursuit of context engineering. The main thing here is that the platform offers a multitude of ways to achieve things, with the flexibility to adapt, change, and expand methods as the AI ecosystem advances.</p><h3>Introducing Agent Builder</h3><p>Elastic <a href="https://www.elastic.co/elasticsearch/agent-builder">Agent Builder</a> is our first foray into the realm of agentic AI tools built to chat with the data you’re already storing in Elastic. Agent Builder offers a chat interface that enables users to create and manage their own agents and tools within Kibana. It comes with built-in MCP and A2A servers, programmatic APIs, and a set of pre-built system tools for querying and exploring Elasticsearch indices, and for generating ES|QL queries from natural language. Agent Builder allows you to create custom tools that target and sculpt the contextual data returned to the agent through expressive <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> query syntax.</p><p>How does ES|QL perform hybrid search, you ask? The core capability is accomplished through the combination of the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">semantic_text</a> field type and the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork">FORK</a>/<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse">FUSE</a> commands (FUSE uses <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">RRF</a> by default to merge results from each fork). Here’s a simple example for a fictitious product search:</p><pre><code>FROM products
| FORK
  (MATCH description "high performance gaming laptop" | EVAL search_type = "bm25"),
  (MATCH description_semantic "high performance gaming laptop" | EVAL search_type = "semantic")
| FUSE 
| LIMIT 20
| KEEP product_name, description, _score, search_type</code></pre><p>The <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval">EVAL</a> clause included with each of the FORK branches in the example above isn’t strictly necessary; it’s only included to demonstrate how you could track which search modality a given result was returned from.</p><h3>Search templates</h3><p>Let’s say you want to point your own external agentic tools to your Elastic deployment. And instead of ES|QL, you want to use multi-stage retrievers or re-use existing DSL syntax you’ve developed, and also want to be able to control the inputs the query accepts, the syntax used to execute the search, and the fields returned in the output. <a href="https://www.elastic.co/docs/solutions/search/search-templates">Search templates</a> allow users to define predefined structures for common search patterns, improving efficiency and consistency in retrieving data. This is particularly beneficial for agentic tools interacting with search APIs, as they help standardize boilerplate code and enable faster iteration on search logic. And if you ever need to adjust any of those factors, you just update the search template and voilā the changes are implemented. If you’re looking for an example of search templates in action with agentic tools, take a look at the Elasticsearch Labs blog ‘<a href="https://www.elastic.co/search-labs/blog/mcp-intelligent-search">MCP for intelligent search</a>’, which utilizes a search template behind a tool call from an external MCP server.</p><h3>Integrated workflows (FTW!)</h3><p>One of the most difficult things to navigate in our new agentic AI world is the non-deterministic nature of semi-autonomous, self-directed “reasoning” agents. Context engineering is a critical discipline to agentic AI: they’re the techniques that help narrow the possible conclusions our agent can generate down to what we know of ground truth. Even with a highly accurate and relevant context window, (when we get outside the realm of numerical facts) we’re still missing that bit of reassurance that the agent’s response is fully repeatable, dependable.</p><p>When you run the same request to an agent multiple times, the answers might be <em>essentially</em> the same with <em>just that little bit</em> of difference in the response. That’s usually fine for simple queries, maybe barely noticeable, and we can try to shape the output with context engineering techniques. But as the tasks we ask of our agents become more complex, there’s more of a chance that one or more of the sub-tasks could introduce a variance that slightly changes the end-result. It’ll likely get worse as we begin to rely more on agent-to-agent communications, and those variances will become cumulative. This points again to the idea that the tools our agents interact with need to be very flexible and tuneable to precisely target contextual data, and that they should respond in an expected output format. It also indicates that for many use cases we have a need to direct agent and tool interactions — this is where workflows enter into the picture!</p><p>Elastic will soon have completely customizable workflows built into the core of the platform. These workflows will be able to operate with agents and tools in a bi-directional manner, so workflows will be able to call agents and tools, and agents and tools will be able to call workflows. Having these capabilities fully integrated into the same search AI platform where all of your data lives will be transformational, the potential of workflows is extremely exciting! Soon, coming very soon!</p><h3>Elastic as the unified memory bank</h3><p>By virtue of being a distributed data platform that’s made for near real-time search, Elastic naturally performs the long-term memory functions for agentic AI systems. With the built-in Agent Builder chat experience, we also have tracking and management of the short-term memory and chat history. And because the entire platform is API-first, it’s extremely easy to utilize Elastic as the platform to persist a tool’s contextual output (and to be able to refer to it later) that might overwhelm the agent’s context window; this technique is sometimes called “<a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents#:~:text=Agents%20can%20assemble%20understanding%20layer%20by%20layer%2C%20maintaining%20only%20what%27s%20necessary%20in%20working%20memory%20and%20leveraging%20note%2Dtaking%20strategies%20for%20additional%20persistence">note-taking</a>” in context engineering circles.</p><p>Having short-term and long-term memory both on the same search platform leads to a lot of intrinsic benefits: imagine being able to use chat histories and persisted contextual responses as part of the semantic influencers to future chat interactions, or to perform threat analytics, or to create persisted data products that are automatically generated from frequently repeated tool calls… The possibilities are endless!</p><h2>Conclusion</h2><p>The emergence of large language models has changed the way we’re able to match content and the methods we use to interrogate our data. We’re rapidly shifting away from our current world, where humans perform the research, contextual consideration, and logical reasoning to answer their own questions, to one where those steps are largely automated through agentic AI. In order for us to trust the generated answers we receive, we need assurance that the agent has considered <em>all</em> of the <em>most relevant</em> information (including the factor of subjective relevance) in generating its response. Our primary method for making agentic AI trustworthy is by grounding the tools that retrieve additional context through RAG and context engineering techniques, but how those tools perform the <em>initial retrieval</em> can be critical to the accuracy of the response.</p><p>The Elastic Search AI platform provides the flexibility and advantage of hybrid search, along with several built-in features that help agentic AI in terms of accuracy, performance, and scalability; in other words, Elastic makes a fantastic platform for several aspects of context engineering! In standardizing context retrieval via a search platform, we simplify agentic tool operations on several fronts — and similar to the oxymoron “slow down to go faster,” simplicity at the context generation layer means faster and more trustworthy agentic AI.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ Implementing agentic search with observability to autotune relevance in Elasticsearch]]></title>
            <description><![CDATA[Learn how to implement agentic search in Elasticsearch to automatically train a relevance model from logged user data.]]></description>
            <link>https://www.elastic.co/search-labs/blog/agentic-search-relevance-autotuning-elasticsearch</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agentic-search-relevance-autotuning-elasticsearch</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Craig Angulo, James Williams]]></dc:creator>
            <pubDate>Wed, 19 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/925bef05321b84a8794c2f4ba15ce29a64c6a223-842x422.png?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>Every search bar is a broken promise. Users type natural language queries, &quot;beachfront paradise in Hawaii with a chef&#x27;s kitchen&quot; but get back irrelevant results from lexical search. We&#x27;ve solved this with Agentic search and relevance autotuning: a reference architecture that combines natural language understanding with self-improving search that learns from every user interaction. No data scientists needed. No manual tuning. Just search that gets smarter automatically. In this post, I&#x27;ll show you exactly how to build it.</p><p>Who does this help:</p><ul><li><strong>Insurance companies</strong>: Customers find the right coverage without understanding policy jargon</li><li><strong>SaaS companies</strong>: Developers find documentation using natural language</li><li><strong>E-commerce</strong>: Buyers discover products using conversational queries</li><li><strong>Any business with search</strong>: Turn your search bar from a cost center into a competitive advantage</li></ul><h2>Objective</h2><p>In this blog, we walk through the steps you will need to take to enable an agentic search solution leveraging any LLM you want and the Elastic platform. This search solution will automatically train a Learn-to-Rank model based on user interactions. We will be leveraging a dataset of properties to make a <code>home-search-agent</code> that allows users to ask natural language queries “Show me houses for sale in Hawaii that have 3 bedrooms, a pool, and cost under 1 million” and have rich search results returned to them.</p><h2>Architecture overview</h2><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/3d4e92f5ac68e7e44f72cf331bf6dfbe95ec650b-1600x725.png" style="width: 100%;" /><ol><li><strong>User asks naturally</strong> → &quot;Show me houses for sale in Hawaii that have 3 bedrooms&quot;</li><li><strong>Agent understands</strong> → Translates to optimized Elasticsearch query</li><li><strong>Results delivered</strong> → Relevant results, no keyword gymnastics</li><li><strong>System learns</strong> → Every interaction teaches the model what users actually want</li></ol><h2>Learn-to-Rank (LTR) implementation</h2><p>The most difficult part of training Learn-to-Rank algorithms is creating judgment lists; this is now handled automatically with Elastic Logging. As searches are run with your home-search-agent those searches, the results, and follow-up questions get logged for training.</p><ul><li>48 features are looked at in this example, ranging from property attributes (pool, number of rooms) to engagement signals (click-through rate, follow-up questions)</li><li>Once enough conversations have been logged, an XGBoost-based reranking model will be trained on the data and deployed back to Elasticsearch.</li><li>The feedback loop: search → log events → train model → d→ improved search</li></ul><h2>Setup and deployment</h2><p>A detailed step-by-step is provided in the <a href="https://github.com/jwilliams-elastic/agentic-search-o11y-autotune">Github readme section</a>, along with a technical deep dive</p><ol><li>Ensure you have Node.js, Git, and Python &gt;=3.10, &lt;3.13</li><li>Clone the provided code repository:<a href="https://github.com/jwilliams-elastic/agentic-search-o11y-autotune">https://github.com/jwilliams-elastic/agentic-search-o11y-autotune</a></li><li>Set up a virtual environment with the requirements.txt provided</li><li>Create an Elastic Serverless project and copy down the following:<ol><li>ELASTIC_URL</li><li>ELASTIC_API_KEY</li></ol></li><li>Create an .env file and provide the credentials from Step 4 (Additionally, you will provide your LLM’s API_KEY)</li><li>Open a Terminal in your virtual environment and run <code>npm run dev</code>. This will spin up your Mastra server and provide you with a URL like <a href="http://localhost:4111/workflows">http://localhost:4111/</a></li></ol><h2>What was deployed and how it is used</h2><ul><li>Multiple search templates were created during the deployment workflow</li><li>Each one of these search templates provides different configurations from V1-V4</li><li>The home-search-agent has a prompt provided that allows the LLM to fill in search templates</li><li>The LLM is able to convert natural language queries and fill in search templates via an ElasticSearch tool the agent can access</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/c0a78ce82c320697a1616e94d290447af281cae3-1080x450.png" style="width: 100%;" /><h2>Demo workflow</h2><ol><li>Open <a href="http://localhost:4111/workflows">http://localhost:4111/workflows</a></li><li>Run <code>elastic-setup-workflow (.env</code> file has default values, but you can override in Mastra UI)</li><li>Run <code>search-autotune-workflow</code> (LOW and HIGH option generates different simulated search engagement behavior - HIGH = Luxury, LOW = Affordable)</li><li>Open <a href="http://localhost:4111/agents">http://localhost:4111/agents</a> and run the &quot;Home Search Agent&quot;</li><li>Show the difference b/t LTR and no-LTR LLM judgment with a query like &quot;affordable home&quot;, &quot;luxury home&quot; and &quot;6 bed, 6 bath single family home near orlando fl with garage and pool under 5M with designer finishes throughout&quot;</li><li>You can trigger engagement by asking for more detail for a specific result (ex: tell me more about result #20 in v4 results)</li><li>Open the &quot;Agentic Search Analytics&quot; dashboard - KPIs like CTR, Average Click Position and search template usage.</li></ol><h2>Agents in Action</h2><p>With your Elastic Agents running on the Mastra Framework, you now can choose between the <code>home-search-agent</code>, which allows you to run property searches, and the <code>home-search-agent-ltr-comparison,</code> which allows you to see the direct benefits of training on personalized data.</p><h3>The home-search-agent</h3><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/a10e3466d36b135de3c388217ac5b9da17e5ff6b-1600x1016.png" style="width: 100%;" /><p>The home-search-agent-ltr-comparison</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/f03dce0c7fe436618c7175874b7dc30897ecd788-1600x1202.png" style="width: 100%;" /><h2>Why Now?</h2><p>As users increasingly expect ChatGPT-like search experiences, traditional search engines lose customers to poor relevance and complicated manual tuning. This architecture democratizes access by enabling non-technical users to find what they need without crafting complex queries, while LTR models continuously adapt to individual behavior patterns to increase relevance. Automated retraining keeps results current as trends evolve, and the built-in observability dashboard reveals usage patterns and gaps in your offerings. These insights directly inform feature development and keep you aligned with actual user needs rather than assumptions. Ready to transform your search engine&#x27;s relevance? Contact us at <a href="http://elastic.co/contact">elastic.co/contact</a> to find out how your search can begin working for you.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[You Know, for Context - Part II: Agentic AI and the need for context engineering]]></title>
            <description><![CDATA[Learn how the evolution of LLMs towards agentic AI  increases the need for context engineering to solve RAG context limits and memory management.]]></description>
            <link>https://www.elastic.co/search-labs/blog/context-engineering-llm-evolution-agentic-ai</link>
            <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/context-engineering-llm-evolution-agentic-ai</guid>
            <category><![CDATA[elasticsearch-labs]]></category>
            <dc:creator><![CDATA[Woody Walton]]></dc:creator>
            <pubDate>Tue, 18 Nov 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/79c0378b68d74d9e018c35ee2c1fd17daeee9f2c-1080x608.webp?fm=webp&amp;w=1440&amp;h=754&amp;q=90&amp;fit=crop" length="0" type="image/webp"/>
            <content:encoded><![CDATA[<p>With that (fairly extensive) <a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-evolution-agentic-ai">background</a> on the ways LLMs have changed the underlying processes of information retrieval, let’s see how they’ve also changed the way we query for data.</p><h2>A new way of interacting with data</h2><p>Generative (genAI) and agentic AI do things differently than traditional search. Whereas the way we used to begin researching information was a search (“let me Google that…”), the initiating action for both gen AI and Agents is usually through natural language entered into a chat interface. The chat interface is a discussion with an LLM that uses its semantic understanding to turn our question into a distilled answer, a summarized response seemingly coming from an oracle that has a broad knowledge of all kinds of information. What really sells it is the LLM’s ability to generate coherent, thoughtful sentences that string together the bits of knowledge it surfaces — even when it’s inaccurate or totally hallucinated, there’s a <a href="https://en.wikipedia.org/wiki/Truthiness">truthiness</a> to it.</p><p>That old search bar we’ve been so used to interacting with can be thought of as the RAG engine we used when <strong><em>we ourselves</em></strong> were the reasoning agent. Now, even Internet search engines are turning our well-worn “hunt and peck” lexical search experience into AI-driven overviews that answer the query with a summary of the results, helping users avoid the need to click through and evaluate individual results themselves.</p><h2>Generative AI &amp; RAG</h2><p>Generative AI tries to use its semantic understanding of the world to parse the subjective intention stated through a chat request, and then uses its inference abilities to create an expert answer on the fly. There are several parts to a generative AI interaction: it starts with the user’s input/query, previous conversations in the chat session can be used as additional context, and the instructional prompt that tells the LLM how to reason and what procedures to follow in constructing the response. Prompts have evolved from simple &quot;explain this to me like I am a five-year-old” type of guidance to complete breakdowns for how to process requests. These breakdowns often include distinct sections describing details of the AI’s persona/role, pre-generation reasoning/internal thought process, objective criteria, constraints, output format, audience, as well as examples to help demonstrate the expected results.</p><p>In addition to the user’s query and the system prompt, retrieval augmented generation (RAG) provides additional contextual information in what’s called a “context window.” RAG has been a critical addition to the architecture; it’s what we use to inform the LLM about the missing pieces in its semantic understanding of the world.</p><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/5b9671d5d07d4caefde372bb3188000754a91eed-1470x746.png" style="width: 100%;" /><p>Context windows can be kind of <a href="https://www.dbreunig.com/2025/06/22/how-contexts-fail-and-how-to-fix-them.html">persnickety</a> in terms of what, where, and how much you give them. Which context gets selected is very important, of course, but the signal-to-noise ratio of the provided context also matters, as well as the length of the window.</p><h3>Too little information</h3><p>Providing too little information in a query, prompt, or context window can lead to hallucinations because the LLM can’t accurately determine the correct semantic context to generate a response from. There are also issues with the vector similarity of document chunk sizes — a short, simple question may not semantically align with the rich, detailed documents found in our vectorized knowledge bases. Query expansion techniques such as <a href="https://medium.com/data-science/how-to-use-hyde-for-better-llm-rag-retrieval-a0aa5d0e23e8">Hypothetical Document Embeddings (HyDE)</a> have been developed that use LLMs to generate a hypothetical answer that is richer and more expressive than the short query. The danger here, of course, is that the hypothetical document is itself a hallucination that takes the LLM even farther astray from the correct context.</p><h3>Too much information</h3><p>Just like it does to us humans, too much information in a context window can overwhelm and confuse an LLM about what the important parts are supposed to be. Context overflow (or “<a href="https://research.trychroma.com/context-rot">context rot</a>”) affects the quality and performance of generative AI operations; it greatly impacts the LLM’s “attention budget” (its working memory) and dilutes relevance across many competing tokens. The concept of “context rot” also includes the observation that LLMs tend to have a <a href="https://alexandrabarr.beehiiv.com/p/context-windows">positional bias</a> — they prefer the content at the beginning or end of a context window over content in the middle section.</p><h3>Distracting or conflicting information</h3><p>The larger a context window gets, the more chance there is that it might include superfluous or conflicting information that can serve to distract the LLM from selecting and processing the correct context. In some ways, it becomes a problem of garbage in/garbage out: just dumping a set of document results into a context window gives the LLM a lot of information to chew on (potentially too much), but depending on how the context was selected there is a greater possibility for conflicting or irrelevant information seeping in.</p><h2>Agentic AI</h2><p>I told you there was a lot of ground to cover, but we did it — we’re finally talking about agentic AI topics! Agentic AI is a very exciting new usage of LLM chat interfaces that expands on generative AI’s (can we call it “legacy” already?) ability to synthesize responses based on its own knowledge and contextual information you provide. As generative AI became more mature, we realized there was a certain level of tasking and automation we could have LLMs perform, initially relegated to tedious low-risk activities that can easily be checked/validated by a human. Over a short period of time, that initial scope grew: an LLM chat window can now be the spark that sends an AI agent off to autonomously plan, execute, and iteratively evaluate and adapt its plan to achieve its specified goal. Agents have access to their LLMs’ own reasoning, the chat history and thinking memory (such as it is), and they also have specific tools made available that they can utilize towards that goal. We’re also now seeing architectures that allow a top-level agent to function as the orchestrator of multiple <a href="https://www.philschmid.de/the-rise-of-subagents">sub-agents</a>, each with their own logic chains, instruction sets, context, and tools.</p><p>Agents are the entry point to a mostly automated workflow: they’re self-directed in that they are able to chat with a user and then use ‘logic’ to determine what tools it has available to help answer the user’s question. Tools are usually considered passive as compared to agents and built to do one type of task. The <em>types</em> of tasks a tool could perform are kind of limitless (which is really exciting!) but a primary task tools perform is to gather contextual information for an agent to consider in executing its workflow.</p><p>As a technology, agentic AI is still in its infancy and prone to the LLM equivalent of attention deficit disorder — it easily forgets what it was asked to do, and often runs off to do other things that weren’t part of the brief at all. Underneath the apparent magic, the “reasoning” abilities of LLMs are still based on predicting the next most likely token in a sequence. For reasoning (or someday, artificial general intelligence (AGI)) to become reliable and trustworthy, we need to be able to verify that when given the correct, most up-to-date information that they will reason through the way we expect them to (and perhaps give us that little extra bit more that we might not have thought of ourselves). For that to happen, agentic architectures will need the ability to communicate clearly (protocols), to adhere to the workflows and constraints we give them (guardrails), to remember where they are in a task (state), manage their available memory space, and validate their responses are accurate and meet the task criteria.</p><h2>Talk to me in a language I can understand</h2><p>As is common in new areas of development (especially so in the world of LLMs), there were initially quite a few approaches for agent-to-tool communications, but they quickly converged on the <a href="https://modelcontextprotocol.io/docs/getting-started/intro">Model Context Protocol (MCP)</a> as the de facto standard. The definition of Model Context Protocol is truly in the name - it’s the <strong>protocol</strong> a <strong>model</strong> uses to request and receive <strong>contextual</strong> information. MCP acts as a universal adapter for LLM agents to connect to external tools and data sources; it simplifies and standardizes the APIs so that different LLM frameworks and tools can easily interoperate. That makes MCP a kind of pivot point between the orchestration logic and system prompts given to an agent to perform autonomously in the service of its goals, and the operations sent to tools to perform in a more isolated fashion (isolated at least with regards to the initiating agent).</p><p>This ecosystem is all so new that every direction of expansion feels like a new frontier. We have similar protocols for agent-to-agent interactions (<a href="https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/">Agent2Agent (A2A)</a> natch!) as well as other projects for improving agent reasoning memory (<a href="https://venturebeat.com/ai/new-memory-framework-builds-ai-agents-that-can-handle-the-real-worlds">ReasoningBank</a>), for selecting the best MCP server for the job at hand (<a href="https://arxiv.org/abs/2505.03275">RAG-MCP</a>), and using semantic analysis such as zero-shot classification and pattern detection on input and output as <a href="https://openai.github.io/openai-guardrails-python/">Guardrails</a> to control what an agent is allowed to operate on.</p><p>You might have noticed that the underlying intent of each of these projects is to improve the quality and control of the information returned to an agent/genAI context window? While the agentic AI ecosystem continues to develop the ability to handle that contextual information better (to control, manage, and operate on it), there will always be the need to retrieve the <em>most relevant</em> contextual information as the grist for the agent to mill on.</p><h2>Welcome to context engineering!</h2><p>If you’re familiar with generative AI terms, you’ve probably heard of ‘prompt engineering’ - at this point, it’s almost a pseudo-science of its own. Prompt engineering is used to find the best and most efficient ways of proactively describing the behaviors you want the LLM to use in generating its response. ‘<a href="https://www.elastic.co/search-labs/blog/context-engineering-overview">Context engineering</a>’ extends ‘prompt engineering’ techniques beyond the agent side to also cover available context sources and systems on the tools side of the MCP protocol, and includes the broad topics of context management, processing, and generation:</p><ul><li><strong>Context management </strong>- Related to maintaining state and context efficiency across long-running and/or more complex agentic workflows. Iterative planning, tracking, and orchestration of tasks and tool calling to accomplish the agent’s goals. Due to the limited “attention budget” agents have to work within, context management is largely concerned with techniques that help refine the context window to capture both the fullest scope and the most important bits of context (its precision versus recall!). Techniques include compression, summarization, and persisting context from previous steps or tool calls to make room in working memory for additional context in subsequent steps.</li><li><strong>Context processing </strong>- The logical and hopefully mostly programmatic steps to integrate, normalize, or refine context acquired from disparate sources so that the agent can reason across all the context in a somewhat uniform manner. The underlying work is to make context from all sources (prompts, RAG, memory, etc.), all consumable by the agent as efficiently as possible. </li><li><strong>Context generation </strong>- If context processing is about making retrieved context usable to the agent, then context generation gives the agent the outreach to request and receive that additional contextual information at will, but also with constraints.</li></ul><img src="https://cdn.sanity.io/images/me0ej585/search-labs-import-testing/4a8240e1eb078b2294b8d981b9caa8593589cac4-1600x900.png" style="width: 100%;" /><p>The various ephemera of LLM chat applications map directly (and sometimes in overlapping ways) to those high-level functions of context engineering:</p><ul><li><strong>Instructions / system prompt</strong> - Prompts are the scaffolding for how the generative (or agentic) AI activity will direct its thinking towards accomplishing the user’s goal. Prompts are context in their own right; they aren’t just tonal instructions — they also frequently include task execution logic and rules for things like “thinking step by step” or “take a deep breath” before responding to validate the answer fully addresses the user’s request. Recent testing has shown markup languages are very effective at framing the different parts of a prompt, but care should also be taken to calibrate the instructions to a sweet spot between too vague and too specific; we want to give enough instruction for the LLM to find the right context, but not be so prescriptive that it misses unexpected insights.</li><li><strong>Short-term memory</strong> (state/history) - Short-term memory is essentially the chat session interactions between the user and the LLM. These are useful in refining context in live sessions, and can be saved for future retrieval and continuation. </li><li><strong>Long-Term Memory</strong> - Long-term memory should consist of information that is useful across multiple sessions. And it’s not just domain-specific knowledge bases accessed through RAG; recent research uses the outcomes from previous agentic/generative AI requests to learn and reference within current agentic interactions. Some of the most interesting innovations in the long-term memory space are related to adjusting how state is <a href="https://steve-yegge.medium.com/introducing-beads-a-coding-agent-memory-system-637d7d92514a">stored and linked-to</a> so that agents can pick up where they left off. </li><li><strong>Structured output</strong> - Cognition requires effort, so it’s probably no surprise that even with reasoning capabilities, LLMs (just like humans) want to expend less effort when thinking, and in the absence of a defined API or protocol, having a map (a schema) for how to read data returned from a tool call is extremely helpful. The inclusion of <a href="https://platform.openai.com/docs/guides/structured-outputs?lang=javascript">Structured Outputs</a> as part of the agentic framework helps to make these machine-to-machine interactions faster and more reliable, with less thinking-driven parsing needed.</li><li><strong>Available tools</strong> - Tools can do all sorts of things, from gathering additional information (e.g., issuing RAG queries to enterprise data repositories, or through online APIs) to performing automated actions on behalf of the agent (like booking a hotel room based on the criteria of the request from the agent). Tools could also be sub-agents with their own agentic processing chains. </li><li><strong>Retrieval Augmented Generation (RAG)</strong> - I really like the description of RAG as “dynamic knowledge integration.” As described earlier, RAG is the technique for providing the additional information the LLM didn’t have access to when it was trained, or it’s a reiteration of the ideas we think are most important to get the right answer — the one that’s most relevant to our subjective query.</li></ul><h2>Phenomenal cosmic power, itty bitty living space!</h2><p>Agentic AI has so many fascinating and exciting new realms to explore! There are still lots of the old traditional data retrieval and processing problems to solve, but also brand new classes of challenges that are only now being exposed to the light of day in the new age of LLMs. Many of the immediate issues we’re grappling with today are related to context engineering, about getting LLMs the additional contextual information they need without overwhelming their limited working memory space.</p><p>The flexibility of semi-autonomous agents that have access to an array of tools (and other agents) gives rise to so many new ideas for implementing AI, it’s hard to fathom the different ways we might put the pieces together. Most of the current research falls into the field of context engineering and is focused on building memory management structures that can handle and track larger amounts of context — that’s because the deep-thinking problems we really want LLMs to solve present increased complexity and longer-running, multi-phased thinking steps where remembering is extremely important.</p><p>A lot of the ongoing experimentation in the field is trying to find the optimal task management and tool configurations to feed the agentic maw. Each tool call in an agent’s reasoning chain incurs cumulative cost, both in terms of compute to perform that tool’s function as well as the impact to the limited context window. Some of the latest techniques to manage context for LLM agents have caused unintended chain effects like “<a href="https://venturebeat.com/ai/ace-prevents-context-collapse-with-evolving-playbooks-for-self-improving-ai">context collapse</a>” where compressing/summarizing accumulated context for long-running tasks gets <em>too</em> lossy. The desired outcome is tools that return succinct and accurate context, without extraneous information bleeding into the precious context window memory space.</p><h3>So many/too many possibilities</h3><p>We want separation of duties with flexibility to reuse tools/components, so it makes complete sense to create dedicated agentic tools for connecting to specific data sources — each tool can specialize in querying one type of repository, one type of data stream, or even one use case. But beware: in the drive to save time/money/prove something is possible there’s going to be a strong temptation to use LLMs as a federation tool… Try not to, we’ve been <a href="https://www.elastic.co/pdf/elastic-distributed-not-federated-search.pdf">down that road</a> before! Federated query acts like a “universal translator” that converts an incoming query into the syntax that the remote repository understands, and then has to somehow rationalize the results from multiple sources into a coherent response. Federation as a technique <em>works</em> <em>okay</em> at small scales, but at large scales and especially when data is multimodal, federation tries to bridge gaps that are just too wide.</p><p>In the agentic world, the agent would be the federator and the tools (through MCP) would be the manually-defined connections to disparate resources. Using dedicated tools to reach out across unconnected data sources might seem like a powerful new way to dynamically unite different data streams on a per query basis, but using tools to ask the same question to multiple sources will likely end up causing more issues than it solves. Each of those data sources are likely different types of repositories underneath, each with their own capabilities for retrieving, ranking, and securing the data within them. Those variances or “impedance mis-matches” between repositories add to the processing load, of course. They also potentially introduce conflicting information or signals, where something as seemingly innocuous as a scoring misalignment could wildly throw off the importance given to a bit of returned context, and affect the relevance of the generated response in the end.</p><h3>Context switching is hard for computers, too</h3><p>When you send an agent out on a mission, often their first task is to find all relevant data it has access to. Just as it is with humans if each data source the agent connects to replies with dissimilar and disaggregated responses, there will be cognitive load (though not exactly the same kind) associated with extracting the salient contextual bits from the retrieved content. That takes time/compute, and each little bit adds up in the agentic logic chain. This leads to the conclusion that, just like what’s being discussed for <a href="https://blog.cloudflare.com/code-mode/">MCP</a>, most agentic tools should instead behave more like APIs — isolated functions with known inputs and outputs, tuned to support the needs of different kinds of agents. Heck, we’re even realizing that <a href="https://arxiv.org/html/2501.12372v5">LLMs need context for context</a> — they do much better at connecting the semantic dots, especially when it’s a task like translating natural language into structured syntax, when they have a schema to refer to (RTFM indeed!).</p><h2>7th inning stretch!</h2><p>Now we’ve covered the <a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-evolution-agentic-ai">impact LLMs have had on retrieving and querying for data</a>, as well as how the chat window is maturing into the agentic AI experience. Let’s put the two topics together and see how we can use our newfangled search and retrieval capabilities to improve our results in context engineering. Onwards to <a href="https://www.elastic.co/search-labs/blog/context-engineering-hybrid-search-agentic-ai-accuracy">Part III: The power of hybrid search in context engineering</a>!</p>]]></content:encoded>
        </item>
    </channel>
</rss>