DBC Tech Academy / AI / 中級

社内文書の
RAG を組むAurora PostgreSQL と Bedrock で、根拠つきの回答まで

「この列はどの処理で更新される?」「この外部キーを外すと何が壊れる?」。設計書に書いてあるのに、探すより人に聞くほうが早い質問が現場にはあります。この講座では、DB 設計書とテーブル定義書を取り込み、質問に対して該当箇所を検索し、根拠を示して答える仕組みを、Aurora PostgreSQL と Bedrock だけで組みます。文書は VPC の外に出ません。

ask.py
$ python ask.py "orders.order_amount はどこで更新される?" order_amount は次の 2 か所で更新されます。 1. 受注確定バッチ(batch_confirm_order)が、明細 order_items の単価×数量の合計を書き戻します。[1][2] 2. 返品処理(proc_return)が、返品分を差し引いた額で 更新します。返品後は order_items との合計が一致 しなくなるため、照合には returns を含めます。[3] 根拠: [1] テーブル定義書 > orders > 列一覧 rrf 0.0161 [2] バッチ設計書 > 受注確定 > 更新対象 rrf 0.0158 [3] 業務処理設計書 > 返品 > 更新テーブル rrf 0.0154 retrieval 38 ms / generation 2.1 s / input 3,120 tokens

答えの各文に、どの設計書のどの見出しを読んで書いたかが付きます。読み手が原典を開いて確かめられることが、この仕組みの信頼の土台です。

What you will have

読み終えたとき、
動いているもの。

この講座は、考え方ではなく動くものを持ち帰るための手順書です。最初に、到達点をそのまま示します。

ingestと ask、2 本の
スクリプト

取り込むスクリプトと、答えるスクリプト。その 2 本だけです。

ingest は設計書を読み、見出しと表の構造で分割し、埋め込みを計算して Aurora PostgreSQL に保存します。ask は質問を埋め込み、ベクトル検索と全文検索を合わせて上位のチャンク (chunk) を取り、Bedrock の Claude に根拠として渡して答えさせます。

  • ingest.py 文書を分割し、埋め込みを計算し、chunks テーブルに保存します。更新は差分だけを取り込みます。
  • ask.py 質問に対して上位 6 件を根拠として渡し、答えと出典を返します。
  • 質問と正解の対 30〜50 件 それで測った検索の到達率と、回答の正しさ。
文書 見出し・表で分割 埋め込み chunks テーブル
質問 ベクトル + 全文 上位 6 件 根拠つきで回答

Aurora PostgreSQL

15.x 以上。pgvector で近傍検索、pg_bigm で日本語と識別子の全文検索を担います。既存の DB にそのまま同居できます。

Bedrock(VPC 経由)

埋め込みと生成だけを任せます。VPC エンドポイント経由で呼ぶため、文書も質問もインターネットに出ません。

対象の文書

テーブル定義書・バッチ設計書・業務処理設計書の 3 種。表と見出しが多い文書で、分割の判断がそのまま効きます。

How it works

取り込みと質問、
2 本の流れがあります。

RAG(Retrieval-Augmented Generation)は、質問に関係する文書のチャンクを先に検索し、それを根拠として LLM に渡して答えさせる仕組みです。文書を覚えさせるのではなく、毎回渡します。だから、文書が更新されたら取り込み直すだけで済み、モデルには手を入れません。

VPC 設計書 Markdown ingest.py 分割 → 埋め込み ask.py 検索 → 回答 Aurora PostgreSQL chunks(本文 + ベクトル) pgvector / pg_bigm Amazon Bedrock 埋め込みモデル Claude(生成) 埋め込みを計算 根拠を渡して回答 VPC endpoint 利用者 質問

取り込み時に作るもの

工程作るもの決めること
分割数百字のチャンクと、その見出しの経路どこで切るか
埋め込みチャンクごとの 1,024 次元ベクトルどのモデルで、何を埋め込むか
保存chunks テーブルの行テーブル設計とインデックス

質問時に作るもの

工程作るもの決めること
検索上位 k 件のチャンクベクトルと全文の配分、k
組み立て根拠つきのプロンプト根拠の並べ方、答えられないときの指示
生成回答と出典モデル、出典の付け方

埋め込みとは何か

埋め込み(embedding)は、文を 1,024 個の数の並び(ベクトル)に変換したものです。意味の近い文は近いベクトルになります。「注文金額はどこで更新されるか」と「order_amount を書き換える処理」は語が違いますが、ベクトルは近くなります。

検索は「質問のベクトルに近いチャンクのベクトルを探す」操作で、近さは pgvector の <=>(コサイン距離)で測ります。

それでも全文検索が要る理由

埋め込みは order_amount のような識別子の完全一致に弱く、似た列名(order_amount と order_total)を近いと判断します。設計書への質問は識別子を含むことが多いので、語の一致で探す全文検索を併用します。

この 2 つは弱点が逆向きです。ベクトルは言い換えに強く識別子に弱い。全文はその逆。だから片方だけでは、質問の種類によって落ちます。検索の節で、その様子を動かして確かめられます。

Before you start

組み始める前に、
決めることが 3 つ。

ここを飛ばすと、後で「良くなった気がする」以上のことが言えなくなります。特に 2 つめの質問と正解の対は、この講座のすべての節で使います。

対象文書を 1 種類に絞る

最初に取り込む文書を絞ります。この講座ではテーブル定義書・バッチ設計書・業務処理設計書の 3 種で、いずれも Markdown です。

文書の種類を増やすほど、分割の規則が増えます。まず 1 種類で動作確認を通し、それから増やしてください。種類ごとに見出しの深さも表の形も違うので、規則をまとめて決めようとすると、どちらにも合わない切り方になります。

PDF や Word からテキストに起こすなら、表の壊れ方を先に見てください。表を含む設計書では、セルが行ごとにばらけて「列名・型・説明」の対応が消えることがあります。変換後のテキストで 1 行の並びが保たれているかを目で確かめ、壊れていれば変換ツールを替えるか、表だけを元の Markdown や Excel から起こします。

質問と正解の対を作る

現場で実際に出た質問を 30〜50 件集め、それぞれに「設計書のどの箇所を読めば答えられるか」を人が付けます。これが正解チャンクです。

質問正解の箇所
orders.order_amount はどこで更新される?テーブル定義書 > orders > 列一覧/バッチ設計書 > 受注確定 > 更新対象
customers を論理削除したとき orders はどうなる?業務処理設計書 > 顧客削除 > 影響範囲
ordered_at のタイムゾーンは?テーブル定義書 > 共通規約 > 日時列

質問の形は 3 種類を混ぜてください。識別子をそのまま含む質問、言い換えた質問(「注文の金額」)、2 つの文書にまたがる質問です。検索の配分は、この 3 種で結果が分かれます。

  1. 検索の到達率を、合格基準にします。正解チャンクが上位 6 件に入る割合です。80% 以上を最初の基準にしてください。
  2. 回答の正しさを、もう 1 つの基準にします。根拠に基づいて正しく答えた割合で、人が読んで判定します。
  3. 対を 2 つに分けます。片方で検索の配分を決め、もう片方で測ります。同じ対で配分を決めて到達率を測ると、その対に合わせ込んだ数字が出るだけで、現場の質問には効きません。

Set up

拡張を 2 つ入れて、
テーブルを 2 つ作る。

Aurora PostgreSQL 側の準備です。pgvector はベクトル型と近傍検索、pg_bigm は日本語の全文検索を担います。

CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS 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 へ追加してから再起動します。

テーブル

CREATE TABLE documents ( document_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_path text NOT NULL UNIQUE, title text NOT NULL, content_hash text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE documents IS '取り込んだ文書。source_path で同一性を判定し、content_hash で更新を検知する'; COMMENT ON COLUMN documents.content_hash IS '取り込み時の本文の SHA-256。変わっていなければ再分割・再埋め込みを省く'; CREATE TABLE chunks ( chunk_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, document_id bigint NOT NULL REFERENCES documents ON DELETE CASCADE, chunk_index integer NOT NULL, heading_path text NOT NULL, content text NOT NULL, token_count integer NOT NULL, embedding_model text NOT NULL, embedding vector(1024) NOT NULL, UNIQUE (document_id, chunk_index) ); COMMENT ON TABLE chunks IS '文書を分割したチャンク。検索の単位'; COMMENT ON COLUMN chunks.heading_path IS 'チャンクが属する見出しの経路。埋め込む本文の先頭にも付ける'; COMMENT ON COLUMN chunks.embedding_model IS 'embedding を計算したモデル ID。モデルを替えたときに再埋め込みの対象を絞る'; CREATE INDEX chunks_embedding_hnsw ON chunks USING hnsw (embedding vector_cosine_ops); CREATE INDEX chunks_content_bigm ON chunks USING gin (content gin_bigm_ops);
  1. 次元は埋め込みモデルの出力に合わせます。この講座で選ぶモデルは 1,024 次元です。モデルを替えて次元が変われば、列の型ごと作り直しになります。
  2. ON DELETE CASCADE は、取り込み直しのためです。文書を更新したときに documents の行を消せば、その文書のチャンクがまとめて消えます。運用の節で使います。
  3. embedding_model を持つのは、モデルを替えた日に困らないためです。ベクトルはモデルごとに別の空間にあり、混ぜると検索が壊れます。どの行がどのモデルで作られたかを、行に持たせておきます。
  4. HNSW の m と ef_construction は既定のまま始めます。件数が 10 万を超えて再現率が気になったら、pgvector HNSW のチューニングの手順で詰めてください。

CREATE INDEX ... USING hnsw は、既存の行数に比例して時間がかかり、その間そのテーブルへの書き込みを待たせます。最初の一括取り込みが終わってから作るか、CREATE INDEX CONCURRENTLY を使ってください。稼働中のデータベースで何も考えずに実行すると、取り込みバッチが止まります。

Bedrock を VPC から呼ぶ

文書と質問を VPC の外に出さないため、Bedrock の VPC エンドポイント(com.amazonaws.<region>.bedrock-runtime)を作ります。アプリの IAM ロールには、使う 2 つのモデルに限った権限だけを与えます。

{ "Effect": "Allow", "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], "Resource": [ "arn:aws:bedrock:<region>::foundation-model/cohere.embed-multilingual-v3", "arn:aws:bedrock:<region>::foundation-model/anthropic.claude-opus-5" ] }

確かめ方。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;

    4 つの規則

    1. ## と ### の境界で切ります。設計書の答えは、「orders の列一覧」「受注確定の更新対象」のように見出し 1 つ分の範囲に収まっているからです。
    2. 上限を超えるなら、段落の境界で分けます。表は分けず、表の直前で切ります。
    3. 表が単独で上限を超えるなら、見出し行を複製して分けます。列名の行がないチャンクは、数字の羅列にしかなりません。
    4. 各チャンクの先頭に、見出しの経路を 1 行付けます。埋め込みにも全文検索にも、この行を含めます。

    4 つめが効く理由

    列一覧の表だけを見ても、どのテーブルの列なのかは分かりません。order_amount という列は orders にも order_items にもあります。

    見出しの経路を本文に含めると、埋め込みが「orders の列」という文脈を持ち、orders での全文検索にも掛かります。この 1 行の有無が、同名列を取り違える事故の分かれ目になります。

    実際、症例 01 はこの規則を落としたときに起きる形です。規約や設定ではなく、取り込みの 1 行が原因になります。

    MAX_CHUNK_TOKENS は、埋め込みモデルの入力上限に合わせて決める(次の節) MAX_CHUNK_TOKENS = 480 def split_markdown(text: str, title: str) -> list[dict]: """見出し境界で分割し、各チャンクに見出し経路を付ける""" chunks, path, buf = [], [title], [] def flush(): body = "".join(buf).strip() if body: heading_path = " > ".join(path) # 経路を本文の先頭に入れる。埋め込みと全文検索の両方に効かせるため chunks.append({"heading_path": heading_path, "content": f"{heading_path}\n{body}"}) buf.clear() for line in text.splitlines(keepends=True): m = re.match(r"^(#{2,3})\s+(.*)", line) if m: flush() depth = len(m.group(1)) path = path[:depth - 1] + [m.group(2).strip()] else: buf.append(line) flush() return [c for chunk in chunks for c in split_long(chunk, MAX_CHUNK_TOKENS)]

    分割できたかの確かめ方

    SELECT width_bucket(token_count, 0, 1200, 12) AS bucket, count(*) FROM chunks GROUP BY bucket ORDER BY bucket;

    50 トークン未満のチャンク(見出しだけで本文がない)が多ければ、空の節を捨てる処理が要ります。上限を超えるものが残っていれば、規則 2 と 3 の分割が効いていません。分布を見ずに次へ進むと、埋め込みの費用を払ってから作り直すことになります。

    Embedding

    埋め込んで、
    保存する。

    日本語の設計書なので、多言語に対応した埋め込みモデルを使います。この講座は Bedrock の Cohere Embed Multilingual(1,024 次元)で組みます。Amazon Titan Text Embeddings V2 も候補で、次元を 256/512/1,024 から選べます。

    import json, boto3 EMBEDDING_MODEL_ID = "cohere.embed-multilingual-v3" bedrock = boto3.client("bedrock-runtime") def embed(texts: list[str], input_type: str) -> list[list[float]]: """input_type は取り込み時 search_document、質問時 search_query""" body = {"texts": texts, "input_type": input_type, "truncate": "END"} res = bedrock.invoke_model(modelId=EMBEDDING_MODEL_ID, body=json.dumps(body)) return json.loads(res["body"].read())["embeddings"]

    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 から選べます。

    保存する

    def store(conn, document_id, chunks): vectors = [] # 96 本ずつに区切って呼ぶ for i in range(0, len(chunks), 96): vectors += embed( [c["content"] for c in chunks[i:i + 96]], "search_document") with conn.cursor() as cur: cur.executemany( """ INSERT INTO chunks ( document_id, chunk_index, heading_path, content, token_count, embedding_model, embedding) VALUES ( :document_id, :chunk_index, :heading_path, :content, :token_count, :embedding_model, :embedding) """, rows(document_id, chunks, vectors))

    埋め込めたかの確かめ方

    -- 同じ文書の隣り合うチャンクは近く、別文書のチャンクは遠いはず SELECT a.heading_path, b.heading_path, a.embedding <=> b.embedding AS distance FROM chunks AS a INNER JOIN chunks AS b ON b.chunk_id = a.chunk_id + 1 WHERE a.document_id = :document_id ORDER BY a.chunk_index LIMIT 10;

    距離がすべて 0.9 以上(ほぼ無関係)なら、input_type の取り違えか、埋め込む本文に見出し経路が入っていない可能性があります。埋め込みは目で見えないので、この種の確認を挟まないと、検索が動かなくなってから原因を探すことになります。

    埋め込みの API は、本数とトークン数で課金されます。取り込みスクリプトを何度も走らせて全件を埋め込み直すと、その分だけ費用がかかります。documents.content_hash が変わっていない文書は飛ばす処理を、最初から入れてください。

    Generation

    根拠の範囲で、
    答えさせる。

    検索した上位 6 件を、番号と見出し経路つきで渡します。答えは根拠の範囲に限り、根拠にないことは「記載がない」と答えさせます。

    from anthropic import AnthropicBedrockMantle GENERATION_MODEL_ID = "anthropic.claude-opus-5" client = AnthropicBedrockMantle(aws_region="ap-northeast-1") SYSTEM_PROMPT = """あなたは社内の DB 設計書について答える担当者です。 与えられた根拠だけを使って答えてください。根拠に書かれていないことは 「設計書に記載がありません」と答え、推測で補わないでください。 答えの各文には、使った根拠の番号を [n] の形で付けてください。""" def answer(question: str, hits: list[dict]) -> str: evidence = "\n\n".join( f"[{i + 1}] {h['heading_path']}\n{h['content']}" for i, h in enumerate(hits) ) message = client.messages.create( model=GENERATION_MODEL_ID, max_tokens=2048, system=[{"type": "text", "text": SYSTEM_PROMPT}], messages=[{"role": "user", "content": f"根拠:\n{evidence}\n\n質問: {question}"}], ) return "".join(b.text for b in message.content if b.type == "text")

    boto3 だけで書くなら、bedrock-runtime の converse を呼びます。渡すものは同じで、system にシステムプロンプト、messages に根拠と質問、inferenceConfig に maxTokens を置きます。上の Anthropic SDK 版を選んだのは、根拠のブロックを組み立てる部分が読みやすいからで、機能の差ではありません。すでに boto3 で統一している環境なら、そのまま converse で書いてください。

    根拠は 6 件まで、関連度の高い順に

    LLM は先頭と末尾の根拠をよく使い、中ほどを見落とす傾向があります。6 件を超えて渡しても正しさは伸びず、入力トークンだけが増えます。

    件数を増やしたくなったら、増やす代わりに検索の配分を直してください。渡す件数の問題ではなく、正解が上位に来ていないことが原因である場合がほとんどです。

    確かめ方。根拠を 0 件にして質問し、「設計書に記載がありません」と答えることを見てください。ここで答えを作ってしまうなら、システムプロンプトの指示が効いていません。

    出典を返す

    回答の [n] を、渡したチャンクの chunk_id と heading_path に戻して表示します。ヒーローの「根拠:」の部分がそれです。

    読み手が設計書の該当箇所を開いて確かめられることが、この仕組みの信頼の土台です。出典のない回答は、正しくても検証できません。

    会話が続く場合は、検索の前に質問を 1 文に書き直します。「それはどのバッチで?」のような 2 問目は、直前のやり取りを知らない検索には意味が取れません。直前の質問と回答を LLM に渡して「order_amount を更新するのはどのバッチか」のような独立した 1 文に直し、その文で検索します。生成が 1 回増えますが、これをしないと 2 問目以降の到達率だけが落ちます。

    閲覧権限のある文書を扱うなら、絞り込みは検索の WHERE で行ってください。検索結果には、質問した人が見られない文書のチャンクが混ざりえます。documents に範囲の列を持ち、検索の段階で絞ります。LLM に渡した後で隠すことはできません。プロンプトで「この文書は見せないで」と指示しても、渡した時点で漏れています。

    Does it actually work

    種類別に測ると、
    直す場所が分かる。

    準備の節で作った対のうち「測る用」の半分を流し、正解チャンクが上位 6 件に入った割合を出します。これが到達率です。

    def recall_at_k(pairs: list[dict], k: int = 6) -> float: hit = 0 for p in pairs: ids = {h["chunk_id"] for h in search(p["question"], top_k=k)} hit += bool(ids & set(p["answer_chunk_ids"])) return hit / len(pairs)

    結果は 3 種類に分けて見ます

    質問の種類件数到達率
    識別子入り10100%
    言い換え1080%
    2 文書にまたがる560%

    落ち方で、直す場所が決まります。識別子入りだけが落ちるなら全文側の配分、言い換えだけが落ちるならベクトル側の配分、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 でチャンクもまとめて消えます。
    • 消さずに追加すると、古い版のチャンクが検索に残ります。その結果、古い仕様で答えます。症例 02 はこの形です。確かめ方は SELECT document_id, count(*) FROM chunks GROUP BY document_id で、1 文書の件数が倍になっていないかを見ることです。
    • 埋め込みモデルを替えるときは、全件を埋め込み直します。ベクトルはモデルごとに別の空間にあり、混在させると検索が壊れます。件数が多ければ新しい列に入れて切り替え、旧列を落とします。

    料金と権限

    • 費用の大半は生成です。質問 1 件で入力 3,000〜4,000 トークン(根拠 6 件)+ 出力 300 トークン。埋め込みは取り込み時の一度きりで、質問側は数十トークンなので無視できます。
    • 生成のモデルを 1 段軽くする判断は、測ってから行います。同じ対で回答の正しさを両方のモデルで測り、差がなければ軽いほうを採ってください。
    • IAM ロールは 2 つのモデルだけに絞ります。Aurora の接続は IAM 認証か Secrets Manager で、アプリに平文のパスワードを持たせません。
    • 質問・返したチャンクの一覧・回答を、1 行ずつ保存します。答えが悪かったときに「検索が外したのか、生成が外したのか」を後から切り分ける材料になります。

    Build or buy

    マネージドで
    足りるかを、先に測る。

    Bedrock Knowledge Bases は、S3 の文書を分割・埋め込み・保存・検索まで AWS が行うマネージドの RAG です。保存先に Aurora PostgreSQL(pgvector)も選べます。

    Knowledge Bases を選ぶ条件

    • 文書が S3 にあり、標準の分割で到達率が基準を満たす。標準の分割は、一定のトークン数で切る「固定長」、見出しの入れ子で親子のチャンクを作る「階層」、文の意味の切れ目で切る「意味」の 3 つから選ぶ
    • 検索結果を SQL で絞る要件(閲覧範囲、部門、文書種別)がないか、文書ごとに付けた属性値での絞り込み(メタデータフィルタ)で足りる。既存のテーブルと結合して絞ることはできない
    • 取り込みの再実行を AWS の同期に任せたい

    自前で持つ条件

    • 表を分けない、見出し経路を付けるといった分割の規則を自分で決める必要がある
    • 全文検索との配分を自分で調整する、または既存テーブルと結合して絞る
    • 検索の SQL を EXPLAIN で追い、レイテンシを自分で詰める

    判断の順番は決まっています。まず Knowledge Bases で、準備の節で作った対を流して到達率を測ってください。基準を満たせばそれで始めます。分割か絞り込みで満たせないと分かってから、この講座の構成に移ります。テーブルは同じ Aurora なので、移るときも文書と対はそのまま使えます。先に自前で組んでから比較するより、この順のほうが早く終わります。

    Local variant

    手元で組むなら、
    呼び先を替えるだけ。

    VPC の代わりに手元の Mac や社内サーバーで組む場合、変わるのは埋め込みと生成の呼び先だけです。テーブルも SQL も、そのまま使えます。

    工程AWS手元
    埋め込みBedrock Cohere(1,024 次元)Ollama bge-m3(1,024 次元・多言語)
    生成Bedrock ClaudeOllama の指示追従モデル
    保存Aurora PostgreSQLPostgreSQL 15 以上 + pgvector + pg_bigm
    import ollama def embed_local(texts: list[str]) -> list[list[float]]: # bge-m3 は input_type の区別がないので、取り込みも質問も同じ呼び方 return ollama.embed(model="bge-m3", input=texts)["embeddings"]

    次元が 1,024 で同じなので chunks.embedding の定義は変わりません。ただし embedding_model 列の値は変わるので、AWS で作った行と混ぜないでください。手元のモデルの選び方は ローカルLLMのしくみで扱っています。

    Read the symptom

    症状から、
    原因を見抜く。

    組む途中と、動かし始めた直後によく持ち込まれる 3 つの症状です。それぞれ、どこに原因があるのかを考えてみましょう。

    Myth vs. reality

    組む前に外しておく、
    3 つの前提。

    この 3 つを持ったまま設計に入ると、作り直しになります。

    MISCONCEPTION 01

    「文書を全部 LLM に渡せば検索は要らない」

    コンテキストに入る量には上限があり、入るとしても入力トークンの料金と時間が質問ごとにかかります。設計書 1 式で数十万トークンになり、質問のたびにその分を払うことになります。検索して 6 件に絞るから、毎回 3,000 トークンで済みます。

    MISCONCEPTION 02

    「チャンクは小さいほど検索が正確」

    小さくすると、答えに必要な文脈(どのテーブルの、どの処理の話か)が切り離され、検索では上位に来ても LLM が根拠として使えなくなります。目指すのは「答えが 1 チャンクに収まる最小の単位」で、設計書ではそれが見出し 1 つ分です。

    MISCONCEPTION 03

    「埋め込みの次元は大きいほど良い」

    次元は保存量と検索時間に比例して増えますが、到達率が上がるかどうかは自分の対で測らなければ分かりません。同じモデルで次元を選べる場合、512 と 1,024 で到達率を比べ、差がなければ小さいほうを採ってください。

    Knowledge check

    9問で、理解を
    確かめよう。

    答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。

    YOUR PROGRESS 01 / 09

    選択肢を1つ選んでください。

    The whole thing in one line

    答えが 1 チャンクに
    収まるように切る。

    対を作る
    構造で分割する
    順位で合わせる
    種類別に測る