実習の一覧へ戻る

週末ラボ1: 自分の資料でRAGを作り、20問で当たり外れを測る

所要時間: 約3〜3.5時間(準備30分+作業2.5〜3時間。ほかに、モデルなどのダウンロード時間が回線次第で30分以上かかります) / 難易度: Pythonスクリプトを動かせれば可 / 費用: 0円(すべて自分のPCで動く)

動作確認: 2026-10-18。スクリプトはPython 3.12・chromadb 1.5.9 で、検索・集計部分の動作を確認済み。Ollama本体と埋め込みモデルを使った通し実行は、作成環境にOllamaが無かったため未確認です(APIの形式は公式ドキュメントで確認済み)。

このラボのゴール

社内の規程やFAQを使う「RAG(検索して答えるAI)」を小さく作り、検索が正しい資料を拾えたかを20問で数えます。

終わったあと、顧客に次のことを自分の言葉で説明できるようになります。

  • 「RAGの誤答」は1種類ではない。①資料に答えが無い ②検索で外した ③検索は当たったが回答が誤り ④元資料が古い・矛盾している に分けると、直す場所(資料の整備/検索の設定/プロンプトやモデル/資料の更新ルール)が変わる。
  • 「何割当たるか」は、実際に聞かれる質問で作った小さなテストセットで測れる。20問程度でも傾向はつかめるが、20問の結果には大きな幅がある(18/20=90%でも、95%区間はおよそ70〜97%)。
  • 意味で探す検索(ベクトル検索)とキーワード検索は得意分野が違う。型番・規程番号・エラーコードのような語はキーワード検索の方が拾いやすいことがある。

用語(このラボで使う意味)

用語意味
チャンク資料を一定の文字数で区切った断片。検索はチャンク単位で行う
埋め込み(ベクトル)文章の意味を数値の並びにしたもの。近い意味の文章は近い数値になる
top-k検索結果の上位k件。RAGでは上位k件だけをAIに渡すのが一般的
hit@k「正解の根拠が書かれたチャンクが上位k件に入った問題の割合」
ゴールデンセット質問と「正解の根拠がどの資料のどこにあるか」を人が決めた評価用の問題集

前提

  • Windows 11、PowerShell(Windows 標準の「Windows PowerShell 5.1」でも「PowerShell 7」でも可)
  • 空きディスク 約10GB(Ollama本体+モデル2つ。埋め込みモデル bge-m3 は約1.2GB、回答用の gemma3:4b は約3.4GB)
  • メモリ 16GB以上を推奨(gemma3:4b を動かすため。8GBでは回答生成が遅い/動かない可能性があります。未確認)
  • 使う資料は公開済みのもの、または自分で作ったダミーにしてください(下の「安全上の注意」参照)

手順

0. 準備(約30分)

(1) Python 3.12

まだ入っていなければ、Python公式が推奨する「Python install manager」を入れてから 3.12 を追加します(Microsoft Store または python.org から入手)。

py install 3.12
py list

py install が「不明なオプション」等で失敗したら、従来の py ランチャー(python.org の 3.12 インストーラ付属)が入っています。py -0 で 3.12 が一覧にあればそのまま次へ進めます。無ければ python.org から Python 3.12 のインストーラを入れてください。(py install / py list は新しい Python install manager のコマンドで、py -0 は新旧どちらの py でも使えます。出典は下の「公式ドキュメント」の Python on Windows)

(2) Ollama(自分のPCでAIモデルを動かすソフト)

https://ollama.com/download から OllamaSetup.exe を実行します(管理者権限は不要)。インストール後、新しいPowerShellで:

ollama --version
ollama pull bge-m3
ollama pull gemma3:4b
ollama list

モデル2つで約4.6GBをダウンロードします。回線によっては30分以上かかるため、前日に ollama pull だけ済ませておくと当日が楽です。このあと入れる chromadb も、依存パッケージを含めて数百MBほど取得します(目安。未計測)。

Ollama はインストール後、http://localhost:11434 で待ち受けます。

(3) 作業フォルダと仮想環境

mkdir C:\lab\rag
cd C:\lab\rag
mkdir docs
# Python の日本語表示が文字化けしないように(このウィンドウの中だけ有効)
$env:PYTHONUTF8 = "1"
py -V:3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install chromadb==1.5.9

$env:PYTHONUTF8 = "1" はこのPowerShellウィンドウを閉じると消えます。ウィンドウを開き直したら、手順3の実行の前にもう一度入力してください。

Activate.ps1 で仮想環境を有効にする方法もありますが、PowerShellの実行ポリシーで止まることがあるため、このラボでは .\.venv\Scripts\python.exe を直接呼びます。

(4) SQLite FTS5 が使えるか確認

キーワード検索に Python 同梱の SQLite の全文検索機能(FTS5、trigram トークナイザ)を使います。trigram は SQLite 3.34.0(2020-12)で追加された機能です。

.\.venv\Scripts\python.exe -c "import sqlite3; print(sqlite3.sqlite_version); c=sqlite3.connect(':memory:'); c.execute('create virtual table t using fts5(x, tokenize=trigram)'); print('FTS5 trigram OK')"

FTS5 trigram OK と出ればOKです(作成環境の Python 3.12 では SQLite 3.42.0 で動作しました)。

1. 資料を用意する(約20分)

C:\lab\rag\docs に、10〜30本の資料を .txt または .md(文字コード UTF-8)で置きます。

  • 例: 自社サイトで公開済みのFAQ、公開している利用規約・プライバシーポリシー、自分で書いたダミーの社内規程
  • PDFやWordはテキストをコピーして .txt に貼り付ければ十分です(表や図は崩れます。それも「RAGの現実」として観察対象です)
  • ファイル名に版や日付を入れておくと、手順5の「元資料が古い」の判定が楽になります(例: 経費規程_2024-04.txt)
  • 古い版と新しい版をわざと両方入れておくと、④の失敗が観察できます
  • サブフォルダを作る場合も、ファイル名はすべて別の名前にしてください(スクリプトはファイル名で資料を区別します)

2. 20問のゴールデンセットを作る(約60分) ― このラボの本体

C:\lab\rag\golden.csv を作ります(Excelで作って「CSV UTF-8」で保存して可)。列は4つです。

列内容
qid問題番号(Q01〜Q20)
question質問文。実際に社員や顧客が聞きそうな言い方で書く
source正解が書いてある資料のファイル名。資料に答えが無い問題は空欄
evidence正解の根拠となる資料中の短い一節(10〜20字程度)をそのままコピー

例:

qid,question,source,evidence
Q01,経費っていつまでに出せばいい?,経費規程_2025-04.txt,翌月10日までに申請
Q02,パスワードは何文字以上?,情報セキュリティ規程.txt,12文字以上
Q03,ERR-4031が出たときの対処は,FAQ_システム.txt,ERR-4031
Q04,社員旅行の補助金はいくら?,,

作り方のコツ:

  • 20問の内訳の目安: ふつうの質問12問、言い換え・略語・口語の質問4問、型番やコードを含む質問2問、資料に答えが無い質問2問
  • 質問は資料の文をコピーしない(コピーすると検索が不自然に当たりやすくなります)
  • evidence は資料からそのままコピーします。スクリプトは空白を無視して「その一節を含むチャンクが上位に出たか」で判定します
  • evidence が長すぎるとチャンクの境目で切れて「外れ」扱いになります。15字前後が目安です

3. スクリプトを置いて実行する(約30分)

下の rag_eval.py を C:\lab\rag\rag_eval.py として保存(UTF-8)し、チャンク長を変えて2回実行します。

cd C:\lab\rag
.\.venv\Scripts\python.exe rag_eval.py --docs docs --golden golden.csv --chunk 300
.\.venv\Scripts\python.exe rag_eval.py --docs docs --golden golden.csv --chunk 800

出力例(数値は例):

チャンク数: 142 / 問題数: 20 / チャンク長: 300字

[vector] 資料に答えがある問題 18 問で集計
  hit@1: 11/18 = 61%  (95%区間 39%〜80%)
  hit@3: 15/18 = 83%  (95%区間 61%〜94%)
  hit@5: 16/18 = 89%  (95%区間 67%〜97%)
  → results_vector_c300.csv に保存

[keyword] 資料に答えがある問題 18 問で集計
  ...
  • [vector] = 埋め込み(bge-m3)+Chroma による意味検索、[keyword] = SQLite FTS5(trigram, BM25順位)によるキーワード検索
  • 「資料に答えが無い問題」(source が空欄)は hit@k の分母から外しています

rag_eval.py(全文)

"""週末ラボ1: 自分の資料でRAGを作り、20問で当たり外れを測る(最小構成)

使い方(PowerShell):
  python rag_eval.py --docs docs --golden golden.csv --chunk 400
  python rag_eval.py --docs docs --golden golden.csv --chunk 400 --answer   # 回答も作る

- ベクトル検索: Ollama の埋め込みモデル(既定 bge-m3) + Chroma
- キーワード検索: SQLite FTS5 (trigram トークナイザ, bm25 順位)
- 結果: 画面に hit@1/3/5 と 95%区間、results_<方式>_c<チャンク長>.csv に問題ごとの順位
"""
import argparse
import csv
import json
import math
import pathlib
import re
import sqlite3
import urllib.request

import chromadb
from chromadb.config import Settings

OLLAMA = "http://localhost:11434"
KS = (1, 3, 5)


# ---------- 1. 資料を読み込んでチャンクに分ける ----------
def load_chunks(doc_dir, size, overlap):
    chunks = []
    seen = {}
    for path in sorted(pathlib.Path(doc_dir).rglob("*")):
        if path.suffix.lower() not in (".txt", ".md"):
            continue
        if path.name in seen:  # 同じファイル名があると、どの資料が当たったか区別できない
            raise SystemExit(f"同じファイル名が2つあります: {seen[path.name]} と {path}。どちらかの名前を変えてから再実行してください")
        seen[path.name] = path
        text = re.sub(r"\s+", " ", path.read_text(encoding="utf-8")).strip()
        step = max(size - overlap, 1)
        start, i = 0, 0
        while start < len(text):
            piece = text[start:start + size]
            chunks.append({"id": f"{path.name}#{i}", "source": path.name, "text": piece})
            if start + size >= len(text):
                break
            start += step
            i += 1
    return chunks


def norm(s):
    """空白を除いて比較する(根拠フレーズの照合用)"""
    return re.sub(r"\s+", "", s or "")


# ---------- 2. Ollama で埋め込みを作る ----------
def post_json(url, payload, timeout=300):
    req = urllib.request.Request(
        url, data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as res:
        return json.loads(res.read().decode("utf-8"))


def embed(texts, model, batch=16):
    vectors = []
    for i in range(0, len(texts), batch):
        out = post_json(f"{OLLAMA}/api/embed", {"model": model, "input": texts[i:i + batch]})
        vectors.extend(out["embeddings"])
    return vectors


# ---------- 3a. ベクトル検索 (Chroma) ----------
def vector_search(chunks, questions, k, model):
    client = chromadb.EphemeralClient(  # メモリ上だけ。毎回作り直す
        settings=Settings(anonymized_telemetry=False))  # 利用統計の送信を止める
    col = client.get_or_create_collection(
        "chunks", embedding_function=None,
        configuration={"hnsw": {"space": "cosine"}})
    col.add(ids=[c["id"] for c in chunks],
            embeddings=embed([c["text"] for c in chunks], model),
            documents=[c["text"] for c in chunks],
            metadatas=[{"source": c["source"]} for c in chunks])
    res = col.query(query_embeddings=embed(questions, model), n_results=min(k, len(chunks)))
    return [[{"id": i, "source": m["source"], "text": d}
             for i, m, d in zip(ids, metas, docs)]
            for ids, metas, docs in zip(res["ids"], res["metadatas"], res["documents"])]


# ---------- 3b. キーワード検索 (SQLite FTS5 trigram + bm25) ----------
def keyword_search(chunks, questions, k):
    db = sqlite3.connect(":memory:")
    db.execute("CREATE VIRTUAL TABLE t USING fts5(cid UNINDEXED, source UNINDEXED, body, tokenize='trigram')")
    db.executemany("INSERT INTO t VALUES (?,?,?)",
                   [(c["id"], c["source"], c["text"]) for c in chunks])
    results = []
    for q in questions:
        q2 = re.sub(r"[\s\"'??。、,.!!「」()()]", "", q)
        grams = sorted({q2[i:i + 3] for i in range(len(q2) - 2)})
        if not grams:
            results.append([])
            continue
        match = " OR ".join(f'"{g}"' for g in grams)
        rows = db.execute("SELECT cid, source, body FROM t WHERE t MATCH ? ORDER BY rank LIMIT ?",
                          (match, k)).fetchall()
        results.append([{"id": r[0], "source": r[1], "text": r[2]} for r in rows])
    return results


# ---------- 4. 当たり判定と集計 ----------
def hit_rank(hits, source, evidence):
    """正解の資料かつ根拠フレーズを含むチャンクが何位に出たか(出なければ None)"""
    for rank, h in enumerate(hits, start=1):
        if h["source"] == source and (not evidence or norm(evidence) in norm(h["text"])):
            return rank
    return None


def wilson(x, n, z=1.96):
    """二項割合の95%区間(Wilson法)"""
    if n == 0:
        return (float("nan"), float("nan"))
    p = x / n
    denom = 1 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return (center - half, center + half)


def answer(question, hits, chat_model):
    context = "\n\n".join(f"[{h['id']}] {h['text']}" for h in hits[:3])
    out = post_json(f"{OLLAMA}/api/chat", {
        "model": chat_model, "stream": False, "options": {"temperature": 0},
        "messages": [
            {"role": "system", "content": "次の資料だけを根拠に日本語で簡潔に答えてください。"
             "資料に答えが無ければ『資料に記載がありません』と答えてください。\n\n" + context},
            {"role": "user", "content": question}]})
    return out["message"]["content"].strip()


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--docs", default="docs")
    ap.add_argument("--golden", default="golden.csv")
    ap.add_argument("--chunk", type=int, default=400, help="1チャンクの文字数")
    ap.add_argument("--overlap", type=int, default=50)
    ap.add_argument("--mode", choices=["vector", "keyword", "both"], default="both")
    ap.add_argument("--embed-model", default="bge-m3")
    ap.add_argument("--answer", action="store_true", help="上位3件で回答も作る(ベクトル検索側)")
    ap.add_argument("--chat-model", default="gemma3:4b")
    args = ap.parse_args()

    chunks = load_chunks(args.docs, args.chunk, args.overlap)
    with open(args.golden, encoding="utf-8-sig", newline="") as f:
        golden = list(csv.DictReader(f))
    questions = [g["question"] for g in golden]
    print(f"チャンク数: {len(chunks)} / 問題数: {len(golden)} / チャンク長: {args.chunk}字")

    modes = ["vector", "keyword"] if args.mode == "both" else [args.mode]
    for mode in modes:
        if mode == "vector":
            all_hits = vector_search(chunks, questions, max(KS), args.embed_model)
        else:
            all_hits = keyword_search(chunks, questions, max(KS))

        rows, ranks = [], []
        for g, hits in zip(golden, all_hits):
            answerable = bool(g["source"].strip())
            rank = hit_rank(hits, g["source"].strip(), g["evidence"].strip()) if answerable else None
            if answerable:
                ranks.append(rank)
            row = {"qid": g["qid"], "question": g["question"], "source": g["source"],
                   "answerable": "yes" if answerable else "no(資料に無い)",
                   "hit_rank": rank if rank else "-",
                   "top1": hits[0]["id"] if hits else "",
                   "top1_text": hits[0]["text"][:80] if hits else ""}
            if args.answer and mode == "vector":
                row["answer"] = answer(g["question"], hits, args.chat_model)
            row["分類(手で記入)"] = ""
            rows.append(row)

        n = len(ranks)
        if n == 0:
            print("source 列が全部空欄です。golden.csv を確認してください")
            return
        print(f"\n[{mode}] 資料に答えがある問題 {n} 問で集計")
        for k in KS:
            x = sum(1 for r in ranks if r is not None and r <= k)
            lo, hi = wilson(x, n)
            print(f"  hit@{k}: {x}/{n} = {x / n:.0%}  (95%区間 {lo:.0%}〜{hi:.0%})")
        out = f"results_{mode}_c{args.chunk}.csv"
        with open(out, "w", encoding="utf-8-sig", newline="") as f:
            w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
            w.writeheader()
            w.writerows(rows)
        print(f"  → {out} に保存")


if __name__ == "__main__":
    main()

4. 回答も作って、外れを分類する(約40分)

上位3チャンクを使って、ローカルの gemma3:4b に回答させます(資料は外部に送られません)。

.\.venv\Scripts\python.exe rag_eval.py --docs docs --golden golden.csv --chunk 300 --mode vector --answer

results_vector_c300.csv をExcelで開くと、問題ごとに hit_rank(正解チャンクの順位。- は5位以内に無し)、1位のチャンク、answer(AIの回答)が並びます。空いている「分類(手で記入)」列に、次のどれかを記入します。

分類判定のしかた直す場所の例
⓪正解資料に答えがあり、answer が正しい(資料に無い問題で「記載がありません」と答えられた場合も⓪)不要
①資料に無いsource が空欄なのに、AIがもっともらしい答えを作った資料の追加、「分からない」と答えさせる指示
②検索で外したhit_rank が -、または4〜5位(AIに渡る上位3件に入っていない)チャンク長、キーワード検索との併用、資料の見出し整備
③検索は当たったが回答が誤りhit_rank が1〜3位なのに answer が誤りプロンプト、モデル、渡す件数
④元資料が古い・矛盾1位のチャンクが旧版の資料、または資料同士が食い違う旧版の削除、版・日付のメタデータ、更新ルール

この4分類は、RAGの失敗点を7つに整理した論文(Barnett ほか 2024 "Seven Failure Points When Engineering a RAG System")の考え方を、顧客説明用に4つにまとめ直したものです。

5. 1枚にまとめる(約20分)

次の表を埋めます。これがそのまま顧客への説明資料の骨格になります。

条件hit@1hit@3hit@595%区間(hit@3)
ベクトル / 300字
ベクトル / 800字
キーワード / 300字
キーワード / 800字
分類件数代表例(問題番号)
⓪正解
①資料に無い
②検索で外した
③検索は当たったが回答が誤り
④元資料が古い・矛盾

観察ポイント

  • チャンク長: 300字と800字で hit@k はどう動いたか。短いと文脈が切れて外れ、長いと関係ない話が混ざって順位が下がる、のどちらが多かったか
  • ベクトル vs キーワード: 型番・コードを含む問題(Q03のような)はどちらが当てたか。言い換え・口語の問題はどちらが当てたか。両方の結果を合わせれば何問拾えるか(これが「ハイブリッド検索」の発想です)
  • k の効果: hit@1 と hit@5 の差が大きいなら「AIに渡す件数を増やす」で改善する余地がある。ただし渡す量が増えると回答側が読み落とすこともある
  • 区間の幅: 18問で hit@3 が 15/18 でも、95%区間は約61〜94%。「83%当たる」と言い切れない理由を数字で確認する(ただし、同じ問題で2条件を比べるときは、区間の重なりではなく「片方だけ当たった問題の数」で見ます)
  • 資料に無い質問: AIは「記載がありません」と言えたか、もっともらしい作り話をしたか

ふりかえりシート

  1. あなたの資料で一番多かった失敗は①〜④のどれでしたか。それを直すのは誰の仕事(資料の担当者/システム担当/AIの設定)ですか。
  2. チャンク長・検索方式の違いで、hit@3 は何問動きましたか。2つの結果CSVを並べ、「Aだけ当たった問題」と「Bだけ当たった問題」を数えてください。同じ問題で比べているので、差を見るときは片方だけ当たった問題の数が大事です(例: 片方だけ当たりが 4対0 なら差がありそう、2対1 なら偶然の範囲)。各条件の95%区間が重なるかどうかだけで判断しないこと。
  3. 顧客が「うちのRAGは9割当たるとベンダーに言われた」と言ったら、どんな質問を返しますか(何問で、どんな質問で、何を「当たり」と数えたか)。
  4. 20問を100問に増やすなら、どこから質問を集めますか(問い合わせ履歴・ヘルプデスクのチケット等)。その際の個人情報の扱いは。
  5. ④(元資料が古い)を防ぐ運用ルールを1つ書いてください。
  6. 結果を使って、顧客向けの説明を3文で書いてください。型: 「実際の質問○問で測ると、正しい資料を上位3件に拾えたのは○問(95%区間○〜○%)でした。外れの主な原因は(①〜④)で、直すのは(誰)です。次は(何問に増やす/何を変える)で再測定します。」

安全上の注意

  • 個人情報・機密情報を含む資料は使わない。練習には公開済み資料かダミーを使う。
  • このラボの構成(Ollama+Chroma+SQLite)は資料を外部に送りません。ただし、資料をClaudeなど外部のAIサービスに貼り付けて比較する場合は、その時点で外部送信になります。公開資料だけにしてください。
  • Chroma は既定で匿名の利用統計を送る設定になっているため、スクリプト内で anonymized_telemetry=False を指定しています。
  • Ollama は既定で自分のPC(localhost)だけで待ち受けます。設定(OLLAMA_HOST)を変えて外部に公開しないでください。
  • Ollama には、外部のサーバーで動く「クラウドモデル」もあります。Ollama の公式ドキュメントでは、クラウドモデルへの入力と回答は Ollama のサーバーで処理されると書かれています(学習には使わないとも記載)。名前の付き方は、公式ドキュメントの例では gemma4:cloud のように cloud で終わります(-cloud で終わる名前も見かけますが、今回確認した公式ページでは未確認)。このラボでは ollama list に出るローカルモデル(bge-m3, gemma3:4b)だけを使い、cloud が付く名前のモデルや ollama signin は使わないでください。クラウド機能を完全に止めたい場合は、公式 FAQ に、環境変数 OLLAMA_NO_CLOUD=1(または ~/.ollama/server.json に "disable_ollama_cloud": true)を設定して Ollama を再起動する方法があります(確認日 2026-10-19。版により変わる可能性があります)。
  • golden.csv や結果CSVに、実際の問い合わせ文をそのまま入れる場合は、氏名・連絡先などを伏せてから使う。

データの保存場所と後片付け

もの保存場所消し方
資料・golden.csv・結果CSV(資料の抜粋とAIの回答を含む)C:\lab\ragRemove-Item -Recurse -Force C:\lab\rag
ベクトル(Chroma)メモリ上のみ(EphemeralClient)。終了時に消える不要
キーワード索引(SQLite)メモリ上のみ(:memory:)不要
モデル%USERPROFILE%\.ollama\models(約4.6GB。OLLAMA_MODELS で場所を変えている場合はその場所)ollama rm bge-m3 / ollama rm gemma3:4b
Ollama本体設定 → アプリ → インストールされているアプリ(公式: 「Add or remove programs」)アンインストール。先にモデルを ollama rm で消しておく(OLLAMA_MODELS で場所を変えていると、アンインストールではモデルが消えません)

結果CSVは資料本文の一部を含みます。メール添付やクラウド共有をする前に中身を確認してください。

うまくいかないとき

  • urllib.error.URLError … Ollama が起動していません。スタートメニューから Ollama を起動するか、ollama list が動くか確認
  • model "bge-m3" not found … ollama pull bge-m3 をやり直す
  • 文字化け・UnicodeDecodeError … 資料・CSVを UTF-8 で保存し直す(Excelでは「CSV UTF-8(コンマ区切り)」)
  • キーワード検索がほとんど当たらない … trigram は3文字未満の語では一致しません。質問が短すぎないか確認
  • 回答生成が極端に遅い … --chat-model gemma3:1b で小さいモデルに替える(精度は下がります)

公式ドキュメント(確認日 2026-10-18)

このラボで確認できなかったこと

  • Ollama+bge-m3+gemma3:4b を使った通し実行(作成環境にOllamaが無いため)。スクリプトの検索・集計・CSV出力は、ダミーの埋め込みで動作を確認しました。

  • 通し実行で失敗したら、まず埋め込みAPIだけを単独で試してください(1024 と出れば Ollama 側は正常で、問題はスクリプトか資料側です)。

    .\.venv\Scripts\python.exe -c "import json,urllib.request as u; r=u.urlopen(u.Request('http://localhost:11434/api/embed', data=json.dumps({'model':'bge-m3','input':['テスト']}).encode(), headers={'Content-Type':'application/json'})); print(len(json.load(r)['embeddings'][0]))"
    
  • 必要メモリ量の正確な下限(Ollamaの公式ページに gemma3:4b の必要メモリの記載なし)。

ふりかえりのメモ(この端末にだけ保存)