「文書を全部 LLM に渡せば検索は要らない」
コンテキストに入る量には上限があり、入るとしても入力トークンの料金と時間が質問ごとにかかります。設計書 1 式で数十万トークンになり、質問のたびにその分を払うことになります。検索して 6 件に絞るから、毎回 3,000 トークンで済みます。
DBC Tech Academy / AI / 中級
「この列はどの処理で更新される?」「この外部キーを外すと何が壊れる?」。設計書に書いてあるのに、探すより人に聞くほうが早い質問が現場にはあります。この講座では、DB 設計書とテーブル定義書を取り込み、質問に対して該当箇所を検索し、根拠を示して答える仕組みを、Aurora PostgreSQL と Bedrock だけで組みます。文書は VPC の外に出ません。
答えの各文に、どの設計書のどの見出しを読んで書いたかが付きます。読み手が原典を開いて確かめられることが、この仕組みの信頼の土台です。
What you will have
この講座は、考え方ではなく動くものを持ち帰るための手順書です。最初に、到達点をそのまま示します。
ingest は設計書を読み、見出しと表の構造で分割し、埋め込みを計算して Aurora PostgreSQL に保存します。ask は質問を埋め込み、ベクトル検索と全文検索を合わせて上位のチャンク (chunk) を取り、Bedrock の Claude に根拠として渡して答えさせます。
15.x 以上。pgvector で近傍検索、pg_bigm で日本語と識別子の全文検索を担います。既存の DB にそのまま同居できます。
埋め込みと生成だけを任せます。VPC エンドポイント経由で呼ぶため、文書も質問もインターネットに出ません。
テーブル定義書・バッチ設計書・業務処理設計書の 3 種。表と見出しが多い文書で、分割の判断がそのまま効きます。
How it works
RAG(Retrieval-Augmented Generation)は、質問に関係する文書のチャンクを先に検索し、それを根拠として LLM に渡して答えさせる仕組みです。文書を覚えさせるのではなく、毎回渡します。だから、文書が更新されたら取り込み直すだけで済み、モデルには手を入れません。
| 工程 | 作るもの | 決めること |
|---|---|---|
| 分割 | 数百字のチャンクと、その見出しの経路 | どこで切るか |
| 埋め込み | チャンクごとの 1,024 次元ベクトル | どのモデルで、何を埋め込むか |
| 保存 | chunks テーブルの行 | テーブル設計とインデックス |
| 工程 | 作るもの | 決めること |
|---|---|---|
| 検索 | 上位 k 件のチャンク | ベクトルと全文の配分、k |
| 組み立て | 根拠つきのプロンプト | 根拠の並べ方、答えられないときの指示 |
| 生成 | 回答と出典 | モデル、出典の付け方 |
埋め込み(embedding)は、文を 1,024 個の数の並び(ベクトル)に変換したものです。意味の近い文は近いベクトルになります。「注文金額はどこで更新されるか」と「order_amount を書き換える処理」は語が違いますが、ベクトルは近くなります。
検索は「質問のベクトルに近いチャンクのベクトルを探す」操作で、近さは pgvector の <=>(コサイン距離)で測ります。
埋め込みは order_amount のような識別子の完全一致に弱く、似た列名(order_amount と order_total)を近いと判断します。設計書への質問は識別子を含むことが多いので、語の一致で探す全文検索を併用します。
この 2 つは弱点が逆向きです。ベクトルは言い換えに強く識別子に弱い。全文はその逆。だから片方だけでは、質問の種類によって落ちます。検索の節で、その様子を動かして確かめられます。
Before you start
ここを飛ばすと、後で「良くなった気がする」以上のことが言えなくなります。特に 2 つめの質問と正解の対は、この講座のすべての節で使います。
最初に取り込む文書を絞ります。この講座ではテーブル定義書・バッチ設計書・業務処理設計書の 3 種で、いずれも Markdown です。
文書の種類を増やすほど、分割の規則が増えます。まず 1 種類で動作確認を通し、それから増やしてください。種類ごとに見出しの深さも表の形も違うので、規則をまとめて決めようとすると、どちらにも合わない切り方になります。
PDF や Word からテキストに起こすなら、表の壊れ方を先に見てください。表を含む設計書では、セルが行ごとにばらけて「列名・型・説明」の対応が消えることがあります。変換後のテキストで 1 行の並びが保たれているかを目で確かめ、壊れていれば変換ツールを替えるか、表だけを元の Markdown や Excel から起こします。
現場で実際に出た質問を 30〜50 件集め、それぞれに「設計書のどの箇所を読めば答えられるか」を人が付けます。これが正解チャンクです。
| 質問 | 正解の箇所 |
|---|---|
| orders.order_amount はどこで更新される? | テーブル定義書 > orders > 列一覧/バッチ設計書 > 受注確定 > 更新対象 |
| customers を論理削除したとき orders はどうなる? | 業務処理設計書 > 顧客削除 > 影響範囲 |
| ordered_at のタイムゾーンは? | テーブル定義書 > 共通規約 > 日時列 |
質問の形は 3 種類を混ぜてください。識別子をそのまま含む質問、言い換えた質問(「注文の金額」)、2 つの文書にまたがる質問です。検索の配分は、この 3 種で結果が分かれます。
Set up
Aurora PostgreSQL 側の準備です。pgvector はベクトル型と近傍検索、pg_bigm は日本語の全文検索を担います。
pg_bigm を使うのは、日本語に分かち書きがないからです。PostgreSQL 標準の全文検索(tsvector)は語の区切りを空白で判断するため、日本語の文には効きません。pg_bigm は 2 文字ずつの並び(bigram)で索引を作るので、分かち書きなしで日本語にも order_amount のような識別子にも掛かります。確かめ方。SELECT extname, extversion FROM pg_extension; で 2 つが出ることを見てください。Aurora ではこの 2 つとも CREATE EXTENSION だけで使え、パラメータグループを触る必要はありません(既定で shared_preload_libraries に読み込まれるのは pg_stat_statements だけで、pg_bigm はそこに要りません)。自前で立てた PostgreSQL では、pg_bigm の配布物に従って shared_preload_libraries へ追加してから再起動します。
ON DELETE CASCADE は、取り込み直しのためです。文書を更新したときに documents の行を消せば、その文書のチャンクがまとめて消えます。運用の節で使います。embedding_model を持つのは、モデルを替えた日に困らないためです。ベクトルはモデルごとに別の空間にあり、混ぜると検索が壊れます。どの行がどのモデルで作られたかを、行に持たせておきます。m と ef_construction は既定のまま始めます。件数が 10 万を超えて再現率が気になったら、pgvector HNSW のチューニングの手順で詰めてください。CREATE INDEX ... USING hnsw は、既存の行数に比例して時間がかかり、その間そのテーブルへの書き込みを待たせます。最初の一括取り込みが終わってから作るか、CREATE INDEX CONCURRENTLY を使ってください。稼働中のデータベースで何も考えずに実行すると、取り込みバッチが止まります。
文書と質問を VPC の外に出さないため、Bedrock の VPC エンドポイント(com.amazonaws.<region>.bedrock-runtime)を作ります。アプリの IAM ロールには、使う 2 つのモデルに限った権限だけを与えます。
確かめ方。VPC 内から aws bedrock-runtime invoke-model を 1 回叩き、エンドポイント経由で応答が返ることを見てください。エンドポイントのプライベート DNS が有効になっていないと、見た目は成功しても NAT ゲートウェイ経由でインターネットに出ています。Bedrock コンソールの「モデルアクセス」で 2 つのモデルが有効になっていることも合わせて確かめます。
Splitting
分割の目的は 1 つです。1 つの質問の答えが、1 つのチャンクに収まっていること。答えが 2 つのチャンクに割れると、検索は片方しか取れず、根拠が欠けた答えになります。 切り方を替えて、5 つの質問の答えが 1 つに収まるかを確かめてみましょう。
題材はテーブル定義書の抜粋です。左の帯がチャンクの区切り、右の縦線が各質問の答えの範囲を示します。緑は 1 チャンクに収まった範囲、橙は 2 つ以上にまたがった範囲です。上限は字数で動かしていますが、実装の上限はトークン数です。日本語は 1 字がおよそ 1〜1.5 トークンなので、480 トークンは 350〜450 字にあたります。
この図が再現しているのは、切り方によって正解の範囲が 1 チャンクに収まるかどうかが変わる様子です。文書はこの講座のための抜粋で、実際の設計書では表の大きさと見出しの深さで結果が変わります。自分の文書では、質問と正解の対について、正解の範囲が分割後に 1 チャンクに入っているかを次の問い合わせで確かめてください。SELECT chunk_index, heading_path, length(content) FROM chunks WHERE document_id = :document_id ORDER BY chunk_index;
## と ### の境界で切ります。設計書の答えは、「orders の列一覧」「受注確定の更新対象」のように見出し 1 つ分の範囲に収まっているからです。列一覧の表だけを見ても、どのテーブルの列なのかは分かりません。order_amount という列は orders にも order_items にもあります。
見出しの経路を本文に含めると、埋め込みが「orders の列」という文脈を持ち、orders での全文検索にも掛かります。この 1 行の有無が、同名列を取り違える事故の分かれ目になります。
実際、症例 01 はこの規則を落としたときに起きる形です。規約や設定ではなく、取り込みの 1 行が原因になります。
50 トークン未満のチャンク(見出しだけで本文がない)が多ければ、空の節を捨てる処理が要ります。上限を超えるものが残っていれば、規則 2 と 3 の分割が効いていません。分布を見ずに次へ進むと、埋め込みの費用を払ってから作り直すことになります。
Embedding
日本語の設計書なので、多言語に対応した埋め込みモデルを使います。この講座は Bedrock の Cohere Embed Multilingual(1,024 次元)で組みます。Amazon Titan Text Embeddings V2 も候補で、次元を 256/512/1,024 から選べます。
input_type を取り違えないでください。Cohere のモデルは、文書側と質問側で別の指定を受け取ります。取り込み時は search_document、質問時は search_query です。取り違えてもエラーは出ず、近さの精度だけが静かに落ちます。到達率が理由もなく低いときは、まずここを見てください。
一度に渡せる本数と 1 本あたりの長さに上限があります。Cohere は 96 本、1 本 512 トークンまでで、超過分は truncate で切られます。
上限を超えた分は、末尾が黙って切られます。切れた部分は埋め込みに入らないので、その内容では検索に掛かりません。エラーは出ないので、気付くのは到達率が低いと分かってからになります。
だから分割の上限は、埋め込みモデルの上限より内側に置きます。前の節で 480 トークンにしてあるのは、Cohere の 512 トークンに対して余裕を取ったためです。
見出し 1 つ分が 480 トークンに収まらない文書を扱うなら、モデルのほうを替えます。Titan Text Embeddings V2 は 8,000 トークンまで受け取れ、次元も 256/512/1,024 から選べます。
距離がすべて 0.9 以上(ほぼ無関係)なら、input_type の取り違えか、埋め込む本文に見出し経路が入っていない可能性があります。埋め込みは目で見えないので、この種の確認を挟まないと、検索が動かなくなってから原因を探すことになります。
埋め込みの API は、本数とトークン数で課金されます。取り込みスクリプトを何度も走らせて全件を埋め込み直すと、その分だけ費用がかかります。documents.content_hash が変わっていない文書は飛ばす処理を、最初から入れてください。
Retrieval
2 つの検索は弱点が逆向きです。合わせ方は RRF(Reciprocal Rank Fusion)で、各検索の順位に対して 1 / (k + 順位) を点数とし、両方を足して並べ直します。 配分を動かして、3 種類の質問で順位がどう入れ替わるかを確かめてみましょう。
3 種類の質問それぞれについて、ベクトル検索と全文検索が返した順位を持たせてあります。青は正解のチャンク、橙の縦線が top_k の位置です。
この図が再現しているのは、配分と top_k を動かしたときに 2 つの検索の結果がどう混ざり、正解の位置がどう動くかです。順位表はこの講座の題材データで取った固定値で、自分の文書では順位そのものが違います。自分の質問と正解の対で、配分を 0.3/0.5/0.7 の 3 通り試し、到達率が最も高い値を採ってください。
点数ではなく順位を使うのは、尺度が違うからです。コサイン距離は 0 から 2 に収まりますが、全文検索の類似度は別の尺度です。単位も分布も違うものを足すと、値の大きいほうが常に勝ちます。順位なら尺度に依存しないので、モデルや拡張を替えても配分の意味が変わりません。
1 / (k + 順位) の k を小さくすると、1 位と 2 位の点差が開き、片方の検索で 1 位になったチャンクが強く前に出ます。大きくすると順位差がならされ、両方の検索に出てきたチャンクが有利になります。
設計書への質問では、識別子で全文検索に掛かり、かつ意味でベクトル検索にも掛かるチャンクが正解であることが多いので、両方に出たチャンクを持ち上げる側、つまり k は 60 前後で始めます。上のシミュレーターで k を 10 まで下げると、片方の 1 位が突出して順位が入れ替わる様子が見えます。
likequery(:q) は、検索語の % や _ をエスケープして前後を % で包み、LIKE に渡せる形にする pg_bigm の関数です。この LIKE が GIN 索引(gin_bigm_ops)を使うので、全件走査になりません。
bigm_similarity(content, :q) は、本文と検索語の 2 文字ずつの並びがどれだけ共通するかを 0 から 1 で返します。LIKE で候補を絞ったあと、この値で並べて順位を付けています。この値そのものを RRF に足してはいけません。使うのは順位だけです。
質問文をそのまま likequery に渡すと、長すぎて掛かりません。質問から識別子と語を抜き出して検索します。
hnsw.ef_search(既定 40)より LIMIT が大きいと、HNSW は ef_search 件までしか候補を返しません。上の LIMIT 20 は既定の範囲内ですが、候補を増やすときは SET LOCAL hnsw.ef_search も合わせてください。接続プールを使っている環境では SET LOCAL でトランザクションに閉じないと、前のリクエストの値が次の検索に効きます。詳しくはpgvector HNSW のチューニングで扱っています。
Generation
検索した上位 6 件を、番号と見出し経路つきで渡します。答えは根拠の範囲に限り、根拠にないことは「記載がない」と答えさせます。
boto3 だけで書くなら、bedrock-runtime の converse を呼びます。渡すものは同じで、system にシステムプロンプト、messages に根拠と質問、inferenceConfig に maxTokens を置きます。上の Anthropic SDK 版を選んだのは、根拠のブロックを組み立てる部分が読みやすいからで、機能の差ではありません。すでに boto3 で統一している環境なら、そのまま converse で書いてください。
LLM は先頭と末尾の根拠をよく使い、中ほどを見落とす傾向があります。6 件を超えて渡しても正しさは伸びず、入力トークンだけが増えます。
件数を増やしたくなったら、増やす代わりに検索の配分を直してください。渡す件数の問題ではなく、正解が上位に来ていないことが原因である場合がほとんどです。
確かめ方。根拠を 0 件にして質問し、「設計書に記載がありません」と答えることを見てください。ここで答えを作ってしまうなら、システムプロンプトの指示が効いていません。
回答の [n] を、渡したチャンクの chunk_id と heading_path に戻して表示します。ヒーローの「根拠:」の部分がそれです。
読み手が設計書の該当箇所を開いて確かめられることが、この仕組みの信頼の土台です。出典のない回答は、正しくても検証できません。
会話が続く場合は、検索の前に質問を 1 文に書き直します。「それはどのバッチで?」のような 2 問目は、直前のやり取りを知らない検索には意味が取れません。直前の質問と回答を LLM に渡して「order_amount を更新するのはどのバッチか」のような独立した 1 文に直し、その文で検索します。生成が 1 回増えますが、これをしないと 2 問目以降の到達率だけが落ちます。
閲覧権限のある文書を扱うなら、絞り込みは検索の WHERE で行ってください。検索結果には、質問した人が見られない文書のチャンクが混ざりえます。documents に範囲の列を持ち、検索の段階で絞ります。LLM に渡した後で隠すことはできません。プロンプトで「この文書は見せないで」と指示しても、渡した時点で漏れています。
Does it actually work
準備の節で作った対のうち「測る用」の半分を流し、正解チャンクが上位 6 件に入った割合を出します。これが到達率です。
| 質問の種類 | 件数 | 到達率 |
|---|---|---|
| 識別子入り | 10 | 100% |
| 言い換え | 10 | 80% |
| 2 文書にまたがる | 5 | 60% |
落ち方で、直す場所が決まります。識別子入りだけが落ちるなら全文側の配分、言い換えだけが落ちるならベクトル側の配分、2 文書にまたがるものだけが落ちるなら top_k か質問の分け方です。全体の平均だけを見ていると、この判断ができません。
同じ質問を ask.py に流し、回答を人が読んで「正しい」「根拠にないことを言った」「答えられないと言った」の 3 つに分けます。
到達率が 80% を超えているのに正しさが 60% を切るなら、原因は検索ではなく組み立てです。この切り分けは、動かし始めてからも繰り返し必要になります。
レイテンシは検索と生成を分けて出します。検索が 100 ms を超えるなら、全文検索側が索引を使っていないか、ef_search が大きすぎます。EXPLAIN (ANALYZE, BUFFERS) で確かめてください。
2 文書にまたがる質問が落ちるのには、構造的な理由があります。両方の正解チャンクが top_k に入る必要があるため、片方が入っただけでは答えられません。top_k を 8 にするか、この種類の質問を 2 回の検索に分けてください。
Running it
RAG は入れて終わりの仕組みではありません。文書が変わり、モデルが変わり、質問の傾向が変わります。
content_hash が同じ文書は飛ばし、変わった文書は documents の行を消してから取り込み直します。ON DELETE CASCADE でチャンクもまとめて消えます。SELECT document_id, count(*) FROM chunks GROUP BY document_id で、1 文書の件数が倍になっていないかを見ることです。Build or buy
Bedrock Knowledge Bases は、S3 の文書を分割・埋め込み・保存・検索まで AWS が行うマネージドの RAG です。保存先に Aurora PostgreSQL(pgvector)も選べます。
判断の順番は決まっています。まず Knowledge Bases で、準備の節で作った対を流して到達率を測ってください。基準を満たせばそれで始めます。分割か絞り込みで満たせないと分かってから、この講座の構成に移ります。テーブルは同じ Aurora なので、移るときも文書と対はそのまま使えます。先に自前で組んでから比較するより、この順のほうが早く終わります。
Local variant
VPC の代わりに手元の Mac や社内サーバーで組む場合、変わるのは埋め込みと生成の呼び先だけです。テーブルも SQL も、そのまま使えます。
| 工程 | AWS | 手元 |
|---|---|---|
| 埋め込み | Bedrock Cohere(1,024 次元) | Ollama bge-m3(1,024 次元・多言語) |
| 生成 | Bedrock Claude | Ollama の指示追従モデル |
| 保存 | Aurora PostgreSQL | PostgreSQL 15 以上 + pgvector + pg_bigm |
次元が 1,024 で同じなので chunks.embedding の定義は変わりません。ただし embedding_model 列の値は変わるので、AWS で作った行と混ぜないでください。手元のモデルの選び方は ローカルLLMのしくみで扱っています。
Read the symptom
組む途中と、動かし始めた直後によく持ち込まれる 3 つの症状です。それぞれ、どこに原因があるのかを考えてみましょう。
Myth vs. reality
この 3 つを持ったまま設計に入ると、作り直しになります。
コンテキストに入る量には上限があり、入るとしても入力トークンの料金と時間が質問ごとにかかります。設計書 1 式で数十万トークンになり、質問のたびにその分を払うことになります。検索して 6 件に絞るから、毎回 3,000 トークンで済みます。
小さくすると、答えに必要な文脈(どのテーブルの、どの処理の話か)が切り離され、検索では上位に来ても LLM が根拠として使えなくなります。目指すのは「答えが 1 チャンクに収まる最小の単位」で、設計書ではそれが見出し 1 つ分です。
次元は保存量と検索時間に比例して増えますが、到達率が上がるかどうかは自分の対で測らなければ分かりません。同じモデルで次元を選べる場合、512 と 1,024 で到達率を比べ、差がなければ小さいほうを採ってください。
Knowledge check
答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。
選択肢を1つ選んでください。
The whole thing in one line
目次