DIVX テックブログ

catch-img

RAGの実装、最低限だけで終わらせていませんか?意識するだけでより自然な回答が得られるポイントを公開!


目次[非表示]

  1. 1.挨拶・最初に
    1. 1.1.RAGの活かし方は様々
  2. 2.RAGの実装が誰にでもできる時代
  3. 3.RAG基礎要素の微調整
    1. 3.1.DB構築時
      1. 3.1.1.コレクションの「距離」選定
    2. 3.2.ドキュメント登録時
      1. 3.2.1.チャンキング
      2. 3.2.2.コレクション又はメタデータを分割して活用
    3. 3.3.プロンプト受信時
      1. 3.3.1.プロンプトの言い換え
      2. 3.3.2.プロンプトから仮説を立てる(HyDE)
  4. 4.まとめ
  5. 5.最後に
  6. 6.参考文献

挨拶・最初に

こんにちは!AI基礎研究チームの二宮です。

今年のアドベントカレンダーは特にRAGで盛り上がっていますが、この記事では私たちが作ってきたプロトタイプで特に手がけた、適当に実装していると以外と気づかないRAGの工夫すべき点を紹介していきます。

RAGの活かし方は様々

同じ技術でも、発想を変えれば様々なユースケースに応用が効きます。DIVXでは最新技術の根本を理解しナレッジを残すAI基礎研究チームと、その技術の応用を考えて社内ツールや、作っている製品などにAIを忍ばせるサクセスチームの2チームに分かれてAI推進活動を行なっております。

そのサクセスチームですが、直近ではソースコードをスキャンして脆弱性のあるコードを検知してくれるツールと、案件概要を書いたら従業員のスキルシートを検索してくれるツールの2つをわずか1ヶ月でプロトタイプを作ってくれています。

これらの詳細に関しては、サクセスチームの関口が12/7に記事を投稿しており、同じチームの渡部も後日投稿する予定なのでぜひ合わせてお読みください!

さて、今回メインとなったRAGとどう向き合っていったのか語っていきます。

RAGの実装が誰にでもできる時代

RAG(Retrieval-Augmented Generation)を調査し始めると、多くの人がすぐに「Langchain」という名称に出くわすでしょう。実際、クイックスタートガイドを見れば、驚くほど簡単にRAGを実装できることに気づきます。

from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain.document_loaders import TextLoader
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

vector_store = Chroma(collection_name="test_collection", persist_directory="db", embedding_function=OpenAIEmbeddings(model="text-embedding-3-small"))
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

document_path = "./document.txt"
loader = TextLoader(document_path)
documents = loader.load()
vector_store.add_documents(documents=documents)

retriever = vector_store.as_retriever() #ここが特にLCの便利なところ
prompt = ChatPromptTemplate.from_template("""
この文脈を使って質問に答えてください:{context}
質問:{question}
""")

question = "ほげほげ"
chain = {
"question": RunnablePassthrough(),
"context": retriever
} | prompt | model | StrOutputParser()

しかし、海外ではLangChainから移行している会社が出てきている状況を見ると、その分かりやすさや使いやすさの裏に潜む問題点が浮き彫りになってきています。つまり、抽象化が進みすぎて、原理を理解せずにブラックボックス的に使ってしまうリスクがあるのです。このコードをプロダクションレベルの製品に入れてた後「何を特に工夫したか」と聞かれても答えが出てきませんよね。

RAGが出てきて数年しか経っていないこともあり、実際どの数値を使って評価できるのかについて意見が別れている状態です。しかし、ブラックボックスに頼ることなく、基礎的なRAGの些細な部分を自分でカスタマイズするだけで、より自然なLLMの応答を引き出すことも可能です。この記事では、その具体的な手法を詳しく紹介していきます。

RAG基礎要素の微調整

DB構築時

まず、普通の言葉で受けたクエリーでドキュメントを検索できるDBとして、ChromaDBがとても導入しやすいのでこの記事はChromaDBベースで話を進めていきます。

コレクションの「距離」選定

RAGにおけるドキュメント検索周りをデバッグするときに、類似度を表す distances という配列と睨み合いをすることになりますが、その値は実は計算の仕方が様々で、その選定は検索性能に大きく影響を与えます。

そのため、ステップ1のベクトルDB構築から早速設定をしっかりと考えることが重要です。ChromaDBにおいては以下のように距離を指定します。

from chromadb import PersistentClient # Langchainを使わずにコードを書いていきます

chroma_client = PersistentClient(path="db/basicrag")
vector_store = chroma_client.get_or_create_collection(
"test_collection",
metadata={"hnsw:space": "cosine"} # 距離メトリックの指定
)

なお、Chromaの公式ドキュメントでは、選択可能なオプションが3つになっています。

  •  cosine  (コサイン類似度) : ベクトル間の角度のコサインを測定し、方向性を重視します。
  •  l2 (ユークリッド距離): ベクトル空間内の2点間の直線距離を計算します。
  •  ip (内積): ベクトルの内積を計算し、絶対的な値の差異を強調します。

例えば、シンプルなキーワード検索において、関連性の高いものから検索結果を並び替えたいときはユークリッドが直感的ですね。一方、関係ない検索結果を省きたいとき、つまり結果をフィルタリングしたい場合、ユークリッド距離の範囲は  [0, +♾️]  になるので、どこから検索結果を無視するかという閾値がとても検討しづらくなる。

その場合、コサイン類似度のように範囲が決まっていて( [-1, 1] )、「-1 は真逆、0は関係ない, 1は似ている」という情報が得られる値の方が使いやすいです。さらに、内積では向きからベクトルの類似度が測れ、大きさでは「似ているもの」の中でも一番注目すべきデータがわかります。例えば、ユーザーがよく買う商品に似ていて、一番おすすめの商品を計算するときに役立つでしょう。

ちなみに、こらちの設定はコレクション構築時に設定し途中から変えることができないので気をつけましょう。

ドキュメント登録時

チャンキング

閾値を使って検索結果をフィルタリングするコサイン類似度の話をしました。その場合は特に文章が長ければ長いほど、同時に処理する情報が増えるため、類似度が0(関係ない)に近づくことがあります。そうすると、例えばキーワードに関連する映画を探したい場合、あらすじを分割して文章を一つひとつ解析した方が実は高い数字が得られ検索結果に出しやすいです。

import numpy as np
def cosine_similarity(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

sentence1 = "レミーは他のネズミたちとは異なり、人間のように料理をすることを夢見ています。ある日、彼はパリの有名な高級レストランで働くことになった見習いシェフのリングイニと出会います。リングイニは料理が得意ではありませんが、レミーの協力のもとで次第に成長していきます。レミーはリングイニの帽子の中に隠れ、彼を助けながら料理を指導します。二人は一緒にさまざまな困難に立ち向かい、味覚や心を試されることになります。彼らの友情は試練を超え、料理の世界で大きな成果を収めることを目指します。この映画は、夢を追い求める情熱と互いに支え合う力の大切さを描いています。"

sentence2 = "リングイニは料理が得意ではありませんが、レミーの協力のもとで次第に成長していきます。"
search_query = "レミー リングイニ"

sentence_vector_1 = embeddings.embed_query(sentence1)
sentence_vector_2 = embeddings.embed_query(sentence2)
search_query_vector = embeddings.embed_query(search_query)

distance1 = cosine_similarity(sentence_vector_1, search_query_vector)
distance2 = cosine_similarity(sentence_vector_2, search_query_vector)

print(distance1) # 0.4757
print(distance2) # 0.5640


これだけではなく、チャンキングのメリットがもう一つあります。RAGは検索した結果をLLMに渡して回答してもらう仕組みですが、LLMをAPI経由で使っていると送信トークンの制限が設定されていることが多いです。このとき、文章がチャンキングされていると、関係する部分だけを引用してLLMに渡すことができるのでトークン数エラーに当たるリスクが低くなります。

コレクション又はメタデータを分割して活用

活用の仕方で同じRAGでも違う機能がたくさん作れるので、一つの商品にRAGを使う複数のモジュールが載ることもあるでしょう。そのとき、コレクションやメタデータを分けて管理すると、各モジュールが特定の用途に応じて必要なデータだけを迅速に引っ張ってくることができます。

例えば同じ製品に脆弱性診断ツールと社内労務チャットボットを入れたときに、同じDBでも

  • 脆弱性診断ツール用のコレクションを作って距離を「コサイン類似度」にする(関連性の低い脆弱性についてはそもそもアドバイスしたくない)
  • チャットボット用のコレクションを作って距離を「ユークリッド距離」にする(関連性順に並び替えてトップ3だけを使って回答する)

のように分けてデータを活用することができるようになります。

from chromadb import PersistentClient

chroma_client = PersistentClient(path="db/basicrag")
vector_store_vulchk = chroma_client.get_or_create_collection(
"vulchk",
metadata={"hnsw:space": "cosine"}
)
vector_store_chatbot = chroma_client.get_or_create_collection(
"chatbot",
metadata={"hnsw:space": "l2"}
)


メタデータの場合は、同じコレクションの中でも、タグをつけることによって検索時のフィルタリングができるようになります。例えば、脆弱性診断ツールにおいて、とあるコードをレビューするときに、

  • そのコードを解析し関連しそうな脆弱性の単語リストを生成して、これらの定義、気をつけるべき点をDBから検索する
  • そのコードのようなアンチパターンがDBにあるかどうかを検索する

のような情報を同時に検索したいとします。機能ごとにコレクションを分けたい場合、概念・説明系の資料とサンプルコード両方を同じコレクションに入れたいところですが、このように検索時のカテゴリが違うドキュメントがお互いに干渉し合わないようにしたいですね。

from chromadb import PersistentClient, Collection
import uuid
from langchain_community.document_loaders import PyPDFLoader

def load_path(path: str, vector_store: Collection, category: str):
for filename in os.listdir(path):
if not filename or filename.startswith("."):
continue
full_path = os.path.join(path, filename)
pdf_loader = PyPDFLoader(full_path)
documents = pdf_loader.load()

for document in documents:
  metadata = document.metadata
  metadata.update({"category": category})
  print(metadata)

vector_store.add(
  ids=[str(uuid.uuid4()) for _ in range(len(documents))],  # 分割されている場合、チャンクごとにIDを生成する(これ以上のチャンキングは飛ばします)
  documents=[document.page_content for document in documents],
  metadatas=[document.metadata for document in documents]
)
chroma_client = PersistentClient(path="db/basicrag")
vector_store_test = chroma_client.get_or_create_collection(
"test",
metadata={"hnsw:space": "cosine"}
)

textbook_path = "docs/textbooks"
code_path = "docs/code"

load_path(textbook_path, vector_store_test, "textbooks")
load_path(code_path, vector_store_test, "code")

注意!簡易化のために PyPDFLoaderを使っていますが、langchain_communityのモジュールを使う場合はライセンスをちゃんと確認した上でプロダクトのコードに入れましょう。



そうすると、以下のようにメタデータを指定して検索することが可能になります。

query_concepts = vector_store_test.query(query_texts=keyword_list, where={"category": "textbooks"})
query_code = vector_store_test.query(query_texts=[source_code], where={"category": "code"})

プロンプト受信時

プロンプトの言い換え

さきほどのメタデータの活用方法を説明するときに実はこのテクニックをこっそり入れましたが、ユーザーからもらったプロンプトをそのまま使って検索すべきという縛りはありません。

そうではなかった場合、同じ脆弱性診断ツールという例において考えると、「コードの解析がしたいならベクトルDBにコードしか入れられないんじゃないかな。類似度を取るなら」と、登録できるデータの幅が大分狭まってきますよね。ただ、日本やアメリカなどにおけるウェブセキュリティ団体が公開しているガイドラインを使わないことはあまりにも勿体無いです。

そこで、一度ユーザーからコードを受信したとき、検索にかける前にそもそもLLMを使って「このコードに関係しそうな脆弱性を’,’で区切ってリストを出してください」と問いかけます。その結果をドキュメントのコレクションの検索に使うと、話す言葉と話す言葉の類似度が計算されるのでちゃんと引っかかる検索結果が増え、LLMが「頭に入れておくべき概念」を文脈として準備できますね。

プロンプトから仮説を立てる(HyDE)

上記の「言い換え」と似ていますが、少しだけ根本が違うテクニックです。まず前提として、RAGはユーザーの質問に答えたいという課題を与えられているので、ベクトルDBを検索するときに「質問と似ている内容」ではなく「答えになりそうな内容」を探したいわけです。

よって、例えば

「〜〜という病気にはどういう治療ができるのか」

という質問について考えます。薬に関するDBがあったとして、おそらく薬とその薬が効く症状とういうのは出てきますが、病気名で引っかかる情報がもしかしたらないのかもしれません。その場合、病気名をスタート地点とし、LLMに考えてもらって以下の仮説を出してもらいます。

「〜〜という病気は、〜〜と〜〜のような症状が伴うので〜〜が必要です」

もちろん、最後の薬名の部分が予測なので、今のチャットボットサービスで懸念されている情報の信憑性が問われます。そこで、その例文と似ている内容をRAGで薬DBから探し、類似度の高いものが得られたら確実な(事前に専門の方が用意した)知識で回答することができます。

まとめ

「データを貯める・ユーザープロンプトから検索をかける・検索結果を使って回答する」RAGが、最初は便利に見えていたが思ったより実装がうまくいかないと思っている方が少なくないと思います。この投稿を通じて以下のような点について少しでも深ぼるきっかけになり、自分でカスタマイズしたRAGがいい回答を出すようになるかもしれませんのでぜひお試しいただきたいです!

  • データをどう検索しやすいように貯めておくか
    • 整理整頓のためにどのメタデータをつける?
    • 内容をどう分割・変換して保存する?
  • 検索した結果をどうしたいのか
    • 関連性順に並び替えるだけ?
    • 検索結果に出たら回答を邪魔する情報はある?条件付きで結果を絞るべき?
  • ユーザーの質問からどれくらいの情報を引き出せるか
    • キーワード検索の方が答えが出やすい?
    • LLMの知識だけでまず仮説を立てられそう?

最後に

基礎的なRAGの実装プロセスで、自分自身で微調整しながら、どのようにして回答の精度を高めるかをお話ししました。最初はシンプルなフレームワークを使用してプロトタイプを素早く作成することも大事ですが、原理を理解して細かい修正が可能になると技術の可能性が広がります。ベクトルDBはChroma以外も、LLMはOpenAI以外もまだまだ探れる可能性がたくさん残されているので、よく理解できない技術こそ今からでも手に取って試行錯誤を繰り返すことが重要になってきます。

もちろん、ここはRAG活用研究の始まりでしかありません。このレベルの技術でも、「精度」を具体的な数値でどう示すか今も議論が続いているので、今後RAGの評価に関する研究を進めてまた報告ができればと考えています。

最後までお読みいただき、ありがとうございました!

参考文献

octomind社:Langchainから移行を始めた理由(英)

ChromaDBの距離選定について

西見公宏, 吉田真吾, 大嶋勇樹. (2024). LangChainとLangGraphによるRAG・AIエージェント[実践]入門

お気軽にご相談ください


ご不明な点はお気軽に
お問い合わせください

サービス資料や
お役立ち資料はこちら

DIVXブログ

テックブログ タグ一覧

人気記事ランキング

関連記事

GoTopイメージ