蔵書ナレッジベースの構築 - Gemini OCRとNuxt 4で書籍PDFをWebリーダーにする
手元にある書籍のPDFをGemini APIでOCR処理し、SQLiteデータベースに格納、Nuxt 4のWebフロントエンドで読めるようにするプロジェクト「book-knowledge-base」の初日開発記録。「創業手帳 Ver.99」(112ページ)を素材に、バックエンドのOCRパイプラインからフロントエンドのMiller Columnsレイアウトまで一気に構築した。
プロジェクトの全体像
目的
紙の書籍やPDFの内容をデータベースに取り込み、ブラウザ上で全文検索・閲覧できるようにする。税務書籍に限らず、あらゆるジャンルの書籍を扱える汎用的な蔵書ナレッジベースを目指す。
アーキテクチャ
[PDF/画像] → [Gemini API (OCR)] → [SQLite DB] → [Nuxt 4 API] → [ブラウザ]
| レイヤー | 技術 | 役割 |
|---|---|---|
| OCR処理 | Python + Gemini API | PDFページごとにテキスト抽出 |
| データ格納 | SQLite + FTS5 | 書籍メタデータとページ本文を保存 |
| APIサーバー | Nuxt 4 Server Routes | SQLiteからデータを取得してJSONで返す |
| フロントエンド | Nuxt 4 + Vue 3 | Miller Columnsで書籍を閲覧 |
コミット履歴
| 時刻 | コミット | 内容 |
|---|---|---|
| 前日 17:49 | a390dc6 | DB設計・CRUD操作・OCRスクリプトの初期実装 |
| 09:28 | 906cc55 | Nuxt 4プロジェクトの初期セットアップ・設定ファイル整備 |
| 09:39 | 8e1985f | API実装・フロントエンド全体を構築 |
| 09:50 | 1f59523 | 追加の設定・ドキュメント整備 |
Phase 1: Gemini APIによるPDF OCR処理
OCRスクリプトの設計
src/import_book.py がPDFまたは画像ディレクトリを受け取り、Gemini APIでページごとにテキスト抽出してSQLiteに格納する。画像入力とPDF入力の両方に対応しており、入力パスの拡張子で自動判定する。
# PDFファイルからインポート
uv run python import_book.py /path/to/sougyou-techo.pdf sougyou-techo-v99
# 画像ディレクトリからインポート
uv run python import_book.py /path/to/images book-slug
Geminiへのプロンプト設計
各ページに対して以下のプロンプトを送信し、構造化されたJSONレスポンスを受け取る。
この書籍ページの画像からテキストを抽出してください。
【ルール】
- ページ内の全てのテキストを正確に抽出すること
- 章タイトル(「第○章」「Chapter N」等)があればchapterに設定
- 節タイトル(「1.2 ...」「第○節」等)があればsectionに設定
- 目次ページであればis_tocをtrueに設定
- 白紙ページであればis_blankをtrueに設定
- ヘッダー・フッター(ページ番号等)は除外
- 図表のキャプションは含めるが、図表の中身は[図表]と記載
Geminiのresponse_schema機能を使って、レスポンスの型を明示的に指定している。
{
"type": "object",
"properties": {
"page_text": { "type": "string", "description": "ページの全テキスト" },
"chapter": { "type": "string", "description": "この時点の章タイトル" },
"section": { "type": "string", "description": "この時点の節タイトル" },
"is_toc": { "type": "boolean", "description": "目次ページかどうか" },
"is_blank": { "type": "boolean", "description": "白紙・空ページかどうか" }
},
"required": ["page_text", "chapter", "section", "is_toc", "is_blank"]
}
PDF処理の並列化とレート制限対策
112ページのPDFを効率的に処理するため、以下の仕組みを実装した。
- バッチ処理: 150ページをひとまとめにしてバッチ処理(
BATCH_SIZE = 150) - 非同期並列:
aiohttp+asyncio.Semaphoreで最大20リクエストを同時実行 - PDF分割送信: 1リクエストあたり20ページずつ送信し、配列形式でレスポンスを受け取る
- レート制限待機: バッチ間で60秒のクールダウン
# PDF用の配列スキーマ(1リクエストで複数ページ分の結果を返す)
array_schema = {
"type": "array",
"items": {
**schema,
"properties": {
**schema["properties"],
"page_number": {"type": "integer", "description": "ページ番号"},
},
"required": [*schema["required"], "page_number"],
},
}
処理結果
創業手帳 Ver.99(112ページ)の処理結果:
- 空白ページは自動的に除外されてDBには格納されない
- OCRエラーが発生したページは
[OCRエラー: ...]として記録 - 正常に処理されたページのテキストが
chunksテーブルに1ページ1レコードで格納される
Phase 2: SQLiteデータベース設計
テーブル構造
書籍のメタデータとページ本文を2つのテーブルで管理する。
-- 書籍メタデータ
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
publisher TEXT,
published_year INTEGER,
isbn TEXT,
category TEXT,
subcategory TEXT,
description TEXT,
toc TEXT,
total_pages INTEGER,
source_path TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
);
-- テキストチャンク(1ページ = 1チャンク)
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL REFERENCES books(id),
page_number INTEGER,
chapter TEXT DEFAULT '',
section TEXT DEFAULT '',
content TEXT NOT NULL,
tags TEXT DEFAULT '',
is_toc INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
FTS5全文検索インデックス
trigram tokenizer を使ったFTS5全文検索を設定。日本語のような分かち書きしない言語でも部分一致検索ができる。
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
content,
chapter,
section,
tags,
content=chunks,
content_rowid=id,
tokenize='trigram'
);
FTSテーブルとメインテーブルの同期はトリガーで自動化。INSERT / UPDATE / DELETE の全操作に対応するトリガーを定義し、常にインデックスが最新の状態に保たれる。
Python DB操作モジュール
src/db.py に以下のCRUD関数を実装:
| 関数 | 用途 |
|---|---|
init_books_db() | テーブル作成(スキーマ実行) |
insert_book() | 書籍メタデータ登録 |
insert_chunks() | チャンク一括登録 |
search_chunks() | FTS5全文検索 |
search_books() | 書籍メタデータ検索 |
get_book() | 書籍1件取得 |
list_books() | 書籍一覧取得 |
delete_book() | 書籍とチャンクの削除 |
get_chunk_count() | チャンク数取得 |
Phase 3: Nuxt 4フロントエンドの構築
npm vs pnpm - パッケージマネージャーの選択
最初は npm でセットアップを試みたが、Nuxt 4の依存関係で問題が発生した。Nuxt公式がpnpmを推奨しており、依存関係の解決がより確実なため pnpm に切り替えた。
{
"dependencies": {
"better-sqlite3": "^11.7.0",
"marked": "^17.0.1",
"nuxt": "^4.0.0",
"vue": "latest",
"vue-router": "latest"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12"
}
}
Nuxt 4では compatibilityDate を指定して互換性バージョンを制御する。
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
devtools: { enabled: false },
app: {
head: {
title: '蔵書ナレッジベース',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
})
Server Routes - SQLiteとの接続
Nuxt 4の Server Routes を使い、SQLiteデータベースにアクセスするAPIエンドポイントを3つ実装した。
データベース接続ユーティリティ
server/utils/db.ts でシングルトンパターンの接続管理を行う。better-sqlite3 を使い、読み取り専用モードで接続する。
import Database from 'better-sqlite3'
import { resolve } from 'node:path'
let _db: Database.Database | null = null
export function useDb(): Database.Database {
if (!_db) {
const dbPath = resolve(process.cwd(), '..', 'data', 'books.db')
_db = new Database(dbPath, { readonly: true })
_db.pragma('journal_mode = WAL')
_db.pragma('foreign_keys = ON')
}
return _db
}
APIエンドポイント
| エンドポイント | メソッド | 用途 |
|---|---|---|
/api/books | GET | 書籍一覧の取得 |
/api/books/[bookId] | GET | 書籍詳細(章・ページ一覧含む) |
/api/books/[bookId]/pages/[page] | GET | 特定ページの本文取得 |
書籍詳細APIは、書籍メタデータに加えて章一覧とページ一覧を返す。ページ一覧は中カラムのナビゲーションに使う。
// server/api/books/[bookId]/index.get.ts
export default defineEventHandler((event) => {
const bookId = getRouterParam(event, 'bookId')
const db = useDb()
const book = db.prepare('SELECT * FROM books WHERE id = ?').get(bookId)
if (!book) {
throw createError({ statusCode: 404, statusMessage: 'Book not found' })
}
const chapters = db.prepare(`
SELECT chapter, MIN(page_number) as first_page
FROM chunks
WHERE book_id = ? AND chapter != ''
GROUP BY chapter
ORDER BY first_page
`).all(bookId)
const pages = db.prepare(`
SELECT page_number, chapter, section
FROM chunks
WHERE book_id = ?
ORDER BY page_number
`).all(bookId)
return { book, chapters, pages }
})
Phase 4: Miller Columnsレイアウトの書籍ビューア
Miller Columnsとは
macOSのFinderで使われている3カラムのナビゲーションパターン。左カラムで選択した項目の詳細が右カラムに展開されていく。書籍ビューアでは以下のように適用した:
| カラム | 幅 | 内容 |
|---|---|---|
| 左カラム | 220px | 蔵書一覧 |
| 中カラム | 280px | 章見出し + ページ番号一覧 |
| 右カラム | 残り | ページ本文 |
CSS Grid による3カラムレイアウト
.miller-columns {
display: grid;
grid-template-columns: 220px 280px 1fr;
flex: 1;
overflow: hidden;
}
.column {
display: flex;
flex-direction: column;
border-right: 1px solid #e5e7eb;
overflow: hidden;
}
各カラムは flex-direction: column で縦方向に積み、ヘッダー部分を flex-shrink: 0 で固定、コンテンツ部分を overflow-y: auto でスクロール可能にしている。
章見出しのスティッキーグループ化
中カラムではページ一覧を章ごとにグループ化し、章見出しを position: sticky で表示する。
interface PageEntry { page_number: number; chapter: string; section: string }
interface ChapterGroup { chapter: string; pages: PageEntry[] }
const chapterGroups = computed<ChapterGroup[]>(() => {
const pages = (bookDetail.value?.pages ?? []) as PageEntry[]
const groups: ChapterGroup[] = []
let current: ChapterGroup | null = null
for (const p of pages) {
const ch = p.chapter || ''
if (!current || current.chapter !== ch) {
current = { chapter: ch, pages: [] }
groups.push(current)
}
current.pages.push(p)
}
return groups
})
.chapter-label {
padding: 0.5rem 0.75rem 0.25rem;
font-size: 0.6875rem;
font-weight: 700;
color: #6b7280;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 1;
}
ページナビゲーション
ページ間の移動は3つの方法で行える:
- 中カラムのページ番号クリック:
router.replace()でURLを遷移 - ヘッダーの前へ/次へボタン: 前後のページ番号を計算して遷移
- キーボードショートカット: 左右の矢印キーで前後のページに移動
function handleKeydown(e: KeyboardEvent) {
const el = document.activeElement
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
goPrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
goNext()
}
}
自動スクロールの実装
ページを切り替えたとき、2つのスクロール処理が連動する:
- 右カラム: ページ本文の先頭にスクロール
- 中カラム: 選択中のページ番号が見えるようにスクロール追従
scrollIntoView は親要素まで巻き込んでスクロールしてしまう問題があるため、getBoundingClientRect() で位置を手動計算し、scrollBy() で中カラムだけをスクロールする。
const scrollToActive = () => {
nextTick(() => {
const container = pagesColumnRef.value
const active = container?.querySelector('.column-item.selected') as HTMLElement | null
if (container && active) {
const containerRect = container.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
const offset = activeRect.top - containerRect.top
- container.clientHeight / 2
+ active.clientHeight / 2
container.scrollBy({ top: offset, behavior: 'smooth' })
}
})
}
モバイル対応
768px以下のブレークポイントでMiller Columnsを非表示にし、ページ本文のみを表示するモバイルレイアウトに切り替える。画面下部に前へ/次へのフッターナビゲーションを固定表示する。
@media (max-width: 768px) {
.desktop-only { display: none; }
.mobile-only { display: flex; }
}
Phase 5: PageContentコンポーネント - OCR出力のレンダリング
HTMLとプレーンテキストの判定
OCR出力にはHTML(<img>, <br> 等)を含むページとプレーンテキストのみのページがある。marked ライブラリを使ってMarkdown/HTMLをレンダリングし、HTMLタグの有無で表示方法を切り替える。
const hasHtml = computed(() =>
!!props.content && (props.content.includes('<img') || props.content.includes('<br'))
)
PDF由来の改行タグの除去
yomitoku(OCRツール)がPDFの見た目上の改行をそのまま <br> として出力する問題への対策。文の途中の不要な改行を除去しつつ、段落区切りや見出し前の改行は維持する。
const cleanBr = (text: string): string =>
text.replace(/<br\s*\/?>\n?/g, (match, offset, src) => {
// 見出し行内の<br>は除去
const lineStart = src.lastIndexOf('\n', offset - 1) + 1
const linePrefix = src.slice(lineStart, lineStart + 7)
if (/^#{1,6}\s/.test(linePrefix)) return ''
// 画像・テーブル・見出しの前の<br>は維持
const after = src.slice(offset + match.length, offset + match.length + 5)
if (/^(<img|<table|<h[1-6]|#|\n|\s*$)/.test(after)) return match
// 句点の後の<br>は維持
const before = src.slice(Math.max(0, offset - 1), offset)
if (/[。\n]/.test(before)) return match
return ''
})
装飾アイコン画像の自動非表示
PDFには装飾用の小さなアイコン画像が多数含まれている。これらは本文の読みやすさを損なうため、面積が一定以下(90,000px以下)の画像を自動的に非表示にする。
const ICON_AREA_THRESHOLD = 90_000
const checkAndHide = (img: HTMLImageElement) => {
if (img.naturalWidth > 0 && img.naturalWidth * img.naturalHeight <= ICON_AREA_THRESHOLD) {
img.style.display = 'none'
}
}
画像の読み込み完了を待ってからサイズ判定するため、load イベントリスナーも設定している。
開発環境
dev serverの起動
ポート3003で開発サーバーを起動して動作確認した。
cd web
pnpm install
pnpm dev # http://localhost:3003
プロジェクト構成
book-knowledge-base/
├── src/
│ ├── import_book.py # Gemini API でページ処理
│ ├── db.py # DB操作モジュール(CRUD + FTS5検索)
│ └── schema/
│ └── 001_books.sql # テーブル定義
├── schemas/
│ └── book_page.json # Gemini用レスポンススキーマ
├── data/
│ └── books.db # SQLiteデータベース
├── web/
│ ├── nuxt.config.ts
│ ├── package.json
│ ├── app/
│ │ ├── app.vue
│ │ ├── components/
│ │ │ └── PageContent.vue # ページ本文レンダリング
│ │ └── pages/
│ │ ├── index.vue
│ │ └── books/
│ │ ├── index.vue # 書籍一覧(モバイル)
│ │ └── [bookId]/
│ │ └── [page].vue # Miller Columns ビューア
│ └── server/
│ ├── utils/db.ts # SQLite接続ユーティリティ
│ └── api/books/
│ ├── index.get.ts # GET /api/books
│ └── [bookId]/
│ ├── index.get.ts # GET /api/books/:id
│ └── pages/
│ └── [page].get.ts # GET /api/books/:id/pages/:page
└── pyproject.toml
振り返り
うまくいったこと
- Gemini APIの構造化出力:
response_schemaを指定することで、OCR結果を確実にJSON形式で取得できた。chapter/section/is_blank等のメタデータも精度よく抽出される - Miller Columnsレイアウト: macOS Finderライクな3カラムで、書籍の構造を直感的にナビゲートできる
- better-sqlite3: Nuxt 4のServer RoutesからSQLiteに直接アクセスでき、別プロセスのAPIサーバーが不要になった
課題と今後
- npm → pnpm の切り替え: Nuxt 4はpnpmのほうが依存関係の解決が安定している。最初からpnpmを使うべきだった
- FTS5検索UI: データベースには全文検索インデックスがあるが、フロントエンドの検索UIはまだ未実装
- 複数書籍の管理: 現在は1冊だけだが、複数書籍を追加したときのUXを改善する余地がある
- OCR精度: 図表の多いページでは抽出精度にばらつきがある。プロンプトの改善やモデルの変更で対応したい