検索インデックスを作ろう 後編 (Hadoopで転置インデックス)

Hadoop転置インデックスを作る。

Hadoopのインストールについては以下を参照。

転置インデックスとは、本の巻末にある索引のことだ。
例えば、るるぶは巻末に転置インデックスがついていて、目的地のページを素早く探せる。

"金閣寺 ・・・ P.15"
"銀閣寺 ・・・ P.15,P.16,P.57"
"高台寺 ・・・ P.11"

前編の単語リストは、文書ID => 単語、単語、単語 だったが、
後編の転置インデックスは、単語 => 文書ID、文書ID、文書ID と、
単語リストをひっくり返す(転置)。

MapReduceの手順

1.文書のURIを並べたテキストファイル

http://localhost/rfc/rfc1.txt
http://localhost/rfc/rfc2.txt
http://localhost/rfc/rfc3.txt
http://localhost/rfc/rfc4.txt
http://localhost/rfc/rfc5.txt

2. Map (各URIをフェッチして、前編の単語リスト生成スクリプトにかける)

rfc1.txtの単語リスト
rfc2.txtの単語リスト
rfc3.txtの単語リスト
rfc4.txtの単語リスト
rfc5.txtの単語リスト

3. Reduce (単語リストをまとめる)

転置インデックスの出来上がり。

RFC文書(英文テキスト243MB)の処理に4分19秒を要した。(Thinkpad X60)

コマンド

Hadoop分散ファイルシステムに、文書のURIを並べたテキストファイル(rfc_list)を置く。

$ bin/hadoop dfs -copyFromLocal rfc_list rfc_list

HadoopMapReduceする。Hadoop Streamingを使うと、catやwcなどの標準入出力を使うプログラムを、Mapper、Reducerとして使える。

$ bin/hadoop jar contrib/streaming/hadoop-0.18.1-streaming.jar \
 -input rfc_list -output rfc_idx \
 -file "getdoc.scm" -mapper "gosh getdoc.scm" \
 -file "reducer.scm" -reducer "gosh reducer.scm" 

結果を取り出す。

$ bin/hadoop dfs -copyToLocal rfc_idx /tmp/

結果を見てみる。

$ head /tmp/rfc_idx/part-00000 

00000000000000001D79EAF247B394BF rfc4418.txt
0000000000000000A6C537D7986FA4AA rfc4418.txt
00000000000000F0 rfc1510.txt
0000000015Full rfc3440.txt
00000000AAFF rfc1493.txt,rfc1748.txt,rfc1743.txt,rfc1286.txt,rfc1230.txt,rfc1231.txt
00000000T000000 rfc4807.txt
00000000T240000 rfc4807.txt
00000001a000015 rfc2259.txt
00000001a000022 rfc2259.txt
00000001a000027 rfc2259.txt

Mapperの解説(getdoc.scm)

前編の単語リスト生成シェルスクリプトを使う。

tr -c '[:alnum:]' '[\n*]' | grep -v '^[0-9]*$' | sort -iu

単語ごとにdoc-idをつけるところが前編とは違う。

A rfc1.txt
ARPA rfc1.txt
Additionally rfc1.txt
After rfc1.txt
Again rfc1.txt

(use rfc.uri)
(use rfc.http)
(use file.util)
(use gauche.process)

(define (main args)
  (port-for-each
   (lambda (uri)
     (receive (_ _ hostname _ path _ _) (uri-parse uri)
       ;;http://localhost/rfc/rfc1.txtなら、doc-idはrfc1.txt。
       (define doc-id (receive (_ name ext) (decompose-path path)
			(string-append name "." ext)))
       (call-with-process-io
	(string-append "tr -c '[:alnum:]' '[\n*]' | grep -v '^[0-9]*$' | sort -iu | sed \"s/.*/&	" doc-id "/\"")
	(lambda (in out)
	  ;;URIをフェッチして、シェルスクリプトにパイプで渡す。
	  (http-get hostname path :sink out :flusher (lambda _ #t))
	  (close-output-port out)
	  ;;シェルスクリプトの結果を標準出力に渡す。
	  (copy-port in (standard-output-port))))))
   read-line)
  (exit))

Reducerの解説(reducer.scm)

GoogleMapReduceは、Reducerはkey valuesを処理するが、
Hadoop Streamingでは、標準入力からこのようにvaluesが分解して入ってくる。

A rfc1.txt
A rfc2.txt
A rfc4.txt
A rfc3.txt
AAA rfc1.txt

なので、プログラマが自分の手で、valuesの形に直す必要がある。
Hadoopは、keyでソートしてからReducerに渡すことになっているので、
1行前のkeyと見比べて同じならvaluesに追加、そこで違っていたら、蓄えたvaluesでreducerを呼ぶという簡単な処理でよい。(そりゃ、もしもソートしてなければ巨大なハッシュテーブルを作るはめになって、MapReduceの意味がないわけだが)

ここでは、valuesの形に直してくれる、call-with-reducerという簡単なラッパーを作った。

(define (call-with-reducer proc)
  (define key "")
  (define vals (list))
  (define (f! line)
    (rxmatch-let (#/^(\S*)\s(\S*)/ line)
	(#f k v)
      (if (string=? key k)
	(push! vals v)
	(begin
	  (and (not (null? vals))
	       (proc key (reverse! vals)))
	  (set! vals (list v))
	  (set! key k)))))

  (port-for-each f! read-line)
  (f! " "))

(define (main args)
  (call-with-reducer (lambda (key vals)
		       (print key #\tab (string-join vals ","))))
  (exit))

結論

URIのリストをHadoopに与えて、簡単なクロールをした。
クロールしたファイルから、単語リストを作った。(Map)
単語リストをひっくり返して転置インデックスにした。(Reduce)

シェルスクリプトを使うと簡単に単語リストを作れるし、
単語リストをHadoopに渡すだけで、転置インデックスの一歩手前の形になるので、
ほとんどプログラムを書かずに済んだ。

Hadoopはヤフーが10000台超の規模で運用するなど実績があるので、
大量の文書の検索インデックスを作る能力があるだろう。