公開できない RAG を作り切って分かったこと ― Titan + S3 Vectors から手元の ruri-v3 + OpenSearch へ、paperlake の総括

公開できない RAG を作り切って分かったこと ― Titan + S3 Vectors から手元の ruri-v3 + OpenSearch へ、paperlake の総括 AI

紙の資料をスキャンして OCR にかけ、Markdown にして検索できるようにし、Claude から MCP で引く。この一連を paperlake という名前で 1 週間ほど作ってきた。検索基盤は Amazon Bedrock Knowledge Base (Titan Text Embeddings v2 + S3 Vectors) から始め、途中で OpenSearch Serverless と比べ、最後は手元の GPU サーバーに置いた ruri-v3 + OpenSearch + 再ランク に落ち着いた。

この記事でプロジェクトを締める。結論を先に書くと、構造は実用になったが、リポジトリは公開しない。扱っているのがスキャン画像と OCR テキスト、つまり著作物だからだ。一方で、RAG の各段を自分の手で置き換えて測れたことは、学習としては大きかった。その両面を残しておく。

これまでの記事 (8 本) は末尾にまとめた。

何を作ったか

paperlake の全体像。紙の資料をスキャンして手元の GPU で OCR し、ページ印付きの Markdown にする。検索基盤は初版の Bedrock KB (Titan v2 + S3 Vectors)、比較用の OpenSearch Serverless、最終形の lakebed (ruri-v3 + OpenSearch + RRF + 再ランク) の 3 通りで、どれも同じ検索画面と MCP から Claude に渡る

  • OCR: YomiToku を 8GB の GPU で動かす。縦書きの資料を 432 ページ 7 分で読む。本文だけでなく柱・ノンブル・注も役割ごとに分かれて出てくるので、それを使って [p.12 | 印刷 34] のようなページ印付きの Markdown を作る
  • 検索基盤: 同じ Markdown を複数の基盤に入れ、検索画面で並べて比べられるようにした。基盤は起動時に自動で見つけて候補に出す
  • MCP: Claude Code から search_documents で当たりを取り、get_pages で前後のページを読み、資料名と印刷ページを添えて答える

資料は 23 冊、索引にすると約 9,600 チャンクになった。

検索基盤の移り変わりと、測った結果

最初に作ったのは物差しだった。資料ごとに「この質問ならこのページが正解」という組を 40 問用意し (Golden セット)、上位 k 件に正解が入るかを hit@k で測る。途中から、同じ 40 問を別の言い回しにした言い換え版も足した。本文の語をそのまま使った質問だけで測ると、キーワード検索が不当に強く見えるからだ。

基盤 方式 元の 40 問 hit@1 / hit@5 / MRR 言い換え版 hit@1 / hit@5 / MRR 1 問の所要時間
Titan v2 + S3 Vectors (Bedrock KB) 意味 0.400 / 0.825 / 0.570 0.300 / 0.600 / 0.410 299 ms
Titan v2 + OpenSearch Serverless (Bedrock KB) ハイブリッド 0.550 / 0.875 / 0.690 ― 321 ms
ruri-v3-310m + OpenSearch (手元) ハイブリッド 0.575 / 0.875 / 0.688 0.325 / 0.700 / 0.469 97 ms
ruri-v3-310m + OpenSearch (手元) ハイブリッド + 再ランク 0.725 / 0.925 / 0.798 0.425 / 0.725 / 0.554 400 ms

この表から読み取れたことは 3 つある。

1. 索引の置き場所では差が出ない。 S3 Vectors と OpenSearch で同じ Titan の埋め込みを使うと、意味検索の成績はほぼ同じだった。差が出たのはキーワード検索を混ぜたハイブリッドにしたときで、これは置き場所ではなく方式の差だ。

2. 効いたのは日本語に強い埋め込みと、再ランク。 手元に移して ruri-v3 (日本語特化の埋め込みモデル) に替え、さらにクロスエンコーダで上位 20 件を並べ直すと、hit@1 は 0.400 から 0.725 まで上がった。再ランクで 1 問 0.4 秒かかるが、人が読む用途なら気にならない。

3. 言い換えにはまだ弱い。 言い換え版では、キーワード検索だけだと hit@1 が 0.65 から 0.20 まで崩れた。取りこぼした正解を追うと、意味検索の 18〜60 位に沈んでいた。再ランクは候補に入っていないものを拾えないので、候補を増やしても誤りが増えるだけだった。伸びしろは埋め込み側にある。

ハイブリッドの混ぜ方は、Reciprocal Rank Fusion (RRF) にした。スコアの尺度が違う意味検索とキーワード検索を、順位だけで足し合わせる。実装はこれだけだ。

def rrf(*ranked: list[dict], k: int = 60, weights: tuple[float, ...] | None = None) -> list[dict]:
    """Reciprocal Rank Fusion。各リストは順位順の {"id", ...}。同じ id は寄与を足す。weights でリストごとの重み。"""
    score: dict[str, float] = {}
    first: dict[str, dict] = {}
    weights = weights or (1.0,) * len(ranked)
    for lst, w in zip(ranked, weights):
        for i, h in enumerate(lst, 1):
            score[h["id"]] = score.get(h["id"], 0.0) + w / (k + i)
            first.setdefault(h["id"], h)
    out = []
    for hid, s in sorted(score.items(), key=lambda kv: -kv[1]):
        h = dict(first[hid]); h["score"] = round(s, 4); out.append(h)
    return out

AWS から手元に移した理由

精度だけなら、OpenSearch Serverless のハイブリッドでもかなり戻った。それでも手元に移したのは費用と、資料を外に出さないためだ。

  • OpenSearch Serverless は固定費が重い。 最小構成でも 1 時間あたり約 0.5 ドルかかり、月に直すと数百ドルになる。個人の 23 冊には見合わないので、比較が終わった数時間後に撤去した
  • S3 Vectors 構成は安い。 9 月の請求で、埋め込みと検索にあたる Bedrock の利用料は 0.59 ドル、S3 は 0.38 ドルだった。同じアカウントのほかの用途も含んだ額なので、paperlake の分はこれ以下になる。初版として試すなら、今でも一番手軽な構成だと思う
  • 手元の構成は追加費用ゼロ。 OCR 用にすでにある GPU サーバーの上で、埋め込み・OpenSearch・再ランクがすべて動く。GPU の使用量は常駐で約 2 GB だ

もう一つの理由は、資料の本文をクラウドに置かずに済むことだ。スキャン画像と OCR テキストは私的使用の範囲で複製しているものなので、置き場所は少ないほうがいい。

詰まったのは検索ではなく、周りだった

検索の精度は表のとおり素直に上がった。手間取ったのはむしろ運用で、どれも「サーバーを再起動したら何かが消える」という形で現れた。

起動の順番に頼らない

サーバーを再起動すると、検索画面の候補から手元の基盤が消えていた。paperlake の Web アプリは起動した瞬間に一度だけ、手元の検索 API に候補を問い合わせていた。一方の API はモデルの読み込みに 45 秒かかり、その間はポートを開けていなかった。問い合わせは「接続拒否」で失敗し、候補から外したまま二度と探しに行かなかった。

systemd や Docker Compose で起動順を制御する手もある。ただ、それで守れるのは全部が同時に立ち上がる最初の 1 回だけだ。デプロイで API だけを再起動した場合や、落ちて自動復帰した場合には効かない。そこで、呼ぶ側が「届かなかった相手を覚えておき、後で探し直す」ようにした。

    def refresh_http(self, interval: float = 60.0) -> list[Backend]:
        """届かなかった HTTP の検索基盤を、interval 秒に 1 回だけ探し直す (画面の状態取得のたびに呼ばれる)。"""
        pending = getattr(self, "_http_pending", [])
        if not pending or time.monotonic() - getattr(self, "_http_last_try", 0.0) < interval:
            return []
        return self.discover_http(",".join(pending), opener=getattr(self, "_http_opener", None))

    def status(self) -> dict:
        self.refresh_http()
        return {"configured": self.configured, "backends": [b.to_dict() for b in self.backends]}

呼ばれる側の API も、モデルを別スレッドで読み込み、ポートはすぐ開けるようにした。この 2 つを入れたあと、電源を落として Wake-on-LAN で起こす試験を 2 回した。どちらも起動から約 1 分半で、手を触れずに候補が戻った。

修正が生んだ不具合: GPU にモデルが二重に載る

モデルの読み込みを裏のスレッドに回したら、別の問題が出た。読み込みの途中に検索が届くと、1 プロセスで GPU を 5 GB 使い、再ランクの途中でメモリ不足になった。普段は 2 GB だ。

原因は functools.lru_cache だった。初回の呼び出しが終わる前に同じ引数で呼ばれると、両方が関数本体を実行する。素の lru_cache に 5 スレッドを同時に当てると、読み込みは 5 回走った。ロックで囲んで 1 回に限った。

def cached_loader(maxsize: int) -> Callable[[Callable[..., T]], Callable[..., T]]:
    """lru_cache と同じく引数ごとに結果を覚える。読み込み中の同時呼び出しは、終わるまで待って同じ結果を受け取る。
    cache_clear() も使える (watch が取り込みのたびに GPU を空けるのに使う)。"""

    def deco(fn: Callable[..., T]) -> Callable[..., T]:
        cached = functools.lru_cache(maxsize=maxsize)(fn)
        lock = threading.Lock()

        @functools.wraps(fn)
        def wrapper(*args, **kwargs) -> T:
            with lock:
                return cached(*args, **kwargs)

        wrapper.cache_clear = cached.cache_clear  # type: ignore[attr-defined]
        wrapper.cache_info = cached.cache_info  # type: ignore[attr-defined]
        return wrapper

    return deco

ある朝、全部が起動しない

別の日の再起動では、手元の検索 API も取り込みの監視も MCP も、全部が起動しなかった。ログには uv の次のエラーが 5 秒おきに 500 回以上並んでいた。

error: Failed to initialize cache at `/home/<user>/.cache/uv`
  cause: failed to create directory `/home/<user>/.cache/uv`: File exists (os error 17)

「作れない」のに「すでにある」。辿ると、~/.cache/uv はデータ用ディスクへのシンボリックリンクで、リンク先のディレクトリが前日に消えていた。リンクだけが残っていたので、uv がディレクトリを作ろうとすると「すでにある」で失敗する。動いていたプロセスは起動時にしかキャッシュを見ないので、再起動するまで誰も気付かなかった。

根本の問題は、サービスが個人のログインアカウントのホームに住んでいたことだった。ホームを整理すると、サービスが壊れる。そこで、ログインできないサービス専用のユーザーを作り、コードを /srv に移した。起動も uv を介さず、仮想環境の Python を直接呼ぶようにした。

# uv を介さず仮想環境の python を直接起動する (起動時に uv のキャッシュにもネットワークにも依存しない)。
ExecStart=/srv/lakebed/.venv/bin/python -m uvicorn lakebed.app:app --host ${LAKEBED_HOST} --port ${LAKEBED_PORT}

同じ流れで、AWS の認証情報も個人のホームから /etc の下に移した。最後に、データを毎日 NAS に取るバックアップも入れた。rsync の --link-dest で、変わっていないファイルは前日分とハードリンクで共有する。初回は 5.7 GB を 1 分 45 秒、2 回目以降の増分は十数秒で終わる。

公開できないものの線引き

paperlake のコード自体は、どこにでもある形をしている。OCR を呼び、Markdown を作り、埋め込んで、索引に入れて、検索する。それでもリポジトリを公開しないのは、著作物に触れる場所が思ったより多いからだ。

  • テストのデータ: 実際の資料で作りたくなる。代わりに、自作の短文で合成したページ画像を使った
  • 評価セット: 質問と正解ページの組には、どうしても本文の言い回しが入る。これはリポジトリに入れず、手元にだけ置いた
  • ログや PR 本文: 検索結果のチャンクを貼ると、それだけで本文の書き写しになる。載せるのは、誤り箇所の数文字までにした
  • 履歴: 一度でも入れると、消しても履歴に残る。実際に、スキャン画像の zip (117 MB) を git add -A で拾いかけた。GitHub の 100 MB 制限で push が拒否されて気付いた

この事故のあと、push の前に必ず走る検査を入れた。禁止するパスと拡張子、大きいファイル、全履歴、それに日本語の長文の混入を見る。

FORBIDDEN_PATH='^(Book/|bench/(pages|out|ref)/|pipeline/data/|data/|.*/(inbox|books)/.*)'
FORBIDDEN_EXT='\.(zip|pdf|jpg|jpeg|png|gif|tif|tiff|webp|bmp|epub|mobi|azw3?|cbz|cbr|rar|7z|tar|gz)$'
MAX_BYTES=2000000
# 自作ファイルの最大は約 3,000 (pipeline/README.md、ocr_worker.py のコメント、manual.html)。OCR 結果は 1 ページで最大約 1,000、
# 1 冊で 79,000〜400,000 なので、8,000 (自作の 2.6 倍、OCR の 8 ページ分以上) を超えたら書き写しとみなす (2026-09-22 実測)
MAX_JA_CHARS=8000

日本語の文字数のしきい値は、実測で決めた。自作のファイルは最大でも 3,000 字ほどで、OCR の結果は 1 冊で 8 万〜40 万字ある。その間の 8,000 字で線を引いた。

公開できるのは、こうした記事と、コードの抜粋と、測った数字だ。この記事にも、資料の名前や本文は一切入れていない。

RAG の学習として得たもの

作る前は、RAG は「ベクトル DB に入れて近いものを取ってくる」ものだと思っていた。作ってみると、段ごとに別の問題があった。

  • 前段 (OCR) がすべてを決める。 柱・ノンブル・注を本文と分け、ページ印を付けておかないと、どんなに検索が良くても「何ページに書いてあるか」を答えられない
  • 物差しが無いと何も言えない。 Golden セットと hit@k を先に作ったので、基盤を替えるたびに同じ 40 問で比べられた。言い換え版を足すまで、キーワード検索を過大評価していた
  • 置き場所より、埋め込みと並べ直し。 マネージドのベクトル DB を替えても精度は変わらない。変わるのは、日本語に合った埋め込みと、最後の再ランクだった
  • 運用は RAG の外にある。 再起動・起動順・ユーザー・認証情報・バックアップ。検索の精度とは関係なく、ここが崩れると全部が使えなくなる

AWS の Bedrock Knowledge Base は、埋め込み・索引・検索を自分で組まずに試せる入口として優秀だった。そこで物差しを作ってから手元に移したので、何が効いて何が効かないかを、実際の数字で確かめながら進められた。

シリーズの記事

  1. 縦書き資料をRAGにする前段を作る ― 8GB GPUのYomiTokuで432ページ7分、paperlakeができるまで
  2. 図は VLM に読ませるべきか ― ローカル VLM で図を Mermaid 化する試験と、文字だけで図が再構成できた話
  3. 機密資料のナレッジベースを AWS の閉域だけで回す ― OCR ローカル案と完全 AWS 案
  4. 紙のページはどうやって文字になるのか ― YomiToku に画像を渡してから Markdown が出るまで
  5. Claude Code から資料を引く仕組み ― paperlake の MCP サーバーは 40 行でできている
  6. 検索は当たっているのか ― RAG に「物差し」を作る: Golden セットと hit@k
  7. S3 Vectors か OpenSearch か ― 同じ 40 問で測ったら、索引では差が出ずハイブリッドで 6 問上がった
  8. 検索基盤を手元に置いたら AWS より当たった ― ローカル埋め込み + OpenSearch + 再ランクで RAG を組み直す
  9. 公開できない RAG を作り切って分かったこと (この記事)

コメント

タイトルとURLをコピーしました