49. レポート課題4:RAGパラメータ比較実験#


49.1. 事前資料#

RAGの基礎概念・用語・今回の実装方針については先に guidance.ipynb を参照してください。

📄 guidance.ipynb — RAG基礎概念・チャンク分割・ROUGE-1・Top-K などの解説


49.2. 課題趣旨#

LLMが回答する前に外部情報源を検索し、その情報に基づいて回答を生成する技術(RAG)を体験します。

今回の知識ベースは架空企業「NALTOMA AI」の社内規程文書(英語)8 件です。 TinyLlama はこれらの文書を学習していないため、RAGなしでは正しく答えられません。 これにより「チャンクの内容が検索できれば答えられる・できなければ答えられない」という RAGの有効性を確認しつつ、また、必ず正答できるわけではないという限界についても観察する内容になっています。

49.3. 全体の流れ#

フェーズ

Level

内容

実行頻度

Phase 1:セットアップ

Drive マウント・ライブラリインストール・モデル読み込み

セッション開始時に1回

Phase 2:実験

Level 1

Medium 条件のベースライン確認

繰り返し可

Level 2

チャンクサイズ比較(Small / Medium / Large)

繰り返し可

Level 3

Top-K 比較(K = 1, 2, 3, 5)

繰り返し可

Level 4

考察(記述)

49.3.1. 実行手順#

  1. Google Drive 直下に 2026dm-rep4 フォルダを作成する。

  2. 本ノートブック・guidance.ipynbnaltoma_ai.tsv を同フォルダにアップロードする。

  3. GPUを指定する。

  • ダウンロード以外の処理時間は、CPU実行すると約1時間かかります。GPU実行すると約10分で終わります。GPUを使いたい場合には以下の手順を取ってください。なお、GPUは使用上限があります。使用上限に達した場合には数時間〜24時間程度、使用できなくなります。この場合は待つか、CPUに戻してから実行してください。

  • 「ランタイム」から「ランタイムのタイプの変更」を選ぶ。

  • 「T4 GPU」を選ぶと良い。選択肢の中では低スペックだが十分早い。

  1. Phase 1 のセルを上から順に実行する(初回はモデルのダウンロードに数分かかる)。

  2. Phase 2 の Level 1〜4 を順に取り組む。

  3. セッション切断後は Phase 1(Drive マウント〜モデル読み込み)から再実行すること。

49.3.2. 独自データセットを使う場合#

Step 0 の DATASET_FILE 変数の値を自分の TSV ファイル名に変更するだけで対応できます。 TSV の形式:1行目はヘッダー(doc_id\ttext)、2行目以降が文書データ。

49.3.3. 再現性#

埋め込み・FAISS・TinyLlama(do_sample=False)はすべて決定論的に動作します。


49.4. Phase 1:セットアップ#

新しいセッションを開くたびにここから実行すること。

49.4.1. Step 0:Google Drive マウント & キャッシュ設定#

from google.colab import drive
drive.mount('/content/drive')

import os
os.environ['HF_HOME'] = '/content/drive/MyDrive/hf_cache'
WORK_DIR = '/content/drive/MyDrive/2026dm-rep4'
os.makedirs(WORK_DIR, exist_ok=True)

# ── データセットファイル名 ─────────────────────────────────────────────────
# 独自データを使う場合はここだけ変更する(TSV 形式: doc_id \t text)
DATASET_FILE = 'naltoma_ai.tsv'
# ─────────────────────────────────────────────────────────────────────────

print('Drive マウント完了')
print(f'データセット: {DATASET_FILE}')

49.4.2. Step 1:ライブラリインストール#

!pip install -q transformers accelerate sentence-transformers faiss-cpu rouge-score plotly

49.4.3. Step 2:埋め込みモデルの読み込み#

from sentence_transformers import SentenceTransformer

EMBED_MODEL_ID = 'sentence-transformers/all-MiniLM-L6-v2'
embed_model = SentenceTransformer(EMBED_MODEL_ID, cache_folder=os.environ['HF_HOME'])
print(f'埋め込みモデル読み込み完了: {EMBED_MODEL_ID}')

49.4.4. Step 3:生成モデルの読み込み(TinyLlama)#

注意: 初回はモデルのダウンロード(約 700 MB)が発生します。2回目以降は Drive キャッシュから読み込まれます。

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

GEN_MODEL_ID = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0'

tokenizer = AutoTokenizer.from_pretrained(GEN_MODEL_ID)
gen_model = AutoModelForCausalLM.from_pretrained(
    GEN_MODEL_ID,
    dtype=torch.float16,
    device_map='auto',
)
gen_model.eval()

# max_length と max_new_tokens の競合を解消する
if getattr(gen_model.generation_config, 'max_length', None) is not None:
    gen_model.generation_config.max_length = None
    print('generation_config.max_length をリセットしました')

print(f'生成モデル読み込み完了: {GEN_MODEL_ID}')
print(f'デバイス: {next(gen_model.parameters()).device}')

49.5. Phase 2:実験#

セッションが新しい場合は Phase 1 を先に実行すること。

49.5.1. 知識ベース(ソース文書)#

TSV ファイルから読み込んだソース文書。デフォルトは「NALTOMA AI」社内規程 8 件(英語・各 430〜520 文字)。

文書ID

内容

remote_work

リモートワーク規程

expense_policy

経費精算規程

code_review

コードレビュー規程

deployment

デプロイ規程

meeting_rooms

会議室予約規程

api_policy

API利用規程

onboarding

オンボーディング規程

performance_review

評価制度規程

重要: これらは架空の文書です。TinyLlama はこれらの規程を学習していないため、 RAG による検索なしでは正しく答えることができません。

独自データを使う場合: Step 0 の DATASET_FILE を変更するだけで、 以降のセルはすべて自動的に新しいデータセットで動作します。

import csv

dataset_path = f'{WORK_DIR}/{DATASET_FILE}'
documents = []
with open(dataset_path, encoding='utf-8') as f:
    reader = csv.DictReader(f, delimiter='\t')
    for row in reader:
        documents.append((row['doc_id'], row['text']))

print(f'データセット  : {DATASET_FILE}')
print(f'ソース文書数  : {len(documents)}')
for key, text in documents:
    print(f'  [{key:20s}] {len(text)} 文字')
print(f'\n総文字数: {sum(len(t) for _, t in documents)}')

49.5.2. クエリ(全実験で共通・変更不可)#

queries = [
    'What are the core working hours for remote employees at NALTOMA AI?',
    'What is the deadline for submitting expense reports?',
    'How many approvals are required to merge a pull request?',
    'What days and times do production deployments occur?',
    'What is the API rate limit per team per minute?',
]

# 各クエリの参照回答(ROUGE-1 評価の基準として使用)
# TinyLlama の出力ではなく、ソース文書に基づいて事前定義した正解文字列
reference_answers = {
    0: 'Remote employees must be available during core hours from 10:00 to 16:00 JST on weekdays.',
    1: 'Business expenses must be submitted within 30 calendar days of purchase.',
    2: 'Pull requests require a minimum of two approvals from team members who were not the PR author.',
    3: 'Production deployments occur on Tuesdays and Thursdays at 02:00 JST.',
    4: 'The API rate limit is 120 requests per minute per team token.',
}

print(f'クエリ数: {len(queries)}')
for i, q in enumerate(queries):
    print(f'  Q{i+1}: {q}')

49.5.3. ユーティリティ関数#

以下の関数を Level 1〜3 で共通利用する。変更不可。

import faiss
import numpy as np
from rouge_score import rouge_scorer as rouge_module

def split_into_chunks(text, chunk_size, overlap=0, word_boundary=True):
    if word_boundary:
        words = text.split()
        chunks = []
        start = 0
        while start < len(words):
            chunk_words, total_len = [], 0
            for w in words[start:]:
                add = len(w) + (1 if chunk_words else 0)
                if total_len + add <= chunk_size:
                    chunk_words.append(w)
                    total_len += add
                else:
                    break
            if not chunk_words:
                chunk_words = [words[start]]
            chunks.append(' '.join(chunk_words))
            start += len(chunk_words)
        return [c for c in chunks if len(c.strip()) > 5]
    else:
        chunks, start = [], 0
        while start < len(text):
            chunk = text[start:start + chunk_size].strip()
            if len(chunk) > 10:
                chunks.append(chunk)
            start += chunk_size - overlap
        return chunks

def build_chunks_from_docs(documents, chunk_size, overlap=0, word_boundary=True):
    all_chunks = []
    for _, text in documents:
        all_chunks.extend(split_into_chunks(text, chunk_size, overlap, word_boundary))
    return all_chunks

def build_index(chunks, embed_model):
    embeddings = embed_model.encode(chunks, convert_to_numpy=True).astype('float32')
    index = faiss.IndexFlatL2(embeddings.shape[1])
    index.add(embeddings)
    return index, embeddings

def retrieve(query, index, chunks, embed_model, top_k=3):
    q_vec = embed_model.encode([query], convert_to_numpy=True).astype('float32')
    distances, indices = index.search(q_vec, top_k)
    return [{'chunk': chunks[i], 'distance': float(d)}
            for d, i in zip(distances[0], indices[0])]

def rag_generate(query, retrieved_chunks, max_new_tokens=150):
    context = '\n'.join([r['chunk'] for r in retrieved_chunks])
    messages = [
        {'role': 'system',
         'content': 'You are a helpful assistant. '
                    'Answer questions based only on the provided context. '
                    'Be concise and direct.'},
        {'role': 'user',
         'content': f'Context:\n{context}\n\nQuestion: {query}'},
    ]
    prompt = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = tokenizer(prompt, return_tensors='pt').to(gen_model.device)
    with torch.no_grad():
        outputs = gen_model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            do_sample=False,
            pad_token_id=tokenizer.eos_token_id,
        )
    generated = outputs[0][inputs['input_ids'].shape[1]:]
    answer = tokenizer.decode(generated, skip_special_tokens=True).strip()
    return answer, prompt

_scorer = rouge_module.RougeScorer(['rouge1'], use_stemmer=True)

def rouge1(hypothesis, reference):
    return _scorer.score(reference, hypothesis)['rouge1'].fmeasure

print('ユーティリティ関数を定義しました')

49.5.4. Level 1:ベースライン確認(Medium 条件)#

チャンクサイズ 120 文字(Medium)で全社内規程文書を分割し、チャンクの状態を確認する。 次に Q1・Q4 について検索と回答生成を実行して基準となる出力を観察する。

CHUNK_MEDIUM = 120

chunks_medium = build_chunks_from_docs(documents, chunk_size=CHUNK_MEDIUM)
print(f'チャンクサイズ: {CHUNK_MEDIUM} 文字(単語境界分割)')
print(f'総チャンク数 : {len(chunks_medium)}')
print()
print('── 先頭 5 チャンク ──')
for i, c in enumerate(chunks_medium[:5]):
    print(f'[{i:02d}] ({len(c)} 文字) {c}')
index_medium, _ = build_index(chunks_medium, embed_model)
print(f'インデックス内ベクトル数: {index_medium.ntotal}')
demo_queries = [queries[0], queries[3]]  # Q1, Q4

for q in demo_queries:
    retrieved = retrieve(q, index_medium, chunks_medium, embed_model, top_k=3)
    answer, _ = rag_generate(q, retrieved)
    print(f'Q: {q}')
    print(f'A: {answer}')
    print('検索ヒット(上位3件):')
    for j, r in enumerate(retrieved):
        print(f'  [{j+1}] (dist={r["distance"]:.3f}) {r["chunk"][:80]}')
    print()

49.5.5. ✍️ Level 1 報告事項#

(1) Medium 条件のチャンク総数を報告せよ。

(2) 先頭 5 チャンクを掲載し、どの文書の内容が含まれているかを確認せよ。単語境界分割によって文字が単語の区切りで終わっていることをコメントせよ。

(3) Q1・Q4 それぞれについて、検索されたチャンク上位 3 件の内容(冒頭 60 文字程度)を掲載せよ。

(4) 生成された回答を掲載し、参照回答と照合して正しく答えているかを簡潔にコメントせよ。 特に、RAG なしでは TinyLlama が答えられない理由をあわせて述べよ。


49.5.6. Level 2:チャンクサイズ比較(Small / Medium / Large)#

3 種類のチャンクサイズでそれぞれインデックスを構築し、同一クエリに対する検索結果と回答品質を比較する。

条件

チャンクサイズ

Small

50 文字

Medium

120 文字

Large

250 文字

評価: reference_answers を参照として ROUGE-1 を計算する(条件間の絶対比較)。 数値だけでなくヒットチャンクの内容と実際の回答テキストを必ず確認すること。

CHUNK_SMALL, CHUNK_LARGE = 50, 250
conditions = {'Small': CHUNK_SMALL, 'Medium': CHUNK_MEDIUM, 'Large': CHUNK_LARGE}

chunks_dict, index_dict = {}, {}
for name, size in conditions.items():
    ch = build_chunks_from_docs(documents, chunk_size=size)
    idx, _ = build_index(ch, embed_model)
    chunks_dict[name] = ch
    index_dict[name]  = idx
    print(f'{name:6s} (size={size:3d}): チャンク数 = {len(ch):3d}')
TOP_K_FIXED = 3
results_l2 = {}

for name in conditions:
    results_l2[name] = {}
    for qi, q in enumerate(queries):
        retrieved = retrieve(q, index_dict[name], chunks_dict[name], embed_model, top_k=TOP_K_FIXED)
        answer, _ = rag_generate(q, retrieved)
        results_l2[name][qi] = {'answer': answer, 'hits': retrieved}
    print(f'{name} 完了')
print('全条件の生成完了')
import pandas as pd

rouge_table = []
for name in conditions:
    for qi in range(len(queries)):
        score = rouge1(results_l2[name][qi]['answer'], reference_answers[qi])
        rouge_table.append({'condition': name, 'query': f'Q{qi+1}', 'rouge1': score})

df_rouge = pd.DataFrame(rouge_table)
pivot = df_rouge.pivot(index='query', columns='condition', values='rouge1').round(3)
print('ROUGE-1 スコア(reference_answers 参照)')
print(pivot)
print()
print('条件別 平均 ROUGE-1:')
print(df_rouge.groupby('condition')['rouge1'].mean().round(3))
for qi in [0, 3]:
    print('=' * 70)
    print(f'クエリ Q{qi+1}: {queries[qi]}')
    print(f'参照回答   : {reference_answers[qi]}')
    for name in conditions:
        r = results_l2[name][qi]
        print(f'\n  [{name}]')
        print(f'  回答: {r["answer"][:180]}')
        print(f'  ヒット[1]: {r["hits"][0]["chunk"][:70]}')
    print()
import plotly.graph_objects as go
from plotly.subplots import make_subplots

cond_names = list(conditions.keys())
colors     = ['#42A5F5', '#66BB6A', '#FFA726']
chunk_counts = [len(chunks_dict[n]) for n in cond_names]
avg_rouge    = df_rouge.groupby('condition')['rouge1'].mean().reindex(cond_names).values

fig = make_subplots(rows=1, cols=2,
    subplot_titles=['Number of Chunks per Condition',
                    'Avg ROUGE-1 (vs reference_answers)'])

for i, (name, cnt, rg) in enumerate(zip(cond_names, chunk_counts, avg_rouge)):
    fig.add_trace(go.Bar(x=[name], y=[cnt], name=name,
                         marker_color=colors[i], showlegend=False,
                         text=[cnt], textposition='outside'), row=1, col=1)
    fig.add_trace(go.Bar(x=[name], y=[round(rg,3)], name=name,
                         marker_color=colors[i], showlegend=False,
                         text=[round(rg,3)], textposition='outside'), row=1, col=2)

fig.update_yaxes(range=[0, max(chunk_counts)*1.2], row=1, col=1)
fig.update_yaxes(range=[0, 1.1], row=1, col=2)
fig.update_layout(title_text='Level 2: Chunk Size Comparison', height=420)
fig.show()
fig.write_html(f'{WORK_DIR}/level2_chunk_comparison.html')
print('図を保存しました')

49.5.7. ✍️ Level 2 報告事項#

(1) Small / Medium / Large 各条件のチャンク総数を報告せよ。

(2) ROUGE-1 スコアの表と条件別平均を掲載せよ。

(3) 代表クエリ(Q1・Q4)について、条件ごとの回答テキストを掲載せよ(各 180 文字程度)。

(4) Medium 条件と比較して Small 条件でスコアが変化したクエリを 1 件以上挙げ、ヒットチャンクの内容を引用しながら スコアが高く/低くなった理由を説明せよ。

(5) チャンクサイズにかかわらず、Top-K 検索結果には異なる規程のチャンクが混入することがある。いずれかの条件でそのようなケースが観察された場合、具体的なチャンク内容を引用しながら回答品質への影響を考察せよ。


49.5.8. Level 3:Top-K 比較#

チャンクサイズを Medium(120 文字) で固定し、Top-K のみを変化させる。

K

説明

1

最も類似度の高い 1 チャンクのみ

2

2 チャンク

3

Level 2 のベースライン

5

より多くのコンテキスト。ノイズ混入リスクも増す

K_VALUES = [1, 2, 3, 5]
results_l3 = {}

for k in K_VALUES:
    results_l3[k] = {}
    for qi, q in enumerate(queries):
        retrieved = retrieve(q, index_medium, chunks_medium, embed_model, top_k=k)
        answer, prompt = rag_generate(q, retrieved)
        results_l3[k][qi] = {
            'answer'    : answer,
            'hits'      : retrieved,
            'prompt_len': len(prompt),
        }
    print(f'K={k} 完了')
print('全 K の生成完了')
rouge_l3 = []
for k in K_VALUES:
    for qi in range(len(queries)):
        score = rouge1(results_l3[k][qi]['answer'], reference_answers[qi])
        plen  = results_l3[k][qi]['prompt_len']
        rouge_l3.append({'K': k, 'query': f'Q{qi+1}', 'rouge1': score, 'prompt_len': plen})

df_l3  = pd.DataFrame(rouge_l3)
pivot3 = df_l3.pivot(index='query', columns='K', values='rouge1').round(3)
print('ROUGE-1 スコア(reference_answers 参照)')
print(pivot3)
print()
print('K 別 平均プロンプト文字数:')
print(df_l3.groupby('K')['prompt_len'].mean().round(0))
for qi in [1, 4]:
    print('=' * 70)
    print(f'クエリ Q{qi+1}: {queries[qi]}')
    print(f'参照回答   : {reference_answers[qi]}')
    for k in K_VALUES:
        r = results_l3[k][qi]
        hit_preview = ' | '.join([h['chunk'][:28]+'...' for h in r['hits']])
        print(f'  [K={k}] {r["answer"][:130]}')
        print(f'        ヒット: {hit_preview}')
    print()
avg_r1   = df_l3.groupby('K')['rouge1'].mean()
avg_plen = df_l3.groupby('K')['prompt_len'].mean()

fig = make_subplots(rows=1, cols=2,
    subplot_titles=['Avg ROUGE-1 vs K (reference_answers)',
                    'Avg Prompt Length vs K'])

fig.add_trace(go.Scatter(x=K_VALUES, y=avg_r1.values,
    mode='lines+markers', line=dict(color='steelblue', width=2),
    marker=dict(size=8), showlegend=False), row=1, col=1)

fig.add_trace(go.Scatter(x=K_VALUES, y=avg_plen.values,
    mode='lines+markers', line=dict(color='coral', width=2),
    marker=dict(size=8), showlegend=False), row=1, col=2)

fig.update_xaxes(tickvals=K_VALUES)
fig.update_yaxes(range=[0, 1.1], row=1, col=1)
fig.update_layout(title_text='Level 3: Top-K Comparison (Medium chunks)', height=420)
fig.show()
fig.write_html(f'{WORK_DIR}/level3_topk_comparison.html')
print('図を保存しました')

49.5.9. ✍️ Level 3 報告事項#

(1) ROUGE-1 スコアの表と K 別平均プロンプト文字数を掲載せよ。

(2) K を増やすことで ROUGE-1 が改善したクエリと改善しなかったクエリを各 1 件以上挙げ、 ヒットチャンクの内容を引用しながら理由を考察せよ。

(3) K=5 の場合、質問と異なる規程文書 のチャンクが混入していると思われるケースはあったか? あった場合は具体的なチャンク内容と文書IDを示し、それが回答にどう影響したかを述べよ。

(4) 「プロンプト長が増えることで生じうる問題」を、コンテキストウィンドウの観点から述べよ。


49.5.10. Level 4:考察#

Level 2・3 の実験結果全体を踏まえ、以下の問いに答えよ。コードの実行は不要。

49.5.11. ✍️ Level 4 報告事項#

必須(両方に答えること)

(1) 今回の実験条件の中で「最も実用的」と判断するチャンクサイズと Top-K の組み合わせを選び、 実験結果に基づいて理由を述べよ。

(2) 今回の知識ベースは「社内規程」という TinyLlama が学習していない文書であった。 もし ML の教科書テキスト(TinyLlama が学習している内容)を知識ベースにした場合、 実験結果の解釈がどう変わるか。RAGの有効性の評価という観点から述べよ。

選択(以下から 1 つ以上)

(3) 本課題で使用した ROUGE-1(reference_answers を参照とする手法)の問題点を具体的に述べよ。 より適切な評価方法があるとすれば何か?

  • ヒント: BERTScore / コサイン類似度 / LLM-as-Judge / 情報抽出ベース

(4) 今回の知識ベース(all-MiniLM-L6-v2)は英語テキストで、埋め込みモデルも英語中心に学習されている。 日本語の社内規程を知識ベースにする場合、どのような問題が予想されるか。

(5) RAG を使ってもモデルが誤った回答を生成してしまうケースを今回の実験から 1 件以上挙げ、 「検索の失敗」と「生成モデルの限界」のどちらが主因かを分析せよ。


49.6. オプション:オーバーラップ実験#

以下は必須ではない。取り組んだ場合はレポートに記載すること(加点対象)。

チャンクサイズ 120 文字・オーバーラップ 40 文字の条件(word_boundary=False)を追加し、 Medium(オーバーラップなし・単語境界)と比較する。

単にスコアを比較するだけでなく「なぜオーバーラップが効く(または効かない)か」を チャンクの内容から説明すること。

CHUNK_OVERLAP = 40

chunks_overlap = build_chunks_from_docs(
    documents, chunk_size=CHUNK_MEDIUM, overlap=CHUNK_OVERLAP, word_boundary=False
)
index_overlap, _ = build_index(chunks_overlap, embed_model)
print(f'Overlap 条件 (word_boundary=False): チャンク数 = {len(chunks_overlap)}')
print(f'Medium 条件 (word_boundary=True) : チャンク数 = {len(chunks_medium)}')

results_ov = {}
for qi, q in enumerate(queries):
    ret = retrieve(q, index_overlap, chunks_overlap, embed_model, top_k=3)
    ans, _ = rag_generate(q, ret)
    results_ov[qi] = {'answer': ans, 'hits': ret}

print('\nROUGE-1(reference_answers 参照)')
for qi in range(len(queries)):
    s_ov  = rouge1(results_ov[qi]['answer'],     reference_answers[qi])
    s_med = rouge1(results_l2['Medium'][qi]['answer'], reference_answers[qi])
    print(f'  Q{qi+1}: Overlap={s_ov:.3f}  Medium={s_med:.3f}')

49.7. 提出物#

  • このノートブック(report4_2026.ipynb)

    • Level 1〜4 のすべてのセルが実行済みの状態で提出すること

    • ✍️ マークの報告事項に対する回答・考察をノートブック内の Markdown セルに記入すること

    • オプションに取り組んだ場合はその結果と考察も記載すること

  • ノートブックでレポートを書きづらい場合には、別途自由形式で作成して提出しても良い。ただし実行結果を保存したノートブックは必ず提出すること。

hf_cache、生成された HTML ファイルは提出不要です。 独自データセット TSV を使用した場合はそのファイルも提出すること。