SQLiteのFTS5で全文検索してみる (Node.js)
全文検索をやったことなかったので、SQLiteでやってみた。
※全文検索のために何が必要で、どうやればいいのかを知りたかっただけなので、仕組みとかは説明しない。コードも最小限ではなく、適当に動かせる程度にいろいろ書いている。
概要
ここではSQLiteのFTS5という仮想テーブルモジュールを使って、簡単な全文検索を行うTypeScript製のCLIアプリを作成する。
FTS5は文字列をトークン化して扱うらしいのだが、日本語をキレイにトークン化するには形態素解析が必要である。形態素解析には kuromoji のJavaScript移植版である kuromoji.js を使う。
全文検索のネタとしては、フロントマターつきMarkdownで書かれた自作小説を使う。Markdownの記法は使っていないが、いくらかHTMLタグを含む想定なので、クレンジングする必要がある。
やることとしては以下の通り。
- SQLiteにメタデータ用のテーブルとFTS5用の仮想テーブルを作成する。
- フロントマターつきMarkdownから抽出した小説のタイトルと本文を分かち書きにして、SQLiteに格納する。(
novel_search.db
としてカレントディレクトリに永続化) - 検索キーワードを指定して、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
は設定してない。
{
"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"
}
}
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"downlevelIteration": true,
"moduleResolution": "node10",
"allowSyntheticDefaultImports": true,
"declaration": true,
"sourceMap": true
}
}
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-index
と search
の2つのサブコマンドを作成する。
create-index
は、指定されたディレクトリに格納されたフロントマターつきMarkdownを舐めて、インデックスを作成するやつ。
search
は、指定されたキーワードを用いて、作成済みのインデックスから全文検索を行うやつ。
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してるだけ。
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
というメタデータ用のテーブルを作成しているが、未使用なので必要無ければ削除してよい。
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
も読み込むようにしてたりする。
ここではまだテキストをそのまま抜き出してるだけで、加工などは行っていない。
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の全文検索で使える。
なお、ここでは「小説をキーワード検索して該当箇所を表示する」を目的にしているので全文をトークンとして残しているが、これが例えば問い合わせ内容の全文検索とかなら、名詞・動詞・形容詞などの品詞から必要なものだけ抽出するのがよさそう。また、活用形を基本形に戻したものも格納しておくと、より広いキーワードでヒットさせられるようになるくさい。
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
列を含めているので実際の値を見ることができるが、確かにマッチ度が高いほど値が小さい……ような?気がする。スクショは次項。
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検索も機能してる模様。
一点気になったのは、トークン同士の距離は指定してるけど順番は指定できてないから、逆転してヒットする場合があるっぽいということ。
以上。