【無料】ローカルLLMでRAGの評価をしてみる
動作環境・使用するツールや言語
- Windows 11 Home 25H2
- RAM 16.0GB
- NVIDIA GeForce RTX 3050 Ti Laptop GPU (4 GB)
- Oracle 26ai free
- Python
この記事の対象者
- Python初心者
- AI初心者
- 普段Oracleを使っている人
はじめに
AIの学習の手始めとして、無料かつローカルで気軽に試せる環境で動作確認していきます。
前回は青空文庫の小説を要約しようとしてハルシネーションを起こしているところまで確認しました。
というわけで精度を向上させたいわけですが、それ以前に何をもって精度が高いとみなすかの指標が必要です。
人間の主観でもいいですが、RAGでは評価の仕組みがあるので今回はそれを実装していきます。
RAGASというライブラリを使うのが一般的ですが、仕組みを理解するために自力で実装してみます。
RAGの評価指標はいくつかありますが、大別すれば2種類です。
1.Retrieval評価
⇒embeddingしてベクトル検索した時に正しいチャンクが取得できるか
問いに対する正解が本文のどの辺にあるのかを正しく特定する能力のようなものです。
2.Generation評価
⇒RAGが生成した回答が本当に正しいか
テキストのチャンク化
前回と同様ですが、まずテキストを読み込んでチャンクに分割し、ベクトルデータベースに格納します。
まずチャンク格納テーブルを作成しておきます。
CREATE TABLE text_documents (
id NUMBER GENERATED ALWAYS AS IDENTITY,
source_file VARCHAR2(200),
line_no NUMBER,
title VARCHAR2(200),
author VARCHAR2(200),
content CLOB,
embedding VECTOR(768, FLOAT32)
);
テキストはあまり長文だと非常に時間がかかるので、今回は短編の「ダゴン」を採用しています。
import glob
import os
from sentence_transformers import SentenceTransformer
import oracledb
import json
from langchain_text_splitters import RecursiveCharacterTextSplitter
def read_all_text_files(folder_path):
"""
指定フォルダ内(サブフォルダ含む)の全ての .txt ファイルを読み込み、内容を返す。
"""
if not os.path.isdir(folder_path):
raise NotADirectoryError(f"指定されたパスはフォルダではありません: {folder_path}")
# 再帰的に .txt ファイルを取得
pattern = os.path.join(folder_path, "**", "*.txt")
file_paths = glob.glob(pattern, recursive=True)
results = {}
for file_path in file_paths:
try:
with open(file_path, "r", encoding="utf-8") as f:
results[file_path] = f.read()
except UnicodeDecodeError:
# UTF-8 で読めない場合は BOM 付き UTF-8 や Shift_JIS を試す
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
results[file_path] = f.read()
except UnicodeDecodeError:
with open(file_path, "r", encoding="cp932", errors="replace") as f:
results[file_path] = f.read()
except Exception as e:
print(f"ファイル {file_path} の読み込み中にエラー: {e}")
return results
if __name__ == "__main__":
context = ""
chunks = []
folder = 'C:\\作業\\txt\\aozorabunko_text-master\\aozorabunko_text-master\\cards\\001699\\files\\57443_txt_58143'
try:
# Embeddingモデル
model = SentenceTransformer('intfloat/multilingual-e5-base')
# Oracle接続
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
cursor = conn.cursor()
contents = read_all_text_files(folder)
for file_path, text in contents.items():
text = "".join(text)
lines = text.splitlines()
title = lines[0].strip()
author = lines[1].strip()
body = "\n".join(lines[2:])
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
separators=[
"\n\n",
"\n",
"。",
"!",
"?",
"、",
" ",
""
]
)
# そのまま使うとチャンクの先頭に句点が入る
chunks = [
c.lstrip("。\n ")
for c in splitter.split_text(text)
]
for i, chunk in enumerate(chunks):
embedding_text = f"""
passage:
作品名:{title}
AUTHOR:{author}
{chunk}
"""
embedding = model.encode(embedding_text).tolist()
cursor.execute(
"""
INSERT INTO text_documents
(
source_file,
line_no,
title,
author,
content,
embedding
)
VALUES
(
:1,
:2,
:3,
:4,
:5,
TO_VECTOR(:6)
)
""",
[
'Aozora-Bunko',
i,
title,
author,
chunk,
json.dumps(embedding)
]
)
conn.commit()
print("success")
except Exception as e:
print(f"エラー: {e}")
finally:
cursor.close()
conn.close()
評価データ生成
次に評価用のデータを生成します。
事前に格納用のテーブルを作成しておきます。
質問と回答、および参考として、回答の根拠、チャンクid(text_documentsのid)などを列として持たせておきます。generated_answerはここでは投入せず、後で投入します。
外部キーは設定していませんが、概念的にはtext_documentsの子テーブルになります。
CREATE TABLE rag_evaluation
(
evaluation_id NUMBER GENERATED ALWAYS AS IDENTITY,
question VARCHAR2(4000),
answer CLOB,
evidence CLOB,
source_chunk_id NUMBER,
generated_answer VARCHAR2(4000)
);
評価データは人間が作ってもいいですが、今回はLLMにJSON文字列として作らせてみます。
LLMにもよりますが、結構フォーマットがぶれたので、これでもかというぐらいにガチガチに固めています。qwenではうまくいきませんでした。
1つのチャンクごとに1つの質問、回答、回答根拠を生成させています。
なおここで生成した質問や回答の内容については実際にはおかしいものも出力されますが、以降は正しいものとして扱っていきます。
LLMを評価しているわけではなく、RAGを評価しているためであり、チャンク設計やembeddingモデル、ベクトル検索などの精度を確認しているわけです。
from ollama import chat
import oracledb
import json
from json_repair import repair_json
if __name__ == "__main__":
try:
# Oracle接続
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
select_cursor = conn.cursor()
select_sql = """
SELECT content,id as source_chunk_no
FROM text_documents
ORDER BY id
"""
insert_cursor = conn.cursor()
insert_eval_sql = """
INSERT INTO rag_evaluation
(
question,
answer,
evidence,
source_chunk_id
)
VALUES
(
:1,
:2,
:3,
:4
)
"""
select_cursor.execute(select_sql)
rag_evals = []
for row in select_cursor:
text = row[0].read()
source_chunk_no = row[1]
question = """
質問と回答と回答根拠を作成してください。
条件:
- Yes/No質問は禁止
- 1問作成
出力例:
{
"questions":[
{
"question":"...",
"answer":"...",
"evidence":"..."
}
]
}
"""
prompt = f"""
回答は必ず有効なJSONのみ返してください。
禁止事項:
- Markdown禁止
- ```json禁止
- 説明文禁止
- 全角引用符(“ ” 「 」)禁止
- JSON以外の文字禁止
{text}
質問:
{question}
"""
response = chat(
model="gemma3:4b",
format="json",
messages=[
{
"role": "user",
"content": prompt
}
]
)
content = response["message"]["content"]
# MarkDownの除去
content = content.strip()
if content.startswith("```json"):
content = content[7:]
if content.endswith("```"):
content = content[:-3]
content = content.strip()
print(source_chunk_no)
try:
data = json.loads(repair_json(content))
print("OK")
except Exception as e:
print("JSONエラー")
print(e)
print(content)
continue
for q in data["questions"]:
insert_cursor.execute(
insert_eval_sql,
[
q["question"],
q["answer"],
q["evidence"],
source_chunk_no
]
)
conn.commit()
except Exception as e:
print(f"エラー: {e}")
finally:
select_cursor.close()
insert_cursor.close()
conn.close()
Retrieval評価
まずRetrieval評価に使うテーブルを作成しておきます。
これはrag_evaluationの子テーブルになります。つまりtext_documentsの孫にあたります。
rag_evaluationは1つのチャンクごとに1つの質問、回答、回答根拠のセットを生成したデータを投入したテーブルであり、主キーはevaluation_idでした。
rag_evaluation_canididateは、チャンクごと質問ごと(evaluation_id)にチャンクの本文をベクトル検索してコサイン類似度が近いチャンクから10行取得して格納します。
CREATE TABLE rag_evaluation_candidate
(
evaluation_id NUMBER,
chunk_id NUMBER,
content CLOB,
rank NUMBER,
score NUMBER
);
質問文をembeddingして本文をベクトル検索し、近いものから10行取得します。(今回だと16行しかありませんが)
この中に正解のチャンクが含まれていればOKです。
from sentence_transformers import SentenceTransformer
import oracledb
import json
if __name__ == "__main__":
# Oracle接続
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
# Embeddingモデル
model = SentenceTransformer('intfloat/multilingual-e5-base')
# 質問の取得
select_cursor = conn.cursor()
insert_cursor = conn.cursor()
select_sql = """
SELECT
evaluation_id,
question
FROM rag_evaluation
"""
rows = select_cursor.execute(select_sql)
try:
# 質問1つごとに処理
for evaluation_id, question in rows:
# 質問をembeddingしてリストにする
query_embedding = model.encode(
f"query: {question}"
).tolist()
# ベクトル検索で質問文に近いcontentを検索する
select_cursor2 = conn.cursor()
select_sql2 = """
SELECT
id,
content,
VECTOR_DISTANCE(
embedding,
TO_VECTOR(:vec),
COSINE
) score
FROM text_documents
ORDER BY score
FETCH FIRST 10 ROWS ONLY
"""
# json文字列にして実行
params = [
json.dumps(query_embedding)
]
select_cursor2.execute(
select_sql2,
params
)
rank = 1
for result in select_cursor2:
insert_cursor.execute(
"""
INSERT INTO rag_evaluation_candidate
(
evaluation_id,
chunk_id,
content,
score,
rank
)
VALUES
(
:1,
:2,
:3,
:4,
:5
)
""",
[
evaluation_id,
result[0],
result[1],
result[2],
rank
]
)
rank += 1
conn.commit()
except Exception as e:
print(f"エラー: {e}")
finally:
select_cursor.close()
insert_cursor.close()
conn.close()
rag_evaluation_candidateの中に正しいチャンク、つまり質問に関連するチャンクが含まれていればOKです。
具体例で判定方法を見ていきましょう。
下記はチャンクID:10についてのrag_evaluationの例です。
"EVALUATION_ID","QUESTION","ANSWER","EVIDENCE","SOURCE_CHUNK_ID","GENERATED_ANSWER"
10,"彫刻のモチーフは、どのような特徴を持っていたか?","彫刻のモチーフは、人間のような姿をしているものの、海底の洞窟で魚のように戯れ、モノリスの祭壇を拝んでいるというグロテスクな描写を含んでいた。","「そのモチーフは、画家のドレを嫉妬させるようなものだった。彫刻は人間、少なくともある種の人間を表しているようだった。ただ、その‘人間’は、海底の洞窟で魚のように戯れ、波の下にあると思われるモノリスの祭壇を拝んでいるように見える。」",10,""
evaluation_id=10について、rag_evaluation_candidateを確認してみます。
見ての通り、先頭(Rank1)にチャンクID:10が出力されているので、これは正しく取得できているということです。
"EVALUATION_ID","CHUNK_ID","CONTENT","RANK","SCORE"
10,10,"特に目を引かれたのは、彫刻の絵柄の方だった。巨大なサイズのお陰で、間に川を挟んでもはっきりと見ることができたが、石の表面に浅浮き彫りの彫刻が並んでいた。そのモチーフは、画家のドレを嫉妬させるようなものだった。彫刻は人間、少なくともある種の人間を表しているようだった。ただ、その「人間」は、海底の洞窟で魚のように戯れ、波の下にあると思われるモノリスの祭壇を拝んでいるように見える。彼らの顔や姿を詳しく述べようとは思わない。思い出すだけで気が遠くなるからだ。ポーやブルワーのような作家の想像力も及ばないほど彼らはグロテスクだったが、忌々しいことに全体的な輪郭は人間によく似ていた。水かきのある手足、驚くほど大きくてたるんだ唇、ギョロッとしたガラスのような目玉、その他、思い出すのも気持ち悪い特徴にもかかわらずだ。奇妙なことに、「人間」とその背景の彫刻の大きさは、ひどくバランスを欠いているようだった。例えば、彼らの一人が鯨を殺している場面で、鯨は人間よりほんの少し大きいだけだ。今述べたとおり、彼らはグロテスクで異様に大きかった",1,0.1691587231148899
10,9,"恐怖で呆然とする一方、科学者や考古学者の喜びのような多少のスリルも感じながら、周囲をさらに詳細に調べてみた。月は今や天頂近く、谷間を囲む高い断崖の上を妖しく鮮やかに輝き、谷底に幅広い川が流れているのが分かった。川は湾曲しており上流も下流も見えない。そして水は、斜面に立つ足のところまで来ていた。谷の向こうでは、巨大なモノリスの土台も波に洗われている。モノリスの表面に文字や粗雑な彫刻が刻まれているのを見ることができた。碑文は象形文字で書かれていたが、私の知らないものであり、また、本で見たいかなるものとも違っていた。文字の大部分は、魚、鰻、蛸、甲殻類、軟体動物、鯨などの水棲動物を様式化していた。文字の中には、海底隆起によりできた平地で、腐敗した死骸を目にした他は、現代の世界では知られていない、海の生物を表しているらしいものもあった。",2,0.19349706007397593
10,8,"月が空高く昇るにつれ、谷の傾斜は思ったより切り立っていないことが分かってきた。岩棚や露出した石が下っていくのに最適な足場になっていて、数百フィートの急な下りを過ぎれば、坂は穏やかになっているようだった。不可解な衝動に駆られ、苦労しながら岩をはい降り、その下のゆるやかな坂に立った。そして、未だ光が射したことのない真っ暗な底を覗きこんだ。
ふと注意を引かれたのは、前方およそ一〇〇ヤードのところにそそり立つ、向かいの斜面にある大きくて奇妙な物体だった。それは、高度を増す月の光に照らされて、白く輝いていた。巨大な岩石であることは、すぐに分かった。しかし、その形や位置が自然の力だけによるのではないという印象も強く受けた。さらによく見ているうちに、言いようのない感覚を覚えた。途方もなく大きく、海底にぽっかり開いた溝に地球ができて間もない頃から存在していたにも関わらず、その奇妙な物体は形を整えられたモノリスであり、その巨体は過去に、知的生物による細工を受け、そしておそらくは礼拝の対象だったに疑いないように思えた。",3,0.20512105145288861
10,14,"夜、とりわけ、欠け始めた月が満月と半月になる頃、あの巨人が見える。モルヒネを試してみたが、薬物によって得られたのは一時的な安らぎだけで、私は絶望的な奴隷のように薬から離れられなくなってしまった。だからもう、全てを終わらせるつもりだ。一部始終は書き終えた。同胞たちにとって何かの参考になるか、あるいは単に馬鹿にされ笑われるのかもしれない。よく自分自身に問いかける。あそこで見たのは、すべてではないにしろ、単なる幻だったのではないか? ドイツ軍艦から逃げ出した後、遮るもののないボートで太陽にやられ、戯言をわめきながら見た幻覚ではなかったか? こう自問すると、それに答えるように、恐ろしいイメージがいつも鮮明に浮かび上がる。深海のことを考えようとすると、あの名前もない生き物を思い出し身震いする。まさにこの瞬間にも、ぬるぬるした海底をじたばた這い回っているかもしれない。石でできた太古の偶像を崇め、水浸しの花崗岩でできた海底のオベリスクに、やつら自身の忌まわしい姿を刻んでいるのかもしれない。やつらが波から上がり、悪臭のするカギ爪でもって、戦争で疲弊した弱々しい人類の生き残りを引きずり下ろす日が見える",4,0.21497102488501696
10,2,"かなりのストレスを感じながら、これを書いている。今夜にはもう、生きていないだろう。金も、頼みの綱のクスリも尽きた。これ以上、苦しみには耐えられない。この屋根裏の窓から、下のうす汚い通りに、身を投げることにしよう。モルヒネ中毒が原因で、身体が弱り、精神も堕落したのだと考えないでほしい。乱雑に走り書いたこの文章を読んでもらえば、完全に理解するのは無理にしても、一体全体なぜ私が忘却や死を望んでいるのか、見当はつくと思う。
船荷監督として乗船していた定期船がドイツの襲撃艇に捕らえられたのは、広い太平洋のなかでも一段と広々として、船の往来がめったにない海域だった。大戦は始まったばかりで、ドイツ人どもの海軍も、後のように落ちぶれきってはいなかった。船は合法的な戦利品にされたといえ、乗組員は海軍の捕虜として、相応の公正さと配慮をもって扱われた。やつらの軍規が実に大らかだったおかげで、拿捕されてから五日後、小さなボートに長期間もつだけの水と食料を積み、ひとり逃げおおすことができた。",5,0.21914367417330882
10,11,"例えば、彼らの一人が鯨を殺している場面で、鯨は人間よりほんの少し大きいだけだ。今述べたとおり、彼らはグロテスクで異様に大きかった。しかしすぐに、それらは原始的な漁業・海洋民族がこしらえた想像上の神々だと思った。ピルトダウン人やネアンデルタール人が誕生する幾時代も前に絶滅した、何らかの種族によるものに違いない。最も大胆な人類学者さえ考えつかない過去の世界を思いもよらず垣間見て、畏敬の念に打たれた。考えこんで立ち尽くしていると、眼前を静かに流れる川に、月が奇妙な影を落とした。",6,0.22069398475317803
10,12,"その時、突然、私はそれを見た。水面をわずかに波立たせて浮上してきたそいつは、暗い水面を出て、視界に滑りこんできた。ポリュフェマスのように忌まわしいその巨人は、悪夢に出てくる恐ろしい怪物のように、モノリスに向かって突進した。鱗の生えた巨大な腕をモノリスに巻き付けると、巨人は醜い頭を垂れて、ゆっくりした一定の声を発した。私はその時に狂ってしまったのだと思う。
半狂乱で斜面と崖を登り、座礁したボートのところに錯乱しながら戻ったが、その時のことはほとんど覚えていない。多くの歌を歌い、歌えないときは奇妙な笑い声をあげたと思う。ボートに到着して間もなく、大きな嵐があったのをぼんやりと覚えている。少なくとも、雷鳴と、自然が最も荒れ狂った時にしか発しないような轟音を耳にしたと思う。",7,0.22114586448652207
10,3,"ようやく自由に、そして漂流する身となったが、自分がどこにいるのか、まったく分からない。優れた航海士ではなかったので、太陽と星の位置から、赤道のやや南にいるとなんとなく推測するしかできなかった。緯度についてはまったく分からず、島や海岸線はどこにも見えない。晴れた日がつづき、焼けつくような太陽の下、何日もあてどなく漂流した。通りすがりの船か、人が住める陸地の岸に打ち上げられるのを待っていた。しかし船も陸地も見えてこず、果てしない海の広大なうねりの中に孤立している状態に絶望を感じ始めた。
状況が変わったのは、寝ている間だった。何が起きたか、詳しくはわからない。というのも、夢にうなされ、よく眠れなかったとはいえ、ずっとまどろんでいたからだ。ようやく目が覚めると、真っ黒な泥のネバネバしたなかに半身が飲み込まれていた。見渡すかぎり、そのぬかるみは単調な起伏としてまわりに広がっていた。少し離れたところに、ボートが乗り上げていた。",8,0.22890785807727332
10,4,"けたはずれの、予想もつかない風景の変化に、まずは驚いたのだろうと思われるかもしれない。しかし、本当のところ、驚きよりも恐怖の方が大きかった。まわりの空気や腐った泥に不吉な気配があって、体の芯まで凍るようだった。辺りにはひどい悪臭が漂っており、腐った死魚や、見たところなんとも言いようのないものの死骸が、果てしなく広がる不潔な泥の平原から突き出ている。完全な静けさの中、不毛な無限の空間に宿る言いようのない恐ろしさは、ひょっとすると言葉だけでは伝わらないかもしれない。何も聞こえず、一面に広がる黒い泥の他は何も見えない。それでも、あたりの静けさが完全なことと、風景が単調なこと、まさにそれらが心に重くのしかかり、吐き気を起こさせるような恐怖を覚えた。",9,0.22925884771517913
10,7,"なぜかは分からないが、その夜は狂気じみた夢を見た。欠け始めの、幻想的な形の月が東の平原に高く昇る前に、冷たい汗をかいて目を覚まし、もう眠らないことに決めた。さっきの夢をもう一度見るのは、とても耐えられないからだ。月の光を浴びながら、日中に歩いてきたのは馬鹿なことをしたと思った。灼熱の太陽がなかったら、歩くのはもっと楽だった。実際、日没時には躊躇したが、今なら丘に登ることができそうな気がした。荷物をまとめ、丘の頂上を目指して出発した。
起伏する平原の途切れない単調さのせいで、言いようのない恐怖を感じることは以前に書いた。しかし、頂上に着いて丘の反対側にある限りなく深い峡谷を見下ろした時の恐怖はさらにひどいものだった。月はまだ低く、暗い峡谷の奥まで照らしてはいない。まるで自分が世界の果てに立ち、その縁から、永遠に終わらない夜の、底なしの混沌を覗きこんでいるように感じられた。奇妙なことに、恐怖を感じながら、『失楽園』と、形のない闇の国から登ってくる恐ろしい魔王の姿とが、心に浮かんだ。",10,0.22953047344653998
ちなみに最初から10行ではなくて1行ではだめなのかというと、質問に関係するチャンクが複数存在する場合、1つのチャンクに1つしか正解がないというのはおかしいからです。(今回は1つのチャンクから正解を生成していますが一般的にはそうとは限りません)
SQLで性能を評価すると以下のようになります。
さっきと同様にevaluation_idでrag_evaluationとrag_evaluation_candidateを結合し、チャンクidが一致しているものの数をカウントします。
評価指標としては、1位までにヒットするか、5位までにヒットするか、10位までにヒットするかで見ていきます。
SELECT
TRUNC(HIT1/TOTAL*100,1) || '%' AS HIT1,
TRUNC(HIT5/TOTAL*100,1) || '%' AS HIT5,
TRUNC(HIT10/TOTAL*100,1) || '%' AS HIT10
FROM (
SELECT
COUNT(*) total,
SUM(
CASE
WHEN c.rank <= 1 THEN 1
ELSE 0
END
) hit1,
SUM(
CASE
WHEN c.rank <= 5 THEN 1
ELSE 0
END
) hit5,
SUM(
CASE
WHEN c.rank <= 10 THEN 1
ELSE 0
END
) hit10
FROM rag_evaluation e
LEFT JOIN rag_evaluation_candidate c
ON e.evaluation_id=c.evaluation_id
AND e.source_chunk_id=c.chunk_id
);
出力は下記の通りでした。
元のテキストやembeddingモデルやチャンクサイズの変更で変わります。
"HIT1","HIT5","HIT10"
"62.5%","81.2%","93.7%"
Generation評価
まず質問をベクトル化し、質問文に近い本文の内容をベクトル検索します。
ここでは3つ取得し、それらを根拠資料としたうえでLLMに同じ質問をして回答を得ます。
これをgenerated_answerとしてrag_evaluationに格納します。
from sentence_transformers import SentenceTransformer
from ollama import chat
import oracledb
import json
if __name__ == "__main__":
# Oracle接続
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
# Embeddingモデル
model = SentenceTransformer('intfloat/multilingual-e5-base')
# 質問の取得
select_cursor = conn.cursor()
update_cursor = conn.cursor()
select_sql = """
SELECT
evaluation_id,
question
FROM rag_evaluation
"""
rows = select_cursor.execute(select_sql)
try:
chunks = []
i = 0
# 質問1つごとに処理
for evaluation_id, question in rows:
# 質問をembeddingしてリストにする
query_embedding = model.encode(
f"query: {question}"
).tolist()
# ベクトル検索で質問文に近いcontentを検索する
select_cursor2 = conn.cursor()
select_sql2 = """
SELECT
id,
content,
VECTOR_DISTANCE(
embedding,
TO_VECTOR(:vec),
COSINE
) score
FROM text_documents
ORDER BY score
FETCH FIRST 3 ROWS ONLY
"""
# json文字列にして実行
params = [
json.dumps(query_embedding)
]
select_cursor2.execute(
select_sql2,
params
)
select_cursor2.execute(select_sql2)
res = select_cursor2.fetchall()
# select結果をchunksに格納する
for r in res:
chunks.append(r[1].read())
prompt = f"""
以下の文書を根拠に質問に回答してください。
文書1:
{chunks[0]}
文書2:
{chunks[1]}
文書3:
{chunks[2]}
質問:
{question}
回答:
"""
response = chat(
model="gemma3:4b",
messages=[
{
"role": "user",
"content": prompt
}
]
)
generated_answer = response["message"]["content"]
print("====================")
print(evaluation_id)
print(question)
print(generated_answer)
print("====================")
update_cursor.execute(
"""
UPDATE RAG_EVALUATION
SET GENERATED_ANSWER = :1
WHERE EVALUATION_ID = :2
""",
[
generated_answer,
evaluation_id
]
)
i = i + 1
if i % 10 == 0:
conn.commit()
conn.commit()
except Exception as e:
print(f"エラー: {e}")
finally:
select_cursor.close()
select_cursor2.close()
update_cursor.close()
conn.close()
途中でevaluation_id,question,generated_answerをprintしていますが、出力例は下記の通りです。
10
彫刻のモチーフは、どのような特徴を持っていたか?
彫刻のモチーフは、グロテスクで異様に大きく、水かきのある手足、驚くほど大きくてたるんだ唇、ギョロッとしたガラスのような目玉など、気持ち悪い特徴を持っていました。また、「人間」という姿をしていたものの、海底の洞窟で魚のように戯れ、波の下にあるモノリスの祭壇を拝んでいるように見えました。
RAGで生成した回答(generated_answer)と、最初の回答(answer)を比較することで、回答が正しいかどうかを点数化していきます。
なお最初の回答は正しいものとして扱っています。
スコアもテーブルに格納します。
CREATE TABLE rag_evaluation_score(
EVALUATION_ID NUMBER,
BERT_PRECISION NUMBER,
BERT_RECALL NUMBER,
BERT_F1 NUMBER,
EMB_SIMILARITY NUMBER,
LLM_SCORE NUMBER,
LLM_REASON CLOB,
LLM_CORRECTNESS VARCHAR2(4000),
LLM_MISSING VARCHAR2(4000),
LLM_HALLUCINATION VARCHAR2(4000)
)
;
評価指標の一つ、BERTScoreは回答をベクトル空間上で比較するので、意味が近ければ高得点になります。
ただし元々は英語向けの指標なので、日本語モデルによっては思ったほど高得点にならない場合があります。
SentenceTransformerを使ってコサイン類似度で比較する方法もあります。
また、LLMに判定させる方法もあります。
from bert_score import score
import oracledb
import numpy as np
from sentence_transformers import SentenceTransformer
import pandas as pd
from ollama import chat
import re
import json
if __name__ == "__main__":
# Oracle接続
conn = oracledb.connect(
user="rag",
password="rag",
dsn="127.0.0.1:1521/FREEPDB1"
)
select_cursor = conn.cursor()
insert_cursor = conn.cursor()
select_sql = """
SELECT
answer,
generated_answer,
question,
evaluation_id
FROM rag_evaluation
"""
insert_sql = """
INSERT INTO rag_evaluation_score
(
EVALUATION_ID,
BERT_PRECISION,
BERT_RECALL,
BERT_F1,
EMB_SIMILARITY,
LLM_SCORE,
LLM_REASON,
LLM_CORRECTNESS,
LLM_MISSING,
LLM_HALLUCINATION
)
VALUES
(
:1,
:2,
:3,
:4,
:5,
:6,
:7,
:8,
:9,
:10
)
"""
df = pd.read_sql_query(
select_sql,
conn
)
df = df.dropna(subset=["ANSWER","GENERATED_ANSWER"])
model = SentenceTransformer(
"intfloat/multilingual-e5-base"
)
# ANSWERはCLOB型
df["ANSWER"] = df["ANSWER"].map(lambda x: x.read() if hasattr(x, "read") else x)
#生成した回答の精度について、各種指標で評価をしていく
try:
# Embedding Similarityの計算
# ベクトル空間上の類似性
# ベクトル化する時に正規化(normalize)して長さを1に揃えておく
emb_refs = model.encode(
df["ANSWER"].tolist(),
normalize_embeddings=True
)
emb_gens = model.encode(
df["GENERATED_ANSWER"].tolist(),
normalize_embeddings=True
)
# コサイン類似度は(A*B)/(|A||B|)
# 長さを1に揃えたので|A||B|=1
# そのためコサイン類似度はA*Bだけで計算できる
# 行ごとに計算(axis=1)
similarities = np.sum(
emb_refs * emb_gens,
axis=1
)
# BERTScoreの計算 引数はリスト
# Precision(適合率)、Recall(再現率)、F1スコア(全体的なバランス)
P, R, F1 = score(
df["GENERATED_ANSWER"].tolist(),
df["ANSWER"].tolist(),
lang="ja"
)
# LLM Judge
model_name = "gemma3:4b"
scores = []
reasons = []
correctnesses = []
missings = []
hallucinations = []
required = [
"score",
"reason",
"correctness",
"missing",
"hallucination"
]
for _, row in df.iterrows():
prompt = f"""
あなたはRAG評価者です。
Referenceだけが正解です。
Generatedが一般常識として正しくても、
Referenceに書かれていない内容は評価しません。
GeneratedがReferenceと異なる情報を含む場合は減点してください。
GeneratedがReferenceより長く、
Referenceにない説明を追加している場合も減点してください。
GeneratedがReferenceより短くても、
Referenceの意味を保持していれば減点しません。
Question:
{row["QUESTION"]}
Reference:
{row["ANSWER"]}
Generated:
{row["GENERATED_ANSWER"]}
以下の基準で評価してください。
5点: 正解とほぼ同じ意味
4点: 一部省略があるが概ね正しい
3点: 部分的に正しい
2点: 誤りが多い
1点: ほぼ誤り
0点: 全く無関係
以下のJSON形式のみで回答してください。
{{
"score": 0-5,
"reason": "理由",
"correctness":"...",
"missing":"...",
"hallucination":"..."
}}
すべてのキーを必ず含めること。
情報がない場合は空文字 "" を設定すること。
他の文章は一切出力しないこと。
"""
response = chat(
model=model_name,
format="json",
messages=[
{
"role": "user",
"content": prompt
}
]
)
content = response["message"]["content"]
match = re.search(r'\{.*\}', content, re.S)
if match is None:
print("JSONが見つかりません")
continue
json_text = match.group()
judge = json.loads(json_text)
for key in required:
if key not in judge:
print(f"{key} がありません")
print(content)
scores.append(judge.get("score"))
reasons.append(judge.get("reason",""))
correctnesses.append(judge.get("correctness",""))
missings.append(judge.get("missing",""))
hallucinations.append(judge.get("hallucination",""))
# 判定結果を格納する
# P,R,F1はtorch.Tensorなのでnumpyに変換する
df["bert_precision"] = P.numpy()
df["bert_recall"] = R.numpy()
df["bert_f1"] = F1.numpy()
df["emb_similarity"] = similarities
df["scores"] = scores
df["reasons"] = reasons
df["correctnesses"] = correctnesses
df["missings"] = missings
df["hallucinations"] = hallucinations
print(df.head())
data = df[
[
"EVALUATION_ID",
"bert_precision",
"bert_recall",
"bert_f1",
"emb_similarity",
"scores",
"reasons",
"correctnesses",
"missings",
"hallucinations"
]
].values.tolist()
insert_cursor.executemany(
insert_sql,
data
)
conn.commit()
print("BERT F1 AVG:", df["bert_f1"].mean())
print("EMB AVG:", df["emb_similarity"].mean())
print(
df[
["bert_f1", "emb_similarity", "scores"]
].corr()
)
print(df[["bert_f1","scores"]])
except Exception as e:
print(f"エラー: {e}")
finally:
select_cursor.close()
insert_cursor.close()
conn.close()
出力結果の一部は下記の通りです。
相関関係を見る限り、LLM JUDGEがうまく機能していないようです。
BERT F1 AVG: 0.6316657
EMB AVG: 0.84769714
bert_f1 emb_similarity scores
bert_f1 1.000000 0.864826 -0.289390
emb_similarity 0.864826 1.000000 -0.205762
scores -0.289390 -0.205762 1.000000
bert_f1 scores
0 0.499359 5
1 0.525504 5
2 0.491250 5
3 0.639414 0
4 0.614782 5
5 0.715051 3
6 0.736263 0
7 0.804758 5
8 0.630938 2
9 0.627814 1
10 0.624659 1
11 0.661466 1
12 0.650244 3
13 0.578835 5
14 0.781595 5
15 0.524720 5
例えばですが、質問
この人物が体験した幻覚は現実の出来事と区別できるものだったか?
に対して、
回答(正解)は
いいえ
となっており(プロンプトでYes/No質問は禁止としたはずですが効いていないようです)、回答根拠は
"あそこで見たのは、すべてではないにしろ、単なる幻だったのではないか? ドイツ軍艦から逃げ出した後、遮るもののないボートで太陽にやられ、戯言をわめきながら見た幻覚ではなかったか?"
となっています。
一方、生成された回答は
"はい、この人物が体験した幻覚は現実の出来事と区別できるものであったと考えられます。
文書1では、彫刻の描写に「グロテスクで異様に大きい」「驚くほど大きくてたるんだ唇、ギョロッとしたガラスのような目玉」といった詳細な不気味さを示しており、現実にはありえないような過剰な特徴が強調されています。また、「鯨を殺している場面で、鯨は人間よりほんの少し大きいだけだ」という描写も、その不自然さを際立たせています。
文書2では、これらの彫刻を「原始的な漁業・海洋民族がこしらえた想像上の神々」「何らかの種族によるもの」と解釈し、遥か昔に絶滅した存在だと認識することで、幻覚の要素がさらに強まっています。この解釈は、現実の出来事ではなく、あくまで人物の想像によって生み出されたものです。
そして文書3では、風景そのものが「不吉な気配」「腐った死魚や、見たところなんとも言いようのないものの死骸」といった具体的な恐怖と結びついており、幻覚的な空間をさらに現実味のあるものにしています。この文章は、実際には存在しない風景描写を通して、精神的な不安を掻き立てています。
これらの要素を総合的に判断すると、この人物が体験したものは、現実の出来事とは異なる、強烈な幻覚であったと結論付けられます。"
となっており、完全に真逆です。
しかしLLMのスコアとしては最高の5で、理由は下記のように記載されています。
Referenceに書かれている内容と完全に一致しており、幻覚が現実の出来事と区別できないことを明確に述べています。