[{"data":1,"prerenderedAt":408},["ShallowReactive",2],{"content-/textbook-ocr-to-knowledge-base":3,"all-pages-for-dir":406,"og-image-/textbook-ocr-to-knowledge-base":407},{"id":4,"title":5,"body":6,"category":390,"description":391,"extension":392,"meta":393,"navigation":256,"ogImage":394,"path":395,"project_name":16,"published":396,"publishedAt":397,"seo":398,"stem":399,"tags":400,"todo":394,"unpublished":396,"updatedAt":394,"__hash__":405},"pages/2026-05/2026-05-27/textbook-ocr-to-knowledge-base.md","自炊した4分冊の参考書をOCRして蔵書DBに取り込み、章・節単位に整理し直すまで",{"type":7,"value":8,"toc":378},"minimark",[9,18,23,31,38,42,48,51,62,98,101,105,108,117,122,137,140,144,151,158,162,169,176,183,296,303,307,310,317,328,331,334,337,374],[10,11,12,13,17],"p",{},"「この資格分野の参考書って蔵書DBに入ってましたっけ」と思い立って ",[14,15,16],"code",{},"book-knowledge-base","（全60冊）を覗いたら、その分野の本は1冊も入っていなかった。手元には自炊した参考書がある。市販の参考書で、4分冊・合計655ページ。これを今日、OCRしてDBに取り込み、ページ単位から章・節単位へ整理し直すところまで持っていった。途中、サブエージェントがDB同期で無言のまま固まり、最後にはローカルのレプリカファイルが壊れて80MBの再ダウンロードが走った。順調に終わるかと思った作業が、Embedded Replica運用の地雷を2つ踏んで終わった。",[19,20,22],"h2",{"id":21},"_4分冊をそれぞれ独立した書籍として扱う方針にした","4分冊を「それぞれ独立した書籍」として扱う方針にした",[10,24,25,26,30],{},"最初に決めたのは、4分冊をどう数えるかだった。1冊として扱うか、4冊として扱うか。分野別に分かれた4冊なので、",[27,28,29],"strong",{},"それぞれ独立した書籍","として登録する方針にした。全文検索でヒットしたときに、どの分野の本かがそのまま分かるほうが後で楽だと判断したからだ。",[10,32,33,34,37],{},"OCRは長丁場になる見込みだったので、走らせる前に各冊の ",[14,35,36],{},"book_id"," とタイトルだけ先に確定させた。途中で命名がブレて再インポートが発生すると、655ページぶんをやり直すことになる。そこだけは慎重に潰した。",[19,39,41],{"id":40},"yomitokuのocrは見積もり60分が1520分で終わった","yomitokuのOCRは見積もり60分が15〜20分で終わった",[10,43,44,47],{},[14,45,46],{},"/yomitoku"," で4分冊のPDFをMarkdownに変換し、本文中の図を画像として抽出させた。最初は計45〜60分かかる長丁場のつもりで、バックグラウンドジョブに投げて待つ構えだった。",[10,49,50],{},"ところが起動直後の出力を覗くと、GPUが約1秒/ページで淡々と回っていた。この速度なら合計15〜20分で終わる。見積もりが3倍ずれていた。",[10,52,53,54,57,58,61],{},"時間とディスクを節約するため、診断用の可視化画像出力（",[14,55,56],{},"-v","）は省いた。一方で図の抽出に効く ",[14,59,60],{},"--figure --figure_letter"," は残した。図がなければ参考書の意味が半減するからだ。",[63,64,69],"pre",{"className":65,"code":66,"language":67,"meta":68,"style":68},"language-bash shiki shiki-themes vitesse-light vitesse-light","# 可視化画像(-v)は省く / 図抽出のフラグは維持\nyomitoku ... --figure --figure_letter\n","bash","",[14,70,71,80],{"__ignoreMap":68},[72,73,76],"span",{"class":74,"line":75},"line",1,[72,77,79],{"class":78},"sxvE3","# 可視化画像(-v)は省く / 図抽出のフラグは維持\n",[72,81,83,87,91,95],{"class":74,"line":82},2,[72,84,86],{"class":85},"senZ8","yomitoku",[72,88,90],{"class":89},"sdGka"," ...",[72,92,94],{"class":93},"snbK4"," --figure",[72,96,97],{"class":93}," --figure_letter\n",[10,99,100],{},"4分冊すべて exit 0 で完了し、生成されたMDの数がページ数と完全に一致した。欠けも重複もない。続いて図ファイル1181枚を一括でリネームした。",[19,102,104],{"id":103},"_4分冊をtursoに640チャンクで格納した","4分冊をTursoに640チャンクで格納した",[10,106,107],{},"OCRしたテキストを Turso（libSQL/SQLite互換のクラウドDB）へ、ページ単位で640チャンクとして格納させた。再実行したときに同じ本が二重に入らないよう、各冊とも「既存削除→投入」の冪等処理にした。一時スクリプトを書いて流し、終わったら消した。",[10,109,110,113,114,116],{},[14,111,112],{},"amazon_metadata"," の紐付けでひと工夫いった。インポートの過程で自動紐付けが4回発火し、最後に処理した分冊が勝ってしまう状態だった。代表となる1冊の ",[14,115,36],{}," に紐付けを確定し直して、書誌情報とコンテンツの対応を1本に揃えた。",[118,119,121],"h3",{"id":120},"試行錯誤init_books_db-がトリガーのsqlでパース失敗した","試行錯誤①：init_books_db() がトリガーのSQLでパース失敗した",[10,123,124,125,128,129,132,133,136],{},"格納スクリプトを走らせたら、",[14,126,127],{},"init_books_db()"," がスキーマSQLのトリガー定義でパースに失敗した。トリガーの ",[14,130,131],{},"BEGIN ... END"," ブロックの中に ",[14,134,135],{},";"," が含まれていて、文の区切りを誤認していた。",[10,138,139],{},"ただ、よく考えればTurso上にはテーブルがもう作成済みだ。この初期化呼び出し自体が不要だった。原因を深追いしてSQLパーサを直すより、いらない呼び出しを削るのが筋だと判断して、その行を消した。これで4分冊のインポートが通り、合計640チャンクが入った。",[19,141,143],{"id":142},"ページ単位640チャンクを章節単位74チャンクへ統合した","ページ単位640チャンクを章・節単位74チャンクへ統合した",[10,145,146,147,150],{},"次に ",[14,148,149],{},"/restructure-book"," を4冊すべてに適用した。OCR直後はページ単位でぶつ切りのチャンクになっている。これを目次に沿って章・節単位へ統合し、640チャンクを74チャンク（17/15/24/18）へまとめ直した。",[10,152,153,154,157],{},"DB書き込み、特にFTS（全文検索インデックス）のリビルドは全体を再構築する重い処理で、並列で走らせると衝突する。なので",[27,155,156],{},"1冊ずつ逐次","、各冊を専用のサブエージェントに委譲して、順番に起動しては完了を待った。",[118,159,161],{"id":160},"試行錯誤サブエージェント経由でembedded-replicaがsyncでハングした","試行錯誤②：サブエージェント経由でEmbedded Replicaがsync()でハングした",[10,163,164,165,168],{},"1冊目（39→17チャンク）の統合自体は終わったのに、サブエージェントが最後の ",[14,166,167],{},"sync()"," で固まった。Embedded Replicaの同期処理が、無言のまま返ってこない。",[10,170,171,172,175],{},"これは前から踏んでいる既知の地雷だった。",[27,173,174],{},"Embedded Replicaは、サブエージェントやバッチ経由で動かすとWALロックでハングする","。ローカルにレプリカファイルを持つ方式なので、先行接続が握ったロックが残ると、後続の処理がロック待ちで止まる。タイムアウトもエラーも出ず、ただ待ち続ける。",[10,177,178,179,182],{},"逃げ道は分かっていた。残り3冊は、サブエージェントに",[27,180,181],{},"全ステップHTTP直接接続","を使わせた。ローカルのレプリカファイルを一切経由せず、Tursoクラウドへ直接書く。これでロックの待ち合わせが起きなくなり、ハングは再発しなかった。",[63,184,188],{"className":185,"code":186,"language":187,"meta":68,"style":68},"language-python shiki shiki-themes vitesse-light vitesse-light","# Embedded Replica（ローカル経由 → サブエージェントだとロックでハング）\nconn = libsql.connect(\"local.db\", sync_url=TURSO_URL, auth_token=TOKEN)\n\n# HTTP直接接続（クラウドへ直接 → ロック待ちなし）\nconn = libsql.connect(database=TURSO_URL, auth_token=TOKEN)\n","python",[14,189,190,195,251,258,264],{"__ignoreMap":68},[72,191,192],{"class":74,"line":75},[72,193,194],{"class":78},"# Embedded Replica（ローカル経由 → サブエージェントだとロックでハング）\n",[72,196,197,201,205,208,211,214,217,221,224,226,229,233,235,238,240,243,245,248],{"class":74,"line":82},[72,198,200],{"class":199},"sG7-3","conn ",[72,202,204],{"class":203},"shFtX","=",[72,206,207],{"class":199}," libsql",[72,209,210],{"class":203},".",[72,212,213],{"class":199},"connect",[72,215,216],{"class":203},"(",[72,218,220],{"class":219},"sMJiu","\"",[72,222,223],{"class":89},"local.db",[72,225,220],{"class":219},[72,227,228],{"class":203},",",[72,230,232],{"class":231},"s4oTP"," sync_url",[72,234,204],{"class":203},[72,236,237],{"class":93},"TURSO_URL",[72,239,228],{"class":203},[72,241,242],{"class":231}," auth_token",[72,244,204],{"class":203},[72,246,247],{"class":93},"TOKEN",[72,249,250],{"class":203},")\n",[72,252,254],{"class":74,"line":253},3,[72,255,257],{"emptyLinePlaceholder":256},true,"\n",[72,259,261],{"class":74,"line":260},4,[72,262,263],{"class":78},"# HTTP直接接続（クラウドへ直接 → ロック待ちなし）\n",[72,265,267,269,271,273,275,277,279,282,284,286,288,290,292,294],{"class":74,"line":266},5,[72,268,200],{"class":199},[72,270,204],{"class":203},[72,272,207],{"class":199},[72,274,210],{"class":203},[72,276,213],{"class":199},[72,278,216],{"class":203},[72,280,281],{"class":231},"database",[72,283,204],{"class":203},[72,285,237],{"class":93},[72,287,228],{"class":203},[72,289,242],{"class":231},[72,291,204],{"class":203},[72,293,247],{"class":93},[72,295,250],{"class":203},[10,297,298,299,302],{},"最大の分冊（242→24チャンク）も含め、残り3冊はHTTP直接接続で詰まらず通った。横断のFTS検索を流すと、",[14,300,301],{},"CHAPTER01 ... | SEC03 ..."," のようなクリーンな章/節ラベルでヒットするようになった。ページ番号で引いていた頃とは別物の引き心地だ。",[118,304,306],{"id":305},"試行錯誤クラウドだけ更新してローカルレプリカが陳腐化しそして壊れた","試行錯誤③：クラウドだけ更新してローカルレプリカが陳腐化し、そして壊れた",[10,308,309],{},"ハングを避けるためにrestructureを全部HTTP直接接続でやった。その代償が最後に出た。",[10,311,312,313,316],{},"クラウド側は74チャンクに更新された一方で、",[27,314,315],{},"ローカルのEmbedded Replicaファイルは旧640チャンクのまま","取り残された。次にPython CLIがEmbedded Replicaで繋いだら、差分同期でまたハングしうる。爆弾を残したまま終わるのは気持ち悪い。",[10,318,319,320,323,324,327],{},"健全性を確認しにいくと、レプリカが壊れていた。",[14,321,322],{},"db file exists but metadata file does not"," ——レプリカ本体のファイルはあるのに、メタデータの ",[14,325,326],{},".db-info"," が欠落していた。これでは整合が取れず、繋いだ瞬間に詰む。",[10,329,330],{},"対処は単純だった。データの本体はクラウドにあるので、ローカルのレプリカは退かしても何も失わない。壊れた3ファイルを退避ディレクトリへ移動し、Embedded Replicaで繋ぎ直した。クラウドから約80MBを再ダウンロードして、レプリカがゼロから自動で組み上がった。",[10,332,333],{},"最終的に、蔵書74チャンク・全62冊。横断FTS検索もクリーンな章/節ラベルで返り、レプリカも再構築済みで健全な状態に戻った。HTTP直接接続で逃げただけで放置せず、本来のEmbedded Replica経路でも動くところまで見届けて終えた。",[19,335,336],{"id":336},"学びメモ",[338,339,340,347,356,362,368],"ul",{},[341,342,343,346],"li",{},[27,344,345],{},"Embedded Replicaは、サブエージェントやバッチ経由だとsync()でハングする。"," ローカルレプリカのWALロック待ちが原因で、エラーも出ずただ固まる。書き込みを連続で回す処理や委譲先のサブエージェントには、最初からHTTP直接接続を使わせるのが安全",[341,348,349,352,353,355],{},[27,350,351],{},"クラウドだけHTTPで更新すると、ローカルレプリカが陳腐化する。"," 旧状態のまま取り残されたレプリカは、次に繋いだとき差分同期でハングしたり、メタデータ欠落（",[14,354,326],{}," なし）で壊れたりする。HTTP直接接続で更新したら、最後にレプリカの健全性まで確認する",[341,357,358,361],{},[27,359,360],{},"壊れたレプリカは退避すれば自動で再構築される。"," データの本体はクラウドにあるので、ローカルファイルを退かすのは非破壊。繋ぎ直せばクラウドから引き直して組み上がる。怖がらずに退避していい",[341,363,364,367],{},[27,365,366],{},"見積もりは外れる前提で、起動直後に実測を取る。"," 60分のつもりが1秒/ページで15〜20分で終わった。長丁場と決めつけて放置せず、最初の数ページで速度を見ると計画を立て直せる",[341,369,370,373],{},[27,371,372],{},"エラーの原因を直すより、いらない処理を消すほうが速いことがある。"," トリガーSQLのパース失敗は、テーブル作成済みなら初期化呼び出しごと不要だった",[375,376,377],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html pre.shiki code .snbK4, html code.shiki .snbK4{--shiki-default:#A65E2B;--shiki-dark:#A65E2B}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sG7-3, html code.shiki .sG7-3{--shiki-default:#393A34;--shiki-dark:#393A34}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}",{"title":68,"searchDepth":82,"depth":82,"links":379},[380,381,382,385,389],{"id":21,"depth":82,"text":22},{"id":40,"depth":82,"text":41},{"id":103,"depth":82,"text":104,"children":383},[384],{"id":120,"depth":253,"text":121},{"id":142,"depth":82,"text":143,"children":386},[387,388],{"id":160,"depth":253,"text":161},{"id":305,"depth":253,"text":306},{"id":336,"depth":82,"text":336},"dev","655ページある4分冊の参考書をyomitokuでOCRしてMarkdown化し、Turso蔵書DBに640チャンクで格納。さらにページ単位を目次に沿って章・節単位74チャンクへ再構造化した。Embedded Replicaがサブエージェント経由でハングする問題と、クラウドだけ更新してローカルレプリカが陳腐化・破損した一日の試行錯誤を記録する。","md",{},null,"/textbook-ocr-to-knowledge-base",false,"2026-05-27T00:00:00.000Z",{"title":5,"description":391},"2026-05/2026-05-27/textbook-ocr-to-knowledge-base",[86,401,402,403,404],"OCR","Turso","蔵書管理","全文検索","bBXupD6-Z174O74t7WyKy3DecrdfyM99AQ_zKGyokTI",[],"https://log.eurekapu.com/og/blog/textbook-ocr-to-knowledge-base.png?v=2026-05-27T00%3A00%3A00.000Z&title=%E8%87%AA%E7%82%8A%E3%81%97%E3%81%9F4%E5%88%86%E5%86%8A%E3%81%AE%E5%8F%82%E8%80%83%E6%9B%B8%E3%82%92OCR%E3%81%97%E3%81%A6%E8%94%B5%E6%9B%B8DB%E3%81%AB%E5%8F%96%E3%82%8A%E8%BE%BC%E3%81%BF%E3%80%81%E7%AB%A0%E3%83%BB%E7%AF%80%E5%8D%98%E4%BD%8D%E3%81%AB%E6%95%B4%E7%90%86%E3%81%97%E7%9B%B4%E3%81%99%E3%81%BE%E3%81%A7&author=Kei%20Komatsu&sig=220435edc1ed3565",1782528843112]