<?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[Alexander Dávila - 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[Alexander Dávila - 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/alexander-davila</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/alexander-davila</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/alexander-davila.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Fri, 18 Sep 2026 18:49:33 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Training LTR models in Elasticsearch with judgement lists based on user behavior data]]></title>
    <description><![CDATA[Learn how to use UBI data to create judgment lists to automate the training of your Learning to Rank (LTR) models in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>A big challenge when using <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr"><em><strong>Learning-to-rank</strong></em></a> models is to create a high-quality <a href="https://www.elastic.co/search-labs/blog/judgment-lists"><em><strong>judgment list</strong></em></a> to train the model on. Traditionally, this process involves a <em><strong>manual</strong></em> evaluation of query-document relevance to assign a grade to each one. This is a slow process that does not scale well and is hard to maintain (imagine having to update a list with hundreds of entries by hand).</p><p>Now, what if we could use real user interactions with our search application to create this training data? Using <a href="https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-insights"><em><strong>UBI</strong></em></a> data lets us do just that. Creating an automatic system that can capture and use our searches, clicks, and other interactions to generate a judgment list. This process can scale and be repeated far more easily than a manual interaction and would tend to yield better results. In this blog, we will explore how we can query UBI data stored in Elasticsearch to calculate meaningful signals to generate a training dataset for an <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction"><em><strong>LTR</strong></em></a> model.</p><p><em><strong>You can find the full experiment </strong></em><a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog.git"><em><strong>here</strong></em></a><em><strong>.</strong></em></p><h2>Why UBI data can be useful to train your LTR model</h2><p>UBI data offers several advantages over a manual annotation:</p><ul><li><p><strong>Volume:</strong> Given that UBI data comes from real interactions, we can collect much more data than we can generate manually. This is assuming we have enough traffic to generate this data, of course.</p></li><li><p><strong>Real User intent:</strong> Traditionally, a manual judgment list comes from an expert evaluation of the available data. On the other hand, UBI data reflects real user behavior. This means we can generate better training data that will improve our search system's accuracy, because it's based on how users actually interact with and find value in your content rather than theoretical assumptions about what should be relevant.</p></li><li><p><strong>Continuous updates:</strong> Judgment lists need to be refreshed over time. If we create them from UBI data, we can have current data that results in updated judgment lists.</p></li><li><p><strong>Cost effectiveness:</strong> Without the overhead of manually creating a judgment list, the process can be repeated efficiently any number of times.</p></li><li><p><strong>Natural query distribution</strong>: UBI data represent real user queries, which can drive deeper changes. For example, do our users use natural language to search in our system? If so, we might want to implement a semantic search or hybrid search approach.</p></li></ul><p>It does come with some warnings, though:</p><ul><li><p><strong>Bias amplification: </strong>Popular content is more likely to receive clicks, just because it gets more exposure. So this might end up amplifying popular items and possibly drowning out better options.</p></li><li><p><strong>Incomplete coverage: </strong>New content lacks any interactions, so it might be difficult for it to be high in the results. Rare queries can also lack sufficient data points to create meaningful training data.</p></li><li><p><strong>Seasonal variations:</strong> If you expect user behaviour to change drastically over time, historical data might not tell you much about what is a good result.</p></li><li><p><strong>Task ambiguity:</strong> A click doesn’t always guarantee that the user found what they were looking for.</p></li></ul><h2>Grades calculation</h2><h3>Grades for LTR training</h3><p>To train LTR models, we need to provide some numerical representation of how relevant a document is for a query. In our implementation, this number is a continuous score going from 0.0 to 5.0+, where higher scores indicate higher relevance.</p><p>To show how this grading system works, consider this manually created example:</p><p>Query</p><p>Document content</p><p>Grade</p><p>Explanation</p><p>"best pizza recipe"</p><p>"Authentic Italian Pizza Dough Recipe with Step-by-Step Photos"</p><p>4.0</p><p>Highly relevant, exactly what the user is looking for </p><p>"best pizza recipe"</p><p>"History of Pizza in Italy"</p><p>1.0</p><p>Somewhat in topic, it is about pizza but is not a recipe</p><p>"best pizza recipe"</p><p>"Quick 15-Minute Pizza Recipe for Beginners"</p><p>3.0</p><p>Relevant, a good result but it maybe misses the mark on being the “best” recipe. </p><p>"best pizza recipe"</p><p>"Car Maintenance Guide"</p><p>0.0</p><p>Not relevant at all, completely unrelated to the query</p><p>As we can see here, the grade is a numerical representation of how relevant a document is to our sample query of “best pizza recipe”. With these scores, our LTR model can learn which documents should be presented higher in the results.</p><p>How to calculate the grades is the core of our training dataset. There are <a href="https://www.elastic.co/search-labs/blog/judgment-lists">multiple approaches</a> to do this, each with its own strengths and weaknesses. For example, we could assign a binary score of 1 for relevant 0 for not relevant or we could just count the number of clicks in a resulting document for each query.</p><p>In this blog post, we will be using a different approach, <em><strong>taking into account the user behavior as our input and calculating a grade number as the output</strong></em>. We will also be correcting bias that could occur from the fact that higher results tend to be more clicked, regardless of the relevancy of the document.</p><h2>Calculating the grades - COEC algorithm</h2><p>The COEC (<a href="https://www.wsdm-conference.org/2010/proceedings/docs/p351.pdf">Clicks over Expected Clicks</a>) algorithm is a methodology for calculating judgment grades from user clicks.
As we stated earlier, users tend to click on higher-positioned results even if the document is not the most relevant to the query; this is called <a href="https://eugeneyan.com/writing/position-bias/">Position Bias</a>. The core idea for using the COEC algorithm is that not all clicks are equally significant; a click on a document at position 10 indicates that the document is much more relevant to the query than a click on a document at position 1. To quote the research paper about the COEC algorithm (linked above):</p><p><em>“It is well known that the click-through rate (CTR) of search results or advertisements decreases significantly depending on the position of the results.”</em></p><p>You can further read about position bias <a href="https://www.researchgate.net/publication/200110550_An_experimental_comparison_of_click_position-bias_models">here</a>.</p><p>To address this with the COEC algorithm, we follow these steps:</p><p><strong>1. Establish position baselines:</strong> We calculate the click-through rate (CTR) for each search position from 1 to 10. This means we determine what percentage of users typically click on position 1, position 2, and so on. This step captures the users’ natural position bias.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    judgment_list = []

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    # Calculate expected clicks for this specific document
    expected_clicks = 0.0

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

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

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

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

    return coec_score<p>This is the function that implements the COEC algorithm. It calculates the CTR for each position, then it compares the actual clicks for a document-query pair, and finally calculates the actual COEC score for each one.</p><h3>generate_judgment_statistics()</h3>def generate_judgment_statistics(df: pd.DataFrame) -&gt; Dict:
    """Generate statistics about the judgment list."""
    stats = {
        'total_judgments': len(df),
        'unique_queries': df['qid'].nunique(),
        'unique_documents': df['docid'].nunique(),
        'grade_distribution': df['grade'].value_counts().to_dict(),
        'avg_judgments_per_query': len(df) / df['qid'].nunique() if df['qid'].nunique() &gt; 0 else 0,
        'queries_with_clicks': len(df[df['grade'] &gt; 1]['qid'].unique()),
        'click_through_rate': len(df[df['grade'] &gt; 1]) / len(df) if len(df) &gt; 0 else 0
    }
    return stats<p>It generates useful statistics from the judgment list, such as total queries, total unique documents, or the grade distribution. This is purely informational and does not change the resulting judgment list.</p><h2>Results and impact</h2><p>If you follow the instructions in the Quick start section, you should see a resulting CSV file containing a judgment list with 320 entries (you can see a <a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog/blob/main/judgment_list.csv">sample output</a> in the repo). With these fields:</p><ul><li><p>qid: unique ID of the query</p></li><li><p>docid: unique identifier for a resulting document</p></li><li><p>grade: the calculated grade for the query-document pair</p></li><li><p>query: The user query</p></li></ul><p> Let’s look at the results for the query “Italian recipes”:</p><p>qid</p><p>docid</p><p>grade</p><p>query</p><p>q1-italian-recipes</p><p>recipe_pasta_basics</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_pizza_margherita</p><p>3.333333</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_risotto_guide</p><p>10.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_french_croissant</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_spanish_paella</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_greek_moussaka</p><p>1.875</p><p>Italian recipes</p><p>We can see from the results that for the query “Italian recipes”:</p><ul><li><p>The risotto recipe is definitely the best result for the query, receiving 10 times more clicks than expected</p></li><li><p>Pizza Margherita is a great result too.</p></li><li><p>The Greek mousaka (surprisingly) is a good result as well and performs better than its position on the results would suggest. This means a few users looking for Italian recipes got interested in this recipe instead. Maybe these users are interested in Mediterranean dishes in general. At the end, what this tells us is that this could be a good result to be shown under the other two ‘better’ matches we discussed above.</p></li></ul><h2>Conclusion</h2><p>Using UBI data lets us automate the training of LTR models, creating high-quality judgment lists from our own users. UBI data provides a big dataset that reflects how our search system is being used.By using the COEC algorithm to generate the grades, we account for inherent bias while at the same time, it reflects what a user considers a better result. The method outlined here can be applied to real use cases to provide a better search experience that evolves with real usage trends.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Elastic Cloud Hosted]]></category>
    <dc:creator><![CDATA[Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt037eb2f4d380fe65/6a170aa67d8d67397170e6e6/762bf09c28829d626d42c2cfadc719e1dd618d1b-1536x1024.png" length="0" type="image/png"/>
    <pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch plugin for UBI: Analyze user data in Kibana]]></title>
    <description><![CDATA[Discover how to capture user behavior data using the Elasticsearch plugin for UBI and build a custom dashboard in Kibana to analyze it. ]]></description>
    <content:encoded><![CDATA[<p>In this article, we’ll show you how to capture and analyze user analytics data using the <strong>UBI</strong> <em>(User Behavior Insights)</em> standard in Elasticsearch.</p><p><em>You can learn more about UBI in </em><a href="https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-insights"><em>this article</em></a><em>.</em></p><p>Data collected with the UBI collector can be used on Kibana to build dashboards that open the window to users’ behavior in our application. In this blog, we will explore how to analyze UBI data in Kibana to gain insights into how our app is being used.</p><h2>Demo set up</h2><p>We can easily reproduce the demo in this blog following these steps:</p><p>1. Clone the repository</p>git clone https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog.git 
cd ubi-dashboard-elasticsearch_blog<p>2. Install required libraries:</p>pip install -r requirements.txt<p>3. Run the setup script. Make sure to have the following environment variables set beforehand</p><ol><li><p>ES_HOST</p></li><li><p>API_KEY</p></li><li><p>KIBANA_HOST</p></li></ol>python setup.py<p>That’s all you need to do. If everything went well, you should see this output from the script execution:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0c568b39867bd357/6a170e3360084be8393c45ff/947a67ef7210fa76f62324a3eadb62a3e10bb887-1600x633.png" alt="" /><p>As we can see the script:</p><ul><li><p>Created two indices with the appropriate mappings</p></li><li><p>Indexed 23 documents to these indices</p></li><li><p>Uploaded some saved objects to Kibana</p></li></ul><p>Now, let’s take a look at what exactly this script did behind the scenes.</p><h2>Understanding the uploaded data</h2><p>First, we put some data in Elasticsearch before creating our visualizations.</p><p>You can reproduce the process manually in Kibana DevTools, copying the mappings and sample data and using the <strong>PUT &lt;index&gt;</strong> and <strong>PUT _bulk</strong> APIs, respectively.</p><h3>Ubi_events index</h3><p>User action data, documents are generated for every click (in this case), and it includes:</p><ul><li><p><strong>application</strong>: The client application that generated the event ("search-ui")</p></li><li><p><strong>action_name</strong>: Type of user action performed ("click")</p></li><li><p><strong>query_id</strong>: Links this event to the corresponding search query session</p></li><li><p><strong>client_id</strong>: A generated, unique ID that represents a user or session without revealing personal data. It is generated instead of using identifiable data like email addresses or usernames. This approach allows us to have privacy advantages such as safe analytics capabilities and secure data sharing without exposing PII, while still having important functionality like session continuity, behavioral analysis, or A/B testing.</p></li><li><p><strong>timestamp</strong>: ISO 8601 formatted timestamp when the event occurred</p></li><li><p><strong>message_type</strong>: Category of the event for processing ("CLICK_THROUGH")</p></li><li><p><strong>message</strong>: Human-readable description of what happened ("Clicked Fahrenheit 451")</p></li><li><p><strong>user_query</strong>: The original search term that led to this event ("fahrenheit")</p></li><li><p><strong>event_attributes</strong>: Nested object containing detailed event context:</p><ul><li><p><strong>object.device</strong>: Device type used by the user ("mobile")</p></li><li><p><strong>object.object_id</strong>: Unique identifier of the clicked item</p></li><li><p><strong>object.description</strong>: Details about the clicked item (book title, date, author)</p></li><li><p><strong>object.position.ordinal</strong>: Ranking position of the item in search results (1st)</p></li><li><p><strong>object.position.page_depth</strong>: Which page of results the item appeared on (1st page)</p></li><li><p><strong>object.user.ip</strong>: User's IP address</p></li><li><p><strong>object.user.city/region/country</strong>: Geographic location data</p></li><li><p><strong>object.user.location</strong>: Precise latitude/longitude coordinates</p></li></ul></li></ul><p>Sample document:</p>       {
         "application": "search-ui",
         "action_name": "click",
         "query_id": "2dd48446-7ca8-4510-89f4-2ebb67ed240b",
         "client_id": "8c1915fe-8ee0-4487-b801-3b1d67c25cf6",
         "timestamp": "2025-07-30T14:25:52.698Z",
         "message_type": "CLICK_THROUGH",
         "message": "Clicked Fahrenheit 451",
         "user_query": "fahrenheit",
         "event_attributes": {
           "object": {
             "device": "mobile",
             "object_id": "ZwoTM5gBPJ218VOaBpj4",
             "description": "Fahrenheit 451(1953-10-15) by Ray Bradbury",
             "position": {
               "ordinal": 1,
               "page_depth": 1
             },
             "user": {
               "ip": "192.168.1.100",
               "city": "New York",
               "region": "New York",
               "country": "United States",
               "location": {
                 "lat": 40.7128,
                 "lon": -74.006
               }
             }
           }
         }
       }<p>You can download the index mappings <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/index_mappings/ubi_events-mappings.json">here</a></p><h3>Ubi_queries index</h3><p>Search data includes data relevant to each search executed by the users:</p><ul><li><p><strong>query_response_id</strong>: Unique identifier for this specific query response instance</p></li><li><p><strong>user_query</strong>: The original search term entered by the user ("fahrenheit")</p></li><li><p><strong>query_id</strong>: Unique identifier for the search query session</p></li><li><p><strong>query_response_object_ids</strong>: Array of object IDs that were returned as search results (["3", "9"])</p></li><li><p><strong>query</strong>: The complete Elasticsearch query object in JSON format, including search parameters, fields to search, result size, sorting, and metadata</p></li><li><p><strong>client_id</strong>: A generated unique ID that represents a user or session without revealing personal data. It is generated instead of using identifiable data like email addresses or usernames. This approach allows us to have privacy advantages such as safe analytics capabilities and secure data sharing without exposing PII, while still having important functionality like session continuity, behavioral analysis, or A/B testing.</p></li><li><p><strong>timestamp</strong>: Unix timestamp in milliseconds when the query was executed (1753885225098)</p></li></ul><p>Sample document:</p>    {
         "query_response_id": "03e8af3e-8725-49d9-99ad-36bf2a8e96d1",
         "user_query": "fahrenheit",
         "query_id": "f8b2f5bc-cb3c-49d4-86bc-19212a782ba7",
         "query_response_object_ids": [
           "3",
           "9"
         ],
         "query": """{"from":0,"size":20,"query":{"multi_match":{"query":"fahrenheit","fields":["author^1.0","name^1.0"]}},"_source":{"includes":["name","author","image_url","url","price","release_date"],"excludes":[]},"sort":[{"_score":{"order":"desc"}}],"ext":{"query_id":"f8b2f5bc-cb3c-49d4-86bc-19212a782ba7","user_query":"fahrenheit","client_id":"8c1915fe-8ee0-4487-b801-3b1d67c25cf6","object_id_field":null,"query_attributes":{}}}""",
         "client_id": "8c1915fe-8ee0-4487-b801-3b1d67c25cf6",
         "timestamp": 1753885225098
       }<p>You can download the index mappings <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/index_mappings/ubi_queries-mappings.json">here</a><strong>.</strong></p><h3>Sample data</h3><p>We can use the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk">_bulk API</a> to index <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/sample_documents/bulk_index.ndjson">some sample</a> data in both indices</p><p>This will create 6 documents in the <strong>ubi_queries </strong>index and 16 in the <strong>ubi_events</strong> index.</p><h3>Dashboard object</h3><p>Before going into details of the visualizations used in this example <a href="https://github.com/Alex1795/ubi-dashboard-elasticsearch_blog/blob/main/dashboards/web_analytics_dashboard.ndjson">here</a>, you can download the Saved Object of the full example dashboard and <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/saved-objects#saved-objects-import">import</a> it into your Kibana instance. This dashboard explores the most searched terms, when searches and events took place, and where they come from (in a map).</p><h2>Visualize Insights</h2><p>We are going to create a Kibana dashboard to analyze the most common metrics leveraging <a href="https://www.elastic.co/docs/explore-analyze/visualize/lens">Kibana Lens</a>. For a reference on available visualizations, visit <a href="https://www.elastic.co/docs/explore-analyze/visualize/supported-chart-types">this</a> page.</p><h3>Ubi_events</h3><p>We will start with some simple Metric visualizations created with Lens: <strong>Total events:</strong> Counts how many events were triggered in the timeframe. Uses a simple count of the documents in the index, denoted by <strong># Records</strong> in the field list.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69d672ece8a13874/6a170e35286714284693e3be/2d06ff89f2cf43ee4102e9e01079ff63754e99fd-502x182.png" alt="" /><p><strong>Event actions: </strong>Counts actions by <code>action_name</code>. This is a simple count of documents split by <code>action_name.keyword</code>. In our sample data, we have two types of actions:</p><ul><li><p>click: Generated when a user clicks in the book link</p></li><li><p>search_input: Generated when a user enters text in the search box (debounce 300ms)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda49e8d513f0166c/6a170e366f7f04c66c9148fb/3735206d066f62990159c0777243f8b3d0703b6b-1188x186.png" alt="" /><p>Now on table visualizations:</p><p><strong>Top clicks: </strong>A table with a count of the number of events split by the query they come from. It uses a Top values function on the <code>user_query.keyword</code>. This can give us visibility on which queries generate more interactions on our webpage.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt81925167886ba5e3/6a170e37b339d58be776a06c/87aba3bbb9cf32aefa2aa126ba8edfb6bb456ae4-223x296.png" alt="" /><p>Finally, some other visualizations:</p><p><strong>Device types:</strong> This visualization breaks down the percentage of events by the device they come from. The device can be one of three categories: Desktop, mobile, or tablet. This visualization is a pie that uses the top values of <code>event_attributes.object.device.keyword,</code> and can give us insights into which type of devices our users have. This can generate alerts if we detect an unexpected, sudden fall of events on a specific type of device, as this might indicate that a recent change in our app resulted in errors when accessing it from a device.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt695213931fbb528c/6a170e397d8d6741e770e7c2/b9e8d1a07fcebf73b3107bcbc728b77a5d30e6a6-846x484.png" alt="" /><p><strong>Events map:</strong> A <a href="https://www.elastic.co/docs/explore-analyze/visualize/maps/maps-getting-started">map visualization</a> that shows where the events are coming from, which allows us to see the geographical distribution of our users. Right now, this shows where individual documents come from, but this can also be used to see the density of users with a heatmap, for example.</p><p>This particular visualization can provide very interesting insights when used with different filters. For example, we can see where different search terms are coming from or where most of our clicks are originating. This can be useful information for making decisions on localization efforts or establishing differences across local markets. The map uses the location at <code>event_attributes.object.user.location</code>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1fbe510ac15fc06a/6a170e3b4a531b59a036a9fb/ca87fe7d5f84b9c38d9787c899c55bd48f2af9f5-1600x759.png" alt="" /><p><strong>UBI Events: </strong>A saved search with the latest UBI events documents</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0101bc86e2a11639/6a170e3dd7c022575bde6545/73c94c7f0d238149851e066b4b53d16dff9e2e74-1309x379.png" alt="" /><h3>Ubi_queries</h3><p>Here we have visualizations from this index:</p><p><strong>Total queries:</strong> A simple document count of the index to show how many queries have been received in total. This shows the big picture and answers the question of how many total queries we had in the selected time window.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2f0e83ff0e87e104/6a170e3e286714171893e3c2/10af8288eaa278aff04625dab4d4a2c86c9ebf6d-218x90.png" alt="" /><p><strong>Unique clients: </strong>A <code>unique_count</code> of the field <code>client_id</code> to show how many different clients have used our website.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt30a15247e689a3c3/6a170e3f509168aec9e1bb82/baf7b29670f9392619dffef4800cb50efb3a0578-249x95.png" alt="" /><p><strong>Top queries (tag cloud):</strong> A Tag cloud of the top 5 most searched terms. This visualization uses the field <code>user_query.keyword</code> and allows us to easily see the main terms that our users are looking for.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt31218b03715ea15c/6a170e41e8fbce688139fd0b/833844afba1a5120ba2e83eade8f83954c34ba82-790x327.png" alt="" /><p><strong>Queries over time: </strong>A line chart of queries per hour, which uses a simple count metric in a horizontal axis of the field timestamp</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3c0731a036283fad/6a170e42a6c2b9839ce79798/01d79d29cd63bbc3be273ed3f55f49cc01859f64-873x182.png" alt="" /><p><strong>Query terms over time:</strong> Similar to the last one, but broken down by the <code>user_query.keyword</code>. This chart can show how many different terms are searched over time.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcb263c69b0e87ecb/6a170e4467045b7cac45c288/afb1c9c19919ca9117b5dc26c6f938c425261ae9-844x209.png" alt="" /><p><strong>Top queries:</strong> A Top values table showing how many times a term was searched. It uses the <code>user_query.keyword</code> field.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfb8f5d3bf9cb7943/6a170e45a292995c17d010b6/19854feaa749b528e328e1e427aa285b2abd16b6-384x295.png" alt="" /><p><strong>Client queries:</strong> A Top values table of the <code>client_id</code> field that counts the total queries and unique queries per client.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9201d14e53e3666a/6a170e4760084b756b3c4608/af18679364d92263913febf59c792957e41e5292-382x291.png" alt="" /><p><strong>No result queries:</strong> A Top value table that shows the top <code>query_terms</code> that didn’t match any document, and a Unique Count of the field client_id. This can be very useful to determine what products our website is lacking. For example, in an e-commerce book store, seeing regular searches for a particular book title could lead us to buy copies to sell. Alternatively, it can also indicate shortcomings in our search implementation, for example, if people are using question-based searches that align better with semantic search approaches.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc59e01f67e2899a1/6a170e48dc55de5d75e00e72/75dc6908767eb86ef2e2b8ac7e25c57b55f722ee-746x574.png" alt="" /><p>Here you can see the full dashboard:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e197f27fb09906b/6a170e4a0e2e49c69541a1b3/39a00886ed753e12e8f2966b509b08afc45b4389-1600x913.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt057ec5248cd60aca/6a170e4c8b73cb5b3b18a0ce/2eb980f0123d6464b94599bc01f61de542f8b8f9-1600x412.png" alt="" /><h2>Analysis of sample data</h2><p>In our dashboard, we can get some insights:</p><ul><li><p>Traffic is coming from 3 different cities in the US</p></li><li><p>Most of our users access our website from a desktop device, but we have a sizable number of users using a mobile device and even some using a tablet.</p></li><li><p>We can see the top query is “asimov,” but at the same time, we do not have any results. This might be a good indicator of what products should be prioritized for stock acquisition.</p></li></ul><p>To further this analysis, we could use Kibana’s Machine Learning capabilities to understand and predict behaviours on our website. Going even one step further, we can create alerts based on these behaviors using the different available connectors.</p><p>From a search relevance perspective, user behavior is a useful input for relevance engineering tools like <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR</a>.</p><h2>Conclusion</h2><p>Data collected by the UBI collector can be easily used to have a better understanding of our users. The resulting dashboard becomes a live pulse of what our users are searching for and can point to data gaps to drive improvements in our search engine.</p><p><strong>Note:</strong> The o19s User Behavior Insights (UBI) plugin mentioned in this article is a third-party, community-maintained plugin and is not officially supported by Elastic. For questions or issues related to this plugin, please refer to the o19s UBI project repository at <a href="https://github.com/o19s/ubi">https://github.com/o19s/ubi</a>. </p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-data-kibana</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-data-kibana</guid>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Eduard Martin,Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d7e4a5bbd9427c2/6a170e4d0e2e4905b641a1b7/04f1738a38cead88c9a67b0f863171b4b43010ab-1600x913.png" length="0" type="image/png"/>
    <pubDate>Fri, 26 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[RAG with a map: Multimodal + geospatial in Elasticsearch]]></title>
    <description><![CDATA[Combining multimodal RAG capabilities with core Elasticsearch features such as geospatial queries and lexical search.]]></description>
    <content:encoded><![CDATA[<p>When working with RAG systems, Elasticsearch offers a significant advantage by combining a <a href="https://www.elastic.co/what-is/hybrid-search">hybrid search</a> (vector search + traditional text search) approach with hard filters to ensure the retrieved data is relevant to the user query. This makes models less prone to hallucination and, in general, improves your system quality. In this blog, we will explore how we can take a multimodal RAG system to the next level using <a href="https://www.elastic.co/docs/explore-analyze/geospatial-analysis">Elastic’s geospatial search features</a>.</p><h2>Getting started</h2><p><em>You can find the full source code used in this blog </em><a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch"><em>here</em></a><em>.</em></p><h3>Prerequisites</h3><ul><li><p>Elasticsearch 8.0.0+</p></li><li><p>Ollama</p><ul><li><p>cogito:3b model</p></li></ul></li><li><p>Python 3.8+</p></li><li><p>Python dependencies:</p><ul><li><p>elasticsearch</p></li><li><p>elasticsearch-dsl</p></li><li><p>ollama</p></li><li><p>clip_processor</p><ul><li><p>torch</p></li><li><p>transformers</p></li><li><p>PIL</p></li></ul></li><li><p>streamlit</p></li><li><p>json</p></li><li><p>os</p></li><li><p>typing</p></li></ul></li></ul><h3>Setup</h3><p>1. Clone the repository:</p>git clone https://github.com/Alex1795/multimodal_RAG_elasticsearch.git  
cd multimodal_RAG_elasticsearch<p>2. Install required libraries:</p>pip install -r requirements.txt<p>3. Install and set up Ollama:</p>Download from https://ollama.com/download/# Download and start the required model
ollama pull cogito:3b
ollama run cogito:3b<p>4. Configure Elasticsearch</p><ul><li><p>Make sure to have the following environment variables set:</p><ul><li><p>ES_INDEX</p></li><li><p>ES_HOST</p></li><li><p>ES_API_KEY</p></li></ul></li><li><p>Set the index mapping on Elasticsearch, put special attention to the geolocation and embeddings definition:</p></li></ul>PUT mmrag_blog
{  
  "mappings": {  
    "properties": {  
      "title": {  
        "type": "text",  
        "analyzer": "standard"  
      },  
      "geolocation": {  
        "type": "geo_point"  
      },  
      "image_filename": {  
        "type": "keyword"  
      },  
      "generated_description": {  
        "type": "text",  
        "analyzer": "standard"  
      },  
      "description": {  
        "type": "text",  
        "analyzer": "standard"  
      },  
      "text_embedding": {  
        "type": "dense_vector",  
        "dims": 512,  
        "index": true,  
        "similarity": "cosine"  
      },  
      "image_embedding": {  
        "type": "dense_vector",  
        "dims": 512,  
        "index": true,  
        "similarity": "cosine"  
      },  
      "photo_id": {  
        "type": "keyword"  
      }  
    }  
  }  
}<h3>Run the application</h3><p>1. Generate and index images’ embeddings and metadata:</p>python upload_documents.py<p>This file runs the data indexing pipeline. It processes the image metadata files and enriches them with multimodal embeddings (from the description and the image itself using the CLIP model). Finally, it uploads the documents to Elasticsearch. After executing this command, you should see the <strong>mmrag_blog </strong>index in Elasticsearch with the image metadata, geolocation, and image and text embeddings. </p><p>2. Run the streamlit app and use the UI in your browser with:</p>streamlit run streamlit_app.py #comment<p>After executing this command, you can see the project webpage at <a href="http://localhost:8501/">http://localhost:8501</a>.</p><p>The webpage is the interface for the RAG application. From there, you can ask a question, and then the assistant will extract the appropriate parameters from your question, run an RRF search on Elasticsearch to find related pictures, and formulate a response. It will also show some pictures from the results. </p><h2>Implementation overview</h2><p>To demonstrate Elastic’s RAG capabilities, we will build an assistant that can answer questions about national parks using relevant data. The search combines 4 approaches with data inferred from the user’s text query:</p><ul><li><p>Image vector search</p></li><li><p>Text vector search</p></li><li><p>Lexical text search</p></li><li><p>Geospatial filtering</p></li></ul><p>This allows our assistant to answer questions that are relevant and focused on what the user needs.</p><p>Now, how can the geospatial filter improve the assistant results? For example, if the user asks, “Where can I find canyons near Salt Lake City?” Without a geospatial filter, the assistant might suggest:</p><ul><li><p>Canyonlands National Park - Utah</p></li><li><p>Grand Canyon National Park - Arizona</p></li></ul><p>However, since we know the user is specifically looking for sites near Salt Lake City, it makes sense to look for answers in Utah. Therefore, the correct option is Canyonlands National Park only.</p><p>The implementation in this blog uses a <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-distance-query">geo_distance query</a> to be able to find results (the picture’s <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/geo-point">geopoint</a>) in a particular national park area. We are also using <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/geo-shape">geoshapes</a> to draw the parks’ areas.</p><p>However, Elastic capabilities with geo queries go well beyond that:</p><ul><li><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-bounding-box-query">geo_bounding_box query</a>: Finds documents (geopoints or geoshapes) that intersect a specified rectangle</p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-grid-query">geo_grid query</a>: Finds documents that intersect a specified geohash, map tile, or H3 bin</p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-shape-query">geo_shape query</a>: Finds documents that are related (intersects, is contained by, is within, or a disjoint operation) to the specified geoshape</p></li></ul><h2>Dataset</h2><p>We will use <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/tree/main/images_metadata">geotagged pictures</a> from national parks obtained from <a href="https://www.flickr.com/groups/335743@N24/pool/">Flickr</a>. We augment these pictures with a description and vectorize both the image and description using the <a href="https://huggingface.co/openai/clip-vit-base-patch32">openai/clip-vit-base-patch32</a> model:</p><p>We merge these embeddings with the images’ metadata, and at the end, we get a document that looks like this:</p>{
         "title": "Spa Geyser in Yellowstone National Park on a sunny day",
         "geolocation": {
           "lat": 44.45899722222222,
           "lon": -110.82573611111111
         },
         "image_filename": "52631363114_Spa_Geyser_in_Yellowstone_National_Park_on_a_sunny.jpg",
         "generated_description": "A small geyser releases steady streams of hot water and steam into the air on a clear sunny day. Colorful mineral deposits surround the thermal feature, creating vibrant orange and yellow formations. The active geothermal vent demonstrates the underground volcanic activity that powers these natural fountains.",
         "text_embedding": [
           0.02323250286281109,
           …
           -0.17811810970306396
         ],
         "image_embedding": [
           -0.22548234462738037,
		…
		-0.040389999747276306
         ]

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

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

Available National Parks:
{parks_info}

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

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

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

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

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

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

    embedding = create_text_embedding(text_query).tolist()

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

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

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

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

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

    #print(s.to_dict())

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

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

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

Original User Query: {original_query}

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

Search results: {results_text}

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

This location offers incredible opportunities to see mountains up close and personal - whether you're hiking, camping, or simply taking in the breathtaking scenery. Would you like more specific information about this park?<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt47eb5192c8735187/6a170afa14b2701ce5e3c619/29da726ea6b572467a0ab2c98b3fab3ab30504bb-498x881.png" alt="" /><h3>Web application</h3><p>A <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/streamlit_app.py">front-end based on Streamlit</a> handles the user input, runs the search pipeline to obtain the LLM final response, and displays images from the search results with their descriptions.</p><p>You can find the application source code and instructions <a href="https://github.com/Alex1795/multimodal_RAG_elasticsearch/blob/main/README.md"><em><strong>here</strong></em></a>.</p><h3>Multimodal RAG and geospatial search usage example</h3><p><strong>Query: </strong>Any places to ride a boat in Oregon?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbb53db2c36ec4a20/6a170afc1949f752b0e7aa32/9ad8f3bf4d2ffb5cf9e644afb77a8143881ad493-741x369.png" alt="" /><p>Here, the LLM extracted these parameters:</p>{
 'context_search': 'boat ride', 
 'distance_km': 100, 
 'location_type': 'Oregon', 
 'reference_location': None, 
 'relevant_parks': ['crater_lake_national_park']
}<p>And the search centered on Crater Lake National Park, so the response comes only from this national park in Oregon. This way, the system makes sure that it responds to the user under the given constraints and does not mention other parks where a boat ride is possible, but are not in Oregon.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4af44b69b02fa51c/6a170afe7d8d67249670e706/a055cb82649b0eb1d9f8c26bdd7a3b8804c8c09b-715x619.png" alt="" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd6672c7f4bda9d50/6a170affb339d566a6769fb6/7e2c54291c33b33c7cd90ee2d7a64ca6b215074f-748x596.png" alt="" /><h2>Conclusion</h2><p>In this article, we saw how integrating multimodal RAG capabilities with Elasticsearch's robust geospatial features significantly enhances the relevance and accuracy of search results in RAG systems. By combining image and text vector search with lexical search and precise geo-filtering, the system can provide highly contextualized answers. This approach not only minimizes hallucinations but also leverages Elasticsearch's diverse geo-query options and Kibana's visualization tools to deliver a comprehensive and user-centric search experience.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/multimodal-rag-elasticsearch-geospatial</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/multimodal-rag-elasticsearch-geospatial</guid>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt054e0e3f92147965/6a170b01961e694ad1c4cf2a/1a50013786b02c4a3a4a2912e279edd9f9d0a44d-1000x628.png" length="0" type="image/png"/>
    <pubDate>Wed, 10 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>