2026年4月16日木曜日

インメモリMCP検索サーバー

 

1. 導入:RAGの「アホアホマン」からの卒業

  • 悩み: Obsidianのメモが増えるたびに、検索が重くなる。

  • 課題: 毎回ディスクをなめる従来の検索(アホアホマン方式)の限界。

  • 解決策: 「DBを入れずに、M4 Maxのメモリに全て載せればいいじゃない」という富豪的発想。

2. アーキテクチャ:なぜDB不要なのか?

  • ハードウェア: Mac Studio (M4 Max / 36GB RAM) の圧倒的スペック紹介。

  • ソフトウェア: MCP (Model Context Protocol) サーバーを自作。

  • ロジック: 起動時に全メモを Map オブジェクトにロード。検索は String.includes() だけの爆速処理。

  • 「10万件載っても大丈夫」の試算: 実際、テキスト10万件なら数百MBで収まるという「気づき」。

3. 実装のこだわりポイント

  • バックアップ機能write_note 時の .bak 生成というプロの配慮。

  • キャッシュ同期: ファイルを書き換えた瞬間にメモリも更新するリアルタイム性。

  • AIとの親和性: ヒット箇所の「前後数十文字(Excerpt)」をAIに渡すことで、回答精度を高める工夫。


    以下、サンプルコード

    ------

    import { Server } from "@modelcontextprotocol/sdk/server/index.js";
    import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
    import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
    import * as fs from "fs/promises";
    import * as path from "path";
    import { glob } from "glob";
    // Vaultのパス(環境変数から取得)
    const VAULT_PATH = path.resolve(process.env.OBSIDIAN_VAULT_PATH || "");
    /**
     * 【富豪的設計】インメモリ全文キャッシュ
     * M4 Maxの広大なメモリを信じ、全データをMapに展開する
     */
    const vaultCache = new Map();
    /**
     * インデックス作成:全ファイルをメモリにロード
     */
    async function refreshIndex() {
        console.error("🚀 Indexing vault..."); 
        const files = await glob("**/*.md", { 
            cwd: VAULT_PATH, 
            ignore: ["**/node_modules/**", "**/.git/**", "**/bin/**"] 
        });
        
        for (const file of files) {
            const fullPath = path.join(VAULT_PATH, file);
            try {
                const stats = await fs.stat(fullPath);
                const content = await fs.readFile(fullPath, "utf-8");
                vaultCache.set(file, { content, mtime: stats.mtimeMs });
            } catch (e) {
                console.error(`⚠️ Skip ${file}: ${e.message}`);
            }
        }
        console.error(`✅ Indexed ${vaultCache.size} files. Ready for ultra-fast search.`);
    }
    // サーバー起動時に一括ロード
    await refreshIndex();
    const server = new Server(
        { name: "obsidian-pro-server", version: "2.0.0" },
        { capabilities: { tools: {} } }
    );
    /**
     * ツール定義:AIに「何ができるか」を教える
     */
    server.setRequestHandler(ListToolsRequestSchema, async () => ({
        tools: [
            {
                name: "search_vault",
                description: "メモリ上のキャッシュから、ファイル名および本文を高速全文検索します。",
                inputSchema: {
                    type: "object",
                    properties: { 
                        query: { type: "string", description: "検索ワード" } 
                    },
                    required: ["query"],
                },
            },
            {
                name: "read_note",
                description: "指定されたファイルをメモリから即座に読み込みます。",
                inputSchema: {
                    type: "object",
                    properties: { relativePath: { type: "string", description: "vaultからの相対パス" } },
                    required: ["relativePath"],
                },
            },
            {
                name: "write_note",
                description: "ファイルを上書き保存し、自動的に.bakバックアップを作成します。メモリも即時更新します。",
                inputSchema: {
                    type: "object",
                    properties: { 
                        relativePath: { type: "string" }, 
                        content: { type: "string" } 
                    },
                    required: ["relativePath", "content"],
                },
            }
        ],
    }));
    /**
     * 各ツールの実行ロジック
     */
    server.setRequestHandler(CallToolRequestSchema, async (request) => {
        const { name, arguments: args } = request.params;
        // パス安全性チェック
        const getSafePath = (rel) => {
            const fullPath = path.join(VAULT_PATH, rel);
            if (!fullPath.startsWith(VAULT_PATH)) throw new Error("Security Error: Access denied");
            return fullPath;
        };
        // --- 1. 高速全文検索 (Search) ---
        if (name === "search_vault") {
            const q = (args?.query || "").toLowerCase();
            const results = [];
            // メモリ上を総当たり(10万件でもM4 Maxなら一瞬)
            for (const [relPath, data] of vaultCache.entries()) {
                if (relPath.toLowerCase().includes(q) || data.content.toLowerCase().includes(q)) {
                    const index = data.content.toLowerCase().indexOf(q);
                    // ヒット箇所の前後30/60文字を抜粋してAIに渡す
                    const excerpt = data.content.substring(
                        Math.max(0, index - 30), 
                        Math.min(data.content.length, index + 60)
                    ).replace(/\n/g, " ");
                    
                    results.push(`- Path: ${relPath}\n  Excerpt: "...${excerpt}..."`);
                }
            }
            return { 
                content: [{ 
                    type: "text", 
                    text: results.length > 0 
                        ? `Found ${results.length} matches:\n\n${results.join("\n")}` 
                        : "No matches found." 
                }] 
            };
        }
        // --- 2. 読み込み (Read) ---
        if (name === "read_note") {
            const cached = vaultCache.get(args.relativePath);
            if (cached) return { content: [{ type: "text", text: cached.content }] };
            
            // 未インデックスの場合のみディスク参照
            try {
                const content = await fs.readFile(getSafePath(args.relativePath), "utf-8");
                return { content: [{ type: "text", text: content }] };
            } catch (e) { return { content: [{ type: "text", text: "File not found." }], isError: true }; }
        }
        // --- 3. 書き込み (Write) ---
        if (name === "write_note") {
            try {
                const targetPath = getSafePath(args.relativePath);
                
                // 安全第一:上書き前にバックアップ作成
                try {
                    const oldContent = await fs.readFile(targetPath, "utf-8");
                    await fs.writeFile(`${targetPath}.bak`, oldContent);
                } catch (e) { /* 新規作成時はパス */ }
                await fs.mkdir(path.dirname(targetPath), { recursive: true });
                await fs.writeFile(targetPath, args.content, "utf-8");
                // メモリも即時同期
                vaultCache.set(args.relativePath, { 
                    content: args.content, 
                    mtime: Date.now() 
                });
                return { content: [{ type: "text", text: `Success: Updated ${args.relativePath} and synced memory.` }] };
            } catch (e) { return { content: [{ type: "text", text: e.message }], isError: true }; }
        }
        throw new Error("Tool not found");
    });
    // サーバー起動
    const transport = new StdioServerTransport();
    await server.connect(transport);

0 件のコメント:

コメントを投稿

インメモリMCP検索サーバー

  1. 導入:RAGの「アホアホマン」からの卒業 悩み : Obsidianのメモが増えるたびに、検索が重くなる。 課題 : 毎回ディスクをなめる従来の検索(アホアホマン方式)の限界。 解決策 : 「DBを入れずに、M4 Maxのメモリに全て載せればいいじゃない」という富豪的発...