前編ではログ用MCPサーバを作り、オーケストレータからClickHouseへイベントを強制記録する仕組みを構築しました。後編では、貯まったログを「使える資産」に変えていきます。
後編のゴール
後編で実装するのは以下の最小ループです。
- knowledge生成 — eventsから有用な情報を抽出し、検索可能な形で保存
- RAG検索 — 会話中にknowledgeを引いて回答品質を上げる
- 評価記録 — ユーザーの反応や検索精度を自動で記録
- 設定更新 — 評価結果に基づきRAGパラメータを調整
このループが回れば、AIエージェントは「使うほど賢くなる」状態になります。
テーブル追加(knowledge / retrieval_metrics)
前編で作ったeventsテーブルに、2つのテーブルを追加します。
| テーブル | 役割 |
|---|---|
events |
生ログ(前編で作成済み) |
knowledge |
eventsから抽出したナレッジ |
retrieval_metrics |
RAG検索の評価データ |
events(生ログ)
↓ 抽出
knowledge(構造化ナレッジ)
↓ 検索・利用
retrieval_metrics(評価)
↓ 分析
RAG設定更新
DDL例:knowledge
CREATE TABLE IF NOT EXISTS knowledge (
knowledge_id UUID DEFAULT generateUUIDv4(),
text String, -- ナレッジ本文
tags Array(String) DEFAULT [], -- 分類タグ
embedding Array(Float32) DEFAULT [], -- 埋め込み(オプション)
source_event_ids Array(String) DEFAULT [], -- 元イベントID群
source_type Enum8( -- ナレッジの出典
'conversation' = 1,
'tool_output' = 2,
'user_feedback' = 3,
'document' = 4
) DEFAULT 'conversation',
created_at DateTime64(3) DEFAULT now64(3),
updated_at DateTime64(3) DEFAULT now64(3)
)
ENGINE = MergeTree()
ORDER BY (created_at, knowledge_id)
TTL toDateTime(created_at) + INTERVAL 365 DAY
SETTINGS index_granularity = 8192;
設計ポイント
-
embeddingはオプション。まずはtext検索で始め、必要なら追加 -
source_event_idsで出典を追跡。問題時の調査や重複防止に使用 -
source_typeで抽出元の種別を記録(会話、ツール出力、フィードバック等)
DDL例:retrieval_metrics
CREATE TABLE IF NOT EXISTS retrieval_metrics (
metric_id UUID DEFAULT generateUUIDv4(),
trace_id String,
turn_id String,
query String,
retrieved_ids Array(String) DEFAULT [],
retrieved_scores Array(Float32) DEFAULT [],
-- 評価指標
user_feedback Enum8(
'none' = 0,
'positive' = 1,
'negative' = 2,
'regenerated' = 3
) DEFAULT 'none',
accepted UInt8 DEFAULT 0,
regenerated UInt8 DEFAULT 0,
citation_coverage Float32 DEFAULT 0.0,
-- RAG設定スナップショット
rag_config_version String DEFAULT '',
top_k UInt8 DEFAULT 5,
created_at DateTime64(3) DEFAULT now64(3)
)
ENGINE = MergeTree()
ORDER BY (created_at, trace_id, turn_id)
TTL toDateTime(created_at) + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
評価指標の取得方法
-
user_feedback: 明示的なフィードバックUI(Enum8で状態管理) -
accepted: 会話継続パターンから推定(「ありがとう」→1、「違う」→0) -
regenerated: 再生成リクエストの有無 -
citation_coverage: 回答と検索結果の照合
実装例:knowledge生成ワーカー
eventsから定期的にナレッジを抽出するワーカーです。
処理フロー
1. チェックポイント読み込み(前回処理位置)
2. events増分取得
3. 抽出対象の判定(ツール結果、長い応答など)
4. LLMで要点抽出
5. knowledge INSERT
6. チェックポイント更新
核心部分
def extract_knowledge(self, events: list[dict]) -> list[dict]:
"""イベント群から要点を抽出"""
prompt = ChatPromptTemplate.from_messages([
("system", """あなたはAIエージェントの会話ログから、将来の回答に役立つナレッジを抽出するエキスパートです。
抽出基準
- ツール実行結果から得られた事実情報
- ユーザーからのフィードバックや訂正
- 成功したタスク完了のパターン
- エラーからの学び
出力形式(JSON配列)
[{{"text": "抽出したナレッジ", "tags": ["タグ"], "source_type": "conversation|tool_output|user_feedback"}}, ...]
ナレッジがない場合は空配列 [] を返してください。"""),
("user", "イベントログ:\n{events_text}")
])
events_text = "\n".join([
f"[{e['event_type']}] {e.get('tool_name', '')}: {e['content'][:500]}"
for e in events
])
chain = prompt | self.llm | JsonOutputParser()
return chain.invoke({"events_text": events_text})
ポイント
- チェックポイント方式で障害時も再開可能
- LangChainの
JsonOutputParserでJSON配列を直接パース - 軽量モデル(gpt-4o-mini等)でコスト管理
実装例:RAG検索
検索戦略(段階的に高度化)
| フェーズ | 方式 | 特徴 |
|---|---|---|
| Phase 1 | タグ・キーワード検索 | 実装が簡単、高速、説明可能 |
| Phase 2 | ベクトル類似検索 | 意味的な類似を捉える |
| Phase 3 | ハイブリッド検索 | キーワード+ベクトルの組み合わせ |
まずはPhase 1から始めるのが現実的です。
キーワード検索の例
def search_by_keywords(self, query: str, top_k: int = 5) -> list[dict]:
# キーワード抽出とサニタイズ(SQLインジェクション対策)
keywords = [kw.replace("'", "''") for kw in query.split()[:10] if kw.strip()]
if not keywords:
score_expr = "0" # 空クエリ時はスコア0
else:
score_expr = " + ".join([f"multiSearchAnyCaseInsensitive(text, ['{kw}'])" for kw in keywords])
sql = f"""
SELECT knowledge_id, text, tags, source_type, ({score_expr}) as score
FROM knowledge
ORDER BY score DESC
LIMIT {top_k}
"""
return self.execute(sql)
ベクトル検索の例
def similarity_search(self, query: str, top_k: int = 5, threshold: float = 0.7) -> list[dict]:
"""類似検索を実行"""
query_embedding = self.embeddings.embed_query(query)
result = self.client.execute(f"""
SELECT knowledge_id, text, tags, source_type,
1 - cosineDistance(embedding, %(query_embedding)s) as similarity
FROM knowledge
WHERE length(embedding) > 0
ORDER BY similarity DESC
LIMIT %(top_k)s
""", {"query_embedding": query_embedding, "top_k": top_k})
# 閾値でフィルタリング
return [row for row in result if row[4] >= threshold]
注意: embeddingの次元はknowledgeテーブルとクエリで揃える必要があります(例: text-embedding-3-smallは1536次元)。次元が異なるとcosineDistanceがエラーになります。パラメータ化クエリを使用してSQLインジェクションを防止しています。
評価の自動記録(3指標)
RAGの品質を継続的に測定するため、3つの指標から始めます。
| 指標 | 計算方法 | 用途 |
|---|---|---|
| accept率 | countIf(accepted = 1) / count() |
回答の受容度 |
| 再生成率 | countIf(regenerated = 1) / count() |
不満の検出(低いほど良い) |
| 引用カバレッジ | 回答中で検索結果が使われた割合 | 検索精度の評価 |
ダッシュボード用クエリ
SELECT
toDate(created_at) as date,
count() as total,
-- 分母0対策: NULLIFで0をNULLに変換し、0除算を回避
countIf(accepted = 1) / nullIf(count(), 0) as accept_rate,
countIf(regenerated = 1) / nullIf(count(), 0) as regeneration_rate,
avg(citation_coverage) as avg_coverage,
rag_config_version
FROM retrieval_metrics
WHERE created_at >= now() - INTERVAL 30 DAY
GROUP BY date, rag_config_version
ORDER BY date DESC;
設定の更新
評価結果に基づいてRAG設定を調整します。小さく動かすが鉄則です。
RAG設定テーブル
CREATE TABLE IF NOT EXISTS rag_config (
config_id UUID DEFAULT generateUUIDv4(),
version String,
top_k UInt8 DEFAULT 5,
similarity_threshold Float32 DEFAULT 0.7,
tag_filter Array(String) DEFAULT [],
prompt_template String DEFAULT '',
is_active UInt8 DEFAULT 0,
created_at DateTime64(3) DEFAULT now64(3),
-- メタデータ
description String DEFAULT '',
created_by String DEFAULT 'system'
)
ENGINE = ReplacingMergeTree(created_at)
ORDER BY (version)
SETTINGS index_granularity = 8192;
設計意図: is_active = 1は常に1件だけにします。設定更新時は旧設定をis_active = 0にしてから新設定をINSERTしてください。読み込み側はWHERE is_active = 1 ORDER BY created_at DESC LIMIT 1で取得し、万が一複数存在しても最新1件を使用します。ReplacingMergeTreeにより同一versionの重複が自動的にマージされます。
更新ルール例
| 条件 | アクション |
|---|---|
| accept率 < 0.6 | top_k を +1(最大10) |
| 再生成率 > 0.2 | similarity_threshold を +0.05 |
| ヒット数 < 1 | similarity_threshold を -0.05(最小0.5) |
運用メモ
TTL設計
| テーブル | 推奨TTL | 理由 |
|---|---|---|
| events | 90日 | 生ログは容量大。必要情報はknowledgeに抽出済み |
| knowledge | 365日 | ナレッジは長期的な資産だが、古すぎる情報は削除 |
| retrieval_metrics | 90日 | 傾向分析に十分 |
| rag_config | 無期限 | 設定履歴は残す(ReplacingMergeTreeで重複排除) |
その他の注意点
- PIIマスキング: knowledge生成時にメール・電話番号・カード番号を除去
- プロンプトバージョン管理: 抽出時のモデル名とプロンプトハッシュを記録
-
embeddingコスト: OpenAI
text-embedding-3-smallで10万件≒$1。まずはキーワード検索で始める
検証結果:PoCで改善効果を確認
本記事のシステムが実際に機能するか、ローカル環境でPoCを実施しました。
検証環境
- ClickHouse(OSS版): Docker (clickhouse/clickhouse-server:24.1)
- LLM: ローカルLLM (Ollama, gpt-oss:20b) + OpenAI API (gpt-4o-mini)
- Embedding: OpenAI text-embedding-3-small (1536次元)
検証手順
Step 1: 会話データの生成
ローカルLLMと34会話×3ターン=約100往復の会話を自動生成し、eventsテーブルに記録しました。会話トピックは技術質問、エラー対処、設計相談など多様なものを用意しました。
総イベント数: 306
ユーザー入力: 102
LLMレスポンス: 102
会話数: 34
Step 2: ナレッジ抽出
knowledge_workerで会話からナレッジを自動抽出しました。LLM(gpt-4o-mini)が会話を分析し、再利用可能な知識を判定・抽出します。
抽出されたナレッジ: 58件
- tool_output由来: 47件
- その他: 11件
100往復の会話から58件のナレッジが抽出されました。抽出率は約57%で、LLMが「再利用価値がない」と判断した会話はスキップされています。
Step 3: 検索精度の確認
ベクトル検索の精度を確認しました。
| クエリ | 最高類似度 | ヒット数 |
|---|---|---|
| APIエラーの対処法 | 0.70 | 3件 |
| データベースのインデックス | 0.73 | 3件 |
| Dockerとは | 0.65 | 2件 |
| コードレビューのポイント | 0.72 | 3件 |
会話から抽出したナレッジは、単発で投入したサンプルデータ(類似度0.5程度)より高い精度でヒットしました。これは「質問→回答」のペアが文脈として保持されているためです。
Step 4: RAGあり/なしの比較
同じ質問に対するRAGあり/なしの回答を比較しました。
Q: データベースのインデックスはいつ作るべき?
【RAGなし】
データベースのインデックスは、特定の条件や状況に応じて作成するべきです。
以下のような場合にインデックスを作成することを検討すると良いでしょう。
1. 検索性能の向上: 大量のデータがあるテーブルで...
(一般的で冗長な説明が続く)
【RAGあり】取得文書: 1件
[1] 類似度: 0.72
データベースインデックスは、検索、結合、ソート、集計の際に作成すべきである。
--- 回答 ---
データベースのインデックスは、以下のような状況で作成すべきです。
1. 検索: 特定のデータを迅速に取得する必要がある場合
2. 結合: 複数のテーブルを結合する際
3. ソート: データを特定の順序で表示する必要がある場合
4. 集計: 集計関数を使用するクエリにおいて
RAGありの回答は、ナレッジの「検索、結合、ソート、集計」という4分類をそのまま採用し、簡潔で一貫性のある回答になっています。
Step 5: 設定調整の効果
similarity_thresholdを0.7→0.65に調整した結果を確認しました。
| クエリ | 閾値0.7 | 閾値0.65 |
|---|---|---|
| APIエラーの対処法 | 0件 | 2件 |
| インデックスはいつ作るべき? | 1件 | 1件 |
| Dockerコンテナとは? | 0件 | 1件 |
閾値を下げることで、より多くのナレッジを回答に活用できるようになりました。設定変更はrag_configテーブルに記録され、バージョン管理されています(v1.0.0 → v1.0.1 → v1.0.2)。
検証で得られた知見
1. 会話からの抽出は精度が高い
単発のドキュメントより、会話の文脈を持つナレッジの方が検索精度が高くなります。「質問→回答」のペアが自然言語として類似性を持つためです。
2. 閾値のチューニングは必須
初期値0.7では厳しすぎて、有用なナレッジがヒットしないケースがありました。0.65程度に下げることで、精度と網羅性のバランスが取れます。
3. ナレッジの量が重要
5件のサンプルデータでは類似度0.5程度でしたが、58件に増やすと0.7以上でヒットするようになりました。ナレッジの量が一定数を超えると、検索精度が大きく向上します。
4. 改善ループの可視化
retrieval_metricsテーブルに検索結果が記録されるため、「どのクエリでナレッジが使われたか」「類似度の分布はどうか」を後から分析できます。これが継続的な改善の基盤になります。
まとめ
前後編を通じて構築したのは以下の最小ループです。
ユーザー会話
↓
オーケストレータ ──→ events(前編)
↓
knowledge生成ワーカー ──→ knowledge(後編)
↓
RAG検索 ──→ retrieval_metrics(後編)
↓
設定更新ワーカー ──→ rag_config(後編)
↓
改善されたRAG検索 ←─┘
このループが回れば、後は改善の自動化範囲を広げていくだけです。
次のステップ例
- ナレッジの自動分類・階層化
- A/Bテストによる設定最適化
- ユーザーセグメント別の設定
- 異常検知(急激な品質低下のアラート)
最小構成から始めて、必要に応じて拡張する。それがこのシステムの設計思想です。