DeepSeek-R1-Distill(ローカルLLM)を用いたRAGの実装方法を解説します。

参考書籍:


3つの基礎知識

ローカルLLMとは

ローカル LLMとは、クラウド環境ではなく、PCなどのローカル環境で稼働するLLMを指します。
推論時のインターネット接続が不要で、外部への情報漏洩リスクが少なく、API使用料がありません。小型モデルの場合、GPUと一定以上のメモリを持つPC(もしくはColab環境)を利用すれば無料で動かせます。
動作に必要なメモリ量は、7Bパラメータで8GB、14Bパラメータで16GBがざっくりの目安です。

RAGとは

RAGとは、情報検索と生成AIを組み合わせた技術です。生成AIは、会社内の規約類や契約書など、事前に訓練されていない情報には回答できない課題があります。
RAGは、ユーザーの質問からドキュメントを検索し、その検索結果を生成AIに与えて質問に回答させます。主に社内のFAQシステムやチャットボットで活用されています。

DeepSeek-R1とは

DeepSeek-R1は、中国のAI企業DeepSeekが開発した大規模言語モデルです。オープンソースですが、推論、数学、コーディングのタスクで、OpenAI-o1と同等の性能を達成しています。ローカルでの推論が可能なため、セキュリティ面で優れ、API利用料金が不要です。

今回は、DeepSeek-R1を蒸留した小型のDeepSeek-R1-Distillモデルを、サーバーエージェントが日本語でファインチューニングしたモデルを使います。

利用モデル:DeepSeek-R1-Distill-Qwen-14B-Japanese

RAG構築の全体的な流れ

DeepSeek-R1-Distillを用いたRAGの全体的なフローを示します。
1~4の構築フェーズでは、モデルのダウンロード等で一部インターネット接続が必要ですが、5の実行フェーズではインターネット接続は不要です。

<構築フェーズ>
1. 青空文庫からテキストを取得して、不要な空白や改行を整形して前処理する
2. 文字数でチャンク分割し、HuggingFaceのE5モデルでベクトル変換する
3. ベクトル化したチャンクをFAISSに登録し、類似度で検索可能なRetrieverを作る
4. DeepSeek-R1モデルをロードし、LangChainのRetrievalQAチェーンを構築する
<実行フェーズ>
5. クエリからRetrieverからの検索結果を受け取り、DeepSeek-R1モデルが回答を生成する

RAG構築の具体的な解説

1. 青空文庫からのテキスト取得と前処理

まずRAGに利用するドキュメントの準備です。Pythonのrequestsを使って青空文庫のURLにアクセスし、BeautifulSoupでHTMLデータをスクレイピングします。
次に前処理として、テキスト解析の邪魔になりやすい<script>などのタグを取り除きます。次にget_text()メソッドでHTMLからテキスト部分のみを抽出します。
import requests
from bs4 import BeautifulSoup

# 青空文庫にリクエストしてHTMLを取得する
response = requests.get('https://www.aozora.gr.jp/cards/000879/files/43017_17431.html')

# レスポンスのHTMLからBeautifulSoupオブジェクトを作る
soup = BeautifulSoup(response.content, 'html.parser')

# 不要なタグを除去
for script_or_style in soup(['script']):
    script_or_style.extract()

# すべてのテキストを抽出
input_text = soup.get_text()

テキスト抽出後は、行ごとに分割し、各行の前後にある空白やタブ文字を取り除きます。その後、行の中にあるダブルスペースでも追加で分割し、不要な空白があれば取り除いています。最後に中身のあるテキストだけを\nで改行しながらまとめ、mikan.txtとして書き出しています。
# 行を分割して、前後の空白文字(スペース、タブ、改行など)を除去
split_lines = (single_line.strip() for single_line in input_text.splitlines())

# ダブルスペースで分割して前後の空白を削除
split_phrases = (phrase.strip() for single_line in split_lines for phrase in single_line.split("  "))

# 空ではないフレーズを改行でつなぐ
processed_text = '\n'.join(phrase for phrase in split_phrases if phrase)

# 前処理済みテキストをファイルに保存
output_text = 'mikan.txt'
with open(output_text, 'w', encoding='utf-8') as file:
    file.write(processed_text)

print(output_text,"に出力しました")

2. テキスト分割とベクトル化

テキストが整形できたら、LangChainのRecursiveCharacterTextSplitterを用いて、テキストを小さなチャンクに分割します。1チャンク200文字とし、前のチャンクと次のチャンクが20文字だけ重なるよう指定しています。こうすることで、長めの文がチャンクの境界で分断されたときでも、ある程度文脈を保ちやすくなります。
with open('mikan.txt','r',encoding='utf-8') as f:
    text = f.read()

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = 200,   # チャンクの文字数
    chunk_overlap  = 20,  # チャンクオーバーラップの文字数
)

texts = text_splitter.split_text(text)

テキスト分割が終わったら、Hugging Faceにあるembedding modelを使って、チャンクをベクトル化します。intfloat/multilingual-e5-largeは多言語のテキストを扱えるモデルで、日本語の類似度検索にも適しています。deviceにcuda:0を指定すると、GPUが利用可能な環境では自動的にGPUを使って高速にベクトル化を行えるようになるため、処理時間を短縮できます。
from langchain.embeddings import HuggingFaceEmbeddings

# Hugging Faceの埋め込みモデルE5を設定
embeddings = HuggingFaceEmbeddings(
    model_name = "intfloat/multilingual-e5-large",
    model_kwargs = {'device':'cuda:0'},
    encode_kwargs = {'normalize_embeddings': False}
)

3. FAISSへの格納とRetrieverの構築

embedding modelでベクトル化したチャンクを、FAISSに登録し、インデックスを作成します。FAISSはFacebookが開発したオープンソースのベクトル検索ライブラリで、ローカル環境で高速な検索が可能です。

FAISS.from_texts()で、チャンクが数値ベクトルとして格納されると同時に、インデックスの検索機能が準備されます。作成したインデックスはmikan.dbというファイル名で保存するように指定しており、その後にload_local()を使えば、同じベクトルストアを再利用できます。
# LangChainコミュニティ版のFAISS VectorStoreをimport
from langchain_community.vectorstores import FAISS

# 文字列リストをベクトル化して、FAISSのインデックスを作成(ベクトルストア作成)
db = FAISS.from_texts(texts, embeddings)

# FAISSのインデックスをローカル保存
db.save_local('mikan.db')

# ローカル保存したFAISSインデックスの読み込み
db = FAISS.load_local('mikan.db',embeddings, allow_dangerous_deserialization=True)

最後に、db.as_retriever()メソッドを呼ぶことで、このFAISSインデックスを「Retriever」と呼ばれる検索器として取り扱えるようにしています。ここでは、検索キーワード(ユーザの質問)に対して類似度が高いものを上位3件返す設定をしています。
#  類似文書上位3件を取得できる検索器の構築
retriever = db.as_retriever(search_kwargs={'k':3})

# 検索のお試し実行 docs = retriever.get_relevant_documents("小娘が汽車から投げた果物は何ですか?")

4. DeepSeek-R1のロードとRetrievalQAチェーンの構築

DeepSeek-R1の14Bパラメータ版をHugging Faceからロードします。次に、transformersのpipelineを使って推論パイプラインを組み立てたら、max_new_tokens=128temperature=0.01といった設定で応答の長さやランダム性の度合いを調整します。
import torch
from transformers import pipeline
from transformers import AutoModelForCausalLM, AutoTokenizer, TextStreamer

#  モデルの準備
model = AutoModelForCausalLM.from_pretrained("cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese", device_map="auto", torch_dtype="auto")
tokenizer = AutoTokenizer.from_pretrained("cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese")
streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)

pipe = pipeline(
    "text-generation",
    model=model,
    tokenizer=tokenizer,
    max_new_tokens=128,
    do_sample=True,
    temperature=0.01,
    repetition_penalty=2.0,
)

Retrieverから取得したテキストと、プロンプトを用意します。
#  プロンプトの準備
template  = """
コンテキストを理解し、400文字以内で回答してください。

コンテキスト: {context}

質問: {question}

回答:
"""

from langchain.prompts import PromptTemplate

prompt = PromptTemplate(
    template=template,
    input_variables=["context", "question"],
    template_format="f-string"
)

次に、LangChainのRetrievalQAチェーンを構築します。内部では、Retrieverによって検索されたテキストをLLMに渡す仕組みが組み込まれています。このとき、chain_type="stuff"を選ぶと、複数の関連文書をシンプルにひとつにまとめて入力として渡す形になります。これでRAGの構築が完了しました。
from langchain.chains import RetrievalQA
from langchain_huggingface import HuggingFacePipeline

# RetrievalQAチェーン作成 qa = RetrievalQA.from_chain_type( llm=HuggingFacePipeline(pipeline=pipe), retriever=retriever, chain_type="stuff", chain_type_kwargs={"prompt": prompt}, verbose=True, )

5. クエリからRAGの実行

それでは、構築したRAGシステムに質問をしてみます。qa.invoke(query)に質問文を渡すと、内部でRetrieverが関連度の高い文章を検索し、そのテキストをプロンプトと組み合わせてDeepSeek-R1が回答を生成します。
#  実行例
query = "小娘が汽車から投げた果物は何ですか?"
answer = qa.invoke(query)
print(answer['result'])

#出力例
"""
答え:みかん """

まとめ

DeepSeek-R1を活用して、ローカル環境でRAGを実装する流れを紹介しました。ローカルLLMは、コスト的にもセキュリティ的にも安心して利用できます。DeepSeekの登場で、ローカルLLMがこれから注目されてくると思います。

関連記事:DeepSeek-R1をColabで動かしてみた!7B・14B・32Bの日本語出力を検証