← 参照コンテンツに戻る MOKUMOKU GUIDE

AIバッチ処理のボトルネック解消術

実践技術教科書 · LLMを使わずにLLMの仕事を終わらせる

対象読者: AIエージェントでバッチ処理を回しているエンジニア。Ollama / ChromaDB / SBERT の基礎を理解している方。前編「HBO パターン」の続編として読むと効果的。 本書の目的: ローカルLLMサーバーのボトルネックを構造的に理解し、LLMを使わずに同等の結果を得る「ファネル脱出」戦略を実践できるようになる。


目次

第I部 技術概念と原理

  1. ボトルネックの構造 — なぜ Ollama は遅いのか
  2. ファネル脱出戦略 — LLMを呼ばずに分類を完了する
  3. SBERTクラスタ転写 — 既存の分類結果を未分類データに投影する
  4. キーワードパターン拡充 — 頻度分析からルールを自動生成する
  5. リソース排他性の物理学 — 何が並列可能で何が不可能か

第II部 用語辞典

  1. 用語辞典

第III部 システム構成

  1. ファネル脱出のデータフロー
  2. リソース競合マップとスケジューリング

第IV部 歴史と背景

  1. なぜこの問題が生まれたか — 3段階ファネルの限界点
  2. 分類精度 vs 速度のトレードオフ史

第V部 手順・マニュアル

  1. ボトルネック診断の5ステップ
  2. SBERTクラスタ転写の実装手順
  3. キーワード拡充の実装手順
  4. 解消後のリソース再配分

第I部 技術概念と原理


1. ボトルネックの構造

問題の定量化

あるプロジェクトで45,121件の特許を技術カテゴリに分類するバッチ処理を実行した。3段階ファネル(ルール → キーワード → LLM)を使い、Phase 1+2で43.7%を即時分類した。残りの56.3%はOllama(ローカルLLMサーバー、8Bモデル)で処理した。

しかし、Ollamaの処理速度は251件/時。残り13,614件の完了には54時間かかる見込みだった。

45,121件
    |
Phase 1 (IPC code): 14,691件 → 即完了 (32.6%)
    |
Phase 2 (keyword):   5,015件 → 即完了 (11.1%)
    |
Phase 3 (Ollama):   25,415件 → 251件/時 → 101時間
    |                          ↑ ボトルネック
    v
途中経過: 11,801件完了、13,614件残り → あと54時間

なぜ251件/時なのか

1件あたり14.3秒の内訳:

HTTP request overhead:  0.1秒  (ネットワーク往復)
Prompt tokenization:    0.2秒  (入力テキスト → トークン)
Prefill (入力処理):     2.0秒  (KVキャッシュ構築)
Decode (出力生成):     10.0秒  (1トークンずつ生成)
Response parsing:       0.1秒  (JSON → Python)
Think tokens overhead:  2.0秒  (qwen3の思考トークン)
                       ------
合計:                  14.4秒/件

Decode(出力生成)が70%を占める。LLMは出力を1トークンずつ逐次生成するため、「WPT」という3文字の回答でも、思考過程を含めると数十トークンを生成している。

Ollamaの排他性

Ollamaはリクエストをキュー方式で処理する。1つのリクエストが推論中は、他のリクエストは待機する。3プロセスが同時にリクエストすると:

プロセスA: [===推論===]        [===推論===]        [===推論===]
プロセスB:            [===推論===]        [===推論===]
プロセスC:                      [===推論===]        [===推論===]
時間軸:   |----|----|----|----|----|----|----|----|
          0    5    10   15   20   25   30   35秒

→ 各プロセスの実効速度: 1/3に低下
→ 全体スループット: ほぼ変わらない(GPUは1つ)

結論: Ollamaのスループットは物理的にGPU 1基で上限がある。並列化しても速くならない。


2. ファネル脱出戦略

ボトルネックを「速くする」のではなく、「通らなくて済むようにする」 という発想。

従来の3段階ファネル              ファネル脱出

45,121件                         45,121件
    |                                |
Phase 1: 14,691件                Phase 1: 14,691件
    |                                |
Phase 2:  5,015件                Phase 2:  5,015件 (v1)
    |                                |
Phase 3: 25,415件                Phase 2': 2,209件 (v2拡充)
    ↑ Ollama 101時間                 |
                                 Phase 2'': 4,061件 (SBERT転写)
                                     |
                                 Phase 3: 11,801件 (既に完了分のみ)
                                     |
                                 残り: 7,344件 (データ無し → UNKNOWN)

                                 → Ollama追加呼び出し: 0件
                                 → 所要時間: 数秒

ファネル脱出の2つの武器:

  1. キーワードパターン拡充 (Phase 2'): 未分類タイトルの頻出語分析 → 新規正規表現パターン追加
  2. SBERTクラスタ転写 (Phase 2''): 既分類データのクラスタ所属から、未分類データのカテゴリを推定

どちらもLLMを一切使わない。CPUで即時完了する。


3. SBERTクラスタ転写

原理

事前にSBERT(Sentence-BERT)で全データをembeddingし、KMeansでクラスタリングしてあるとする。27,776件が30クラスタに分かれている。

このとき、各クラスタには「既にLLMで分類済みの特許」と「未分類の特許」が混在している。同じクラスタに属する特許は意味的に類似しているため、既分類データの多数決カテゴリを未分類データに転写できる。

Cluster 7 (2,478件):
  ├── 2,200件: auto_category = "WPT" (LLMで分類済み)   → 96%
  ├──   200件: auto_category = "EH"                     →  4%
  └──    78件: auto_category = NULL (未分類)
                → 多数決で "WPT" を転写

精度の考察

クラスタの「純度」(最頻カテゴリの比率)が信頼度の指標になる:

純度 解釈 転写の信頼性
> 90% ほぼ単一カテゴリ 高い。安全に転写可能
70-90% 支配的カテゴリあり 中程度。誤分類リスクあり
< 70% 混合クラスタ 低い。LLM確認が望ましい

実際の結果では、30クラスタ中20クラスタが純度70%以上だった。4,061件を転写し、LLMを完全に回避した。

なぜ「分類」にLLMを使うのがオーバースペックなのか

LLM分類の本質は「テキストの意味を理解してカテゴリに振り分ける」こと。しかし:

これはk-近傍法(kNN)の特殊ケースだ。LLMが1件15秒かけてやる推論を、SBERTクラスタは数十ミリ秒で近似できる。精度は若干劣るが、バッチ分類の用途では十分。


4. キーワードパターン拡充

原理

Phase 2のキーワード正規表現パターンは、最初に設計した時点の知識に基づいている。しかし実データを見ると、設計時に想定しなかったパターンが大量に存在する

未分類データのタイトルを頻度分析すると:

ceramic:     851件  → 電子部品(セラミックコンデンサ等)
battery:     715件  → 電池
capacitor:   575件  → コンデンサ
multilayer:  557件  → 積層セラミック
substrate:   485件  → 基板

これらは明らかに特定のカテゴリに属するが、初期のキーワードパターンではカバーされていなかった。

拡充の手法

# Step 1: 未分類タイトルの頻出語をカウント
words = Counter()
for row in unclassified:
    for w in re.findall(r'[a-z]{3,}', row['title'].lower()):
        words[w] += 1

# Step 2: 頻出語からカテゴリパターンを設計
EXTRA_KEYWORDS = {
    "BATTERY": [r"\bbatter", r"electrolyte", r"lithium"],
    "OTHER":   [r"ceramic.?(electronic|component)", r"multilayer.?ceramic"],
    "SIGNAL_PROC": [r"\bfilter\b.*(circuit|frequency)", r"resonator"],
    # ...
}

# Step 3: 未分類データに適用
for row in unclassified:
    for cat, patterns in EXTRA_KEYWORDS.items():
        if any(re.search(p, row['title'], re.I) for p in patterns):
            row['auto_category'] = cat
            break

この手法で2,209件を追加分類した。所要時間は数秒。

Phase 2 vs Phase 3 の費用対効果

手法 速度 精度 コスト 適用量
Phase 1 (IPCコード) 即時 99%+ ¥0 14,691件
Phase 2 (キーワード) 即時 95%+ ¥0 5,015件
Phase 2' (キーワード拡充) 即時 90%+ ¥0 2,209件
Phase 2'' (SBERT転写) 即時 85%+ ¥0 4,061件
Phase 3 (Ollama LLM) 14.4秒/件 95%+ 電気代のみ 11,801件

Phase 2の拡充とSBERT転写で6,270件を救出し、Ollamaの追加稼働54時間をゼロにした。


5. リソース排他性の物理学

ボトルネック解消後、解放されたリソースをどう再配分するか。各リソースの排他性を理解する必要がある。

リソース排他性マトリクス

              読み   書き   推論
SQLite       共有   排他    -
ChromaDB     共有   排他    -
Ollama embed  -      -    準排他(軽量、並列許容)
Ollama LLM    -      -    排他(重い、キュー待ち)
SBERT CPU     -      -    共有(メモリ依存)
SSH           共有   共有    -

排他性の原因

SQLite: WAL(Write-Ahead Logging)モードでも、同時書込はシリアライズされる。複数プロセスが同じDBに書き込むとdatabase is lockedエラー。

ChromaDB: 内部でSQLiteを使用。加えて、コレクション操作がプロセス間で共有されない。別プロセスがdelete_collectionした後にaddするとCollection does not existエラー。

Ollama LLM推論: GPU演算ユニットは物理的に1つ。KVキャッシュもVRAMを排他的に消費。OLLAMA_NUM_PARALLELで擬似並列が可能だが、VRAM容量に制約される。

SBERT CPU: メインメモリ上で動作。モデルロード(384MB)後は複数プロセスが同時にencode可能。ただしメモリ帯域で律速される。

最適リソース配分戦略

                Ollama解放後の配分
        +----------------------------------+
        |                                  |
  +-----v-----+  +--------+  +-----------+
  | Ollama GPU |  |MBP CPU |  | SQLite    |
  | (解放済み) |  |  (空き) |  | (解放済み)|
  +-----+------+  +----+---+  +-----+-----+
        |              |            |
  [S: CatX 23K]  [T: whisper]  [U: CSV import]
  scoring         文字起こし     619件新規投入
  (qwen3:14b)    (CPU集中)     (即完了)

ルール: 1. Ollama LLMタスクは1つに集中(S: Cat X scoring) 2. CPU集中タスク(T: whisper)はOllamaと並行OK 3. SQLite書込は短時間で終わらせ、ロックを即解放


第II部 用語辞典


6. 用語辞典

分類・機械学習

用語 意味
3段階ファネル ルール → キーワード → LLM の段階的フィルタリング。安価な手法で先にふるい、LLMの負荷を最小化する設計パターン
SBERTクラスタ転写 SBERT embeddingのクラスタリング結果を使い、既分類データのラベルを未分類データに投影する手法。kNNの特殊ケース
多数決カテゴリ クラスタ内の最頻出カテゴリ。純度(最頻カテゴリの比率)が信頼度の指標
KMeans k個のクラスタ中心にデータを割り当てるクラスタリングアルゴリズム。SBERT embeddingとの組合せが定番
kNN(k近傍法) 距離が近いk個のラベル付きデータから多数決でラベルを推定する分類手法
Phase 2拡充 実データの頻度分析に基づいてキーワードパターンを追加し、ルールベース分類のカバレッジを拡大する手法

パフォーマンス

用語 意味
ファネル脱出 LLM推論のボトルネックを「速くする」のではなく「通らなくて済むようにする」戦略
スループット 単位時間あたりの処理件数。Ollama LLMの場合、GPU 1基で上限がある
OLLAMA_NUM_PARALLEL Ollama の環境変数。同一モデルの並列リクエスト数を制御。VRAMに余裕があれば2-4並列可能
Think tokens qwen3等の推論モデルが出力する「思考過程」トークン。分類タスクでは不要だがオフにできない場合がある
Prefill LLMがプロンプトを処理してKVキャッシュを構築する段階。入力長に比例
Decode LLMが出力トークンを1つずつ生成する段階。出力長に比例。ボトルネックの主因

データベース

用語 意味
WAL (Write-Ahead Logging) SQLiteのジャーナルモード。読み書きの並行性を向上させるが、同時書込のシリアライズは避けられない
database is locked SQLiteの同時書込エラー。原因は常に「複数プロセスが同じDBファイルに書込」
Collection does not exist ChromaDBのエラー。別プロセスによるコレクション操作の競合が原因

第III部 システム構成


7. ファネル脱出のデータフロー

patent_search.db (45,121件)
    |
    |  SELECT id, title, ipc, cpc, auto_category
    |  WHERE auto_category IS NULL
    |
    v
[6,270件: 未分類 + title有り]
    |
    +---- Phase 2' (キーワード拡充) --------+
    |     |                                  |
    |     | 頻度分析:                        |
    |     |   "ceramic": 851件              |
    |     |   "battery": 715件              |
    |     |   → 新パターン設計              |
    |     |                                  |
    |     +---> 2,209件を即時分類            |
    |                                        |
    +---- Phase 2'' (SBERT転写) -----------+
    |     |                                  |
    |     | bizc_27k_clusters.csv (30 clusters)
    |     |   +                              |
    |     | patent_search.db (既分類21,506件) |
    |     |   → cluster × category 多数決   |
    |     |                                  |
    |     +---> 4,061件を即時分類            |
    |                                        |
    v                                        |
[残り0件: title有りは全件分類完了]           |
    +                                        |
[7,344件: title/IPC全てNULL]                 |
    → "UNKNOWN" (データ不足で分類不能)       |
                                             |
   合計所要時間: 数秒                        |
   Ollama呼び出し: 0回                       |
   → Ollama帯域を次タスクに全解放           |

8. リソース競合マップとスケジューリング

Before(ボトルネック状態)

Mac mini GPU:  [G: 分類 Phase3 qwen3:8b]============> 54h占有
MBP CPU:       (空き)
SQLite:        [G が断続的にロック]

→ 他のOllamaタスクが実行不能

After(ファネル脱出後)

Mac mini GPU:  [S: CatX scoring qwen3:14b]=========>
MBP CPU:       [T: whisper 24ファイル]=======>
SQLite:        [U: CSV import](即完了)

→ 3タスクが資源分離で並行稼働
→ Ollama帯域を高価値タスク(S)に集中

スケジューリング判断ツリー

ボトルネック検出
    |
    +-- 残り件数は? → 6,270件(title有り)+ 7,344件(データ無し)
    |
    +-- SBERT embeddingは事前にある? → YES(27K件30クラスタ)
    |     |
    |     +-- 未分類データとクラスタの重複は? → 100%(6,270/6,270)
    |     |
    |     +-- → SBERTクラスタ転写を適用
    |
    +-- キーワード拡充の余地は? → YES(頻度分析で新パターン発見)
    |     |
    |     +-- → Phase 2' を適用
    |
    +-- データ無し件数は? → 7,344件(分類不能)
          |
          +-- → UNKNOWN でマーク、プロセスkill、Ollama解放

第IV部 歴史と背景


9. なぜこの問題が生まれたか

3段階ファネルは「Phase 3のLLM呼び出しを最小化する」設計思想で作られた。Phase 1(IPC)で32.6%、Phase 2(キーワード)で11.1%を削減し、LLMに到達するのは56.3%に抑えた。

しかし、56.3%でも25,415件。LLMの処理速度(251件/時)では101時間かかる。

根本原因: ファネルの設計時に「Phase 2のカバレッジ」が十分に検討されなかった。初期キーワードパターンは主要カテゴリ(WPT, RFID, ANTENNA等)のみをカバーし、周辺カテゴリ(BATTERY, ceramic電子部品等)のパターンが不足していた。

初期設計時の想定:
  Phase 1: 30% → Phase 2: 30% → Phase 3: 40%

実際:
  Phase 1: 32.6% → Phase 2: 11.1% → Phase 3: 56.3%
                    ↑ ここが想定の1/3

さらに、SBERT embeddingとクラスタリングはバッチ処理の「成果物」として事後的に生成されたが、分類タスクへのフィードバックが設計に含まれていなかった。


10. 分類精度 vs 速度のトレードオフ史

1950s  ルールベース分類(if-then)
  |      速度: 極めて高速  精度: ルールの質に依存
  |
1990s  機械学習(SVM, Naive Bayes)
  |      速度: 高速  精度: 特徴量設計に依存
  |
2018   BERT / Sentence-BERT
  |      速度: 中程度(バッチ処理可能)  精度: 高い
  |      → embedding + kNNで分類が可能に
  |
2023   LLM直接分類(GPT-4, Claude, Ollama)
  |      速度: 遅い(1-15秒/件)  精度: 非常に高い
  |      → ゼロショットで任意のカテゴリに分類可能
  |
2026   ファネル脱出パターン(本書)
         ルール → キーワード → SBERT転写 → LLM
         速度: 大部分が即時  精度: LLMに準ずる
         → LLMは最終手段。到達する前に解決する

教訓: 最新の手法(LLM)が常に最適とは限らない。問題の構造に合わせて、古い手法と新しい手法を段階的に組み合わせるのが最も効率的。


第V部 手順・マニュアル


11. ボトルネック診断の5ステップ

前提条件

Step 1: 処理速度の定量化

# progress.json から速度を推定
cat data/.categorize_progress.json
# {"phase": "3", "processed": 11801, "total": 25415, "timestamp": "..."}

# 稼働時間と件数から速度を計算
python3 -c "
processed = 11801
hours = 47  # 稼働時間
remaining = 25415 - processed
rate = processed / hours
eta = remaining / rate
print(f'Rate: {rate:.0f} items/hour')
print(f'ETA: {eta:.1f} hours')
"

Step 2: ボトルネック箇所の特定

# Ollamaの現在の負荷
curl -s http://<サーバーIP>:11434/api/ps | python3 -c "
import sys,json
d = json.load(sys.stdin)
for m in d.get('models', []):
    print(f'{m[\"name\"]}: VRAM {m.get(\"size_vram\",0)//1024//1024}MB')
"

# 複数プロセスがOllamaを叩いていないか
ps aux | grep -E "batch_|scoring|categorize" | grep -v grep

Step 3: 未分類データの分析

# 未分類データに何があるか
SELECT
    SUM(CASE WHEN title IS NULL THEN 1 ELSE 0 END) as no_title,
    SUM(CASE WHEN ipc IS NOT NULL THEN 1 ELSE 0 END) as has_ipc,
    COUNT(*) as total
FROM patents WHERE auto_category IS NULL

Step 4: 脱出経路の判定

未分類データの特性を確認:
  ├── title有り + SBERTクラスタ有り → SBERT転写(最速)
  ├── title有り + SBERTクラスタ無し → キーワード拡充
  ├── title有り + 上記で漏れ → LLM続行(仕方ない)
  └── title無し + IPC無し → UNKNOWN(分類不能)

Step 5: 実行と検証

# 1. バッチプロセスをkill(DBロック解放)
kill <PID>

# 2. ファネル脱出を適用(数秒で完了)
python3 scripts/funnel_escape.py

# 3. 結果確認
python3 -c "
import sqlite3
db = sqlite3.connect('data/patent_search.db')
total = db.execute('SELECT COUNT(*) FROM patents').fetchone()[0]
classified = db.execute('SELECT COUNT(*) FROM patents WHERE auto_category IS NOT NULL').fetchone()[0]
print(f'{classified}/{total} ({100*classified/total:.1f}%)')
"

# 4. Ollama解放 → 次タスク投入

12. SBERTクラスタ転写の実装手順

前提条件

実装

import sqlite3, pandas as pd

# 1. クラスタデータ読込
clusters = pd.read_csv("examples/bizc_27k_clusters.csv")

# 2. 既分類データとマージ
db = sqlite3.connect("data/patent_search.db")
classified = pd.read_sql(
    "SELECT pub_number, auto_category FROM patents WHERE auto_category IS NOT NULL", db)
merged = clusters.merge(classified, on='pub_number', how='inner')

# 3. クラスタ → カテゴリのマッピング(多数決)
cluster_map = {}
for cid in merged['cluster'].unique():
    subset = merged[merged['cluster'] == cid]
    majority = subset['auto_category'].value_counts().index[0]
    confidence = subset['auto_category'].value_counts().iloc[0] / len(subset)
    cluster_map[cid] = (majority, confidence)

# 4. 未分類データに適用
pub_to_cluster = dict(zip(
    clusters['pub_number'].astype(str), clusters['cluster']))

remaining = db.execute(
    "SELECT id, pub_number FROM patents WHERE auto_category IS NULL"
).fetchall()

for r in remaining:
    cid = pub_to_cluster.get(str(r[1]))
    if cid is not None and cid in cluster_map:
        cat, conf = cluster_map[cid]
        db.execute(
            "UPDATE patents SET auto_category=?, category_method='sbert_cluster' WHERE id=?",
            (cat, r[0]))

db.commit()

13. キーワード拡充の実装手順

Step 1: 頻出語分析

from collections import Counter
import re

rows = db.execute(
    "SELECT title FROM patents WHERE auto_category IS NULL AND title IS NOT NULL"
).fetchall()

words = Counter()
for r in rows:
    for w in re.findall(r'[a-z]{3,}', r[0].lower()):
        words[w] += 1

# Top 40 を確認 → カテゴリに帰属できる単語を特定
for w, c in words.most_common(40):
    print(f"{w:20s}: {c}")

Step 2: パターン設計

頻出語をカテゴリにグループ化:

EXTRA_KEYWORDS = {
    "BATTERY": [r"\bbatter", r"electrolyte", r"lithium", r"solid.?state.?batter"],
    "OTHER":   [r"ceramic.?(electronic|component)", r"multilayer.?ceramic"],
    # ... 頻出語の分析結果に基づいて設計
}

Step 3: 適用

for r in unclassified:
    text = r['title'].lower()
    for cat, patterns in EXTRA_KEYWORDS.items():
        if any(re.search(p, text, re.I) for p in patterns):
            db.execute(
                "UPDATE patents SET auto_category=?, category_method='keyword_v2' WHERE id=?",
                (cat, r['id']))
            break
db.commit()

14. 解消後のリソース再配分

ボトルネック解消でOllamaが解放された後、リソースを再配分する。

判断基準

1. 最も価値の高いOllamaタスクを特定
   → Cat X scoring (23K件): 未処理の最大カテゴリ

2. CPU独立タスクを並行投入
   → 音声文字起こし (faster-whisper): CPUのみ、Ollamaは briefing時のみ

3. 短時間SQLiteタスクを即完了
   → inbox CSVインポート: 619件、数秒で完了

実行順序

# 1. 短時間タスク(即完了)
python3 import_inbox_csvs.py  # SQLite write, 数秒

# 2. Ollamaメインタスク(バックグラウンド)
python3 relevance_scoring.py --input catX.csv &  # 数時間

# 3. CPU並行タスク(バックグラウンド)
python3 audio_processor.py --inbox --skip-done &  # whisper + 軽量Ollama

本書の核心: ボトルネックに遭遇したとき、最初に考えるべきは「速くする方法」ではなく「通らなくて済む方法」。LLMは万能だが遅い。事前に蓄積したデータ(SBERT embedding、既分類結果)を活用すれば、LLMの仕事の大部分をLLMなしで完了できる。ファネル脱出は、過去の投資を現在の問題解決に転用する技術だ。

← 参照コンテンツ一覧に戻る