その3 では経路 B (ページ画像を Bedrock に直接読ませる) の流れを書きました。この記事では、両経路が揃ってからの仕上げと、ここまでの形に落ち着くまでに踏んだ事象を書きます。
事象の方は「モデルに JSON を返させる」という部分で起きたもので、経路 A と経路 B の両方に同じ対応を入れています。


finalizer (Lambda) は経路 A → 経路 B の順に処理し、成果物を result-*.json → comparison.json → DynamoDB の順に書きます。
comparison.json は後工程に渡さないこのファイルは RAG などの後工程に渡すものではなく、2 つの経路を見比べるための補助ファイルです。成果物はあくまで result-textract.json と result-bedrock.json です。
{
"jobId": "93029b04...",
"finalizedAt": "2026-08-22T05:26:39Z",
"status": "REVIEW_PENDING",
"needsReview": true,
"routes": {
"textract": { "status": "succeeded", "resultKey": "outputs/.../result-textract.json", ... },
"bedrock": { "status": "succeeded", "resultKey": "outputs/.../result-bedrock.json", "missingPages": 1, ... }
},
"diff": { ... }
}
routes: 経路ごとの状態経路の状態は 3 つあります。
succeeded: 正規化・検証して outputs/ に書けたfailed: 結果が得られなかった。Parallel の Catch が置いた理由か、finalizer 側で見た事実 (結果が S3 に無い) を残すskipped: その経路を通していない。日本語 PDF は経路 A を通らないのでこれになる経路 B は、ページが欠けていても 1 ページでも結果があれば succeeded にします。不完全さは missingPages と needsReview で表します。成否を 2 値に潰さず、どこまで揃ったかを数で残すという分け方です。
各経路には、結果の場所、所要時間、使用量、警告、検証レポートが並びます。その3 のステップ 5 で成果物には入れなかった検証の根拠は、ここに入ります。
diff: 並べるだけで判定はしない両経路がともに succeeded のときだけ diff を作ります。片方しか無ければ null です。
並べるのは、題名、著者名の一覧、要旨、要素ごとの件数 (章・図・表・参考文献)、章見出しの一覧です。それぞれに equal を付けますが、これは一致したかどうかだけを表し、どちらが正しいかは判定しません。
見出しの一覧を入れているのは、件数だけでは分からない構造の違いを一目で見るためです。参考文献の照合結果は検証レポートに既にあるので、diff には重複して載せません。
レビュー要 (needsReview) になるのは次のどちらかです。
片方の失敗をレビュー要にしているのは、失敗した事実そのものが見るに値するためです。skipped は数えません。設計どおり通していないだけなので、成功した経路の判定に従います。
状態はこの順に決まります。
| 条件 | 状態 |
|---|---|
| 成功した経路が 0 | FAILED |
| レビュー要 | REVIEW_PENDING |
| それ以外 | COMPLETED |
comparison.json を先に書き、DynamoDB の状態更新はその後です。両経路が失敗して実行を失敗させる場合も、comparison.json を書いてからエラーにします。何が起きたのかを調べる材料を残しておきたいためです。
この Lambda の書き込みはすべて上書きなので、同じ入力で何度実行しても結果は変わりません。経路 A の入力は work/ の正規化前の結果で、自分の出力を読み戻すことはありません。
diff は並べるだけで、どちらの経路がどの項目で正確かの評価はしていません。正解データとの突き合わせが未検証で、目視の所見しか無い段階だからです。数値による評価は別の機会に扱えたらと思っています。
ここまでがパイプラインの流れです。次からは、この形に落ち着くまでに踏んだ事象を書いていきます。

ここからは、現在の形に落ち着くまでに踏んだ二つの事象を書きます。どちらも「モデルに JSON を返させる」という部分で起きています。
はじめは tool use を使わず、指示文にスキーマを書いて自由文で JSON を返させていました。
Return one JSON object and nothing else. No prose, no code fence.
Schema:
{
"title": string,
"authors": [{"name": string, "affiliation": string, "email": string}],
"sections": [{"level": int, "heading": string, "text": string}],
...
}
アプリ側は、応答のテキストから JSON の部分を切り出して読む形です。コードフェンスや前置きが付いてくることは想定していて、その分は取り除いてから解釈していました。
この形でも、短いページや図表だけのページは問題なく通ります。
英語論文 4 本を通したところ、3 本で経路 B が途中のページで失敗しました。エラーはいずれも JSON の解釈失敗です。
| 失敗したページ | エラー | そのページにあった語句 |
|---|---|---|
| 論文 1 の p.11 | invalid character 'B' after object key:value pair | "Beauty" |
| 論文 2 の p.3 | invalid character 'O' after object key:value pair | “On ... |
| 論文 3 の p.12 | invalid character 'p' after object key:value pair | “prove ... |
どのエラーも、値の途中で文字列が終わったと解釈され、その先の文字で壊れています。
本文に含まれる引用句の引用符を、モデルが \" にエスケープせずにそのまま文字列へ転記していました。
{ "text": "The authors call this property "Beauty" and argue ..." }
"Beauty" の先頭の引用符が文字列の終わりと解釈され、その直後の B が余分な文字になるため、ここで JSON として読めなくなります。エラーメッセージに出ている 'B' 'O' 'p' は、いずれも引用句の 1 文字目です。
注意したいのは、モデルが内容を読み違えたわけではないことです。紙面に印字された文字は正しく転記されています。壊れているのは、それを JSON という入れ物に入れるときの約束の方だけです。
この失敗は、引用句のあるページで再現します。一時的な不調ではなく、そのページの中身が引き起こすものだからです。
解釈に失敗したときの再試行は 1 回だけ入れていますが、同じページで同じところが壊れるだけでした。temperature を 0 にしているので、別の書き方になることを期待できる形でもありません。
指示文に「引用符は \" でエスケープすること」と書くことはできます。実際、それで通るページは増えると思います。
ただ、それは「モデルがそのとおりに書いてくれる」以上の保証にはなりません。ページ分の本文を書き写す途中の 1 箇所で崩れれば、そのページの結果は丸ごと読めなくなります。引用符だけでなく、改行やバックスラッシュも同じ問題を起こします。
ここでの問題は、抽出した中身ではなく、出力の形式を保証する手段が指示文しか無いことだと考えました。指示文は守られることを期待するものであって、崩れたときに防ぐ手立てにはならないためです。
応答を JSON として読むコードは両経路で共通です。経路 A は本文をモデルに書き写させないので確率は低いものの、見出しや参考文献の記載に引用符が含まれれば同じことが起きます。実際に起きたのは経路 B ですが、対策は両方に入れることにしました。
このとき、モデルの生の応答は残していませんでした。手元にあるのはエラー文だけで、そこから読めるのは「'B' という余分な文字がある」ということだけです。
原因に辿り着けたのは、エラーに出ている文字 ('B' 'O' 'p') が、どれもそのページの原本にある引用句の 1 文字目と一致していたからです。3 本とも同じ形だったので、引用符が原因だと見当を付けました。裏を返せば、この一致が無ければ推測のしようがありませんでした。
解釈に失敗した応答を page-NNNN.error.json に残すようにしたのは、この後の対応でのことです (その3 のステップ 3「bedrock-parser (Lambda) がページ単位の結果を S3 に保存する」)。
次のステップで、形を保証する側に寄せた対応を書きます。

対策は、形式を守らせる役目をプロンプトから仕組みの側に移すことでした。Bedrock の tool use を使います。
モデルに「ツール」の定義 (名前と、入力の JSON Schema) を渡しておくと、モデルが通常のテキストの応答ではなく「このツールをこの入力で呼んでほしい」という形 (ツール名 + スキーマに沿った JSON) で応答を返す仕組みです。
本来はアプリ側がその入力でツールを実行し、結果をモデルに返して会話を続けるためのものです。ここでは実行したいツールがあるわけではなく、「スキーマどおりの JSON を返させる」ためだけに使っています。
参考: Amazon Bedrock (User Guide) - Use a tool to complete a model response
自由文で返させていたときは、応答はただの文字列でした。JSON として成立するかどうかは、モデルが引用符を正しくエスケープして書いてくれるかに依っていました。
tool use では、ツールの入力は構造を持ったデータとして返ってきます。引用符を含む文章も、値の中身として扱われます。「文字列のどこが終わりか」をモデルの書き方に委ねない形になる、というのがここでの要点です。
ツールは 1 つだけ登録し、toolChoice でそのツールの呼び出しを強制します。これで、モデルがツールを呼ばずにテキストで答えてくることがなくなります。
// tool を 1 つだけ登録し、toolChoice でその tool の呼び出しを強制する
spec := awsbedrockruntimetypes.ToolSpecification{
Name: new(t.Name),
InputSchema: &awsbedrockruntimetypes.ToolInputSchemaMemberJson{Value: awsbedrockruntimedocument.NewLazyDocument(t.Schema)},
}
return &awsbedrockruntimetypes.ToolConfiguration{
Tools: []awsbedrockruntimetypes.Tool{&awsbedrockruntimetypes.ToolMemberToolSpec{Value: spec}},
ToolChoice: &awsbedrockruntimetypes.ToolChoiceMemberTool{Value: awsbedrockruntimetypes.SpecificToolChoice{Name: new(t.Name)}},
}, nil
※ 現在のコードにはもう 1 つ Strict の指定がありますが、その経緯はステップ 5 で書くのでここでは省いています
応答の側は、toolUse の input をそのまま結果として読みます。ツールは 1 つしか登録していないので、最初の toolUse だけを採ります。
形を決めるのがスキーマになったので、指示文に書いていたスキーマの写しを削りました。指示文には値の意味と規則だけを残しています。
同じことを 2 箇所に書くと、片方を直したときにもう片方が古いままになり、どちらが効いているのか分からなくなります。
応答を JSON として読むコードは両経路で共通なので、経路 A の問い合わせも tool use に変えました。その2 で見た structure_paper がそれです。実際に壊れたのは経路 B だけですが、見出しや参考文献の記載に引用符が含まれれば同じことが起きるためです。
Bedrock は呼ぶたびに課金されるので、テストで実 API を叩かない方針にしています。記録した応答を再生して検証する形です。
記録は 1 回分の応答を保存した JSON ファイルで、backend/testdata/bedrock/ に置いています。テストは Bedrock の代わりにこのファイルから応答を返す実装を差し込み、そこから先の処理を実 API なしで動かします。
今回は、引用符を含む本文を模した記録を手で作って追加し、それを再生して壊れずに復元できることを確かめました。
{
"modelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"route": "bedrock",
"recordedAt": "2026-08-22T00:00:00Z",
"response": {
"toolInput": { "sections": [ { "text": "... the word \"Beauty\" was shown ..." } ] },
"stopReason": "tool_use",
"usage": { "inputTokens": 2410, "outputTokens": 198, "totalTokens": 2608 },
"latencyMs": 4702
},
"note": "synthetic fixture written by hand for Issue #111 ...; not a real recording"
}
response がモデルから返ってきた中身で、その外側にモデル ID、経路、記録した日時、そして由来を書く note が付きます。この記録には「手で書いたもので実レスポンスではない」と明記しています。手書きのフィクスチャを実物と誤認すると、通っているテストが何も保証しなくなるためです。
応答を読む処理には、toolUse の入力が無い場合に従来どおり自由文から JSON を切り出す経路を残してあります。tool use 以前に取った記録を再生するためです。
この変更で、JSON として読めない応答は無くなりました。ただし、読めることと、想定した型で入っていることは別です。次のステップでは、スキーマを渡しているのに型が崩れた話を書きます。

tool use に切り替えたあと、同じ 4 本を投げ直しました。引用符で壊れていたページはどれも通り、JSON として読めない応答は出なくなりました。
ただ、1 本だけ、また同じページで止まりました。前のステップの表の最後にある、論文 3 の p.12 です。
エラーは前とは別のものでした。
tool input: json: cannot unmarshal string into Go struct field PageResult.sections of type []bedrockroute.PageSection
sections は、ページの本文を「見出しで区切ったブロックの並び」として受け取るキーです。見出しのあるところで新しい要素を始めてもらうので、想定している形はこうです。
"sections": [
{ "level": 2, "heading": "5.2 Analysis", "text": "We compare ..." },
{ "text": "The remaining cases are discussed ..." }
]
返ってきたのはこれです。
"sections": "Recent work has shown that ..."
配列も、要素の入れ物も無くなって、本文の文字列だけが入っていました。JSON としては成立していて、文字も読む限り正しく写せています。違っているのは形だけです。前の事象で問題にしていた「文字列の終わりが分からなくなる」現象も起きていません。
止まったのは、見出しが 1 つも無く、本文だけが続くページでした。このページを正しく返すなら、要素が 1 つだけの配列になります。要素が 1 つしか無い場面で包みが外れたように見えますが、他のページと見比べて確かめたわけではないので、これが引き金だとは言い切れません。
前の事象を受けて、解釈に失敗したときは 5 秒後に 1 回だけ送り直す形にしていました。今回はそれが働きましたが、2 回とも同じエラーでした。
引用符のときと同じで、そのページの中身が引き起こしている失敗なので、送り直しても結果は変わりません。
tool use の入力スキーマは、strict を付けない限り、モデルへの手がかりとして扱われます。型やキーが必ずそのとおりになるという保証はありません。
公式ドキュメントも「strict を付けない場合は型の不一致や必須キーの欠落が起こり得る」と書いていて、その解決策として strict: true (constrained decoding) を挙げています。
参考
前のステップの対処で、JSON として読めない応答は無くなりました。ただ、読めることと、想定した型で入っていることは別の保証でした。スキーマを渡した時点で型まで決まったつもりでいた、というのがここでの見落としです。
何が返ってきたのかは残っておらず、エラー文と原本のページを突き合わせて推測するしかありませんでした。
解釈に失敗した応答を page-NNNN.error.json に残す処理は、この反省から、次のステップの対応と一緒に入れたものです。
なお、このページが欠けたまま経路 B 全体がどう扱われたかは、ステップ 6 で書きます。

対処は、スキーマを「参考」から「制約」に変えることでした。
ツールの定義に Strict を付けます。
spec := awsbedrockruntimetypes.ToolSpecification{
Name: new(t.Name),
InputSchema: &awsbedrockruntimetypes.ToolInputSchemaMemberJson{Value: awsbedrockruntimedocument.NewLazyDocument(t.Schema)},
Strict: new(true),
}
ステップ 3 で「もう 1 つ指定がある」と書いて省いたのがこれです。これで制約付き生成 (constrained decoding) が働き、スキーマに合う出力しか生成されなくなります。型やキーが揃っているかを、生成のあとに検査するのではなく、生成の側で外せなくする形です。
条件では切り替えず、常に付けています。tool use を使うのは構造化した結果が欲しいときだけで、緩めたい場面が無いためです。
strict を有効にすると、書ける JSON Schema がサブセットに絞られます。
additionalProperties: false と required が要る (ネストした items の object も同じ)minimum minLength 再帰 外部の $ref は使えないminItems に指定できるのは 0 と 1 だけ値の範囲や長さのような規則は、スキーマでは表せなくなります。もともと指示文のルールとして書いていたので、そちらはそのままにしました。スキーマは型だけを持ち、値の意味は指示文が持つ、という分け方です。
additionalProperties: false は機械的に付けられますが、required は決める必要があります。「常に値のあるキー」に限る、という方針にしました。
| キー | 扱い | 理由 |
|---|---|---|
sections figures tables references | required | 該当が無いページでも空配列で返させる。キーごと消えないので、読む側の分岐が増えない |
title authors abstract keywords | 省略可 | 書誌情報は表紙のページにしか印字されない。required にすると、無いページで空文字が埋まる |
doi | 省略可 | 記載の無い文献が多い。空文字で埋まると「無い」と「空だった」を後から区別できない |
配列の要素の中も同じ考え方で、必ず印字されているものだけを required にしています。
"sections": array(object(map[string]any{"level": integer, "heading": str, "text": str}, "text")),
"figures": array(object(map[string]any{"label": str, "caption": str}, "label", "caption")),
// object は strict な tool use の object を組み立てる (additionalProperties: false は strict の必須条件)
func object(properties map[string]any, required ...string) map[string]any {
return map[string]any{
"type": "object",
"properties": properties,
"required": append([]string{}, required...),
"additionalProperties": false,
}
}
required にしたキーは省略できなくなるので、埋めようが無いときにどうするかを書き足しました。
変更前
Omit any key you cannot fill. Never invent a value.
変更後
Never invent a value. Omit an optional key you cannot fill.
A required key with nothing to record takes "" or [].
スキーマは初回に文法としてコンパイルされ、その分の待ちが出ることがあります。結果はキャッシュされますが、スキーマの構造を変えると作り直しになります。
bedrock-parser のタイムアウトは 300 秒、Map の 1 ページには 660 秒を置いていて、そのうえで再試行があります。この中で吸収できる前提にしました。
tool use のときと同じく、経路 A のスキーマにも additionalProperties: false と required を入れています。Strict は経路ごとの分岐を持たず、ツールを渡すすべての呼び出しに付きます。
Bedrock は呼ぶたびに課金されるので、ここでも記録した応答の再生で検証しています。strict を入れたあとのスキーマで、記録済みの応答が型どおりに読めることを確かめました。
この記事を書いている時点では、型が崩れたページを実際に投げ直すところまではやれていません。次に実行するときにまとめて確認する予定です。

ここまでの二つは、どちらも 1 ページの失敗でした。その 1 ページが、ジョブ全体にどこまで波及するのかを書きます。
この部分も一度直していて、直す前は失敗の伝わり方に穴がありました。
経路 B の Map には、失敗を許容して残りを走らせる設定 (ToleratedFailureCount など) を置いていません。1 ページが再試行を含めて失敗すると、その時点で Map が終わり、未着手のページは処理されません。
置いていないのは意図してのことです。1 ページでも欠けた経路はどのみち投げ直すことになるので、残りのページを最後まで処理しても、その分のトークンが無駄になります。止まった時点までの結果は S3 に残っているので、何が起きたかを調べる材料は失われません。
Map の失敗は States.ALL の Catch で受け、経路 B の枝だけを終わらせます。その2 のステップ 8 で書いたとおり、Parallel は 1 つの枝が失敗すると全体が止まるので、Catch で受けないと並走している経路 A の (課金済みの) 結果まで捨てることになります。
Catch が受けた理由 (Error と Cause) は枝の出力として残り、finalizer (Lambda) がそれを読みます。
finalizer (Lambda) は、Map がどこで止まったかを知らないまま、1 ページ目から pageCount まで固定のキーを順に読みます。無いページは飛ばします。
ページ結果が 1 件でもあれば経路は succeeded です。揃った分だけでも比較の材料にはなるので、一部が欠けただけで全体を捨てません。欠けたページを警告に残すところは、その3 のステップ 4 で書いたとおりです。
最初は「ページ結果が 1 件以上あれば成功」だけを見ていて、欠落も Catch の理由も見ていませんでした。その結果、15 ページ中 7 ページしか揃っていない結果が COMPLETED / needsReview: false で終わっていました。
参考文献の載ったページが丸ごと欠けると references は 0 件になりますが、これは検証から見ると「参考文献の無い論文」と区別が付きません。欠落は警告には残っていたものの、状態には出ておらず、成果物は静かに欠けたまま正常終了に見えていたことになります。
経路 B が succeeded のときも、次の 2 つを comparison.json に残すようにしました。
missingPages: pageCount と実際に読めたページ数の差error / cause: Map の Catch が残した理由どちらかがあれば needsReview: true にします。検証が異常を見つけなくても COMPLETED にはせず、ジョブの状態は REVIEW_PENDING になります。
"bedrock": {
"status": "succeeded",
"resultKey": "outputs/.../result-bedrock.json",
"missingPages": 1,
"error": "States.TaskFailed",
"cause": "...",
"needsReview": true
}
不完全さを成否の 2 値に潰さず、どこまで揃ったかを数で残す、というのがここでの分け方です。
1 ページの失敗にも種類があるので、Map の中の Task では分けて扱っています。
| 失敗の種類 | 再試行 | 理由 |
|---|---|---|
| 入力が不正 (ページ画像が無い、ページ番号が不正) | しない | 何度送っても結果は変わらない |
| 応答を解釈できない | 5 秒後に 1 回だけ | 1 ページ分の課金で経路全体を救えることがある |
| その他の失敗 (Lambda の一時的な不調など) | 10 秒から始めて 2 回まで、間隔は倍に | 時間をおけば直る種類のもの |
スロットリングはこの表には出てきません。Lambda の中で先に待って再送していて、Step Functions まで上がってこないためです。
応答を解釈できない失敗に 1 回だけ再試行を入れているのは、ステップ 2 の事象を受けての保険です。ステップ 2 と 4 で見たとおり、ページの中身が引き起こす失敗はこれでは直りませんが、通る可能性があるなら 1 ページ分は試す価値がある、という判断でした。
この記事では、両経路が揃ってからの仕上げと、ここに落ち着くまでに踏んだ事象を書きました。
2つの事象は、見た目は別ですが同じ形をしていました。モデルが紙面から読み取った中身は正しくて、崩れていたのはそれを JSON という入れ物に入れるときの約束の方だけです。そしてどちらも、その約束を守らせる手段が指示文しか無いうちは、直したつもりでも保証にはなっていませんでした。
指示文でお願いする、ツールの定義で受け取る形にする、制約付き生成で外せなくする。この順に仕組みの側へ寄せて、やっと形が決まりました。プロンプトで直せそうに見える問題ほど、仕組みで決められないかを先に見た方がよかった、というのがここでの反省です。
もう一つは、欠けたことを静かに通さないことです。一部が失敗しても全体を捨てないという方針は変えていませんが、欠けたことが数と理由として残り、ジョブの状態にも出るようにしました。完了と言い切れないものを完了にしない、というだけの話ですが、これが無いと成果物を信用できません。
残っているのは比較の中身です。今の comparison.json は両経路を並べるだけで、どちらが正確かは判定していません。正解データとの突き合わせが無いためです。ここを数値で評価するところは、別の機会に書けたらと思っています。
AWS AIPの知見を活かし、論文PDFを構造化JSONに変換するパイプラインを構築。S3、Lambda、Step Functions、Textract、Bedrockを組み合わせ、2つの抽出経路で日本語・英語PDFを処理し、メタデータ付きの構造化データをRAGのソースとして活用する仕組みを実装。
論文PDFを構造化JSONに変換するパイプラインの経路B(画像をBedrockに直接読ませる方式)について、Step Functionsでページを並列処理し、各Lambdaがページ画像からモデルで構造化データを抽出、S3に保存、最後にfinalizerで結合・正規化・検証する流れを詳説。
論文PDFを構造化JSONに変換するパイプラインの経路A(Textract+Bedrock)について、各ステップの設計理由と実装を詳細に解説。出力上限問題への対処として、モデルに本文を書かせず位置情報のみ返させることで、生成時間を半減させた改善事例を紹介。
Bedrock cross-region inference profileの動的ルーティングにより、大阪リージョンでモデルアクセスが未設定だったため、リージョン間で交互に成功・失敗するエラーが発生。大阪でのモデルアクセス申請とLambda IAM権限追加で解決した。
Grafana × Loki を使用してログ検索・表示方法を解説。LogQL でのログ検索、ログ詳細表示、Trace との相互遷移機能を紹介。Log と Trace を組み合わせることで問題切り分けが効率化される利点を説明。