DuckDBとCodeMirrorを使って青空文庫のデータを検索する
脆弱エンジニアの Advent Calendar 2024 19日目の記事です。
謝辞
本プロジェクトは@voluntasさんのduckdb-wasm-parquetを参考にさせていただきました。この場を借りて感謝申し上げます。
ライセンス情報 (クリックで展開)
This product includes software developed by @voluntas (duckdb-wasm-parquet). Licensed under the Apache License, Version 2.0.TL;DR
青空文庫データ分析ツールを作成しました。DuckDBとCodeMirrorを使って、青空文庫のデータをブラウザ上で検索・分析できるようにしました。
はじめに
青空文庫には多くの無料で読める文学作品が収録されていますが、大量のデータから特定の作品や著者を効率的に検索したり、データを分析したりするのは容易ではありません。この記事では、ブラウザ上で動作する軽量データベースエンジン DuckDB とインタラクティブなコードエディタ CodeMirror を組み合わせて、青空文庫のデータを快適に検索・分析できるツールを構築する方法を解説します。
DuckDBとは
DuckDBは、分析用に設計された組み込み型SQLデータベースエンジンです。PostgreSQLに似た文法を持ちながら、より分析処理に特化した機能を提供します。
主な特徴
- アナリティクス指向のアーキテクチャ
- 列指向ストレージエンジンによる高速な分析処理
列指向ストレージエンジンは、列ごとにデータを格納することで、必要な列のみを効率的に読み取ることができます。これにより、大規模なデータセットに対してもメモリ使用量を抑えつつ、高速に分析処理を行えます1。 - ベクトル化実行エンジンによる効率的なクエリ処理
ベクトル化実行エンジンは、データをチャンク単位で処理し、CPUキャッシュの効率を最大化します2。 - OLAP(Online Analytical Processing)ワークロードに最適化
OLAP向けのクエリ処理は、大量のデータの集計や分析を短時間で行うことを目指しています。 - SQLインジェクション対策の実装
パラメータ化されたクエリの採用により、SQLインジェクションのリスクを最小限に抑えた設計となっています3。
- 列指向ストレージエンジンによる高速な分析処理
- トランザクションサポート
WebAssembly版の特徴
DuckDB-WASMは、ブラウザ上で動作するDuckDBの実装です。
- 完全なクライアントサイド処理
- サーバーレスでの分析が可能
- ネットワーク遅延なしでのクエリ実行
- プライバシー保護(データがローカルで処理される)6
- ブラウザストレージとの統合
Parquet形式との連携
DuckDBは Apache Parquet 形式との親和性が高く、以下の利点があります。
- 列指向フォーマットによる効率的なデータ格納
- 高い圧縮率によるストレージ効率の向上
- スキーマ情報の保持による型安全性
- 必要な列のみを読み込むことによる高速化
CodeMirrorについて
CodeMirrorは、プログラマブルなテキストエディタを実現するためのTypeScriptライブラリです。バージョン6から完全に再設計され、より高度な機能と柔軟性を提供しています。
主要機能
- 最新のエディタ機能
- ユニコード対応
- 双方向テキストのサポート
- モバイルデバイスへの対応
- カスタマイズ可能なキーバインディング
- プログラミング言語サポート
- 200以上の言語に対応
- SQLの高度な構文ハイライト
- リアルタイムの構文エラー検出
- オートコンプリート機能
- 拡張システム
- State Field API による状態管理
- Compartments による拡張の分離
- View Plugins によるUI拡張
- Facet による設定の管理
SQLエディタとしての利用
CodeMirrorをSQLエディタとして活用する際の主な設定項目。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const editor = new EditorView({
state: EditorState.create({
doc: DEFAULT_SQL,
extensions: [
sql(), // SQLサポート
basicSetup, // 基本機能セット
EditorView.lineWrapping, // 行折り返し
EditorState.readOnly.of(false), // 編集可能設定
// SQLに特化したスタイル設定
EditorView.theme({
"&": {
height: "400px",
maxWidth: "100%"
},
".cm-content": {
fontFamily: "monospace"
}
})
]
})
});
パフォーマンス特性
- 仮想DOM不使用: 直接DOMを操作し高速なレンダリング
- インクリメンタルパース: 変更された部分のみを再解析
- レイジーローディング: 必要な機能のみを読み込み
データ処理の詳細
青空文庫のテキスト形式
青空文庫のテキストには、以下のような特殊な形式が含まれています。
- ルビ(読み仮名)
- 例:
零時半《れいじはん》
- 例:
桜《さくら》の花
- 例:
- 注記
- 例:
[#ここから2字下げ]
- 例:
[#「」は縦線付きの七重鉤括弧、第3段]
- 例:
テキストクリーニング
ルビの処理
1
2
3
4
5
def remove_ruby(text: str) -> str:
"""ルビを削除する関数"""
# 漢字《よみがな》形式のルビを削除
text = re.sub(r'([^《]*)《[^》]*》', r'\1', text)
return text
この関数は以下のように動作します。
零時半《れいじはん》
→零時半
桜《さくら》の花
→桜の花
完全なテキストクリーニング
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def clean_text(text: Optional[str]) -> Optional[str]:
"""テキストをクリーンアップする関数"""
if text is None:
return None
# Unicode正規化(NFKC)
text = unicodedata.normalize('NFKC', text)
# ルビの削除
text = remove_ruby(text)
# 制御文字の除去(改行は保持)
text = ''.join(char for char in text
if char == '\n' or unicodedata.category(char)[0] != 'C')
# 注記の削除
text = re.sub(r'[#[^]]*]', '', text)
# 改行の正規化
text = text.replace('\r\n', '\n').replace('\r', '\n')
# 連続する空白や改行を1つに
text = re.sub(r'\n\s*\n', '\n\n', text)
text = re.sub(r'[ \t]+', ' ', text)
return text.strip()
Parquetファイルへの変換と分割
データ処理の最終段階として、クリーニングしたテキストをParquet形式に変換し、扱いやすいサイズに分割します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def split_parquet_file(input_file: str, output_dir: str = "splits"):
"""Parquetファイルを60MB単位で分割する"""
con = duckdb.connect()
# 総行数の取得
total_rows = con.execute(
"SELECT COUNT(*) FROM read_parquet(?)",
[input_file]
).fetchone()[0]
# 6分割(各60MB程度)
rows_per_chunk = total_rows // 6
for i in range(6):
output_file = f'aozora_combined_part{i:02d}.parquet'
# 分割してエクスポート
con.execute(f"""
COPY (
SELECT *
FROM read_parquet('{input_file}')
LIMIT {rows_per_chunk}
OFFSET {i * rows_per_chunk}
) TO '{output_file}'
(FORMAT 'parquet', COMPRESSION 'zstd')
""")
アプリケーションの実装
このアプリケーションは、青空文庫のデータをParquetファイルから読み込み、DuckDB WASTを使用してブラウザ上でSQLクエリを実行できるようにします。
DuckDBの初期化とデータロード
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// DuckDBの初期化
const worker = new duckdb_worker()
const logger = new duckdb.ConsoleLogger()
const db = new duckdb.AsyncDuckDB(logger, worker)
await db.instantiate(duckdb_wasm)
async function loadParquetParts(db) {
const baseUrl = import.meta.env.BASE_URL || '/';
const totalParts = 6;
try {
const conn = await db.connect();
for (let i = 0; i < totalParts; i++) {
// OPFSからバッファを取得を試みる
let buffer = await getBufferFromOPFS(i);
// キャッシュにない場合はダウンロード
if (!buffer) {
const partUrl = new URL(
`${FILE_NAME_PREFIX}${i.toString().padStart(2, '0')}.parquet`,
window.location.origin + baseUrl
).href;
// ダウンロードとプログレス表示の処理
const response = await fetch(partUrl);
// ... ストリーム処理とプログレス更新 ...
// OPFSへの保存
await saveStreamToOPFS(stream, i);
buffer = await getBufferFromOPFS(i);
}
// DuckDBへの登録
await db.registerFileBuffer(`part${i}.parquet`, new Uint8Array(buffer));
// テーブルの作成または追加
if (i === 0) {
await conn.query(`
CREATE TABLE aozora_combined AS
SELECT * FROM read_parquet('part0.parquet');
`);
} else {
await conn.query(`
INSERT INTO aozora_combined
SELECT * FROM read_parquet('part${i}.parquet');
`);
}
}
} catch (error) {
throw error;
}
}
検索機能の実装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 基本検索機能
searchInput?.addEventListener('input', async () => {
const searchTerm = searchInput.value.trim()
if (!searchTerm) {
const resultElement = document.getElementById('result')
if (resultElement) resultElement.innerHTML = ''
return
}
const conn = await db.connect()
try {
const result = await conn.query(`
SELECT 作品名, 姓 || ' ' || 名 as 著者名, 公開日
FROM aozora_combined
WHERE 作品名 LIKE '%${searchTerm}%'
OR 姓 LIKE '%${searchTerm}%'
OR 名 LIKE '%${searchTerm}%'
LIMIT 100;
`)
displayResults(result)
} finally {
await conn.close()
}
})
// 著者統計
document.getElementById('author-stats')?.addEventListener('click', async () => {
const conn = await db.connect()
try {
const result = await conn.query(`
SELECT
姓 || ' ' || 名 as 著者名,
COUNT(*) as 作品数,
MIN(公開日) as 最初の公開日,
MAX(公開日) as 最新の公開日
FROM aozora_combined
GROUP BY 姓, 名
ORDER BY 作品数 DESC
LIMIT 20;
`)
displayResults(result)
} finally {
await conn.close()
}
})
まとめ
本プロジェクトでは、DuckDBとCodeMirrorを組み合わせることで、以下を実現しました。
- ブラウザ上での高速なテキストデータ検索
- インタラクティブなSQLクエリ実行環境
- サーバーレスでの大規模テキストデータ分析
コード
aozora-duckdbにて、本プロジェクトのソースコードを公開しています。
ライセンス
本プロジェクトはApache License 2.0の下で公開されています。
DuckDB Documentation: File Formats ↩
DuckDB Documentation: Execution Format ↩
ACID Compliance: Changing Data with Confidence and ACID ↩
DuckDB WASM: Running in the Browser ↩
File System Access API: MDN Web Docs ↩
OPFS Support: OPFS API Overview ↩