Cloudflare D1 を使った日本語の全文検索を実装する
最近、SQL アンチパターンという本を読んでいたら、MySQL、 PostgreSQL、SQLite などのデータベースでも拡張機能を利用することで全文検索を実装できることを知りました。[1]
SQLite で構築されている Cloudflare D1 についても調べてみたところ、制限はあるものの全文検索の拡張機能が使えるということがわかりました。
Export is not supported for virtual tables, including databases with virtual tables. D1 supports virtual tables for full-text search using SQLite’s FTS5 module.
今回は個人ブログの全文検索 API を D1 を利用して実装してみたので、その方法について書きたいと思います。
作成した API
全文検索用のエンドポイント (/api/fts/search
) とデータ登録用のエンドポイント (/api/fts/insert
) を Cloudflare Workers と D1 を使って実装しました。各 API の仕様は次の通りです。
POST /api/fts/insert
- 認証:あり (今回は Basic 認証を実装)
- リクエストボディ
{
"id": "post-1",
"title": "タイトル",
"content": "ブログのコンテンツ"
}
- レスポンス:
-
201
:データ登録に成功した場合
-
GET /api/fts/search
- 認証:なし
- クエリ
-
q
:検索クエリ
-
- レスポンス:
-
200
:検索に成功した場合 -
400
:検索クエリがなかった場合
-
{
"posts": [
{
"id": "post-1",
"title": "タイトル1",
"content": "ブログのコンテンツ1"
},
{
"id": "post-2",
"title": "タイトル2",
"content": "ブログのコンテンツ2"
}
]
}
プロジェクトの作成
今回は Hono を利用して API の実装を行いました。次のコマンドでプロジェクトの雛形を作成します。
pnpm create hono@latest blog-fts
また、D1 対応している ORM として drizzle-orm も利用したかったのですが、全文検索の実装に必要な Virtual Table のサポートがまだされていないようなので、今回は見送りました。[2]
テーブルの作成
SQLite では全文検索のための fts テーブルを次のように定義します。カラムには、実際の検索対象となるドキュメントを格納します。
CREATE VIRTUAL TABLE table_name USING fts5(column1, column2, tokenizer='trigram', ... );
ここで重要になるのが、ドキュメントを検索対象の単位のトークンに分割する tokenizer のオプションです。SQLite の拡張で提供されている選択肢は、次の 4 つになっています。
- unicode61 tokenizer (デフォルト)
- Unicode 6.1.0 で定義されているスペースや句読点 (punctuation) で分割する方法
- ascii tokenizer
- unicode61 tokenizer をベースに、ascii 文字に関してルールを追加して分割する方法
- porter tokenizer
- 他の tokenizer と組み合わせて利用し、出力されたトークンを porter stemmer というアルゴリズムで正規化する方法
- 例) running, ran などの活用形が変化しているトークンを run に統一する
- trigram tokenizer
- 3 文字をトークンの単位とし、文字列の先頭から 1 文字ずつ順番に分割する方法
これらの tokenizer は、どれも日本語の場合を考慮できておらず、検索の精度があまり良くありません。今回の実装では、格納するドキュメントを別のツールを使ってトークンに分割し、得られたトークンをスペース区切りでつなげたものを fts テーブルへ格納するようにしました。[3] また、ドキュメント自体は別のテーブルで管理するようにしました。実際に作成したテーブル定義は、次のようになります。
CREATE TABLE IF NOT EXISTS contents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT NOT NULL
);
// content と title に格納するドキュメントを別のツールを使って分かち書きし、
// トークンをスペース区切りでつなげたものを segments へ格納する
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(segments);
作成するテーブル定義を決めたので、次のコマンドで D1 へ反映します。
# 新規のデータベースの作成
wrangler d1 create blog-fts
# SQL の実行 (本番環境に向けるときは --local を --remote にする)
wrangler d1 execute blog-fts --local --file=./schema.sql
API の実装
DB の準備ができたら、Hono を利用した API の実装をしていきます。
import { Hono } from "hono";
// エンドポイントの実装で利用する環境変数
// https://hono.dev/docs/api/context#env
type Bindings = {
DB: D1Database;
AUTH_USERNAME: string;
AUTH_PASSWORD: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// POST /api/fts/insert
app.post("/api/fts/insert", ...);
// GET /api/fts/search
app.get("/api/fts/search", ...)
export default app;
POST /api/fts/insert
データ登録用のエンドポイントでは、主に次の処理を行います。
- 受け取ったデータを contents テーブルに追加する
- Intl.Segmenter を利用して、受け取ったデータからトークンを抽出する
- 作成したトークンをスペース区切りで結合し、fts テーブルに追加する
これらの処理を行う具体的なコードは、次のようになりました。
import { basicAuth } from "hono/basic-auth";
type Post = {
id: string;
title: string;
content: string;
};
const segmenter = new Intl.Segmenter("ja", { granularity: "word" });
app.post(
"/api/fts/insert",
// Basic認証用の設定
async (c, next) => {
const auth = basicAuth({
username: c.env.AUTH_USERNAME,
password: c.env.AUTH_PASSWORD,
});
await auth(c, next);
},
async (c) => {
const post = await c.req.json<Post>();
const { id, title, content } = post;
const db = c.env.DB;
// 1. 受け取ったデータを contents テーブルに追加する
const res = await db
.prepare(
"INSERT INTO contents (post_id, title, content) VALUES (?1, ?2, ?3)",
)
.bind(id, title, content)
.run();
// レスポンスからデータ追加時の rowid を取得する (fts テーブルへのデータ追加時に利用する)
const rowId = res.meta.last_row_id;
// 2. Intl.Segmenter を利用して、受け取ったデータからトークンを抽出する
const segments = Array.from(segmenter.segment(`${title}。${content}`))
.filter((s) => s.isWordLike)
.map((s) => s.segment);
// 3. 作成したトークンをスペース区切りで結合し、fts テーブルに追加する
await db
.prepare("INSERT INTO fts (rowid, segments) VALUES (?1, ?2)")
.bind(rowId, segments.join(" "))
.run();
c.status(201);
return c.body(null);
},
);
文書のトークンへの分割 (分かち書き) には、Intl.Segmenter を利用しました。IPA 辞書を利用した単語分割を行なっているようで、詳しいアルゴリズムについては次の記事を参考にしてもらえればと思います。
検索精度のことを考えると、本当は sudachi や lindera などの日本語に特化した形態素解析のツールを利用した方が良いと思うのですが、JS からこれらのツールを簡単に利用できなさそうだったので今回は見送りました。[4]
GET /api/fts/search
全文検索用のエンドポイントでは、主に次の処理を行います。
- 検索クエリが渡されているかのバリデーション
- 検索クエリにマッチしたドキュメントを fts と contents テーブルを利用して取得する
これらの処理を行う具体的なコードは、次のようになりました。
app.get("/api/fts/search", async (c) => {
// 1. 検索クエリが渡されているかのバリデーション
const query = c.req.query("q");
if (!query) {
return c.json({ error: "Query parameter 'q' is required" }, 400);
}
const db = c.env.DB;
// 2. 検索クエリにマッチしたドキュメントを fts と contents テーブルを利用して取得する
// ランクが高い順に最大 5件のデータを返すようにしている
const { results } = await db
.prepare(
`SELECT contents.post_id as id, contents.title, contents.content FROM contents
JOIN fts ON contents.rowid = fts.rowid
WHERE fts MATCH ?1
ORDER BY rank
LIMIT 5`,
)
.bind(query)
.all<Post>();
return c.json({ posts: results });
});
API の挙動の確認
次のコマンドを実行することで、ローカルで挙動を確認できます。
# ローカルでの API サーバーの起動 (本番環境へのデプロイは wrangler deploy コマンドを使う)
wrangler dev src/index.ts
# データの登録 (Basic 認証の token は適切なものをセットする)
curl "http://localhost:8787/api/fts/insert" \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: Basic ....." \
-d '{"id" : "post-1" , "title" : "タイトル1", "content" : "ブログのコンテンツ1"}'
# データの検索
curl "http://localhost:8787/api/fts/search?q=keyword"
まとめ
今回の記事では、個人ブログの全文検索 API を Cloudflare Workers と D1 を利用して実装する方法を紹介しました。実装の全体を確認したい方は、次のコードを参考にしてもらえればと思います。
今回は全文検索を実装しましたが、Cloudflare はベクトルデータベースも提供しているので、検索機能を実装する選択肢としてベクトル検索もありそうです。
ただ、上記の記事でも触れられているのですが、Cloudflare からは英語を対象としたベクトル生成用の LLM のみしか提供されていません。その部分については OpenAI などの API を使うしかないようです。Cloudflare は、今回利用した D1 以外にも個人で色々試すには良い機能をたくさん提供してくれているので、日本語に対応した LLM も使えるようになることを期待しています。
追記: データ登録のエンドポイントでデータ更新もできるようにする
このブログを最初に書いた時点では、登録したデータに変更があった場合は毎回 DB を作り直すことを想定していたのであまり考えていなかったのですが、実際のケースではデータの登録だけでなく更新もできるとよさそうです。
ということで、/api/fts/insert
のエンドポイントの実装を upsert のような挙動になるように修正してみました。修正した結果のコードを次に示します。
app.put(
"/api/fts/upsert",
async (c, next) => {
const auth = basicAuth({
username: c.env.AUTH_USERNAME,
password: c.env.AUTH_PASSWORD,
});
await auth(c, next);
},
async (c) => {
const post = await c.req.json<Post>();
const { id, title, content } = post;
const db = c.env.DB;
// contents テーブルへのデータ追加
await db
.prepare(
`INSERT INTO contents (post_id, title, content) VALUES (?1, ?2, ?3)
ON CONFLICT(post_id) DO UPDATE SET title = excluded.title, content = excluded.content;`
)
.bind(id, title, content)
.run();
// rowid を取得する
const rowId = await db
.prepare("SELECT id FROM contents WHERE post_id = ?1")
.bind(id)
.first<string>("id");
if (!rowId) {
return c.json({ error: "Failed to get rowid" }, 500);
}
// fts テーブルへのデータ追加
const segments = Array.from(segmenter.segment(`${title}。${content}`))
.filter((s) => s.isWordLike)
.map((s) => s.segment);
await db
.prepare("INSERT OR REPLACE INTO fts (rowid, segments) VALUES (?1, ?2)")
.bind(rowId, segments.join(" "))
.run();
c.status(204);
return c.body(null);
}
);
実装の変更点としては、contents テーブルと fts テーブルへの insert 処理が upsert 処理に変わっています。SQLite では upsert 相当の処理は、次の2つの方法で実装できるようです。
-
INSERT INTO ... ON CONFLICT ... DO UPDATE SET ...
- すでにデータが登録されている場合は、既存のレコードを更新する
- fts テーブルのような仮想テーブルには利用できない
-
INSERT OR REPLACE INTO ...
- すでにデータが登録されている場合は、既存のレコードを削除してから新しく追加する
このとき contents テーブルの更新では、fts テーブルから参照されている id カラムを変更したくないため ON CONFLICT
を利用する方法を採用しました。一方で、fts テーブルの更新では ON CONFLICT
を利用する方法で更新することはできないため、REPLACE
を利用する方法を採用しています。
ここまで実装してみましたが、やはりトランザクションの仕組みは欲しいなという気持ちになりました。D1 は batch statements として簡易的なトランザクションの仕組みは提供してくれているのですが、今回のようなロジックのあるトランザクションは現時点で実装することができません。
-
この分野に詳しくないのですが、実務では Elasticsearch や Meilisearch などの専用のライブラリの利用を検討した方が良いと思います。 ↩︎
-
lindera-js や sudachi-wasm などの wasm ベースのツールはあったのですが、更新があまりされていないようなので利用しませんでした。 ↩︎
Discussion