👨‍💻

【生成AI入門】フレームワークを使用せずにゼロからシンプルなRAGを実装

はじめに

みなさんこんにちは。グエン・タイ・ゴックと申します。本記事では、フレームワークを使用せずにゼロからシンプルなRAGを実装する方法について解説します。

本記事は、前回の【生成AI入門】RAGの論理的基盤の続編であり、理論を踏まえて実装にフォーカスします。未読の方は先に前回記事をご覧いただくと理解がスムーズです。

本記事では、フレームワーク(LangChainなど)をあえて使用せず、ゼロから実装することで内部処理の理解を深めることを目的としています。フレームワークは多くの処理を抽象化しているため、その裏側を把握しづらいという側面があるためです。また、本記事で扱うRAGはあくまでシンプルな構成にとどめており、実務レベルの高度な最適化や複雑な設計は含まれていません。RAGの各コンポーネントや処理の流れといった基本的な概念を理解することに重点を置いています。

事前準備

本記事では、実装環境としてKaggle Notebookを使用します。そのため、事前にKaggleアカウントを作成し、ログインしたうえで、新しいNotebookを作成してください。

また、実装にあたっては、データセットとして Japanese FakeNews Dataset を事前に追加する必要があります。Notebook画面右側の「Add input」ボタンからデータセット名で検索し、追加してください。

さらに、ライブラリのインストールを行うために、Notebookの設定でインターネット接続(Internet)を有効化しておく必要があります。Notebook画面右側から「Session options」部分で、「Internet」をONにして下さい。

これらの準備を整えておくことで、スムーズに実装を進めることができます。

実装

RAGの処理は、インデキシング・検索・生成の3つのフェーズに分かれています。以下では、それぞれのフェーズを順番に実装していきます。

本記事に登場するコードブロックは、それぞれ1つのKaggle Notebookのコードセルに対応しています。順番通りに新しいセルを作成し、コードをコピペして実行してください。各コードブロックの下には、その処理内容の解説を記載していますので、実行結果と合わせてご確認ください。

各ステップでprint(data_frame["カラム名"].iloc[行番号])を使うと、処理結果を随時確認できます。

また、Notebookを開いた際に最初から用意されているコードセルを実行すると、/kaggle/input/datasets/tanreinama/japanese-fakenews-dataset/fakenews.csv というファイルパスが表示されます。パスが正しく表示されていることを確認してから、以降の実装に進んでください。

インデキシング(Indexing)

インデキシングとは、外部ドキュメントを検索可能な形式に変換してVector DBに保存するフェーズです。具体的には、テキストのクリーニング・チャンク化・ベクトル化という一連の処理を行います。

  1. データ収集
df = pd.read_csv('/kaggle/input/datasets/tanreinama/japanese-fakenews-dataset/fakenews.csv')

まず、Kaggleに追加したJapanese FakeNews Datasetを読み込みます。pd.read_csvを使用することで、CSVファイルをDataFrame形式として扱えるようになります。

fake_news = df[df["isfake"] == 2][["id", "context"]].head(5)

データの中からフェイクニュース(isfake == 2)のみを抽出し、先頭5件を取得しています。

データセットの isfake カラムには3種類の値が含まれています。0 が本物の記事、1 が部分的にフェイク、2 が完全なフェイクです。今回あえて完全なフェイクニュースを使用するのは、LLMの事前学習データには含まれていない情報であるため、外部ドキュメントから情報を補完するというRAGの目的をより明確に示せるからです。

また、カラムはidcontextの2つのみを取得しています。idは後続のメタデータ構築で各チャンクの出典を管理するために使用し、contextはクリーニング・チャンク化・ベクトル化の対象となる本文テキストです。

  1. テキストのクリーニング
import re

def preprocess(text: str) -> str:
    """テキストから不要な文字・記号を除去し、クリーンな文字列を返す。"""
    # 1. 制御文字(改行・タブなど)をスペースに置換
    text = re.sub(r'[\r\n\t]', ' ', text)

    # 2. 不要な記号を削除(日本語・英数字・基本句読点のみ残す)
    text = re.sub(r'[^\wぁ-んァ-ン一-龥。、!?\s]', '', text)

    # 3. 余分なスペースを削除
    text = re.sub(r'\s+', ' ', text).strip()

    return text

改行・タブ・不要な記号を取り除き、テキストをシンプルな形に整えています。

fake_news["clean_text"] = fake_news["context"].apply(preprocess)

処理結果は元のfake_newsclean_textという新しいカラムとして追加されます。

  1. ドキュメントのチャンク化
!pip install -q transformers

今回はトークン数を基準にチャンクを分割する方法を採用します。transformersHugging Faceが提供するオープンソースライブラリで、自然言語処理・画像・音声など幅広いタスクに対応した事前学習済みモデルを簡単に利用できます。トークン数基準の分割を行うには、モデルと同じ語彙でテキストをトークン化する必要があるため、ここではtransformersのトークナイザーを使用します。

トークンと単語の違いは一見同じように見えますが、LLMの世界では明確に異なります。トークナイザーはテキストを意味の単位ではなく、モデルが学習した語彙(vocabulary)に基づいた部分文字列に分割します。

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/paraphrase-multilingual-mpnet-base-v2")

paraphrase-multilingual-mpnet-base-v2は、日本語を含む50以上の言語に対応した多言語埋め込みモデルです。文章の意味的な類似度を計算することに特化しており、異なる言語間でも高精度な検索が可能です。モデルサイズは約1GBと比較的軽量で、Kaggle Notebookの無料環境でも問題なく動作します。このモデルは後続の埋め込みフェーズでも引き続き使用します。

from typing import List

def chunk_by_token(text: str, max_tokens: int = 100, overlap: int = 20) -> List[str]:
    encoded = tokenizer(
        text,
        max_length=max_tokens,
        truncation=True,
        return_overflowing_tokens=True,
        stride=overlap,
        return_tensors=None
    )
    return [
        tokenizer.decode(ids, skip_special_tokens=True)
        for ids in encoded["input_ids"]
    ]

長い文章をそのまま扱うのではなく、max_tokensで指定したトークン数を上限としてチャンクに分割しています。overlapは隣り合うチャンク間で共有するトークン数を指定するパラメータです。こうすることで、チャンク境界をまたいだ文脈の断絶を防ぎ、検索精度の低下を抑えることができます。

fake_news["chunks"] = fake_news["clean_text"].apply(chunk_by_token)

処理結果はfake_newschunksという新しいカラムとして追加されます。

  1. メタデータによるタグ付け
def build_metadata(chunks, source_id):
    return [{"source": source_id, "chunk": i} for i in range(len(chunks))]

各チャンクに対して「どのデータから生成されたものか(source)」と「何番目のチャンクか(chunk)」という情報を付与しています。

fake_news["metadata"] = fake_news.apply(
    lambda row: build_metadata(row["chunks"], row["id"]),
    axis=1
)

処理結果はfake_newsmetadataという新しいカラムとして追加されます。

  1. 埋め込み
!pip install -q sentence-transformers

テキストをベクトルに変換する方法として、transformersライブラリを直接使う方法もありますが、その場合は実装がやや複雑になります。より簡単に埋め込みを扱うために、今回はtransformersをベースに構築された高レベルライブラリであるsentence-transformersを使用します。

from sentence_transformers import SentenceTransformer
embedding_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-mpnet-base-v2")

事前学習済みモデルを読み込み、テキストをベクトルに変換するモデルを準備します。チャンク化で使用したトークナイザーと同じモデルを使用することで、トークンの解釈が一貫します。

fake_news["embeddings"] = fake_news["chunks"].apply(
    lambda chunks: [embedding_model.encode(chunk) for chunk in chunks]
)

各チャンクを768次元の埋め込みベクトルに変換します。このベクトルはテキストの意味を数値として表現しており、後続の検索フェーズでコサイン類似度を用いた意味的検索を可能にします。処理結果はfake_newsembeddingsという新しいカラムとして追加されます。

  1. ベクトル保存
!pip install -q chromadb

生成した埋め込みベクトルは、ベクトルデータベースに保存します。ベクトルデータベースは通常のデータベースとは異なり、ベクトル間の類似度に基づいた高速な検索に特化しています。今回はChromaDBを使用します。ChromaDBはオープンソースの軽量なベクトルデータベースで、追加の設定なしにインメモリで動作するため、今回のような学習目的の実装に適しています。

import chromadb

client = chromadb.Client()
collection = client.create_collection(
    "rag_demo", 
    configuration={"hnsw": {"space": "cosine"}}
)

configurationではインデックスの設定を指定しており、ここではHNSW(近傍探索アルゴリズム)を使用し、ベクトル間の類似度計算にコサイン類似度(cosine)を利用するように設定しています。これにより、RAGなどのユースケースで効率的に類似検索が可能になります。

import chromadb を実行した際にエラーが発生した場合は、「Restart and run all cells」をクリックして、再度ご確認下さい。

from itertools import chain

all_chunks = list(chain.from_iterable(fake_news["chunks"]))
all_metadatas = list(chain.from_iterable(fake_news["metadata"]))
all_embeddings = list(chain.from_iterable(fake_news["embeddings"]))
all_ids = [f"{row['id']}_chunk_{i}" for _, row in fake_news.iterrows() for i in range(len(row["chunks"]))] 

ベクトルデータベースでは「1チャンク=1レコード」として登録する必要があるため、ネストされたリストをchain.from_iterableでフラット化しています。all_idsには各チャンクを一意に識別するIDを生成しています。

collection.add(documents=all_chunks, embeddings=all_embeddings, metadatas=all_metadatas, ids=all_ids)

フラット化したデータをベクトルデータベースに登録します。

検索(Retrieval)

検索フェーズでは、ユーザーのクエリを同じベクトル空間に埋め込み、コサイン類似度が高いチャンクを取得します。

def semantic_search(collection, query_embedding: list, n_results: int = 5):
    """クエリエンベディングに基づいて意味的類似度検索を行う。"""
    return collection.query(
        query_embeddings=[query_embedding],
        n_results=n_results,
        include=["documents", "metadatas"]
    )

collection.queryはクエリベクトルに最も近いn_results件のチャンクを返します。includeパラメータで取得するフィールドを制限することで、不要なデータの転送を省いています。

def get_context_with_sources(results):
    """検索結果からコンテキストテキストと出典情報を整理する。"""
    documents = results["documents"][0]
    metadatas = results["metadatas"][0]

    context = "\n".join(documents)
    sources = [
        f"source={m['source']}, chunk={m['chunk']}\n  text: {doc}"
        for m, doc in zip(metadatas, documents)
    ]
    return context, sources

検索結果からコンテキストと出典情報を整理します。contextは検索で得られたチャンクを連結した文字列で、後続の生成フェーズでプロンプトに組み込まれます。sourcesは回答の根拠となったチャンクの一覧であり、RAGにおける透明性・追跡可能性を担保します。

生成(Generation)

生成フェーズでは、検索で取得したコンテキストをプロンプトに組み込み、LLMに回答を生成させます。

from transformers import pipeline

generator = pipeline(
    "text-generation",
    model="Qwen/Qwen2.5-1.5B-Instruct",
    device=-1  # CPU使用。GPUが利用可能な場合は device=0 に変更
)

pipelinetransformersライブラリが提供する高レベルAPIで、タスク名を指定するだけでモデルの読み込みから推論まで一連の処理をまとめて扱えます。今回は"text-generation"タスクを指定することで、与えられたプロンプトに続くテキストを生成するパイプラインを構築しています。

Qwen2.5-1.5B-Instructは、Alibaba Cloudが公開している軽量な命令チューニング済みモデルです。モデルはHugging Faceからダウンロードされてローカルで実行されるため、APIキーは不要です。パラメータ数が1.5Bと小さくCPUでも動作するため、Kaggle Notebookの無料環境にも適しています。

SYSTEM_INSTRUCTION = (
    "あなたはRAG(検索拡張生成)のための有用なアシスタントです。"
    "必ず提供されたコンテキストのみを使用して回答してください。"
    "コンテキスト内に答えが見つからない場合は、"
    "「提供されたドキュメントに基づいては分かりません。」と答えてください。"
)

def build_prompt(context: str, question: str) -> str:
    """LLMに渡すプロンプト文字列を構築する"""
    return (
        f"{SYSTEM_INSTRUCTION}\n\n"
        f"コンテキスト:\n{context}\n\n"
        f"質問: {question}\n"
        f"回答:"
    )

プロンプトはシステム指示・コンテキスト・質問の3つのパートで構成されています。システム指示でLLMに「コンテキスト外の知識を使わないこと」を明示することで、ハルシネーション(事実と異なる内容の生成)を抑制します。

def rag_answer(collection, query: str, n_results: int = 5):
    """クエリに対してRAG全体のパイプラインを実行する"""

    # ① クエリをベクトルに変換
    query_embedding = embedding_model.encode(query).tolist()

    # ② 意味的類似度でチャンクを検索
    results = semantic_search(collection, query_embedding, n_results)
    context, sources = get_context_with_sources(results)

    if not context.strip():
        print("関連するコンテキストが見つかりませんでした。")
        return "", []

    # ③ プロンプトを構築してLLMで回答を生成
    prompt = build_prompt(context, query)
    output = generator(
        prompt,
        max_length=None,
        max_new_tokens=200,
        do_sample=False
    )
    # output は [{"generated_text": "..."}, ...] の形式で返る
    answer = output[0]["generated_text"].split("回答:")[-1].strip()

    # ④ 結果を表示
    print("\n=== ANSWER ===\n")
    print(answer or "[回答が生成されませんでした]")

    print("\n=== 参照元 ===")
    if sources:
        for i, s in enumerate(sources, 1):
            print(f"{i}. {s}")
    else:
        print("[参照元が見つかりませんでした]")

rag_answer関数はRAG全体のパイプラインをまとめたエントリポイントです。①〜④の流れで処理が進みます。

テスト

実装が完了したら、実際にRAGを動かして動作を確認してみましょう。登録したフェイクニュースの内容を確認したうえで、その内容に関連した質問をrag_answerに渡してみてください。回答がコンテキストに基づいて生成されているか、また=== 参照元 ===に表示される出典が質問と関連したチャンクになっているかを確認することで、RAGが正しく機能しているかどうかを検証できます。

以下は3件目のフェイクニュース記事をもとに質問した例です。

rag_answer(collection, "誰がグッドウィルの派遣事業に対して批判を行いましたか?")

=== ANSWER ===

2006年2月13日には、グッドウィルグッゲンハイムアンハルクを経営するジャーナリスト、フリーライター、ジャーナリストおよび日本政府の幹部から、派遣事業の管理体制がとても曖昧になる可能性がある、派遣事業の管理体制を整えなければ派遣事業を行う社員が不当に賃金を上げられる恐れがあるなどと非難があがった。この批判は、グッドウィルの派遣事業に対するものです。ただし、具体的な名前や組織については記載されていません。

=== 参照元 ===
1. source=000c9ac3d552, chunk=1
  text: 金を上げられる恐れがあるなどと非難があがった。2009年8月29日のNHK総合テレビジョンクロちゃんで、このような事態に関し、グッドウィルは全く悪印象が付かないものではないと主張していた。
2. source=000c9ac3d552, chunk=0
  text: 毎日新聞時事通信によると、2006年2月13日には、グッドウィルグッゲンハイムアンハルクを経営するジャーナリスト、フリーライター、ジャーナリストおよび日本政府の幹部から、派遣事業の管理体制がとても曖昧になる可能性がある、派遣事業の管理体制を整えなければ派遣事業を行う社員が不当に賃金を上げられる恐れがあるなどと非難があがった。2009年8月29
3. source=00012b7a8314, chunk=3
  text: 早海人の保護に向けて請願書を添えて諫早湾干拓地に対して諫早湾干拓地の土地争奪の会として活動している。
4. source=00012b7a8314, chunk=0
  text: 11月5日の各社報道によると、諫早湾干拓事業は諫早海人諫早湾の海に囲まれる大洋に位置することから、人身売買により、環境問題に加え、環境保護にも関心が向けられた。国は諫早湾干拓事業後も諫早海人を保護する目的で、諫早海原の生態系に影響を及ぼす可能性のある植物の栽培に力を入れるよう要請している
5. source=00012b7a8314, chunk=2
  text: いて、約14万mの土地の確保を求める諫早湾干拓計画の土地争奪の会を結成した。組合理事長には諫早漁業協同組合長で、諫早干拓地に漁業協定を締結し、2017年平成29年2月5日に、干拓地の土地購入を求める請願書を諫早海人の保護に向けて請願書を添えて諫早湾干拓地に対して諫早

まとめ

本記事では、フレームワークを使用せずにシンプルなRAGをゼロから実装しました。
各コンポーネントの役割を整理すると以下のようになります。

フェーズ 処理内容
Indexing ドキュメントをクリーニング・チャンク化し、ベクトルに変換してベクトルデータベースに保存
Retrieval クエリを同じベクトル空間に埋め込み、コサイン類似度で関連チャンクを取得
Generation 取得したコンテキストをプロンプトに組み込み、LLMで回答を生成

LangChainのようなフレームワークはこれらの処理を抽象化しているため、内部で何が行われているかを把握しにくいという側面があります。本記事を通じて、RAGの各コンポーネントがどのように連携しているかをより深く理解していただけたなら幸いです。

ただし、本記事の実装はあくまで学習目的のシンプルな構成であり、実務レベルのRAGとは大きく異なります。実際のプロダクトでは精度・スケーラビリティ・コストなど多くの観点から改善が必要です。

Sun* Developers

Discussion