プログラム

redisで作成した転置インデックスで検索してみる

検索エンジン自作入門 ~手を動かしながら見渡す検索の舞台裏を読んでみたところから続く検索してみるシリーズ。

前回のエントリで転置インデックスをredisに作ってみるところまではできたので、次は実際にこれを利用して検索を行ってみます。

前回はbi-gramで文章を分割し、分割語を転置インデックスとして以下の形で格納しています。それぞれ、

  • (A)分割語が属す文書番号を管理するセット型の集合
  • (B)特定の文書に特定の分割語の出現位置を管理する文字列型の集合

これを利用して検索を行うために以下の手順で処理を進めます

(1) 与えられた分割語をbi-gramで分割する

ここでは検索語として「コロスケ」を考えます。bi-gramなので、コロ, ロス, スケ に分割。

(2) 分割語が全て含まれる文書を特定する

(A)のセット型の集合に対してSINTERコマンドを発行して各分割語が全て含まれる文書番号を抽出します。

127.0.0.1:6379> SINTER コロ ロス スケ
1) "9"
...

(3) (2)で得られた文書中の分割語の出現位置を確認

(2)で得られた分割語が全て含まれる文書、それぞれに対して分割語毎にその出現位置を確認し、その語が連続しているかを確認します。
例えば、コロ, ロス, スケの分割語が文書番号9で含まれていることが(2)でわかっていれば、その出現位置が連続で出現するかを確認します。

127.0.0.1:6379> GET コロ-9
"3"
127.0.0.1:6379> GET ロス-9
"4"
127.0.0.1:6379> GET スケ-9
"5"

この場合は文書番号9の3文字目から分割語が連続で出現していることから、「コロスケ」という単語が文書番号9の3文字目に含まれていることがわかります。

上記までの内容をまとめると以下の様な概要図になります。

実際にnode.jsで書いてみた例は以下の形になりました。

'use strict';
var ngram = require('../util/ngram'),
  _ = require('underscore'),
  redis = require("redis"),
  client = redis.createClient();
var search = {};
search.basic = function (query, n, callback) {
  var grams = ngram(query, n),
    results = [];
  client.sinter(grams, function (err, documentNumbers) {
    if (documentNumbers.length == 0) {
      client.end();
      callback(null, results);
    }
    documentNumbers.forEach(function (docNo) {
      var positions = grams.map(function (gram) {
        return gram + "-" + docNo
      });
      client.mget(positions, function (err, res) {
        var charPositions = res.map(function (pos) {
          return pos.split(",").filter(function (e) {
            return e !== ""
          }).map(function (e) {
            return parseInt(e)
          });
        });
        for (var i = 1; i < charPositions.length; i++) {
          charPositions[i] = charPositions[i].map(function (v) {
            return v - i
          });
        }
        charPositions.reduce(function (a, b) {
          return _.intersection(a, b)
        }).forEach(function (position) {
          results.push({
            no: docNo,
            pos: position
          });
        });
        if (docNo == documentNumbers[documentNumbers.length - 1]) {
          client.end();
          callback(null, results);
        }
      });
    });
  });
}
module.exports = search;

これで先日作ったredisの転置インデックスを利用して検索が行えます。

現状だと作ってみただけになるので実際に少し大きめの文書例を入れてみて測定してみるのはまた次に。

あわせて読みたい

転置インデックスをredisで永続化する

検索エンジン自作入門 ~手を動かしながら見渡す検索の舞台裏を読んでみたところから続く検索してみるシリーズ。

過去に転置インデックスを利用して検索してみるといったエントリを書いてJavaScriptのハッシュで構成された転置インデックスを利用して検索を試みていたが、このままだと永続化できないのでredisを用いて永続化してみた。

node.jsでredisを扱う場合はnode_redisを利用するのがよさそうなのでnpm経由でインストール。

$ npm install redis --save

使い方はそのままの形なので先のgithubのREADME.mdを参照すれば特に支障なく利用できるかと思う。
基本的にはredisのコマンドがそのままメソッドとして実装されているので、見た感じそのままである。

今回は転置インデックスをredisに格納するにあたって、以下の方法で実装してみた。ここはひとまずとりあえず。

  • 該当の単語が出現する文書番号を格納
    • redisのセット型
    • keyを単語とし、その集合として文書番号を格納
  • 該当の単語が特定の文書中に出現する位置を格納
    • redisの文字列型
    • keyで単語と文書番号を表現し、その値に出現位置をカンマ区切りで格納

としてみた。

こうすることで、検索語が与えられた場合、その検索後を転置インデックスに対応する形で単語分割し、その単語が全て含められる文書を絞り込んだ上で、更に転置インデックスを参照して該当する単語が含まれる位置を探すことで検索が実現できそう。

'use strict';
var ngram = require('../util/ngram'),
  redis = require("redis"),
  client = redis.createClient(),
  fs = require('fs'),
  byline = require('byline');
var index = {};
var indexing = function (no, str) {
  ngram(str, 2).forEach(function (gram, pos, array) {
    client.sadd(gram, no);
    client.append(gram + "-" + no, pos + ",");
  });
}
index.redis = function (path) {
  client.flushdb(function (err, didSucceed) {
    var stream = byline(fs.createReadStream(path, {
      encoding: 'utf8'
    }));
    var i = 0;
    stream.on('data', function (line) {
      indexing(i, line);
      i++;
    });
    stream.on('finish', function () {
      client.end();
    });
  });
}
module.exports = index;

こうすることでredisに転置インデックスを格納することができた。

では、ここまでで転置インデックスの永続化はできたので、実際にこれを利用して検索するのはまた次に。

併せて読みたい

redisのコマンドリファレンスはページ上で実際に試せた

Command reference – Redis というredisのコマンドがまとめられたページがあるのだけど、各コマンドの詳細が書かれているページにEXAMPLESという項目があって、コマンド例がまとめられているのだけど、この欄実際に自分で試しにコマンドを入力できることに今日気づいた。

Webページ上で実際にコマンドを入力して挙動を試せたりできるので便利。

f:id:hideack:20150110192222p:plain

JavaScriptの処理時間を計測する(console.time / console.timeEnd)

肝心なところ知らないこと多い...。

console.timeconsole.timeEnd で挟んだ部分の時間を計測できる。引数にはラベル名を指定できてそのラベルで対応した範囲の時間が計測される。

// Indexing
console.time("indexing");
var stream = byline(fs.createReadStream('/tmp/foo.txt', { encoding: 'utf8' }));
var i=0;
stream.on('data', function(line) {
indexing(i, line);
i++;
});
stream.on('finish', function(){
console.timeEnd("indexing");
// Search
console.time("search");
var res = search("コロスケ", 2);
console.timeEnd("search");
// Results
var resultOut = _.template("文書番号=<%=no %> : 文字位置=<%=pos %>")
_.each(res, function(v){
console.log(resultOut(v));
});
});

こうすると、以下の様な形で処理時間を計測できる。

$ node test.js
indexing: 5ms
search: 1ms
文書番号=0 : 文字位置=15
文書番号=2 : 文字位置=8
文書番号=2 : 文字位置=35

node.jsで1行毎にテキストファイルを読む – byline: buffered Stream for reading lines

素朴に便利だったのでメモエントリ。

$ npm install byline --save

npmからインストールして以下の様な形で使える。

var byline = require('byline');
var fs = require('fs');
var stream = byline(fs.createReadStream('/tmp/foo.txt', { encoding: 'utf8' }));
stream.on('data', function(line) {
console.log(line);
});
stream.on('finish', function(){
console.log("ファイル読み終わり");
});

redisの型について整理した

redisの型の認識が何気だったので改めて整理してみた。自分の頭の整理用に図も描いたりしたのでせっかくなので掲載。


文字列型

一番シンプルな形。

127.0.0.1:6379> SET key1 value1
OK
127.0.0.1:6379> SET key2 value2
OK
127.0.0.1:6379> GET key1
"value1"
127.0.0.1:6379> GET key2
"value2"
127.0.0.1:6379> MGET key1 key2
1) "value1"
2) "value2"

expireを設定することもできるのでキャッシュとして用いる場合はこれを利用する。

127.0.0.1:6379> SETEX cache1 5 cache-val1
OK
127.0.0.1:6379> GET cache1
"cache-val1"
(5秒経過後)
127.0.0.1:6379> GET cache1
(nil)

リスト型

双方向な線形リストで構成。要素の追加の方向は基本先頭か末尾。

これを利用することでスタックやキューを構成することができる。

スタック(LIFO)

LPUSH , LPOP を利用するとスタックを構成できる。LPOPで取り出されている要素を確認すると一番最後に格納した要素から取り出せている。

127.0.0.1:6379> LPUSH list1 1
(integer) 1
127.0.0.1:6379> LPUSH list1 2
(integer) 2
127.0.0.1:6379> LPUSH list1 3
(integer) 3
127.0.0.1:6379> LPOP list1
"3"
127.0.0.1:6379> LPOP list1
"2"
127.0.0.1:6379> LPOP list1
"1"
127.0.0.1:6379> LPOP list1
(nil)

キュー(FIFO)

RPUSH, LPOPを利用するとキューを構成できる。LPOPから取り出されている要素を確認すると最初に格納した要素が取り出せている。

127.0.0.1:6379> RPUSH list1 1
(integer) 1
127.0.0.1:6379> RPUSH list1 2
(integer) 2
127.0.0.1:6379> RPUSH list1 3
(integer) 3
127.0.0.1:6379> LPOP list1
"1"
127.0.0.1:6379> LPOP list1
"2"
127.0.0.1:6379> LPOP list1
"3"
127.0.0.1:6379> LPOP list1
(nil)

リスト操作

リストに対して部分操作ができる。例えば先頭から2要素を抽出するときは LRANGE コマンドを使う。

127.0.0.1:6379> RPUSH list1 1
(integer) 1
127.0.0.1:6379> RPUSH list1 2
(integer) 2
127.0.0.1:6379> RPUSH list1 3
(integer) 3
127.0.0.1:6379> RPUSH list1 4
(integer) 4
127.0.0.1:6379> LRANGE list1 0 1
1) "1"
2) "2"

セット型

値の重複を許さない文字列型の集合で且つ集合同士の操作ができる。例えば SINTER コマンドを使うと積集合を得ることができる。

下の例だと集合 set1set2 に対して SINTER コマンドを発行すると両方の集合に対して属している 3 が取り出せている。

127.0.0.1:6379> SADD set1 1
(integer) 1
127.0.0.1:6379> SADD set1 2
(integer) 1
127.0.0.1:6379> SADD set1 3
(integer) 1
127.0.0.1:6379> SADD set2 3
(integer) 1
127.0.0.1:6379> SADD set2 4
(integer) 1
127.0.0.1:6379> SADD set2 5
(integer) 1
127.0.0.1:6379> SINTER set1 set2
1) "3"

ソート済みセット型

セット型で更にそれぞれの要素はスコアを持ち、そのスコアにもとづいてソートされた形で格納される。

127.0.0.1:6379> ZADD sortset 5 A
(integer) 1
127.0.0.1:6379> ZADD sortset 10 B
(integer) 1
127.0.0.1:6379> ZADD sortset 8 C
(integer) 1
127.0.0.1:6379> zrangebyscore sortset -inf +inf
1) "A"
2) "C"
3) "B"

ハッシュ型

こちらはそのまま。集合内の要素は全てキーと値で一意となるハッシュとして管理される。

127.0.0.1:6379> HSET hash1 foo 10
(integer) 1
127.0.0.1:6379> HSET hash1 bar 20
(integer) 1
127.0.0.1:6379> HSET hash1 hoge 30
(integer) 1
127.0.0.1:6379> HGET hash1 foo
"10"
127.0.0.1:6379> HGET hash1 bar
"20"
127.0.0.1:6379> HGET hash1 hoge
"30"
127.0.0.1:6379> HGET hash1 hoge2
(nil)

転置インデックスを利用して検索してみる

n-gram及び、転置インデックス を作ってみたので実際に検索してみます。

var _ = require('underscore');
// (snip)
var search = function (query, n) {
  var grams = ngram(query, n),
    firstPostingList = invertedIndex[grams[0]],
    results = [];
  var searchProcess = function (positions, key) {
    positions.forEach(function (pos) {
      if (query.length <= n) {
        results.push({
          no: key,
          pos: pos
        });
      } else {
        for (var i = 1; i < grams.length; i++) {
          postingList = invertedIndex[grams[i]];
          if (postingList[key]) {
            var targetList = postingList[key];
            if (_.contains(targetList, pos + i)) {
              if (i == grams.length - 1) {
                results.push({
                  no: key,
                  pos: pos
                });
              }
            } else {
              continue;
            }
          }
        }
      }
    });
  };
  _.each(firstPostingList, searchProcess);
  return results;
}
// Indexing
var text = ["本日は晴天なり。いい天気なり。コロスケなり。", "コロばない様にスケートボートに乗ります", "今日は黒豆を煮てコロスケとコロコロコミックとコロッケを買いに行きます。コロスケ〜。"];
for (var i = 0; i < text.length; i++) {
  indexing(i, text[i]);
}
// Search
var res = search("コロスケ", 2);
// Results
var resultOut = _.template("文書番号=<%=no %> : 文字位置=<%=pos %>")
_.each(res, function (v) {
  console.log(resultOut(v));
});

これを実行してみると、「コロスケ」で検索した結果が出力されます。

文書番号=0 : 文字位置=15
文書番号=2 : 文字位置=8
文書番号=2 : 文字位置=35

インデックスに投入した文書とくらべて、検索した単語が含まれる文書が抽出できていて且つ、出現位置も正しく特定できています。

やっていることとしては、

  • 検索語をn-Gramで分割する (上の例なら "コロスケ" → "コロ", "ロス", "スケ")
  • 分割された最初の分割語を持つ文書とその出現位置を転置インデックスから得る
  • 得られた一つ目の文書について
    • 2番目の分割語がその文書に含まれているか確認する
    • 含まれていればその語の出現位置が1番目の検索語の次にあるかを確認する
    • 以下、検索語を分割した数だけ繰り返す
  • 以下、分割された最初の分割語が含まれる文書数繰り返す

といった手順。

ひとまず素朴な検索ができたけれど、このままだと転置インデックスが永続化されないですね。

では、次に何かしらのストレージを利用して永続化させることをやってみます。


一連のn-gram, 転置インデックス, 検索の話は以下の本を読んでいたら実際に手を動かして確認したくなったので試してみたのでした。

具体例も多く、読んでいても面白いですが実際に手を動かすともっと面白いのでお勧め。

今回の話は第4章「検索しよう」の内容を読みながら進めてみました。

検索エンジン自作入門 ~手を動かしながら見渡す検索の舞台裏

JavaScriptで転置インデックスを作る

先日ngramに分割する処理を書いたので次は転置インデックスを作ってみます。
今回は完全に単語の位置まで記録するものとし、

{'晴天': { '0': [ 3 ] }}

といった具合で2-gramで分割された単語とそれに対応する文書とその位置が管理されるものとします。上の例だと「晴天」という単語は文書番号0の3文字目に記録されているといった具合。

var invertedIndex = {}
var ngram = function(words, n) {
  var i;
  var grams = [];
  for(i=0; i<=words.length-n; i++) {
    grams.push(words.substr(i, n).toLowerCase());
  }
  return grams;
}

var indexing = function(no, str) {
  ngram(str, 2).forEach(function(gram, index, array) {
    var postingList;
    if (invertedIndex[gram]) {
      postingList = invertedIndex[gram];
    } else {
      postingList = {};
    }
    if (postingList[no]) {
      postingList[no].push(index);
    } else {
      postingList[no] = [index];
    }
    invertedIndex[gram] = postingList;
  });
}

これに対して文書を与えることで2-gramな転置インデックスが作成されます。

var text = [
"本日は晴天なり。いい天気なり。コロスケなり。",
"コロばない様にスケートボートに乗ります",
"今日は黒豆を煮てコロスケとコロコロコミックとコロッケを買いに行きます"
];
for(var i=0; i<text.length; i++) {
indexing(i, text[i]);
}

実際に転置インデックスを覗いてみます。

> console.log(invertedIndex)
{ '本日': { '0': [ 0 ] },
'日は': { '0': [ 1 ], '2': [ 1 ] },
'は晴': { '0': [ 2 ] },
'晴天': { '0': [ 3 ] },
'天な': { '0': [ 4 ] },
'なり': { '0': [ 5, 12, 19 ] },
'り。': { '0': [ 6, 13, 20 ] },
'。い': { '0': [ 7 ] },
'いい': { '0': [ 8 ] },
'い天': { '0': [ 9 ] },
'天気': { '0': [ 10 ] },
'気な': { '0': [ 11 ] },
'。コ': { '0': [ 14 ] },
'コロ': { '0': [ 15 ], '1': [ 0 ], '2': [ 8, 13, 15, 22 ] },
'ロス': { '0': [ 16 ], '2': [ 9 ] },
'スケ': { '0': [ 17 ], '1': [ 7 ], '2': [ 10 ] },
'ケな': { '0': [ 18 ] },
(以下省略)

といった具合。「コロ」という語が文書番号2(text変数の3番目の配列)に頻出しているので確認できやすいですね。

じゃ、次はこれを使って検索してみようということになるので、これはまた次回。

JavaScriptでn-gram

素朴に書いてみようと思って書きましたよ。のメモ。

var ngram = function(words, n) {
  var i;
  var grams = [];
  for(i=0; i<=words.length-n; i++) {
    grams.push(words.substr(i, n).toLowerCase());
  }
  return grams;
}

var text = "Hi. 本日は晴天なり。";
console.log(ngram(text,2));
console.log(ngram(text,3));

実行すると、BigramとTrigramの実行結果が得られます。

[ 'hi', 'i.', '. ', ' 本', '本日', '日は', 'は晴', '晴天', '天な', 'なり', 'り。' ]
[ 'hi.', 'i. ', '. 本', ' 本日', '本日は', '日は晴', 'は晴天', '晴天な', '天なり', 'なり。' ]

ロジックゲートをnode.jsでシミュレーションする

Rubyで2の補数の話題ぐらいから6年ぶり*1のビット演算の話題。

ロジックゲートをシミュレーションするg8というライブラリがあったので少し触ってみました。

サンプル見ればそのままなのですが、一応手元でも試してみました。

なぜか「ぬ」という日本語名がプロジェクト名。

var g8 = require('g8');
var a = g8.word(8);
var b = g8.word(8);
console.log("a=" + a('11110000', '1'));
console.log("b=" + b('00001111', '1'));
console.log("---");
console.log("a or b = " + g8.or(a('00000000', '0'), b('00000000', '0')));
console.log("not b  = " + g8.not(b('00000000', '0')));
a=11110000
b=00001111
---
a or b = 11111111
not b  = 11110000

これを利用して、10bit CPUの実装も行われていて興味深い。

最近コードを書いていてもビットの気持ちを忘れているなと感じたので時々触って思い出そうと思う。

*1:多分...ちゃんと振り返ってない...