RFCの内容をもとに回答するAIチャットアプリを作ってみた
はじめに
最近、生成AIやRAG(Retrieval Augmented Generation)という言葉を目にする機会が増えてきましたが、「何ができるのか」「どうやって使えばよいのか」といった点はまだイメージしにくい方も多いのではないかと思います。
私自身もその一人でしたが、今回、RFC(Request for Comments)という技術文書をもとに回答するAIチャットアプリを試しに作ってみました。
この記事では、そのアプリの構成や仕組み、RAGを取り入れる際に工夫した点などを紹介します。
本格的なプロダクトというよりは、動くことを優先したシンプルなデモアプリなので、完成度としてはまだまだですが、RAGに初めて触れる方にとって、構築の流れや実装のとっかかりとして何か参考になる部分があれば幸いです。
どんなアプリを作ったのか
私の職場では、調べ物をする際の一次情報として、RFC(インターネット技術の仕様書)をよく参照しますが、内容が専門的かつ文量も膨大なため、目的の情報を探すのに時間がかかることがよくあります(RFCの例:RFC 5322, RFC 7489, ...)。
そこで、膨大なRFCを読み込む負担を軽くするため、RFCをもとに質問に回答するAIチャットアプリを作成しました。回答には、どのRFCのどの部分を参照しているかもあわせて表示されるようにしています(チャットの画面は以下の通りです)。
今回のアプリのGitHubリポジトリはこちらです。
アプリの構成と仕組み
このアプリは大きく以下の3つの要素から構成されています。
-
RFC文書の取り込み
RFC文書をベクトル化し、ベクトルDB(Pinecone)に取り込みます。 -
関連情報の検索
ユーザから投げられた質問(クエリ)をベクトル化し、ベクトルDBで検索にかけます。ベクトルDBでは、クエリベクトルと空間的に近い(意味的に近い)ベクトルを探索します。 -
回答の生成
2で得られた関連情報とユーザからの質問内容を合わせて、回答方法を指示したプロンプトを作成します。生成AIは、このプロンプトをもとに回答を生成します。
以上の流れは、一般的なRAGの仕組みに沿って構成しています。RAGについてより詳しく知りたい方は、以下のページが参考になると思います(英語のページです)。
利用サービスの一覧
今回使用したサービスは以下の通りです。基本的には、無料で利用可能なサービスを選定しています。Webアプリの開発には、ReactベースのフレームワークであるNext.jsを使用しています。
用途 | サービス | 課金の有無 |
---|---|---|
ホスティング | Vercel | 無料 |
Webアプリフレームワーク | Next.js | 無料 |
UIデザインライブラリ | shadcn/ui | 無料 |
LLM開発(UI構築) | Vercel AI SDK | 無料 |
LLM開発(バックエンド処理) | LangChain | 無料 |
ベクトル化用モデル | text-embedding-3-small(OpenAI) | 有料(トークン単位で課金) |
回答生成用モデル | Gemini 2.0 Flash(Google) | 無料 |
ベクトルDB | Pinecone | 無料 |
開発
ここからは、アプリ開発の流れを順序立てて説明します。
1. RFC文書を収集する
Pineconeの無料枠(Starterプラン)ではストレージの容量が2 GBまでに制限されています。
そのため今回は、業務上必要なメール技術に関連するRFC文書にのみ絞ってベクトルDBに取り込むことにしました。
今回収集したRFCの一覧
📬 メール転送・配信プロトコル
RFC# | タイトル | 概要 |
---|---|---|
1939 | Post Office Protocol - Version 3 | メール受信プロトコルPOP3の仕様 |
3501 | INTERNET MESSAGE ACCESS PROTOCOL - VERSION 4rev1 | メール受信プロトコルIMAP4rev1の仕様 |
5321 | Simple Mail Transfer Protocol | メール送信プロトコルSMTPの仕様 |
4409 | Message Submission for Mail | メールの送信手続き仕様 |
📝 メッセージフォーマット・MIME
RFC# | タイトル | 概要 |
---|---|---|
5322 | Internet Message Format | メールの本文やヘッダの構造の定義 |
2045 | MIME Part One: Format of Internet Message Bodies | MIMEのフォーマット定義 |
2046 | MIME Part Two: Media Types | MIMEメディアタイプ定義 |
2047 | MIME Part Three: Message Header Extensions for Non-ASCII Text | ヘッダ用エンコード方式 |
2048 | MIME Part Four: Registration Procedures | メディアタイプ登録手順 |
2049 | MIME Part Five: Conformance Criteria and Examples | MIME準拠基準と実例 |
🔒 認証・なりすまし対策
RFC# | タイトル | 概要 |
---|---|---|
6376 | DKIM Signatures | DKIM署名による認証方式 |
5617 | DKIM ADSP | DKIMと公開署名ポリシー |
7208 | SPF for Authorizing Use of Domains in Email, Version 1 | なりすまし防止のSPF定義 |
7489 | DMARC | DMARCによる送信ドメイン認証 |
8616 | Email Authentication for Internationalized Mail | SPF/DKIM/DMARCでの国際化ドメイン名の扱い |
8617 | The Authenticated Received Chain (ARC) Protocol | メール転送時に認証情報を引き継ぐARCプロトコルの仕様 |
8463 | A New Cryptographic Signature Method for DKIM | DKIMにおける新しい署名方式 |
🌐 国際化と多言語対応
RFC# | タイトル | 概要 |
---|---|---|
6530 | Overview and Framework for Internationalized Email | 国際化対応メールの枠組み |
6531 | SMTP Extension for Internationalized Email | 国際化対応のためのSMTP拡張 |
6532 | Internationalized Email Headers | メールヘッダの国際化対応 |
6533 | Internationalized Delivery Status and Disposition Notifications | 配信通知の国際化対応 |
📬 配信通知とスパム対策
RFC# | タイトル | 概要 |
---|---|---|
2505 | Anti-Spam Recommendations for SMTP MTAs | SMTP MTAにおけるスパム対策の推奨事項 |
3461 | SMTP Service Extension for DSNs | 配信通知のためのSMTPの拡張仕様 |
3464 | An Extensible Message Format for Delivery Status Notifications | 配信通知の拡張メッセージ形式 |
3798 | Message Disposition Notification | メッセージ処理通知の仕様 |
🔐 その他の関連RFC
RFC# | タイトル | 概要 |
---|---|---|
4409 | Message Submission for Mail | メールの送信手続き仕様 |
5788 | IMAP4 Keyword Registry | IMAPキーワードのIANA登録手順 |
8058 | Signaling One-Click Functionality for List Email Headers | ワンクリック配信停止機能の実装 |
2. RFC文書のデータを格納するためのインデックスを作成する
Pineconeのアカウントを登録しコンソール画面にログインしたら、Create index
ボタンを押します。
インデックスの名前と今回使用するモデルtext-embedding-3-small
を指定し、Create index
ボタンを押すと、インデックスが作成されます。ベクトル化したデータを取り込む際は、このインデックスを指定します。
3. 収集したRFC文書をベクトルDBに取り込む
以下の流れでRFC文書をベクトルDBに格納しました。
- RFC文書をダウンロードする
- ダウンロードした文書をセクションごとに分割する
- 各セクションをチャンク分割する
- 各チャンクをベクトル化する
- ベクトル化したデータをベクトルDBにアップロードする
文書の分割(チャンキング)
文書のベクトル化に使用する生成AIの埋め込みモデルでは、一度にベクトル化できるデータ長が制限されており、文書全体を一度に丸ごとベクトル化することはできません。
今回使用するOpenAIのtext-embedding-3-small
の場合、ベクトル化の最大入力トークンは8,191トークンまでとされています。
したがって、データ長の制限を超えないよう、文書をより小さな単位(チャンク)に分割したうえでベクトル化する必要があります。
また、このチャンク分割はRAGの回答精度を向上させる観点でも重要な処理です。
各チャンクのサイズ(チャンクサイズ)やチャンク間の重複度合い(オーバーラップサイズ)によって、回答精度に差が生まれます。
チャンクサイズとオーバーラップサイズについては、以下の記事を参考にしながら微調整し、良い感じの回答が生成される条件をざっくりと見積もりました。
最終的に、以下の条件としています。
- チャンクサイズ:500トークン
- オーバーラップサイズ:100トークン(チャンクサイズの20%)
メタデータの付与
引用元のチャンクが所属するセクションに関する情報を、各チャンクにメタデータとして付与することができます。これにより、どのRFCのどのセクションの文章を引用したのかを、回答と併せて表示できるようになります。
例えば、メタデータのrfc_number
とsection_anchor
を用いると、以下のように引用元セクションのURLを作成できます。これを回答の際に表示します。
https://datatracker.ietf.org/doc/html/rfc${rfc_number}#${section_anchor}
このように、メタデータを付与することで、検索結果の活用性が向上します。
実行コードの紹介
以下のコードをGoogle Colaboratory上で実行し、RFC文書のダウンロードからベクトルDBへのアップロードまでを行いました。
実行コード
import os
import re
import requests
from typing import List
from google.colab import userdata
from langchain_openai import OpenAIEmbeddings
from langchain_pinecone import PineconeVectorStore
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 認証情報
os.environ['OPENAI_API_KEY'] = userdata.get("OPENAI_API_KEY")
os.environ['PINECONE_API_KEY'] = userdata.get("PINECONE_API_KEY")
# LangChainの設定
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
index_name = "YOUR_INDEX_NAME" # Pineconeで設定したインデックスの名前を指定
vectorstore = PineconeVectorStore(index_name=index_name, embedding=embeddings)
# RFC本文取得
def download_rfc_text(rfc_number: str) -> str:
url = f"https://www.rfc-editor.org/rfc/rfc{rfc_number}.txt"
response = requests.get(url)
response.raise_for_status()
return response.text
# slugifyユーティリティ
def slugify(text: str) -> str:
text = text.lower().replace(" ", "-")
return re.sub(r"[^a-z0-9\-]", "", text)
# セクション分割(タイトルを含める)
def split_into_sections(text: str) -> List[dict]:
lines = text.splitlines()
sections = []
current_title = None
current_text = []
section_re = re.compile(r"^(\d+(?:\.\d+)*\.)\s{1,2}([A-Z].+)$")
appendix_re = re.compile(r"^Appendix\s+([A-Z])\.\s{1,2}(.+)$")
for line in lines:
match = section_re.match(line)
appendix = appendix_re.match(line)
if match or appendix:
if current_title:
sections.append({
"id": slugify(current_title),
"title": current_title,
"text": "\n".join(current_text).strip()
})
if match:
current_title = f"{match.group(1).rstrip('.')} {match.group(2).strip()}"
elif appendix:
current_title = f"Appendix {appendix.group(1)} {appendix.group(2).strip()}"
current_text = []
else:
if current_title:
current_text.append(line)
if current_title:
sections.append({
"id": slugify(current_title),
"title": current_title,
"text": "\n".join(current_text).strip()
})
return sections
# セクション → チャンクに分割(トークンベース)しメタデータ付与
def split_section_into_chunks(rfc_number: str, section: dict) -> List[Document]:
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=500,
chunk_overlap=100
)
chunks = splitter.split_text(section["text"])
documents = []
# section_anchorの生成
title = section["title"]
anchor = ""
section_match = re.match(r"^(\d+(?:\.\d+)*)\s", title)
appendix_match = re.match(r"^Appendix\s+([A-Z](?:\.\d+)*)(\s|$)", title)
if section_match:
anchor = f"section-{section_match.group(1)}"
elif appendix_match:
appendix_id = appendix_match.group(1).replace(".", ".")
anchor = f"appendix-{appendix_id}"
for chunk in chunks:
documents.append(Document(
page_content=chunk,
metadata={
"rfc_number": rfc_number,
"section_id": section["id"],
"section_title": section["title"],
"section_anchor": anchor
}
))
return documents
# メイン処理(複数RFC対応)
def process_rfc_documents(rfc_numbers: List[str]):
all_docs = []
for rfc_number in rfc_numbers:
try:
print(f"📥 Processing RFC {rfc_number}...")
text = download_rfc_text(rfc_number)
sections = split_into_sections(text)
for section in sections:
chunk_docs = split_section_into_chunks(rfc_number, section)
print(chunk_docs)
all_docs.extend(chunk_docs)
except Exception as e:
print(f"❌ Failed to process RFC {rfc_number}: {e}")
if all_docs:
print(f"🚀 Uploading {len(all_docs)} chunks to Pinecone...")
vectorstore.add_documents(all_docs)
print("✅ Upload complete.")
else:
print("⚠️ No documents to upload.")
# 実行(複数RFC番号対応)
process_rfc_documents(["5321"])
4. アプリを実装する
実装コードの紹介
ユーザから質問を受け取り、ベクトルDBで関連情報を検索して回答を生成するまでの処理を以下のコードで実装しました。
実装コード
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { LangChainAdapter, Message } from 'ai';
import { createVectorStore } from "@/app/utils/pinecone";
import { OpenAIEmbeddings } from "@langchain/openai";
import { translateToEng } from '@/app/utils/translate';
import { buildPrompt } from '@/app/utils/prompt'; // プロンプト生成関数
export const maxDuration = 30;
export async function POST(req: Request) {
try {
// 1. アプリのクライアントから質問を取得
const { messages }: { messages: Message[] } = await req.json();
const question = messages[messages.length - 1]?.content;
if (!question) {
return new Response('No question provided', { status: 400 });
}
// 2. 質問を英語に翻訳
const translatedQuestion = await translateToEng(question);
// 3. 質問をベクトル化
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-small"
});
// 4. Pinconeで類似検索
const vectorStore = await createVectorStore(embeddings);
const searchResults = await vectorStore.similaritySearch(
translatedQuestion,
20
);
// 5. 検索結果からコンテキストを生成
const contexts = searchResults.map(result => {
const rfcNumber = result.metadata?.rfc_number;
const sectionAnchor = result.metadata?.section_anchor;
const url = `https://datatracker.ietf.org/doc/html/rfc${rfcNumber}#${sectionAnchor}`;
return `${result.pageContent}\n\n[参照: ${url}]`; // コンテキストにURLを含める
}).join('\n----------------------------------------------------------------------------\n');
// 6. プロンプトを生成
const prompt = buildPrompt(contexts, question);
// 7. AIモデルで回答を生成
const llm = new ChatGoogleGenerativeAI({
model: "gemini-2.0-flash"
})
const stream = await llm.stream(prompt);
// 8. クライアントへ回答を返却
return LangChainAdapter.toDataStreamResponse(stream); // https://sdk.vercel.ai/docs/reference/stream-helpers/langchain-adapter
} catch (error) {
console.error('Error processing request:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
処理の流れは以下の通りです。
-
アプリのクライアントから質問を取得
クライアントから送信されたJSONリクエストからmessages
配列を受け取り、その最後の内容(最新の質問)を取得します。 -
質問を英語に翻訳
translateToEng
関数で質問を英語に翻訳します。translateToEng関数
src/app/utils/translate.tsimport { google } from '@ai-sdk/google'; import { generateText } from 'ai'; export async function translateToEng(input: string): Promise<string> { const result = await generateText({ model: google('models/gemini-2.0-flash'), prompt: `Translate the following question to English:\n\n"${input}"`, }); return result.text.trim(); }
-
質問をベクトル化
OpenAIの埋め込みモデルtext-embedding-3-small
を使って、質問をベクトルに変換します。 -
Pineconeで類似検索
Pineconeのベクトルストアを利用し、ベクトル化した質問に対して20件の類似ドキュメントを検索します。 -
検索結果からコンテキストを生成
検索結果の各ドキュメントからページ内容と参照URL(RFC文書の該当セクション)を組み合わせて、AIへの入力コンテキストを作成します。 -
プロンプトを生成
buildPrompt
関数で、作成したコンテキストと元の質問を組み合わせてAIへのプロンプト(指示文)を作ります。buildPrompt関数
src/app/utils/prompt.tsexport function buildPrompt(contexts: string, question: string): string { return ` あなたはRFCに基づいて正確に答えるAIアシスタントです。 以下のルールに従って回答してください。 - 以下のコンテキストに基づいて正確に日本語で回答してください。 - 回答の際は、専門用語の使用を避け、必ず一般的な日本語を用いて具体的に説明してください。 - 回答の最後には、回答作成時に参照したRFCのセクション(例:[RFC 5322 Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)のリンク形式)とそのセクションの原文をコードブロックで示してください。 - セクションの原文を含むコードブロックは、該当セクションへのリンクの直後に配置してください。 - コンテキストに答えがない場合は「分かりません」とだけ答えてください。 質問: ${question} コンテキスト: ※破線(----------------------------------------------------------------------------)で区切られた部分は、コンテキストの区切りを示しています。 ${contexts} 回答: `; }
-
AIモデルで回答を生成
Googleの生成AIモデルgemini-2.0-flash
を使い、ストリーム形式で回答を生成します。 -
クライアントへ回答を返却
LangChainAdapter.toDataStreamResponse
を使い、生成した回答をストリーミングレスポンスとしてクライアントに返します。
その他の詳細な実装については、GitHubのリポジトリをご覧ください。
LangChainおよびVercel AI SDKの活用
今回はLangChainおよびVercel AI SDKを活用し、実装を効率化しました。
-
LangChain
大規模言語モデル(LLM)を使ったアプリケーション開発を支援するオープンソースのフレームワークです。これを用いることで、AIモデルやベクトルDBの種類に依存しない柔軟な実装が可能になります。 -
Vercel AI SDK
VercelやNext.jsを用いた環境でAI機能(特にAIチャットやストリーミング応答)を簡単に組み込むためのSDK(ソフトウェア開発キット)です。OpenAIやGeminiなどのAIモデルに関係なくメッセージ構造(Message[]
型)やレスポンス形式が統一されているため、AIモデルの切り替えも容易になります。
アプリの動作イメージ
最後に改めて、実装したアプリの動作イメージを説明します。
まず、「メールが送信される仕組みを教えてください。」という質問を投げると、以下のようにRFCの内容に沿って回答が返されます。回答の際に専門用語の使用は避けるようプロンプトで指示しているため、理解しやすい平易な言葉で説明してくれます。
回答と併せて引用元のセクションや原文も表示しているため、どのRFCのどの部分に関連情報があるのかが一目でわかります(これもプロンプトの指示通りです)。
また、引用元セクションへのリンク「RFC 5321 Section 2.1」をクリックすると、RFCの該当セクションに遷移するため、周辺の文章を読み進めることもできます。
RFCに書かれていない情報については、もちろん、「わかりません」と返答します。
終わりに
今回は、RFCの内容をもとに質問に答えるAIチャットアプリを、RAGという技術を用いて作成した事例をご紹介しました。
私自身、RAGや生成AIの活用にはまだまだ不慣れな点も多く、模索しながらの開発となりましたが、実際に動くアプリを作ってみることで、仕組みや可能性を肌で感じることができました。特に、RFCのような専門的で膨大な情報を扱う場面では、こうした仕組みが非常に有効であると実感しています。
生成AI周りの技術の進歩は目まぐるしいため、もっと簡単に実装できる方法もあると思いますが、これからRAGを触ってみたい方にとって、この記事が何かしら参考になれば嬉しいです。
最後まで読んでいただきありがとうございました!
Discussion