ローカルRAGは本当に効く?自作13記事をQwen 3 8Bへ検索させ20問で検証

実験日: 2026-08-24
環境: RTX 5070 Ti 16GB / WSL2 / Ollama 0.32.15 / Qwen 3 8B Q4

RAGを使うとLLMが賢くなる、と説明されることがあります。しかし検索が外れれば、LLMへ正しい根拠は届きません。また、元からmodelが知っている一般知識だけで試すと、retrievalの効果を過大評価します。

RAGをつなぐと賢くなったように見えますが、検索と回答を分けて測らなければ効果は判断できません。

そこで、このPCで直前に測った速度・VRAM・正解率を含む自作13記事をlocal corpusにしました。これらの数値はQwen 3のpretraining時には存在しません。同じ20問を「検索なし」と「TF-IDFで上位3 chunkを検索して渡すRAG」で回答させたところ、正解は0/20から19/20へ増えました。

ただし1問は、正しいdocumentを検索できたのに必要なheading chunkを渡せず失敗しました。RAGはdatabase接続の魔法ではなく、retrieval、chunking、generationを別々に評価すべきpipelineです。

0問から19問へ増えた

metric 結果
検索なしの数値正解 0/20
RAGの数値正解 19/20
期待sourceがrank 1 17/20
期待sourceがtop 3内 20/20
期待sourceを回答で引用 19/20
平均回答時間:検索なし 0.316秒
平均回答時間:RAG 0.376秒
ローカル13記事をTF-IDF検索してQwen 3 8Bへ渡したRAGの20問評価
ローカル13記事をTF-IDF検索してQwen 3 8Bへ渡したRAGの20問評価

この結果は「RAGなら95%正しい」という一般性能ではありません。このcorpus、この20問、公開した正規表現採点での結果です。一方、検索なし0/20は、modelの記憶では答えられないPC固有の数値を選べたことを確認します。

RAGとは何を追加する仕組みか

RAGはRetrieval-Augmented Generationの略です。質問を受けてから外部資料を検索し、関連部分をLLMのpromptへ追加して回答させます。

user question
    ↓
retriever ──→ top-k chunks
    ↓              ↓
    └──── promptへ結合
                   ↓
              local LLM
                   ↓
          answer + source ID

model parameterを再学習するfine-tuningとは違い、文書を差し替えれば新しい情報へ更新できます。反対に、検索で取得しなかった情報はpromptに入りません。

RAGの評価を1個の「回答正解率」だけにすると、どこが壊れたか分かりません。今回は次を分けました。

retrieval hit@1 : 正解sourceが1位か
retrieval hit@3 : 正解sourceが上位3件にあるか
answer accuracy : 必要な数値を回答したか
citation check  : 期待source IDを引用したか
latency         : generationに何秒かかったか

modelが知らない20問を作る

質問は、このPCで2026-08-24に実測して記事へ記録した値です。

12BモデルのGPU peak使用量は何GiBだった?
context 16384条件で実際のprompt token数はいくつ?
code生成でQwen 3 8Bは初回と修正後に何問合格した?
batch 8192のpeak allocatedは約何MiB?
HOG+SVMとCNNのtest accuracyをそれぞれ答えて。

一般的な「日本の首都」や「backpropagationとは」を使いません。既存知識で正答すると、retrievalが効いたのか判別できないからです。

20問には正解sourceと受理する数値patternを事前に固定しました。例えば12BのTruthfulQA strict率なら73.0を要求します。file sizeのように回答が3.1095 GiBから3.11 GiBへ丸められても意味が同じ場合は、両方を受理しました。

採点規則を出力後に都合よく変えないことが重要です。最初のdiagnostic runで3.11と4.64を誤答扱いする過度に厳しいpatternを発見し、意味を保つ丸めだけを許して全20問を再実行しました。この変更もwork logへ残しています。

corpusは13記事、198 chunk

対象は、これまで作ったlocal LLM、PyTorch、FashionMNISTなどのMarkdown 13本です。

13 documents
198 heading chunks

## headingをchunk境界にしました。固定長で800文字ごとに切る方法より、1つの話題を保ちやすいからです。画像記法を除き、1 chunkの上限を3,500文字にしました。

parts = re.split(r"(?=^##\s+)", text, flags=re.M)

for i, part in enumerate(parts):
    chunks.append({
        "id": f"{path.name}#{i}",
        "source": path.name,
        "text": clean[:3500],
    })

source IDにfilenameとchunk番号を持たせると、LLMのcitationを元文書へ戻せます。productionではheading名、URL、更新日、access controlもmetadataへ入れます。

日本語tokenizerなしでcharacter n-gramを使う

最初のretrieverにはscikit-learnのTfidfVectorizerを使いました。

vectorizer = TfidfVectorizer(
    analyzer="char_wb",
    ngram_range=(2, 4),
    min_df=1,
    sublinear_tf=True,
)

日本語は英語のようにspaceで単語が分かれません。word analyzerをそのまま使うと分割が粗くなるため、2~4文字の連続部分をfeatureにしました。Qwen 3 8Bbatch 8192GPU peakのような英数字も部分一致します。

TF-IDFは、document内で多い語のterm frequencyと、corpus全体で珍しい語のinverse document frequencyを組み合わせます。全記事に出る「実験」より、特定記事に出るTruthfulQA8192を強く扱えます。

query vectorと198 chunkのsparse vectorの内積を取り、score上位3件を選びました。

qvec = vectorizer.transform([question])
scores = (matrix @ qvec.T).toarray().ravel()
top_idx = np.argsort(scores)[::-1][:3]

vectorをL2正規化するdefault設定なので、内積はcosine similarityとして解釈できます。

promptは「資料だけ」「無ければ無い」と指定

RAG promptです。

提供資料だけを根拠に質問へ簡潔に答えてください。
最後に根拠のchunk IDを角括弧で1つ以上引用してください。
資料に無ければ「資料内に記載なし」と答えてください。

資料:
[article_...md#4]
...

質問: ...

比較用の検索なしpromptにも「分からなければ不明」「推測で数値を作らない」と入れました。両条件はtemperature 0、seed 42、think:falsenum_predict:120で揃えています。

検索なしが0/20だったのは望ましいbaselineです。例えば2026年8月にこのPCで測ったVRAM使用量を、pretrained modelが知っているはずはありません。曖昧な推測を正解に含めない設計です。

RAGは19/20へ改善した

成功例です。

質問:
12BモデルのGPU peak使用量は何GiBだった?

回答:
9.7GiB [article_ollama_4b_8b_12b.md#4]

この質問では期待sourceはretrieval rank 2でした。rank 1は別のquantization記事でしたが、top 3を全て渡したため正答できました。

別の例です。

質問:
code生成で4Bの修正後合格数はいくつ?

回答:
7/10 [article_llm_codegen_pytest.md#1]

これも期待sourceはrank 2です。hit@1が17/20でもanswerが19/20なのは、top-kを3にした効果です。

唯一の失敗は「document hit、chunk miss」

失敗した質問です。

context 16384条件で実際のprompt token数はいくつ?

retrieval rank 1は正しいarticle_ollama_context_4k_8k_16k.mdでした。しかし選ばれた3 chunkは#2、#15、#10で、11,244が書かれたchunkを含みませんでした。

LLMの回答は次です。

資料内に記載なし

これは良いfailureです。根拠が届かなかったのに11,244を推測せず、unknownを返しました。answer accuracyは不合格でも、hallucination controlは機能しています。

一方、「正しいdocumentが1位だからretrieval成功」とだけ集計すると、この失敗を見落とします。LLMが読む単位はdocumentではなく渡されたchunkです。document-level hitとevidence-level hitを分ける必要があります。

どう直すか

最初に試す改善は、chunkへdocument titleとheadingを必ず付けることです。今回のquestionにあるcontext 16384と、数値tableを含むheadingの語が近づきます。

次の候補です。

  • top-kを3から5へ増やす
  • 前後chunkも一緒に渡す
  • heading + bodyでindexを作る
  • query中の数値やmodel名を重くする
  • TF-IDFとembeddingを併用するhybrid検索
  • 上位候補をcross-encoderやLLMでrerankする

ただしtop-kを増やすほどpromptは長くなり、無関係な文も増えます。context windowへ収まることと、回答が正しいことは別です。embedding検索を同じquestion setで比較すると、このTF-IDF baselineとの差を測れます。

citationがあっても正しいとは限らない

期待sourceの引用は19/20でしたが、文字列としてsource IDがあるだけではfaithfulnessの証明になりません。

最低でも次を確認します。

citation validity   : IDが実在するか
citation correctness: そのchunkが主張を含むか
citation completeness: 回答の主要claimすべてに根拠があるか
answer correctness  : ground truthと合うか

今回は数値とexpected sourceを自動照合しました。自由記述の長文では、claim分割と人手review、または別のentailment evaluatorが必要です。

平均0.06秒の増加をどう読むか

平均wall timeは検索なし0.316秒、RAG 0.376秒でした。約0.06秒増です。

この値はretrieval計算を含むfull API latencyではなく、scriptがOllama generate requestを送ってからresponseを受け取る時間です。TF-IDF検索は小規模corpusで非常に短く、今回は個別計測していません。

RAG promptはtop 3 chunk分長いためprompt evaluationが増えます。corpusが100万chunkになればindex検索やnetwork、storageも効きます。この13記事の値を大規模systemへ外挿できません。

再現手順

cd /mnt/c/Users/user/Documents/Codex/2026-08-24/ko
/opt/ai-lab/venv/bin/python -u work/ai_lab/experiment_local_rag.py
/opt/ai-lab/venv/bin/python work/ai_lab/plot_local_rag.py

artifactです。

work/ai_lab/experiment_local_rag.py
work/ai_lab/results/local_rag.json
work/ai_lab/plot_local_rag.py
work/figures/local-rag-tfidf-qwen3.png

JSONには全20問について、期待pattern、期待source、top 3 chunk本文とscore、検索なし回答、RAG回答、正誤、citation、wall timeを保存しています。集計値だけでなく、唯一のfailureを再調査できます。

RAG実験で最初に置くべきbaseline

いきなりvector databaseやagent frameworkを導入する前に、小規模corpusならTF-IDF baselineを作る価値があります。

1. modelが暗記していないquestion setを固定
2. sourceとevidence chunkを正解dataへ付与
3. simple lexical retrievalを測る
4. retrieval hitとanswer accuracyを分離
5. raw retrieved textとresponseを保存
6. failureをretrieval / generation / scoringへ分類

このbaselineよりembeddingが本当に良いか、複雑なframeworkが本当に必要かを比較できます。

今回、検索なしのQwen 3 8Bは20個のPC固有値を1つも答えられず、RAGで19個を正答しました。しかし残る1件はmodelを大きくしても直りません。必要なchunkがpromptへ届いていないからです。

RAGの中心課題は「LLMへ資料を付ける」ことではなく、「答えを支持する最小の根拠を、再現可能な方法で選び、届かなかったときに検出する」ことです。

関連記事