Skip to content

MCP Toolbox がエージェントのテキストを ClickHouse のベクトルに変える方法

pjhampton square 1
2026年9月7日 · 31分で読む

セマンティック検索が必要な AI エージェントを作ったことがあれば、誰もがぶつかる厄介なギャップに心当たりがあるはずです。LLM が話すのはテキスト、データベースが話すのは SQL とベクトルであり、その間のどこかで変換しなければなりません。そのどこかは、たいてい自分で書いて保守する専用のアプリケーションコードやツールになります。クエリ文字列を受け取り、埋め込み API を呼び出し、ベクトルを整形して SQL に差し込み、エスケープ漏れがないことを祈る、という具合です。

Google の MCP Toolbox for Databases は、このギャップをネイティブに埋めます。ClickHouse にも最初から対応しています。YAML で Gemini の埋め込みモデルを宣言し、ツールのパラメーターにそのモデルを embeddedBy として指定すれば、Toolbox が text → vector → search のパイプライン全体を、呼び出し側には見えない形で処理します。エージェントがベクトルを目にすることはありません。エージェントは "how do I configure TTL on a table?"(テーブルに TTL を設定するには?)を送り、ランキング済みの行を受け取ります。

この記事では、MCP Toolbox とは何か、インストールと設定の方法、prebuilt の ClickHouse ツールの使い方を取り上げ、そのうえで本題に入ります。挿入時にテキストをベクトル化する取り込みツールと、クエリをベクトル化してコサイン距離でランキングする検索ツールを作ります。自前の埋め込みサービスを用意して維持する手間はかかりません。最後に合成コーパスを読み込ませ、実際に何が返ってくるかを見ていきます。

試してみたい場合は、この記事をコーディングエージェントに渡し、手順どおりに環境を用意させてみてください。

以下の内容はすべて、執筆時点の安定版である Toolbox 1.9.0ClickHouse Cloud 26.4.1 のサービスに対して、一連の流れを実際に実行して確かめました。

MCP Toolbox とは

MCP Toolbox for Databases は、Google がオープンソース(Apache 2.0)で公開している Model Context Protocol サーバーです。MCP が存在する前に genai-toolbox として公開され、その後に改名されました。AI エージェントとデータベースの間に位置する単一の Go バイナリであり、次の 2 種類の役割を担います。

  1. すぐに使える MCP サーバー。 --prebuilt フラグで ClickHouse や Postgres などのデータベースを指定すれば、Claude Code、Gemini CLI、Codex、IDE といった任意の MCP クライアントから、execute_sqllist_tables のような汎用ツールをすぐに使えます。調査や開発に向いています。
  2. カスタムツールのフレームワーク。 パラメーター付きの SQL 文を厳選して YAML で定義し、生の SQL アクセスの代わりにそれらをツールとして公開します。これは本番環境に適したパターンです。エージェントが呼び出せるのは、こちらで用意したクエリだけであり、与えるのは型付きのパラメーターで、ドライバーがデータベースへ送る際にエスケープするからです。

ClickHouse のほかに、PostgreSQL、MySQL、SQL Server、Oracle、MongoDB、Redis、Valkey、Elasticsearch、Neo4j、Cassandra、Snowflake、Trino、CockroachDB、TiDB、そして Google Cloud 系(AlloyDB、BigQuery、Cloud SQL、Spanner、Firestore)に対応しています。1 つの tools.yaml に複数のソースを定義できるため、ClickHouse の分析用ツールと Postgres のアプリケーションデータベース用ツールを、1 つの MCP エンドポイントから並べて公開できます。ただしこれはフェデレーションではありません。各ツールはちょうど 1 つのソースに結び付くため、エージェントが両者を突き合わせるには 2 回呼び出し、SQL の中ではなく自身のコンテキストの中で結果を結合することになります。

内部では、コネクションプーリング、ツールの呼び出しに認証を求めるオプション、OpenTelemetry のメトリクスとトレースも、追加の手間なく利用できます。

インストール

環境に合う方法を選んでください。

# Homebrew (macOS / Linux)
brew install mcp-toolbox

# Or grab the binary directly (see the releases page for versions/platforms)
export VERSION=1.9.0
curl -L -o toolbox https://storage.googleapis.com/mcp-toolbox-for-databases/v$VERSION/darwin/arm64/toolbox
chmod +x toolbox

# Or Docker
docker pull us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:$VERSION

# Or zero-install via npx (convenient, but not the fastest startup)
npx @toolbox-sdk/server --config tools.yaml

toolbox --version で確認します。サーバーは既定で 127.0.0.1:5000 を待ち受けます。全インターフェイスではなくループバックであり、データベースの資格情報を保持するプロセスの既定値としては妥当です。以下は tools.yaml の例です。

kind: source
name: my-clickhouse
type: clickhouse
host: ${CLICKHOUSE_HOST}
port: ${CLICKHOUSE_PORT}
database: ${CLICKHOUSE_DATABASE}
user: ${CLICKHOUSE_USER}
password: ${CLICKHOUSE_PASSWORD}
protocol: ${CLICKHOUSE_PROTOCOL}
secure: true

---
kind: tool
name: execute_sql
type: clickhouse-execute-sql
source: my-clickhouse
description: Execute a SQL query against ClickHouse and return the rows.

---
kind: tool
name: list_databases
type: clickhouse-list-databases
source: my-clickhouse
description: List all databases in ClickHouse.

---
kind: tool
name: list_tables
type: clickhouse-list-tables
source: my-clickhouse
description: List the tables in a ClickHouse database.

---
kind: embeddingModel
name: gemini-embedder
type: gemini
model: gemini-embedding-001
project: ${GOOGLE_CLOUD_PROJECT}
location: ${GOOGLE_CLOUD_LOCATION}
dimension: 768

---
kind: tool
name: insert_doc
type: clickhouse-sql
source: my-clickhouse
description: Indexes a new document and its vector embedding.
statement: |
  INSERT INTO vectors.documents (content, embedding) VALUES (?, ?)
parameters:
  - name: content
    type: string
    description: The text content to store.
  - name: text_to_embed
    type: string
    description: Hidden copy of content, embedded as a vector.
    valueFromParam: content
    embeddedBy: gemini-embedder

---
kind: tool
name: search_docs
type: clickhouse-sql
source: my-clickhouse
description: Finds the most semantically similar documents to a query.
statement: |
  SELECT content, cosineDistance(embedding, ?) AS distance
  FROM vectors.documents
  ORDER BY distance ASC
  LIMIT 5
parameters:
  - name: query
    type: string
    description: The natural-language search query.
    embeddedBy: gemini-embedder

---
kind: toolset
name: semantic_search
tools:
  - insert_doc
  - search_docs

---
kind: toolset
name: clickhouse_explore
tools:
  - execute_sql
  - list_databases
  - list_tables

YAML を書き始める前に知っておきたい点があります。1.9 ではフラットな設定形式が推奨されます。各リソースがそれぞれ独立した YAML ドキュメントになり、kindnametype のキーを持ち、--- で区切られます。インターネット上の古い例では、kind が型を表すネスト形式(sources:my-clickhouse: → …)が使われています。この古い形式も 1.9 でそのままパースされ、支障なく動作します(ネスト形式の設定を残したまま実行してみましたが、問題なく動きました)。そのため古い設定があっても壊れているわけではありません。新しい形式にしたいときは toolbox migrate で変換でき、以下の例はすべて新しい形式を使っています。

クイックスタート: prebuilt の ClickHouse ツール

Toolbox は、mcp-clickhouseClickHouse Cloud MCP サーバー を接続するのと同じように、汎用の ClickHouse MCP サーバーとして使えます。MCP クライアントの設定(Claude Code なら .mcp.json、Claude Desktop なら claude_desktop_config.json)に次を追加します。

{
  "mcpServers": {
    "clickhouse": {
      "command": "npx",
      "args": ["-y", "@toolbox-sdk/server", "--prebuilt=clickhouse", "--stdio"],
      "env": {
        "CLICKHOUSE_HOST": "your-instance.clickhouse.cloud",
        "CLICKHOUSE_PORT": "8443",
        "CLICKHOUSE_USER": "default",
        "CLICKHOUSE_PASSWORD": "…",
        "CLICKHOUSE_DATABASE": "default",
        "CLICKHOUSE_PROTOCOL": "https"
      }
    }
  }
}

この 6 つの変数はすべて必須です。prebuilt の設定はすぐに失敗し、どれが足りないかを正確に伝えてくれるので、接続タイムアウトを待つよりも扱いやすいです。CLICKHOUSE_HOST はホスト名だけを書く点に注意してください。スキームとポートは CLICKHOUSE_PROTOCOLCLICKHOUSE_PORT に置くため、ホストの欄に https://host:8443 と書いても動きません。

これでエージェントはすぐに 3 つのツール、execute_sqllist_databaseslist_tables を使えるようになります。「events テーブルのスキーマは?」と尋ねれば、エージェントが調べてくれます。

Toolbox はこのモードの用途について率直で、起動のたびに次の警告をログに出力します。

これらの prebuilt の設定は、信頼できる開発者の作業をエージェントが支援する「ビルド時」の用途を想定しています。エージェントが信頼できない可能性のある開発者と対話する「実行時」の用途には、セキュリティが十分ではありません。

そこで、次に挙げる厳選したツールの出番です。

ClickHouse 側も ベクトル検索 を手厚くサポートしており、コサインなどの距離関数や HNSW インデックスを備えています。Toolbox のカスタムツールのフレームワークは、埋め込みモデルをリソースの種別として第一級で扱います。ツールのパラメーターに embeddedBy: <model-name> のヒントが付いていると、Toolbox は呼び出し時に生のテキストを受け取り、埋め込みモデルの API へまとめて送り、返ってきたベクトルを文の ? プレースホルダーにバインドします。

ClickHouse の場合、ベクトルは自分で組み立てた文字列ではなく、生の []float32 として clickhouse-go ドライバーに渡されます。

// internal/embeddingmodels/embeddingmodels.go
// FormatVectorForClickHouse returns the raw []float32 slice, which the
// clickhouse-go driver binds natively to Array(Float32) parameters.
func FormatVectorForClickHouse(vectorFloats []float32) any {
    if len(vectorFloats) == 0 {
        return []float32{}
    }
    return vectorFloats
}

つまり、パラメーターのプレースホルダーに対して ClickHouse のベクトル関数を SQL からそのまま使えます。このフォーマッターはコードベースにちょうど 2 つあり、pgvector 用と ClickHouse 用です。

? を見て混同しないでください。これは「プリペアドステートメント」と同じものではありません。実行された文を system.query_log から取り出してみると、ベクトルはリテラルとしてインライン展開された状態で届いています。

query_head: SELECT content, cosineDistance(embedding, [-0.002279004, 0.012003845, 0.0052243723, …
query_len:  10626
param_1 present in Settings: 0

ここにはサーバー側のパラメーターバインドはなく、param_* の設定もありません。clickhouse-go がクライアント側で補間した 10 KB の SQL 文字列があるだけです。これがセキュリティにどう影響するかを確かめるため、string のパラメーターに x') AS a FROM system.one WHERE 1=1 UNION ALL SELECT version(-- を入れて、基本的なインジェクションを試しました。返ってきたのは実行された SQL ではなく、結果カラムに入ったこの文字列そのものです。つまり保証されるのはドライバーが型付きの値をシリアライズしてエスケープしてくれることであり、リテラルを手で組み立てたときに生じるインジェクションの余地はなくなります。ただし、プリペアドステートメントではありません。

欠点もあります。埋め込みを使うクエリは、ベクトルを含む全文を query_log に書き込みます。検索ツールがよく使われればこのテーブルはかなり膨らみますし、ベクトルはそこに置けるもののなかで最も圧縮が効かないデータです。

ステップ 1: ClickHouse のテーブル

ドキュメントとその埋め込みを保持するテーブルが必要です。ここにはいくつか意図的な選択があります。

CREATE DATABASE vectors;

CREATE TABLE vectors.documents
(
    id        UUID DEFAULT generateUUIDv4(),
    content   String,
    embedding Array(Float32),
    INDEX idx_embedding embedding TYPE vector_similarity('hnsw', 'cosineDistance', 768)
)
ENGINE = MergeTree
ORDER BY (id);

スキーマについての補足です。

  • Array(Float32) は ClickHouse における埋め込みカラムの標準的な型で、Toolbox がバインドする先でもあります。
  • vector_similarity スキップインデックスにより、近似最近傍(HNSW)検索が使えます。3 番目の引数(768)はベクトルの次元数で、埋め込みモデルに設定する dimension と一致させる必要があります。26.4 では allow_experimental_vector_similarity_indexenable_vector_similarity_index がどちらもすでに 1 だったため、設定の変更は不要でした。それより古いバージョンでは自分で有効化する必要があるかもしれません。コーパスが小さい場合(数百万行までなら)、インデックスは完全に省いてもかまいません。カラム指向の Array(Float32) に対する総当たりの cosineDistance は高速で、インデックスは後から ALTER TABLE ... ADD INDEX で追加できます。
  • コサインを選び、それを使い続けてください。 gemini-embedding-001 が 768 次元で出力するベクトルは、単位長に正規化されていません。筆者のコーパスでは L2 ノルムが 0.5788〜0.5928 の範囲に収まり、平均は 0.5862 でした。cosineDistance は内部で正規化するため影響を受けませんが、L2Distance はランキングが変わり、ベクトルの大きさに引っ張られます。しかも上記のインデックスは 'cosineDistance' を宣言しているため、L2Distance のクエリはインデックスの助けをまったく得られません。指標を 1 つ決め、インデックスで宣言し、一貫して使ってください。
  • ORDER BY はベクトル検索そのものを速くしません。ANN 検索はスキップインデックスを通ります。ORDER BY は、ベクトル検索と組み合わせるメタデータのフィルター(テナント、カテゴリ、日付)を基準に決めてください。これは実質的に変更できません。ALTER TABLE … MODIFY ORDER BY はカラムの追加しかできず、並べ替えや削除はできないため、データを投入する前に決めておく必要があります。検索ツールが必ず、たとえば category で絞り込むなら、それを先頭に置きます: ORDER BY (category, id)

ClickHouse Cloud では共有ストレージ用のエンジンが自動的に差し替えられ、インデックスはそのまま引き継がれます。

CREATE TABLE vectors.documents
(
    `id` UUID DEFAULT generateUUIDv4(),
    `content` String,
    `embedding` Array(Float32),
    INDEX idx_embedding embedding TYPE vector_similarity('hnsw', 'cosineDistance', 768) GRANULARITY 100000000
)
ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')
ORDER BY id
SETTINGS index_granularity = 8192

ステップ 2: Toolbox の設定

すべては 1 つの tools.yaml に収まります。リソースは 3 種類で、ソース、埋め込みモデル、ツールです。

ソース
ClickHouse Cloud へは 8443 番ポートの HTTPS で接続し、ローカルのインスタンスへは 8123 番ポートの HTTP で接続します。

kind: source
name: my-clickhouse
type: clickhouse
host: ${CLICKHOUSE_HOST}
port: ${CLICKHOUSE_PORT}
database: ${CLICKHOUSE_DATABASE}
user: ${CLICKHOUSE_USER}
password: ${CLICKHOUSE_PASSWORD}
protocol: ${CLICKHOUSE_PROTOCOL}
secure: true

埋め込みモデル
1.9 の時点で、サポートされるプロバイダーは gemini だけです。internal/embeddingmodels の下にあるパッケージは gemini 1 つだけで、openaiollamabedrock などはいずれも設定のパース時に拒否されます。現時点で別のプロバイダーが必要なら、埋め込みは Toolbox の外で行うことになります。

認証の方法は 2 通りあり、どちらになるかは設定するフィールドで決まります。まず Google AI Studio で取得した API キーを使う場合です。

kind: embeddingModel
name: gemini-embedder
type: gemini
model: gemini-embedding-001
apiKey: ${GOOGLE_API_KEY}
dimension: 768

あるいは apiKey の代わりに projectlocation を指定すると、バックエンドが Vertex AI に切り替わり、Application Default Credentials が使われます。

kind: embeddingModel
name: gemini-embedder
type: gemini
model: gemini-embedding-001
project: ${GOOGLE_CLOUD_PROJECT}
location: ${GOOGLE_CLOUD_LOCATION}   # e.g. us-central1
dimension: 768

gcloud auth application-default login を済ませていれば、この 2 番目の形式は追加の設定なしで動き、サーバーは起動時にどちらの経路を使ったかを伝えます。

INFO "Using Vertex AI backend for Gemini embedding" "my-project" "us-central1"
INFO "Initialized 1 embeddingModels: gemini-embedder"

フィールドは少なく、厳密です。modeldimension、そして apiKey か、projectlocation の組み合わせのどちらかです。いかにもありそうな追加のフィールド(useVertextaskTypeoutputDimensionality)は、いずれも未知のフィールドとして拒否されます。拒否される理由はソースコードで確認できます。

// internal/embeddingmodels/gemini/gemini.go
embedConfig := &genai.EmbedContentConfig{
    TaskType: "SEMANTIC_SIMILARITY",
}

if m.Dimension > 0 {
    embedConfig.OutputDimensionality = genai.Ptr(m.Dimension)
}

まず、dimension はそのまま OutputDimensionality に対応します。つまりこのフィールドは、モデルの出力を記述するものではなく、モデルに指示するものです。同じ gemini-embedding-001 に対して dimension: 512 を設定すれば、512 要素のベクトルが返ってきます。したがって、モデルの出力次元を事前に調べる必要はありません。dimension が一致していなければならないのは、カラムの vector_similarity インデックスで宣言した幅だけです。Toolbox はその幅を知らないため、不一致は設定のパースの失敗としては現れず、挿入時または検索時に ClickHouse のエラーとして現れます。最初から正しく合わせておいてください。

次に、セマンティック検索の記事としてより重大な点です。taskTypeSEMANTIC_SIMILARITY にハードコードされており、だからこそ設定フィールドとしては拒否されます。検索に関する Google 自身のガイダンスは非対称で、ドキュメントは RETRIEVAL_DOCUMENT、クエリは RETRIEVAL_QUERY として埋め込むよう勧めています。こうすれば、モデルが調整された空間に両者が収まります。Toolbox ではそのどちらも指定できず、上書きもできません。SEMANTIC_SIMILARITY が最適化するのは「この 2 つのテキストは似ているか?」であり、「このドキュメントはこのクエリに答えているか?」とは微妙に異なる問いです。しかも取り込み側と検索側で同じタスクタイプが使われます。

実際には、後述の結果が示すとおりこれでも十分に機能します。ただし、そこで見られる距離の幅の狭さ、つまり値が広く散らばらず 0.15〜0.30 に収まる点には、この設計が影響している可能性があります。検索の品質を詰めたいときに最初に手を伸ばしたくなるのがこのつまみですが、いまは回せません。現時点で設定する方法は、Toolbox の外で埋め込むことだけです。

取り込みツール
ここはよくできた仕組みです。挿入時には同じテキストが 2 回必要になります。1 回は String として保存するため、もう 1 回はベクトルカラムに埋め込むために使います。同一の文字列を 2 つのパラメーターに繰り返させるのは LLM にとって無駄が多く、間違いも起きやすいため、Toolbox には valueFromParam があります。これは別のパラメーターの値をそのまま写す隠しパラメーターです。ツールのマニフェストには現れないので、エージェントはその存在を知りません。

kind: tool
name: insert_doc
type: clickhouse-sql
source: my-clickhouse
description: Indexes a new document and its vector embedding.
statement: |
  INSERT INTO vectors.documents (content, embedding) VALUES (?, ?)
parameters:
  - name: content
    type: string
    description: The text content to store.
  - name: text_to_embed
    type: string
    description: Hidden copy of content, embedded as a vector.
    valueFromParam: content
    embeddedBy: gemini-embedder

MCP 経由でサーバーにツールのマニフェストを要求すると、insert_doc については 1 つのプロパティだけが返ります。

- execute_sql    | params: sql
- insert_doc     | params: content
- list_databases | params: none
- list_tables    | params: database
- search_docs    | params: query

検索ツール
エージェントはプレーンなテキストを渡し、Toolbox がそれを埋め込んでベクトルを ? にバインドします。

kind: tool
name: search_docs
type: clickhouse-sql
source: my-clickhouse
description: Finds the most semantically similar documents to a query.
statement: |
  SELECT content, cosineDistance(embedding, ?) AS distance
  FROM vectors.documents
  ORDER BY distance ASC
  LIMIT 5
parameters:
  - name: query
    type: string
    description: The natural-language search query.
    embeddedBy: gemini-embedder

必要に応じて、これらをツールセットにまとめておけば、クライアントはこの 2 つだけを読み込めます。

kind: toolset
name: semantic_search
tools:
  - insert_doc
  - search_docs

覚えておきたい制約が 2 つあります。1 つ目は、embeddedBy を宣言できるのが string 型のパラメーターだけだという点です。違反していれば、サーバーが起動する前に設定のパーサーが検出します。

ERROR "unable to parse config file at \"tools.yaml\": document 3: error unmarshaling
tool \"dim_probe\": … parameter type \"integer\" cannot specify 'embeddedBy'"

2 つ目は、参照する埋め込みモデルも同じ設定の中で定義されていなければならない点です。ただしこちらはパース時に検査されません。存在しないモデルを指しているツールは何の問題もなく読み込まれ、最初に使ったときに失敗します。実行してから気づくことになるので、こちらのほうがはるかに厄介です。

ERROR "error embedding parameters: embedding model does not exist: does-not-exist"

ステップ 3: 実行する

./toolbox --config tools.yaml   # listens on 127.0.0.1:5000

筆者は普段、同梱の UI である http://127.0.0.1:5000/ui をまず開き、何が読み込まれたかを目で確かめます(サーバーの起動時に --ui を付けます)。ただし、クライアントを接続する前でも、シェルから直接ツールを動かせます。

toolbox invoke search_docs '{"query":"making queries faster"}' \
  --config tools.yaml --log-level ERROR

jq にパイプするなら --log-level ERROR が重要になります。Toolbox は起動時のログ行を JSON の結果と一緒に標準出力へ書き出すため、既定のログレベルで呼び出すと、出力は有効な JSON になりません。クライアント連携のデバッグに入る前に、埋め込み用の資格情報が有効かどうかを確かめたいときは、このワンライナーが最も手早い方法です。

コーパスを読み込む

2 行だけのデモでは、セマンティック検索が機能しているかどうかはほとんど分かりません。そこで、反証できる程度に話題の幅があるデータを読み込みます。5 つのクラスターにまたがる 57 件の短いドキュメントで、内容は ClickHouse の内部構造、運用と Kubernetes、一般的なプログラミング、料理、山歩きです。検索が本当にセマンティックであれば、クエリの言い回しがそれを求めるときには検索結果がクラスターの境界を越え、求めないときには同じクラスターの内側にとどまるはずです。

取り込みは insert_doc ツール自体を通すため、埋め込みはすべて Toolbox が行います。/api/tool/<name>/invoke の REST エンドポイントは 1.9 では既定で無効であり、フラグを付けずに呼ぶと 410 Gone とエラーが返る点に注意してください。

{"status":"Gone","error":"/api native endpoints are disabled by default. Please use the standard /mcp JSON-RPC endpoint"}

--enable-api を付けてサーバーを起動すれば、同じリクエストは 200 を返し、結果も正しくランキングされます。より単純なクライアントで済ませたいなら、REST の経路も使えます。筆者は代わりに MCP の JSON-RPC を選びました。理由は 2 つです。追加のフラグが要らないこと、そしてどうせエージェントが使うトランスポートであり、データ投入の経路が本番のトラフィックと同じコードを通ることです。

クライアント全体で 20 行ほどです。

def invoke(url: str, tool: str, arguments: dict) -> dict:
    """Call a tool over Toolbox's MCP JSON-RPC endpoint."""
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {"name": tool, "arguments": arguments},
    }
    request = urllib.request.Request(
        f"{url}/mcp",
        data=json.dumps(payload).encode(),
        headers={
            "Content-Type": "application/json",
            "Accept": "application/json, text/event-stream",
        },
    )
    with urllib.request.urlopen(request) as response:
        body = json.load(response)
    if "error" in body:
        raise RuntimeError(body["error"]["message"])
    return body["result"]

Accept: application/json, text/event-stream ヘッダーは、streamable HTTP の MCP トランスポートが定めているものです。Toolbox 1.9 はたまたまこれがなくても応答しますが、それでも付けてください。コストはかかりませんし、仕様に準拠したクライアントならそうするからです。

あとはコーパスを小さなスレッドプールに分散させます。

with ThreadPoolExecutor(max_workers=8) as pool:
    failures = [failure for failure in pool.map(insert, documents) if failure]
$ python3 seed_documents.py --url http://127.0.0.1:5000
inserted 57/57 in 4.1s

57 件のドキュメントを同時実行数 8 で処理して 4 秒です。しかもその 1 件ごとに Vertex AI への埋め込みのラウンドトリップが入っています。何が入ったかを確認します。

SELECT count() AS docs, any(length(embedding)) AS dims
FROM vectors.documents
┌─docs─┬─dims─┐
1. │   57 │  768 │
   └──────┴──────┘

保存にかかるコストも見ておきましょう。地味に聞こえますが、意外と興味深い話です。ただし、どのカラムを参照するかには注意してください。data_uncompressed_bytes はカラムのデータだけを対象とし、bytes_on_disk はセカンダリインデックスを含むため、この 2 つを直接比べても何も分かりません。インデックスは分けて見ます。

SELECT sum(rows) AS docs,
       formatReadableSize(sum(data_uncompressed_bytes))              AS col_uncompressed,
       formatReadableSize(sum(data_compressed_bytes))                AS col_compressed,
       formatReadableSize(sum(secondary_indices_uncompressed_bytes)) AS idx_uncompressed,
       formatReadableSize(sum(secondary_indices_compressed_bytes))   AS idx_compressed,
       formatReadableSize(sum(bytes_on_disk))                        AS on_disk
FROM system.parts
WHERE database = 'vectors' AND table = 'documents' AND active
┌─docs─┬─col_uncompressed─┬─col_compressed─┬─idx_uncompressed─┬─idx_compressed─┬─on_disk────┐
1. │   57 │ 178.36 KiB       │ 162.34 KiB     │ 100.77 KiB       │ 69.05 KiB      │ 231.85 KiB │
   └──────┴──────────────────┴────────────────┴──────────────────┴────────────────┴────────────┘

埋め込みは実際にほとんど圧縮できません。178.36 KiB が 162.34 KiB になるだけで、圧縮率は 1.1 倍です。ログやメトリクスのカラムでは 10 倍以上になることも多いのに対し、エントロピーの高い浮動小数点数はコーデックに手がかりを与えません。(この数字は 3 つのカラム全体を対象にしていますが、その大半は embedding で、content は全体のうち数 KiB です。)そしてこの規模では、HNSW インデックスがパートの 29.8% を占め、合計 232 KiB のうち 69 KiB です。57 行を総当たりでスキャンする場合と比べて、得られるものはありません。小さなコーパスで「インデックスを追加すべきか?」と考えているなら、まだ必要ありません。

検索してみる

ここからは、うまくいくかいかないかがはっきり出る部分です。クエリを 4 つ用意しました。いずれも、本来取得されるべきドキュメントと意味のあるキーワードを共有していません。それぞれ上位 3 件をコサイン距離とともに示します。

「making queries faster」(クエリを速くする)

0.1653  Distributed tables fan a query out to shards and merge the partial results…
0.1708  Dictionaries hold reference data in memory so joins become key lookups…
0.1715  Reading fewer columns is the single biggest lever on scan performance…

「why did my container get killed」(コンテナがなぜ強制終了されたのか)

0.1833  The OOMKilled reason on a terminated container means it exceeded its memory limit…
0.2313  Liveness probes restart a container; readiness probes only remove it from…
0.2345  A pod stuck in CrashLoopBackOff is usually failing its own startup…

「staying safe in bad weather on a hill」(悪天候の山で安全を保つ)

0.1565  Pitching a tent on a slight rise keeps you dry when overnight rain pools…
0.1667  Mountain weather can turn within an hour, so carry a shell even on a clear morning.
0.2069  Navigating with a map and compass still works when the phone battery dies…

気に入っているのは 2 番目です。「why did my container get killed」は OOMKilled についてのメモを最初に取得します。「killed」と「container」という語を除けば、このクエリはそのメモと何も共有していません。「memory」も「limit」もありません。3 件の結果はいずれも 12 件のドキュメントからなる運用のクラスターの内側にとどまり、残りの 45 件は入り込んでいません。

そして次は、あまり見事ではない例です。

「what should I make for dinner」(夕食は何を作ればよいか)

0.2853  Sourdough starter needs feeding with equal parts flour and water roughly…
0.2943  Baking is closer to chemistry than cooking, so weigh your flour rather than…
0.3030  Distributed traces show where a request spent its time; metrics tell you…

上位 2 件は正しいクラスターに入っていますが、3 位には分散トレーシングのメモが紛れ込み、夕食の席に割り込んできました。結果全体は 0.28〜0.30 に収まり、うまくいったクエリの 0.16〜0.23 に比べてはるかに平坦で、距離も遠いです。筆者のコーパスには「what should I make for dinner」に答えるドキュメントがそもそもありません。料理の技法はありますが、レシピはないからです。そして純粋な top-k 検索は、ふさわしい行があるかどうかに関係なく、必ず k 件を返します。本番では距離のしきい値(自分のデータに合わせて調整した WHERE distance < 0.25 など)を設け、ノイズを返すくらいなら何も返さないようにします。無関係な 3 件を渡されたエージェントは、たいていそれを使おうとします。

使い方: MCP クライアントとアプリケーション SDK

MCP クライアントから (Claude Code、Gemini CLI、…)

動作中のサーバーに HTTP でクライアントを向けます。

{
  "mcpServers": {
    "clickhouse-semantic-search": {
      "type": "http",
      "url": "http://127.0.0.1:5000/mcp/semantic_search"
    }
  }
}

http://127.0.0.1:5000/mcp は、ファイル内のすべてのツールを公開します。ツールセット名を末尾に付けると、対象をそのツールセットに限定できます。

常駐するサーバーを管理したくない場合は、stdio 経由でクライアント自身にプロセスを起動させます。MCP クライアントはシェルのプロファイルを読み込まないので、資格情報の読み込みも、この形式なら同時に解決できます。

{
  "mcpServers": {
    "clickhouse-toolbox": {
      "command": "sh",
      "args": [
        "-c",
        "set -a; . \"$HOME/.config/clickhouse.env\"; set +a; exec toolbox --config /path/to/tools.yaml --stdio"
      ]
    }
  }
}

必要以上に時間を取られた落とし穴があり、その本当の原因は、目につきやすいものとは違いました。tools.yaml に埋め込みモデルとベクトル用のツールを追加したのに、すでに起動していた Toolbox のプロセスは、ツールが 3 つだけの古いマニフェストを返し続けました。tools/listinsert_doc は現れず、それでも呼び出すと invalid tool name: tool with name "insert_doc" does not exist が返ります。こうなると、ありもしない YAML の不具合を探し回ることになります。

最初はホットリロードが失敗したのだと考えました。しかし違いました。リロードは、その場への追記でも、アトミックな差し替えでも、1〜2 秒で問題なく動きます。実際の原因は、${VAR} の展開が、動作中のプロセスが持つ環境変数をもとに解決されることです。このサーバーは、GOOGLE_CLOUD_PROJECTGOOGLE_CLOUD_LOCATION を環境変数ファイルに追加するに起動していたため、リロードを試みるたびに解決できない変数に当たり、Toolbox は当然の判断として、最後に成功した設定を保持し続けていました。これを見つけにくくしているのはログレベルです。

WARN "error loading configs unable to parse config file at \"tools.yaml\":
error parsing environment variables: environment variable not found: \"GOOGLE_CLOUD_PROJECT\""

これは ERROR ではなく WARN で、リクエストのログに埋もれています。しかも、1 時間前に別のターミナルで起動したままのサーバーの話です。つまり、ホットリロードは確かに機能し、文やパラメーターの変更なら頼ってかまいませんが、新しい環境変数が加わる変更には再起動が必要です。動作中のプロセスは、fork した時点で存在しなかった変数を見られないからです。

エージェントの視点では、やりとりは次のようになります。

ユーザー: このメモを保存して: 「ClickHouse のスキップインデックスはグラニュールに関するメタデータを保持するため、確実に一致しないブロックをクエリがスキップできます」

エージェント: insert_doc(content="ClickHouse skipping indexes store…") を呼び出します。Toolbox は content の値を隠しパラメーターにコピーし、768 次元のベクトルに埋め込み、その両方を INSERT にバインドします。

ユーザー: クエリを速くする話について、何を保存しましたか?

エージェント: search_docs(query="making queries faster") を呼び出します。Toolbox がクエリを埋め込み、ClickHouse がコサイン距離でランキングします。

エージェントのツール呼び出しはプレーンなテキスト、データベースのクエリはプレーンな SQL です。ベクトルの層は見えません。

アプリケーションコードから

独自のエージェントを作る場合に向けて、Toolbox は Python、JavaScript/TypeScript、Go、Java のクライアント SDK を提供しています。LangChain/LangGraph、LlamaIndex、Google の ADK 向けのフレームワークアダプターもあります。まずは中心となる Python SDK です。

pip install toolbox-core
import asyncio
from toolbox_core import ToolboxClient

async def main():
    async with ToolboxClient("http://127.0.0.1:5000") as client:
        # Load the toolset and hand the tools to your agent framework…
        tools = await client.load_toolset("semantic_search")

        # …or invoke a tool directly. The string is embedded server-side;
        # your application never touches a vector.
        search = await client.load_tool("search_docs")
        results = await search(query="how do I make queries faster?")
        print(results)

asyncio.run(main())

あるいは、ツールをそのまま LangGraph のエージェントに渡すこともできます。

from toolbox_langchain import ToolboxClient
from langgraph.prebuilt import create_react_agent

async with ToolboxClient("http://127.0.0.1:5000") as client:
    tools = client.load_toolset("semantic_search")
    agent = create_react_agent(model, tools)

どちらの場合も、埋め込みの処理は Toolbox のサーバー側にとどまります。gemini-embedding-001 を別のモデルに差し替えても、次元数を変えても、アプリケーションコードの変更は要りません。

本番運用での注意点

デモの先へ進める前に知っておきたい点をいくつか挙げます。

  • バッチ処理。 1 回の呼び出しの中に、同じモデルを使う埋め込みパラメーターが複数あれば、Toolbox はそれらをまとめて 1 回の埋め込み API リクエストにします。ただし埋め込みはツールの呼び出しごとに、その処理の中で同期的に実行されます。そのため 57 件のドキュメントは Vertex AI への 57 回のラウンドトリップになりました。4.1 秒なら問題ありませんが、100 万行ではまったく現実的ではありません。大量に取り込むなら、オフラインで埋め込んでから、きちんとしたバッチのパイプラインで投入してください(INSERT あたり数万行、どうしても少量ずつ流すなら async_insert=1)。insert_doc は、エージェントが 1 件ずつ書き込む用途に取っておきましょう。
  • ノイズを返すくらいなら何も返さない。 夕食のクエリで見たとおり、top-k は常に k 件を返します。エージェントが結果に基づいて動く検索ツールには、距離のしきい値を追加してください。
  • プリペアドステートメントではなく、ドライバーがエスケープするパラメーター。 通常の parameters は、型付きの値から clickhouse-go がシリアライズしてエスケープするため、インジェクションの余地はなくなります。ただし文は、ベクトルのリテラルも含めて補間された 1 つの文字列としてサーバーに届きます。したがって、これをサーバー側のバインドと説明してはいけませんし、query_log のエントリーが太ることは覚悟してください。Toolbox はテーブル名などのために templateParameters もサポートしますが、こちらはエスケープを一切行わない単純な文字列置換なので、ユーザーが制御できる値は通常のパラメーターに入れてください。
  • 厳密か近似か。 インデックスなしの cosineDistance は厳密で O(n) です。vector_similarity の HNSW インデックスを使うと近似になり、高速になります。57 行ではインデックスがパートの 29.8% を占めるだけで何も得られないため、この規模では総当たりで十分です。ANN に切り替える前に、自分のデータで再現率を確かめてください。
  • 埋め込みのタスクタイプは設定できない。 SEMANTIC_SIMILARITY がハードコードされているため、Google が検索向けに推奨する非対称な RETRIEVAL_DOCUMENTRETRIEVAL_QUERY の組み合わせは使えません。検索の品質を最適化したいなら、埋め込みは Toolbox の外で行ってください。
  • オブザーバビリティと認証。 Toolbox は OpenTelemetry のトレース(埋め込みの呼び出しも含む)とメトリクスを最初から出力し、ツールごとに認証付きの呼び出しを必須にできます。insert_doc のような書き込みツールを公開する前に、設定しておく価値があります。
  • バインドアドレスとオリジン。 既定のリスナーはループバックだけですが、--allowed-origins--allowed-hosts はどちらも既定値が * で、Toolbox は起動のたびに DNS リバインディングのリスクを警告します。これらは明示的に設定してください。

まとめ

ここで示したパターンは、お試しのドキュメントテーブルにとどまらず、広く応用できます。Array(Float32) カラムを持つ ClickHouse のテーブルなら、40 行ほどの YAML でエージェントから検索できるようになります。書くのは、どのパラメーターがテキストを意味するかどのモデルがそれをベクトルに変えるかの宣言だけです。同じ embeddedBy の仕組みは、Postgres 上の pgvector でも使えます。先に触れたとおり、コードベースにベクトルのフォーマッターを持つエンジンは、ClickHouse とこの pgvector だけです。

粗さが残っているのは、考え方そのものよりも周辺の作りです。埋め込みのプロバイダーが Gemini だけであること、REST エンドポイントにはオプトインのフラグが必要であること、環境変数が増えた設定はリロードではなく再起動が必要であることです。周辺の作りではない唯一の問題は、ハードコードされた SEMANTIC_SIMILARITY のタスクタイプで、これが検索品質の上限を決めてしまいます。この組み合わせでは、エージェントとの境界は MCP Toolbox が受け持ち、保存とランキングは ClickHouse に任せます。カラム指向のデータに対する ClickHouse の総当たりのベクトル性能と、成熟しつつある HNSW インデックスを踏まえると、「テーブルにテキストがある」状態から「エージェントがそれをセマンティックに検索できる」状態へ至る道筋として、これは筆者が見てきたなかで今も最も速いものの 1 つです。


MCP Toolbox はオープンソース(Apache 2.0)で、github.com/googleapis/mcp-toolbox にあります。ドキュメントの全体は mcp-toolbox.dev にあり、ClickHouse インテグレーション埋め込みモデルのリファレンス も含まれます。Toolbox 1.9.0 と ClickHouse Cloud 26.4.1 で検証しました。


この記事をシェア

  • Y Combinator icon
  • X icon
  • Bluesky icon
  • Facebook icon
  • LinkedIn icon

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!

Follow us

XBlueskySlackGithubTelegramMeetupRSS