前回の 論文 PDF を構造化 JSON にするパイプラインを構成してみる (その1) では全体構成と 2 つの抽出経路の概要を書きました。
今回の記事では、経路 A (Textract で読み取り、Bedrock で整える) について、各設定の理由と出力結果を書きます。
前提として、Textract は日本語に対応していないため、この経路には英語の PDF だけが流れてきます。
言語は前段の preprocessor (Lambda) が Go のロジックで判定させています。モデルは使わず、テキストレイヤーの全文で日本語文字 (ひらがな・カタカナ・漢字) とラテン文字を数え、日本語文字の比率があらかじめ指定した割合以上なら日本語、未満なら英語としています。※英文要旨や英語の参考文献を含む日本語論文でも日本語と判定できるよう、割合は低めにしている

| Lambda | 役割 |
|---|---|
validator | PDF の妥当性判定と冪等性チェック |
preprocessor | ページ画像 (PNG) とテキストレイヤーの生成、言語判定 |
textract-parser | 経路 A: Textract で読み取り、Bedrock で整える |
bedrock-parser | 経路 B: ページ画像を Bedrock に直接読ませる (ページごとに並列) ※今回は触れない |
finalizer | 正規化、検証、結果の保存 |
経路 A のシーケンス図 (番号は下のステップとは別の通し番号)
textract-parser (Lambda) を起動し、トークンを渡して待つtextract-parser (Lambda) が Textract に解析を依頼し、トークンを退避して終了するtextract-parser (Lambda) を起動するtextract-parser (Lambda) が Block を読み順・表・図に組み立て直すtextract-parser (Lambda) が Bedrock に「構造」だけを答えさせるtextract-parser (Lambda) が文書 JSON を保存し、トークンで完了を返すfinalizer (Lambda) が経路 A の結果を正規化・検証して保存する以下、ステップごとに設定とその理由を書いていきます。
textract-parser (Lambda) を起動し、トークンを渡して待つTextract の解析は数十秒〜数分かかります。Lambda の実行時間の上限は 15 分なので Lambda の中でポーリングして待つことも可能ではありますが、今回は Step Functions の waitForTaskToken を使って Step Functions 側で待つ形にしました。
タスクトークンは Step Functions が Task ごとに発行する一意の文字列です。後でこの文字列を添えて「成功」か「失敗」を返すと、待っていた Task が再開する仕組みになっています。
Step Functions から textract-parser に渡されるイベントサンプル
{
"jobId": "...",
"taskToken": "AQB4AAAA...",
"language": "en",
"pageCount": 19,
"hasTextLayer": true
}
備考: waitForTaskToken: Task の Resource に .waitForTaskToken を付けると、Step Functions がトークンを発行して入力に渡し、そのトークン付きで SendTaskSuccess / SendTaskFailure が呼ばれるまで Task を止めたまま待つ仕組み
textract-parser (Lambda) が Textract に解析を依頼し、トークンを退避して終了する前提として Textract の API は 2 系統あり、同期的に実行する AnalyzeDocument は 1 ページの画像か PDF しか受け付けない仕様になっています。論文 PDF は複数ページなので、ページごとに分割して複数回実行しても実現できますが、非同期で実行する StartDocumentAnalysis であれば S3 上の PDF を丸ごと渡せて、ページ番号付きの 1 つの結果として返ってきます。分割・並列・結合などのロジックをこちらで定義する必要がないので今回はこちらを使います。ただし、この処理が完了したことを受け取る仕組み (ステップ 1〜3) はこちらで持つことになります。
StartDocumentAnalysis に渡すのは、S3 上の PDF の場所 (DocumentLocation)、読み取る機能 (FeatureTypes)、完了の通知先 (NotificationChannel) の 3 つと、目印の JobTag です。呼び出しはジョブ ID を返してすぐ戻り、解析は Textract 側で進みます。
StartDocumentAnalysis のリクエストサンプル
{
"DocumentLocation": {
"S3Object": {
"Bucket": "dev-folio-documents-{accountId}",
"Name": "uploads/{jobId}/original.pdf"
}
},
"FeatureTypes": ["LAYOUT", "TABLES"],
"NotificationChannel": {
"SNSTopicArn": "arn:aws:sns:us-east-1:{accountId}:dev-folio-textract-completion",
"RoleArn": "arn:aws:iam::{accountId}:role/dev-folio-textract-publish-role"
},
"JobTag": "{jobId}"
}
StartDocumentAnalysis のレスポンスサンプル
{
"JobId": "36b9261f9f1cd77865b66fb85613945b7a97a796f62a6b3e954f8ee496dadbfe"
}
Block はここでは返らず、この JobId を手がかりに完了後に取りに行きます (ステップ 3)。
リクエストのうち、FeatureTypes と NotificationChannel は設定の判断が入るので補足します。
FeatureTypes は読み取る機能で、LAYOUT と TABLES の 2 つを指定しています。
※ほかに FORMS (キーと値の組) や QUERIES (質問への回答) もあるが、論文の構造化には使わないため未指定 (機能ごとにページ単価が加算されるため、必要なものだけ指定している)
NotificationChannel は完了の通知先で、SNS トピックの ARN と、Textract がそのトピックに発行するときに引き受ける IAM ロールの ARN を渡します。通知を受けてからの流れはステップ 3 で書きます。
完了通知を受け取るのは、Textract を開始した Lambda の実行ではなく、SNS から改めて起動される 2 回目の実行 (ステップ 3) です。1 回目の実行はトークンを変数として持っていますが、終了した時点で消えるので、2 回目の実行には引き継げません。Textract に持たせられる目印 (JobTag) にトークンを入れたいところですが、JobTag は 64 文字まで (英数字と一部の記号のみ) なので収まりません。
そのため JobTag には jobId だけを入れ、トークンは work/{jobId}/textract/callback.json に書いておきます。完了通知には JobTag がそのまま載るので、通知を受けた側はそれでこのファイルの場所を組み立てて読み戻せます。ここには jobId・タスクトークン・Textract のジョブ ID・開始時刻のほか、使った機能と元 PDF の情報 (source) を入れています。
退避は Textract を開始した後に行います。※Textract のジョブ ID は StartDocumentAnalysis の戻り値で初めて分かるため、開始前には書けない
work/{jobId}/textract/callback.json のサンプル
{
"jobId": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"taskToken": "AQB4AAAAKgAAAAMAAAAAAAAAAaVVFPfg1S60BoqhPTIpupFZeumYBsp9Qi7Z...", // タスクトークン
"textractJobId": "36b9261f9f1cd77865b66fb85613945b7a97a796f62a6b3e954f8ee496dadbfe",
"startedAt": "2026-08-22T03:19:51Z",
"featureTypes": ["LAYOUT", "TABLES"],
"source": { ... }
}
textract-parser (Lambda) が Block を読み順・表・図に組み立て直すTextract の結果は Block の列で、それぞれに種類 (LAYOUT_TITLE、LAYOUT_SECTION_HEADER、LAYOUT_TEXT、TABLE など)、座標、確信度が付いています。これを Go のコードで「読み順に並んだ要素」に組み立て直します。
段の境界・表の行列・キャプションは座標と Block の関係から一意に決まるので、モデルに推論させる必要がなく、コードの方が速く安く、毎回同じ結果になります。この段階ではモデルを使いません。

provenance.confidence に入れるprovenance.warnings に残す
ここまでで「文字」と「並び」と「図表」は決まりました。残るのは論文としての構造の判断で、これを次のステップでモデルに任せます。
textract-parser (Lambda) が Bedrock に「構造」だけを答えさせるステップ 4 で、要素は読み順に並び、表と図も復元できました。ただ、この時点ではまだ「どれが題名と著者か」「どの見出しからどこまでが 1 つの章か」「どの要素が参考文献の 1 件か」は分かっていません。Textract の LAYOUT が返すのは「見出し」「本文」といったページ上の役割までで、論文としての構造 (書誌・章立て・参考文献) は内容を読まないと決められないからです。
この「内容を読んで判断する」部分だけを Bedrock (Claude) に任せます。文字そのものは Textract が読み取り済みで、並びと図表はステップ 4 で決まっているので、モデルに頼むのは構造の判断だけで足ります。
並べた要素に番号 (#0, #1, ...) を振ったテキストを Bedrock に渡します。要素 1 つを [#番号 Block の種類] 文字列 の 1 行にし、ページごとに ## page N で区切り、表は Markdown で埋め込みます。
## page 1
[#0 LAYOUT_TITLE] AI4AI-Bench: ...
[#1 LAYOUT_TEXT] Author A, Author B ...
[#2 LAYOUT_SECTION_HEADER] 1 Introduction
[#3 LAYOUT_TEXT] Large language models ...
文字・並び・図表はここまでで決まっており、内容を読まないと決められないのは次の 3 つだけのため、答えさせるのはこの 3 つに限定しています。
モデルが返す JSON のサンプル (値はすべて例示)
{
"title": "Example Paper: A Placeholder Title for Illustration",
"authors": [{ "name": "A. Author", "affiliation": "Example University", "email": "" }, ...],
"abstract": "...",
"keywords": [],
"venue": "arXiv",
"year": 2026,
"sections": [
{ "level": 1, "heading": "Introduction", "from": 5, "to": 24 },
{ "level": 1, "heading": "Method", "from": 25, "to": 61 },
{ "level": 2, "heading": "Task formulation", "from": 25, "to": 33 },
...
],
"references": [
{
"element": 310,
"title": "An Example Reference Title",
"authors": ["B. Author", "C. Author", "..."],
"year": 2016,
"venue": "Proceedings of an Example Conference",
"doi": null
},
...
]
}
本文と参考文献の原文はモデルに書き写させません (理由はステップ 9 「出力上限」で書きます)。モデルが返した番号の範囲から Go のコードが本文を組み立てます。
応答は tool use で受け取ります。tool use とは、モデルに「ツール」の定義 (名前と、入力の JSON Schema) を渡しておくと、モデルが通常のテキストの応答ではなく「このツールをこの入力で呼んでほしい」という形 (ツール名 + スキーマに沿った JSON) で応答を返す仕組みです。本来はアプリ側がその入力でツールを実行し、結果をモデルに返して会話を続けるためのものですが、ここでは実行したいツールがあるわけではなく、「スキーマどおりの JSON を返させる」ためだけに使っています (テキストの応答として JSON を書かせず、この形にしている理由は、経路 B で出た事象によるものなので、その4 で書きます)。
この経路で渡しているツールは structure_paper の 1 つだけです。論文の書誌・章立て・参考文献を記録するために定義したツールで、名前が structure_paper、入力の JSON Schema が上のサンプルの形 (書誌・章立て・参考文献) です。実体のある処理は持たず、モデルがこのスキーマに沿った JSON を「structure_paper の呼び出し」として返すための受け皿として定義しています。ツールの選択も structure_paper に固定しているため、モデルがツールを呼ばずにテキストで答えてくることはありません。さらに strict を有効にして、出力の型とキーをスキーマに従わせています。
structure_paper の定義 (Go)。object / array / str / integer / nullableStr は JSON Schema の断片を組み立てる自前のヘルパで、後半にその定義を置いている
// Bedrock に渡すツールの定義 (名前・説明・入力の JSON Schema)
// モデルはこのスキーマに沿った JSON を structure_paper の呼び出しとして返す
var paperTool = &bedrock.ToolSpec{
Name: "structure_paper",
Description: "Record the bibliographic data, section outline and reference entries of an academic paper.",
Schema: object(map[string]any{
"title": str,
"authors": array(object(map[string]any{"name": str, "affiliation": str, "email": str})),
"abstract": str,
"keywords": array(str),
"venue": str,
"year": integer,
"sections": array(object(map[string]any{"level": integer, "heading": str, "from": integer, "to": integer})),
"references": array(object(map[string]any{
"element": integer,
"title": str,
"authors": array(str),
"year": integer,
"venue": str,
"doi": nullableStr,
})),
}),
}
// 型だけを持つスキーマの部品
// doi は見つからないことを null で表すため null を許す
var (
str = map[string]any{"type": "string"}
nullableStr = map[string]any{"type": []string{"string", "null"}}
integer = map[string]any{"type": "integer"}
)
// object 型のスキーマを組み立てる
// 全キーを required にし、additionalProperties: false を付ける (strict の必須条件)
// 欠損は "" / [] / 0 / null で表す約束なので、任意キーは作らない
func object(properties map[string]any) map[string]any {
return map[string]any{
"type": "object",
"properties": properties,
"required": slices.Sorted(maps.Keys(properties)),
"additionalProperties": false,
}
}
// array 型のスキーマを組み立てる
func array(items map[string]any) map[string]any {
return map[string]any{"type": "array", "items": items}
}
指示文は英語で書いています (この経路には英語の論文しか流れてこないため)。
また、temperature (出力のばらつきを決める推論パラメータ) は 0 にして、同じ入力に対する出力のばらつきを最小にしています。
※補足: temperature は Bedrock (Converse API) の推論パラメータの 1 つで、モデルが次の単語 (トークン) を選ぶときのばらつきを決める値です。0 に近いほど毎回もっとも確率の高い候補を選び、大きいほど候補をランダムに選ぶようになります
textract-parser (Lambda) が文書 JSON を保存し、トークンで完了を返すモデルが返した構造と、ステップ 4 で復元した図表を合わせて 1 つの文書 JSON にし (形は Go の構造体 domain.Document として自前で定義している)、work/{jobId}/textract/document.json に保存します。
これは正規化前の中間結果で、構造はその1 で示した最終成果物 result-textract.json と同じです。最終成果物は finalizer が document.json を正規化・検証して outputs/{jobId}/result-textract.json に書きます。
work/{jobId}/textract/
├── callback.json # タスクトークンの退避
├── raw.json # Textract の生出力 (Block の列)
└── document.json # 構造化した文書 JSON (正規化前)
document.json の provenance には、経路 (textract)、ステップ 4 で出した確信度と warnings、所要時間 (Textract の開始から保存の直前まで)、cost (Textract のページ数と機能、Bedrock のモデル ID と入出力トークン数) が入ります。
work/{jobId}/textract/document.json のサンプル
{
"jobId": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"schemaVersion": "1.0",
"source": { ... },
"metadata": { ... },
"sections": [ ... ],
"figures": [ ... ],
"tables": [ ... ],
"references": [ ... ],
"provenance": {
"route": "textract",
"extractedAt": "2026-08-22T05:24:28.438854939Z",
"confidence": {
"title": 0.493408,
"sections": 0.821642,
"figures": 0.760547,
"tables": 0.707704
},
"cost": {
"textractPages": 19,
"textractFeatures": ["LAYOUT", "TABLES"],
"bedrockModel": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"bedrockInputTokens": 33346,
"bedrockOutputTokens": 9371
},
"durationMs": 159518,
"warnings": [
"figure-1: キャプションを特定できなかった", // ユーザ定義のメッセージ
"figure-2: キャプションを特定できなかった"
]
}
}
保存できたら SendTaskSuccess にタスクトークンと結果の S3 キー (resultKey、rawKey) を渡します。これで待っていた Task が再開し、finalizer (Lambda) に進む流れにしています。
SendTaskSuccess で返す出力のサンプル (待っていた Task の出力になる)
{
"jobId": "{jobId}",
"resultKey": "work/{jobId}/textract/document.json",
"rawKey": "work/{jobId}/textract/raw.json"
}
返すのは S3 のキーだけで、文書 JSON の本体は渡しません。
Step Functions の State 間で受け渡せるデータは 256 KB までのため、本体は S3 に置き、場所だけを返す形にしています。
ここで textract-parser (Lambda) の仕事は終わりです。Task が再開すると Parallel の経路 A 側が完了し、経路 B と合流したあと、返したキーを手がかりに finalizer (Lambda) が動きます。
finalizer (Lambda) が経路 A の結果を正規化・検証して保存する
Parallel を抜けると finalizer (Lambda) に進みます。finalizer は両経路の結果をまとめて処理する Lambda ですが、ここでは経路 A の結果 (resultKey が指す document.json) がどう扱われるかだけを追います。経路 A の結果に対しては次のことをします。
document.json を読み、表記のゆれを正規化する (空白、番号付き見出しの階層、DOI の表記、年の妥当性、欠落した配列の形といった機械的なゆれだけ。抽出内容の差は比較対象なので触らない)provenance.confidence はこの結果で再計算されるoutputs/{jobId}/result-textract.json に書く※ここでの正規化の目的は、経路 A と B の結果を同じ形で比べられるようにすることと、後段の検証に誤った形の値 (URL 付きの DOI など) を渡さないことを目的としています
※Crossref: 学術文献の DOI を登録・管理している機関。登録済みのメタデータを公開 API (REST API) で検索でき、参考文献が実在するか・DOI は何かを確かめる照合先として使っています
outputs/{jobId}/result-textract.json のサンプル (長いため各配列は 1 件に絞り、本文は短縮)
{
"jobId": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"schemaVersion": "1.0",
"source": {
"bucket": "dev-folio-documents-{accountId}",
"key": "uploads/93029b04.../original.pdf",
"sha256": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"language": "en",
"pageCount": 19,
"hasTextLayer": true,
"uploadedAt": "2026-08-22T05:21:22Z"
},
"metadata": {
"title": "AI4AI-Bench: Benchmarking LLM Agents in Algorithmic Design for Recursive Self-Improvement",
"authors": [{ "name": "Yizhe Chi", "affiliation": "Navers Lab, Einsia.A" }, ...],
"abstract": "...",
"venue": "arXiv",
"year": 2026
},
"sections": [
{ "level": 1, "heading": "Introduction", "text": "Recursive self-improvement (RSI) rests on a loop: ...", "pages": [1, 2, 3] },
...
],
"figures": [
{ "id": "figure-1", "caption": "", "page": 1, "bbox": [0.786386, 0.595519, 0.808832, 0.611191] },
...
],
"tables": [
{ "id": "table-1", "caption": "", "page": 4, "bbox": [ ... ], "header": [ ... ], "rows": [ ... ] },
...
],
"references": [
{
"raw": "[1] Marcin Andrychowicz, Misha Denil, Sergio Gómez, ...",
"title": "Learning to learn by gradient descent by gradient descent",
"authors": ["Marcin Andrychowicz", "Misha Denil", "..."],
"year": 2016,
"venue": "Advances in Neural Information Processing Systems",
"doi": null
},
...
],
"provenance": {
"route": "textract",
"extractedAt": "2026-08-22T05:24:28.438854939Z",
"confidence": { "title": 0.7467, "sections": 0.909, "figures": 0.8803, "tables": 0.8383, "references": 0.9992 },
"cost": {
"textractPages": 19,
"textractFeatures": ["LAYOUT", "TABLES"],
"bedrockModel": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"bedrockInputTokens": 33346,
"bedrockOutputTokens": 9371
},
"durationMs": 159518
}
}
ここまでが、Textract の起動から最終成果物の保存までがすべて成功したときの流れです。実際には各ステップに失敗する箇所があり、それぞれ扱いが違うため、ステップ 8 でまとめて書きます。
また、経路 B の結果との比較 (comparison.json)、レビュー要否の判定、DynamoDB の状態更新は両経路が揃ってからの話なので、次の記事で書きます。
ステップ 1〜7 は正常系、つまり成功時の流れに焦点を当てていましたが、失敗時は「Textract を起動できない」「通知が食い違う」「Textract のジョブ自体が失敗する」「Bedrock や保存で失敗する」「Task がもう待っていない」など、発生要因、場所がばらばらで、対処もそれぞれ違います。
各セクションごとに明記すると流れが追いにくくなるため、検証中に検討していたもの、及び発覚し対策を講じたものをここにまとめたいと思います。
If any branch fails, because of an unhandled error or by transitioning to a Fail state, the entire Parallel state is considered to have failed and all its branches are stopped. If the error is not handled by the Parallel state itself, Step Functions stops the execution with an error.
callback.json と食い違う場合は古い通知として無視するようにしています。TextractJobFailed と、結果の取得・構造化・保存のどこかで失敗した ExtractFailed の 2 つ失敗の種別と理由は finalizer (Lambda) が comparison.json の routes.textract に残します。
{
"jobId": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"finalizedAt": "2026-08-22T05:26:39.698778064Z",
"status": "COMPLETED",
"needsReview": false,
"routes": {
"textract": {
"status": "succeeded",
"resultKey": "outputs/93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc/result-textract.json",
"needsReview": false,
"durationMs": 159518,
"cost": {
"textractPages": 19,
"textractFeatures": ["LAYOUT", "TABLES"],
"bedrockModel": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"bedrockInputTokens": 33346,
"bedrockOutputTokens": 9371
},
"warnings": [
"figure-1: キャプションを特定できなかった",
"figure-2: キャプションを特定できなかった",
"verify: Crossref で照合できなかった参照文献がある: notFound=45 unavailable=0"
],
...
}
},
"report": { ... },
"diff": { ... }
}
}
ステップ 5 (要素のテキストを Bedrock に渡して構造を返させる処理) は、論文 1 本分を 1 回の Bedrock 呼び出しで扱う設計です。
最初はこの応答に本文と参考文献の原文も含めて書かせていました。出力上限 (maxTokens) は 8,192 にしていました。
変更前のモデル出力のサンプル (値はすべて例示)。※ sections[].text に節の本文を、references[].raw に参考文献の記載をそのまま書き写させていました。
{
"sections": [
{
"level": 1,
"heading": "Introduction",
"text": "Large language models have emerged as a general modeling framework ... (節の本文を全文そのまま)",
"pages": [1, 2]
},
...
],
"references": [
{
"raw": "[1] B. Author and C. Author. An Example Reference Title. In Proceedings of an Example Conference, pages 1-10, 2016.",
"title": "An Example Reference Title",
"authors": ["B. Author", "C. Author"],
"year": 2016,
"venue": "Proceedings of an Example Conference",
"doi": null
},
...
]
}
19 ページ・参考文献約 60 件の論文を初めて通したところ、応答がこの上限で途中で切れ、JSON として読めずに失敗しました。当時のコードは応答の StopReason (max_tokens で止まったかどうか) を見ていなかったため、エラーは「JSON が途中で終わっている」としか出ず、上限に当たったことがログからも分からない状態でした。
1 回目の対処は、検出と引き上げです。
これで再投入すると、検出は狙いどおり動いたものの、24,576 でも上限に達して失敗しました (生成に 491 秒かかったうえで失敗)。
本文を書き出させる限り出力はページ数に比例して増え、19ページの論文で約 28〜30K トークン、さらに多い 25〜28 ページの論文では約 37〜42K トークンが必要と見積もれました。さらに 36 トークン/秒では 32,768 トークンの生成に約 910 秒かかり、先に Lambda の timeout になってしまいます。加えて、imeout で落ちると SNS 経由の非同期起動は既定で 2 回再試行されることで Bedrock の課金も増えるため、上限を上げる方向では解決は難しいと判断しました。
2 回目の対処が、本文をモデルに書かせないことです (ステップ 5 の形)。
本文はすでに Textract が読み取り、ステップ 4 で読み順に並べてあるので、モデルには「節の見出しと、本文が #何番から #何番までか」と「参考文献が #何番か」だけを返させ、本文と参考文献の原文は Go が要素から組み立てます。これで出力は書誌 + 見出し + 範囲 + 参考文献の分解だけになり、ページ数にほぼ依存しない構成になります。
この対策がなぜ効くのかについてもう少し補足すると、モデルに渡すテキストには論文の本文がすべて含まれているにも関わらず、変更前は応答でも同じ本文を書き写させていたことが無駄にトークを消費していたものと予想しました。
モデルが新しく判断しているのは「どこからどこまでが節か」だけで、文章そのものは入力にあるものと同じものであり、出力トークンは 1 語ずつ逐次生成されるうえに maxTokens の上限に数えられるため、出力トークンは本文の長さ、つまりページ数に比例して増えていく状態になっていることがわかりました。

小さな例にするとこうです。
入力 (プロンプトに含まれる)
[#2 LAYOUT_SECTION_HEADER] 1 Introduction
[#3 LAYOUT_TEXT] Large language models have emerged as a general modeling framework ...
[#4 LAYOUT_TEXT] Many modern AI systems, in contrast, represent information using ...
変更前の出力 (モデルが本文を書き写す)
{ "heading": "Introduction", "text": "Large language models have emerged as a general modeling framework ... Many modern AI systems, in contrast, represent information using ..." }
変更後の出力 (位置だけ返す)
{ "heading": "Introduction", "from": 3, "to": 4 }
モデルが新しく判断している情報は「#3〜#4 が Introduction の本文」という境界だけで、文章そのものは入力にあるものと同じです。それを出力で書き直させると、次の 3 点で損をすることがわかりました。
これが、text と raw の代わりに from / to と element を返させるようにした理由です。
同じ論文での結果は次のとおりです。

図のとおり、同じ 19 ページの論文で次のように変わりました。
result-textract.json が生成されるようになった。経路 A は「文字と配置は Textract、構造の判断は Bedrock、組み立ては Go」と役割を分けた構成でした。
モデルに任せる範囲を「内容を読まないと決められないこと」だけに絞ったことで、出力の上限と生成時間の問題を避けられました。
その3 では経路 B (ページ画像を Bedrock に直接読ませる) について書きます。経路 B で出た JSON の壊れと型の崩れ (tool use と strict に切り替えた経緯) は、経路 A にも同じ対応を入れているため、その4 で単独に扱います。
AWS AIPの知見を活かし、論文PDFを構造化JSONに変換するパイプラインを構築。S3、Lambda、Step Functions、Textract、Bedrockを組み合わせ、2つの抽出経路で日本語・英語PDFを処理し、メタデータ付きの構造化データをRAGのソースとして活用する仕組みを実装。
論文PDFを構造化JSONに変換するパイプラインの経路B(画像をBedrockに直接読ませる方式)について、Step Functionsでページを並列処理し、各Lambdaがページ画像からモデルで構造化データを抽出、S3に保存、最後にfinalizerで結合・正規化・検証する流れを詳説。
論文PDF処理パイプラインの最終段階について解説。両経路の結果をまとめるfinalizer Lambdaが、comparison.jsonを生成してDynamoDBを更新する仕組みと、経路ごとの状態管理、レビュー要否の判定ロジックを説明しています。
AppSync Events を使用したイベント駆動アーキテクチャの実装をCDKで定義し、WebSocket通信によるリアルタイムイベント配信の構成と動作確認を紹介。
AppSync Events を用いたイベント駆動型Webアプリケーションの実装例を紹介。API Lambda、Event Lambda、フロントエンドの実装コードと、AppSync Events への Publish 時の JSON 二重シリアライズやAPI キー認証などの注意点を解説。