CLOVER🍀

That was when it all began.

Luceneでドキュメントの更新

最近、Elasticsearchの本を読みまして。

高速スケーラブル検索エンジン ElasticSearch Server (アスキー書籍)

高速スケーラブル検索エンジン ElasticSearch Server (アスキー書籍)

こちらを読んでいて、ふと気になったのがドキュメントの更新。登録する時のソース(JSON)を持っていれば、ユニークキーを指定してのドキュメントの更新が可能だとか。

そういえば、Solrも近い話でしたねぇ。更新そのものはできないので、更新する/しないに関わらず全部のフィールドを送ることとなっていました。

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)

以前自分で入門的にドキュメントの追加、削除を書いた時も、そういえば更新はしていませんでした。

チュートリアルを参考に、インデックスの登録、検索、削除を書いてみる
http://d.hatena.ne.jp/Kazuhira/20130608/1370709395

よい機会です。これを機に、ちょっとやってみましょう。

パッと見た感じ、IndexWriter#updateDocumentなどを使えばよさそうです。あと、こちらのサイトも参考にしました。

Updating Document Fields in Lucene
http://hrycan.com/2009/11/26/updating-document-fields-in-lucene/

では、続けて。

準備

まずは、依存関係の定義。
build.sbt

name := "lucene-document-update"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.4"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked")

val luceneVersion = "4.7.1"

libraryDependencies ++= Seq(
  "org.apache.lucene" % "lucene-analyzers-kuromoji" % luceneVersion
)

mainメソッドを持ったクラスの雛形。
src/main/scala/org/littlewings/lucene/update/LuceneUpdateDocument.scala

package org.littlewings.lucene.update

import scala.collection.JavaConverters._

import org.apache.lucene.analysis.Analyzer
import org.apache.lucene.analysis.ja.JapaneseAnalyzer
import org.apache.lucene.document.{Document, Field, StringField, TextField}
import org.apache.lucene.index.{DirectoryReader, IndexableField, IndexWriter, IndexWriterConfig, Term}
import org.apache.lucene.search.{IndexSearcher, MatchAllDocsQuery, Sort, SortField, TermQuery, TopFieldCollector}
import org.apache.lucene.store.{Directory, RAMDirectory}
import org.apache.lucene.util.Version

object LuceneUpdateDocument {
  def main(args: Array[String]): Unit = {
  }

  implicit class CloseableWrapper[A <: AutoCloseable](val underlying: A) extends AnyVal {
    def foreach(fun: A => Unit): Unit =
      try {
        fun(underlying)
      } finally {
        underlying.close()
      }
  }
}

AutoCloseableをfor式、というかforeachが使えるように、Implicit ClassかつValue Classを定義。

以下、ちょっとずつ書いていきましょう。

ドキュメントの登録と全件検索

まずは、Analyzerの作成メソッド。

  private def createAnalyzer(version: Version): Analyzer =
    new JapaneseAnalyzer(version)

ドキュメントを作成するためのメソッドを用意。

  private def createDocument(entry: Map[String, Any]): Document = {
    val document = new Document
    document.add(new StringField("isbn", entry("isbn").toString, Field.Store.YES))
    document.add(new TextField("title", entry("title").toString, Field.Store.YES))
    document.add(new StringField("price", entry("price").toString, Field.Store.YES))
    document.add(new TextField("summary", entry("summary").toString, Field.Store.YES))
    document
  }

今回も、ネタは書籍です。

これらを使って、インデックスにドキュメントを登録していきます。

  private def registerDocuments(directory: Directory, version: Version, analyzer: Analyzer): Unit =
    for (writer <- new IndexWriter(directory,
                                   new IndexWriterConfig(version, analyzer))) {
      Array(
        createDocument {
          Map("isbn" -> "978-4774127804",
              "title" -> "Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
              "price" -> 3360,
              "summary" -> "Luceneは全文検索システムを構築するためのJavaのライブラリです。")
        },
        createDocument {
          Map("isbn" -> "978-4774161631",
              "title" -> "[改訂新版] Apache Solr入門 オープンソース全文検索エンジン",
              "price" -> 3780,
              "summary" -> "最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。")
        },
        createDocument {
          Map("isbn" -> "978-4797352009",
              "title" -> "集合知イン・アクション",
              "price" -> 3990,
              "summary" -> "レコメンデーションエンジンをつくるには?ブログやSNSのテキスト分析、ユーザー嗜好の予測モデル、レコメンデーションエンジン……Web 2.0の鍵「集合知」をJavaで実装しよう!")
        }
      )
      .foreach(writer.addDocument)

      writer.commit()
    }

3冊登録。

そして、登録したドキュメントを全件検索して表示するメソッド。

  private def printAllDocuments(directory: Directory, version: Version): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val searcher = new IndexSearcher(reader)
      val allQuery = new MatchAllDocsQuery
      val limit = 1000

      val collector =
        TopFieldCollector
          .create(new Sort(new SortField("price", SortField.Type.INT, true)),
                  limit,
                  true,
                  false,
                  false,
                  false)

      searcher.search(allQuery, collector)

      val topDocs = collector.topDocs
      val hits = topDocs.scoreDocs

      hits.foreach { h =>
        val hitDoc = searcher.doc(h.doc)
        println(s"Doc, id[${h.doc}]:" + System.lineSeparator +
                hitDoc
                  .getFields
                  .asScala
                  .map(f => s"${f.name}:${f.stringValue}")
                  .mkString("  ", System.lineSeparator + "  ", ""))
      }
    }

ソート順は、価格の高い順です。

これらを使用して、mainメソッドを実装します。

  def main(args: Array[String]): Unit = {
    val version = Version.LUCENE_CURRENT
    val analyzer = createAnalyzer(version)

    for (directory <- new RAMDirectory) {
      registerDocuments(directory, version, analyzer)

      printAllDocuments(directory, version)
    }
  }

実行すると、こんな結果になります。

Doc, id[2]:
  isbn:978-4797352009
  title:集合知イン・アクション
  price:3990
  summary:レコメンデーションエンジンをつくるには?ブログやSNSのテキスト分析、ユーザー嗜好の予測モデル、レコメンデーションエンジン……Web 2.0の鍵「集合知」をJavaで実装しよう!
Doc, id[1]:
  isbn:978-4774161631
  title:[改訂新版] Apache Solr入門 オープンソース全文検索エンジン
  price:3780
  summary:最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。
Doc, id[0]:
  isbn:978-4774127804
  title:Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築
  price:3360
  summary:Luceneは全文検索システムを構築するためのJavaのライブラリです。

登録したドキュメントを更新する

では、ここから、登録済みのドキュメントを更新するためのメソッドを作成します。

  private def updateDocument(directory: Directory,
                             version: Version,
                             analyzer: Analyzer,
                             term: Term,
                             fields: IndexableField*): Unit =
    for {
      writer <- new IndexWriter(directory,
                                new IndexWriterConfig(version, analyzer))
      reader <- DirectoryReader.open(directory)
    } {
      val searcher = new IndexSearcher(reader)
      val query = new TermQuery(term)

      val hits = searcher.search(query, null, 1).scoreDocs
      
      if (hits.size > 0) {
        val hitDoc = searcher.doc(hits(0).doc)

        fields.foreach { f =>
          hitDoc.removeField(f.name)
          hitDoc.add(f)
        }

        writer.updateDocument(term, hitDoc)

        writer.commit()
      }
    }

IndexWriter#updateDocumentメソッドを使用することで、指定したTermで取得できるドキュメントをアトミックに削除/追加することができるらしいです。

引数がTermということで、ElasticsearchやSolrがユニークキーを必要とするのは、きっとこういうことなんだなぁと思ったり。

ですので、同じTermで引けた方がよいだろうということで、TermQueryでドキュメントを検索。

      val searcher = new IndexSearcher(reader)
      val query = new TermQuery(term)

      val hits = searcher.search(query, null, 1).scoreDocs

上記クエリは、Termで指定した内容で検索結果が1意に決まる前提で書いています。

検索結果があれば、それに対して更新を行います。

      if (hits.size > 0) {
        val hitDoc = searcher.doc(hits(0).doc)

        fields.foreach { f =>
          hitDoc.removeField(f.name)
          hitDoc.add(f)
        }

        writer.updateDocument(term, hitDoc)

        writer.commit()
      }

この時、ドキュメントの最初の1件を取得して、その中のフィールドを新しい値(このメソッドの引数)で置き換えます。いったん削除して、追加。

        fields.foreach { f =>
          hitDoc.removeField(f.name)
          hitDoc.add(f)
        }

つまり、過去に登録したドキュメントの値を自分で変えているわけですね。

最後、更新。

        writer.updateDocument(term, hitDoc)

        writer.commit()

コミットも付けて。

IndexWriter#updateDocumentはIndexableFieldインターフェースを実装したクラス(各種Field)をIterableの形式で与えるように宣言されていますが、DocumentクラスもIterableなIndexableFieldとして定義されているので、ここではDocumentそのものを与えました。

public void updateDocument(Term term,
                  Iterable<? extends IndexableField> doc)

まあ、後で出しますがFieldのListなどで指定する場合は、更新後にDocumentとして持ちたいFieldを全部指定することになりますので、素直にDocumentを渡すでいいのかなぁ?

これらを全部合わせると、呼び出し元はこんな感じに。

  def main(args: Array[String]): Unit = {
    val version = Version.LUCENE_CURRENT
    val analyzer = createAnalyzer(version)

    for (directory <- new RAMDirectory) {
      registerDocuments(directory, version, analyzer)

      printAllDocuments(directory, version)

      println("==================================================")

      updateDocument(directory,
                     version,
                     analyzer,
                     new Term("isbn", "978-4774127804"),
                     new StringField("price", "5000", Field.Store.YES))

      updateDocument(directory,
                     version,
                     analyzer,
                     new Term("isbn", "978-4797352009"),
                     new TextField("title", "【集合知イン・アクション】", Field.Store.YES),
                     new StringField("price", "2000", Field.Store.YES))

      printAllDocuments(directory, version)
    }
  }

一部テキストの装飾を買えたり、価格を変更したりしています。

実行してみましょう。

before

Doc, id[2]:
  isbn:978-4797352009
  title:集合知イン・アクション
  price:3990
  summary:レコメンデーションエンジンをつくるには?ブログやSNSのテキスト分析、ユーザー嗜好の予測モデル、レコメンデーションエンジン……Web 2.0の鍵「集合知」をJavaで実装しよう!
Doc, id[1]:
  isbn:978-4774161631
  title:[改訂新版] Apache Solr入門 オープンソース全文検索エンジン
  price:3780
  summary:最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。
Doc, id[0]:
  isbn:978-4774127804
  title:Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築
  price:3360
  summary:Luceneは全文検索システムを構築するためのJavaのライブラリです。

after

Doc, id[3]:
  isbn:978-4774127804
  title:Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築
  summary:Luceneは全文検索システムを構築するためのJavaのライブラリです。
  price:5000
Doc, id[1]:
  isbn:978-4774161631
  title:[改訂新版] Apache Solr入門 オープンソース全文検索エンジン
  price:3780
  summary:最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。
Doc, id[4]:
  isbn:978-4797352009
  summary:レコメンデーションエンジンをつくるには?ブログやSNSのテキスト分析、ユーザー嗜好の予測モデル、レコメンデーションエンジン……Web 2.0の鍵「集合知」をJavaで実装しよう!
  title:【集合知イン・アクション】
  price:2000

よくよく見ると、ドキュメントのIDが振り直されていますね。また、フィールドの位置もずれています。

ドキュメントのIDが変わったのは、削除してから追加したからなのでしょう。フィールドの順番が変わっているは、更新分のフィールドをは最後に追加する挙動になっているからですね。

なお、今回はこのようなコードで

        fields.foreach { f =>
          hitDoc.removeField(f.name)
          hitDoc.add(f)
        }

フィールドの更新を確認することができましたが、これは実はドキュメントのフィールドがすべてStore.YESとなっていることが前提となっています。

仮に、Store.NOのフィールドがいた場合、上記ループにはStore.NOのフィールドは出現しないことになります。つまり、フィールドが減ってしまうわけですね。

単純に、ドキュメントを検索するTermと更新対象のフィールドのみを指定、というのはNGかと。この場合は、ドキュメントに残るのは更新対象のフィールドのみとなってしまいます。

まあ、面倒でなければSolrのように、更新しないフィールドも含めて全部ドキュメントの更新リクエストに含めるのがいいんでしょうかね。ちょっと納得した気がします。

今回のソースコードは、こちらにアップしておきました。

https://github.com/kazuhira-r/lucene-examples/tree/master/lucene-document-update