Elasticsearch での検索結果のハイライト表示

BLOG

1. 前書き

前回に引き続き、ホワイトペーパー「Elasticsearchを使った簡易RAGアプリケーションの作成」(*脚注11)に記載した技術的要素を紹介いたします。

今回は、検索結果のハイライト表示です。

なお、このブログ内に記載しているソースコードおよび Elasticsearch 用のリクエストは、下記のgithub リポジトリでも公開しています。

blogs/2025-04-highlight at main · sios-elastic-tech/blogs
A repository for blog sources. Contribute to sios-elastic-tech/blogs development by creating an account on GitHub.

対象者

  • Elastic Cloud のアカウントを持っている人(トライアルライセンスを含む)
  • Elasticsearch の初心者~中級者

できるようになること

  • Elasticsearch でキーワード検索(またはハイブリッド検索)した結果のハイライト表示を行えるようになる。

前提条件

  • Elastic Cloud (version: 8.17.3)
  • Python 3.13
  • Streamlit 1.42.2
  • Elastic Cloud 上で、日本語検索をするための準備が完了していること。
  • Elastic Cloud 上で、検索対象となるインデックスを作成済であること。
  • Elastic Cloud 上で、検索対象となるインデックスにドキュメントを登録済であること。

※前回の [Bulk API] で、検索対象となるインデックスへのドキュメント登録を行っています。

※あくまでもサンプル用のソースコードなので、実運用で利用する場合には、エラー処理やセキュリティ対策を補足してください。

(2025年03月31日時点の情報を元に記載しています。)

2. ハイライト表示とは?

検索にマッチした部分を強調したり、色を変えたりしてハイライト表示させる機能です。

Elasticsearch では、キーワード検索でマッチしたキーワード部分をハイライト表示することができます。

キーワード検索+密ベクトル検索のハイブリッド検索でも、ハイライト表示することが可能です。

密ベクトル検索のみでは、ハイライト表示できません。

3. ハイライト表示を指示するAPI

Highlighting | Elasticsearch Guide [8.17] | Elastic

検索結果をハイライト表示するには、Search API で、”highlight” 句を指定します。

ハイライト表示を行うリクエストの例:

GET /index_name/_search
{
  "query": {
    "match": {
      "content": "検索文字列"
    }
  },
  "highlight": {
    "pre_tags": ["<strong>"],
    "post_tags": ["</strong>"],
    "fields": {
      "content": {}
    }
  }
}

このリクエストを実行すると、次のようなレスポンスが返ってきます。

{
  ...
  "hits": {
    ...
    "hits": [
      {
        "_index": "index_name",
        "_id": "...",
        ...,
        "highlight": {
          "content": [
            " ... <strong>ヒットした文字列</strong> ..."
          ]
        }
      },
      {
        ...
      }
    ]
  }
}

“highlight” の部分を取得することで、画面にハイライト表示できるようになります。 (*脚注22)

4. ハイブリッド検索用テンプレートの修正

さきほどの内容を踏まえ、ハイライト表示できるよう、ハイブリッド検索用テンプレートを以下のようにします。

変更前のハイブリッド検索用テンプレート(ハイライトに非対応)

PUT _scripts/rrf_search_template
{
  "script": {
    "lang": "mustache",
    "source": """{
      "_source": false,
      "fields": [ "chunk_no", "content" ],
      "size": "{{size}}{{^size}}10{{/size}}",
      "retriever": {
        "rrf": {
          "retrievers": [
            {
              "standard": {
                "query": {
                  "match": {
                    "content": "{{query_string}}"
                  }
                }
              }
            },
            {
              "knn": {
                "field": "text_embedding.predicted_value",
                "k": "{{size}}{{^size}}10{{/size}}",
                "num_candidates": "{{num_candidates}}{{^num_candidates}}100{{/num_candidates}}",
                "query_vector_builder": {
                  "text_embedding": {
                    "model_id": ".multilingual-e5-small_linux-x86_64",
                    "model_text": "{{query_for_vector}}"
                  }
                }
              }
            }
          ],
          "rank_window_size": "{{size}}{{^size}}10{{/size}}",
          "rank_constant": "{{rank_constant}}{{^rank_constant}}20{{/rank_constant}}"
        }
      }
    }
    """
  }
}

変更後のハイブリッド検索用テンプレート(ハイライトに対応)

PUT _scripts/rrf_search_template
{
  "script": {
    "lang": "mustache",
    "source": """{
      "_source": false,
      "fields": [ "chunk_no", "content" ],
      "size": "{{size}}{{^size}}10{{/size}}",
      "retriever": {
        "rrf": {
          "retrievers": [
            {
              "standard": {
                "query": {
                  "match": {
                    "content": "{{query_string}}"
                  }
                }
              }
            },
            {
              "knn": {
                "field": "text_embedding.predicted_value",
                "k": "{{size}}{{^size}}10{{/size}}",
                "num_candidates": "{{num_candidates}}{{^num_candidates}}100{{/num_candidates}}",
                "query_vector_builder": {
                  "text_embedding": {
                    "model_id": ".multilingual-e5-small_linux-x86_64",
                    "model_text": "{{query_for_vector}}"
                  }
                }
              }
            }
          ],
          "rank_window_size": "{{size}}{{^size}}10{{/size}}",
          "rank_constant": "{{rank_constant}}{{^rank_constant}}20{{/rank_constant}}"
        }
      }
      {{#highlight}}
      ,
      "highlight": {
        "fields": {
          "content": {},
          "pre_tags": ["<strong>"],
          "post_tags": ["</strong>"]
        }
      }
      {{/highlight}}
    }
    """
  }
}

検索パラメーター: “highlight” に true を指定することでハイライト表示が行われるようになります。false を渡した場合は、ハイライト表示は行われません。

5. 検索用 API Key の作成

Python から Elastic Cloud にアクセスするには、Access Key が必要です。

前回の [Bulk API] の中で、読み書き用の Access Key を作成しましたが、今回は検索用として、読み取り専用の Access Key を作成します。

(読み書き用の Access Key を使って、読み取ることも可能ですが、予期せぬ事故を回避するため、読み取り専用の Access Key を使った方が安全です。)

読み取り専用の Access Key を作成するには、下記のリクエストを Elastic Cloud の Console から実行します。

(Elastic Cloud の Console の表示方法は、 [Elasticsearch へのインデックスの作成] の Dev Tools に関する説明を参照してください。)

読み取り専用 Access Key の作成リクエスト

POST /_security/api_key
{
   "name": "kakinosuke_read_api_key",
   "role_descriptors": {
     "kakinosuke_read_role": {
       "cluster": ["all"],
       "indices": [
         {
           "names": ["kakinosuke*"],
           "privileges": ["read"]
         }
       ]
     }
   }
}

このリクエストを実行した際に返却される encode された値を、6.1. の .env ファイルに転記します。

6. ソースコードの説明

これらを踏まえて、ハイブリッド検索の結果をハイライト表示できるようにします。

ソースコード全体を見たい方は、下記のgithub リポジトリを参照してください。

blogs/2025-04-highlight at main · sios-elastic-tech/blogs
A repository for blog sources. Contribute to sios-elastic-tech/blogs development by creating an account on GitHub.

以下、ソースコードの抜粋です。

6.1. .env

elasticsearch_endpoint=''
read_api_key_encoded=''

接続に必要な key などを記載します。

elasticsearch_endpoint の取得方法は、2025年03月に公開したブログ [Bulk API] を参照してください。

read_api_key_encoded には、5. で作成した検索用の API Key の encode された値を転記します。

6.2. docker-compose.yml

services:
  highlight_sample_202504:
    build:
      context: ./
      dockerfile: Dockerfile
    container_name: 'highlight_sample_202504'
    volumes:
      - ./:/app
    ports:
      - 8501:8501

6.3. Dockerfile

FROM python:3.13

WORKDIR /app

COPY ./requirements.txt ./

RUN pip install --upgrade pip
RUN pip install -r ./requirements.txt

RUN groupadd -r appgroup && useradd -r -s /usr/sbin/nologin -g appgroup appuser

COPY --chown=appuser:appgroup ./ /app

USER appuser

ENV PYTHONUNBUFFERED 1

ENTRYPOINT ["/bin/sh", "-c", "while :; do sleep 30; done"]

6.4. requirements.txt

elasticsearch==8.17.2
python-dotenv==1.0.1
streamlit==1.42.2

今回のプログラムで必要となるライブラリです。

6.5. src/app.py

...
def search(es_client: Elasticsearch, query: str, show_highlight: str = '') -> list:
    ...
def search_onclick():
    ...
    # 4. 検索結果の表示
    for i, results in enumerate(search_results):
        st.write(f'-- {i+1} --')
        for sub_result in results:
            st.write(f'{sub_result}', unsafe_allow_html=True)

def show_search_ui():
    ...
if __name__ == '__main__':
    ...

検索アプリの本体です。Streamlit から呼び出します。

<strong>~</strong> で囲まれた部分をハイライト表示できるように、unsafe_allow_html=True を指定しています。

6.6. src/elastic/es_consts.py

...

# 検索対象のインデックス(のエイリアス)
SEARCH_INDEX = 'kakinosuke'

# 検索テンプレートのId
SEARCH_TEMPLATE_ID = 'rrf_search_template'

# ドキュメントの本文を格納しているフィールド名
CONTENT_FIELD_NAME = 'content'

Elasticsearch 関連の定数を定義しています。

6.7. src/elastic/es_func.py

...

def create_es_client(elasticsearch_endpoint: str, api_key_encoded: str) -> Optional[Elasticsearch]:
    ...
def initialize_es(my_env: Dict[str, str]) -> Optional[Elasticsearch]:
    ...
def create_search_params(query: str, highlight: bool = False) -> Mapping[str, any]:
    ...
    return {
        QUERY_STRING : query,
        QUERY_FOR_VECTOR : query,
        HIGHLIGHT : highlight
    }

def extract_search_results(search_results, has_highlight: bool = False, field_name: str = CONTENT_FIELD_NAME, max_count: int = MAX_DOCS_COUNT) -> List[str]:
    ...
def es_search_template(es_client: Elasticsearch, search_params: Mapping[str, any], search_index: str = SEARCH_INDEX, search_template_id: str = SEARCH_TEMPLATE_ID, field_name: str = CONTENT_FIELD_NAME, max_count: int = MAX_DOCS_COUNT) -> List[str]:
    ...

Elasticsearch 関連の関数を集めたファイルです。ハイライト表示に対応させます。

7. 実行

Docker 上のコンテナにデプロイしていきます。

(ここでは Docker を利用していますが、Docker は必須ではありません。)

7.1. ビルド

docker compose build

7.2. コンテナの起動

docker compose up -d

7.3. コンテナ内で bash を実行

docker exec -it highlight_sample_202504 /bin/bash

“highlight_sample_202504” はコンテナ名です。

直接、streamlit を実行してもよいのですが、エラーが起きた場合などのハンドリングが小難しくなってしまうので、いったん、bash を経由してから実行します。

7.4. 検索プログラムの開始

さきほどコンテナ内で実行した bash から、下記のコマンドを実行します。

(Streamlitから検索プログラムを実行します)

streamlit run src/app.py

7.5. Webブラウザからのアクセス

Web ブラウザから http://localhost:8501 にアクセスします。

Web サーバーに正常に接続できたら、下記のような画面が表示されます。

ハイライト表示する:いいえ を選択し、検索したい内容に「柿之助 おばあさん」を入力して、【検索】ボタンを押してみます。すると、次のような画面が表示されます。

「柿之助」または「おばあさん」にマッチしたドキュメントが表示されますが、ハイライト表示はされていません。

次に、ハイライト表示する:はい を選択し、検索したい内容に「柿之助 おばあさん」を入力して、【検索】ボタンを押してみます。

「柿」、「助」、「おばあさん」にマッチした部分がハイライト表示されます。

一部は「柿」のみハイライト表示されている箇所があります。

これは、現時点の設定では、

「柿之助」 → 「柿」「助」

と分解されて、それぞれでマッチングが行われているためです。(「之」は助詞と判定され、マッチング対象になっていません。)

これについては次回、「柿之助」をユーザー辞書登録することで改善されます。

8. まとめ

Elasticsearch の Search API で highlight を指定することにより、検索結果をハイライト表示することができました。

次回も、ホワイトペーパー内に記載した技術的要素を取り上げて、紹介したいと思います。

(次回は、ユーザー辞書登録について紹介する予定です。)


  1. https://elastic.sios.jp/whitepaper/
    からホワイトペーパーをダウンロードすることが可能です(E-mail アドレスなどの入力が必要です)。 ↩︎
  2. 注意事項
    下記のような markdown で表現された表が格納されている状態で、表の一部(あるいは表の直前や直後)がマッチしていた場合、highlight の項目には、表の一部のみが返却される場合があります。(表全体が返却されるとは限りません) → highlight で返却される内容は、表が崩れたものとなることがあります。

    | xxx | xxx | xxx |
    |:–|:–|:–:|
    | xxx | xxx | xxx |
    ↩︎