2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【作ってみた】ローカルLLM × MCP連携で実現する、原文エビデンス付きドキュメントQA【Ruri v3-30m】

Last updated at Posted at 2025-09-01

TL;DR

このページを読むと、

  • ローカルなドキュメント(Markdown)群 を対象として、

  • 必ずエビデンスを付けて ハルシネーション限りなく0 にした、

  • ローカルLLM を使用した高速かつ軽量な RAG を、

  • 外部送信なしの セキュアな 環境で、

  • 開発の知識がない方でも 簡単に 実現する方法

が分かるようになります。


例えば、こんな事が可能になります。

画像

だいぶ作業が楽になりそうですね!

また、ローカルで完結しているので、Claude Codeが情シス的に使えない現場でも使えます。
(実際うちがそうです。)


0. これは何?

画像

画像

MCPで Filesystem / ripgrep / Chroma をクライアントに登録し、ローカル資料だけを根拠に答えるQA運用を整えました。

このページでは、その導入方法を解説します。

導入が簡単で、開発部以外の方でも扱えます。
セキュリティも万全です。

Chromaで候補取得→ripgrepで確証→原文引用を必須化してすることで、ハルシネーション最小化を徹底しています

Claude DesktopおよびVS CodeのCopilotなどで使えます。

1. (あえて最初に。)よくある質問

画像

Q: これはドキュメントの「MCPサーバー化」?

A: いいえ。
サーバーを新規開発せず、既製の MCP サーバーをクライアント(私達のPC本体)に登録して運用設計したのがポイントです。
つまり、完全にローカルで完結しています。


Q. セキュリティ面が不安です。

A. MCPというのはあくまで“接続の規格”で、今回はすべてローカル実行(stdio:ローカル子プロセス間通信) です。

  • Filesystem はアクセスできるフォルダを固定するため

  • ripgrep はローカルファイル検索するため

  • Chroma はローカル埋め込み検索するため

なので外部送信はありません(外部APIキーを入れない限り)。
VS Code/Copilot 側も管理者ポリシーとツール承認フローがあるので、組織運用に馴染みます。


Q. 社外に出ませんか?

A. 出ません。
HTTPは使っていません。
Chroma もローカル埋め込み(Sentence Transformers Ruri v3-30m)で、外部APIキーを入れない限りどこにもデータは送信されません。


Q. 読める範囲はどこまで?勝手にPC全体を読まない?

A. 許可したフォルダ配下のみです。
Filesystem サーバーは許可ディレクトリでサンドボックス化されます。
読み取り専用実装に替える運用も可能です。


Q. VS Code / Copilot の統制は?

A. ツールごとに有効化・承認が必要で、組織ポリシーで MCP 自体を有効化/無効化できます。


画像

Q. なぜエンベディングモデルにRuri v3-30mを選定したのですか?

A. Ruri v3-30m はModernBERT-Jaを土台にした日本語特化エンベディングで、JMTEB平均74.51・Retrieval 82.48を示し、日本語タスクで同クラス帯のモデルを明確に上回る実測を公開しています。
検索用途に直結する精度で、特に小型ながら強いモデルとなっており、クエリと文書で「検索クエリ:」「検索文書:」のプリフィクス使い分けが推奨され、再現性ある評価設定が提示されているためです。

また、ライセンスはApache 2.0として整理されており、商用利用を含む幅広い利用が可能です。

(なにより、中国アリババのQwenなどといった主流のローカルLLMのモデルと異なり、開発元が日本(名古屋大学 笹野研究室)である というのが情シスの信頼を置けそう、という裏の魂胆もあります。)

しかしそういった裏事情を抜きにしても、Ruri v3-30mは性能面から見て、 ノートPCスペックで動かせる「日本語ドキュメント検索用モデル」として現時点で最高のモデルだと判断しました。

2. システム構成

画像

画像

Ruri君、Chroma君、Claude君の3人が主役です。

2-1. 通信の全体像

画像

2-2. シーケンス図

画像

2-3. 処理の具体的な流れ

画像

画像

画像

画像

3. 導入解説

画像

画像

3-1.前提

  • ドキュメントのリポジトリのクローンをローカルに置いていること。

画像

プロジェクトのドキュメントをローカルで保存する手法について、前回の記事で書いているのでそちらを参考にしてください!

注)パスに空白や日本語が含まれていると機能しません。
そのため、OneDrive以外のディレクトリが推奨されます。

Claude Desktop からはこのフォルダにだけアクセスを許可するので安全です。

3-2. 必要ツールのインストール

画像

3-2-1. Node.js LTS

npx を使うため。**(もし既にPCにNode環境があればインストールは不要です。)
**
PowerShellを立ち上げて以下を実行します。

winget install OpenJS.NodeJS.LTS

インストール後、PowerShellを立ち上げ直します。
以下のコマンドでバージョンを確認。
バージョンが表示されればインストール完了です。

node -v
npm -v

3-2-2. ripgrep

高速全文検索のため。
PowerShellで以下を実行します。

winget install --id=BurntSushi.ripgrep.MSVC -e

インストール後、PowerShellを立ち上げ直します。
以下のコマンドでバージョンを確認します。

rg --version

バージョンが表示されればインストール完了です。

3-2-3. uv

Chroma MCP の推奨ランチャー uvxのため。
PowerShellで以下を実行する。

winget install --id=astral-sh.uv -e

インストール後、PowerShellを立ち上げ直します。
以下のコマンドでバージョンを確認。
バージョンが表示されればインストール完了です。

uv --version

3-2-4. Python

PowerShellで以下を実行します。

winget install --id=Python.Python.3.11 -e

インストール後、PowerShellを立ち上げ直します。
以下のコマンドでバージョンを確認。
バージョンが表示されればインストール完了です。

python --version
# または
py --version

3-2-5. PyTorch

https://pytorch.org/get-started/locally/

Get Started Set up PyTorch easily with local installation or supported cl pytorch.org

このサイトのオレンジの部分をポチポチして、読者の環境に適したpipを構成しましょう。
**「Compute Platform」で「CPU」を選ぶことに注意。
**出来上がった「Run this Command:」のpipをPowerShellに貼り付けて実行しましょう。

画像

注) PCにCUDAコアがある(NVIDIA製GPUがある)人へ
ここでCUDAを選んでしまうと、インデックス化したchroma dbを他者と共有する際にCUDAを持たない人と互換性が取れなくなります。
共有する予定がある人は無難に「CPU」にしておきましょう。

もし既にCUDAでPyTorchをインストールしてしまっている場合、PyTorchをアンインストールしなくて大丈夫。
後述のingest.pyを実行する際に、

# CUDAデバイスを隠す
$env:CUDA_VISIBLE_DEVICES = "-1"

とすれば良いです。

3-2-6. Ruri v3-30mモデル

今回使用するモデル Ruri v3-30mをダウンロードします。
以下をPowerShellで実行します。
(もしPythonコマンドが動かないなら py コマンドだと動くかも)

# 1) 必要なパッケージ
pip install -U huggingface_hub "transformers>=4.48.0" sentence-transformers

# 2) 置き場
$env:HF_HOME="C:\chroma-data\hf-home"

# 3) Pythonワンライナーで Ruri v3-30m を丸ごと保存
python -c "from huggingface_hub import snapshot_download; \
snapshot_download(repo_id='cl-nagoya/ruri-v3-30m', \
local_dir=r'C:/chroma-data/hf-home', local_dir_use_symlinks=False); \
print('downloaded')"

downloadedと表示されれば成功です。

3-2-7. pipで色々いれる(chromadb, sentencepiece, rich, pypdf, python-docx)

Chromaというのは、LLMアプリのための軽量な埋め込みデータベースのことです。
(※chromeじゃないよ)

テキストや画像などの埋め込みベクトルを保存し、コサイン類似度などで高速検索できるのが大きな特徴。
chroma dbを構築するための諸々を入れていきます。
richは進捗バーのためのおまけ。

pip install -U chromadb sentencepiece rich pypdf python-docx

Successfully installedと出ていれば成功です。

3-2-8. Claude Desktop

こちらのサイトからClaude Desktopをインストールします。

画像

Windows版を選択します。
インストール後、Claudeアカウントでのログインを行ってください。

長かったインストール編もこれで終わりです。お疲れ様でした。

3-3. ドキュメントをインデックス化

画像

※プロジェクトメンバーから既にC:/chroma-dataフォルダを貰っていれば、C:直下にそれを置くだけでかまいません。スキップしてください。

画像

こんなふうに。

この直下にC:\\chroma-data\\hf-homeのフォルダが有るようにします。

C:\\Users\\ユーザー名の場所に以下の内容のingest.pyを追加します。
途中のディレクトリ名やhogeindex(インデックス名)は、ご自身のディレクトリパスや名前に置き換えてください。

# ingest.py
import os, pathlib, hashlib, re, sys
import chromadb
from chromadb.utils import embedding_functions
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn, SpinnerColumn
from pypdf import PdfReader
import docx
import torch

# === 設定 ===(環境変数で上書き可)
ROOT = os.environ.get("WL_ROOT", r"ご自身のディレクトリパス")
PERSIST = os.environ.get("WL_CHROMA_DIR", r"C:/chroma-data")
COLL_NAME = os.environ.get("WL_COLL_NAME", "hogeindex")
EMBED_MODEL = os.environ.get("WL_EMBED_MODEL", "cl-nagoya/ruri-v3-30m")  
EXTS = {".md", ".txt", ".pdf", ".docx"}

CHUNK_SIZE = 1200
OVERLAP    = 150
BATCH_SIZE = 400

DOC_PREFIX   = "検索文書: "   #  Ruri v3 推奨の接頭辞(文書側)
QUERY_PREFIX = "検索クエリ: " #  クエリ側は Claude にこの接頭辞を付けさせる

def read_text(p: pathlib.Path) -> str:
    suf = p.suffix.lower()
    if suf in (".md", ".txt"):
        return p.read_text(encoding="utf-8", errors="ignore")
    if suf == ".pdf":
        out = []
        with open(p, "rb") as fh:
            reader = PdfReader(fh)
            for page in reader.pages:
                out.append(page.extract_text() or "")
        return "\n".join(out)
    if suf == ".docx":
        d = docx.Document(str(p))
        return "\n".join(para.text for para in d.paragraphs)
    return ""

def chunks(txt: str, size=CHUNK_SIZE, overlap=OVERLAP):
    if not txt.strip():
        return []
    units = re.split(r"(?<=\n)", txt)
    buf, out = "", []
    for u in units:
        if len(buf) + len(u) >= size and buf:
            out.append(buf)
            buf = buf[-overlap:]
        buf += u
    if buf.strip():
        out.append(buf)
    return out

def main():
    root = pathlib.Path(ROOT)
    if not root.exists():
        print(f"[ERR] not found: {root}", file=sys.stderr); sys.exit(2)

    # ファイル一覧を先に集計
    file_list = []
    for base, _, files in os.walk(root):
        for f in files:
            p = pathlib.Path(base) / f
            if p.suffix.lower() in EXTS:
                file_list.append(p)
    total_files = len(file_list)

    # Ruri v3 を GPU 優先でロード(なければ CPU)
    device = "cuda" if torch.cuda.is_available() else "cpu"

    ef = embedding_functions.SentenceTransformerEmbeddingFunction(
        model_name=EMBED_MODEL,
        device=device,
        normalize_embeddings=True,  # 余弦類似度前提
    )

    # 永続クライアント&コレクション(v1.0+ は「埋め込み関数の永続化」に対応)
    client = chromadb.PersistentClient(path=PERSIST)
    coll = client.get_or_create_collection(
        name=COLL_NAME,
        embedding_function=ef,
        metadata={
            "hnsw:space": "cosine", 
            "embed_model": EMBED_MODEL,
            "embed_device": device,
            "prefix_doc": DOC_PREFIX,
            "prefix_query": QUERY_PREFIX,
        }
    )

    docs, metas, ids = [], [], []
    processed = skipped = added_chunks = 0

    with Progress(
        SpinnerColumn(),
        TextColumn("[bold]Indexing[/] {task.description}"),
        BarColumn(),
        TextColumn("{task.percentage:>3.0f}%"),
        TimeElapsedColumn(),
        TimeRemainingColumn(),
        transient=False,
    ) as progress:
        t_files  = progress.add_task("files", total=total_files)
        t_chunks = progress.add_task("chunks (added)", total=None)

        for p in file_list:
            try:
                rel = str(p.relative_to(root)).replace("\\", "/")
                text = read_text(p)
                cks = chunks(text)

                # Ruri 用に文書プレフィックスを付与して投入
                for i, ch in enumerate(cks):
                    stat = p.stat()
                    h = hashlib.md5(f"{rel}|{int(stat.st_mtime)}|{i}".encode()).hexdigest()
                    docs.append(DOC_PREFIX + ch)
                    metas.append({"path": rel, "chunk": i, "mtime": int(stat.st_mtime)})
                    ids.append(h)

                    if len(docs) >= BATCH_SIZE:
                        coll.add(documents=docs, metadatas=metas, ids=ids)
                        added_chunks += len(docs)
                        progress.update(t_chunks, advance=len(docs))
                        docs, metas, ids = [], [], []

                processed += 1
            except Exception:
                skipped += 1
            finally:
                progress.update(t_files, advance=1)

        if docs:
            coll.add(documents=docs, metadatas=metas, ids=ids)
            added_chunks += len(docs)
            progress.update(t_chunks, advance=len(docs))

    print(f"done. files={processed} skipped={skipped} chunks_added={added_chunks} total_in_collection={coll.count()}")

if __name__ == "__main__":
    main()

以下をPowerShellで実行します。

 # 実行
python .\ingest.py
# または
py .\ingest.py

画像

done. file=...と表示されれば成功です。

このインデックス化は、本来GPUでやるべきことをCPUでやっているので、1時間くらいかかります。
GPUだと十数秒で終わる処理なのですが......。
互換性のためには、しょうがないですね。

大抵55%くらいでずっと固まってますが、固まって見えても大丈夫。
上手くいくので気楽に行きましょう。
ここでインデックス化すれば、後の検索が段違いです。

また、作成したC:\\chroma-dataは同じプロジェクトメンバー内で使い回せます。
誰か一人が作れば、もう残りの人が行う必要はありません。

3-4. Windows起動時に事前プリロード

2本の超短いPythonをタスクスケジューラに登録します。
これでOSのファイルキャッシュが温まり、同一マシン上の後続プロセス(Claude→chroma-mcp)でのロードが速くなります。

C:\\chroma-data\\scripts\\prewarm\_embeddings.pyに以下を保存します。

# C:\chroma-data\scripts\prewarm_embeddings.py
import os, torch
from sentence_transformers import SentenceTransformer

# すでにDL済み前提(HF_HUB_OFFLINE=1)。GPU優先→CPU
device = "cuda" if torch.cuda.is_available() else "cpu"
m = SentenceTransformer("cl-nagoya/ruri-v3-30m", device=device, trust_remote_code=True)
m.max_seq_length = 512
m.encode(["検索クエリ: ping"], batch_size=1, normalize_embeddings=True)
print("embeddings prewarmed on", device)

同フォルダに以下を保存します。

途中のhogeindexは、ご自身のインデックス名に置き換えてください。

# C:\chroma-data\scripts\prewarm_chroma.py
import chromadb, os
client = chromadb.PersistentClient(path=r"C:/chroma-data")
coll = client.get_collection("hogeindex")  # 既存の埋め込み関数/設定をそのまま利用
# HNSWを読み出し&最小探索(top_k=1)
coll.query(query_texts=["検索クエリ: ping"], n_results=1)
print("chroma prewarmed")

PowerShellを管理者で開き、以下を実行します。

schtasks /Create /TN "RuriPrewarm"   /TR "py C:\chroma-data\scripts\prewarm_embeddings.py" /SC ONSTART /RL LIMITED /F
schtasks /Create /TN "ChromaPrewarm" /TR "py C:\chroma-data\scripts\prewarm_chroma.py"     /SC ONSTART /RL LIMITED /F

画像

「正しく作成されました」と出れば成功です。

3-5. Claude Desktop に MCP サーバーを登録

Claude Desktopアプリを起動します。
左上のハンバーガーメニューから「ファイル」→「設定」を選択します。

画像

「開発者」タブを選択し、「設定を編集」をクリックしてください。

画像

そうすると、エクスプローラーで設定ファイルが開き、claude_desktop_config.jsonファイルが選択された状態で表示されます。

画像

注:  デフォルトではclaude_desktop_config.jsonファイルが存在しないことがあるので、その場合は新規作成する必要があります。

もしハンバーガーメニューが表示されなかったら、フォルダアプリから直接表示することもできます。
以下のパスを参照してください。
C:\\Users\\ユーザー名\\AppData\\Roaming\\Claude

ClaudeにMCPサーバーを登録します。
以下のコードを貼り付けてください。
(filesystemのargsは、ご自身のcloneしたディレクトリのパスにしてください。)

 {
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "ご自身のディレクトリのパス"
      ]
    },
    "ripgrep": {
      "command": "npx",
      "args": ["-y", "mcp-ripgrep@latest"]
    },
    "chroma": {
      "command": "uvx",
      "args": [
        "--with",
        "sentence-transformers",
        "--with",
        "sentencepiece",
        "--with",
        "torch",
        "--with",
        "chroma-hnswlib",
        "chroma-mcp",
        "--client-type",
        "persistent",
        "--data-dir",
        "C:\\chroma-data"
      ]
    }
  }
}

保存後、Claude Desktop を「ファイル」→「終了」の手順で一旦終了して、再起動します。
(✕ボタンでは閉じないように!)

画像

もう一度起動し、「ファイル」→「設定」→「開発者」から、3つのMCPサーバーが登録されていればOKです。

3-6. Claude の「プロジェクト指示」に固定文を入れる

Claudeの「プロジェクト指示」に、固定文(ripgrep ⇒ 原文引用 ⇒ RAGの型と既定パス)を入れておくことで、
私達ユーザーはClaudeに対して 「〇〇ってどうなってたっけ?」 みたいにラフに聞くだけでOKになります。

Claude Desktopの「プロジェクト」→「+新規プロジェクト」を選択。

画像

プロジェクト名を入力して、「プロジェクト作成」をクリック。
そうすると、入力したプロジェクト固有の会話が立ち上がるので、「手順」をクリック。

画像

以下の内容を入力して「指示を保存」をクリック。

**【対象フォルダ】のディレクトリのパスや、インデックス名は、ご自身のディレクトリ名やインデックス名に書き換えてください。**

【起動時ウォームアップ】
- chroma_get_collection_count: { "collection": "hogeindex" }
- chroma_query_documents:
  { "collection":"hogeindex",
    "query_texts":["検索クエリ: ウォームアップ"], "top_k":1 }

【目的】hogeindex に「書いてあることだけ」で答える。外部情報で補完しない。

【対象フォルダ】ご自身のディレクトリのパス
【Chroma 永続ディレクトリ】C:/chroma-data
【既存コレクション】hogeindex  (※存在しない場合は「インデックス未構築」と案内)

【必須手順】
1) まず Chroma で候補取得:
   chroma_query_documents:
   { "collection":"hogeindex",
     "query_texts":[ "検索クエリ: " + <ユーザー質問> ],
     "top_k":5 }

2) 各候補は必ず原文確認(裏取り):
   - filesystem.read_file で該当ファイルを開く。
   - 必要に応じて ripgrep:advanced-search(固定文字列, 行番号, 前後3行)

3) 裏取りできた箇所のみ回答。各項目に (相対パス:行番号) と短い原文引用。完全一致があれば優先。

4) ripgrep 単体の結果がゼロで、かつ 1)〜3) で裏取り不能なら **「記載なし」** のみ返答(推測禁止)。

4. 使い方

画像

画像

4-1. 初期設定

PCでClaude Desktopを立ち上げて最初に聞く分には応答がかなり遅いです。
おそらく延々とこの画面が続いて不安になります。

画像

これは、HNSWインデックスとメタデータをRAMにロード&モデルを初期化するウォームアップが走るためです。
そのため固定プロンプトにはウォームアップ用の文章があったのですね。

最初だけ遅く、2回目以降は爆速になるので大丈夫です。
もしこれを使用する大事なプレゼンがあるときには、5分前くらいに雑に「こんにちは」とか投げて起動させておきましょう。

画像

一応、これを防ぐために3-4の項目でWindows起動時に事前プリロードしていました。

4-2. 2回目以降

雑に聞くだけです。

使用するAIは速度と精度のバランスから、Claude Sonnet 4がおすすめです。
(ここでClaudeは要約の役割を果たします)

以下のように許可を求められるので、「常に許可」をクリックします。

画像

画像

画像

正常に動作すると1分程度で返ってきます。

4-3. ドキュメントに更新が来たら?

もう一度ingest.pyを実行する必要はありません。
差分だけ取り込めばよいです。
以下のrefresh\_ruri.pyを実行しましょう。

設定項目にあるディレクトリのパスやインデックス名はご自身のドキュメントのパスやインデックス名に書き換えてください。

# refresh_ruri.py
import os, sys, re, json, pathlib, hashlib
import chromadb
from chromadb.utils import embedding_functions
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn, SpinnerColumn
from pypdf import PdfReader
import docx, torch
from sentence_transformers import SentenceTransformer


# ==== 設定(環境変数で上書き可)====
ROOT     = pathlib.Path(os.environ.get("WL_ROOT", r"ご自身のディレクトリのパス"))
PERSIST  = pathlib.Path(os.environ.get("WL_CHROMA_DIR", r"C:/chroma-data"))
COLL     = os.environ.get("WL_COLL_NAME", "hogeindex")

HF_HOME  = os.environ.get("HF_HOME", r"C:/chroma-data/hf-home")
MODEL    = os.environ.get("WL_EMBED_MODEL", "cl-nagoya/ruri-v3-30m")

MANIFEST = PERSIST / f"{COLL}.manifest.json"   # path -> {mtime:int, chunks:int}
EXTS     = {".md", ".txt", ".pdf", ".docx"}

CHUNK_SIZE = 1800
OVERLAP    = 120
BATCH_SIZE = 200
DOC_PREFIX = "検索文書: "     # Ruri 文書側プレフィックス

class RuriEmbedFn:
    def __init__(self, model_path_or_id: str, device: str, batch_size: int = 8, max_len: int = 512):
        from sentence_transformers import SentenceTransformer
        self.model_name = model_path_or_id
        self.device = device
        self.normalize = True
        self.batch_size = batch_size
        self.model = SentenceTransformer(model_path_or_id, device=device, trust_remote_code=True)
        self.model.max_seq_length = max_len

    def name(self) -> str:
        return "sentence_transformer"   

    def to_dict(self) -> dict:
        return {
            "model_name": self.model_name,
            "device": self.device,
            "normalize_embeddings": self.normalize,
            "kwargs": {},               
        }

    def __call__(self, input: list[str]) -> list[list[float]]:
        emb = self.model.encode(
            input, batch_size=self.batch_size,
            normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False
        )
        return emb.tolist()

def read_text(p: pathlib.Path) -> str:
    suf = p.suffix.lower()
    if suf in (".md", ".txt"):
        return p.read_text(encoding="utf-8", errors="ignore")
    if suf == ".pdf":
        out = []
        with open(p, "rb") as fh:
            r = PdfReader(fh)
            for page in r.pages:
                out.append(page.extract_text() or "")
        return "\n".join(out)
    if suf == ".docx":
        d = docx.Document(str(p))
        return "\n".join(par.text for par in d.paragraphs)
    return ""

def chunks(txt: str, size=CHUNK_SIZE, overlap=OVERLAP):
    if not txt.strip():
        return []
    units = re.split(r"(?<=\n)", txt)
    buf, out = "", []
    for u in units:
        if len(buf) + len(u) >= size and buf:
            out.append(buf)
            buf = buf[-overlap:]
        buf += u
    if buf.strip(): out.append(buf)
    return out

def make_id(rel: str, i: int) -> str:
    # 衝突が極小になるよう path の md5 を使いつつ “人が見ても分かる” 形に
    h = hashlib.md5(rel.encode()).hexdigest()[:8]
    return f"{rel}|{i}|{h}"

def load_manifest():
    if MANIFEST.is_file():
        try:
            return json.loads(MANIFEST.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}

def save_manifest(data: dict):
    MANIFEST.parent.mkdir(parents=True, exist_ok=True)
    MANIFEST.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

def main():
    if not ROOT.exists():
        print(f"[ERR] not found: {ROOT}", file=sys.stderr); sys.exit(2)

    # Embedding(GPU優先→CPU)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    ef = RuriEmbedFn(MODEL, device, batch_size=8, max_len=512)
    client = chromadb.PersistentClient(path=str(PERSIST))
    # 既存がなければ cosine で作る(既にある場合はそのまま)
    coll = client.get_or_create_collection(
        name=COLL, embedding_function=ef, metadata={"hnsw:space":"cosine"}
    )

    manifest = load_manifest()
    # 現在のファイル一覧
    files = []
    for b, _, fs in os.walk(ROOT):
        for f in fs:
            p = pathlib.Path(b)/f
            if p.suffix.lower() in EXTS: files.append(p)
    relpaths_now = {str(p.relative_to(ROOT)).replace("\\","/") for p in files}

    # 削除されたファイルを先に処理(IDは manifest から計算)
    to_delete_files = [rel for rel in manifest.keys() if rel not in relpaths_now]
    del_ids_batch = []
    for rel in to_delete_files:
        old_chunks = int(manifest[rel].get("chunks", 0))
        del_ids_batch.extend([ make_id(rel, i) for i in range(old_chunks) ])
        del manifest[rel]
    if del_ids_batch:
        # まとめて消す(ID 指定が最速)
        for i in range(0, len(del_ids_batch), 2000):
            coll.delete(ids=del_ids_batch[i:i+2000])   # ID削除は標準機能
        del_ids_batch.clear()

    # 変更・新規だけ upsert(DB照会ゼロ)
    upsert_ids, upsert_docs, upsert_metas = [], [], []
    deletes_tail = []  # 余りチャンク削除用ID
    total_changed = 0

    with Progress(SpinnerColumn(), TextColumn("[bold]Refresh fast[/] {task.description}"),
                  BarColumn(), TextColumn("{task.percentage:>3.0f}%"),
                  TimeElapsedColumn(), TimeRemainingColumn()) as progress:
        t = progress.add_task("files", total=len(files))
        for p in files:
            rel = str(p.relative_to(ROOT)).replace("\\","/")
            mtime = int(p.stat().st_mtime)
            rec = manifest.get(rel)
            if rec and rec.get("mtime") == mtime:
                progress.update(t, advance=1); continue  

            # 変更 or 新規 → 読み込み & 再チャンク
            text = read_text(p)
            cks  = chunks(text)
            new_n = len(cks)
            old_n = int(rec.get("chunks", 0)) if rec else 0

            # 0..new_n-1 を upsert
            for i, ch in enumerate(cks):
                upsert_ids.append(make_id(rel, i))
                upsert_docs.append(DOC_PREFIX + ch)
                upsert_metas.append({"path": rel, "chunk": i, "mtime": mtime})

                if len(upsert_ids) >= BATCH_SIZE:
                    coll.upsert(ids=upsert_ids, documents=upsert_docs, metadatas=upsert_metas) 
                    upsert_ids.clear(); upsert_docs.clear(); upsert_metas.clear()

            # new_n < old_n の“余り”をIDで消す(DB照会不要)
            if new_n < old_n:
                for i in range(new_n, old_n):
                    deletes_tail.append(make_id(rel, i))
                # バッチ削除
                for j in range(0, len(deletes_tail), 2000):
                    coll.delete(ids=deletes_tail[j:j+2000])
                deletes_tail.clear()

            manifest[rel] = {"mtime": mtime, "chunks": new_n}
            total_changed += 1
            progress.update(t, advance=1)

    if upsert_ids:  # 残りを一括
        coll.upsert(ids=upsert_ids, documents=upsert_docs, metadatas=upsert_metas)

    save_manifest(manifest)
    print(f"done. changed_files={total_changed} total={coll.count()}")

if __name__ == "__main__":
    main()

画像

done: change\\file=... と表示されれば成功です。

4-4. おまけ:VSCodeでMCPを使用する方法

GitHub CopilotのAgentモードをONにしていると、MCPが使用して回答することができます。ドキュメントに基づいてコードを書けて便利。

VSCodeを開いて、以下の2つのファイルを置きます。

4-4-1. .vscode/mcp.json

VS Code で MCP サーバー(filesystem / ripgrep / chroma)を有効化する設定です。

置き場所:リポジトリ直下の .vscode/mcp.json(無ければ .vscode フォルダを作成)

filesystemのargsのフォルダのパスはご自身のディレクトリのパスに変更してください。

4-4-2. .github/copilot-instructions.md

Copilot Chat が毎回読む常設インストラクション。ここに「Chroma→原文裏取り→ripgrep」の運用を固定化します。

置き場所:リポジトリ直下の .github/copilot-instructions.md(.github が無ければ作成)
Copilot はリポジトリの .github/copilot-instructions.md を自動でチャットへ反映します(ワークスペース単位)。
マルチルートでは効かない既知事象があるため、単一ルートでの利用を推奨します。

対象範囲のパスやインデックス名はご自身の内容に変更してください。

【目的】hogeindex に「書いてあることだけ」で答える。外部情報で補完しない。

【対象フォルダ】ご自身のディレクトリのパス

【Chroma 永続ディレクトリ】C:/chroma-data
【既存コレクション】hogeindex  (※存在しない場合は「インデックス未構築」と案内)

【必須手順】
1) まず Chroma で候補取得:
   chroma_query_documents:
   { "collection":"hogeindex",
     "query_texts":[ "検索クエリ: " + <ユーザー質問> ],
     "top_k":5 }

2) 各候補は必ず原文確認(裏取り):
   - filesystem.read_file で該当ファイルを開く。
   - 必要に応じて ripgrep:advanced-search(固定文字列, 行番号, 前後3行)

3) 裏取りできた箇所のみ回答。各項目に (相対パス:行番号) と短い原文引用。完全一致があれば優先。

4) ripgrep 単体の結果がゼロで、かつ 1)〜3) で裏取り不能なら **「記載なし」** のみ返答(推測禁止)。
    

5. 助けて!chromaが起動しないの!という時のために

powershellで以下を叩きます。

uvx chroma-mcp --client-type persistent `
  --data-dir C:\chroma-data `
  --dotenv-path C:\chroma-data\.chroma_env

このコマンドで何かエラーが出ればそれを参考にしましょう。


6. 最後に

画像

ここまでお読みいただきありがとうございました!
最初の立ち上げにどうしても時間がかかるというデメリットこそありつつも、とても便利かつセキュアなRAGができたと思います。

ドキュメントをローカルのMarkdownで管理していることのメリットを最大に活かせる使い方ですね。
Ruri の生みの親、hppさん( https://x.com/hpp_ricecake )に最大の感謝を。

ではまたお会いしましょう!


これからもQiitaで発信していくので、気になったらぜひいいね・フォローお願いします!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?