🤖

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リポジトリはこちらです。
https://github.com/AdaisukeV/rfc-ai-chat

アプリの構成と仕組み

このアプリは大きく以下の3つの要素から構成されています。

  1. RFC文書の取り込み
    RFC文書をベクトル化し、ベクトルDB(Pinecone)に取り込みます。
  2. 関連情報の検索
    ユーザから投げられた質問(クエリ)をベクトル化し、ベクトルDBで検索にかけます。ベクトルDBでは、クエリベクトルと空間的に近い(意味的に近い)ベクトルを探索します。
  3. 回答の生成
    2で得られた関連情報とユーザからの質問内容を合わせて、回答方法を指示したプロンプトを作成します。生成AIは、このプロンプトをもとに回答を生成します。

以上の流れは、一般的なRAGの仕組みに沿って構成しています。RAGについてより詳しく知りたい方は、以下のページが参考になると思います(英語のページです)。
https://www.pinecone.io/learn/retrieval-augmented-generation/

利用サービスの一覧

今回使用したサービスは以下の通りです。基本的には、無料で利用可能なサービスを選定しています。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までに制限されています。
https://www.pinecone.io/pricing/#plan-comparison-table

そのため今回は、業務上必要なメール技術に関連する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に格納しました。

  1. RFC文書をダウンロードする
  2. ダウンロードした文書をセクションごとに分割する
  3. 各セクションをチャンク分割する
  4. 各チャンクをベクトル化する
  5. ベクトル化したデータをベクトルDBにアップロードする

文書の分割(チャンキング)

文書のベクトル化に使用する生成AIの埋め込みモデルでは、一度にベクトル化できるデータ長が制限されており、文書全体を一度に丸ごとベクトル化することはできません。
今回使用するOpenAIのtext-embedding-3-smallの場合、ベクトル化の最大入力トークンは8,191トークンまでとされています。
https://platform.openai.com/docs/guides/embeddings/embedding-models#embedding-models

したがって、データ長の制限を超えないよう、文書をより小さな単位(チャンク)に分割したうえでベクトル化する必要があります。

また、このチャンク分割はRAGの回答精度を向上させる観点でも重要な処理です。
各チャンクのサイズ(チャンクサイズ)やチャンク間の重複度合い(オーバーラップサイズ)によって、回答精度に差が生まれます。

チャンクサイズとオーバーラップサイズについては、以下の記事を参考にしながら微調整し、良い感じの回答が生成される条件をざっくりと見積もりました。
https://zenn.dev/m_nakano_teppei/articles/28f55278088020

最終的に、以下の条件としています。

  • チャンクサイズ:500トークン
  • オーバーラップサイズ:100トークン(チャンクサイズの20%)

メタデータの付与

引用元のチャンクが所属するセクションに関する情報を、各チャンクにメタデータとして付与することができます。これにより、どのRFCのどのセクションの文章を引用したのかを、回答と併せて表示できるようになります。

例えば、メタデータのrfc_numbersection_anchorを用いると、以下のように引用元セクションのURLを作成できます。これを回答の際に表示します。

https://datatracker.ietf.org/doc/html/rfc${rfc_number}#${section_anchor}

このように、メタデータを付与することで、検索結果の活用性が向上します。

実行コードの紹介

以下のコードをGoogle Colaboratory上で実行し、RFC文書のダウンロードからベクトルDBへのアップロードまでを行いました。

実行コード
rfc_pinecone_preparer.py
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で関連情報を検索して回答を生成するまでの処理を以下のコードで実装しました。

実装コード
src/app/api/chat/route.ts
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 });
    }
}

処理の流れは以下の通りです。

  1. アプリのクライアントから質問を取得
    クライアントから送信されたJSONリクエストからmessages配列を受け取り、その最後の内容(最新の質問)を取得します。

  2. 質問を英語に翻訳
    translateToEng関数で質問を英語に翻訳します。

    translateToEng関数
    src/app/utils/translate.ts
    import { 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();
    }
    
  3. 質問をベクトル化
    OpenAIの埋め込みモデルtext-embedding-3-smallを使って、質問をベクトルに変換します。

  4. Pineconeで類似検索
    Pineconeのベクトルストアを利用し、ベクトル化した質問に対して20件の類似ドキュメントを検索します。

  5. 検索結果からコンテキストを生成
    検索結果の各ドキュメントからページ内容と参照URL(RFC文書の該当セクション)を組み合わせて、AIへの入力コンテキストを作成します。

  6. プロンプトを生成
    buildPrompt関数で、作成したコンテキストと元の質問を組み合わせてAIへのプロンプト(指示文)を作ります。

    buildPrompt関数
    src/app/utils/prompt.ts
    export 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}
    
    回答:
    `;
    }
    
  7. AIモデルで回答を生成
    Googleの生成AIモデルgemini-2.0-flashを使い、ストリーム形式で回答を生成します。

  8. クライアントへ回答を返却
    LangChainAdapter.toDataStreamResponseを使い、生成した回答をストリーミングレスポンスとしてクライアントに返します。

その他の詳細な実装については、GitHubのリポジトリをご覧ください。
https://github.com/AdaisukeV/rfc-ai-chat

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モデルの切り替えも容易になります。

https://js.langchain.com/docs/introduction/
https://ai-sdk.dev/docs/introduction

アプリの動作イメージ

最後に改めて、実装したアプリの動作イメージを説明します。

まず、「メールが送信される仕組みを教えてください。」という質問を投げると、以下のようにRFCの内容に沿って回答が返されます。回答の際に専門用語の使用は避けるようプロンプトで指示しているため、理解しやすい平易な言葉で説明してくれます。

回答と併せて引用元のセクションや原文も表示しているため、どのRFCのどの部分に関連情報があるのかが一目でわかります(これもプロンプトの指示通りです)。

また、引用元セクションへのリンク「RFC 5321 Section 2.1」をクリックすると、RFCの該当セクションに遷移するため、周辺の文章を読み進めることもできます。

RFCに書かれていない情報については、もちろん、「わかりません」と返答します。

終わりに

今回は、RFCの内容をもとに質問に答えるAIチャットアプリを、RAGという技術を用いて作成した事例をご紹介しました。
私自身、RAGや生成AIの活用にはまだまだ不慣れな点も多く、模索しながらの開発となりましたが、実際に動くアプリを作ってみることで、仕組みや可能性を肌で感じることができました。特に、RFCのような専門的で膨大な情報を扱う場面では、こうした仕組みが非常に有効であると実感しています。
生成AI周りの技術の進歩は目まぐるしいため、もっと簡単に実装できる方法もあると思いますが、これからRAGを触ってみたい方にとって、この記事が何かしら参考になれば嬉しいです。
最後まで読んでいただきありがとうございました!

Discussion