Safie Engineers' Blog!

Safieのエンジニアが書くブログです

複数LLMでコンテキストを共有できる仕組みを作ってみた

はじめに

こんにちは。セーフィーでプロダクト開発をしています大町です。 最近は、ChatGPT → Gemini → Claude →(たまにGrok)のように、利用するLLMサービスを気分に応じて使い回しています。

最近、次々とLLMの新しいモデルが出ていますが、以下のようなことを思う時があります。

  • もともとChatGPTを使っていたけどGeminiで性能のいいモデルができたので乗り換えたい
  • アイデア出しはChatGPTで、調べ物はGeminiでといったように使い分けたい

→ でもサービスを変えるとコンテキストがなくなるので使いづらい

そんなエンジン(LLM)だけ変えたいけどデータは変わらないままにしたい!という要望を解決するためのシステムをプロトタイプとして作って社内の勉強会で発表しました。このブログではその内容を共有したいと思います。

今回紹介するシステムの概要

システムを一言で説明すると、自前でデータ基盤(DB)を用意して、そのデータ基盤に対して各LLMがMCPツールを使ってチャットのデータを入れたり、または必要に応じてとってきたりする、というものです。

MCPは、LLMが外部ツールを共通のインターフェースで呼び出すための仕組みで、今回は複数のLLMから同じデータ基盤を扱うための接点として利用しています。

以下がシステムのアーキテクチャ図です。

登場人物は次の3つです

  1. LLMサービス: ChatGPTやClaudeなど。MCPに対応していれば何でもOKです。
  2. DB: 今回は手軽なSQLiteを採用しました。
  3. MCPサーバー: 今回のシステムの核となる部分です。

DB設計

今回のシステムでは、LLMがMCPツールを使ってユーザーの回答に応じてDBから情報をとってきます。その時に、文章の意味を考慮する必要があるので、生のテキストに加えて文章の埋め込みベクトルもDBに保存するようにしています。

erDiagram
    entries {
        INTEGER id PK "自動増分"
        TEXT text "保存するメモ内容"
        TEXT source "発言者 (デフォルト: unknown)"
        TIMESTAMP created_at "作成日時"
        INTEGER dim "ベクトルの次元数"
        BLOB embedding "ベクトルデータ"
    }

MCPツールの実装

ここが今回のシステムの中心になります。 今回はミニマム実装ということで、MCPのツールとしてingestとrecallというツールを定義しました。

  1. ingest … データを保存する

ユーザー入力時にLLMがデータをDBに入れるためのツールです。 以下がシーケンス図です。ユーザーの発言をベクトル化してDBに保存します。

sequenceDiagram
    autonumber
    participant User as ユーザー
    participant LLM as LLM (Claudeなど)
    participant API as 共通API (MCP)
    participant DB as データベース

    Note over User, DB: 【Ingest】記憶を保存する
    
    User->>LLM: 「昨日の夕飯はカレーだった」と発言
    
    Note right of LLM: ユーザーの意図を汲み<br/>保存ツールを選択
    
    LLM->>API: ingest(text="昨日の夕飯は...", source="you")
    
    activate API
    Note right of API: テキストをEmbedding<br/>(ベクトル化)
    API->>DB: INSERT (テキスト, ベクトル, 作成日時)
    DB-->>API: 保存完了 (ID返却)
    deactivate API
    
    LLM-->>User: 「保存しました」と応答

コード

@mcp.tool()
async def ingest(text: str, source: str = "you") -> dict:
    """
    メモを保存するツール。
    ユーザーが「覚えて」「メモして」などと依頼したときに利用する。
    入力テキストをベクトル化してデータベースに登録する。
    """
    vec = embed([text])[0]   # 正規化済み
    db.insert_entry(text=text, source=source or "unknown", vec=vec)
    return {"text": text, "source": source}

vec = embed([text])[0]

の部分で与えられたテキストに対応する埋め込みベクトルを作っています。埋め込みベクトルとは、単語・文や画像などのデータを意味や関係性を捉えた数値のリスト(ベクトル・座標)です。

意味や関係性の情報を保持するので、例えば、犬と猫は近くて犬とコンピュータは遠くなります。

embedの中身は以下です。学習済みのモデルを使ってテキストをベクトル化し、正規化までしています。

import os
import numpy as np
from sentence_transformers import SentenceTransformer

MODEL_NAME = os.getenv("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")

_model = SentenceTransformer(MODEL_NAME)

def embed(texts):
    vecs = _model.encode(texts, normalize_embeddings=True)
    return np.asarray(vecs, dtype=np.float32)
  1. recall … データを検索する

ユーザーが質問した際に、DBにあるデータを検索し、レスポンスするツールです。

シーケンス図:ユーザーの質問文をベクトル化するところまでは ingest と同じです。質問文のベクトルと、DBに格納されたデータのベクトルとの類似度を計算し、検索を行います。

sequenceDiagram
    autonumber
    participant User as ユーザー
    participant LLM as LLM (ChatGPTなど)
    participant API as 共通API (MCP)
    participant DB as データベース

    Note over User, DB: 【Recall】記憶を検索・共有する

    User->>LLM: 「昨日の夕飯なに?」と質問
    
    Note right of LLM: 質問に答えるために<br/>検索ツールを選択
    
    LLM->>API: recall(query="昨日の夕飯なに?")
    
    activate API
    Note right of API: クエリをベクトル化し<br/>全データと類似度計算
    API->>DB: 全エントリのベクトル取得
    DB-->>API: データ返却
    
    API->>API: 類似度が高い順に抽出<br/>(閾値以下は除外)
    
    API-->>LLM: 検索結果 (JSON: "昨日の夕飯はカレー...")
    deactivate API
    
    LLM-->>User: 「昨日の夕飯はカレーでした」と回答

コード

@mcp.tool()
async def recall(query: str) -> dict:
    # 設定値
    limit = 5 # 最大5件返す
    min_score = 0.35 # 類似度の下限値。これを下回った場合は返さない。
    
    rows = db.fetch_all_entries()
    if not rows:
        return {"query": query, "hits": []}

    q = embed([query])[0]
    # DBの全ベクトルを展開
    vecs = np.vstack([db.from_blob(r["embedding"], r["dim"]) for r in rows])
    
    # 内積計算(正規化済みなのでコサイン類似度)
    scores = (vecs @ q).astype(float)
    idx = np.argsort(-scores)

    hits = []
    
    for i in idx[:limit]:
        s = float(scores[i])
 
        if s < min_score:
            break 
            
        r = rows[i]
        hits.append({
            "id": r["id"],
            "text": r["text"],
            "source": r["source"],
            "created_at": str(r["created_at"]),
            "score": s,
        })
  
    return {"query": query, "hits": hits}

ingestより処理が長くなっていますが、処理の内容としては質問文とDBにあるデータの類似度を計算し、類似度の高いものをレスポンスしています。

scores = (vecs @ q) で、質問文のベクトル q と全データのベクトル行列 vecs の積を取ることで、一度に内積(コサイン類似度)を計算しています。 今回はプロトタイプなのでNumpyで全件計算していますが、プロダクトとしてスケールさせるなら sqlite-vss などのベクトル検索拡張を使うのが良いでしょう。


デモ

実際に動かしてみました。Claudeでingestして、ChatGPTでrecallしています。

ingest by Claude
recall by ChatGPT

課題とその解決策

実際に使ってみて感じたのですが、検索のところが難しいということがわかりました。

いくつか課題とその解決策を挙げてみました。

  • いつの「昨日の夕飯」かわからない問題
    • 課題: ベクトル検索は意味の近さを探すため、「日時」などのメタ情報を考慮しません。そのため、「昨日の夕飯」と聞いているのに、文脈が似ていれば1年前のデータを取ってくる可能性があります。
    • 解決策: recall ツールの引数に date_range などのフィルタ条件を追加し、LLM側で「昨日=2025-10-17」のように具体的な日付に変換して渡してもらうことで解決できそうです。
  • 「質問」と「答え」の形が違う問題
    • 課題: ユーザーの質問「昨日の夕飯は何?(疑問形)」と、DB内の記録「昨日の夕飯はカレー(平叙文)」では、文章のベクトルが必ずしも近くならない場合があります。
    • 解決策: 単純な類似度検索だけでなく、LLMに「この質問に対する答えとしてありそうな文章」を仮想的に生成させてから検索にかける(HyDE: Hypothetical Document Embeddingsのようなアプローチ)か、データ保存時に「どんな質問に対する答えか」も一緒に保存するなどの工夫が考えられます。

終わりに

今回のシステムは、LLMのサービスを変えてもコンテキスト(データ)を維持できるようにするためのシステムでした。LLMは素晴らしい頭脳(モデル)を持っていますが、あくまでも一般的な知識に基づいたものでよりよく使うためには個別のコンテキストを与える必要があります。LLMのサービス側でコンテキストを持つことでその問題を解決していますが、そのことによりベンダーロックインも発生します。 今回のシステムではLLMサービスが提供しているモデルとデータを切り離すことで、モデルに関しては置き換え可能にし、データは自分の元で一元管理することで自由に閲覧・操作できる構成を考えました。 今回作ったのはプロトタイプでそのままでは使い物になりませんが、MCPやRAGを使ったシステムの1つのアイデアとしてみていただければ幸いです。

© Safie Inc.