Azure Searchのハイライト機能 & ハイライト機能の癖を回避した実装について


#Azure Searchのハイライト機能 & ハイライト機能の癖を回避した実装について

##はじめに

Azure Searchの検索結果はデフォルトだと、キーワードにヒットした本文をハイライトしてくれません
なのでAzure Searchの検索結果のハイライトを実装したいと思います。

ハイライト機能はやや癖があるので、癖を回避した実装について書きます
　
　
　
＜ハイライトを使用しない場合の検索結果イメージ＞
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524081/e2b58b2e-65f9-8eb7-c8e8-12b07ae0a749.png)



##基礎部分の作成

npm install

```console
npm install express ejs request --save
```

　

index.js ※Azure Searchの設定関連は未入力状態になっています

```javascript
// /////////////////////////////////////////////////////////////////////////////////////
// Azure Searchの設定関連　
// /////////////////////////////////////////////////////////////////////////////////////
// Azure Searchのサービス名
const searchServiceName='';
// Azure Searchのクエリキー
const queryKey=  '';
// Azure Searchのインデクス名
const indexName='';
// コンテンツを保持しているフィールド名(ほとんどの場合はcontent、OCRのマージフィールドを指定する場合はmerged_content)    
const content_field_name = "content";

// /////////////////////////////////////////////////////////////////////////////////////
// 定義関連　
// /////////////////////////////////////////////////////////////////////////////////////
// MVCフレームワークとしてexpressを利用するための設定
var express = require('express');
var app = express();

// ejsをビューに使う為の設定
app.set('view engine', 'ejs');

// 非同期処理における例外発生時にエラーに繋ぐためのラッパー
const asyncwrap = fn => (req, res, next) => fn(req, res, next).catch(next);

// 静的コンテンツを外部ファイル化（publicフォルダ配下を<ROOT>/staticでアクセス許可）
app.use('/static', express.static('public'));

// /////////////////////////////////////////////////////////////////////////////////////
// 検索の初期表示 と 検索実施
// http://localhost:8080/にアクセスしたときの処理
// /////////////////////////////////////////////////////////////////////////////////////
app.get('/', asyncwrap(async (req, res) => {

    // 画面から投げた検索キーワードの設定。　キーワードが投げられていない場合はワイルドカード(*=条件未指定)を設定する
    const q = req.query.keyword || '*';
    console.log(q);

    // キーワードをエンコードして設定
    const query = encodeURIComponent(q) + '&count=true&searchMode=all';
    
    // 検索実行
    var searchResult =  await new Promise((resolve, reject) => {
        const request = require('request');
        request({
            method: 'GET',
            url: `https://${searchServiceName}.search.windows.net/indexes/${indexName}/docs?api-version=2019-05-06&search=${query}`,
            headers: {
                'Content-type': 'application/json',
                'api-key': queryKey
            },
            json: true,
        }, function (err, res, body) {
            if (err) {
                reject(err);
            } else {
                resolve(body);
            }
        });
    });

    // 通常の検索結果とハイライト付の検索結果はそれぞれ異なるフィールドに設定されるので、ハイライトを優先的に取得します
    var result = [];
    for( var i = 0; i < searchResult.value.length; i++ ) {

        // １．タイトル（ファイル名）の取得
        var title = searchResult.value[i].metadata_storage_name;

        // ２．本文の取得
        var body = searchResult.value[i][`${content_field_name}`];

        result.push({'title':title,  'body':body});
    }

    // index.ejsに検索結果を渡して画面描画
    res.render('index', {  searchResult: result, inputKeyword: q});
}));

// /////////////////////////////////////////////////////////////////////////////////////
// 起動
// /////////////////////////////////////////////////////////////////////////////////////
app.listen(8080, () => console.log('access -> http://localhost:8080/'))
```
　




index.ejs ※viewsフォルダ配下に入れましょう

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <link rel="stylesheet" media="all" href="./static/style.css" />
    <title>ハイライト</title>
</head>
<body>

    <% //************************************************ %>
    <% //   検索条件を設定し、検索を行う為のフォームエリア    %>
    <% // *********************************************** %>
    <form style="position:relative; margin-bottom:20px;" action="/">
        <% // キーワード入力 %>
        <input id="keyword" class="keyword" name="keyword" type="text" placeholder="キーワードを入力" value="<%= inputKeyword %>" />
        
        <% // 検索ボタン %>
        <input type="submit" class="submitsearch" value="検索" />
    </form>

    <% //************************************************ %>
    <% //   検索結果表示                                   %>
    <% // *********************************************** %>
    <% for(var i=0;i<searchResult.length;i++){ %>

        <% // ファイル名 %>
        <p class="filename">
            <%= searchResult[i].title %>
        </p>

        <% // 本文 %>
        <p id="a<%= i %>" class="docmain">
            <%- searchResult[i].body %>
        </p>

        <% // 隙間調整 %>
        <br>

    <% } %>
</body>
</html>
```


　



style.css ※publicフォルダ配下に入れましょう

```css

.docmain{
    width:850px;margin: 0 0 0 0;padding: 12px 15px;color: #777;background: #fafafa;border: 1px solid #ddd; position:relative;  left:40px;
}
.keyword{
    outline:0;height:50px;padding:0 10px;left:0;top:0; width:230px;border-radius:2px;background:#eee;
}
.submitsearch{
    width:70px;height:50px; left:260px; top:0;border-radius:2px;background:#7fbfff;color:#fff;font-weight:bold;font-size:16px;border:none;
}
```


　
　


##ハイライトの実装に必要な２つの要素


ハイライトの実装に必要な要素は大きく２つです。

　
###１つ目がハイライト検索を行う為のクエリの作成です

ハイライトの要求はクエリで行う為、クエリに以下の3つのパラメータを追加します。

| パラメータ       | 説明                                  |例                                 |
|-----------------|--------------------------------------|--------------------------------------|
| highlight       | どのフィールドをハイライトしたいか       |本記事ではcontentフィールド等を指定       |
| highlightPreTag | ハイライト開始に指定したいタグ            |本記事ではmarkタグを指定         |
| highlightPostTag| ハイライトの終了に指定したタグ             |本記事では/markタグを指定             |

※markタグは囲った文字列をマーカー調にハイライトしてくれます
　
クエリ周りを以下のように実装します

```javascript

    // Azure Searchのハイライトは以下のようにクエリで指定します。
    // ハイライトの設定（検索結果に含まれるキーワードを<mark>タグで囲うように設定）
    var highlight = `&highlight=${content_field_name}-3&highlightPreTag=<mark>&highlightPostTag=</mark>`;

    // キーワードをエンコードして設定
    const query = encodeURIComponent(q) + '&count=true&searchMode=all' + highlight;
```


　
　
 


###２つ目がハイライトされた検索結果の取得です

ハイライトされた文字列は、本文とは異なるフィールドにマップされる為
以下のような実装が必要になります。

ハイライトされている場合　→　ハイライトフィールドをサマリとして活用
ハイライトされていない場合　→　デフォルトフィールドをサマリとして活用

```javascript

        // ２．本文の取得
        var body = null;
        if(searchResult.value[i]['@search.highlights'] != undefined){
            // ハイライトが存在する場合
            let highlights = searchResult.value[i]['@search.highlights'];
            body =  highlights[`${content_field_name}`].join('\n');
        } else {
            // ハイライトが存在しない場合
            body =  searchResult.value[i][`${content_field_name}`];
        }
```



###ハイライトの動作確認をします

キーワードにマッチする本文がハイライトされていることがわかります
しかしハイライトされているのは一部で本文全体がハイライトされているわけではありません。

　
　
＜ハイライト機能をさくっと実装した際のイメージ＞
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524081/040fc4fe-2781-a0d8-3771-bc213087eb5e.png)




　
　
　
　
　
　



##ハイライト処理のカスタマイズ


###本文全体の中からマッチするワードをハイライトするようにカスタマイズします

一番オーソドックスなやりかたとしては

①『ハイライト文章』からハイライトタグを除去して、『未ハイライト文章』を作成
② 本文から『未ハイライト文章』を検索して、『ハイライト文章』で置換します

①②を繰り返すことで、本文全体の中からマッチするワードがハイライトされるようになります。

```javascript
        // ２．本文の取得
        body =  searchResult.value[i][`${content_field_name}`];
        if(searchResult.value[i]['@search.highlights'] != undefined){
            // ハイライトが存在する場合、本文にハイライトを設定する
            let highlights = searchResult.value[i]['@search.highlights'];
            for( var j = 0; j < highlights[`${content_field_name}`].length; j++ ) {
                // ハイライトを1つずつ取得            
                let highlight = highlights[`${content_field_name}`][j];

                // ハイライトタグを除去して、未ハイライト文章を作成する
                let notHighlight = highlight.replace(/<mark>/g, '').replace(/<\/mark>/g, '');

                // 本文から未ハイライト文章を検索し、ハイライト済文章で置換する
                body = body.replace(notHighlight, highlight);
            }
        }

```

　
　
　


###カスタマイズしたハイライトの動作確認をします

本文全体がハイライトされていることが確認できました

＜ハイライト機能の癖を回避した実装のイメージ＞
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524081/e7c23549-255b-690d-d237-4b99dcec4d1a.png)

　↑ 構造が複雑なPDFファイル等をこの手法でハイライトする場合は
　 構造データが本文フィールドに混じる場合があり、ハイライトフィールドには混じらないことがあるので
　 そういった場合は、もう１工夫が必要です。（上記手法だけだと、置換の為の検索対象が本文に存在しないので、置換がうまくいかない場合があります。）
　 ※現時点ではそうなってしまう状態ですが、バージョンアップでいずれ解消するかもしれません。
　
　
　
　

　
###最終的なindex.jsのソースです


```javascript
// /////////////////////////////////////////////////////////////////////////////////////
// Azure Searchの設定関連　
// /////////////////////////////////////////////////////////////////////////////////////
// Azure Searchのサービス名
const searchServiceName='makineko2-xxxxxxxxxxxxxxxxxxxxxxxxxxx';
// Azure Searchのクエリキー
const queryKey=  '230918Axxxxxxxxxxxxxxxxxxxxxxxxx';
// Azure Searchのインデクス名
const indexName='azureblob-index';
// コンテンツを保持しているフィールド名(ほとんどの場合はcontent、OCRのマージフィールドを指定する場合はmerged_content)    
const content_field_name = "content";

// /////////////////////////////////////////////////////////////////////////////////////
// 定義関連　
// /////////////////////////////////////////////////////////////////////////////////////
// MVCフレームワークとしてexpressを利用するための設定
var express = require('express');
var app = express();

// ejsをビューに使う為の設定
app.set('view engine', 'ejs');

// 非同期処理における例外発生時にエラーに繋ぐためのラッパー
const asyncwrap = fn => (req, res, next) => fn(req, res, next).catch(next);

// 静的コンテンツを外部ファイル化（publicフォルダ配下を<ROOT>/staticでアクセス許可）
app.use('/static', express.static('public'));

// /////////////////////////////////////////////////////////////////////////////////////
// 検索の初期表示 と 検索実施
// http://localhost:8080/にアクセスしたときの処理
// /////////////////////////////////////////////////////////////////////////////////////
app.get('/', asyncwrap(async (req, res) => {

    // 画面から投げた検索キーワードの設定。　キーワードが投げられていない場合はワイルドカード(*=条件未指定)を設定する
    const q = req.query.keyword || '*';
    console.log(q);

    // Azure Searchのハイライトは以下のようにクエリで指定します。
    // ハイライトの設定（検索結果に含まれるキーワードを<mark>タグで囲うように設定）
    var highlight = `&highlight=${content_field_name}-3&highlightPreTag=<mark>&highlightPostTag=</mark>`;

    // キーワードをエンコードして設定
    const query = encodeURIComponent(q) + '&count=true&searchMode=all' + highlight;
    
    // 検索実行
    var searchResult =  await new Promise((resolve, reject) => {
        const request = require('request');
        request({
            method: 'GET',
            url: `https://${searchServiceName}.search.windows.net/indexes/${indexName}/docs?api-version=2019-05-06&search=${query}`,
            headers: {
                'Content-type': 'application/json',
                'api-key': queryKey
            },
            json: true,
        }, function (err, res, body) {
            if (err) {
                reject(err);
            } else {
                resolve(body);
            }
        });
    });

    // 通常の検索結果とハイライト付の検索結果はそれぞれ異なるフィールドに設定されるので、ハイライトを優先的に取得します
    var result = [];
    for( var i = 0; i < searchResult.value.length; i++ ) {

        // １．タイトル（ファイル名）の取得
        var title = searchResult.value[i].metadata_storage_name;

        // ２．本文の取得
        body =  searchResult.value[i][`${content_field_name}`];
        if(searchResult.value[i]['@search.highlights'] != undefined){
            // ハイライトが存在する場合、本文にハイライトを設定する
            let highlights = searchResult.value[i]['@search.highlights'];
            for( var j = 0; j < highlights[`${content_field_name}`].length; j++ ) {
                // ハイライトを1つずつ取得            
                let highlight = highlights[`${content_field_name}`][j];

                // ハイライトタグを除去して、未ハイライト文章を作成する
                let notHighlight = highlight.replace(/<mark>/g, '').replace(/<\/mark>/g, '');

                // 本文から未ハイライト文章を検索し、ハイライト済文章で置換する
                body = body.replace(notHighlight, highlight);
            }
        }
        result.push({'title':title,  'body':body});
    }

    // index.ejsに検索結果を渡して画面描画
    res.render('index', {  searchResult: result, inputKeyword: q});
}));

// /////////////////////////////////////////////////////////////////////////////////////
// 起動
// /////////////////////////////////////////////////////////////////////////////////////
app.listen(8080, () => console.log('access -> http://localhost:8080/'))
```