BLOG

  • トップ
  • ブログ
  • Apache Solr の日本語シノニム検索とハイライト 最新事情(2023年版)

Apache Solr の日本語シノニム検索とハイライト 最新事情(2023年版)

著者: 関口宏司

     

投稿日: 2023年01月04日  更新日: 2023年08月05日

 
  • Solr

昨年後半はおかげさまで、セマンティックサーチで大忙しであった。そんなことを振り返っていた、弊社終業日である12月29日のこと。某検索エンジンのリプレース案件で、現在使われているシノニム辞書のSolrへの取り込みを検討していたときであった。

「Solr 9では、Solr 8のときに常用していたパラメーターの組み合わせでは、シノニムとハイライト検索が正しく動作しない」

という情報がもたらされた。簡単な調査では、Solr 9で取り込まれたLUCENE-9207の変更が関係しているらしいことがわかった。情報源は緻密な技術調査を得意とすることで知られる弊社サポート担当のN。彼が正しく動作しないといったらそれを疑う余地はない。

セマンティックサーチを多用する本案件では、Solr 9の利用が必須である。そしてこのプロジェクトは元々スケジュールが押していて、年明けの始業日から調査していたのでは到底間に合わない。

「年末年始に調査する以外にない」

元々別件の作業を予定していたが、優先順位を変更するしかなかった。ついでなので調査結果を記事にすることにした。本稿はその調査結果を報告するものである。

シノニムとハイライトを考慮した日本語検索の、これまでの標準的な設定

さて、Solr 8.11以前の、(よく使われる人気の検索機能である)シノニムとハイライトを考慮した場合の日本語検索における「常識」とも言える設定は、以下の通りであった。

  • autoGeneratePhraseQueries=true
  • sow=true

本論に入る前に、まずはこれらのパラメーターの意味を復習しておこう。

autoGeneratePhraseQueries

autoGeneratePhraseQueriesは、managed-schemaファイルに、以下のように設定する。

  <!-- 形態素解析器の場合 -->
  <fieldType name="text_ja_exact" class="solr.TextField" autoGeneratePhraseQueries="true" positionIncrementGap="100">
    <analyzer>
      <tokenizer name="japanese" mode="normal" userDictionary="userdict_ja.txt"/>
      <filter name="lowercase"/>
    </analyzer>
  </fieldType>

  <fieldType name="text_ja_exact_np" class="solr.TextField" autoGeneratePhraseQueries="false" positionIncrementGap="100">
    <analyzer>
      <tokenizer name="japanese" mode="normal" userDictionary="userdict_ja.txt"/>
      <filter name="lowercase"/>
    </analyzer>
  </fieldType>

  <!-- 2-gramの場合 -->
  <fieldType name="text_2g_exact" class="solr.TextField" autoGeneratePhraseQueries="true" positionIncrementGap="100">
    <analyzer>
      <tokenizer name="nGram" maxGramSize="2" minGramSize="2"/>
      <filter name="lowercase"/>
    </analyzer>
  </fieldType>

  <fieldType name="text_2g_exact_np" class="solr.TextField" autoGeneratePhraseQueries="false" positionIncrementGap="100">
    <analyzer>
      <tokenizer name="nGram" maxGramSize="2" minGramSize="2"/>
      <filter name="lowercase"/>
    </analyzer>
  </fieldType>

上記XMLの上半分が形態素解析器の場合で下半分が2-gramの設定となっている。autoGeneratePhraseQueriesパラメーターについて語る場合にこの両者で違いはないので、以降しばらくは形態素解析器の方だけ見ていくことにする。すると2つのフィールド型が定義されているのがわかり、ひとつはtext_ja_exactでautoGeneratePhraseQueries="true"となっており、もうひとつはtext_ja_exact_npでautoGeneratePhraseQueries="false"と定義されている。

autoGeneratePhraseQueriesはその名の通り、「このフィールド型で検索されるキーワードは、(Analyzerで解析されたときに複数単語となった場合)自動的にフレーズ検索(PhraseQuery)として展開される」という意味だ。したがって、上記フィールド型名の接尾辞 _np は「Non-phrase」(フレーズではない)を表していると捉えて欲しい。ちなみに、 _exact というのは、それが付いていないバージョンとの対比で、「シノニムが適用されていないフィールド型」でありしたがって「ユーザー入力のキーワードを(シノニム展開せず)『正確に』検索するフィールド型」であることを示している。

さて、これらのフィールド型が適用されたtitle_exactとtitle_exact_npフィールドにて、「内閣総理大臣」を検索してそのときのデバッグクエリでフレーズ検索が展開されているか確認してみよう。

まずはtitle_exact_npフィールドにて、「内閣総理大臣」を検索してデバッグクエリを確認する。すると予想通り、次のようになった。

# ポート番号が10983となっていることに注意。
# 本稿ではSolr 8.11とSolr 9.1を同時に立ち上げて比較しているので、9.1はSolr標準のポート番号8983、8.11では10983を使用している。
# したがって、以下はSolr 8.11の結果である(ただし、この単純な結果はSolr 9.1でも変わらない)。
curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title_exact_np%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3' |jq '.debug.parsedquery'
"+(title_exact_np:内閣 title_exact_np:総理 title_exact_np:大臣)~3"

「内閣総理大臣」は形態素解析器によって「内閣/総理/大臣」と3つの単語に分割される。そしてtitle_exact_npフィールドにて、これら3つの単語がOR検索されている。ただし、 ~3 により「OR検索句のうち少なくとも3つの句がヒットしているもの」が探されている。このOR検索では「内閣/総理/大臣」という3つの単語がOR検索され、ただしこの3つのBooleanクエリ句が全てヒットしていないといけないので、これは実質的にAND検索である。

ちなみに、上の検索のパラメーター q.op=OR (デフォルトクエリオペレーター;デフォルトはOR)をANDに変えると、次のようになる。

curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=AND&q=title_exact_np%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3' |jq '.debug.parsedquery'
"+(+(+title_exact_np:内閣 +title_exact_np:総理 +title_exact_np:大臣))"

+title_exact_np:内閣 のように、Booleanクエリの各句の先頭に + が付いているのがわかる。これは AND 検索されていることを示している。そしてANDオペレーターを使うと、 ~n のような条件は付かなくなる。これは「少なくともn個の句がヒットしているもの」を探すということなので、ORクエリでしか意味を持たないからである。このnはmmパラメーター(mmはMinimum should Matchから来ている)で変えられる。絶対値指定の他、 mm=100% のようにパーセンテージ指定ができる(デフォルトは mm=100% )。試しに mm=67% (OR句のうち、少なくとも2/3のヒットを求めることを表す)と指定した場合、以下のようになる。

# '%' をURLエンコードすると%25になる。
curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title_exact_np%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3&mm=67%25' |jq '.debug.parsedquery'
"+(title_exact_np:内閣 title_exact_np:総理 title_exact_np:大臣)~2"

狙い通り、 ~2 となり、「3つのOR検索句のうち少なくとも2つの句がヒットしているもの」を探すように指示できた。なお、本稿の以降ではこれらのパラメーターはデフォルトの q.op=OR&mm=100% で固定する。

では同じことをtitle_exactフィールドで試してみよう。すると、次のようになる。

curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title_exact%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3' |jq '.debug.parsedquery'
"+(title_exact:内閣 title_exact:総理 title_exact:大臣)~3"

autoGeneratePhraseQueries="true"としているにもかかわらず、フレーズ検索になっていない。そこで、sowパラメーターに注意を向けなければならないわけだ。

sow

sowはSplit On Whitespaceから来ており、これをtrueに設定すると、「ユーザー入力のクエリ文字列を、クエリパーサーにてホワイトスペース単位で区切ってAnalyzeする」ようになる。デフォルトはfalseでこの場合、日本語のように明示的にフレーズクエリを書かない(明示的にフレーズクエリを指定するとはすなわち "" でくくることをいう)と、autoGeneratePhraseQueries="true"のフィールドでもフレーズクエリにならなくなってしまう。

したがって、上記最後のcurlコマンドを、次のように明示的なフレーズクエリにしたり、

curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title_exact%3A"%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3"' |jq '.debug.parsedquery'
"+PhraseQuery(title_exact:\"内閣 総理 大臣\")"

あるいは、sow=trueと指定することで期待通りフレーズクエリが発行されるようになる。

curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title_exact%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3&sow=true' |jq '.debug.parsedquery'
"+PhraseQuery(title_exact:\"内閣 総理 大臣\")"

以上の設定はシノニムとハイライトを考慮した場合でも有効であったので、つまり以下のパラメーターの組み合わせが得られ、これが冒頭で話した「シノニムとハイライトを考慮した場合の日本語検索における『常識』」であった。年末の終業日にSolr 9.1でこの常識を覆す結果を見るまでは・・・。

  • autoGeneratePhraseQueries=true
  • sow=true

では次にこれらのパラメーターを、シノニムを適用したフィールド( _exact が付かないテキストフィールド)に適用してデバッグクエリや検索結果を確認していく。

シノニムフィールドで確認

_exact が付かないテキストフィールドである、シノニムが設定されたフィールド(フィールド型は text_ja や text_2g)で動作確認を行う。以下にこれらのフィールド型を示す。

  <fieldType name="text_ja" class="solr.TextField" autoGeneratePhraseQueries="true" positionIncrementGap="100">
    <analyzer type="index">
      <tokenizer name="japanese" mode="normal" userDictionary="userdict_ja.txt"/>
      <filter name="japanesePartOfSpeechStop" tags="stoptags_ja.txt"/>
      <filter name="japaneseKatakanaStem" minimumLength="4"/>
      <filter name="stop" words="stopwords-ja.txt" ignoreCase="true"/>
      <filter name="lowercase"/>
    </analyzer>
    <analyzer type="query">
      <tokenizer name="japanese" mode="normal" userDictionary="userdict_ja.txt"/>
      <filter name="japanesePartOfSpeechStop" tags="stoptags_ja.txt"/>
      <filter name="japaneseKatakanaStem" minimumLength="4"/>
      <filter name="managedSynonymGraph" managed="synonym-ja"/>
      <filter name="stop" words="stopwords-ja.txt" ignoreCase="true"/>
      <filter name="lowercase"/>
    </analyzer>
  </fieldType>

  <fieldType name="text_2g" class="solr.TextField" autoGeneratePhraseQueries="true" positionIncrementGap="100">
    <analyzer type="index">
      <tokenizer name="nGram" maxGramSize="2" minGramSize="2"/>
      <filter name="lowercase"/>
    </analyzer>
    <analyzer type="query">
      <tokenizer name="nGram" maxGramSize="2" minGramSize="2"/>
      <filter name="managedSynonymGraph" managed="synonym-2g"/>
      <filter name="lowercase"/>
    </analyzer>
  </fieldType>

そして、シノニム定義は以下の通りである。

首相,総理,総理大臣,内閣総理大臣

なおManagedSynonymGraphFilterになってからスキーマ定義だけ見ても判然としないが、後述するように、 expand=true となるようにクエリ時展開式で適用することとする。

また、インデクシングするデータは以下の通りとする。

cat testdata.json
[
 {"id": "01", "title": "山田太郎が第200代内閣総理大臣になった。"},
 {"id": "02", "title": "山田太郎が第200代総理大臣になった。"},
 {"id": "03", "title": "山田太郎が第200代総理になった。"},
 {"id": "04", "title": "山田太郎が第200代首相になった。"},
 {"id": "11", "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"},
 {"id": "12", "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"},
 {"id": "13", "title": "山田花子大臣が内閣官房らと総理官邸にて山田太郎内閣総理大臣と面会した。"}
]

idが01から04の文書は、前述のシノニムのバリエーションで文書が書かれており、シノニムが効いていることを確認するためのものである。idが11以降のものは、「内閣」「総理」「大臣」の単語が散らばって存在するように書かれた文書で、これによりフレーズやBooleanオペレーターの効き具合を確認しようというものである。

ではこのようにシノニムが設定され、データがインデクシングされたSolr 8.11とSolr 9.1でtitleフィールド(autoGeneratePhraseQueries=trueとなっていてかつ前述のシノニムが効いている)に対して「内閣総理大臣」を検索してみよう。sowはこれまでの常識であるtrueに設定するとする。結果は次の通り(下記出力では、jqコマンドにてヒットした文書とデバッグクエリが同時に見られるようにしている)。

# Solr 8.11
curl --silent 'http://localhost:10983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3&sow=true' |jq -r '{docs: .response.docs, debug: .debug.parsedquery}'
{
  "docs": [
    {
      "id": "01",
      "title": "山田太郎が第200代内閣総理大臣になった。"
    },
    {
      "id": "13",
      "title": "山田花子大臣が内閣官房らと総理官邸にて山田太郎内閣総理大臣と面会した。"
    },
    {
      "id": "02",
      "title": "山田太郎が第200代総理大臣になった。"
    },
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "11",
      "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"
    },
    {
      "id": "03",
      "title": "山田太郎が第200代総理になった。"
    },
    {
      "id": "04",
      "title": "山田太郎が第200代首相になった。"
    }
  ],
  "debug": "+SpanOrQuery(spanOr([spanNear([title:内閣, title:総理, title:大臣], 0, true), title:総理, spanNear([title:総理, title:大臣], 0, true), title:首相]))"
}

# Solr 9.1
curl --silent 'http://localhost:8983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3&sow=true' |jq -r '{docs: .response.docs, debug: .debug.parsedquery}'
{
  "docs": [],
  "debug": "+(PhraseQuery(title:\"内閣 総理 大臣\") title:総理 PhraseQuery(title:\"総理 大臣\") title:首相)~4"
}

Solr 8.11では全文書7件がヒットしているが、Solr 9.1ではヒット0件となった。ヒット0件となってしまった原因は、デバッグクエリを見ればわかる。デバッグクエリによれば、シノニム定義した「内閣総理大臣」のその他のバリエーション(「総理大臣」と「総理」と「首相」)が展開されている。しかもSolr 9.1ではフレーズ検索に展開されているのが素晴らしい(Solr 8.11ではSpanQueryで展開されており、かなり苦しい)。しかしその後がいけない。 ~4 がつけられていることで、OR展開されたこれらシノニムの全バリエーション4つが全て含まれた文書しかヒットしないように展開されてしまっている。

Solr 9での新常識

そこでSolr 9.1ではこれまでの常識であったsow=trueを指定しないようにしてみる。すると次のようになった。

# Solr 9.1
curl --silent 'http://localhost:8983/solr/synonyms/select?debugQuery=true&fl=id%2Ctitle&indent=true&q.op=OR&q=title%3A%E5%86%85%E9%96%A3%E7%B7%8F%E7%90%86%E5%A4%A7%E8%87%A3' |jq -r '{docs: .response.docs, debug: .debug.parsedquery}'
{
  "docs": [
    {
      "id": "01",
      "title": "山田太郎が第200代内閣総理大臣になった。"
    },
    {
      "id": "13",
      "title": "山田花子大臣が内閣官房らと総理官邸にて山田太郎内閣総理大臣と面会した。"
    },
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "11",
      "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"
    },
    {
      "id": "04",
      "title": "山田太郎が第200代首相になった。"
    },
    {
      "id": "02",
      "title": "山田太郎が第200代総理大臣になった。"
    },
    {
      "id": "03",
      "title": "山田太郎が第200代総理になった。"
    }
  ],
  "debug": "+((PhraseQuery(title:\"内閣 総理 大臣\") title:総理 PhraseQuery(title:\"総理 大臣\") title:首相))~1"
}

全文書7件のヒットが得られる。デバッグクエリを見ると、 ~4 だった部分が ~1 となり、「4つのバリエーションのシノニムのうちひとつがあたればOK」という展開がなされる。ちなみに、Solr 8.11でもデバッグクエリも含め、同じ結果が得られる。

どうやら日本語においても、sow=false(デフォルト)とする時代がやってきたのかもしれない。本稿前半に話したsow=falseの弊害はあるにせよ、シノニムを多用する業務アプリケーションの現場では、sow=falseの方が好ましい動作をするようである。sowはフィールド毎に設定できないので、ある部分を犠牲にする必要はあるが、業務アプリケーションの検索は日本語に限定されるものでもないので、デフォルトのsow=falseの方がよいなら、それに倣うのがよかろう。

そこで以降の本稿では、テストケースを絞るため(私だって少しは正月に休みたい)、以下の実用的なパラメーターの組み合わせに固定(*1)して、シノニムとハイライトの動作検証を行うことにする。

  • defType=edismax
  • autoGeneratePhraseQueries=true
  • sow=false
  • ManagedSynonymGraphFilterをクエリ時展開(expand=true)で適用
  • JapaneseTokenizerはmode=normalとする
  • ハイライトはhl.method=original と unified の場合を確認
  • text_ja と text_2g を確認

クエリオペレーターと括弧を使った検索の動作確認

sow=false(何度も言うがデフォルトだ)というこれまでと真逆の設定を行うので、シノニムとハイライトのテストを行う前に、まずはBooleanクエリオペレーターや括弧を含むクエリ式の動作を確認したい。

Booleanクエリオペレーターや括弧を含むクエリ式を試した結果は以下の通り。よさげである。

# q=首相 AND 総理 AND 大臣
curl --silent 'http://localhost:8983/solr/synonyms/select?debugQuery=true&defType=edismax&fl=id%2Ctitle&indent=true&q.op=OR&q=%E9%A6%96%E7%9B%B8%20AND%20%E7%B7%8F%E7%90%86%20AND%20%E5%A4%A7%E8%87%A3&qf=title_exact' |jq -r '{docs: .response.docs, debug: .debug.parsedquery}'
{
  "docs": [
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "11",
      "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"
    }
  ],
  "debug": "+(+DisjunctionMaxQuery((title_exact:首相)) +DisjunctionMaxQuery((title_exact:総理)) +DisjunctionMaxQuery((title_exact:大臣)))"
}

# q=(首相 OR 総理) AND 大臣
curl --silent 'http://localhost:8983/solr/synonyms/select?debugQuery=true&defType=edismax&fl=id%2Ctitle&indent=true&q.op=OR&q=(%E9%A6%96%E7%9B%B8%20OR%20%E7%B7%8F%E7%90%86)%20AND%20%E5%A4%A7%E8%87%A3&qf=title_exact' |jq -r '{docs: .response.docs, debug: .debug.parsedquery}'
{
  "docs": [
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "11",
      "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"
    },
    {
      "id": "13",
      "title": "山田花子大臣が内閣官房らと総理官邸にて山田太郎内閣総理大臣と面会した。"
    },
    {
      "id": "02",
      "title": "山田太郎が第200代総理大臣になった。"
    },
    {
      "id": "01",
      "title": "山田太郎が第200代内閣総理大臣になった。"
    }
  ],
  "debug": "+(+(DisjunctionMaxQuery((title_exact:首相)) DisjunctionMaxQuery((title_exact:総理))) +DisjunctionMaxQuery((title_exact:大臣)))"
}

# q=(首相 OR 総理) AND 大臣 NOT 官房
curl --silent 'http://localhost:8983/solr/synonyms/select?debugQuery=true&defType=edismax&fl=id%2Ctitle&indent=true&q.op=OR&q=(%E9%A6%96%E7%9B%B8%20OR%20%E7%B7%8F%E7%90%86)%20AND%20%E5%A4%A7%E8%87%A3%20NOT%20%E5%AE%98%E6%88%BF&qf=title_exact' |jq -r '{docs: .response.docs, debug: .debug.parsedquery}'
{
  "docs": [
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "02",
      "title": "山田太郎が第200代総理大臣になった。"
    },
    {
      "id": "01",
      "title": "山田太郎が第200代内閣総理大臣になった。"
    }
  ],
  "debug": "+(+(DisjunctionMaxQuery((title_exact:首相)) DisjunctionMaxQuery((title_exact:総理))) +DisjunctionMaxQuery((title_exact:大臣)) -DisjunctionMaxQuery((title_exact:官房)))"
}

シノニム検索

edismaxを使うので、通常であればtitleとtitle_2gをqfパラメーターに指定するところであるが、目的に鑑みてそれぞれ別個にテストを行うこととする。

下記は「首相」をtitleフィールドで検索した結果である。

{
  "docs": [
    {
      "id": "01",
      "title": "山田太郎が第200代内閣総理大臣になった。"
    },
    {
      "id": "13",
      "title": "山田花子大臣が内閣官房らと総理官邸にて山田太郎内閣総理大臣と面会した。"
    },
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "11",
      "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"
    },
    {
      "id": "04",
      "title": "山田太郎が第200代首相になった。"
    },
    {
      "id": "02",
      "title": "山田太郎が第200代総理大臣になった。"
    },
    {
      "id": "03",
      "title": "山田太郎が第200代総理になった。"
    }
  ],
  "debug": "+DisjunctionMaxQuery(((((title:\"内閣 総理 大臣\" title:総理 title:\"総理 大臣\" title:首相))~1)))"
}

「首相」以外のシノニムバリエーションで検索しても、上記と同じ結果となった。

同じキーワードをtitle_2gフィールドで検索すると、以下のようになる。

{
  "docs": [
    {
      "id": "01",
      "title": "山田太郎が第200代内閣総理大臣になった。"
    },
    {
      "id": "13",
      "title": "山田花子大臣が内閣官房らと総理官邸にて山田太郎内閣総理大臣と面会した。"
    },
    {
      "id": "02",
      "title": "山田太郎が第200代総理大臣になった。"
    },
    {
      "id": "12",
      "title": "山田花子大臣が総理官邸にて山田太郎首相と面会した。"
    },
    {
      "id": "11",
      "title": "山田花子大臣が内閣官房らと首相官邸にて総理と面会した。"
    },
    {
      "id": "04",
      "title": "山田太郎が第200代首相になった。"
    },
    {
      "id": "03",
      "title": "山田太郎が第200代総理になった。"
    }
  ],
  "debug": "+DisjunctionMaxQuery(((((title_2g:\"内閣 閣総 総理 理大 大臣\" title_2g:総理 title_2g:\"総理 理大 大臣\" title_2g:首相))~1)))"
}

そして同フィールドにおけるシノニムの「首相」以外のバリエーションの検索結果も上記と同じであった。

デバッグクエリをtitleとtitle_2gで比較すると、両者の単語分割が異なるところから来るフレーズ展開が異なるだけである。

よって前述*1のパラメーターの組み合わせで正しくシノニム検索ができる、と結論づけてよいだろう。

ハイライトも加える

次にハイライト機能を組み合わせた場合をテストする。ハイライト機能のさまざまなパラメーターのうち、特に、「山田花子大臣」の「大臣」や「内閣官房」の「内閣」など、「内閣総理大臣」がフレーズ検索されたときに、フレーズ単位でハイライトされるよう(つまり、「大臣」や「内閣」が単独でハイライトされないよう)、 hl.usePhraseHighlighter=true を設定してあることに言及しておく。

結果を述べると、シノニムも含めてきちんとハイライトされたが、title_2g フィールドにおいて、 hl.method=original の場合にハイライト部分がおかしくなる現象が見られた。一例を挙げると、 q=首相 のときに次のようになった。

山田花子大臣が内閣官房らと<b>総理官邸にて山田太郎内閣総理大臣</b>と面会した。

つまり、「総理官邸にて山田太郎内閣総理大臣」がすべてハイライトされてしまった。これは昔から知られる仕様で、それがために私がFastVectorHighlighterを開発したわけだが、今回はテスト対象から外した(理由は後述の参考文献を参照)。

テストスクリプト

上記シノニムとハイライトを実行し、結果をJSON/HTMLに保存する、今回のテストに使用したシェルスクリプトを共有しておこう。

#!/bin/bash

rm -f result-*.json

function url_encode(){
  echo $(echo $1 | jq -Rr '@uri')
}

SOLR_SCHEME=http
SOLR_HOST=localhost
SOLR_PORT=8983
SOLR_COLLECTION=synonyms
SOLR_HANDLER=/select
SOLR_PARAMS='debugQuery=true&defType=edismax&fl=id%2Ctitle&indent=true&q.op=OR'
SOLR_HL_METHOD=original

function request_url(){
  param_qf=$1
  param_q=$(url_encode $2)
  if [ -n "$3" ]; then
    SOLR_HL_METHOD=$3
  fi
  echo "$SOLR_SCHEME://$SOLR_HOST:$SOLR_PORT/solr/$SOLR_COLLECTION$SOLR_HANDLER?$SOLR_PARAMS&qf=$param_qf&q=$param_q&hl.method=$SOLR_HL_METHOD"
}

JQ_OUT1='{docs: .response.docs, debug: .debug.parsedquery}'

function html_table_row(){
  echo "\"<tr><td>$1</td><td>\" + .highlighting.\"$1\".title[0] + \"</td><td>\" + .highlighting.\"$1\".title_2g[0] + \"</td></tr>\" +"
}

function html_table(){
  if [ -n "$2" ]; then
    SOLR_HL_METHOD=$2
  fi
  echo "\"q=$1, hl.method=$SOLR_HL_METHOD\" +" \
  '"<br><table border=\"1\" style=\"border-collapse: collapse\">" +' \
  '"<tr><td>doc</td><td>text</td><td>text_2g</td></tr>" +' \
  "$(html_table_row '01') $(html_table_row '02') $(html_table_row '03') $(html_table_row '04')" \
  "$(html_table_row '11') $(html_table_row '12') $(html_table_row '13')" \
  '"</table><br><br>"'
}

# query on title field
curl --silent "$(request_url title '首相')" | jq -r "$JQ_OUT1" > result-title-syn1.json
curl --silent "$(request_url title '総理')" | jq -r "$JQ_OUT1" > result-title-syn2.json
curl --silent "$(request_url title '総理大臣')" | jq -r "$JQ_OUT1" > result-title-syn3.json
curl --silent "$(request_url title '内閣総理大臣')" | jq -r "$JQ_OUT1" > result-title-syn4.json

# query on title_2g field
curl --silent "$(request_url title_2g '首相')" | jq -r "$JQ_OUT1" > result-title_2g-syn1.json
curl --silent "$(request_url title_2g '総理')" | jq -r "$JQ_OUT1" > result-title_2g-syn2.json
curl --silent "$(request_url title_2g '総理大臣')" | jq -r "$JQ_OUT1" > result-title_2g-syn3.json
curl --silent "$(request_url title_2g '内閣総理大臣')" | jq -r "$JQ_OUT1" > result-title_2g-syn4.json

# highlight using original
curl --silent "$(request_url title%20title_2g '首相')" | jq -r "$(html_table '首相')" > result-hl-original.html
curl --silent "$(request_url title%20title_2g '総理')" | jq -r "$(html_table '総理')" >> result-hl-original.html
curl --silent "$(request_url title%20title_2g '総理大臣')" | jq -r "$(html_table '総理大臣')" >> result-hl-original.html
curl --silent "$(request_url title%20title_2g '内閣総理大臣')" | jq -r "$(html_table '内閣総理大臣')" >> result-hl-original.html

# highlight using unified
curl --silent "$(request_url title%20title_2g '首相' unified)" | jq -r "$(html_table '首相' unified)" > result-hl-unified.html
curl --silent "$(request_url title%20title_2g '総理' unified)" | jq -r "$(html_table '総理' unified)" >> result-hl-unified.html
curl --silent "$(request_url title%20title_2g '総理大臣' unified)" | jq -r "$(html_table '総理大臣' unified)" >> result-hl-unified.html
curl --silent "$(request_url title%20title_2g '内閣総理大臣' unified)" | jq -r "$(html_table '内閣総理大臣' unified)" >> result-hl-unified.html

KandaSearch でテストを行う場合

KandaSearch のアカウントを持っている方は、下記の拡張機能で本稿を再現するためのConfigurationとデータおよびスクリプトを入手できる。

プロジェクト内にインスタンス(トライアルインスタンスでも可)がある方は、KandaSearchのSolrで簡単に試すことが可能である。

まとめ

日本語検索に多大な影響を及ぼす sow パラメーターが導入されて以来、日本では sow=true で運用することが「常識」となっていたが、今回 Solr 9.1 であらためてテストしたところ、そろそろその常識をアップデートする必要性に迫られ、抗い、抵抗むなしくテストを行うこととなった。

まあ結果的にすっきりした気分で新年が迎えられてよかった。

調査結果をまとめると、以下のようになるだろう。なお、クエリパーサーは edismax を想定している。

  • 日本語フィールドにおいてはあいかわらず autoGeneratePhraseQueries=true を推奨。ただし、 sow=false (デフォルトであり、フィールド毎に設定できるパラメーターではない)も推奨するので、この組み合わせは結局のところ、検索キーワードがAnalyzerによって複数単語に分割されても、フレーズ検索には展開してくれない(AND検索になる)。
  • (シノニム展開しないフィールドにおいて)フレーズ検索に展開したいなら、明示的に "" でくくらないといけない。ただしなんでもかんでも機械的にくくればいいというものでもないので(たとえば、日本語キーワードでない場合や、edismax の他のパラメーター(pf など)の利用を考慮した場合など)、常に実行できる戦略とは言いがたい。
  • ManagedSynonymGraphFilterをクエリ時展開(expand=true)で適用するのが推奨。
  • シノニム適用フィールドではJapaneseTokenizerはmode=normalとするのが推奨。
  • text_ja 系フィールド型では、シノニムとハイライトの組み合わせはフレーズ展開も含めて、hl.method=originalとhl.method=unifiedの両方で正しく動作する。
  • 一方、text_2g 等 N-gram 系フィールド型では、シノニムとハイライトの組み合わせはフレーズ展開も含めて、hl.method=unifiedでのみ正しく動作する。

では、上記パラメーターの組み合わせで hl.method=unified とすれば「最強」だろうか。実は残念ながら Solr 9.1 段階でそうはいかない。hl.method=unified だと hl.alternateField パラメーターをサポートしていないからだ。これは結構致命的である。現時点では以上の長短を考慮し、アプリケーションごとに最適な使い方を模索しなければならない。

以上、最新 Solr 9.1 におけるシノニムとハイライト検索の好ましい組み合わせを調査した。今は1月2日の22時。なんとか1日ぐらい休めそうである。明日あたり、神田明神に参拝することにしよう。

末尾に、皆様の健勝をお祈りします。本年もどうぞよろしくお願い申し上げます。

参考文献

お見積もり・詳細は KandaSearch チームに
お気軽にお問い合わせください。

お問い合わせ