Go製全文検索エンジンBlugeで日本語形態素解析をおこなう

7 min read読了の目安(約7100字

Blugeは Go で書かれた全文検索エンジンです。Go 製の全文検索エンジンといえば Bleve が有名ですが、bluge はその後継となります。

Bleve には日本語用の形態素解析が blevesearch/blevex に用意されていたのですが、Bluge の方にはなかったので、kagome を利用して日本語形態素解析のアナライザーを用意しました。

https://github.com/ikawaha/blugeplugin

検索で必要な形態素解析を用意する

Analyzer を用意していきます。Analyzer は

  • Char filters
  • Tokenizer
  • Token filters

の3つの層からなります。Solr とか Elasticsearch とかと同じような構成です。Char filters -> Tokenizer -> Token filters の順に適用されます。

Char filters

入力を NFKC で正規化します。たとえば、パーセント のように正規化されます。

Tokenizer

形態素解析をおこないます。動詞や形容詞、形容動詞については活用で変化するので、こちらは形態素解析時に基本形に変換します(棲んでいる棲む)。

また、品詞フィルターとして、助詞( など)などは検索には不要なためフィルターします。

品詞のフィルターは以下のリストを利用しています:

https://github.com/apache/lucene-solr/blob/master/lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/stoptags.txt

Token filters

  • LowerCaseFilter
  • StopWordsFilter

を適用します。LowerCaseFilter は大文字を小文字に統制するフィルターです。StopWordsFilter は不要な語を落とすためのフィルターです。それ とか これ といった指示語や する などが該当します。

ストップワードは、以下のリストを利用しています:

https://github.com/apache/lucene-solr/blob/master/lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/stopwords.txt

Analyzer

以上より Analyzer のコードは以下のようになりました。StopTagsFilterBaseFormFilter はオプションとして設定できるようになっています。自分の処理に必要なフィルターが不足している、あるいは過剰にフィルターしすぎるなどあれば、以下を参考に Analyzer を構築するとよいでしょう。

// Analyzer returns the analyzer suite in Japanese.
func Analyzer() *analysis.Analyzer {
	return &analysis.Analyzer{
		CharFilters: []analysis.CharFilter{
			NewUnicodeNormalizeCharFilter(norm.NFKC),
		},
		Tokenizer: NewJapaneseTokenizer(StopTagsFilter(), BaseFormFilter()),
		TokenFilters: []analysis.TokenFilter{
			token.NewLowerCaseFilter(),
			NewStopWordsFilter(),
		},
	}
}

全文検索エンジン Bluge で利用する

https://github.com/blugelabs/bluge_examples を参考に検索の流れを見ていきます。

func main() {
	// サンプルなので in memory で済ませる
	config := bluge.InMemoryOnlyConfig()

	// index writer を用意する
	w, err := bluge.OpenWriter(config)
	if err != nil {
		log.Fatalf("error opening writer: %v", err)
	}
	defer w.Close()

	// 対象ドキュメント(詳細は別途記載)
	docs := NewDocuments()

	// indexing
	for _, doc := range docs {
		doc := doc
		if err := w.Update(doc.ID(), doc); err != nil {
			log.Fatalf("error updating document: %v", err)
		}
		// 表示
		fmt.Printf("indexed document with id:%s\n", doc.ID())
		doc.EachField(func(field segment.Field) {
			fmt.Printf("\t%s: %s\n", field.Name(), field.Value())
		})
	}

	// index reader を用意する
	r, err := w.Reader()
	if err != nil {
		log.Fatalf("error getting index reader: %v", err)
	}
	defer r.Close()

	// クエリ
	q := "踊る人形"
	query := bluge.NewMatchQuery(q).SetAnalyzer(ja.Analyzer()).SetField("body")
	req := bluge.NewTopNSearch(10, query).WithStandardAggregations()
	fmt.Printf("query: search field %q, value %q\n", query.Field(), q)

	// search
	ite, err := r.Search(context.Background(), req)
	if err != nil {
		log.Fatalf("error executing search: %v", err)
	}
	// 検索結果
	for {
		match, err := ite.Next()
		if err != nil {
			log.Fatalf("error iterator document matches: %v", err)
		}
		if match == nil {
			break
		}
		if err := match.VisitStoredFields(func(field string, value []byte) bool {
			fmt.Printf("%s: %q\n", field, string(value))
			return true
		}); err != nil {
			log.Fatalf("error loading stored fields: %v", err)
		}
	}
}

Indexing

ドキュメントを indexing していきます。例では InMemoryOnlyConfig を利用しているのでプログラムが終了するとインデックスはなくなってしまいますが、フォルダを指定して config を作成すれば、インデックスを再利用できます。

	// サンプルなので in memory で済ませる
	config := bluge.InMemoryOnlyConfig()

次に index write を用意します。

	// index writer を用意する
	w, err := bluge.OpenWriter(config)
	if err != nil {
		log.Fatalf("error opening writer: %v", err)
	}
	defer w.Close()

ドキュメントを用意します。

func NewDocuments() []*bluge.Document {
	docs := []struct {
		ID     string
		Author string
		Text   string
	}{
		{
			ID:     "1:赤い蝋燭と人魚",
			Author: "小川未明",
			Text:   "人魚は南の方の海にばかり棲んでいるのではありません",
		},
		{
			ID:     "2:吾輩は猫である",
			Author: "夏目漱石",
			Text:   "吾輩は猫である。名前はまだない",
		},
		{
			ID:     "3:狐と踊れ",
			Author: "神林長平",
			Text:   "踊っているのでなければ踊らされているのだろうさ",
		},
		{
			ID:     "4:ダンスダンスダンス",
			Author: "村上春樹",
			Text:   "音楽の鳴っている間はとにかく踊り続けるんだ。おいらの言っていることはわかるかい?",
		},
	}
	var ret []*bluge.Document
	for _, v := range docs {
		auth := bluge.NewTextField("author", v.Author).WithAnalyzer(ja.Analyzer())
		body := bluge.NewTextField("body", v.Text).WithAnalyzer(ja.Analyzer())
		doc := bluge.NewDocument(v.ID).AddField(auth).AddField(body)
		ret = append(ret, doc)
	}
	return ret
}

ドキュメントに必要なのは、ドキュメントを識別する ID と値を設定する Field です。フィールドを設定する際に、Analyzer をセットしておきます。そうすることでそのフィールドがセットした Analyzer で解析されて処理されます。

ドキュメントを indexing します。特に難しいことはありません。

	// indexing
	for _, doc := range docs {
		doc := doc
		if err := w.Update(doc.ID(), doc); err != nil {
			log.Fatalf("error updating document: %v", err)
		}
		// 表示
		fmt.Printf("indexed document with id:%s\n", doc.ID())
		doc.EachField(func(field segment.Field) {
			fmt.Printf("\t%s: %s\n", field.Name(), field.Value())
		})
	}

検索

インデックスを検索するために、Reader を準備します。すでに開いている Writer から Reader を作成できることに注意して下さい。(この Reader は snapshot されていないインデックス部分にアクセスできる特別な Reader で、in memory で利用しているときはこの方法で Reader を取得する必要がありそうです。config から Reader を取得しようとすると失敗しました)

	// index reader を用意する
	r, err := w.Reader()
	if err != nil {
		log.Fatalf("error getting index reader: %v", err)
	}
	defer r.Close()

検索時のクエリを設定します。

クエリにも Analyzer を適用します。踊る/人形 のようになるはずです。クエリからリクエストを生成します。ここでは上位10件を取得するリクエストを作成しています。

	q := "踊る人形"
	query := bluge.NewMatchQuery(q).SetAnalyzer(ja.Analyzer()).SetField("body")
	req := bluge.NewTopNSearch(10, query).WithStandardAggregations()
	fmt.Printf("query: search field %q, value %q\n", query.Field(), q)

リクエストをセットして検索します。結果は Iterator で得られます。

	// search
	ite, err := r.Search(context.Background(), req)
	if err != nil {
		log.Fatalf("error executing search: %v", err)
	}
	// 検索結果
	for {
		match, err := ite.Next()
		if err != nil {
			log.Fatalf("error iterator document matches: %v", err)
		}
		if match == nil {
			break
		}
		if err := match.VisitStoredFields(func(field string, value []byte) bool {
			fmt.Printf("%s: %q\n", field, string(value))
			return true
		}); err != nil {
			log.Fatalf("error loading stored fields: %v", err)
		}
	}

結果

インデックスされたドキュメントは以下です:

indexed document with id:1:赤い蝋燭と人魚
	_id: 1:赤い蝋燭と人魚
	author: 小川未明
	body: 人魚は南の方の海にばかり棲んでいるのではありません
indexed document with id:2:吾輩は猫である
	_id: 2:吾輩は猫である
	author: 夏目漱石
	body: 吾輩は猫である。名前はまだない
indexed document with id:3:狐と踊れ
	_id: 3:狐と踊れ
	author: 神林長平
	body: 踊っているのでなければ踊らされているのだろうさ
indexed document with id:4:ダンスダンスダンス
	_id: 4:ダンスダンスダンス
	author: 村上春樹
	body: 音楽の鳴っている間はとにかく踊り続けるんだ。おいらの言っていることはわかるかい?

検索結果:

query: search field "body", value "踊る人形"
_id: "3:狐と踊れ"
_id: "4:ダンスダンスダンス"

踊る が含まれるドキュメント 3 と 4 が取得できました。
どちらのドキュメントにも 踊る は入っていないことに注目して下さい。含まれているのは 踊ら踊り ですが、正規化の処理が入るので 踊る でインデックスされていて検出されています。

これで日本語を含むドキュメントでも全文検索できそうですね。

Happy hacking!