JavaScriptNode.js

TypeScriptで全文検索を実行する – lunr編

最近になってJSだけで全文検索ができるということをようやく知りまして、早速有名どころのライブラリを触ってみました。 セットアップ 本体・言語拡張・型定義をそれぞれインストールします。 そのまま英語で動かしてみる 英語の場 […]

広告ここから
広告ここまで

最近になってJSだけで全文検索ができるということをようやく知りまして、早速有名どころのライブラリを触ってみました。

セットアップ

本体・言語拡張・型定義をそれぞれインストールします。

$ yarn add lunr lunr-languages
$ yarn add -D @types/lunr

そのまま英語で動かしてみる

英語の場合は、かなりシンプルに扱えます。

import lunr from 'lunr'
const idx = lunr(function () {
      // 検索するフィールドの設定
      this.field('title')
      this.field('body')
     
      // インデックスの追加
      this.add({
        "title": "Twelfth-Night",
        "body": "If music be the food of love, play on: Give me excess of it…",
        "author": "William Shakespeare",
        "id": "1"
      })
      this.add({
        "title": "The Phantom of the Opera",
        "body": "The Phantom of the Opera's origins came from Leroux's curiosity with the Phantom being real. In the prologue, he tells the readers about the Phantom and the research that he did to prove the truth of the ghost. His findings connected the corpse from the opera house to the Persian phantom himself",
        "author": "Gaston Leroux",
        "id": "2"
      })
    })

idx.search('music')
[
  {
    "matchData": {
      "metadata": {
        "music": {
          "body": {}
        }
      }
    },
    "ref": "1",
    "score": 0.925
  }
]

検索処理として組み込む例

lunrは「なにがどこにマッチしたか」「マッチしたidはどれか」「どれくらいのスコアか」の3点を取得できます。しかし検索結果として利用する場合、対象となるドキュメントを返してやる必要があります。

先ほどの例を書き換えるとこのような形となります。

type Document = {title: string, body: string, author: string}
const search = (documents: Array<Document>, search: string): {
  document: Document,
  score: number
} | null => {
    const idx = lunr(function () {
        this.field('title')
        this.field('body')
        const self = this
        documents.forEach((doc, i) => {
            self.add({
            ...doc,
            id: String(i+1)
            })
        })
    })
    const target = idx.search(search)
    if (!target || target.length < 1) return null
    const targetIndex = Number(target[0].ref)
    return {
      document: documents[targetIndex],
      score: target[0].score
    }
}

{
  "document": {
    "author": "Gaston Leroux",
    "body": "The Phantom of the Operas origins came from Lerouxs curiosity with the Phantom being real. In the prologue, he tells the readers about the Phantom and the research that he did to prove the truth of the ghost. His findings connected the corpse from the opera house to the Persian phantom himself",
    "title": "The Phantom of the Opera"
  },
  "score": 0.925
}

スコアをつけるようにしていますが、省略することでシンプルにもできます。

lunr-launguagesがTypeScript非対応なので力技を使う

で、問題は多言語化です。

多言語化のためのライブラリとしてlunr-languagesがありますが、TypeScriptに対応していません。

TypeDefinition for this library? #51

https://github.com/MihaiValentin/lunr-languages/issues/51

「requireつかえ」という結論っぽいのですが、ちょっとだけ頑張ってみます。

import Lunr from 'lunr'
type Ilunr = (config: Lunr.ConfigFunction) => Lunr.Index
type JPlunr = Ilunr & {
    ja: any
}

const lunr: JPlunr = require('lunr')
require('lunr-languages/lunr.stemmer.support.js')(lunr)
require('lunr-languages/tinyseg.js')(lunr)
require('lunr-languages/lunr.ja.js')(lunr)


const documents = [{
    title: '吾輩は猫である',
    text: '吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。'
}, {
    title: '学問のすゝめ',
    text: '「天は人の上に人を造らず人の下に人を造らず」と言えり。されば天より人を生ずるには、万人は万人みな同じ位にして、生まれながら貴賤上下の差別なく、万物の霊たる身と心との働きをもって天地の間にあるよろずの物を資り、もって衣食住の用を達し、自由自在、互いに人の妨げをなさずしておのおの安楽にこの世を渡らしめ給うの趣意なり。'
}, {
    title: '蜘蛛の糸',
    text: 'ある日の事でございます。御釈迦様は極楽の蓮池のふちを、独りでぶらぶら御歩きになっていらっしゃいました。池の中に咲いている蓮の花は、みんな玉のようにまっ白で、そのまん中にある金色の蕊からは、何とも云えない好い匂が、絶間なくあたりへ溢れて居ります。極楽は丁度朝なのでございましょう。'
}]

const idx = lunr(function () {
    this.ref('title')
    this.field('text')
    this.use(lunr.ja)
    const self = this

    documents.forEach(function (doc) {
        self.add(doc)
    }, this)
})

const result = idx.search('天')
[
  {
    "matchData": {
      "metadata": {
        "天": {
          "text": {}
        }
      }
    },
    "ref": "学問のすゝめ",
    "score": 0.884
  }
]

やったこと

そのまま使うと、this.use(lunr.ja)の部分で型エラーが発生します。なのでjaプロパティがある型だということでrequireしなおしました。

@types/lunrからextendなどができる型が取れない様子だったため、lunrにつける型定義を改めて書き直しています。

正直ハック的な解決ではありますが、requireを使って型情報を失うのももったいないのでどっちを負債として受け入れるかかなぁという印象です。

No DB的全文検索の使い所

全文検索といえばElasticsearchのイメージだったのでした。が、APIレスポンスに対する絞り込み検索のようなちょっとした用途であれば、こういうライブラリで実装してしまうのも手かなと思います。「外部のAPIで思うような検索ができない・・・」という時にもアプリ内 or AWS API Gateway + Lambdaで検索用のプロキシーAPIを実装すればいけそうですし。

とはいえインデックスに時間がかかる場合もありそうなので、Web Workerなどに逃がしてやる必要はありそうです。この辺りは実験が必要かもしれません。

Appendix: インデックスのエクスポート

lunr()関数の戻り値がそのままインデックスデータになっています。ですのでJSONとして出力し、検索の際はそれをREADするようにすれば毎回index処理を走らせる必要が無くなります。

import * as Lunr from 'lunr'
import fs from 'fs'

type Ilunr = (config: Lunr.ConfigFunction) => Lunr.Index
type JPlunr = Ilunr & {
    ja: any,
    Index: {
        load: (index: object) => Lunr.Index
    }
}

const lunr: JPlunr = require('lunr')
require('lunr-languages/lunr.stemmer.support.js')(lunr)
require('lunr-languages/tinyseg.js')(lunr)
require('lunr-languages/lunr.ja.js')(lunr)


const documents = [{
    title: '吾輩は猫である',
    text: '吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。'
}, {
    title: '学問のすゝめ',
    text: '「天は人の上に人を造らず人の下に人を造らず」と言えり。されば天より人を生ずるには、万人は万人みな同じ位にして、生まれながら貴賤上下の差別なく、万物の霊たる身と心との働きをもって天地の間にあるよろずの物を資り、もって衣食住の用を達し、自由自在、互いに人の妨げをなさずしておのおの安楽にこの世を渡らしめ給うの趣意なり。'
}, {
    title: '蜘蛛の糸',
    text: 'ある日の事でございます。御釈迦様は極楽の蓮池のふちを、独りでぶらぶら御歩きになっていらっしゃいました。池の中に咲いている蓮の花は、みんな玉のようにまっ白で、そのまん中にある金色の蕊からは、何とも云えない好い匂が、絶間なくあたりへ溢れて居ります。極楽は丁度朝なのでございましょう。'
}]

/**
 * Fileからインデックスをリストア
 **/
const restoreIndex = () => {
    const index = fs.readFileSync('lunr-index.json', 'utf-8')
    return lunr.Index.load(JSON.parse(index))
}

/**
 * インデックスを作成+ファイルにエクスポート
 **/
export const createIndex = () => {
    const index = lunr(function () {
        this.ref('title')
        this.field('text')
        this.use(lunr.ja)
        const self = this
    
        documents.forEach(function (doc) {
            self.add(doc)
        }, this)
    })
    try {
        fs.writeFileSync('lunr-index.json', JSON.stringify(index))
    } catch (e) {
        console.log('Failed to create a index file')
    }
    return index
}

/**
 * リストアをtry後、見つからなければインデックスを作る
 **/
const getIndex = () => {
    try {
        return restoreIndex()
    } catch (e) {
        if (!/no such file or directory/.test(e.message)) throw e
        return createIndex()
    }
}
export const idx = getIndex()


ブックマークや限定記事(予定)など

WP Kyotoサポーター募集中

WordPressやフロントエンドアプリのホスティング、Algolia・AWSなどのサービス利用料を支援する「WP Kyotoサポーター」を募集しています。
月額または年額の有料プランを契約すると、ブックマーク機能などのサポーター限定機能がご利用いただけます。

14日間のトライアルも用意しておりますので、「このサイトよく見るな」という方はぜひご検討ください。

広告ここから
広告ここまで

Related Category posts