こうこく
作 ▸

SQLiteのFTS5で全文検索してみる (Node.js)

全文検索をやったことなかったので、SQLiteでやってみた。

※全文検索のために何が必要で、どうやればいいのかを知りたかっただけなので、仕組みとかは説明しない。コードも最小限ではなく、適当に動かせる程度にいろいろ書いている。

Node.js v22.14.0better-sqlite3 11.9.1kuromoji 0.1.2
もくじ

概要

ここではSQLiteのFTS5という仮想テーブルモジュールを使って、簡単な全文検索を行うTypeScript製のCLIアプリを作成する。

FTS5は文字列をトークン化して扱うらしいのだが、日本語をキレイにトークン化するには形態素解析が必要である。形態素解析には kuromoji のJavaScript移植版である kuromoji.js を使う。

全文検索のネタとしては、フロントマターつきMarkdownで書かれた自作小説を使う。Markdownの記法は使っていないが、いくらかHTMLタグを含む想定なので、クレンジングする必要がある。

やることとしては以下の通り。

  1. SQLiteにメタデータ用のテーブルとFTS5用の仮想テーブルを作成する。
  2. フロントマターつきMarkdownから抽出した小説のタイトルと本文を分かち書きにして、SQLiteに格納する。(novel_search.db としてカレントディレクトリに永続化)
  3. 検索キーワードを指定して、SQLiteに格納された小説のタイトルと本文から全文検索を行い、該当箇所をJSON形式で標準出力する。

では順番に見ていこう。

準備

以下コマンドで必要なパッケージをインストール。

コマンド
npm install better-sqlite3 kuromoji commander yaml typescript @types/node @types/better-sqlite3 @types/kuromoji

SQLiteへのアクセスは better-sqlite3 を使う。ただこれ、pnpmでインストールすると実行時に Error: Could not locate the bindings file. エラーが出るみたいなので、このプロジェクトではnpmを使っている。

なお、今回の package.json とかはこんな感じ。CLIと言いつつ、どうせtsxで実行するので bin は設定してない。

package.json
{
  "name": "my-full-text-search",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "@types/better-sqlite3": "^7.6.13",
    "@types/kuromoji": "^0.1.3",
    "@types/node": "^22.14.1",
    "better-sqlite3": "^11.9.1",
    "commander": "^13.1.0",
    "kuromoji": "^0.1.2",
    "typescript": "^5.8.3",
    "yaml": "^2.7.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.24.0",
    "eslint": "^9.24.0",
    "prettier": "^3.5.3",
    "tsx": "^4.19.3",
    "typescript-eslint": "^8.29.1"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2022",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "downlevelIteration": true,
    "moduleResolution": "node10",
    "allowSyntheticDefaultImports": true,
    "declaration": true,
    "sourceMap": true
  }
}
eslint.config.mjs
import js from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended],
    rules: {
      'no-irregular-whitespace': 'off',
    },
  },
  ...tseslint.configs.recommended,
  ...tseslint.configs.stylistic,
);

CLIのエントリーポイント

CLIアプリなので、 commander.js を使ってコマンドライン引数をパースする。

ここでは create-indexsearch の2つのサブコマンドを作成する。

create-index は、指定されたディレクトリに格納されたフロントマターつきMarkdownを舐めて、インデックスを作成するやつ。

search は、指定されたキーワードを用いて、作成済みのインデックスから全文検索を行うやつ。

src/index.ts
import { Command } from 'commander';
import { search } from './search';
import { createIndex } from './createIndex';

const program = new Command();

// "mycli" はテキトー、どうせ tsx で実行するので
program.name('mycli').description('小説の全文検索をするCLIアプリケーション').version('0.1.0');

// インデックス作成サブコマンド
program
  .command('create-index')
  .description('指定されたディレクトリのフロントマターつきMarkdownからインデックスを作成します')
  .argument('<source-directory>', 'インデックス作成元のファイルのディレクトリ')
  .action(async (directory) => {
    await createIndex({ dirPath: directory });
  });

// 検索サブコマンド
program
  .command('search')
  .description('指定されたキーワードで小説を検索します')
  .argument('<query>', '検索キーワード... 複数指定した場合はAND検索')
  .action(async (query) => {
    const results = await search({ query, limit: 10 });
    if (results.length === 0) {
      console.log('結果がありませんでした。');
      return;
    }
    // 結果をJSONで標準出力
    console.log(highlightForCLI(JSON.stringify(results, null, 2)));
  });

program.parse(process.argv);

/**
 * 検索結果の <b></b> をCLI用に強調表示する
 * @param text
 * @returns
 */
function highlightForCLI(text: string): string {
  return text
    .replace(/<b>/g, '\x1b[1m\x1b[31m') // 赤の太字開始
    .replace(/<\/b>/g, '\x1b[0m'); // リセット
}

テーブル作成~インデックス作成

インデックス作成してるとこ

create-index サブコマンドの実装。

これはエントリーポイントから呼ばれる関数だが、普通のことしかしてない。強いて言えば、FTS5用の仮想テーブル novel_tokens (次項で説明) にトークン化したテキストを格納してるけど、普通にバインドしてINSERTしてるだけ。

src/createIndex.ts
import { Database } from 'better-sqlite3';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { getDatabase, initializeDB, isInitialized } from './libs/dbManager';
import { loadMarkdownWithFrontMatterFiles } from './libs/fileLoader';
import { generateTokenizedText } from './libs/textProcessor';

interface CreateIndexParams {
  dirPath: string;
}

interface IndexData {
  id: string;
  metadata: {
    series: string;
    title: string;
  };
  tokens: {
    title: string;
    content: string;
  };
}

/**
 * 指定されたディレクトリのフロントマターつきMarkdownからインデックスを作成する
 * @param dirPath
 */
export async function createIndex({ dirPath }: CreateIndexParams): Promise<void> {
  if (!(await fs.lstat(dirPath).catch(() => false))) {
    console.error(`指定されたディレクトリが存在しません: ${dirPath}`);
    process.exit(1);
  }

  // データベースが初期化されてなければ初期化
  const db = getDatabase();
  if (!(await isInitialized(db))) {
    initializeDB(db);
  }

  // 小説ファイル読み込み
  // 型引数のメタデータは、お手元のMarkdownのフロントマターに合わせてください
  const novelFiles = await loadMarkdownWithFrontMatterFiles<{ series: string; title: string }>(dirPath);

  // テキスト処理してインデックスに追加
  for (const file of novelFiles) {
    const { metadata, content } = file;
    const id = `${metadata.series}/${path.basename(file.path)}`;
    await addToIndex(db, {
      id,
      metadata: {
        series: metadata.series,
        title: metadata.title,
      },
      tokens: {
        title: await generateTokenizedText(metadata.title),
        content: await generateTokenizedText(content),
      },
    });
    console.log(`インデックス追加: ${id}`);
  }
  console.log('インデックス作成完了');
}

/**
 * ファイルの内容をインデックスに追加する (すでに存在する場合は上書き)
 * @param db データベース接続
 * @param data インデックスに追加するデータ
 */
async function addToIndex(db: Database, { id, metadata, tokens }: IndexData): Promise<void> {
  // トランザクション
  const transaction = db.transaction(() => {
    // メタデータ
    const insertEntry = db.prepare('INSERT OR REPLACE INTO novel_metadata (id, series, title) VALUES (?, ?, ?)');
    insertEntry.run(id, metadata.series, metadata.title);

    // 全文検索インデックス
    const insertContent = db.prepare('INSERT OR REPLACE INTO novel_tokens (id, title, content) VALUES (?, ?, ?)');
    insertContent.run(id, tokens.title, tokens.content);
  });
  transaction();
}

テーブル作成してるとこ

次はテーブルを作っているところだ。

FTS5の仮想テーブル novel_tokens を作っているところが少し特殊。データ型を指定する必要が無いというか、すべてのカラムが TEXT 型になるのか? 型、制約、PRIMARY KEYは指定できない。

UNINDEXED は大事で、これを指定していないカラムは全て全文検索の対象になる。つまり直接検索対象にしたくないID列やメタデータには UNINDEXED を指定するべき。

なお、ここでは別途 novel_metadata というメタデータ用のテーブルを作成しているが、未使用なので必要無ければ削除してよい。

src/libs/dbManager.ts
import path from 'node:path';
import DatabaseConstructor, { Database } from 'better-sqlite3';

// DBファイルのパス
const DB_PATH = path.join(process.cwd(), 'novel_search.db');

/**
 * データベース接続を取得
 */
export function getDatabase(): Database {
  const db = new DatabaseConstructor(DB_PATH);
  return db;
}

/**
 * データベースファイルを初期化済みならtrueを返す
 * @param db データベース接続
 * @returns
 */
export function isInitialized(db: Database): boolean {
  // メタデータテーブルの存在を確認
  const result = db.prepare<[], { name: string }>(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'novel_metadata'`).get();
  return !!result;
}

/**
 * 必要なテーブルを作成する (あれば作り直す)
 * @param db データベース接続
 */
export function initializeDB(db: Database) {
  // メタデータテーブル
  db.exec(`DROP TABLE IF EXISTS novel_metadata`);
  db.exec(`
    CREATE TABLE IF NOT EXISTS novel_metadata (
      id TEXT PRIMARY KEY,
      series TEXT,
      title TEXT
    );
  `);

  // 全文検索用のFTS5仮想テーブル
  // ※UNINDEXED を指定したカラムは全文検索時にヒットしない
  db.exec(`DROP TABLE IF EXISTS novel_tokens`);
  db.exec(`
    CREATE VIRTUAL TABLE IF NOT EXISTS novel_tokens USING FTS5 (
      id UNINDEXED,    -- ID (シリーズ + 元のファイル名)
      title,           -- タイトル 検索対象
      content          -- 本文 検索対象
    );
  `);
}

フロントマターつきMarkdownの読み込み

次は、フロントマターつきMarkdownを読み込んでるところだ。

しきりに『フロントマターつきMarkdown』と書いているが、具体的には AstroのContent Collections で管理している本家サイトの小説を想定してるので、*.md だけでなく *.mdx も読み込むようにしてたりする。

ここではまだテキストをそのまま抜き出してるだけで、加工などは行っていない。

src/libs/fileLoader.ts
import { promises as fs } from 'node:fs';
import path from 'node:path';
import YAML from 'yaml';

export interface ParsedFile<TMetadata> {
  path: string;
  metadata: TMetadata;
  content: string;
}

/**
 * 指定されたディレクトリから全てのフロントマターつきMarkdownファイルを読み込み、メタデータと本文に分離
 * @param dirPath 対象ディレクトリパス
 * @returns
 */
export async function loadMarkdownWithFrontMatterFiles<TMetadata>(dirPath: string): Promise<ParsedFile<TMetadata>[]> {
  // ディレクトリ内のすべての .md or .mdx ファイルを取得
  const mdFiles = (await fs.readdir(dirPath)).filter((file) => {
    const ext = path.extname(file);
    return ext === '.md' || ext === '.mdx';
  });

  // 各ファイルをパース
  const result: ParsedFile<TMetadata>[] = [];
  for (const fileName of mdFiles) {
    const filePath = path.join(dirPath, fileName);
    const mdxStr = await fs.readFile(filePath, 'utf-8');

    // フロントマターと本文を分離
    const parts = mdxStr.split('---');
    if (parts.length < 3) {
      console.warn(`フロントマターの形式が不正です: ${filePath}`);
      continue;
    }

    // parts[0]は通常カラ、parts[1]がフロントマター、parts[2]以降が本文
    const frontMatter = parts[1].trim();
    const metadata: TMetadata = YAML.parse(frontMatter);
    const content = parts.slice(2).join('---').trim();

    result.push({ path: filePath, metadata, content });
  }

  return result;
}

テキストのトークン化

次は、テキストをトークン化しているところだ。ここでトークン化したものを、上記のSQLiteの novel_tokens テーブルに格納することになる。

テキストからHTMLタグを除去して、改行と全角スペースを半角スペースに置換して、kuromoji.jsのトークナイザーを使ってトークン化する。

と言っても、バラバラのものをそのままテーブルに格納はできないので、半角スペースで結合して1つの文字列に戻している。これで分かち書きの形式になるので、FTS5の全文検索で使える。

なお、ここでは「小説をキーワード検索して該当箇所を表示する」を目的にしているので全文をトークンとして残しているが、これが例えば問い合わせ内容の全文検索とかなら、名詞・動詞・形容詞などの品詞から必要なものだけ抽出するのがよさそう。また、活用形を基本形に戻したものも格納しておくと、より広いキーワードでヒットさせられるようになるくさい。

src/libs/textProcessor.ts
import path from 'node:path';
import * as kuromoji from 'kuromoji';

let _tokenizer: kuromoji.Tokenizer<kuromoji.IpadicFeatures> | null = null;

/**
 * kuromojiのトークナイザーを初期化
 * @returns
 */
export async function getTokenizer(): Promise<kuromoji.Tokenizer<kuromoji.IpadicFeatures>> {
  if (_tokenizer) {
    return _tokenizer;
  }
  return new Promise((resolve, reject) => {
    // kuromojiのビルダーを作成
    kuromoji
      .builder({
        // 辞書のパス (node_modules/kuromoji/dict)
        dicPath: path.join(path.dirname(require.resolve('kuromoji')), '..', 'dict'),
      })
      .build((err, tokenizer) => {
        if (err) {
          reject(err);
          return;
        }
        _tokenizer = tokenizer;
        resolve(_tokenizer);
      });
  });
}

/**
 * テキストを前処理して分かち書きにしたものを返す
 * @param text 処理するテキスト
 * @returns
 */
export async function generateTokenizedText(text: string): Promise<string> {
  // HTMLタグを除去、改行と全角スペースを半角スペースに置換、連続するスペースを1つにまとめる
  const cleanedText = text
    .replace(/<[^>]*>/g, '')
    .replace(/\r?\n/g, ' ')
    .replace(/ /g, ' ')
    .replace(/\s+/g, ' ')
    .trim();

  // トークナイザーを取得
  const tokenizer = await getTokenizer();

  // テキストをトークンに分解
  const tokens = tokenizer.tokenize(cleanedText);

  // 分かち書きにする (surface_form はトークンの表層形で、元のテキストそのままである)
  return tokens.map((token) => token.surface_form).join(' ');
}

全文検索するとこ

そういうわけで検索の実装。指定されたキーワードをFTS5の MATCH 演算子に渡して、全文検索を行う。

実際に少し動かしてみてすぐ気付いたが、キーワード自体をまたトークン化してFTS5用のクエリに変換する必要があったのでそうしている。細かいことは以下コードの convertQuery 内のコメントを見てほしい。

ここでは小説の全文検索を目的としているので、例えばキーワードで「キリウ君」なる愛称が指定された場合、「キリウ」と「君」がバラバラにヒットするようでは使い勝手が悪い。よって、NEARグループを使ってフレーズ同士の距離を制限している。(※FTS5における「フレーズ」の概念をきちんと理解せずに言ってるので、話半分で聞いてほしい。概ね思った通りに動いてはいる。)

検索の該当箇所の抽出とハイライトには snippet() 関数を使っている。こんなドンピシャな関数が用意されてて驚いた。ただこれはCLIアプリなので、ここで付与した <b> タグは先に書いた index.ts にてANSIエスケープシーケンスに変換している。

それと検索結果のソートに使用している rank 列は、FTS5が自動で追加してくれる列で、BM25アルゴリズムに基づいているらしい。これを昇順で並べることで、一致度の高い順に並ぶ。ここでは取得結果にそのまま rank 列を含めているので実際の値を見ることができるが、確かにマッチ度が高いほど値が小さい……ような?気がする。スクショは次項。

src/search.ts
import { getDatabase } from './libs/dbManager';
import { getTokenizer } from './libs/textProcessor';

interface SearchResult {
  id: string;
  title: string;
  snippet: string;
  rank: number;
}

interface SearchParams {
  query: string;
  limit: number;
}

/**
 * 指定されたキーワードで小説を検索して、一致度の高い順に結果を返す
 * @param query 検索クエリ (半角スペース区切りの場合はAND検索)
 * @returns 検索結果の配列
 */
export async function search({ query, limit }: SearchParams): Promise<SearchResult[]> {
  // クエリをFTS5用に変換
  const fts5Query = await convertQuery(query);
  console.log(`「${query}」で検索... MATCH: ${fts5Query}`);

  const db = getDatabase();

  // 検索SQL
  const stmt = db.prepare<unknown[], SearchResult>(`
      SELECT 
        c.id,
        m.title,
        -- 第二引数は、テーブル作成時に「UNINDEXED」を指定しなかったカラムの中で何番目を対象とするか。-1なら全て。
        snippet(novel_tokens, -1, '<b>', '</b>', '...', 15) as snippet,
        -- rank列は自動で追加され、全文検索時にbm25()関数の結果がセットされる。昇順で並べれば一致度が高い順に並ぶ。
        rank
      FROM 
        novel_tokens c
      JOIN 
        novel_metadata m ON c.id = m.id
      WHERE 
        novel_tokens MATCH ?
      ORDER BY 
        rank
      LIMIT ?
    `);

  // 検索実行
  const rows = stmt.all(fts5Query, limit);

  // 結果を整形
  return rows.map((row) => ({
    id: row.id,
    title: row.title,
    snippet: row.snippet.replace(/ /g, ''), // 分かち書きの半角スペースを除去
    rank: row.rank,
  }));
}

/**
 * 検索クエリをSQLiteのFTS5用に変換する
 */
async function convertQuery(query: string): Promise<string> {
  const tokenizer = await getTokenizer();

  return (
    query
      // 半角スペースで分割して…
      .split(' ')
      // 各キーワードを形態素解析して更に分かれるようなら、NEARグループで、それらが隣接したものだけひっかけるようにする
      .map((word) => {
        const tokens = tokenizer.tokenize(word);
        if (tokens.length > 1) {
          // NEARの第二引数は「最初のフレーズから最後のフレーズまでの間がnトークン以下である」のnの部分
          // つまり隣接したものをひっかけるなら、ここではトークン数 - 2 を指定すればいいはず (2トークンなら0)
          return `NEAR(${tokens.map((token) => `"${escapeDoubleQuotes(token.surface_form)}"`).join(' ')}, ${tokens.length - 2})`;
        }
        // それ以外はそのまま
        return `"${escapeDoubleQuotes(word)}"`;
      })
      // 明示的なAND検索に変換 (FTS5はデフォルトでAND検索っぽいが、一応)
      .join(' AND ')
  );
}

/**
 * SQLのパラメータに使用する文字列のクォートをエスケープする
 */
function escapeDoubleQuotes(str: string): string {
  return str.replace(/"/g, `""`);
}

実行してみる

以下のような感じで実行する。(横着してtsxで実行してるが、ビルドしてnodeコマンドで実行してもよい。)

# インデックス作成
npx tsx src/index.ts create-index /path/to/novels

# 検索
npx tsx src/index.ts search "キリウ君"
npx tsx src/index.ts search "タバコ 火"
npx tsx src/index.ts search "ジュン君"

結果はこう。けっこうそれっぽい?

『キリウ君』の検索結果のスクリーンショット
『キリウ君』の検索結果

AND検索も機能してる模様。

『タバコ 火』の検索結果のスクリーンショット
『タバコ 火』の検索結果

一点気になったのは、トークン同士の距離は指定してるけど順番は指定できてないから、逆転してヒットする場合があるっぽいということ。

『ジュン君』の検索結果のスクリーンショット。『君』『ジュン』の並びでヒットしてしまっている。
『ジュン君』の検索結果

以上。

この記事に何かあればこちらまで (非公開)