2012年4月13日金曜日

検索するSaaS

どうもどうも。月刊たけはらブログ。

先日AWSからCloudSearchが発表されて、なかなかホットな分野になりつつある検索SaaS。検索って言ってもサイトの検索じゃなくてドキュメントの検索。で、最近注目してたのがIndexTank。これをサービス提供してるSearchifyっていうのがあります。

Searchify - Hosted search as a service - IndexTank API

IndexTankで検索しても対して情報はないんだけど↓こんなです。

米LinkedIn、買収したIndexTankの検索技術をオープンソースに

IndexTankのサービス提供は IndexTank - hosted search you control っていうのが先にやってたんだけど、そこでのサービス提供がなくなって Searchify一本になった矢先のAWS CloudSearch。 熱い分野です!っていうか、選択肢のあまりない分野?

まぁ、そんなことはどうでもいいね。システムが提供する検索どーしよっかなー、自分でLuceneとか用意しようかなー、なんて考えてる人たくさんいますよね。SQLServerでのFulltext Searchか単なるLike検索か。規模が大きくなればなるほど、データが多くなればなるほど、困ったことになる検索。

Searchify、使ってみようじゃないか!

といっても、単純に使うのはすごく簡単。.NETからの利用に関してもライブラリが既にあるので、サンプルそのまま書けばあっさり検索できる。超簡単。

IndexTankDotNet - the IndexTank Client Library for .NET

サンプルを転載(ズルくない)。

1.Indexを取得。

IndexTankClient client = new IndexTankClient("<YOUR API URL HERE>");

Index index = client.GetIndex("<YOUR INDEX NAME HERE>");

2.ドキュメントの登録。

string documentId = "<A DOCUMENT IDENTIFIER>";
string documentText = "<TEXTUAL CONTENT OF DOCUMENT>";

Document document = new Document(documentId).AddField("text", documentText);

index.AddDocument(document);

3.ドキュメントの検索。

string queryText = "<TEXT TO SEARCH FOR>";

Query query = new Query(queryText);

SearchResult result = index.Search(query);

Console.WriteLine(string.Format("There were {0} matches found for '{1}'.", result.Matches, result.QueryText));
Console.WriteLine(string.Format("The search took {0} seconds.", result.SearchTime));

foreach (ResultDocument document in result.ResultDocuments)
{
   Console.WriteLine(string.Format("Document ID: {0}", document.DocumentId));
}

簡単です!ドキュメントを登録することで勝手にインデックスが構築されて検索可能になります。

考え方としてはIndexっていうのが検索スコープで、ドキュメントっていうのがコンテンツ。IndexTankの面白いところは、ドキュメントに対してフィールド(セマンティック項目)を定義できるところ。通常の全文検索項目は”text”っていう名前の項目。それ以外にも自分で好きなように項目(Field)を定義して、検索オプションに利用できます。さらにさらに、Valiablesっていう特別項目もあって、これを利用することで、更新頻度の高い項目を別枠で検索できるようになります(In-RAM)。通常のFieldはインデクサが頑張ってから検索可能になるのに対して、Variableは即座に検索に反映されるっていう使い分け。しかも、Variableは範囲検索もできるっていうのが特徴的。これAWS CloudSearchではまだできない。範囲検索超強力。

詳しくはドキュメントどうぞ。

www.indextank.com/_static/papers/IndexTank WhitePaper Technical.pdf

ドキュメントに書かれてるソースだけで、分かった気になるのはだらしないので、ちゃんとサンプルも書きました。

    public class Searchify
    {
        public string ApiUrl = "https://アカウント登録してね!";
        public string IndexName = "FreshTest";

        public Index GetIndex()
        {
            var client = new IndexTankClient(ApiUrl);
            return client.GetIndex(IndexName);
        }

        private Dictionary<string, Person> _mugiwara = new Dictionary<string, Person>
        {
            {"1",new Person{Name="モンキー・D・ルフィ",Date=new DateTime(2000,1,1),Spec = "ゴムゴム",Age = 19.0f, Bounty = 40000f,
                            Text="(麦わらのルフィ)声 - 田中真弓 本作の主人公。麦わらの一味船長。「ゴムゴムの実」の能力者のゴム人間。麦わら帽子がトレードマーク。夢は「海賊王」と「シャンクスとの再会」。"}},
            {"2",new Person{Name="ロロノア・ゾロ",Date=new DateTime(2000,1,1),Spec = "",Age = 21.0f, Bounty = 12000f,
                            Text="(海賊狩りのゾロ)声 - 中井和哉 麦わらの一味戦闘員。「三刀流」の剣士。クールでストイックな武士道精神の持ち主。世界一の大剣豪を目指している。"}},
            {"3",new Person{Name="ナミ",Date=new DateTime(2000,3,1),Spec = "",Age = 20.0f, Bounty = 1600f,
                            Text="(泥棒猫)声 - 岡村明美 麦わらの一味航海士。元は海賊専門の泥棒。お金・お宝に目がない。世界地図を描くのが夢。"}},
            {"4",new Person{Name="ウソップ",Date=new DateTime(2000,3,1,12,0,0),Spec = "",Age = 19.0f, Bounty = 3000f,
                            Text="(狙撃の王様そげキング)声 - 山口勝平 麦わらの一味狙撃手。臆病でお調子者ながら、器用で口八丁なパチンコの名手。父・ヤソップのような勇敢なる海の戦士を目指している。"}},
            {"5",new Person{Name="サンジ",Date=new DateTime(2000,7,1),Spec = "",Age = 21.0f, Bounty = 7700f,
                            Text="(黒足のサンジ)声 - 平田広明 麦わらの一味コック。コックの命である手を傷つけないように、足技で戦う。無類の女好き。伝説の海「オールブルー」を探す。"}},
            {"6",new Person{Name="トニートニー・チョッパー",Date=new DateTime(2000,7,1,12,0,0),Spec = "ヒトヒト",Age = 17.0f, Bounty = 0.005f,
                            Text="(わたあめ大好きチョッパー)声 - 大谷育江 麦わらの一味船医。「ヒトヒトの実」を食べた人間トナカイ。人獣型、人型、獣型に変形出来る。何でも治せる医者を目指している。"}},
            {"7",new Person{Name="ニコ・ロビン",Date=new DateTime(2000,7,2),Spec = "ハナハナ",Age = 30.0f, Bounty = 8000f,
                            Text="(悪魔の子)声 - 山口由里子 麦わらの一味考古学者。「ハナハナの実」の能力者。歴史上の「空白の百年」の謎を解き明かすため旅をしている。"}},
            {"8",new Person{Name="フランキー",Date=new DateTime(2000,8,1),Spec = "",Age = 36.0f, Bounty = 4400f,
                            Text="(鉄人(サイボーグ))声 - 矢尾一樹 麦わらの一味船大工。体中に武器を仕込んだサイボーグ。自分の作った船に乗り、その船が海の果てに辿り着くのを見届けることが夢。"}},
            {"9",new Person{Name="ブルック",Date=new DateTime(2000,9,1),Spec = "ヨミヨミ",Age = 90.0f, Bounty = 3300f,
                            Text="(鼻唄のブルック)声 - チョー 麦わらの一味音楽家。一度死んだが「ヨミヨミの実」でガイコツ姿で蘇ったアフロ剣士。リヴァース・マウンテンで別れた鯨のラブーンとの再会を誓う。"}},
        };

        public void CreateIndex()
        {
            var index = GetIndex();
            var documents = new List<Document>();
            foreach (var doc in _mugiwara)
            {
                // insert document
                Document document = new Document(doc.Key);
                document.AddField("text", string.Format("{0}\r\n{1}", doc.Value.Name, doc.Value.Text));
                document.AddField("spec", doc.Value.Spec);
                document.AddVariable(0, doc.Value.Age);
                document.AddVariable(1, (doc.Value.Date - new DateTime(1, 1, 1)).Days);

                documents.Add(document);
            }

            Console.WriteLine("start at " + DateTime.Now);
            index.AddDocuments(documents);
            Console.WriteLine("end at " + DateTime.Now);
        }
    }

見にくい!ほとんどデータ。麦わら屋。

            var searchify = new Searchify();
            searchify.CreateIndex();

って、実行すれば登録されます。

あとは、検索するだけ。

Searchifyの管理画面からも簡単な検索はできるので、そこからドキュメントが登録されてるかは確認できます。

searchify1

拡大してみてね。"麦わら"で検索したら9件出てきました。すべでのドキュメントに含まれてる単語だからね。っと、単語とは言ったけど、形態素ってわけじゃないっぽい。ちゃんとしたドキュメントは見つけられてないけど、N-gramだと思われる。多言語対応しようと思うとそのほうが都合いいもんね。

わざわざ適当な日付を利用してVariable項目に登録してるんですが、これを使って検索してみましょう。

            text = "声";
            query = new Query(text);
            var days = (new DateTime(2000, 3, 1) - new DateTime(1, 1, 1)).Days;
            results = index.Search(
                query.WithDocumentVariableFilter(1, 0f, days)
            );
            Console.WriteLine("search '{0}' {1} matches", text, results.Matches);
            foreach (var result in results.ResultDocuments)
            {
                Console.WriteLine(" {0}", result.DocumentId);
            }

 

searchify2

すべてのドキュメントに"声"っていうのが含まれてるので、”声”で検索しつつ、日付として2000/3/1以前のものを検索してます。うまく4件だけ表示されました。

ここで、ちょっと変なことしてるんですけど、そもそもVariableに指定できる項目はfloatのみ。文字列も入れられないし、日付も入れられない。なので、数値に変換した日付を入れてます。しかもfloatだから有効な仮数は10進数で7ケタ。なかなか厳しいけど、日付だけなら1/1/1からの経過日数でしのげるので、日数をセットしてます。

ちなみにIndexTankDotNetの実装に少し不具合もあって(IndexTankのRESTの不具合ともいえる?)、floatを文字列にしたときに指数表記(1.234e5とか)になると検索できない。ドキュメントの値として送信する場合はJSONで送られるんだけど、その場合はJSONが指数表記に対応してるからか、うまく登録されるんだけど、それを検索しようと思ってもQueryStringの指数表記までは解釈してくれない。ちょっと残念だけど、そこは気を付けましょうってことで(フィードバックすればいいかも)。

話を戻すと、Variableを利用すると範囲検索ができるので、とても便利っていうことです。

あと、Categories(Facet)っていう特別な項目(CloudSearchにもありますね)もあって、ドキュメントがどのカテゴリに含まれてるのか検索結果に含ませて返したり、条件指定したりできます。が、複数指定するのはできないっぽいので、複数のクラスタに属するドキュメントを検索する場合はFieldにスペース区切りで値を入れると検索できるようになります。

例)lang fieldに言語をスペース区切りで入れる

docid: takepara
lang:c# japascript sql

docid: suzuki
lang:c# php

こんな感じになってて検索キーワードに lang:c# って入れると両方とれるし、lang:phpって入れたら suzukiだけとれる。その辺はまたドキュメントどーぞ。

Query Syntax documentation – Searchify

ドキュメント件数が少ないと(数万とか数10万程度)だと、ネットワークのラウンドトリップのほうが気になるだろうけど、数100万超えてくるとSaaS使うメリットが出てくると思うので、とても魅力的なソリューションになりえると思います。

実運用を考えると、マスターデータとの同期でタイムラグが発生すると思います。ドキュメントの登録自体は複数ドキュメントの一括更新ができて、ある程度速度的には許容できるんだけど、Variableの更新は1件ずつしかできないのが、ちょっと残念。Variableこそ一括更新したいと思うんだけど。更新頻度の高いデータを大量に更新しないような方法をうまく考える必要はあるね。

なので、現実的な落としどころは、外部検索ではIDだけを取得して、更新頻度の高い情報と取得したIDをもとに内部で結合して利用する、っていうのがシステム設計として必要になると思う。その際、取得するIDがあまり多くならないようにしておくことも含め、いろいろ考えるところがありそう。とはいえ、実質1000件以上とか検索結果が出てきても、見るわけもなく。

不要な検索結果の切り捨て(件数は数万って出しても、ページングした最後のページに「もっと絞り込んでね」って出すとか)はGoogleでも普通にやるし、Amazon(お店のほうね)でも300件ほどで切り捨ててるし(検索結果が数万件って表示されてもページングは20まで)。

その辺、ちゃんと意識して効率よく使っていきましょう!

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。