その1 では全体構成、その2 では経路 A (Textract で読み取り、Bedrock で整える) を書きました。この記事では経路 B (ページ画像を Bedrock に直接読ませる) について書きます。
経路 B は「ページの画像を 1 枚渡して、そこに何が印字されているかをモデルに答えさせる」だけの経路です。文字を読むのも、見出しか本文かを決めるのも、表を行列に戻すのも、すべて 1 回のモデル呼び出しの中で起きます。経路 A が Textract と Bedrock と Go に分担させていた仕事を、モデルが一体で引き受ける形です。
この経路が要るのは、Textract が日本語に対応していないためです。画像をそのまま読ませる方法なら言語を選ばないので、日本語 PDF はこの経路だけを通ります。
一方、英語 PDF は両方の経路を通せます。分担の仕方が違えば出力の形も詰まる箇所も変わるはずで、それを同じ PDF で見たかったため、Choice で片方を選ぶのではなく Parallel で並走させています。

| Lambda | 役割 |
|---|---|
validator | PDF の妥当性判定と冪等性チェック |
preprocessor | ページ画像 (PNG) とテキストレイヤーの生成、言語判定 |
textract-parser | 経路 A: Textract で読み取り、Bedrock で整える ※今回は触れない (その2 で扱った) |
bedrock-parser | 経路 B: ページ画像を Bedrock に直接読ませる (ページごとに並列) |
finalizer | 正規化、検証、結果の保存 |

bedrock-parser (Lambda) を並列に呼ぶbedrock-parser (Lambda) がページ画像と指示文を Bedrock に送るbedrock-parser (Lambda) がページ単位の結果を S3 に保存するfinalizer (Lambda) がページ結果を 1 つの文書 JSON に結合するfinalizer (Lambda) が正規化・検証して result-bedrock.json を書く以下、ステップごとに詳細を書いていきます。

前段の preprocessor (Lambda) が、PDF をページごとの PNG にして work/{jobId}/pages/page-NNNN.png に置き、総ページ数 (pageCount) を確定させています。経路 B
はここから始まり、そのページ画像を 1 枚ずつモデルに読ませていきます。
ページをまたぐ処理は無く、1 ページの中で完結する読み取りをページ数だけ繰り返すことになるため、Step Functions の Map で 1 ページ 1 起動の並列にしています。
ステートマシンの定義 (経路 B の Map の部分) は以下です。
{
"BedrockPages": {
"Type": "Map",
"Items": "{% [1..$pageCount] %}",
"ItemSelector": {
"jobId": "{% $jobId %}",
"page": "{% $states.context.Map.Item.Value %}",
"language": "{% $language %}"
},
"MaxConcurrency": 5,
"ItemProcessor": {
"ProcessorConfig": { "Mode": "INLINE" },
"StartAt": "BedrockPage",
"States": {
"BedrockPage": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Arguments": {
"FunctionName": "${bedrock_parser_arn}",
"Payload": "{% $states.input %}"
},
"Output": "{% $states.result.Payload %}",
"TimeoutSeconds": 660,
"End": true
}
}
},
"Output": { "pages": "{% $count($states.result) %}" }
}
}
判断が入っているのは、繰り返す対象、1 起動の粒度、並列数の 3 つです。
Items は [1..$pageCount] で、1 から総ページ数までの数値の配列です。ページ画像の S3 キーの一覧は作らず、Lambda 側が jobId とページ番号からキーを組み立てます。
work/{jobId}/pages/page-NNNN.png の 4 桁ゼロ埋め) を知っているのは Go のコード 1
箇所だけになります。ステートマシンの定義に文字列で書き写すと、変えたときに 2 箇所を直すことになります。Map の 1 反復に渡す入力は ItemSelector で組み立てています。ここで jobId と language を添えているのは、Lambda がページ番号だけでは画像の場所も指示文の言語も決められないためです。
$states.context.Map.Item.Value (今が何番目の反復かを示す値) を読めるのは ItemSelector の中だけで、ItemProcessor の中の State からは参照できません。そのため
1 反復ぶんの入力はここで完成させ、ItemProcessor の Task は受け取った入力をそのまま Lambda に渡すだけにしています。
bedrock-parser に渡される入力は結果としてこの形になります。
{
"jobId": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"page": 7,
"language": "en"
}
1 起動で複数ページをまとめて処理する形も取れますが、1 ページに固定しています。
State 間で受け渡せるデータが 256KB までであることも、この形と噛み合っています。画像そのものは Step Functions を通らず、各 Lambda が S3 から読みます。
MaxConcurrency を 5 にしています。ページ数ぶんを一斉に起動すると Bedrock の同時実行数の上限に当たってスロットリングされるためです。
同時実行の抑制は Map の MaxConcurrency と、Lambda の中の指数バックオフの 2 つで持たせています。後者はモデルを呼ぶ側の話なので、ステップ 2「bedrock-parser (Lambda) がページ画像と指示文を Bedrock に送る」で書きます。
Output は { "pages": N } だけです。ページごとの抽出結果は各 Lambda が S3 に書いており、finalizer (Lambda) はそこから読み直します。ここでも 256KB の制限を避けるため、実体は S3 に置き、Step Functions には件数だけを返しています。
ここまでで、ページ番号と jobId を受け取った bedrock-parser (Lambda) がページごとに並列で立ち上がりました。
次のステップでは、そのうちの 1 つが自分の担当ページの画像をどうモデルに渡しているかを見ていきます。

ステップ 1 で、bedrock-parser (Lambda) は jobId とページ番号と言語を受け取って起動しました。ここからが 1 ページ分の本体で、画像を取ってきて、モデルに渡し、返ってきた構造を受け取るまでを 1 回の起動で行います。
Lambda は受け取った jobId とページ番号から work/{jobId}/pages/page-NNNN.png を組み立て、その 1 枚だけを取得します。画像は前段の preprocessor (Lambda) が 150 DPI で描いたものです。
この解像度は、精度と入力トークンの兼ね合いで決めています。A4 (210 x 297 mm) を 150 DPI で描くと 1240 x 1754 px になり、Claude が高解像度の画像として受け取れる上限 (長辺 2576 px) に収まるため、モデル側で縮小されません。200 DPI にすると画素数が約 1.8 倍になり、画像のトークンもそれに比例して増えます。
キーが存在しない場合は再試行しても直らないため、InvalidInputError として返し、Step Functions の Retry の対象から外しています。
送るのは、役割と規則を書いた指示文 (システムプロンプト)、ページ画像 1 枚、そのページが何ページ目かを伝える 1 行、そして出力の形を決めるツールの定義です。
指示文は英語で書いています。経路 B には日本語 PDF も流れてくるので経路 A とは事情が違うのですが、それでも英語にしているのは、指示を日本語で書くと出力の言語がそちらに引きずられ、英語論文の本文が日本語に訳されて返ることがあるためです。あわせて「原本の言語のまま転記し、翻訳しない」を規則として明示しています。
前段の preprocessor (Lambda) が判定した言語は、画像に添える 1 行でヒントとして渡します。
This image is page 7 of the document. Extract it with the extract_page tool.
The document is written in "en" (ISO 639-1), so transcribe it in that language.
指示文の規則は、ページ 1 枚を見たときに迷う箇所を潰すために書いています。抜粋すると次のようなものです。
- Never invent a value. Omit an optional key you cannot fill.
A required key with nothing to record takes "" or [].
- "title", "authors", "abstract" and "keywords" describe the paper as a whole.
Fill them only on the page that prints them, which is normally the first page.
- "sections" lists the body blocks of this page in reading order.
Start a new entry at every heading.
- "sections[].text" holds running text only.
Leave out captions, running headers, page numbers and footnotes.
- "tables[].header" is the array of header rows, so a two level header has two entries.
Repeat a merged cell value into every cell it spans.
書誌を「印字されているページでだけ埋める」と決めているのは、経路 B が 1 ページずつ独立に走るためです。どのページのモデルも紙面しか見ていないので、表紙にしか出てこない題名や著者を他のページで埋めさせると、推測で書かれた値が混ざります。
このページの章が前ページの続きかどうかは、モデルには真偽値 1 つで答えさせるだけにしています。実際に繋ぐのは全ページが揃ってからで、ステップ 4「finalizer (Lambda) がページ結果を 1 つの文書 JSON に結合する」でコードが行います。
continuesPreviousSection: ページの冒頭に見出しの無い本文があるとき、それが前ページの章の続きなら true。ページが見出しで始まるときはキーごと省くcontinuesPreviousReference: 参考文献の 1 件目が、前ページの末尾で切れた 1 件の続きなら trueこの 2 つは既定を逆にしています。continuesPreviousSection はキーが無ければ「続き」とみなします。本文がページをまたぐのは普通のことで、明示が無いときに分断する方が誤りが増えるためです。continuesPreviousReference はキーが無ければ「続きではない」とみなします。参考文献はページの先頭から新しい 1 件が始まる方が普通で、既定で繋ぐと別々の文献が 1 件に潰れてしまいます。
応答は tool use で受け取ります。extract_page というツールを 1 つだけ渡し、その入力の JSON Schema が出力の形そのものになります。この形にした経緯 (自由文で JSON を書かせていたときに何が起きたか) は、次の記事で書きます。
スキーマは型だけを持ち、値の意味は指示文の規則の方に書いています。同じことを 2 箇所で言うと、食い違ったときにどちらが効いているのか分からなくなるためです。
var pageTool = &bedrock.ToolSpec{
Name: "extract_page",
Description: "Record the structured content of one page of an academic paper.",
Schema: object(map[string]any{
"title": str,
"authors": array(object(map[string]any{"name": str, "affiliation": str, "email": str}, "name")),
"abstract": str,
"keywords": array(str),
"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")),
"tables": array(object(map[string]any{
"label": str,
"caption": str,
"header": array(array(str)),
"rows": array(array(str)),
}, "label", "caption", "header", "rows")),
"references": array(object(map[string]any{
"raw": str,
"title": str,
"authors": array(str),
"year": integer,
"venue": str,
"doi": str,
}, "raw")),
"continuesPreviousSection": boolean,
"continuesPreviousReference": boolean,
}, "sections", "figures", "tables", "references"),
}
必須にしているのは、どのページにも必ず値があるキーだけです。sections figures tables references は該当が無くても [] で返させ、書誌のように「そのページには無いのが普通」のキーは省略できるようにしています。空文字で埋めさせると、後段から見て「無かった」のか「読めなかった」のか区別が付かなくなります。
モデルが返す JSON はこの形になります。※値はすべて例示
{
"sections": [
{
"level": 1,
"heading": "3 Method",
"text": "We formulate the task as a sequence of decisions ..."
},
{ "text": "Each candidate is then scored by the evaluator ..." }
],
"figures": [{ "label": "Figure 2", "caption": "Overview of the proposed pipeline." }],
"tables": [],
"references": [],
"continuesPreviousSection": true
}
maxTokens は 8,192、temperature は 0 です。
maxTokens は 1 ページ分に対する上限です。表を含むページでも 1 ページの JSON は数千トークンに収まるので、余裕を見た固定値にしています。経路 A が同じ値で足りず引き上げることになったのは、論文 1 本ぶんを 1 回の応答で返させていたためで、ページ単位で切ってあるこちらでは上限の意味が違います。
temperature は 0 にして、同じページ画像からは毎回同じ結果が返るようにしています。
Bedrock には同時実行数の上限があり、並列で走っている他のページと重なると ThrottlingException が返ります。この再送は Lambda の中の Bedrock を呼ぶ層が担っており、初回を含めて最大 5 回、待ち時間を 0.5 秒から倍にしながら上限 20 秒まで広げます。
待ち時間には full jitter を掛けて散らしています。Map で並列に起動された 5 つの Lambda が同時にスロットリングされた場合、同じ待ち時間で一斉に送り直すとまた同時に当たるためです。
再送してもスロットリングが続いた場合や、他の理由で失敗した場合の扱いは、次の記事でまとめて書きます。
モデルの応答から組み立てた結果のうち、ページ番号だけはモデルの答えを使わず、起動時に渡された番号で上書きしています。紙面に印字されたノンブルと PDF 上のページ番号がずれている論文があり (表紙や目次を数えない、章ごとに振り直すなど)、モデルはどうしても印字された数字に引きずられるためです。ページ番号は後段の並べ替えと欠落の検出に使う値なので、ここが揺れると結合が崩れます。
あわせて、使ったモデル ID と入出力のトークン数もこの時点で結果に添えます。ページごとの使用量は最後に合算して provenance.cost に載ります。
ここまでで、1 ページ分の構造化された結果が Lambda の手元にできました。次のステップで、これを S3 のどこにどういう形で置くかを見ていきます。

ステップ 2 で、1 ページ分の構造化された結果ができました。これを S3 に置き、Step Functions には置いた場所だけを返して、この起動は終わります。
結果は work/{jobId}/bedrock/page-NNNN.json に 1 ページ 1 ファイルで書きます。
work/{jobId}/
├── pages/
│ └── page-0007.png ページ画像 (preprocessor が作る)
└── bedrock/
├── page-0007.json ページ単位の抽出結果
└── page-0009.error.json 解釈できなかった応答の控え (失敗したページにだけできる)
ページ番号は画像と同じ 4 桁のゼロ埋めにして、キーの辞書順とページ順を揃えています。桁が揃っていないと page-10.json が page-2.json より前に並び、一覧したときの順番がページ順と違ってしまいます。4 桁にしているのは、Textract の非同期処理が扱えるページ数の上限 (3,000 ページ) に 3 桁では届かないためです。
抽出結果そのものだけでなく、いつ終わったかとどれだけかかったかを包んで保存します。
{
"jobId": "93029b04f8612f4bfdd221a6f726e86a2d1408cab0a3256866f164e3c3749dfc",
"page": 7,
"extractedAt": "2026-08-22T05:23:41.882374Z",
"durationMs": 6180,
"result": {
"page": 7,
"sections": [ ... ],
"figures": [ ... ],
"tables": [ ... ],
"references": [ ... ],
"continuesPreviousSection": true,
"modelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"usage": { "inputTokens": 2417, "outputTokens": 1043 }
}
}
durationMs は画像の取得から抽出の完了まで、extractedAt は保存直前の時刻です。この 2 つをページごとに残しているのは、経路 B 全体の所要時間を後から復元するためです。経路 A は textract-parser (Lambda) が開始から保存までを 1 つの Lambda の中で測れますが、経路 B には経路全体を測っている場所がありません。ページごとの Lambda は自分の 1 ページしか知らないためです。
ページは並列で走るので、各ページの所要時間を足し合わせても実際の経過時間にはなりません。並列度を変えても合計は変わらないので、経路 A の壁時計時間とは並べられなくなります。
完了時刻から所要時間を引けばそのページの開始時刻が戻るので、後段の finalizer (Lambda) は「最も早い開始から最も遅い完了まで」を経路 B の所要時間として算出できます。これで両方の経路の所要時間が同じ意味の数字になり、provenance.durationMs と comparison.json に並べて載せられます。
modelId と usage を添えているのも同じ理由で、ページごとの入出力トークン数を合算して provenance.cost に載せます。その場で集計せずに持たせておくのは、ページごとの Lambda が互いの結果を知らないためです。
モデルの応答を結果の型に読めなかった場合は、生の応答を page-NNNN.error.json に書いてから失敗を返します。
同じ応答をもう一度再現する手段が無い点と、同じ画像を送り直しても次は違う応答が返る可能性があり、残しておかなければ「何が返ってきて読めなかったのか」を後から調べられない点が理由です。
キーを page-NNNN.json と別にしているのは、後段がページ結果を拾うときに混ざらないようにするためです。
Lambda が返すのは、どのページの結果をどこに置いたかだけです。
{
"jobId": "{jobId}",
"page": 7,
"resultKey": "work/{jobId}/bedrock/page-0007.json"
}
抽出結果の本体を返さないのは、Step Functions の State 間で受け渡せるデータが 256KB までだからです。本文を含むページ結果をページ数だけ Map が集めると、数十ページの論文で簡単に超えます。
さらに Map 全体の出力は件数だけに畳んでいます (ステップ 1「Step Functions の Map が 1 ページ 1 起動で bedrock-parser (Lambda) を並列に呼ぶ」)。キーも集めないのは、後段が jobId とページ番号から同じキーを組み立てられるためです。
{ "pages": 19 }
ここまでで、ページごとの結果が S3 に並びました。この時点ではまだ、ページをまたぐ章も参考文献も分断されたままです。次のステップで、これを 1 つの文書に戻す処理を見ていきます。

全ページの Map が終わると Parallel を抜け、finalizer (Lambda) に進みます。ここからはページをまたいだ仕事になります。
この段階で手元にあるのは、紙面 1 枚だけを見て書かれた結果がページ数だけです。章はページの境目で切れており、参考文献も 1 件が途中で分断されています。これを 1 つの文書に戻します。
Map からは件数しか受け取っていないので、finalizer (Lambda) は jobId とページ番号からキーを組み立て、 1 ページ目から pageCount までを順に読みます。存在しないキーはエラーにせず飛ばします。そのページは失敗しており、ここで読み直しても現れないためです。
結合の前に、ページ結果を結合できる並びに整えます。
これらはどれもエラーにせず先に進みます。揃ったページだけでも結果は使えるので、一部が欠けただけで全体を捨てないという判断です。 ただし、何が起きたかは残します。除外や欠落を見つけると、次の形の文を組み立てて provenance.warnings に追記します。この配列はこの先も持ち回され、成果物の result-bedrock.json と、両経路をまとめる comparison.json にそのまま載ります。
以下は実際に出力されるメッセージです。
ページの抽出結果が欠落したまま結合した: page=9
同じページの結果が重複したため後着を除外した: page=12
※ページ番号は例 (あくまでも私が後から経緯を辿りやすいようにするための記録、この文字列を後工程が解析することはありません)
欠落したページ数がレビュー要否にどう効くかは、次の記事で書きます。
章の結合は、見出しの有無とフラグだけで機械的に決めます。規則は 3 つです。
continuesPreviousSection が欠落または true なら直前の章に連結し、false なら見出しなしの章を始める連結したときは、その章の pages にページ番号を追加します。最終的な sections[].pages はこうして埋まります。
ここでモデルを呼び直していないのは、判断に必要な材料がすでに揃っているからです。呼び直せばコストも、同じ入力でも結果が揃わない可能性も、ページ数に比例して増えます。
参考文献も同じで、ページ先頭の 1 件だけが連結の候補になります。continuesPreviousReference が true なら、前のページの末尾の 1 件に raw を繋いで 1 件に畳みます。2 件目以降はそのページで完結しているのでそのまま並べます。
畳むときは、断片側が持っている項目で、前半に無かったものだけを埋めます。前半に著者と題名が載り、DOI だけが次のページに回る並びがあるためです。
分断された本文や raw を繋ぐときは、境目の文字を見て空白の入れ方を変えています。前の末尾と後の先頭がどちらも半角なら間に空白を 1 つ入れ、どちらかが全角なら何も入れません。英文は語の切れ目に空白が要り、日本語は入れると不自然な隙間になるためです。
書誌 (題名・著者・要旨・キーワード) は、ページ順に見て先に見つかった値を採ります。通常は表紙で揃いますが、表紙のページが欠落した場合でも後続のページが拾えていればそちらを使えます。
図と表はページ順に並べるだけで、ページ番号を添えます。経路 B では座標 (bbox, Textract が返す値) を取得できないので、そのキーは落ちます。表の header と rows は該当がなくても null にせず空配列に揃えます。経路間で差分を取るときに、キーの型が揃っていないと比べにくいためです。
ページごとの入出力トークン数はここで合算し、provenance.cost に入れます。Textract のページ数と機能は経路 B では発生しないので空のままです。
ここまでで、ページに分かれていた結果が 1 つの文書に戻りました。形は経路 A と同じですが、まだ正規化も検証もしていない中間結果です。次のステップでこれを成果物にします。

ステップ 4 でページが 1 つの文書に戻りました。ただしこれはモデルの答えを繋いだだけの中間結果で、表記のゆれも残っていれば、値が原本にあるかも確かめていません。
ここで正規化と検証を通し、成果物にします。この 2 段は経路 A と共通で、同じコードが同じ順に走ります。経路ごとに後処理を変えると、出力の違いが抽出の違いなのか後処理の違いなのか分からなくなるためです。
揃えるのは、人が見れば同じと判断できるものだけです。
doi: の接頭辞を落とす)null ではなく空配列に揃える逆に、抽出内容の差には手を入れません。座標の有無、表の崩れ方、読み順、著者名の書き方などは比較の対象そのものなので、ここで揃えてしまうと違いが見えなくなります。
判断できなかったことは normalize: の接頭辞付きで警告に残します。ステップ 4 で出た欠落の警告と混ざらないようにするためです。
モデルの自己申告より、原本や外部との突合の方が信頼できる、という方針です。使う根拠は 3 種類あります。
原本との突合に使うのは、preprocessor (Lambda) が出した work/{jobId}/text/layer.txt です。経路 B は画像しか見ていないので、ここで初めて PDF の持っている文字と突き合わされます。
突合は完全一致ではなく、合字 (fi など) の展開や行末ハイフネーションの結合といった、PDF のテキストレイヤーに残りやすい表記のゆれを畳んでから行います。
一致度が低い値は、表記のゆれではなく「原本に無い値」とみなして記録します。どの項目のどの値がどれだけ一致しなかったかをレポートに残す形で、この段階で値を書き換えたり削ったりはしません。
信頼度は項目ごと (題名、著者、要旨、章、図、表、参考文献) に 0〜1 で出します。
ここで注意が要るのは、経路 B には Textract の確信度が無いことです。無い根拠を 0 として平均に含めると、経路 B の信頼度が不当に低く出ます。そこで、無い根拠は平均から除外し、ある根拠だけで平均を取ります。根拠が 1 つも無ければ null のままにし、ゼロとは区別します。
いずれかの項目が閾値 (暫定で 0.7) を下回ればレビュー要とします。
bbox) を持たないので、図表のキーからは落ちるreferences が 0 件のままでも、検証は「疑わしい値が無い」としか言えないため正規化と検証を通した結果を outputs/{jobId}/result-bedrock.json に書きます。経路 A の result-textract.json と同じ形で、経路名と、経路 B では埋まらないキーが違うだけです。
{
"jobId": "93029b04...",
"schemaVersion": "1.0",
"source": { ... },
"metadata": { ... },
"sections": [ ... ],
"figures": [{ "id": "Figure 2", "caption": "...", "page": 7 }],
"tables": [ ... ],
"references": [ ... ],
"provenance": {
"route": "bedrock",
"extractedAt": "...",
"durationMs": 63216,
"confidence": { "title": 0.92, "sections": 0.88, "references": 0.74 },
"cost": {
"bedrockModel": "us.anthropic.claude-...",
"bedrockInputTokens": 33726,
"bedrockOutputTokens": 11711
},
"warnings": ["ページの抽出結果が欠落したまま結合した: page=9", "verify: ..."]
}
}
※値は例示。図に bbox が無いのが経路 A との違い
検証の詳しい根拠 (項目ごとの内訳、原本に見つからなかった値、Crossref の照合結果) はこのファイルには入れず、次の記事で扱う comparison.json にまとめます。後工程に渡すのはこの成果物だけで、検証の経緯は別のファイルに分けるという分け方です。
ここまでで経路 B の成果物が揃いました。両経路を並べた comparison.json と DynamoDB の状態更新は、次の記事で書きたいと思います。
経路 B は、経路 A とは逆に、モデルに任せる範囲を広く取った構成でした。文字を読むのも、見出しか本文かを決めるのも、表を行列に戻すのも、ページ画像 1 枚に対する 1 回の呼び出しの中で起きます。
とはいえ全部を任せているわけではなく、分担は 1 つだけ決めています。そのページを見れば決まることはモデルに答えさせ、ページをまたぐ判断はコードでやる、という分け方です。章が前ページの続きかどうかもモデルには真偽値 1 つで答えさせるだけにして、実際に繋ぐのは全ページが揃ってからにしました。この形にしたことで、1 ページ 1 起動の並列がそのまま成り立ち、失敗の単位もページに収まっています。
もう 1 つの特徴は、この経路が画像しか見ていないことです。紙面に書かれていない値が混ざっても、この経路の中では気づけません。原本のテキストレイヤーと突き合わせる検証は、経路 A よりもこちらで効いてきます。
次の記事では、両経路を並べた comparison.json と状態の決まり方を書きます。あわせて、ここに至るまでに踏んだ事象として、モデルに JSON を返させる部分で起きた壊れと型の崩れ (tool use と strict に切り替えた経緯) を扱いたいと思います。
AWS AIPの知見を活かし、論文PDFを構造化JSONに変換するパイプラインを構築。S3、Lambda、Step Functions、Textract、Bedrockを組み合わせ、2つの抽出経路で日本語・英語PDFを処理し、メタデータ付きの構造化データをRAGのソースとして活用する仕組みを実装。
論文PDFを構造化JSONに変換するパイプラインの経路A(Textract+Bedrock)について、各ステップの設計理由と実装を詳細に解説。出力上限問題への対処として、モデルに本文を書かせず位置情報のみ返させることで、生成時間を半減させた改善事例を紹介。
論文PDF処理パイプラインの最終段階について解説。両経路の結果をまとめるfinalizer Lambdaが、comparison.jsonを生成してDynamoDBを更新する仕組みと、経路ごとの状態管理、レビュー要否の判定ロジックを説明しています。
Bedrock cross-region inference profileの動的ルーティングにより、大阪リージョンでモデルアクセスが未設定だったため、リージョン間で交互に成功・失敗するエラーが発生。大阪でのモデルアクセス申請とLambda IAM権限追加で解決した。
VS Code の PostgreSQL 拡張機能を使用して、ベクトル検索を用いた関連記事レコメンド機能の SQL をEXPLAIN分析し、CTE を活用して絞り込みを早期化することで実行時間を約65%削減した改善事例。