再帰的な Migemo 検索

最近 youtube のデータベースを Scheme で作っています。ただ、データベースはかなり大きくなってきたものの、検索エンジンの実装は、経験が無かったためずっと後回しにしていました。

Migemo (C/Migemo ライブラリ) が使えそうなことは Winamp 等の実験で分かってましたので、あとは AND 検索とか OR 検索とかの仕組みを考えたいわけです。

例えば、特定の二人が一緒に登場するものを検索したいとすると、それぞれの名前の部分を OR で並べて検索できると便利そうですよね:

(AND (OR "gaki" "risa")
     (OR "kame" "eri"))

より厳密を期すため、次のように式を組みたい場合もあるでしょう:

(AND (OR "gaki" (AND "risa" (NOT "risako")))
     (OR "kame" (AND "eri"  (NOT "erika"))))

このように AND 式の中に OR 式があり、その中に AND 式があり、さらにその中に NOT 式がある、というような入れ子構造を処理する方法といえば、と考えた時、「それって Lisp じゃん」ということに気付きました。

ネストされたリスト構造に、マッピングによって再帰的に関数を適用する、あのエレガントなスタイルです。

(define youtube-search/db
  (let ((m #f))                         ; migemo object
    (define (match-type type)
      (lambda (x)
        (and (pair? x)
             (symbol? (car x))
             (string=? type
                       (string-upcase
                        (symbol->string (car x)))))))
    (define and? (match-type "AND"))
    (define or?  (match-type "OR"))
    (define not? (match-type "NOT"))
    (define rxp? (fun byte-regexp? regexp?))
    ;; Compiles search string into regexp object
    (define (regexp/m query)
      (cond ((string? query) (m query)) ; migemize string
            ((rxp? query) query)
            ((and? query)
             (map regexp/m (cdr query))) ; strip off the 'AND operator
            ((or? query)
             (cons (car query) (map regexp/m (cdr query))))
            ((not? query)               ; unary operator
             (list (car query) (regexp/m (cadr query))))
            ((list? query)
             (map regexp/m query))
            (else #f)))                 ; error!
    (define (match query info)
      (cond ((rxp? query)
             (or (regexp-match query (lookup 'title info))
                 (regexp-match query (lookup 'desc info))))
            ((or? query)
             (ormap (cut match <> info) (cdr query)))
            ((not? query)
             (not (match (cadr query) info)))
            ((list? query)
             (andmap (cut match <> info) query))
            (else #f)))
    (lambda (query)
      (unless m
        (set! m ((dynamic-require "migemo.ss" 'migemo))))
      (if (eq? query 'done)
          (set! m (m 'done))
          (filter-db (cute match (regexp/m query) <>))))))

ここまで書いてしまったところで再び気付きました。「これって SICP 4 章で出てきたやつだ」

一瞬で次のように書き換えました:

(define youtube-search/db
  (let ((m #f))                         ; migemo object
    (define (match-type type)
      (lambda (x)
        (and (pair? x)
             (symbol? (car x))
             (string=? type
                       (string-upcase
                        (symbol->string (car x)))))))
    (define and? (match-type "AND"))
    (define or?  (match-type "OR"))
    (define not? (match-type "NOT"))
    (define rxp? (fun byte-regexp? regexp?))
    (define (compile query)
      (cond ((rxp? query)
             (lambda (info)
               (or (regexp-match query (lookup 'title info))
                   (regexp-match query (lookup 'desc info)))))
            ((string? query)
             (compile (m query)))       ; migemize string
            ((and? query)
             (let ((procs (map compile (cdr query))))
               (lambda (info)
                 (andmap (cut <> info) procs))))
            ((or? query)
             (let ((procs (map compile (cdr query))))
               (lambda (info)
                 (ormap (cut <> info) procs))))
            ((not? query)               ; unary operator
             (complement (compile (cadr query))))
            ((list? query)
             (compile (cons 'and query)))
            (else #f)))                 ; error!
    (lambda (query)
      (unless m
        (set! m ((dynamic-require "migemo.ss" 'migemo))))
      (if (eq? query 'done)
          (set! m (m 'done))
          (filter-db (compile query))))))

検索文字列の正規表現化と DB 検索の両関数を一つに統合した、というのが変更点です。

どちらも基底条件 (rxp? の時) における演算の上に論理的な演算を被せていくことで、ネストされた式を再帰的に解いている点は同じです。

ただ、最初のバージョンでは DB のレコードごとに検索式の構文解析が行われていたのに対し、後者では初めに compile した時点で完全に解析が終了しています。何千・何万回と一回の違いですから、もの凄い効率化になっているわけです。


ちなみに、関係無いんですが、動画のリストを出力する関数は以下のようになっています:

(define (youtube-vids->html file vids)
  (let ((vids (mapfilter youtube-video vids)))
    (and (pair? vids)
         (call-with-output-file file
           (lambda (out)
             ((send-reply out)
              (html:html
               (html:head
                (html:meta "Content-Type" "text/html; charset=utf-8")
                (html:link (@ (rel "stylesheet") (href "youtube.css")))
                (html:javascript (@ (src "youtube.js"))))
               (html:body
                (html:javascript
                 "var vids="
                 (lambda (_)
                   (json-write
                    (map (lambda (info)
                           (let ((vid  (car info))
                                 (info (cute lookup <> (cdr info)))
                                 (item (hash)))
                             (for-each
                              item
                              '(vid title user desc watch thumb flv)
                              (list vid
                                    (info 'title)
                                    (info 'user)
                                    (info 'desc)
                                    (watch-url vid)
                                    (thumb-url vid)
                                    (video-url vid (info 't))))
                             item))
                         vids)
                    out))
                 ";")))))
           'replace)
         'done)))

詳細は措いて、この関数のかっこいい個所を発表させていただきます。ここです:

(for-each item
          (list of keys)
          (list of values))

item はハッシュ表なんですが、これだけでハッシュ表に情報を登録できているんです。

本来

(lambda (key value)
  (hash-table-put! item key value))

とするところが `item' だけで済んでいるわけです。Applicable Hashtable というものを使っています。


scheme-youtube


参考までに、Common Lisp:

#'(lambda (key value)
    (setf (gethash key item) value))

Arc:

(fn (k v) (= (item k) v))

となるでしょうか。


追記:
Arc だとこういう方法もありますね:

(fill-table
  (table)
  `(k1 ,v1 ... kn ,vn))


さて、肝心の C/Migemo ライブラリのラッパーモジュールなんですが、ちょっと自分でも理解していない問題があり、てきとうな実装です。API 関数の定義部分だけでも参考にしてください:

(module migemo mzscheme
  (provide migemo)

  (require "prelude.ss"
           (lib "2.ss" "srfi")
           "ffi.ss"
           "iconv.ss")

  (define main-dict "c:\\MAIN-DICT-DIR\\migemo-dict")
  (define user-dict "c:\\USER-DICT-DIR\\user-dict")

  (define MIGEMO_DICTID_MIGEMO 1)
  (define MIGEMO_OPINDEX_NEST_IN 1)

  (define-api migemo
    (migemo_open (_string -> _pointer))
    (migemo_close (_pointer -> _void))
    (migemo_query (_pointer _string -> _bytes))
    (migemo_release (_pointer _bytes -> _void))
    (migemo_load (_pointer _int _string -> _int))
    (migemo_is_enable (_pointer -> _int))
    (migemo_get_operator (_pointer _int -> _string))
    (migemo_set_operator (_pointer _int _string -> _int)))

  (define (debug x who)
    (printf "[~a] ~a~%" who x)
    x)

  (define (chars->branches chars)
    (string->bytes/utf-8
      (format "(?:~a)"
              (mapconcat identity
                         (string->list chars)
                         "|"))))

  (define (open-migemo dict)
    (and-let* ((m (migemo_open dict)))
      (lambda (k)
        (case k
          ((done) (migemo_close m) #f)
          ((load) (lambda (id dict)
                    (migemo_load m id dict)))
          ((set-op) (lambda (id op)
                      (migemo_set_operator m id op)))
          ((release) (lambda (p) (migemo_release m p)))
          ((query) (lambda (q) (migemo_query m q)))))))

  (define (migemo)
    (and-let* ((m (open-migemo main-dict))
               (c->u (iconv "CP932" "UTF-8")))
      ((m 'load) MIGEMO_DICTID_MIGEMO user-dict)
      ((m 'set-op) MIGEMO_OPINDEX_NEST_IN "(?i:")
      (lambda (str)
        (if (eq? str 'done)
            (begin (c->u 'done)
                   (m 'done))
            (let ((p ((m 'query) str)))
              (begin0
                  (byte-regexp
                   (debug
                    (regexp-replace* #rx"\\[(.+?)\\]"
                                     (c->u p)
                                     (lambda (_ chars)
                                       (chars->branches
                                        (bytes->string/utf-8 chars))))
                    'regexp))
                ((m 'release) p))))))))