langchain-awsのAmazonS3VectorsでLangGraphのRAGのベクトルストアをマネージドに差し替えてみる

langchain-awsのAmazonS3VectorsでLangGraphのRAGのベクトルストアをマネージドに差し替えてみる

2026.06.05

はじめに

データ事業本部のkobayashiです。

以前の記事でLiteLLMとLangGraphを組み合わせてエージェントを構築する方法と、Amazon S3 Vectorsをs3vectors-embed-cliから操作する方法をそれぞれ紹介しました。

https://dev.classmethod.jp/articles/python-litellm-langgraph/

https://dev.classmethod.jp/articles/s3vectors-embed-cli/

今回はその両方をつなぎ合わせて、ベクトルデータの保存先にS3 Vectorsを使ったLangGraphのRAG実装を試してみます。litellm-ragの記事ではChromaDBをローカルに置く構成で書きましたが、その保存先をマネージドなS3 Vectorsに差し替える、というイメージです。

https://dev.classmethod.jp/articles/python-litellm-rag/

LangChainチームが提供するlangchain-awsパッケージにはAmazonS3VectorsというVectorStoreの実装が含まれており、これを使うとboto3を直接触らずにLangGraphのretrieverとしてS3 Vectorsを利用できます。

なぜLangGraph × S3 Vectorsなのか

S3 Vectorsをそのまま使う場合は、AWS CLI(aws s3vectors put-vectors)かs3vectors-embed-cli、あるいはboto3のs3vectorsクライアントを叩く必要があります。これらでもRAGは作れますが、

  • 埋め込み生成→put→queryのループを毎回自前で書かなければならない
  • LangChainのretrieverインターフェースに乗らないので、LangGraphのStateGraphやcreate_agentと素直につなぎにくい

という課題があります。AmazonS3Vectorsを間に挟むと、

  • embeddingBedrockEmbeddings等)を指定するだけで自動的にベクトル化→S3 Vectorsへ保存
  • as_retriever()でLangChain標準のretrieverに変換できる
  • そのretrieverをLCELパイプラインやStateGraphのノード、@toolの中身に直接組み込める

という形で、LangGraphエコシステムの中にS3 Vectorsをそのまま取り込めます。

環境構築

環境

今回使用した環境は以下の通りです。

Python 3.13
litellm 1.83.14
langgraph 1.1.10
langchain 1.1.0
langchain-litellm 0.6.4
langchain-aws 1.4.6
boto3 1.41.0

インストール

pipでインストールします。

$ uv pip install litellm langgraph langchain langchain-core langchain-litellm langchain-aws boto3

前提条件

  • AWS認証情報の設定(AWS_PROFILEなど)
  • Amazon Bedrockのamazon.titan-embed-text-v2:0へのアクセス権が有効化済み
  • S3ベクトルバケットが作成済み(インデックスはコード側で自動作成可)
# ベクトルバケットの作成(既に作成済みであれば不要)
$ aws s3vectors create-vector-bucket \
  --vector-bucket-name s3-vectors-482842011168

LLM呼び出し用のAPIキー

ChatLiteLLMの宛先プロバイダーに合わせてAPIキーを設定します。今回はAnthropicを使いました。

$ export ANTHROPIC_API_KEY="sk-ant-..."

ベクトルストアの初期化(common.py)

まずは共通のベクトルストアを定義します。AmazonS3VectorsBedrockEmbeddingsを渡すだけでS3 Vectorsへの読み書きが完結します。

common.py
import os

from langchain_aws import BedrockEmbeddings
from langchain_aws.vectorstores.s3_vectors import AmazonS3Vectors
from langchain_core.documents import Document

BUCKET_NAME = os.environ.get("S3_VECTORS_BUCKET", "s3-vectors-482842011168")
INDEX_NAME = os.environ.get("S3_VECTORS_INDEX", "company-docs")
EMBEDDING_MODEL_ID = "amazon.titan-embed-text-v2:0"
REGION = os.environ.get("AWS_REGION", "ap-northeast-1")

# 社内ドキュメント
documents = [
    Document(
        id="doc_1",
        page_content="有給休暇は入社6ヶ月後に10日付与されます。申請は3営業日前までに上長の承認が必要です。",
        metadata={"category": "leave"},
    ),
    Document(
        id="doc_2",
        page_content="リモートワークは週3日まで可能です。事前申請は不要ですが、勤怠システムへの記録が必要です。",
        metadata={"category": "workstyle"},
    ),
    Document(
        id="doc_3",
        page_content="経費精算は月末締め、翌月15日払いです。領収書の原本またはスキャンデータの提出が必要です。",
        metadata={"category": "expense"},
    ),
    Document(
        id="doc_4",
        page_content="育児休業は子が1歳になるまで取得可能です。延長の場合は最長2歳まで認められます。",
        metadata={"category": "leave"},
    ),
    Document(
        id="doc_5",
        page_content="健康診断は年1回、会社負担で受診できます。35歳以上は人間ドックも選択可能です。",
        metadata={"category": "health"},
    ),
]

# BedrockのTitan Text Embeddings V2で1024次元のベクトルを生成
embeddings = BedrockEmbeddings(model_id=EMBEDDING_MODEL_ID, region_name=REGION)

# S3 Vectorsをバックエンドにしたベクトルストア
# create_index_if_not_exist=True なのでインデックスが無ければ自動作成される
vector_store = AmazonS3Vectors(
    vector_bucket_name=BUCKET_NAME,
    index_name=INDEX_NAME,
    embedding=embeddings,
    region_name=REGION,
    distance_metric="cosine",
    create_index_if_not_exist=True,
)

def ingest() -> None:
    """サンプルドキュメントをS3 Vectorsに登録する。同じIDで上書きされる。"""
    vector_store.add_documents(documents)
    print(f"ドキュメント {len(documents)} 件を登録しました")

if __name__ == "__main__":
    ingest()

AmazonS3Vectorsの主なパラメータ

パラメータ 説明
vector_bucket_name 既存のS3ベクトルバケット名(必須)。事前にaws s3vectors create-vector-bucketで作成しておく
index_name ベクトルインデックス名(必須)。3〜63文字、英小文字・数字・ハイフン・ドット
embedding Embeddingsインターフェースを実装したインスタンス。BedrockEmbeddingsなど
distance_metric "cosine"(デフォルト)または"euclidean"
create_index_if_not_exist Trueの場合、インデックスが無ければ自動作成。CLIで事前作成しなくてもよい
region_name / credentials_profile_name AWS認証関連。指定しなければデフォルトプロファイルを使用
non_filterable_metadata_keys メタデータフィルタの対象外にするキーのリスト

AmazonS3Vectorsは内部的にDocument.page_content_page_contentというメタデータキーに保存し、retrieve時にそこからpage_contentを復元します。S3 Vectors側のメタデータには元のテキストも含まれるので、検索結果のドキュメントからpage_contentを取り出せばそのままコンテキストに使えます。

最初に1回だけ実行してドキュメントを登録します。

$ python common.py
ドキュメント 5 件を登録しました

これでS3 Vectorsに5件のドキュメントがamazon.titan-embed-text-v2:0の1024次元ベクトルとして保存されました。以降のスクリプトはこのインデックスを参照します。

パターン1: LCELパイプラインによるシンプルRAG

まずはLangGraphを使わずに、LCEL(LangChain Expression Language)でretriever→prompt→LLMをチェーンする最もシンプルな構成です。litellm-ragsimple_rag.pyをS3 Vectorsに置き換えた版という位置付けになります。

simple_rag.py
from common import vector_store
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_litellm import ChatLiteLLM

COMPLETION_MODEL = "anthropic/claude-sonnet-4-6"

# S3 Vectorsをretrieverとして利用
retriever = vector_store.as_retriever(search_kwargs={"k": 2})

prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "以下の社内ドキュメントの情報のみをもとに質問に回答してください。"
            "ドキュメントに記載がない場合は「情報が見つかりませんでした」と回答してください。\n\n"
            "{context}",
        ),
        ("user", "{question}"),
    ]
)

llm = ChatLiteLLM(model=COMPLETION_MODEL)

def format_docs(docs):
    return "\n".join(d.page_content for d in docs)

# LCELでretrieve → prompt → LLM → 文字列抽出 のパイプラインを構築
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

queries = [
    "リモートワークは週何日までできますか?",
    "健康診断の費用は自己負担ですか?",
]

for query in queries:
    print(f"\n{'='*50}")
    print(f"質問: {query}")
    print(f"{'='*50}")
    print(f"回答: {chain.invoke(query)}")

vector_store.as_retriever(search_kwargs={"k": 2})の部分でS3 Vectorsをretrieverとして取り出しています。search_kwargs{"filter": {"category": {"$eq": "leave"}}}のようなS3 Vectorsのフィルタ式を渡せば、メタデータでの絞り込み検索もできます。

実行結果は以下のようになりました。

$ python simple_rag.py

==================================================
質問: リモートワークは週何日までできますか?
==================================================
回答: リモートワークは週3日まで可能です。事前申請は不要ですが、勤怠システムへの記録が必要です。

==================================================
質問: 健康診断の費用は自己負担ですか?
==================================================
回答: 自己負担ではありません。健康診断は年1回、会社負担で受診できます。35歳以上は人間ドックも選択可能です。

S3 Vectorsからcosine距離で最も近い2件を取得し、その内容だけをコンテキストにして回答していることが確認できます。

パターン2: StateGraphによる明示的なRAGフロー

LCELはコンパクトに書ける一方、途中の検索結果やステートが見えにくいという面があります。LangGraphのStateGraphを使うと、retrieve→generateの各ステップを独立したノードとして定義でき、検索ヒットを明示的にステートに乗せられます。

langgraph_rag.py
from typing_extensions import TypedDict

from common import vector_store
from langchain_core.documents import Document
from langchain_litellm import ChatLiteLLM
from langgraph.graph import END, START, StateGraph

COMPLETION_MODEL = "anthropic/claude-sonnet-4-6"

llm = ChatLiteLLM(model=COMPLETION_MODEL)
retriever = vector_store.as_retriever(search_kwargs={"k": 2})

class RAGState(TypedDict):
    question: str
    documents: list[Document]
    answer: str

def retrieve_node(state: RAGState) -> dict:
    """S3 Vectorsから関連ドキュメントを取得する。"""
    docs = retriever.invoke(state["question"])
    return {"documents": docs}

def generate_node(state: RAGState) -> dict:
    """取得したドキュメントをコンテキストにしてLLMで回答を生成する。"""
    context = "\n".join(d.page_content for d in state["documents"])
    messages = [
        {
            "role": "system",
            "content": (
                "以下の社内ドキュメントの情報のみをもとに質問に回答してください。"
                "ドキュメントに記載がない場合は「情報が見つかりませんでした」と回答してください。\n\n"
                f"{context}"
            ),
        },
        {"role": "user", "content": state["question"]},
    ]
    response = llm.invoke(messages)
    return {"answer": response.content}

# retrieve → generate の2ノードグラフを構築
graph = StateGraph(RAGState)
graph.add_node("retrieve", retrieve_node)
graph.add_node("generate", generate_node)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)

app = graph.compile()

queries = [
    "リモートワークは週何日までできますか?",
    "健康診断の費用は自己負担ですか?",
]

for query in queries:
    print(f"\n{'='*50}")
    print(f"質問: {query}")
    print(f"{'='*50}")
    result = app.invoke({"question": query})
    print("\n[検索ヒット]")
    for d in result["documents"]:
        print(f"- {d.page_content}")
    print(f"\n[回答] {result['answer']}")

ポイントは以下です。

  • RAGStatequestiondocumentsanswerの3フィールドを定義。documentsDocumentのリストで、retrieveの結果がそのままステートに保持される
  • retrieve_nodestate["question"]を読んでS3 Vectorsを検索し、{"documents": docs}を返してステートを更新
  • generate_nodestate["documents"]からコンテキストを組み立ててLLMに渡す
  • グラフはSTART → retrieve → generate → ENDの直列フロー

LCEL版と機能的には同じですが、result["documents"]で実際に検索にヒットしたドキュメントを取り出せるので、デバッグや評価(取得精度のロギング、ヒットしなかったときの分岐など)を入れる際に拡張しやすい構成です。

実行結果は以下のようになりました。

$ python langgraph_rag.py

==================================================
質問: リモートワークは週何日までできますか?
==================================================

[検索ヒット]
- リモートワークは週3日まで可能です。事前申請は不要ですが、勤怠システムへの記録が必要です。
- 経費精算は月末締め、翌月15日払いです。領収書の原本またはスキャンデータの提出が必要です。

[回答] リモートワークは週3日まで可能です。

==================================================
質問: 健康診断の費用は自己負担ですか?
==================================================

[検索ヒット]
- 健康診断は年1回、会社負担で受診できます。35歳以上は人間ドックも選択可能です。
- 有給休暇は入社6ヶ月後に10日付与されます。申請は3営業日前までに上長の承認が必要です。

[回答] いいえ、自己負担ではありません。健康診断は年1回、会社負担で受診できます。

[検索ヒット]に実際にS3 Vectorsから返ってきたpage_contentが出ているのが確認できます。ここに条件分岐を入れれば、「ヒットしなかったらWeb検索ツールにフォールバック」「最上位の距離が閾値より遠ければ情報が見つかりませんでしたを返す」といったcorrective-RAG系の拡張がしやすくなります。

パターン3: retrieverをツールにしたエージェント型RAG

最後はretrieverを@toolで包んでcreate_agent()に渡す、エージェント型のRAGです。LLM自身が「ツールを呼ぶか・直接答えるか」を判断するので、社内制度の質問はS3 Vectorsを検索し、雑談には検索せず直接答える、といった切り分けを自動でやってくれます。

agentic_rag.py
from common import vector_store
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_litellm import ChatLiteLLM

COMPLETION_MODEL = "anthropic/claude-sonnet-4-6"

retriever = vector_store.as_retriever(search_kwargs={"k": 3})

@tool
def search_company_docs(query: str) -> str:
    """社内規定や制度に関するドキュメントをS3 Vectorsから検索します。"""
    docs = retriever.invoke(query)
    if not docs:
        return "該当するドキュメントが見つかりませんでした"
    return "\n\n".join(d.page_content for d in docs)

system_prompt = (
    "あなたは社内ドキュメントに基づいて質問に回答するアシスタントです。\n"
    "以下のルールに従ってください:\n"
    "1. 社内制度に関する質問には、必ず search_company_docs ツールで検索してから回答してください\n"
    "2. 検索結果に情報がない場合は「社内ドキュメントに該当する情報が見つかりませんでした」と回答してください\n"
    "3. 一般的な知識で回答できる質問(挨拶など)には、ツールを使わず直接回答してください\n"
    "4. 回答は簡潔にまとめてください"
)

llm = ChatLiteLLM(model=COMPLETION_MODEL)
agent = create_agent(llm, tools=[search_company_docs], system_prompt=system_prompt)

queries = [
    "リモートワークのルールを教えてください",
    "こんにちは!",
    "育児休業はいつまで取れますか?",
    "Pythonのリスト内包表記について教えてください",
]

for query in queries:
    print(f"\n{'='*60}")
    print(f"質問: {query}")
    print(f"{'='*60}")
    result = agent.invoke({"messages": [{"role": "user", "content": query}]})
    print(f"回答: {result['messages'][-1].content}")

S3 Vectorsの検索処理はsearch_company_docsツールの中に閉じ込められ、LLMからはdocstring(「社内規定や制度に関するドキュメントをS3 Vectorsから検索します。」)だけを根拠にツールを呼ぶかどうかを判断します。docstringの書き方が呼び出し精度に直結するので、ツールの責務を簡潔に書くのがポイントです。

実行結果は以下のようになりました。

$ python agentic_rag.py

============================================================
質問: リモートワークのルールを教えてください
============================================================
回答: リモートワークは週3日まで可能です。事前申請は不要ですが、勤怠システムへの記録が必要です。

============================================================
質問: こんにちは!
============================================================
回答: こんにちは!社内制度に関するご質問があればお気軽にどうぞ。

============================================================
質問: 育児休業はいつまで取れますか?
============================================================
回答: 育児休業は子が1歳になるまで取得可能です。延長の場合は最長2歳まで認められます。

============================================================
質問: Pythonのリスト内包表記について教えてください
============================================================
回答: 申し訳ありませんが、こちらは社内ドキュメントに基づいて回答するアシスタントのため、Pythonに関する技術的な質問にはお答えできません。

社内制度系の質問ではsearch_company_docsが呼ばれてS3 Vectorsの検索結果に基づいた回答が返り、雑談や対象外の質問ではツールを呼ばずに直接応答していることが確認できます。

メタデータフィルタを使う

AmazonS3Vectorsではsimilarity_searchas_retrieversearch_kwargsにフィルタ条件を渡すことで、S3 Vectorsのメタデータフィルタを利用できます。common.pyでは各ドキュメントにcategoryメタデータを付けていたので、これを使って「休暇関連のドキュメントだけから検索する」というretrieverを作れます。

leave_retriever = vector_store.as_retriever(
    search_kwargs={
        "k": 3,
        "filter": {"category": {"$eq": "leave"}},
    }
)

docs = leave_retriever.invoke("休みについて教えて")
for d in docs:
    print(d.page_content)

$eq(等しい)以外にも$gte$lte$and$orなどの演算子が使えます。エージェント型RAGの場合、検索対象のスコープが違うツールを複数並べ(例: 休暇用、経費用、健康関連用)、LLMに使い分けさせるという設計もできます。

まとめ

LangGraphエコシステムからAmazon S3 Vectorsを利用するRAG実装を3つのパターンで試してみました。

  • シンプルRAG(LCEL): retriever→prompt→LLMを一直線につなぐ最小構成
  • StateGraph版: retrieve / generateをノードに分け、検索結果をステートで観測可能にした構成
  • エージェント型RAG: retrieverを@tool化してLLMに使い方を任せる構成

langchain-awsAmazonS3Vectorsによって、S3 Vectorsが他のLangChainベクトルストアと同じインターフェースで扱えるため、ChromaDBFAISSで書かれた既存のRAGコードからの移行もvector_storeの差し替えだけでだいたい済みます。バックエンドをマネージドサービスに寄せたい・ベクトル数が増えてもインフラを気にしたくない、というユースケースではS3 Vectorsへの差し替えはかなり手軽な選択肢です。

ChatLiteLLMのおかげで上流のLLMはanthropic/claude-sonnet-4-6openai/gpt-5.5gemini/gemini-2.5-flashに差し替えるだけで切り替えられるので、「埋め込みはBedrock + S3 Vectorsで固定、生成側はモデルを比較したい」といった構成も同じコードのまま試せます。

最後まで読んでいただきありがとうございました。

この記事をシェアする

関連記事