C#で簡単に全文検索機能を取り入れる

目的

お手軽開発に使う目的で,全文検索エンジン Lucene.Netを試しに使ってみます.

C#をサポートするオープンソース全文検索エンジン

オープンソース全文検索エンジンはいくつかありますが,C#で書かれている,もしくはC#からの呼び出しを開発元がサポートしているものは,Lucene.Netしかありませんでした.
蛇足ですけど,C++呼び出しできるならInteropでごにょっとすればいいでしょというつっこみはありです.ですが,アプリケーション組み込みの検索エンジンでは,アプリでやりたいことが実現できるAPIが必要で,さらに検索対象になるドキュメントの単語1つ1つに属性をつけたり,アプリケーション依存のコードを検索エンジンに組み込みたくなるわけです.このような,単なる外部呼出しAPIとは設計思想が違うものは,開発時点でそのように作られていないとだめなわけで.
そこの辺りをLuceneが満たしていたわけです.ざっとエンジンへの要求を書いてみると:

  1. 純粋に検索エンジンだけが切り離して提供されていること
    1. アプリケーションへの組み込みには,クローラとか文書からのテキスト抽出とか不要です,
  2. ファイルのメタ情報を扱えて,かつそれらの検索設定が可能,
    1. アプリケーションではファイルから抽出した属性情報を扱えないと使い物にならないので,これがとっても重要
  3. インデックスの元になるTermなどを,アプリケーションからいじれる
    1. Wikiエンジン開発に使うため,Wikinameのリンク構造の逆引きするのに,Wiki構文からWikiリンクだけ抜き出して検索対象にしておくと便利なのです,
    2. テキストをTermに分解するときに分かる属性情報をTermにつけて保存すると,テキストに加えてリンク構造の検索など,応用が広がります,
  4. 汎用のドキュメントストレージ機能がある,
    1. お手軽開発ではドキュメントのストレージをどうするかは,いろいろ悩みどころになっちゃいます,
    2. ドキュメントストレージとして使うために,本文の保存をする/しないが選択可能というのはあって欲しい機能,

このあたり.

ちょうど@ITに記事がありました

と記事を書いていたところ,ちょうど@IT http://www.atmarkit.co.jp/fdotnet/vblab/extcompo_06/lucenenet_01.html 連載:VBで実践! 外部コンポーネント活用術
全文検索エンジンLucene.Net」を使う,が一昨日27日に掲載されました.
この中で分かち書きにより形態素解析しています.この方法では辞書ファイルが必要になり,ちょっとお手軽ではなくなります.そこで,ここでは辞書ファイルが不要なN-gramでいってみます.

デフォルトそのままでいくと,monogramになっていました

最後に掲載しているソースコードでは,英単語は単語単位でトークンになっていましたが,日本語はmonogramになっていました.
Java版ならばbi-gramなCJKAnalyserがcontribにあるらしいですが,Lucene.Netのファイル群には見あたりません.
monogramを使うと,検索結果にノイズが増える(らしい)ですが,例えば"蚊"のような1文字の検索ができます.高速なgrepとして考えると,monogramで十分に目的を満たすかもしれません.

ちゃんと書くべきだけど試していないので項目羅列だけする

このエントリみたいに,ちょっと使ってみました記事はいくらもあるのですよ.ぶっちゃけ.
検索を実用にするには, ドキュメントに入れるフィールドをどうするかという,Lucene.Netに限らない一般的なデータベースの作り方とかの運用の解説は不可欠かなと.
Lucene.Netを実用で使いこなすためのクラス紹介とかも必要でしょうし.たとえば,メモリいっぱい搭載した電源も落ちないサーバだとインメモリで運用したいかもしれない,それならストレージにLucene.Net.Store.RAMDirectoryを使えばよいとか.
またLucene自体の機能紹介には,検索式の集合和とかの論理式,正規表現の書き方,そしてあいまい検索式の例とかを出してこないと.
Luceneをもっと便利にする,というか実用で使うとこの機能欲しいだろうという要求があって作られたのだろうけど,強調表示(contribute/Highlighter.Net), 語尾や活用を正規化(するのか?)してそれら変化に強い検索(contribute/SnowBall.Net),類語検索(contribute/WordNet.Net),さらに分散検索(DistributedSearch.Net)を,中に入っている技術と,そのコードの書き方と,使いこなし方との3点を押さえて紹介しないと,実用にはならんですよね.
そこら辺のことが押さえられているのは, http://www.google.co.jp/search?q=Lucene+in+Action&lr=lang_ja&ie=utf-8&oe=utf-8&aq=t&rls=org.mozilla:ja:official&client=firefox-a この書籍かな.著者二人は検索の研究が本業な人とLuceneの開発している人だし,目次を見るだけでも重要な情報が得られるから中身は推して知るべしと思えるし. って書いていたら第2版がでるのね. http://www.lucidimagination.com/blog/2009/03/11/lia2/ に2009年4月1日までの限定40%オフのクーポンコードが掲載されていた. これを使うと電子版だと17米ドルと安価.早速購入してみると,"XXX"みたいな表記がたくさん残っているけど気にせず読むと,良い本でした.

正規表現での全文検索との使い分け

感覚的に,ファイル数1000程度であればSystem.Text.Regex正規表現でも処理速度は十分です.そこで,全文検索エンジンを導入すると嬉しくなる境界がどのあたりにあるのかを調べてみました.
検索対象にブログの原稿テキスト(ファイル数600, 総ファイルサイズ 2.6MByte)を使い,以下の場合それぞれについて ThinkPadX61s (Core2Duo 1.4GHz,メモリ2.5Gbyte,WindowsXP)での処理時間を求めました.

  1. 逐次,ファイルを読み込み正規表現で検索していく.
  2. 全てのファイルをメモリに読み込み,その後正規表現で検索する.
  3. Lucene.Netでインデックスファイルを構築して,検索する.

1の方法では,初回実行時は250msec,2回目以降の検索は78msecでした.一旦読み込んだファイルがキャッシュされたために2回目以降の検索時間が短縮されたと推測します.
2の方法では,メモリ上の文字列検索は0msecでした.処理時間の計測が15msec程度を単位としているようで,これより短い時間で処理が終了するために計測できなかったようです.使用したパソコンのメモリ読み込み速度は100Mバイト/秒程度でしたから,メモリ上のデータを正規表現で検索かけるとプロセッサのデータキャッシュは意味をなさないでしょうから,処理速度はメモリ読み込み速度で律速されそうです.
3の方法では,インデックスファイル構築に700msec,検索に15~30msec程度でした.
これから,検索処理を100msec以内に完了させるとして,ファイルを逐次検索すると,4MByte程度のテキストであれば十分に処理可能でした. また,いまどきのパソコンであればメモリを100Mbyteの単位で消費できます.全てのファイルをメモリ上に読み込んでしまえば,メモリ読み込み速度から10Mbyte程度なら正規表現クラスでも処理可能かなと思います.IOキャッシュが効くためでしょうが,私の予測と違い,ディスクから逐次ファイルを読み出しても結構な処理速度がでました.
正規表現での検索には文字列を逐次読む処理が必須ですから,この方法はメモリの読み込み速度で律速されそうです.メモリ周りの速度はプロセッサの計算能力ほど倍々では伸びませんから,この速度評価結果は数年単位で有効かなと思います.
で,さすがに処理に1秒もかかると問題になりそうです.先ほどの結果から,全文検索エンジンを導入して嬉しくなる文書サイズは100Mbyteを越えたあたりかなと思います.

Lucene.Netを使う手順

情報源

クラス構成は http://www.codeproject.com/KB/library/IntroducingLucene.aspx がよくまとまっています.
実際のコードの書き方は,Demo以下のコードが簡略で読みやすくよくまとまっています.
検索式の実例は, http://www.codeplex.com/linqtolucene の例文が大変分かりやすいです.

ダウンロード

まずLucene.Netをダウンロードします.
ZIPファイルで提供されているソースコードは2007年5月のバージョン2.0.004と少し古いために,SVNレポジトリからソースコードを入手します.Subversionをインストールして,以下のコマンドで入手しました:
$ svn co https://svn.apache.org/repos/asf/incubator/lucene.net/tags/Lucene.Net_2_3_1/
ソースコードを入手したならば,Visual Studioのソリューションファイルがありますから,それでビルドすればLucene.dllが出来上がります.ディレクトリ Demo には,インデックスファイルの構築,検索,およびインデックスからのファイル削除などの実例があります.

Demoを見ていると,Lucene.Netは使いやすいと思います.LuceneのDocumentは単なるユーザが定義するFieldの集合体ですから, 例えば文書ごとにカスタムな属性情報を追加するなど,検索すべき情報の設定が簡単です.またこのFieldは文書ごとに設定できますから,内容や種類が違う扱いの異なるファイルを混在させた運用ができそうです.

コードを書く

Demoからコピーして貼り付けると,こんなコードになりました.使い方は,まずインデックスを格納するフォルダを指定してコンストラクタを呼びます.次にデータファイルが格納されいてるフォルダを指定してBuildIndexメソッドを呼び出して,インデックスを構築します.最後にSearchメソッドで検索します.

  class SearchByLucene
    {
        #region Variables
        String _index_folder;
        #endregion

        #region Constructor
        public SearchByLucene(String index_folder)
        {
            _index_folder = index_folder;
        }
        #endregion

        #region Public methods
        //テキストファイルをLuceneのDocumentオブジェクトにします
        Lucene.Net.Documents.Document GetDocument(String file_path)
        {
            Lucene.Net.Documents.Document doc = new Lucene.Net.Documents.Document();
            doc.Add(new Lucene.Net.Documents.Field("path", file_path, Lucene.Net.Documents.Field.Store.YES, Lucene.Net.Documents.Field.Index.UN_TOKENIZED));
            doc.Add(new Lucene.Net.Documents.Field("content", File.ReadAllText(file_path), Lucene.Net.Documents.Field.Store.YES, Lucene.Net.Documents.Field.Index.TOKENIZED));
            return doc;
        }

        // インデックスファイルを構築
        public void BuildIndex(String data_folder)
        {
            Lucene.Net.Index.IndexWriter writer = new Lucene.Net.Index.IndexWriter(_index_folder, new Lucene.Net.Analysis.Standard.StandardAnalyzer());
            foreach (String fpath in Directory.GetFiles(data_folder))
                writer.AddDocument(GetDocument(fpath));
            writer.Optimize();
            writer.Close();
        }
        // 検索
        public String Search(String searchText)
        {
            StringBuilder report = new StringBuilder();

            Lucene.Net.Index.IndexReader reader = Lucene.Net.Index.IndexReader.Open(_index_folder);
            Lucene.Net.Search.Searcher searcher = new Lucene.Net.Search.IndexSearcher(reader);
            Lucene.Net.Analysis.Analyzer analyzer = new Lucene.Net.Analysis.Standard.StandardAnalyzer();
            Lucene.Net.QueryParsers.QueryParser parser = new Lucene.Net.QueryParsers.QueryParser("content", analyzer);
            Lucene.Net.Search.Query query = parser.Parse(searchText);
            Lucene.Net.Search.Hits hits = searcher.Search(query);
            report.AppendFormat("Find {0} documents. \n", hits.Length());
            for (int i = 0; i < hits.Length(); i++)
            {
                Lucene.Net.Documents.Document doc = hits.Doc(i);
                report.AppendFormat(" ID:{0} Score:{1} Path:{2}.\n", hits.Id(i), hits.Score(i), doc.Get("path"));
            }
            return report.ToString();
        }
        #endregion
    }