• #Nuxt4
  • #Gemini API
  • #OCR
  • #SQLite
  • #PDF
  • #Vue.js
  • #pnpm
開発book-knowledge-baseメモ

蔵書ナレッジベースの構築 - 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 APIPDFページごとにテキスト抽出
データ格納SQLite + FTS5書籍メタデータとページ本文を保存
APIサーバーNuxt 4 Server RoutesSQLiteからデータを取得してJSONで返す
フロントエンドNuxt 4 + Vue 3Miller Columnsで書籍を閲覧

コミット履歴

時刻コミット内容
前日 17:49a390dc6DB設計・CRUD操作・OCRスクリプトの初期実装
09:28906cc55Nuxt 4プロジェクトの初期セットアップ・設定ファイル整備
09:398e1985fAPI実装・フロントエンド全体を構築
09:501f59523追加の設定・ドキュメント整備

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/booksGET書籍一覧の取得
/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つの方法で行える:

  1. 中カラムのページ番号クリック: router.replace() でURLを遷移
  2. ヘッダーの前へ/次へボタン: 前後のページ番号を計算して遷移
  3. キーボードショートカット: 左右の矢印キーで前後のページに移動
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精度: 図表の多いページでは抽出精度にばらつきがある。プロンプトの改善やモデルの変更で対応したい