Chienomi

ウェブサイト全文検索システムの開発

zsh

開発経緯

NamazuやGroongaも試したのだが、いまひとつ望むものにはならなかったので、シンプルで美しい全文検索システムを書くことにした。

これは、第一にはGoogle検索に依存しているMimir Yokhamaの検索機能を自前で持つこと、 第二にはChienomiを含むWordPressのシステムの置換えである。

検索システムの開発自体はそれほど難しくないと思うのだが、どのように動作するのが望ましいかということを考えると非常に難しい。 Googleの検索システムは非常に高度なので、それに匹敵するものを作るのは難しいのだ。

だが、ここはPureBuilder Simplyにふさわしいシンプルなものを目指すことにする。

設計その1

とりあえず、grepを使えば話が早いのだが、HTMLだと余計な要素を含んだ検索になってしまう。 HTMLからタグを除去するのは難しくないが、どういうポリシーで除去するのか、いつどうやって除去するのか、などが難しい。

PureBuilder Simplyの構成から言えば、生成時に、Pandocで生成するのが望ましい。

$ pandoc index.md -t plain index.txt

だが、生成時に生成を全く無視してインデックスを生成するのはどうだろうか? そもそもPre pluginsはソースファイルを、Post Pluginsは生成したHTMLファイルを加工するものであるため、本来の目的から逸脱してしまう。 例えばPre Pluginsを使って

#!/bin/zsh

typeset exclude=(_err/* ./mailform.md ./feed.md)
typeset indexdir="../Build/search"

sourcefile="$1"
filename="$(ruby -ryaml -e 'print YAML.load(ENV["$pbsimply_doc_frontmatter"])["_filename"]')"
pandoc "$sourcefile" -t plain -o "$indexdir/$pbsimply_subdir"

cat

とかもできる。

ただ、今のところ検索対象になるようなソースファイルを加工するようなPre plguinsを使っていないため、別にこのようにする必要性はない。

設計その2

あとから処理するためのもの。 .indexes.rbm に基づいて処理を行う方式。

#!/bin/zsh

if [[ -e .search_index_timestamp ]]
then
    export LASTUPDATE="$(cat .search_index_timestamp)"
else
    export LASTUPDATE="1969-12-31 23:59:59"
fi

typeset exclude=(_err/* ./mailform.md ./feed.md)

for i in **/.indexes.rbm
do
    ( 
        cd ${i:h}
        ruby -rdate -e 'lup=ENV["LASTUPDATE"]' -e 'index = Marshal.load(File.read(".indexes.rbm")).select {|k, v| ut = v["last_update"].kind_of?(Date) ? v["last_update"].strftime("%Y-%m-%d %H:%M:%S") : v["last_update"]; ut > lup}' -e 'puts index.map {|i| i[1]["_filename"] }'
    ) | while read
    do
        if [[ ${exclude[(i)${i:h}/$REPLY]} -gt ${#exclude} ]]
        then
            [[ -e ../Build/sindex/${i:h} ]] || mkdir -pv ../Build/sindex/${i:h}
            print -P "%F{blue}Creating index: %f$REPLY"
            pandoc "${i:h}/$REPLY" -t plain -o "../Build/sindex/${i:h}/${REPLY:r}.txt"
        fi
    done
done

date +"%F %T" >| .search_index_timestamp

検索

いずれにせよここまでやってしまえば検索は簡単。 grepで検索できる状態なので、シンプルに検索可能。

AND検索の要領としては

result = exprs.map do |expr|
  IO.popen(["grep", "-FlR", "-e", expr, "base/index"], "r") {|io| io.read }.split("\n")
end.inject([]) {|sum, x| sum &= x }

ものすごく検索対象が多い場合は、検索対象そのものを絞り込んでいくほうがいいだろう。 だが、プロセス起動回数が増えることを考えると、そのような場合は自前実装のほうが良い可能性が高い。

files = Dir.glob("base/index/**/*.txt")
result = files.select do |fn|
  content = File.read(fn)
  exprs.all? {|i| content.include?(i) }
end

OR検索はもっと簡単で

result = IO.popen(["grep", "-FlR", ] + exprs.map{|i| [ "-e", i ] }.flatten + [ "base/index"], "r") {|io| io.read }.split("\n")

スペースの取り扱い方とか、case問題とか考え始めると難しい。 ただ、世の中そんな複雑な検索をしている検索エンジンはあまりないし、多分ローカルにそんなもの作ったところで報われないのでこれくらいでいいような気もする。

ANDまたはORではなく自由にANDとORを結合できるようにした場合は、expr自体に評価できるメソッドを追加すると良い。例えば

class Expr
  def initialize(*arg)
    @exprs = arg
  end
end

class Expr::Or < Expr
  def eval(content)
    @exprs.any? {|i| i.kind_of?(Expr) ? i.eval(content) : content.include?(i) }
  end
end

class Expr::And < Expr
  def eval(content)
    @exprs.all? {|i| i.kind_of?(Expr) ? i.eval(content) : content.include?(i) }
  end
end

といった感じである。

あとがき

検索機能の実装自体は難しくないのだが、ChienomiをPureBuilder Simply化するという話になると結構難しい。

既にかなりの記事があり、検索からの流入も多いため、どうしても全記事に対してマップせざるをえない。 これもなかなか面倒だ。

だが、もっと問題Chienomiの記事は書き方が一定でない、ということだ。

Chienomiの記述形式はPOD, RDoc, ACCS2, PureDoc, PureDoc2, PureDoc2::Markdown, Pandoc Markdownがある。 PureBuilder SimplyはPandocでの処理を前提としているため、なんとかしないといけない。

過去記事については諦める方針ならばHTMLとして抜き出すという方法もあるのだが(Mimir Yokohamaでウェブサイトのサルベージでよく使う方法だ)、 できれば避けたいというのもある。

また、タグとカテゴリのつけ方が一定ではないため、これを処理しなければならない。

さらに厄介なのがメディアファイルだ。 WordPressはメディアファイルの使い方が独特だし、そのためにメディアファイルについてはWordPress上で追加する方法をとっていた。 さらにサイト移行時にメディアファイルを紛失したこともあって、結構大規模な作業になると思う。

そのことを考えると一筋縄ではいかない。

それはともかくとして、サイトの検索で非常に複雑な演算子を使いたがる人はまずいない、 どころかサイト内検索なんてほぼ使われていないに等しいので、基本的な検索機能で十分だと思うのだが、それであればこの通り実装はとても簡単だ(例によって設計で稼いだ感があるが)。

というわけでちょっとした実装例、そしてシェルスクリプトサンプルとして役立てば幸いである。