CLOVER🍀

That was when it all began.

Hibernate Searchで試す、日本語検索 - N-gram編

先ほど、Hibernate Searchを使った日本語検索ということで、Kuromojiを使った形態素解析を試したこんなエントリを書きました。

Hibernate Searchで試す、日本語検索 - 形態素解析編
http://d.hatena.ne.jp/Kazuhira/20150110/1420889429

※続きも書きました
Hibernate Searchで試す、日本語検索 - 形態素解析+N-gram編
http://d.hatena.ne.jp/Kazuhira/20150110/1420893906

今度は、N-gramを使ってみようと思います。

N-gramを使うといっても、正直にN-gramのTokenizerだけ使ってしまうと苦しい(フィルタによる正規化の機能は欲しくなる)ので、CJKAnalyzerを使います。

CJKAnalyzerは、以下の組み合わせで構成されています。

  • StandardTokenizer
  • CJKWidthFilter
  • LowerCaseFilter
  • CJKBigramFilter
  • StopFilter

英単語は小文字に寄せられてトークン化されますし、ストップワードもあります。

日本語については、Nが2として(bi-gram)トークン化されます。

…ところで、最近はCJKAnalyzerの名前を全然見ませんね。NGramTokenizerやNGramTokenFilterの方がよく見かける感じです。これらのTokenizerやFilterでAnalyzerを作るのが、今の感じなんでしょうか?

ま、進めてみましょう。使用するEntityやテーブル、Maven依存関係の定義は、先に書いたKuromojiの例と同じとします。

形態素解析の時と同じように使ってみる

それでは、形態素解析の時と同じように検索して、違いをみていってみます。

まずは最初の例。「東京」で検索。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    new Article("全文検索入門"),
                    new Article("文書に含まれる全文を対象とした検索"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("東京").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:東京");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("東京都は、日本の中枢だ");

これは、一緒の結果ですね。

次、「京都」で検索。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    new Article("全文検索入門"),
                    new Article("文書に含まれる全文を対象とした検索"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("京都").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:京都");
        assertThat(articles)
            .hasSize(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("東京都は、日本の中枢だ");
        assertThat(articles.get(1).getContents())
            .isEqualTo("京都は歴史のある都市だ");

bi-gramでトークン化された結果、「京都」で「東京都」がヒットするようになりました。

全角小文字「java」で検索。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    new Article("全文検索入門"),
                    new Article("文書に含まれる全文を対象とした検索"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("java").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:java");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("あなたとJAVA、今すぐダウンロード");

CJKAnalyzerだと、このあたりも面倒をみてくれて半角英字、小文字の「java」に寄せてくれます。

「東京スカイツリー」の例だと、結果は一緒ですが投げているクエリが全然違うことになります。

        addArticles(new Article("東京スカイツリー"),
                    new Article("ツリーといえば、クリスマス?"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("東京スカイツリー").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:東京 contents:京ス contents:スカ contents:カイ contents:イツ contents:ツリ contents:リー");
        assertThat(articles)
            .hasSize(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("東京スカイツリー");
        assertThat(articles.get(1).getContents())
            .isEqualTo("ツリーといえば、クリスマス?");

そりゃあ、N-gramですからね…。

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:東京 contents:京ス contents:スカ contents:カイ contents:イツ contents:ツリ contents:リー");

「全文検索」。
※しばらく、淡々と書きます

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    new Article("全文検索入門"),
                    new Article("文書に含まれる全文を対象とした検索"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("全文検索").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:全文 contents:文検 contents:検索");
        assertThat(articles)
            .hasSize(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("全文検索入門");
        assertThat(articles.get(1).getContents())
            .isEqualTo("文書に含まれる全文を対象とした検索");

「きゃりーぱみゅぱみゅ」。

        addArticles(new Article("きゃりーぱみゅぱみゅ"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("きゃりーぱみゅぱみゅ").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:きゃ contents:ゃり contents:りー contents:ーぱ contents:ぱみ contents:みゅ contents:ゅぱ contents:ぱみ contents:みゅ");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("きゃりーぱみゅぱみゅ");

「デジタル一眼レフ」。

        addArticles(new Article("デジタル一眼レフ"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("デジタル一眼レフ").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:デジ contents:ジタ contents:タル contents:ル一 contents:一眼 contents:眼レ contents:レフ");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("デジタル一眼レフ");
    }

「一眼レフ」。

        addArticles(new Article("一眼レフ"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("一眼レフ").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:一眼 contents:眼レ contents:レフ");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("一眼レフ");

どれも、N-gramなので「そりゃあそうだろう」という結果ですね。

Nより小さい単語をヒットさせられない

N-gramを使った場合の注意点としては、Nより小さい単語をヒットさせることができません。

形態素解析で書いた「水」の例では、こうなります。

        addArticles(new Article("飲料水"),
                    new Article("この町の水はおいしい"),
                    new Article("水性ボールペン"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("æ°´").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:æ°´");
        assertThat(articles)
            .hasSize(0);

ヒット件数0。

今回はbi-gram(N=2)なので、「水」という長さ1の単語は検索することができません。

これを回避する方法のひとつとしては、ワイルドカードクエリを使用します。

        addArticles(new Article("飲料水"),
                    new Article("この町の水はおいしい"),
                    new Article("水性ボールペン"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().wildcard().onField("contents").matching("æ°´*").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:æ°´*");
        assertThat(articles)
            .hasSize(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("この町の水はおいしい");
        assertThat(articles.get(1).getContents())
            .isEqualTo("水性ボールペン");

が、これでも「飲料水」はヒットさせられませんでした。「飲料」「料水」なので、「水*」の前方一致では一致する単語がないからです。

また、そもそもインデックスに登録する際も、N未満の単語は登録できないことになるので何かくっつけるなどの考慮が必要だったりします。

フレーズクエリを使う

続いて、先ほどの形態素解析の時と同様、フレーズクエリを使ってみます。

「全文検索」。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    new Article("全文検索入門"),
                    new Article("文書に含まれる全文を対象とした検索"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.phrase().onField("contents").sentence("全文検索").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:\"全文 文検 検索\"");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("全文検索入門");

「東京スカイツリー」。

        addArticles(new Article("東京スカイツリー"),
                    new Article("ツリーといえば、クリスマス?"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.phrase().onField("contents").sentence("東京スカイツリー").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:\"東京 京ス スカ カイ イツ ツリ リー\"");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("東京スカイツリー");

結果だけ見ると、形態素解析と同じヒットさせられているように見えます。

なんですが、「デジタル一眼レフ」に対して「一眼レフ」は形態素解析だとヒットさせられませんでしたが、N-gramだとできるようになります。

        addArticles(new Article("デジタル一眼レフ"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.phrase().onField("contents").sentence("一眼レフ").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:\"一眼 眼レ レフ\"");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("デジタル一眼レフ");

というわけで、同じような例を形態素解析とN-gram両方を使って書いてみました。

N-gramだと、それなりにヒット率をあげることができますし、辞書がないので流行語や新しい単語にも左右されませんが、ノイズが多い(予期せぬ対象がヒットすることも多い)というのが難点です。

これに対して形態素解析だと、検索精度は高いですが、辞書にない単語には滅法弱くなるわけです。

こういう性質を考慮して、形態素解析かN-gramか選んでいくわけですね…。