give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

Lucene.NetをC#で使ってみた

とある案件でデータベースのLIKE検索よりも全文検索を使ってみたほうが良いのではないか思うものがあったので、全文検索について調べてみました。
.NET環境ではLucene.Netというツールがあるみたいです。


全文検索エンジンLucene.Net」を使う
http://www.atmarkit.co.jp/fdotnet/vblab/extcompo_06/lucenenet_01.html


上のページを参考にすると大体Lucene.Netの仕組みが理解できます。
今回はこの情報に加えて、自分でサンプルコードを動かしながら気づいた点や注意した方がよい点についてまとめておきます。

  • JapaneseAnalyzerというライブラリに不具合があるようで、大量にインデックスを作成するとメモリー使用量がどんどん増えて最後にはOut of Memory Errorが発生します。自分で修正する場合はMeCabTokenizer.csというファイルを修正して再コンパイルする必要があります。現在は修正されています。(2010.9.6)
public MeCabTokenizer(TextReader _in)
{
    input = _in;
    mecab = new Mecab("-Ochasen");
    init();
    mecab.mecab_destroy(); //この一行を追加してメモリーを解放する
}
  • 大量のインデックスを作成する場合はIndexWriterのMaxBufferedDocsを増やした方がパフォーマンスが良くなります。
IndexWriter writer = new IndexWriter("hogehoge", new JapaneseAnalyzer("piyopiyo"), true);;
writer.SetMaxBufferedDocs(500); //詳しくは http://lucene.jugem.jp/?eid=72 を参照
  • 約9万5000件、1件あたり平均150文字程度のデータをデータベースから取得してインデックスを作成したときにかかった時間は10分強でした。ただし、前述したMaxBufferedDocs等のパラメータを調整すればもっとパフォーマンスはあがるはずです。
  • 上記で変換した9万5000件のデータを検索したところ、全文検索はストレスなく一瞬で検索が完了しました(0.02秒ぐらい)。しかし、データベース上でLIKE検索した場合も1秒程度で返ってきたので、体感上劇的な差があるとまでは言えませんでした。ちなみにLuceneはローカルPC内で、またデータベースはネットワーク経由でアクセスしています。
  • MeCabと呼ばれる形態素解析ツールはサーバー側にもインストールしないといけないようです。ローカル環境で動いているだけじゃダメみたいですね。
  • 表記のゆれ(全角半角や大文字小文字の違い)や活用形の違いを全文検索エンジン側である程度吸収してくれるので、LIKE検索よりもヒット率が上がることもあります(ちょっとGoogleっぽい感じかも)。
  • 一方で、文章や単語によっては形態素解析がうまくいかず、LIKE検索の方がヒット率が上がることもあります。


他にも色々とチューニングテクニックや落とし穴があるのかもしれませんが、今回は簡単に試してみただけなので、とりあえずこれだけです。
なお、最近ではデータベース側でも全文検索の機能があるようなので、データがデータベースに存在している場合はデータベース側の全文検索機能を使ったほうが良いかもしれません(詳細未調査)。


最後に@ITのサンプルコードがVB.NETで書かれているので、C#派の方のためにC#版のサンプルコードを載せておきます。
あくまでサンプルコードを単純にC#に書き換えただけなので、C#らしくないコーディングになっている箇所もありますが、ご了承ください。


「分解されたトークンを確認してみる」で書かれているコード

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.IO;
using System.Text;
using System.Windows.Forms;

using Lucene.Net.Index;
using Lucene.Net.Documents;
using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Ja;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Search;
using Lucene.Net.QueryParsers;

namespace WindowsApplication //ネームスペースはお好みで
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
            textBox1.Text = "ここに解析する文章を入力してください。";
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Analyzer an;
            an = new JapaneseAnalyzer("analyzer-mecab.xml");
            StringReader reader = new StringReader(textBox1.Text);
            TokenStream stm = an.TokenStream("", reader);
            displayTokens(stm);
        }

        private void displayTokens(TokenStream stream)
        {
            Lucene.Net.Analysis.Token tkn;
            string s = "";            
            while(true)
            {
                tkn = stream.Next();
                if (tkn == null){
                    break;
                }
                string text = tkn.TermText();
                int pos = tkn.GetPositionIncrement();
                int start = tkn.StartOffset();
                int send = tkn.EndOffset();
                string type = tkn.Type();
                // pos : 直前のトークン位置からの増分(通常は常に1)
                // start: トークンの開始位置
                // send: トークンの終了位置
                // type: 品詞情報
                s = s + String.Format("{0} - {1} - {2} - {3} [{4}]", 
                    text, pos, start, send, type) + Environment.NewLine;
            }
            textBox2.Text = s;
        }
    }
}


「IndexWriterクラスとDocumentクラスでインデックスを作成」で書かれているコード

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;

using Lucene.Net.Index;
using Lucene.Net.Documents;
using Lucene.Net.Analysis.Ja;

namespace ConsoleApplication //ネームスペースはお好みで
{
    class Program
    {
        static void Main(string[] args)
        {
            //Lucene.Netのインデックスを保存する場所
            string indexPath = @"c:\lucene-index";
            IndexWriter writer = null;
            string sBaseUrl= @"http://www.atmarkit.co.jp";

            //インデックスが存在するかどうか
            bool bExist = IndexReader.IndexExists(indexPath);
            if (bExist)
            {
                Directory.Delete(indexPath, true);
                Directory.CreateDirectory(indexPath);
            }
            writer = new IndexWriter(indexPath, 
                new JapaneseAnalyzer("analyzer-mecab.xml"), true);
            try
            {
                XmlNodeList nodeList;
                string file = "sample.xml";
                XmlDocument xmlDoc  = new XmlDataDocument();

                xmlDoc.Load(file);
                nodeList = xmlDoc.SelectNodes("/data/tip");

                //<tip>要素を処理する
                foreach (XmlNode nd in nodeList)
                {
                    string href = nd.Attributes["href"].Value;
                    string title = nd.InnerText;
                    string tipID = nd.Attributes["id"].Value;

                    Console.WriteLine("{0}:{1}", tipID, title);

                    // Documentオブジェクトの作成
                    Document doc = new Document();

                    // Fieldオブジェクトの作成
                    Field fldTitle = new Field("title", title,
                              Field.Store.YES, Field.Index.TOKENIZED);
                    Field fldUrl = new Field("url", sBaseUrl + href,
                              Field.Store.YES,Field.Index.UN_TOKENIZED);
                    Field fldTipID = new Field("tipid", tipID,
                              Field.Store.YES, Field.Index.UN_TOKENIZED);

                    // Documentオブジェクトにフィールドを追加
                    doc.Add(fldTitle);
                    doc.Add(fldUrl);
                    doc.Add(fldTipID);

                    // インデックスにDocumentオブジェクトを追加
                    writer.AddDocument(doc);
                }
                writer.Optimize(); // インデックスを最適化する
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
            }
            finally
            {
                writer.Close();
            }         
        }
    }
}


「検索アプリケーションを作成」で書かれているコード

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;

using Lucene.Net.QueryParsers;
using Lucene.Net.Search;
using Lucene.Net.Documents;
using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Ja;

namespace WindowsApplicationSearch //ネームスペースはお好みで
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            string str = textBox1.Text;

            //日本語アナライザの準備
            Analyzer analyzer = new JapaneseAnalyzer("analyzer-mecab.xml");

            QueryParser queryPsr = new QueryParser("title", analyzer);
            Query query = queryPsr.Parse(str);

            IndexSearcher searcher = new IndexSearcher(@"c:\lucene-index");
            Hits hits = searcher.Search(query);

            string s = "";

            for (int i = 0; i < hits.Length(); i++)
            {
                Document doc = hits.Doc(i);
                int id = hits.Id(i);
                string url = doc.Get("url");
                string title = doc.Get("title");
                double score = hits.Score(i); // スコア
                s += string.Format("{0:0.000}:{1}({2}){3}",
                    score, title, url, Environment.NewLine);
            }
            textBox2.Text = s;
        }
    }
}


「検索ページ」で書かれているコード

using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

using Lucene.Net.QueryParsers;
using Lucene.Net.Search;
using Lucene.Net.Documents;
using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Ja;

public partial class _Default : System.Web.UI.Page 
{
    protected void Page_Load(object sender, EventArgs e)
    {

    }
    protected void Button1_Click(object sender, EventArgs e)
    {
        string searchWord = TextBox1.Text;

        Analyzer analyzer = new JapaneseAnalyzer(@"C:\inetpub\analyzer-mecab.xml");

        QueryParser queryPsr= new QueryParser("title", analyzer);
        Query query = queryPsr.Parse(searchWord);

        IndexSearcher searcher = new IndexSearcher(@"c:\inetpub\lucene-index");
        Hits hits = searcher.Search(query);

        DataTable tbl = new DataTable("Result");
        DataRow row;

        tbl.Columns.Add("Url", System.Type.GetType("System.String"));
        tbl.Columns.Add("Title", System.Type.GetType("System.String"));
        tbl.Columns.Add("Score", System.Type.GetType("System.String"));

        Label1.Text = hits.Length().ToString(); // ヒット数
        
        for (int i = 0; i < hits.Length(); i++)
        {
            Document doc = hits.Doc(i);
            string url = doc.Get("url");
            string title = doc.Get("title");
            Single score = hits.Score(i);

            row = tbl.NewRow();
            row["Url"] = url;
            row["Title"] = title;
            row["Score"] = string.Format("{0:0.000}", score);
            tbl.Rows.Add(row);
        }
        MyDataList.DataSource = tbl;
        MyDataList.DataBind();
    }
}