この記事でできること
前回組んだ RAG アプリに、ブラウザ画面からのソース追加(Markdown と PDF)と、1本ずつの削除機能を足せるようになります。
前回(v0.1)は、レンタルGPUサーバーの上に「日本語で質問すると出典つきで答えが返るアプリ」——NotebookLM のミニチュア版——を部品5つ(画面の HTML/API サーバの FastAPI/ベクトル化役の bge-m3/ベクトルDB の Chroma/回答役の qwen3:8b)で組みました。ただしソースを入れる手段は、ターミナルからのスクリプト実行だけ。読み込めるのも Markdown だけでした。今回はロードマップに予告したとおり、ここを画面のボタンに置き換え、PDF も読めるようにします。前回より短めの、続編の記事です。
- 対象読者:前回の記事を読んだ方/RAG アプリの「ソースの追加・削除」をどう作るか知りたい方
- 今回増えた部品:PDF の文字取り出しライブラリ(pypdf)の1つだけ。構成(FastAPI・Chroma・Ollama)は前回のまま
- コスト:実機確認まるごとで GPU 代 約0.52ドル
v0.2 で足したもの
画面の左パネルに「+資料を入れる」ボタンと、ソースごとの「消す」ボタンが付きました。中身はこの4つです。

| 追加・変更 | 中身 |
|---|---|
| ソースの追加 | ファイル選択(.md / .pdf・複数可)。サーバが受けて、分割 → ベクトル化 → Chroma 保存まで自動 |
| ソースの削除 | 各行の「消す」。押すと行の中に「本当に消す/やめる」の2ボタンが出る |
| PDF 読み込み | pypdf で文字を取り出し、あとは Markdown と同じ流れに合流 |
| 原本を保存しない | v0.1 はソースの原本もサーバに置いていた。v0.2 は検索用の切れはしだけを持ち、原本は手元の Mac にだけ残す |
サーバ側の API は2本増えただけです。まず追加のほう。中身を要約するとこれだけで、新しく書いたのは「PDF なら文字を取り出す」の分岐だけです。分割 → ベクトル化 → Chroma 保存は、v0.1 でスクリプト投入用に作った ingest_document() にファイル名ごと渡して使い回します(このファイル名が、切れはしの source メタデータになります)。
@app.post("/api/upload")
async def upload(file: UploadFile):
data = await file.read()
if file.filename.endswith(".pdf"):
text = rag.extract_pdf_text(data) # pypdf での文字取り出し(関数1つに分離)
else:
text = data.decode("utf-8")
n = rag.ingest_document(file.filename, text, settings) # 分割→ベクトル化→Chroma保存(v0.1 と同じ)
return {"chunks": n}
(実物にはこのほかに拡張子チェック・サイズ上限・エラー時のメッセージ分岐が入ります)
次に削除。こちらの中身は、実は1行です。v0.1 で切れはし1つ1つに「どのソースから来たか」をメタデータとして持たせてあったのが、ここで効きました。
col.delete(where={"source": filename})
画面側の「本当に消す/やめる」は、行の中にボタンを2つ差し込むだけの素朴な作りです。
del.addEventListener("click", () => {
del.style.display = "none"; // 「消す」を隠して
const yes = button("本当に消す"), no = button("やめる"); // 2ボタンを同じ行に出す
no.addEventListener("click", () => { yes.remove(); no.remove(); del.style.display = ""; });
yes.addEventListener("click", async () => {
await fetch("/api/sources/" + encodeURIComponent(name), { method: "DELETE" });
loadSources();
});
});
1つだけ引っかかる箇所があります。ソース名にスラッシュが入る(フォルダ名つきで登録した)場合、/api/sources/趣味/メモ.md の「/」が URL の区切りと衝突して 404 になります。直すのは2箇所です。画面側の encodeURIComponent が「/」を「%2F」に変えるので、名前の中身が区切りと混ざらずに届きます。サーバ側はパスパラメータを {source_name:path} にします——FastAPI の :path は「スラッシュが入っていても、そこから先を丸ごと1つの値として受け取る」型です。
つまずいた話(3つ)
1. 画面をファイルとして開くと「サーバに繋がらない」
動作確認のとき、手元の index.html をダブルクリックで開いてしまい、「サーバに繋がりませんでした」の表示に15分悩みました。サーバは正常。原因は開き方です。画面の JavaScript は fetch("/api/ask") のように相対パスで API を呼ぶので、file:// で開くと飛び先がありません。この画面は必ずサーバの URL(RunPod なら proxy URL)から開く必要があります。分かってしまえば当たり前ですが、症状が「サーバ側の故障」にしか見えないのが厄介でした。
2. 「本当に消しますか?」に confirm() を使うのをやめた
削除の確認は、最初はブラウザ標準の confirm() で済ませるつもりでした。やめた理由は、あのダイアログが画面全体を止めるからです。人が押す分には困りませんが、ブラウザの自動操作で動作確認をするとき、ダイアログが出た瞬間に操作が返せなくなります。確認は行の中に「本当に消す/やめる」の2ボタンを出す方式にしました。結果として、本家 NotebookLM の削除の見た目にも近づきました。
3. PDF ライブラリはライセンスで選んだ
文字の取り出し精度なら PyMuPDF が有名です。でも選んだのは pypdf でした。PyMuPDF のライセンスは AGPL——組み込んで公開するコード全体に、同じライセンスの適用を求める——で、コードを GitHub で公開する予定と合わないためです。pypdf は BSD ライセンスなので公開の妨げになりません。精度が足りなかったときのために、文字取り出しは関数1つにまとめてあり、日本語 PDF で取りこぼしが出たら pdfminer.six に1箇所だけ差し替えれば済む作りにしました。なお、画像だけの PDF(スキャンした紙)は文字を取り出せないので v0.2 では読めません。
コストと結果
- pytest は36件すべて通過(v0.1 の20件に、v0.2 で16件追加)
- 実機確認は RTX 4090(0.34ドル/h)で約1.5時間、GPU 代 約0.52ドル
- 決めておいた合格条件5つ——画面から追加できる・1本ずつ消せる・PDF を読める・v0.1 の機能が生きている・次バージョンの機能を先取りしていない——を実機で全部確認しました
次にやること
- v0.3:メモ機能(画面右に3枚目のパネル)/利用者ごとにソースを分ける
- その先の候補:ソースの全削除ボタン/Web ページの取り込み/音声概要
v0.1 の記事で「ソースは各自が自分で登録する形にするのが目標」と書きました。画面から入れられるようになった今回は、その最初の一歩です。ここから先は1バージョンずつ、NotebookLM に近づけていきます。