<?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[Matt Nowzari  - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Matt Nowzari  - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/author/matt-nowzari</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/matt-nowzari</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/matt-nowzari.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Mon, 21 Sep 2026 16:59:22 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Know your facts: How Elasticsearch AI Indices let agents skip the reading and keep the answer]]></title>
    <description><![CDATA[A technical walkthrough of precomputing facts into an Elasticsearch AI Index, so agents answer from a single ES|QL query instead of reading whole documents, with fewer tokens and lower latency.]]></description>
    <content:encoded><![CDATA[<p>Pulling whole documents into an agent's context to answer one question is expensive, and the cost compounds with every miss. In this walkthrough, we precompute the facts instead. A Kibana workflow distills each document into a fact-level Knowledge Indicator (KI), stored in an Elasticsearch AI Index and retrieved with a single Elasticsearch Query Language (ES|QL) query. On the same question, an agent answering from KIs reached the same grounded answer using fewer tokens and lower latency than reading raw documents, without loading a single full document into context. These facts are precomputed once and then stored for use by future agents when they encounter similar queries. This is Part 2 of our series on building context with AI indices; <a href="https://www.elastic.co/search-labs/blog/ai-index-building-context-agents">Part 1</a> covered routing agents to the right index.</p><p>Managing context depends on good retrieval. Rather than have agents rediscover the same content for every question, burning tokens by retracing similar steps over and over again, Elastic’s agentic AI capabilities enable us to precompute these details and store them in a structured, searchable form, and they let agents load that context directly. We call this precomputed unit of context a Knowledge Indicator.</p><p>The default agentic retrieval augmented generation (RAG) pattern does the opposite. It retrieves whole documents and dumps them into the model's context at query time, paying for that retrieval in tokens and latency on every single question. Precomputing the answer as a KI moves that cost out of the hot path and does it once.</p><h2>How it works: AI Index, Kibana Workflows, and the query-ki skill</h2><p>Building context through AI indices has three main parts: the AI Index (a special Elasticsearch index where KIs live), Kibana Workflows to create your KIs, and a <code>query-ki</code> skill to help agents directly query KIs using ES|QL: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt201b0bf84c5f5002/6a8fef16ecdaa77015050aa9/unnamed.png" alt="AI Index architecture: Kibana Workflows write Knowledge Indicators, agents read them via the query-ki ES|QL skill" /><p>This blog post is similar to Part 1 in that we’re using the same core building blocks. But in this post, we’re demonstrating a very different use case. Instead of precomputing index metadata, we’re distilling specific <em>facts</em> from our indexed documents that may be used to directly answer agents’ questions without subsequent searches. We've also provided a <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/precomputed-context-technical-walkthrough-part-2/index-facts-kis.ipynb">notebook</a>, if you'd like to create the same KIs yourself, end to end, as you go through these examples. </p><h3>Prerequisites: Elasticsearch Serverless and an LLM API key</h3><p>This tutorial assumes you have:</p><ol><li><p>An Elasticsearch Serverless project. You can <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">sign up for a trial</a> if you don't have one.</p></li><li><p>An API key to access your Elasticsearch project.</p></li><li><p>An OpenAI-compatible large language model (LLM) API key, to access AI indices via Deep Agents scripts.</p></li></ol><h2>Load the BrowseComp-Plus sample corpus into Elasticsearch</h2><p>First, we’ll need some sources. Sources can be data that already exists in your Elasticsearch indices or external data accessed via connectors or ES|QL data sources. 
For this blog, we’ll create an index, <code>browsecomp-plus</code>, to hold our example data, with the following mappings:</p>{
  "browsecomp-plus": {
    "mappings": {
      "_meta": {
        "description": "BrowseComp-Plus corpus: ~100k human-verified web documents (news articles, Wikipedia entries, institutional pages) used as a reasoning-intensive browsing/QA retrieval benchmark. BM25-only index."
      },
      "properties": {
        "docid": {
          "type": "keyword",
          "meta": {
            "description": "Stable corpus document id."
          }
        },
        "text": {
          "type": "text",
          "meta": {
            "description": "Full document text: title, date, and body content."
          }
        },
        "title": {
          "type": "text",
          "meta": {
            "description": "Document title (from the document's front matter)."
          }
        },
        "url": {
          "type": "keyword",
          "meta": {
            "description": "Source URL the document was crawled from."
          }
        }
      }
    }
  }
}<p>and populate it with a small sample of BrowseComp-Plus data via the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk"><code>_bulk</code> API</a>. You can use the supporting <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/precomputed-context-technical-walkthrough-part-2/index-facts-kis.ipynb">notebook</a> to load a sample of this data in your project. </p><h2>Create the AI Index that stores your KIs</h2><p>Just like in Part 1, the first step is to create an AI Index:</p>PUT ai-index-idx-my-corpus<p>This is preconfigured with the same required mappings as we listed out in Part 1. We perform hybrid search here using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> out of the box.</p><h2>How agents retrieve KIs using ES|QL</h2><p>A KI is a document in the AI Index. What makes KIs useful is <em>retrieval</em>, or querying the AI Index to find the right content. This query is packaged within a small, portable skill that’s harness-agnostic and can be run in any agent harness. </p><p>Here’s a sample <code>query-ki</code> skill:</p> ---
name: query-ki
description: &gt;-
  Retrieve Knowledge Indicators (precomputed context) from the Elasticsearch AI
  Index before answering. Use it to find which index to search (routing profiles)
  or to look up precomputed facts without reading source documents. Trigger on any question that depends on specific facts, names, dates, or on choosing a data source.
allowed-tools: esql_query
---

# Retrieving Knowledge Indicators

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

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

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

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

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

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

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

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

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

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


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

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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

**Sources cited:** "Georgia Hirst" and "Georgia Hirst - Wikipedia" Knowledge Indicators from the AI Index.<h2>How much can precomputing facts reduce agent token usage?</h2><p>Both agents had similar conclusions, but they took far different paths to get there: </p><p>The same question and the same grounded answer result in 93% fewer tokens and two tool calls instead of eight, when answering from KIs.</p><p>
</p><p>Baseline (No AI Index)</p><p>With AI Index</p><p>Total tool calls</p><p>8</p><p>2</p><p><code>read_file</code> calls</p><p>2</p><p>1</p><p><code>esql_query</code> calls</p><p>6, all against the <code>browsecomp-plus</code> index</p><p>1, from <code>ai-index-idx-*</code></p><p>Tokens consumed</p><p>386,187</p><p>27,625</p><p>Latency</p><p>44.86s</p><p>15.22s</p><p>Answer</p><p>Grounded, correct</p><p>Grounded, correct</p><p>Exact tool call counts, latency, and answers will vary between runs and using different agents. </p><p>Both agents produced solid, grounded answers. The difference is cost. Querying KIs from the AI Index cut token use by 93% and cut latency by roughly two thirds. Here’s how both paths went, side by side:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt52fac74907997882/6a8ff2a049d4293b02a4fd64/unnamed.png" alt="Agentic RAG tool calls: 8 calls and 386,187 tokens without Knowledge Indicators, 2 calls and 27,625 tokens with them" /><p>That was in-depth, but it shows what AI indices and Workflows do together: the same answer, at a fraction of the tokens.</p><h2>Build precomputed context in Elasticsearch Serverless</h2><p>This walkthrough shows how to generate more sophisticated KIs based on documented facts and query them for knowledge retrieval use cases using Elasticsearch primitives. </p><p>Managing context is critical in agentic search systems. And at its core, context is a retrieval problem. AI indices help you manage context within the Elastic Stack. Try it out in Serverless, and let us know what you think in our <a href="https://discuss.elastic.co/top?period=monthly">Discuss forums</a> or the <code>#stack-kibana</code> channel in our <a href="https://elasticstack.slack.com/signup#/domain-signup">Community Slack</a>.</p><p>We’d also love to hear from you about what use cases you’d like to solve using AI indices.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/agentic-rag-precomputed-facts-ai-index</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/agentic-rag-precomputed-facts-ai-index</guid>
    <category><![CDATA[AI Tools ]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso,Matt Nowzari ,Apostolos Matsagkas,Peter Pišljar]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3e1939e01169bb08/6a8fedbec8ced9f736055f59/1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Building context in Elasticsearch: how AI Indices power smarter agents using fewer tokens]]></title>
    <description><![CDATA[Store AI agent context in an AI Index and power smarter agents using fewer tokens. Step-by-step walkthrough with ES|QL and Kibana Workflows included.]]></description>
    <content:encoded><![CDATA[<p>Agents burn tokens exploring your data before they answer anything, inspecting mappings, sampling documents, probing which index to use. Elasticsearch AI Indices let you precompute that work once and store it as a Knowledge Indicator (KI): a structured, searchable record agents retrieve directly instead of rediscovering from scratch. This walkthrough shows you how to build the full pipeline: create an AI Index, generate routing KIs with a Kibana Workflow, and wire them to any agent harness via a portable ES|QL skill. We've also provided a <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/building-context-technical-walkthrough-part-1">notebook</a> if you'd like to run it yourself end to end as you go through the examples in this blog. This is Part 1 in a blog series providing a technical walkthrough to managing your context through KIs and AI indices. </p><p>While AI indices will be included in future Stack releases, today we recommend using Serverless.</p><h2>How it works: AI Index, Kibana Workflows, and the query-ki skill</h2><p>Building context in this walkthrough has three moving parts:</p><ol><li><p>An <strong>AI Index</strong>, where KIs live. It's a regular Elasticsearch index or data stream with a specific naming convention triggering component templates to configure the right mappings automatically.</p></li><li><p><strong>Kibana Workflows</strong>, which read from your data sources, run an LLM to structure content into KIs, and write those KIs into the AI Index.</p></li><li><p>A <strong><code>query-ki</code></strong><strong> skill</strong>, a skill that queries KIs directly from the AI Index using ES|QL, and that a chat agent can call as a tool.</p></li></ol><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfb84f57dc630f26b/6a7b37268bcd80ea30262453/image4.png" alt="Architecture: Kibana Workflows write KIs to an AI Index, agents read AI agent context via a query-ki skill" /><p></p><h3>Prerequisites</h3><p>This tutorial assumes you have:</p><ol><li><p>An Elasticsearch Serverless project. You can <a href="https://cloud.elastic.co/registration?onboarding_token=search&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">sign up for a trial</a> if you don't have one.</p></li><li><p>An API key to access your Elasticsearch project. </p></li></ol><h2>Create sample indices for agent routing</h2><p>First, we’ll need some sources. Sources can be data that already exists in your Elasticsearch indices, or external data accessed via connectors or ES|QL data sources. </p><p>For this blog, we’ll create some indices with example data. We’ll start with an example using three datasets: <a href="https://huggingface.co/datasets/BeIR/fiqa">BEIR/fiqa</a> (financial), <a href="https://huggingface.co/datasets/BeIR/nfcorpus">beir-nfcorpus</a> (biomedical/nutrition), and <a href="https://huggingface.co/datasets/BeIR/scifact">beir-scifact</a> (scientific fact-checking). Each index is populated with its own <code>_meta.description</code>. </p><p>Here are the mappings we define for these indices: </p>{
  "beir-fiqa": {
    "mappings": {
      "_meta": {
        "description": "FiQA: financial question answering corpus from StackExchange Finance community posts and web crawls. Covers investments, banking, taxes, and market analysis. BM25-only index."
      },
      "properties": {
        "text": {
          "type": "text",
          "meta": {
            "description": "Full document body text."
          }
        },
        "title": {
          "type": "text",
          "meta": {
            "description": "Document or article title."
          }
        }
      }
    }
  }
}


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


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

triggers:
  - type: manual

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

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

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

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

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

            Index name: {{ foreach.item }}

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

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

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

# Retrieving Knowledge Indicators

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

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

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

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

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


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

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


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


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

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

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

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

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

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

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


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

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


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

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

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

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

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

print("--- Answer ---")
print(result["messages"][-1].content)<p>This agent will always query the KI indices to get the answer.</p><h2>How much do Knowledge Indicators reduce agent token usage?</h2><p>Since we’re using agents, the results of these scripts are non-deterministic. However, when I ran these results against the query <code>Is there scientific evidence that vitamin D supplementation prevents cancer?</code>, both agents led to the same conclusion, but they took different paths to get there: </p><p>
</p><p>Baseline (No AI Index)</p><p>With AI Index</p><p>Total tool calls</p><p>12</p><p>8</p><p><code>read_file</code> calls</p><p>0</p><p>2</p><p><code>get_mapping</code> calls</p><p>3</p><p>0</p><p><code>esql_query</code> calls</p><p>9</p><p>6 </p><p>Total indices queried</p><p>2 (bounced between <code>beir-scifact</code> and <code>beir-nfcorpus</code>)</p><p>1 (<code>beir-nfcorpus</code>)</p><p>Tokens consumed</p><p>167,763</p><p>92,711</p><p>Latency</p><p>39.58s</p><p>36.15s</p><p>Answer</p><p>Grounded, correct</p><p>Grounded, correct</p><p>The KI answers were both grounded and correct, but an interesting datapoint is the fact that the overall tool usage and token utilization was smaller when using KIs (latency was roughly equivalent). Here’s how both paths went, side by side: </p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcce26759ded49327/6a7b3983288839683507dea0/image5.png" alt="Agent comparison: 12 tool calls without KIs vs 8 with KIs, 45% fewer tokens, same grounded answer" /><h2>Run the full AI Index pipeline in Serverless</h2><p>This walkthrough offered a deep dive into the build-it-yourself version of AI indices and KIs. In production, you wouldn't hand-write these workflows; a setup agent would generate them, and a feedback loop would refine KIs from the agent's own traces. But the primitives are exactly what you just used: extract KIs with a workflow, store them in an AI Index, and retrieve them with a skill.</p><p>Managing context is key to a relevant and efficient agentic search system, and AI indices are a way to manage this context with the full power of the Elastic stack. Try it out in Serverless and let us know what you think in our <a href="https://discuss.elastic.co/top?period=monthly">Discuss forums</a> or the <code>#stack-kibana</code> channel in our <a href="https://elasticstack.slack.com/signup#/domain-signup">Community Slack</a>! </p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-index-building-context-agents</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-index-building-context-agents</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Kathleen DeRusso,Matt Nowzari ,Apostolos Matsagkas,Peter Pisljar]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt82d8e9495cde2e30/6a7b37020c5aa95ac5f88c47/image3.png" length="0" type="image/png"/>
    <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>