この記事でできること
レンタルGPUサーバーに、日本語で質問すると出典つきで答えが返るアプリ——いわば NotebookLM のミニチュア版——を、部品5つで自分の手で組めるようになります。
Google の NotebookLM は便利です。ソースを登録して質問すると、ソースにもとづいた答えが出典つきで返ってきます。ただ、私には登録できないソースがありました。個人情報の入った自分のメモ類です。外部の AI サービスに送りたくないソースは、自分の管理下で動く AI に答えさせる必要があります。
そこで、同じ動きをするアプリを自分で組むことにしました。あわせて、言葉だけ知っていた「RAG」の中身——ソースの分割・ベクトル化・検索・回答生成——を、部品を1つずつ手で並べて確かめます。この記事はその記録です。
- 対象読者:ChatGPT や NotebookLM は使っていて、RAG の中身をこれから理解したい方/LLM の組み込みに初めて挑戦する方。Python が読めれば大丈夫です
- 扱えるソース:Markdown ファイルのみ(PDF・Word は今回のバージョンでは非対応。最後の「今後の拡張」で触れます)
- コスト:検証まるごとで GPU 代 約0.45ドル+保管に月1.4ドル(後述)
- コード一式:GitHub で公開しています → mini-notebooklm-v0.1
完成イメージ
ブラウザにこんな画面が出ます。左に読み込んだソースの一覧、中央にチャット欄。

ソースに書いてあることを聞くと、答えと一緒にどのソースを見たか(出典)が出ます。逆に「富士山の標高は何メートル?」のようにソースに無いことを聞くと、知ったかぶりをせず「資料にありません。」と返します。ここが RAG の良いところで、LLM の記憶ではなく渡したソースだけを根拠に答えさせます。
RAG は4つの手順でできている
RAG(Retrieval-Augmented Generation)と聞くと身構えますが、やっていることは4つだけです。
| 手順 | 何をするか | 担当する部品 |
|---|---|---|
| ①分割 | ソースを500文字ずつに切る(隣と100文字重ねる) | 自作の Python |
| ②数値化 | 切れはしをベクトル(意味を表す数値の並び)に変える | bge-m3 |
| ③検索 | 質問文も同じく数値化し、数値が近い切れはしを5つ探す | Chroma |
| ④生成 | その5つだけを渡して、答えを作らせる | qwen3:8b |
部品の名前を補足すると、bge-m3 は埋め込みモデル(文をベクトルに変えるモデル)、Chroma はベクトルを保管・検索する DB(ベクトルDB)、qwen3:8b は回答役の LLM です。
肝は手順2〜3です。ソースも質問も同じモデルで数値化するから、数値の近さ=意味の近さとして探せる。ソースに「感想」と書いてあって質問が「レビュー」でも、意味が近ければ数値も近くなるので拾えます。キーワード検索との一番の違いはここです。
そして手順4の「5つだけ」という数が、あとで説明する弱点の原因になります。
部品は5つ。ぜんぶ Pod の中で動く
質問が通る順に並べます。
| 役割 | 選んだもの | 選んだ理由 |
|---|---|---|
| 画面 | HTML | React などは入れない。複雑にしない |
| 受付(API サーバ) | FastAPI | 軽い。エンドポイント3本で足りる |
| ベクトル化役 | bge-m3 | 日本語対応。qwen3 と同じ Ollama で動かせる |
| ベクトルDB | Chroma | Python 数行で使えて中身を覗ける=勉強向き |
| 回答役の LLM | qwen3:8b | 日本語が得意で、モデル約6GB と手頃 |
Ollama を選んだ理由は1つで、LLM と埋め込みモデルの2役を1本でまとめて動かせるからです。部品が1つ減ると、壊れる場所も1つ減ります。
構成はこうなります。回答生成もベクトル化も全部この Pod の中で完結するので、外部の AI サービスにはソースを一切送りません。
環境:RunPod とネットワークボリューム
自分のPCに NVIDIA の GPU が無いので、レンタルGPUクラウド RunPod を使いました。GPU 付きの仮想マシンを「Pod」と呼び、使った時間だけ課金されます。GPU があるなら自分のPCでも手順はほぼ同じです(Ollama・Chroma・FastAPI を入れるだけ)。VRAM はモデルファイル合計が約7GB なので、12GB クラスあれば同じ構成が動く見込みです(私が確認したのは 24GB の RTX 4090 のみ)。
最初にやることは2つ。
- ネットワークボリュームを作る(20GB・月1.4ドル)——ソース・モデル・ベクトルDB をここに置く。Pod 本体は消してもここは残る
-
Pod を立てる——ボリュームを
/workspaceにマウントして起動する
コストの実績も書いておきます。RTX 4090 が 0.34ドル/時間で、環境構築から動作確認・ひととおりテストが終わるまで Pod 稼働は合計1時間20分ほど、GPU 代は約0.45ドル(70円くらい)でした。継続コストは保管の月1.4ドルだけ。Pod は使い終わったら必ず消します(消してもボリュームに全部残るので、次回は立て直して起動スクリプトを打つだけ)。消し忘れると 0.34ドル/h の課金が続くので、終了の確認だけは毎回行います。
組み立て:ファイルは6本
10_実装/
├── setup.sh # Pod 起動後の準備(Ollama 起動〜サーバ起動まで5段階)
├── config/settings.json # 設定値のまとめ(下に全文)
├── rag.py # RAG の本体(分割・数値化・保存・検索・生成)
├── app.py # FastAPI(API 3本)
├── upload_docs.py # PCのソースを Pod へ送るスクリプト
└── web/index.html # 画面(fetch するだけの素の HTML)
設定値はコードに埋めず、settings.json に集めました。全文でこれだけです。
{
"llm": {
"model": "qwen3:8b",
"temperature": 0.2,
"system_prompt": "あなたは資料アシスタントです。渡した資料だけを根拠に日本語で答えてください。資料に無いことを聞かれたら「資料にありません」と答えてください。答えの根拠になった資料の内容は正確に引用してください。"
},
"embedding": { "model": "bge-m3" },
"chunk": { "size": 500, "overlap": 100 },
"search": { "top_k": 5 },
"server": { "port": 8000 },
"ollama": { "url": "http://127.0.0.1:11434" },
"paths": { "docs": "/workspace/docs", "chroma": "/workspace/chroma" }
}
rag.py の中心はこの2つです(抜粋。全体は GitHub にあります)。
読み込み側——ソースを切って、数値化して、Chroma に入れる:
def ingest(src_dir: str) -> dict:
for md in sorted(Path(src_dir).glob("*.md")):
text = md.read_text(encoding="utf-8")
chunks = split_text(text, size=500, overlap=100) # 1. 分割
embs = embed(chunks) # 2. 数値化(bge-m3)
collection.add( # 3. 保存(Chroma)
ids=[f"{md.name}:{i}" for i in range(len(chunks))],
documents=chunks,
embeddings=embs,
metadatas=[{"source": md.name}] * len(chunks),
)
質問側——質問を数値化して、近い5つを探して、LLM に渡す:
def ask(question: str) -> dict:
q_emb = embed([question])[0]
hits = collection.query(query_embeddings=[q_emb], n_results=5) # 検索
context = "\n---\n".join(hits["documents"][0])
answer = ollama_chat( # 生成
system=SETTINGS["llm"]["system_prompt"],
user=f"資料:\n{context}\n\n質問: {question}",
)
sources = sorted({m["source"] for m in hits["metadatas"][0]})
return {"answer": answer, "sources": sources}
app.py の API は3本だけ。画面(index.html)が使うのは GET /api/sources(ソース一覧)と POST /api/ask(質問)の2本で、POST /api/ingest(読み込み)は コマンドラインから実行する upload_docs.py 用です。index.html は入力欄と fetch が書いてあるだけの1枚です。
起動とソース投入
Pod に入ったら setup.sh を1回打ちます。段階ごとにログを残す作りにしました。失敗したとき「どこまで進んで、どこで止まったか」が一目で分かるためです。
[1/5] 01:43:12 Ollama を起動する
[2/5] 01:43:38 GPU を確認する
[3/5] 01:43:39 モデルを確認する(無ければダウンロードする)
[4/5] 01:45:18 保存先と Python パッケージを確認する
[5/5] 01:45:41 サーバを起動する
モデル取得(qwen3:8b 約6GB+bge-m3)は約100秒で終わりました。RunPod の回線は速い。
ソースはPCから送ります。
python3 upload_docs.py --url https://<PodのID>-8000.proxy.runpod.net
私は自分のメモ 61本を読み込ませました(1本が数十の切れはしになります)。ブラウザで開くと左に61本が並び、質問すると出典つきで答えが返る。「富士山の標高は?」には「資料にありません。」。ここまで来れば完成です。
1つ正直に書いておきます。この画面と API に認証は付けていません。URL に含まれる Pod の ID はランダムな文字列で推測は現実的に難しく、Pod 自体も使うときだけ立てて終わったら消す前提だからです。もし常時起動で運用するなら、公開する前に認証(Basic 認証やトークン)を足してください。個人情報を含むソースを載せるアプリなので、ここは省略しない方がいい場所です。
なお ollama ps を見ると、qwen3:8b も bge-m3 も PROCESSOR 列が 100% GPU でした。CPU に逃げていると桁違いに遅くなるので、ここは確認する価値があります。
実機でつまずいた6箇所
机上では問題なしでした。実機では6回つまずきました。同じ手順をなぞる人のために全部書きます。
1. ネットワークボリューム対応のデータセンターに、狙った GPU が無い
当初は RTX 3090(0.22ドル/h)の予定が、ボリュームを作れるデータセンターを21箇所調べても 3090 はゼロ。RTX 4090(0.34ドル/h)に変更しました。ボリュームと GPU の在庫は場所単位で紐づくので、先にボリュームの置き場所から決めるのが正解です。
2. API から Pod を立てると SSH に入れない
Pod は起動したのに Connection refused。RunPod 公式イメージの sshd は環境変数 PUBLIC_KEY があるときだけ起動します。Web 画面からのデプロイなら鍵が自動で入りますが、API 経由では自分で env に渡さないと sshd 自体が立ちません。1台目はこれで作り直しました。
3. rsync がイメージに入っていない/ボリュームが chown を拒む
コード転送の rsync が protocol error。イメージに rsync が無いのでまず apt-get install rsync。次に chown の警告が大量に出ました。ネットワークボリュームは所有者変更を許さないので、-a をやめて -rlt(所有者・グループを送らない)にすると静かに通ります。
4. ssh 越しの pkill が自分の接続を切る
古いプロセス掃除に ssh <Pod> "pkill -f 'setup.sh|uvicorn'" と打った瞬間、exit 255 で切断。ssh が実行するコマンドライン自身がパターンに一致して、pkill が自分自身を終了させてしまいます。pkill -f "setup[.]sh" のように角かっこで自己一致を外すのが定石です。
5. Ollama のインストーラが zstd を要求する
setup.sh の1段目で「This version requires zstd for extraction」。イメージに zstd が無いので入れてから再実行。
6. まっさらな Pod では、その zstd のインストールも失敗する
Pod を立て直して再現確認をしたら、今度は zstd の install 自体がこける。素の runpod/pytorch イメージはパッケージ一覧が空で、apt-get update 無しには何も入りません。結局 setup.sh の冒頭に恒久対策を1行足しました。
(command -v zstd >/dev/null && command -v rsync >/dev/null) || (apt-get update -qq && apt-get install -y -qq zstd rsync)
小ネタをもう1つ。qwen3 は「考え中の独り言」(think)を出力に混ぜてくる癖があるので、API 呼び出しで think を無効にし、万一混ざっても取り除くよう先回りしてあります。
組んだ直後の RAG は、意外と弱い
動いた直後にいろいろ質問して、すぐ気づきました。素の RAG には最初から弱点があります。
| 弱点 | 実際に起きること |
|---|---|
| ソースをまたぐ集計ができない | 「今まで何冊読んだ?」——数えるには全部の切れはしが要るのに、渡すのは5つだけ |
| 言い回しが違うと拾い損ねることがある | 意味検索にも限度はある |
| 切れ目で文脈が切れる | 1つの話が2つの切れはしに割れると、片方だけ渡って中途半端になる |
| 時系列が分からない | 「最近の◯◯は?」——切れはしに日付が無ければ「最近」を判断できない |
原因は作りそのものです。手順4で書いたとおり、LLM に渡るのは検索で選ばれた5つの切れはしだけ。61本のソースを全部読んでいるわけではないので、全体を数える・並べる質問には構造的に答えられません。これは触ってみて初めて本当に理解できました。
本家 NotebookLM との比較も正直に載せます。
| 本家 | 今回のミニチュア版 | |
|---|---|---|
| 出典つき回答 | できる | できる |
| ソースの預け先 | Google のサーバ | Pod 内で完結・外部へ送らない |
| PDF・音声・動画 | できる | Markdown のみ |
| メモ・音声概要 | できる | 無い |
| 中身の理解 | 見えない | 全部自分のコード |
機能では勝負になりません。勝っているのは「ソースが外に出ない」と「中身を全部説明できる」の2点で、今回の目的にはそれで十分でした。
もう1つ、触った日に見つけて直した話を。「資料にありません。」と答えたときにも、出典欄にソース名が並んでいました。検索は「意味が近い順」に必ず何かを拾うので、答えに使っていないソース名がそのまま見えていたのです。嘘ではないけれど紛らわしい。その日のうちに「ソースに無いときは出典を出さない」に直しました。答えの正直さは、出典表示の正直さとセットです。
settings.json の4つの値で育てる
このミニチュア版は、設定値を回して育てられるように作ってあります。
| 設定値 | 回すと何が変わるか |
|---|---|
| system_prompt(指示文) | 答えの姿勢。一番効く。「箇条書きで」「日付を添えて」など |
| chunk.size / overlap(切れはしの大きさ・重なり) | 検索の当たりやすさ。外す質問が多ければ小さくして再読み込み |
| search.top_k(渡す件数) | 材料の量。「資料にありません」が多ければ増やし、的外れが混ざれば減らす |
| llm.model(モデル) | 日本語の自然さ・賢さ。8B→14B は品質と速度のトレード |
流儀は1つだけ守っています。一度に変えるのは1つ。2つ同時に変えると、どちらが効いたのか永遠に分からなくなります。同じ質問セットを固定して、変更の前後で答えを比べる——地味ですが、これが「育てる」の正体です。
今後の拡張ロードマップ
v0.1 は出発点で、ここから使いながら育てていきます。予定も公開してしまいます。
- v0.2:画面からのソースの追加・削除(いまはコマンド実行)/PDF 読み込み/実装中に見つけた細かい直し
- v0.3:メモ機能(画面右に3枚目のパネル)/利用者ごとにソースを分ける——最終的には他の人にも使ってもらい、ソースは各自が自分で登録する形にするのが目標です
- その先の候補:音声概要/LoRA でのモデル調整/推論サーバを Ollama から SGLang に差し替えて速度比較(どちらも OpenAI 互換 API なので差し替えて比べられます)
逆に言うと、現時点は一人で・使うときだけ Pod を立てる設計です。常時起動・複数人同時利用はまだ範囲外なので、社内導入の土台にする場合はそこから設計が要ります。
まとめ
- NotebookLM のミニチュア版は、部品5つ・ファイル6本で組めた
- コストは GPU 代約0.45ドル+保管月1.4ドル。Pod は使うときだけ立て、ソースはボリュームに残す
- RAG の弱点(集計できない・切れ目で切れる)は、部品から手で組むと構造ごと理解できる
- ソースは最初から最後まで、外部の AI サービスに一度も送っていない
「動かせる」と「分かる」は別物です。RAG はフレームワークを使えば半日で動きますが、あえて部品を並べて組んだことで、動かせるだけでなく人に説明できるところまで理解が進みました。次は v0.2 です。