背景: Mattermost Bleve検索

2021/06にリリースされたMattermost v5.24から、Bleveによるメッセージの日本語検索が利用できるようになった

Bleve search (experimental)

それ以前のバージョンでは、Mattermostセットアップ時にDB(MySQL, PostgreSQL)の設定に手を加える必要があったが、Bleveによる検索機能が公式にサポートされたことで、システムコンソール画面からいくつか設定を変更するだけで日本語検索が実現できるようになった。嬉しい。

問題: 意図せぬ投稿が検索される

Bleve検索自体はとても便利な機能だが、その検索結果に少し問題がある。

2文字以上の単語で構成される日本語クエリによるBleve検索をおこなった際、本来ならばその単語が出現する投稿のみが検索結果として表示して欲しいところだが、実際の検索結果には、その単語内で使用されている文字が全て現れる投稿を検索してしまう。

例えば、「メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。」という投稿があったとする。

「激怒」「邪智暴虐」などの単語で検索を行うと、想定通りこの投稿を検索することができる。しかし、「激暴」という単語で検索を行った場合も、この単語自体は対象の投稿に存在しないにも関わらず、単語を構成する「激」「暴」の両方の文字が投稿に含まれているため、対象の投稿が検索結果として表示されてしまう。

problem-bleve-search

この問題を改善するため、調査を行った。

調査1: Bleveのcjk言語向けAnalyzer

まず、Mattermost側でBleveをどのように使用しているかを調べる。

投稿内容のインデックスは、以下で定義されている。

...
import (
    ...
	"github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
    ...
)
...
func init() {
    ...
	standardMapping = bleve.NewTextFieldMapping()
	standardMapping.Analyzer = standard.Name
    ...
}
...
func getPostIndexMapping() *mapping.IndexMappingImpl {
	postMapping := bleve.NewDocumentMapping()
    ...
	postMapping.AddFieldMappingsAt("Message", standardMapping)
    ...
}

https://github.com/mattermost/mattermost-server/blob/5ea2ca8a3a25cb89b4cf52012ae3897d03e0a64f/services/searchengine/bleveengine/bleve.go#L79

Mattermostの投稿(Post)の内容(Message)は、Bleveのstandard.Nameで表されるAnalyzerによって解析されるようだ。Bleveについてあまり詳しくはないが、standard.Nameで表されるAnalyzerの内容を見ると、en.StopNameが使われており、英文を前提としたAnalyzerだと考えられるので、この部分が問題なのではないかと思う。
https://github.com/blevesearch/bleve/blob/master/analysis/analyzer/standard/standard.go

BleveのAnalyzerについて調べていると、cjk向けのAnalyzerもあることが分かった。
https://github.com/blevesearch/bleve/tree/master/analysis/lang/cjk

単純にMattermostの投稿内容のインデックスをBleveのcjk Analyzerを使って行うよう変更してみる。

@@ -14,7 +14,7 @@ import (
 
        "github.com/blevesearch/bleve/v2"
        "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
-       "github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
+       "github.com/blevesearch/bleve/v2/analysis/lang/cjk"
        "github.com/blevesearch/bleve/v2/mapping"
 
        "github.com/mattermost/mattermost-server/v6/model"
@@ -49,7 +49,7 @@ func init() {
        keywordMapping.Analyzer = keyword.Name
 
        standardMapping = bleve.NewTextFieldMapping()
-       standardMapping.Analyzer = standard.Name
+       standardMapping.Analyzer = cjk.AnalyzerName
 

この変更を有効にしてMattermostを再起動し、Bleveインデックスを破棄→再構築すると、「激暴」という単語で当該の投稿が検索されなくなった。

bleve-search-improve1-1

bleve-search-improve1-2

めでたしめでたし。

…とはならなかった。

上記の変更を加えたことで、今度は1語による検索ができなくなった。

problem-bleve-search2

「検索は2語以上で」という制限を設ければ問題は解決だが、この制限はあまり好ましくない。

調査2: Bleve cjk Analyzerを使用した際に1語検索ができるようにする

Bleveのcjk AnalyzerはTokenFilterとしてBigramを使用している。つまり、2語ずつインデックスを構築しているので、1語での検索ができないようだ。

...
const AnalyzerName = "cjk"

func AnalyzerConstructor(config map[string]interface{}, cache *registry.Cache) (*analysis.Analyzer, error) {
    ...
	bigramFilter, err := cache.TokenFilterNamed(BigramName)
	if err != nil {
		return nil, err
	}
	rv := analysis.Analyzer{
		Tokenizer: tokenizer,
		TokenFilters: []analysis.TokenFilter{
			widthFilter,
			toLowerFilter,
			bigramFilter,
		},
	}
    ...
}
...

https://github.com/blevesearch/bleve/blob/d89c6c0a6873fcca1673fb6e7e3128d39bc6494d/analysis/lang/cjk/analyzer_cjk.go#L40

もう一度、Bleveのcjkパッケージを見てみるとoutput_unigramという設定があることに気づく。名前から、この設定をOnにしてインデックスを構築することで、1語のインデックスも出力されるようになるのではないかと予想する。(それによるインデックスサイズの増加についてはここでは考慮しないことにする)

...
func CJKBigramFilterConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.TokenFilter, error) {
	outputUnigram := false
	outVal, ok := config["output_unigram"].(bool)
	if ok {
		outputUnigram = outVal
	}
	return NewCJKBigramFilter(outputUnigram), nil
}
...

https://github.com/blevesearch/bleve/blob/d89c6c0a6873fcca1673fb6e7e3128d39bc6494d/analysis/lang/cjk/cjk_bigram.go#L185

output_unigramの設定をOnにする方法について色々調べてみたが、どうやってもOnにする方法が見つからない(これがこの記事を書くきっかけだったりする)。

以下のItemNamed関数内で実行しているbuild(name, nil, cache)の第二引数が前述のCJKBigramFilterConstructor関数の第一引数 (config)になり、ここにoutput_unigram: trueが設定されていれば有効にできるらしいが、nilでハードコードされているのでどうしようもない。

func (c *ConcurrentCache) ItemNamed(name string, cache *Cache, build CacheBuild) (interface{}, error) {
    ...
	// try to build it
	newItem, err := build(name, nil, cache)
    ...
}

https://github.com/blevesearch/bleve/blob/d89c6c0a6873fcca1673fb6e7e3128d39bc6494d/registry/cache.go#L47

自分が気づいていない方法があるのかも知れないが、調べていてもわからないので、output_unigramtrueにしたAnalyzerを新たに登録し、それを使用してみる。

@@ -13,9 +13,13 @@ import (
        "time"
 
        "github.com/blevesearch/bleve/v2"
+       "github.com/blevesearch/bleve/v2/analysis"
        "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
-       "github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
+       "github.com/blevesearch/bleve/v2/analysis/lang/cjk"
+       "github.com/blevesearch/bleve/v2/analysis/token/lowercase"
+       "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
        "github.com/blevesearch/bleve/v2/mapping"
+       "github.com/blevesearch/bleve/v2/registry"
 
        "github.com/mattermost/mattermost-server/v6/model"
        "github.com/mattermost/mattermost-server/v6/shared/mlog"
@@ -44,12 +48,50 @@ var keywordMapping *mapping.FieldMapping
 var standardMapping *mapping.FieldMapping
 var dateMapping *mapping.FieldMapping
 
+const customCJKAnalyzerName = "custom_cjk_analyzer"
+const customCJKTokenFilterName = "custom_cjk_filter"
+
+func CustomCJKAnalyzerConstructor(config map[string]interface{}, cache *registry.Cache) (*analysis.Analyzer, error) {
+       tokenizer, err := cache.TokenizerNamed(unicode.Name)
+       if err != nil {
+               return nil, err
+       }
+       widthFilter, err := cache.TokenFilterNamed(cjk.WidthName)
+       if err != nil {
+               return nil, err
+       }
+       toLowerFilter, err := cache.TokenFilterNamed(lowercase.Name)
+       if err != nil {
+               return nil, err
+       }
+       bigramFilter, err := cache.TokenFilterNamed(customCJKTokenFilterName)
+       if err != nil {
+               return nil, err
+       }
+       rv := analysis.Analyzer{
+               Tokenizer: tokenizer,
+               TokenFilters: []analysis.TokenFilter{
+                       widthFilter,
+                       toLowerFilter,
+                       bigramFilter,
+               },
+       }
+       return &rv, nil
+}
+
+func CustomCJKBigramFilterConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.TokenFilter, error) {
+       return cjk.NewCJKBigramFilter(true), nil
+}
+
 func init() {
+       registry.RegisterTokenFilter(customCJKTokenFilterName, CustomCJKBigramFilterConstructor)
+       registry.RegisterAnalyzer(customCJKAnalyzerName, CustomCJKAnalyzerConstructor)
+
        keywordMapping = bleve.NewTextFieldMapping()
        keywordMapping.Analyzer = keyword.Name
 
        standardMapping = bleve.NewTextFieldMapping()
-       standardMapping.Analyzer = standard.Name
+       standardMapping.Analyzer = customCJKAnalyzerName
 
        dateMapping = bleve.NewNumericFieldMapping()
 }

冗長だ。

再びMattermostを再起動、Bleveインデックスの破棄→再構築を行い検索を実行すると、1語でも検索ができるようになった。

bleve-search-improve2-1

bleve-search-improve2-2

bleve-search-improve2-3

おわりに

というわけで、MattermostのBleveによる日本語検索の改善について調べてみた。
とりあえず小さなサンプルでは実現可能なことが分かったが、output_unigramの設定のせいで修正量が多くなりそうなので、もう少し調査してからIssueで報告しようと思う。

調査内容としては以下のあたりかな。

Mattermostで実装するときは、Bleveの設定画面でAnalyzer選ぶような感じになるのかな。CJK以外でAnalyzer変更が必要な言語が無かったりすると、ちょっと独自改造っぽい感じになるので受け入れられにくそうな感じもするな。

comments powered by Disqus