llive v0.2 — RAD 知識庫 × 外部 LLM 連携

Raptor の Research Aggregation Directory (49 分野 / 44,864 docs / 112 MB) を llive 配下に取り込み、生物学的記憶モデルから書き戻し可能にし、 Claude Desktop / LM Studio / Cursor / Ollama / OpenWebUI から呼べる ローカルファースト記憶 LLM として実用化するまでの設計・実装記録。

2026-05-15 441 → 527 tests / 全 PASS ruff clean commits: a75ccd4 → f361d06

1. はじめに

本資料の目的

本資料は llive v0.2 系の RAD 横断エピックを、後から自分で読み返したときに 「なぜこの形にしたか」が再構成できる粒度で記録するためのものです。API リファレンスというより 設計思想と判断ポイントのスナップショットとして書いています。

対象読者

前提

用語 RAD = Research Aggregation Directory (Raptor の 21+ 分野コーパス階層)。 本資料では RAG (Retrieval-Augmented Generation) と混同しないよう、 RAD はデータ層、RAG は技法、と一貫して使い分けます。

2. 全体アーキテクチャ

取り込み (A) → 知識庫 API (B) → 外部 LLM 連携 (C) の 3 層構造で、C 層は さらに backend 抽象化 (C-1)MCP server (C-2)OpenAI HTTP server (C-3) の 3 経路に分岐します。

flowchart LR raptor["Raptor RAD
D:/docs/<domain>_v2/"] importer["scripts/import_rad.py
(stdlib)"] data["data/rad/
+ _learned/
+ _index.json"] loader["RadCorpusIndex
(loader/query/skills/types)"] append["append_learning"] consol["Consolidator
(biological memory)"] llm["LLMBackend
Mock / Anthropic / OpenAI / Ollama"] mcp["MCP server (stdio)
8 tools"] http["OpenAI HTTP server
/v1/chat/completions"] cd["Claude Desktop
LM Studio
Cursor / Continue.dev"] ow["Ollama
OpenWebUI"] raptor --> importer --> data data --> loader loader --> append consol --> append loader --> mcp loader --> http llm --> mcp llm --> http mcp --> cd http --> ow
なぜ 3 経路に分けたか MCP (stdio) は最汎用ですが、Ollama や多くの OpenAI 互換ツールは MCP client ではなく HTTP の /v1/chat/completions しか喋りません。逆に Cursor / Claude Desktop は MCP に最適化されています。両方をカバーするには tool 実装は共通化し、transport だけ分ける のが最小コストの解になります。

3. Phase A — RAD 取り込み層

3.1 設計判断

なぜ stdlib にこだわるか 取り込みは 初回 1 度だけ走る冷却パスです。ここで requests や pandas を 要求すると、まだ環境構築途中のユーザに依存解決の負債を押し付けることになります。 本体機能 (C 層) と切り離すことで、「llive を試したいだけ」のユーザの負担を抑えます。

3.2 スクリプト構造

# 主要関数 (~250 行)
def resolve_source(arg) -> Path: # --source > $LLIVE_RAD_SOURCE > $RAPTOR_CORPUS_DIR > D:/docs
def resolve_dest(arg) -> Path:   # --dest > $LLIVE_RAD_DIR > <repo>/data/rad
def list_corpora(source, include_legacy, only) -> list[Path]:  # スマート判定
def sync_corpus(src, dst, *, mirror, dry_run, force) -> CorpusStat:
def write_index(dest, report, *, dry_run) -> Path:  # _index.json 生成
def ensure_learned_dir(dest, *, dry_run):           # 書き層予約

3.3 ディレクトリレイアウト

data/rad/
  <domain>_v2/         # Raptor からの読み専用ミラー
  _learned/<domain>/   # llive 学習堆積 (Phase B で書く)
    <doc_id>.md
    <doc_id>.provenance.json
  _index.json          # 分野/ファイル数/バイト/取り込み日時
  README.md            # 構造説明 (gitignore 除外)
注意 .gitignoredata/rad/ を全除外し、!data/rad/README.md で 説明だけ残す。コーパス本体 (112 MB) はリポジトリに含めない — 再生成可能なので含めるとリポジトリが肥大化するだけ。

4. Phase B — RadCorpusIndex (読み + 書き)

4.1 読み+書き統合の理由

最初の検討では「読み API」と「書き API」を別クラスにする案がありました。が、 Consolidator (生物学的記憶モデル) の出口から見ると、両方を同じ ハンドルで扱えた方が呼び出し側がシンプルです。

# Bad (2 クラスに分かれている案)
reader = RadCorpusReader()
writer = RadCorpusWriter()
writer.append(...)
hits = reader.query(...)  # reader と writer が同じ root を見ているか保証されない

# Good (実装)
idx = RadCorpusIndex()        # 単一エントリポイント
idx.append_learning(...)
hits = idx.query(...)         # 同じ root、書いた直後に検索可能 (reload 自動)

4.2 API 表面

メソッド用途
list_domains()読み + 書き両方のドメイン名一覧
list_read_domains()Raptor 由来の読み層のみ
list_learned_domains()_learned/ 配下のみ
get_domain_info(name)ファイル数・バイト・取り込み日時
iter_documents(domain)ドメイン内の全ファイルパス
read_document(domain, rel_path)本文取得 (path traversal 防御つき)
append_learning(...) (関数)_learned/<domain>/<doc_id>.md 書き込み
query(...) (関数)filename×3 + content score の検索
reload()キャッシュ無効化 (書き込み後に呼ぶ)

4.3 パストラバーサル防御

def read_document(self, domain, rel_path):
    info = self.get_domain_info(domain)
    full = (info.path / rel_path).resolve()
    # resolve() 後にドメインルート配下か検証
    try:
        full.relative_to(info.path.resolve())
    except ValueError as exc:
        raise PermissionError(f"path traversal blocked: {rel_path}") from exc
なぜ resolve 後検証なのか 文字列レベルで ".." を弾くのは脆く、シンボリックリンクや 正規化前の ./../ パターンで抜けます。Path.resolve() で絶対化したあと relative_to() を使うと、Python レベルで安全な「中に居るか」判定ができます。

4.4 Consolidator (生物学的記憶モデル) 統合

llive の Consolidator は episodic → semantic への「Wiki Compile」フェーズで ConceptPage を作ります。この出口に RAD 書き戻しを差し込みます。

flowchart LR ep[EpisodicEvent] cl[Cluster] llm[CompileLLM] cd[CompileDecision] cp[ConceptPage] rad["_learned/<page_type>/
<concept_id>.md"] prov[provenance.json
derived_from=event_ids] ep --> cl --> llm --> cd --> cp cp -- "mirror (non-fatal)" --> rad cp -- "LLW-AC-01" --> prov prov -.-> rad
def _mirror_to_rad(self, page, cluster_events, result):
    if self.rad_index is None:
        return                                   # 互換: rad_index=None なら何もしない
    try:
        from llive.memory.rad.append import append_learning
        domain = self._rad_domain_for(page)        # page_type を [a-z0-9_]+ に
        prov = Provenance(
            source_type="consolidator",
            source_id=page.concept_id,
            derived_from=[e.event_id for e in cluster_events],
            confidence=0.8,
        )
        append_learning(self.rad_index, domain,
                        f"# {page.title}\n\n{page.summary}\n",
                        prov, doc_id=page.concept_id)
    except Exception as exc:
        result.errors.append(f"rad_mirror: {exc}")   # non-fatal
なぜ non-fatal なのか RAD は 二次的なミラーであり、主の真実は ConceptPage 自体です。 RAD への書き込みが失敗してもサイクル全体を倒すべきではないし、 Provenance の derived_from=event_idsLLW-AC-01 source-anchored provenance は維持されるので、後から再走しても同じ結果が再現できます。

5. Phase C-1 — LLM Backend Abstraction

5.1 抽象化の形

4 つの backend (Mock / Anthropic / OpenAI / Ollama) を 単一の generate(GenerateRequest) -> GenerateResponse に寄せます。 これにより consolidation / MCP tool / HTTP server すべてが backend に依存しない コードで書けます。

@dataclass
class GenerateRequest:
    prompt: str
    system: str | None = None
    max_tokens: int = 1024
    temperature: float = 0.2
    stop: list[str] = field(default_factory=list)
    model: str | None = None                  # backend-specific id
    images: list[bytes | Path | str] = field(default_factory=list)  # VLM (C-1.1)

@dataclass
class GenerateResponse:
    text: str
    finish_reason: str = "stop"
    backend: str = ""
    model: str = ""
    raw: dict = field(default_factory=dict)        # backend native payload (debug)

5.2 backend 解決の優先順位

def resolve_backend(name=None) -> LLMBackend:
    # 1. 引数 name
    # 2. $LLIVE_LLM_BACKEND
    # 3. $ANTHROPIC_API_KEY が在れば anthropic
    # 4. $OPENAI_API_KEY が在れば openai
    # 5. $OLLAMA_HOST が在れば ollama
    # 6. mock (no-network fallback)
設計のコツ fallback を 常に mockにする。これでテストやデモが ネットワーク未接続でも動き、CI でも追加設定不要で通る。

5.3 VLM 拡張 (C-1.1)

request.imagesbytes | Path | str を渡せるようにし、 各 backend がそれぞれの multimodal フォーマットに変換します。

backend送信形式
Ollamatop-level "images": [<base64>, ...]
Anthropicmessages content に [{"type":"image","source":{"type":"base64","media_type":"...","data":"..."}}]
OpenAIcontent に [{"type":"image_url","image_url":{"url":"data:image/...;base64,..."}}]
Mock枚数を text に追記、media_type と base64 長を raw に記録
def _normalise_image(img) -> tuple[str, str]:
    # Path → 拡張子から media_type 推定
    # bytes → magic bytes (PNG/JPEG/GIF/WEBP) 推定、不明は image/png
    # str → 既に base64、media_type=image/png 仮定
    return media_type, base64_str

6. Phase C-2 — MCP Server

6.1 transport 分離設計

MCP の実装で最も価値が高い設計判断は、tool 関数と MCP transport を分けること。

tools.py (純 Python)

def tool_query_rad(keywords, ...) -> list[dict]:
    # JSON 直列化可能な値だけ受け取り、
    # JSON 直列化可能な値だけ返す
    ...

def dispatch(name, args) -> Any:
    if name == "query_rad":
        return tool_query_rad(**args)
    ...

server.py (MCP 薄ラッパ)

async def _call_tool(name, arguments):
    try:
        result = dispatch(name, arguments)
    except Exception as e:
        return error_text(e)
    return [TextContent(
        type="text",
        text=json.dumps(result))]
この分け方の利点
  • tool ロジックは MCP runtime なしで unit test できる (12 cases)
  • 同じ tool ロジックを HTTP server からも呼べる (DRY)
  • MCP SDK のバージョン更新で transport が壊れても、ロジックは無傷
  • mcp パッケージは lazy import なので、未インストールでも他機能は動く

6.2 8 つの tool

tool用途
list_rad_domains全ドメイン一覧 + メタ
get_domain_info1 ドメイン詳細 + corpus2skill ヒント
query_radキーワード検索 (filename×3 + content score)
read_documentドキュメント本文取得 (truncate 付き)
append_learning_learned/ へ書き戻し
vlm_describe_image画像 + 任意の domain_hint で RAD grounding
code_completeコード補完 (temperature=0.0)
code_reviewsecurity_corpus_v2 ヒント注入セキュリティレビュー

6.3 smoke E2E (実 MCP client で round-trip)

async def _call_query_rad(rad_root):
    async with stdio_client(_server_params(rad_root)) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            response = await session.call_tool(
                "query_rad",
                {"keywords": "buffer", "limit": 5})
            return json.loads(response.content[0].text)

公式 mcp client から subprocess 起動 → initialize → list_tools / call_tool までを 本物の stdio プロトコルで実行。モックでなく、Claude Desktop と同じ経路で 動作確認できています。

7. Phase C-3 — OpenAI 互換 HTTP server

7.1 なぜ HTTP も必要か

Ollama / OpenWebUI / その他多くのローカル LLM フロントエンドは MCP client ではなく、OpenAI HTTP 互換クライアントです。 これらから llive を呼ぶには、/v1/chat/completions を喋るサーバが必要です。

よくある誤解 「llive は Ollama を 使える」と「llive を Ollama から 使える」は別の話。 前者は OllamaBackend (C-1) で済むが、後者は 逆方向の HTTP server (C-3) が必要。両方を成立させて初めて「Ollama スタックに溶け込む」。

7.2 エンドポイント

メソッドパス役割
GET/healthliveness probe
GET/v1/modelsllive-rad + llive-rad/<backend-model> を advertise
GET/v1/tools(非標準) MCP tool 一覧の JSON Schema
POST/v1/chat/completions標準 OpenAI 互換 + llive 拡張

実装は stdlib http.server.ThreadingHTTPServer + BaseHTTPRequestHandler。 依存ゼロ、起動は py -3.11 -m llive.server.openai_api だけ。

7.3 RAG-on-by-flag 拡張

POST /v1/chat/completions
{
  "model": "llive-rad/llama3.1",
  "messages": [{"role":"user","content":"Explain buffer overflow"}],
  "x_rad_domain": "security_corpus_v2",   # llive 拡張
  "x_rad_hint_limit": 5                      # llive 拡張
}

x_rad_domain が与えられると、サーバ側で最新の user メッセージで query_rad を実行し、上位 N excerpt を system block に prepend してから LLM backend に流します。レスポンスには x_llive_backend / x_llive_model / x_llive_rad_hints を含めてどの doc が使われたか開示します。

なぜ専用フィールドにしたか system ロールに自前で詰めて送る案もありますが、クライアント側で何も 変えずに RAG を on/off できることが重要。messages 構築ロジックを クライアントに押し付けると、Ollama 既製品 UI からは使えなくなります。 非標準フィールドだが、知らないクライアントは無視するだけで壊れないのが利点。

8. テスト戦略

ファイルcases役割
test_rad.py25loader / query / append / skills 単体
test_consolidation_rad_mirror.py4biology integration (実 DB)
test_mcp_tools.py12tool 関数 (transport なし)
test_mcp_server_smoke.py2実 mcp client subprocess E2E
test_mcp_vlm_coding_tools.py12VLM / coding tool + RAD grounding
test_llm_backend.py13backend abstraction + Ollama HTTP mock
test_llm_backend_vlm.py9multimodal (画像入力) 経路
test_openai_api_server.py9HTTP server ephemeral port で in-thread 起動
合計86 新規441 → 527 / 全 PASS / 21 秒
テスト戦略の指針
  • tmp_path フィクスチャで synthetic corpus を作る — 実 RAD への依存ゼロ、CI で再現性 100%
  • MockBackend を default fallback に — ネットワークなしでも全テスト通る
  • Ollama HTTP は urlopen を monkey-patch — 実サーバ無しで body を検証
  • MCP server だけは実 client で smoke — transport の仕様変更を捕まえるため
  • HTTP server は ephemeral port + ThreadingHTTPServer — 並列実行可能

9. 利用シナリオ別ガイド

シナリオ A: Claude Desktop / Cursor から RAD を使う

# 1. 取り込み (1 回だけ)
py -3.11 -m pip install -e .[mcp]
py -3.11 scripts/import_rad.py

# 2. MCP host の config に登録 (Claude Desktop の例)
{
  "mcpServers": {
    "llive": {
      "command": "py",
      "args": ["-3.11", "-m", "llive.mcp.server"],
      "env": {"LLIVE_RAD_DIR": "D:/projects/llive/data/rad"}
    }
  }
}

シナリオ B: Ollama / OpenWebUI から llive を呼ぶ

# 1. HTTP server を起動
py -3.11 -m llive.server.openai_api --host 127.0.0.1 --port 8765

# 2. クライアント側
base_url = "http://127.0.0.1:8765/v1"
api_key  = "any"                    # llive 側は認証なし
model    = "llive-rad/llama3.1"      # Ollama の llama3.1 を経由

シナリオ C: 内部コードから直接 query する

from llive.memory.rad import RadCorpusIndex
from llive.memory.rad.query import query
from llive.memory.provenance import Provenance
from llive.memory.rad.append import append_learning

idx = RadCorpusIndex()
hits = query(idx, "buffer overflow", domain="security_corpus_v2", limit=5)

for h in hits:
    print(h.domain, h.doc_path.name, h.score, h.excerpt[:80])

# 学習物を書き戻す
append_learning(
    idx,
    domain="security_concept",
    content="# Heap spray\n\nSummary...",
    provenance=Provenance(source_type="manual", source_id="note-001"),
)

シナリオ D: Consolidator に RAD を繋ぐ

cons = Consolidator(
    episodic=ep,
    structural=sm,
    rad_index=RadCorpusIndex(),         # これだけで biology → RAD パスが完成
)
result = cons.run_once(limit=200)
# ConceptPage が _learned/<page_type>/<concept_id>.md に自動ミラー

10. 拡張ポイント

項目状態方針
recall_memory tool未実装semantic memory (faiss) + MemoryEncoder を MCP tool として公開
code モデル prompt template未実装Qwen2.5-Coder / DeepSeek-Coder / Code Llama 用に特化テンプレ
tool_calls (OpenAI function calling)未実装HTTP server で tools 引数を受けて MCP dispatch に橋渡し
SemanticMemory + RAD のハイブリッド検索未実装filename/content 検索 + 埋め込みベクトル類似度のリランカ
VLM 出力の RAD 自動学習未実装vlm_describe_image の結果を _learned/vision/ に堆積
Rust 移植 (Phase 5+)未着手query.py のキーワード検索ホットパスを llive_rust_ext

11. 学習要点まとめ

  1. 一時操作は stdlib のみで書く。取り込みのようなセットアップ系コードに 依存を入れると、ユーザの最初の体験を悪くする。本体機能 (C 層) と切り離す。
  2. 読み API と書き API は単一のインデックスに統合する。 呼び出し側 (Consolidator) のシンプルさが、長期的に最大の価値を持つ。
  3. tool 実装と transport は分ける。MCP / HTTP / 直接呼び出しの 3 経路で 同じロジックを再利用可能になり、テストも transport-free にできる。
  4. fallback は常に「動く mock」を用意するresolve_backend() が 最終的に MockBackend に落ちるおかげで、ネットワーク無し・API キー無しでも 全テストが通り、CI が単純になる。
  5. provenance を必ず derived_from で生イベントに繋ぐ。Consolidator が Page を作るとき、その情報源 (event_ids) を持たせる。LLW-AC-01 source-anchored provenance は AI 由来コンテンツのループ汚染防御として効く。
  6. 非破壊な拡張フィールドは未知のクライアントが無視するだけで壊れない。 OpenAI HTTP server に x_rad_domain を足したが、知らない既製品 UI (Ollama 純正 chat 等) は単に渡さないだけで正しく動く。
  7. ミラー先への書き込み失敗は non-fatal にする。RAD への append_learning が失敗しても consolidation サイクル全体は完了させる。 result.errors に蓄積して可視化、再走で復旧可能にする。
  8. MCP の smoke test は実 SDK client で回す。stdio プロトコルや JSON-RPC のシリアライズはモックでは検証しきれない。バージョン更新で 壊れたら即気付ける防御線になる。
  9. SemVer はメインフェーズ用に占有しておく。横断エピックは 名前付きマイルストーン (RAD-A / RAD-B / ...) + build メタで識別。 リリース番号の衝突を防ぐ。
  10. Path traversal は resolve() + relative_to() で検証する。 文字列レベルでの防御は脆い。Python のパス API を信用する。