📌

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

2024/03/25に公開

きっかけ

https://x.com/mattn_jp/status/1771380499107488061

mattn=san が bleve の紹介をされていて、日本語は cjk の 2-gram が使えるみたい、とつぶやかれていたことがきっかけでした。

そのむかし、日本語形態素解析器 kagome は bleve 用の analysis/lang/ja として利用されていたことがあるのですが、go get が重くなるということで大変不評で、blevex とうリポジトリが用意され、検索エンジンとは分けられた経緯があります。そうこうしているうちに、bleve は v2 となって、これまで blevex で管理されていた analysis/lang は同一リポジトリに復帰したのですが、ja はハブられてしまっていたのでした 😱

bleve 用の analysis/lang/ja を用意する

https://github.com/ikawaha/bleveplugin/

動作サンプル

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"os"

	"github.com/blevesearch/bleve/v2"
	"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
	"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
	"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
	"github.com/ikawaha/bleveplugin/analysis/lang/ja"
)

func main() {
	if err := run(os.Args); err != nil {
		log.Println(err)
		os.Exit(1)
	}
	os.Exit(0)
}

func run(_ []string) error {
	// document mapping
	keywordFieldMapping := bleve.NewTextFieldMapping()
	keywordFieldMapping.Analyzer = keyword.Name
	jaTextFieldMapping := bleve.NewTextFieldMapping()
	jaTextFieldMapping.Analyzer = "ja"
	dm := bleve.NewDocumentMapping()
	dm.AddFieldMappingsAt("type", keywordFieldMapping)
	dm.AddFieldMappingsAt("id", jaTextFieldMapping)
	dm.AddFieldMappingsAt("author", jaTextFieldMapping)
	dm.AddFieldMappingsAt("text", jaTextFieldMapping)

	// index mapping
	im := bleve.NewIndexMapping()
	im.TypeField = "type"
	im.AddDocumentMapping("book", dm)
	if err := im.AddCustomTokenizer("ja_tokenizer", map[string]any{
		"type":      ja.Name,
		"dict":      ja.DictIPA,
		"base_form": true,
		"stop_tags": true,
	}); err != nil {
		return fmt.Errorf("failed to create ja tokenizer: %w", err)
	}
	if err := im.AddCustomAnalyzer("ja", map[string]any{
		"type":      custom.Name,
		"tokenizer": "ja_tokenizer",
		"token_filters": []string{
			ja.StopWordsName,
			lowercase.Name,
		},
	}); err != nil {
		return fmt.Errorf("failed to create ja analyzer: %w", err)
	}

	// index
	index, err := bleve.NewMemOnly(im)
	defer index.Close()

	// documents
	docs := []string{
		`{"type": "book", "id": "1:赤い蝋燭と人魚", "author": "小川未明", "text": "人魚は南の方の海にばかり棲んでいるのではありません"}`,
		`{"type": "book", "id": "2:吾輩は猫である", "author": "夏目漱石", "text":   "吾輩は猫である。名前はまだない"}`,
		`{"type": "book", "id": "3:狐と踊れ", "author": "神林長平", "text": "踊っているのでなければ踊らされているのだろうさ"}`,
		`{"type": "book", "id": "4:ダンスダンスダンス", "author": "村上春樹", "text": "音楽の鳴っている間はとにかく踊り続けるんだ。おいらの言っていることはわかるかい?"}`,
	}
	// indexing
	for _, doc := range docs {
		var data map[string]any
		if err := json.Unmarshal([]byte(doc), &data); err != nil {
			log.Printf("SKIP: failed to unmarshal doc: %v, %s", err, doc)
			continue
		}
		if err := index.Index(data["id"].(string), data); err != nil {
			return fmt.Errorf("error indexing document: %w", err)
		}
		// printing ...
		fmt.Printf("indexed document with id:%s, %s\n", data["id"], doc)
	}
	dc, err := index.DocCount()
	if err != nil {
		return fmt.Errorf("failed to count documents: %w", err)
	}
	fmt.Printf("doc count: %d\n --------\n", dc)

	// query
	q := "踊る人形"
	query := bleve.NewMatchQuery(q)
	query.SetField("text")
	req := bleve.NewSearchRequest(query)
	fmt.Printf("query: search field %q, value %q\n", query.Field(), q)

	// search
	result, err := index.Search(req)
	if err != nil {
		log.Fatalf("error executing search: %v", err)
	}
	// search result
	for _, v := range result.Hits {
		fmt.Println(v.ID)
	}
	return nil
}

出力結果

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

解説

上のサンプルを元に利用方法などを解説します。

IndexMapping

IndexMapping は検索インデックスを表しています。今回、IndexMapping には2つ登録するものがあります。

DocumentMapping

IndexMapping に DocumentMapping をセットしてやることで、指定したドキュメントを指定した形式で解析することが出来ます。
DocumentMapping とは、ドキュメントがどのようなフィールドを持っていて、どのように解析してほしいかを指定するものです。

以下の部分がそうです:

	// document mapping
	keywordFieldMapping := bleve.NewTextFieldMapping()
	keywordFieldMapping.Analyzer = keyword.Name
	jaTextFieldMapping := bleve.NewTextFieldMapping()
	jaTextFieldMapping.Analyzer = "ja"
	dm := bleve.NewDocumentMapping()
	dm.AddFieldMappingsAt("type", keywordFieldMapping)
	dm.AddFieldMappingsAt("id", jaTextFieldMapping)
	dm.AddFieldMappingsAt("author", jaTextFieldMapping)
	dm.AddFieldMappingsAt("text", jaTextFieldMapping)

これは、ドキュメントが

  • type
  • id
  • author
  • text

のフィールドを持っていて、それぞれ keywordFieldMappingjaTextFieldMapping で解析してほしいことを示しています。今回、keywordFieldMapping で示されるのは、bleve で用意している analyzer で、テキストをキーワードとして対応します。jaTextFieldMapping で用意しているのは、この後出てくる日本語形態素解析の analyzer です。"ja" という名前は後で設定されるカスタムアナライザーの名前です。

具体的には以下のような JSON のドキュメントに対応します:

{
    "type": "book",
    "id": "1:赤い蝋燭と人魚", 
    "author": "小川未明", 
    "text": "人魚は南の方の海にばかり棲んでいるのではありません"
}

CustomTokenizer, CustomAnalyzer

日本語形態素解析の tokenizer とそれを利用した analyzer を用意します。tokenizer には以下のオプションが指定できます。

オプション 内容
dict "ipa" もしくは "uni" IPA辞書もしくは UniDic が選べます
base_form bool値 動詞 / 形容詞 / 形容動詞 を辞書形(食べ→食べる)に直します
stop_tags bool値 lucene の stop tagsの定義に従って、品詞のフィルターをかけます

指定方法:

im.AddCustomTokenizer("ja_tokenizer", map[string]any{
		"type":      ja.Name,
		"dict":      ja.DictIPA,
		"base_form": true,
		"stop_tags": true,
	})

tokenizer を設定できたら、これを元に analyzer を指定します。上で用意した ja_tokenizer の他に ja.StopWordsName を token_filters に指定しています。これは、格助詞などの形態素を取らないようにするためのものです。落とす形態素のリストは lucene のもの に従います。あとは好みで、CharFilter や TokenFileter などを足すことも出来ます。下記の例では lowercase フィルターが追加されています。

im.AddCustomAnalyzer("ja", map[string]any{
	"type":      custom.Name,
	"tokenizer": "ja_tokenizer",
	"token_filters": []string{
		ja.StopWordsName,
		lowercase.Name,
	},
})

Index

IndexMapping が用意できたら、いよいよ Index を作成できます。今回はサンプルなので、Index はオンメモリだけの作成にしています。Index を作成するときに、先ほどの IndexMapping が必要になります。

// index
index, err := bleve.NewMemOnly(im)
defer index.Close()

ディスク上に作成したい場合は、

bleve.Open("sample.book", im)

などとするとディレクトリが作成されてそこに Index が保存されます。

Index にドキュメントを登録する処理は以下になります。

// documents
docs := []string{
	`{"type": "book", "id": "1:赤い蝋燭と人魚", "author": "小川未明", "text": "人魚は南の方の海にばかり棲んでいるのではありません"}`,
	`{"type": "book", "id": "2:吾輩は猫である", "author": "夏目漱石", "text":   "吾輩は猫である。名前はまだない"}`,
	`{"type": "book", "id": "3:狐と踊れ", "author": "神林長平", "text": "踊っているのでなければ踊らされているのだろうさ"}`,
	`{"type": "book", "id": "4:ダンスダンスダンス", "author": "村上春樹", "text": "音楽の鳴っている間はとにかく踊り続けるんだ。おいらの言っていることはわかるかい?"}`,
}
// indexing
for _, doc := range docs {
	var data map[string]any
	if err := json.Unmarshal([]byte(doc), &data); err != nil {
		log.Printf("SKIP: failed to unmarshal doc: %v, %s", err, doc)
		continue
	}
	if err := index.Index(data["id"].(string), data); err != nil {
		return fmt.Errorf("error indexing document: %w", err)
	}
	// printing ...
	fmt.Printf("indexed document with id:%s, %s\n", data["id"], doc)
}

Index 時に id として渡すのは、適当な文字列なら何でもいいです。例えばドキュメントの実際のファイル名とかです。今回は分かりやすいように id をデータとして用意しています。
注意としては、index.Index() に渡すデータは、Unmarshal したデータである。ということです(JSON を []byte のまま渡しても動きますが、Mapping が上手く働かないです)。

検索

実際に登録したデータを検索してみます。今回は 踊る人形 というクエリで text フィールドを検索してみます。結果として、

{"type": "book", "id": "3:狐と踊れ", "author": "神林長平", "text": "踊っているのでなければ踊らされているのだろうさ"}
{"type": "book", "id": "4:ダンスダンスダンス", "author": "村上春樹", "text": "音楽の鳴っている間はとにかく踊り続けるんだ。おいらの言っていることはわかるかい?"}

がヒットします。これは、text踊る が入っているのでヒットしています。注目してほしいのは、どちらのドキュメントにも 踊る は入っていないことです。含まれているのは 踊ら や 踊り ですが、base_form の正規化の処理が入るので 踊る でインデックスされていて検出されています。

これで日本語を含むドキュメントでも形態素解析して全文検索できそうです ╭( ・ㅂ・)و ̑̑ グッ

Happy hacking!

Discussion